viddl-rb 0.95 → 0.96
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 +5 -5
- data/README.md +3 -1
- data/Rakefile +18 -8
- data/bin/helper/driver.rb +1 -1
- data/bin/helper/parameter-parser.rb +35 -16
- data/helper/plugin-helper.rb +0 -2
- data/helper/utility-helper.rb +22 -4
- data/plugins/youtube.rb +23 -288
- data/plugins/youtube/decipherer.rb +149 -0
- data/plugins/youtube/format_picker.rb +116 -0
- data/plugins/youtube/url_resolver.rb +77 -0
- data/plugins/youtube/video_resolver.rb +111 -0
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
SHA1:
|
3
|
-
metadata.gz: 6b2cb672ebfa65f7f7231fdd615a77287757a529
|
4
|
-
data.tar.gz: 03441807280152b0f821e648bec6289862aa7a60
|
5
2
|
SHA512:
|
6
|
-
|
7
|
-
|
3
|
+
data.tar.gz: ff67e34025fae7f2799db8f7fcde11485942e5a7971d8f915bff66ec9ed49d20a0d8013f14268765dade6adc24c6c80320306fef363b78c876076942071d11eb
|
4
|
+
metadata.gz: d77571b6da60ad4fdb6c235232c19c528ce7e3f03f290c580d89953ee4dc24d04d77fc02d9e5422007c4902a8263fa0c5fccf47e5329ca37c9970fa82ccbd462
|
5
|
+
SHA1:
|
6
|
+
data.tar.gz: fafe08cbf9074f38e92dcb632f397e45f86d2c00
|
7
|
+
metadata.gz: 2019cc2be56479dd8827a2979641b8b14bc6cd08
|
data/README.md
CHANGED
@@ -23,7 +23,9 @@ Viddl-rb supports the following command line options:
|
|
23
23
|
-f, --filter REGEX Filters a video playlist according to the regex (Youtube only right now)
|
24
24
|
-s, --save-dir DIRECTORY Specifies the directory where videos should be saved
|
25
25
|
-d, --downloader TOOL Specifies the tool to download with. Supports 'wget', 'curl' and 'net-http'
|
26
|
-
-q, --quality QUALITY Specifies the video format and resolution in the following way
|
26
|
+
-q, --quality QUALITY Specifies the video format and resolution in the following way: width:height:res (e.g. 1280:720:mp4)
|
27
|
+
The width, height and resolution may be omitted with a *.
|
28
|
+
For example, to match any quality with a width of 720 pixels in any format specify --quality *:720:*
|
27
29
|
-h, --help Displays the help screen
|
28
30
|
```
|
29
31
|
|
data/Rakefile
CHANGED
@@ -2,21 +2,31 @@ require 'rubygems'
|
|
2
2
|
require 'bundler/setup'
|
3
3
|
require 'rake/testtask'
|
4
4
|
|
5
|
-
|
5
|
+
ALL_INTEGRATION = FileList["spec/integration/*.rb"]
|
6
|
+
ALL_UNIT = FileList["spec/unit/*/*.rb"]
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
|
8
|
+
task :default => [:all]
|
9
|
+
|
10
|
+
Rake::TestTask.new(:all) do |t|
|
11
|
+
t.test_files = ALL_INTEGRATION + ALL_UNIT
|
12
|
+
end
|
13
|
+
|
14
|
+
Rake::TestTask.new(:test_unit) do |t|
|
15
|
+
t.test_files = ALL_UNIT
|
16
|
+
end
|
17
|
+
|
18
|
+
Rake::TestTask.new(:test_integration) do |t|
|
19
|
+
t.test_files = ALL_INTEGRATION
|
10
20
|
end
|
11
21
|
|
12
22
|
Rake::TestTask.new(:test_lib) do |t|
|
13
|
-
t.test_files = FileList["spec/lib_spec.rb"]
|
23
|
+
t.test_files = FileList["spec/integration/lib_spec.rb"]
|
14
24
|
end
|
15
25
|
|
16
26
|
Rake::TestTask.new(:test_extract) do |t|
|
17
|
-
t.test_files = FileList["spec/url_extraction_spec.rb"]
|
27
|
+
t.test_files = FileList["spec/integration/url_extraction_spec.rb"]
|
18
28
|
end
|
19
29
|
|
20
|
-
Rake::TestTask.new(:
|
21
|
-
t.test_files = FileList["spec/
|
30
|
+
Rake::TestTask.new(:test_download) do |t|
|
31
|
+
t.test_files = FileList["spec/integration/download_spec.rb"]
|
22
32
|
end
|
data/bin/helper/driver.rb
CHANGED
@@ -36,7 +36,7 @@ class Driver
|
|
36
36
|
plugin.get_urls_and_filenames(url, @params)
|
37
37
|
|
38
38
|
rescue ViddlRb::PluginBase::CouldNotDownloadVideoError => e
|
39
|
-
raise "
|
39
|
+
raise "CouldNotDownloadVideoError.\n" +
|
40
40
|
"Reason: #{e.message}"
|
41
41
|
rescue StandardError => e
|
42
42
|
raise "Error while running the #{plugin.name.inspect} plugin. Maybe it has to be updated?\n" +
|
@@ -34,6 +34,10 @@ class ParameterParser
|
|
34
34
|
optparse = OptionParser.new do |opts|
|
35
35
|
opts.banner = "Usage: viddl-rb URL [options]"
|
36
36
|
|
37
|
+
opts.on('-h', '--help', 'Display this screen') do
|
38
|
+
print_help_and_exit(opts)
|
39
|
+
end
|
40
|
+
|
37
41
|
opts.on("-e", "--extract-audio", "Save video audio to file") do
|
38
42
|
if ViddlRb::UtilityHelper.os_has?("ffmpeg")
|
39
43
|
options[:extract_audio] = true
|
@@ -75,21 +79,18 @@ class ParameterParser
|
|
75
79
|
end
|
76
80
|
|
77
81
|
opts.on("-q", "--quality QUALITY",
|
78
|
-
"Specifies the video format and resolution in the following way
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
opts.on_tail('-h', '--help', 'Display this screen') do
|
92
|
-
print_help_and_exit(opts)
|
82
|
+
"Specifies the video format and resolution in the following way: width:height:res (e.g. 1280:720:mp4). " +
|
83
|
+
"The width, height and resolution may be omitted with a *. For example, to match any quality with a " +
|
84
|
+
"width of 720 pixels in any format specify --quality *:720:*") do |quality|
|
85
|
+
|
86
|
+
tokens = quality.split(":")
|
87
|
+
raise OptionParser::InvalidArgument.new("#{quality} is not a valid argument.") unless tokens.size == 3
|
88
|
+
width, height, ext = tokens
|
89
|
+
validate_quality_options!(width, height, ext, quality)
|
90
|
+
|
91
|
+
options[:quality] = {width: width == "*" ? nil : width.to_i,
|
92
|
+
height: height == "*" ? nil : height.to_i,
|
93
|
+
extension: ext == "*" ? nil : ext}
|
93
94
|
end
|
94
95
|
end
|
95
96
|
|
@@ -109,7 +110,25 @@ class ParameterParser
|
|
109
110
|
def self.validate_url!(url)
|
110
111
|
unless url =~ /^http/
|
111
112
|
raise OptionParser::InvalidArgument.new(
|
112
|
-
|
113
|
+
"please include 'http' with your URL e.g. http://www.youtube.com/watch?v=QH2-TGUlwu4")
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def self.validate_quality_options!(width, height, extension, quality)
|
118
|
+
if !width =~ /(\*|\d+)/ || !height =~ /(\*|\d+)/ || !extension =~ /(\*|\w+)/
|
119
|
+
raise OptionParser::InvalidArgument.new("#{quality} is not a valid argument.")
|
113
120
|
end
|
114
121
|
end
|
115
122
|
end
|
123
|
+
|
124
|
+
|
125
|
+
|
126
|
+
|
127
|
+
|
128
|
+
|
129
|
+
|
130
|
+
|
131
|
+
|
132
|
+
|
133
|
+
|
134
|
+
|
data/helper/plugin-helper.rb
CHANGED
data/helper/utility-helper.rb
CHANGED
@@ -3,11 +3,29 @@
|
|
3
3
|
module ViddlRb
|
4
4
|
|
5
5
|
class UtilityHelper
|
6
|
-
|
7
|
-
#
|
6
|
+
|
7
|
+
# Loads all plugins in the plugin directory.
|
8
|
+
# The plugin classes are dynamically added to the ViddlRb module.
|
9
|
+
# A plugin can have helper classes. These classes must exist in a in directory under the
|
10
|
+
# plugins directory that has the same name as the plugin filename wihouth the .rb extension.
|
11
|
+
# All classes found in such a directory will dynamically added as inner classes of the
|
12
|
+
# plugin class.
|
8
13
|
def self.load_plugins
|
9
|
-
|
10
|
-
|
14
|
+
plugins_dir = File.join(File.dirname(__FILE__), "../plugins")
|
15
|
+
plugin_paths = Dir[File.join(plugins_dir, "*.rb")]
|
16
|
+
|
17
|
+
plugin_paths.each do |path|
|
18
|
+
filename = File.basename(path, File.extname(path))
|
19
|
+
plugin_code = File.read(path)
|
20
|
+
class_name = plugin_code[/class (\w+) < PluginBase/, 1]
|
21
|
+
components = Dir[File.join(plugins_dir, filename, "*.rb")]
|
22
|
+
|
23
|
+
ViddlRb.class_eval(plugin_code)
|
24
|
+
|
25
|
+
components.each do |component|
|
26
|
+
code = File.read(component)
|
27
|
+
ViddlRb.const_get(class_name).class_eval(code)
|
28
|
+
end
|
11
29
|
end
|
12
30
|
end
|
13
31
|
|
data/plugins/youtube.rb
CHANGED
@@ -1,231 +1,26 @@
|
|
1
|
-
# -*- coding: utf-8 -*-
|
2
1
|
|
3
2
|
class Youtube < PluginBase
|
4
3
|
|
5
|
-
# see http://en.wikipedia.org/wiki/YouTube#Quality_and_codecs
|
6
|
-
# TODO: we don't have all the formats from the wiki article here
|
7
|
-
VIDEO_FORMATS = {
|
8
|
-
"38" => {:extension => "mp4", :name => "MP4 Highest Quality 4096x3027 (H.264, AAC)"},
|
9
|
-
"37" => {:extension => "mp4", :name => "MP4 Highest Quality 1920x1080 (H.264, AAC)"},
|
10
|
-
"22" => {:extension => "mp4", :name => "MP4 1280x720 (H.264, AAC)"},
|
11
|
-
"46" => {:extension => "webm", :name => "WebM 1920x1080 (VP8, Vorbis)"},
|
12
|
-
"45" => {:extension => "webm", :name => "WebM 1280x720 (VP8, Vorbis)"},
|
13
|
-
"44" => {:extension => "webm", :name => "WebM 854x480 (VP8, Vorbis)"},
|
14
|
-
"43" => {:extension => "webm", :name => "WebM 480x360 (VP8, Vorbis)"},
|
15
|
-
"18" => {:extension => "mp4", :name => "MP4 640x360 (H.264, AAC)"},
|
16
|
-
"35" => {:extension => "flv", :name => "FLV 854x480 (H.264, AAC)"},
|
17
|
-
"34" => {:extension => "flv", :name => "FLV 640x360 (H.264, AAC)"},
|
18
|
-
"6" => {:extension => "flv", :name => "FLV 640x360 (Soerenson H.263)"},
|
19
|
-
"5" => {:extension => "flv", :name => "FLV 400x240 (Soerenson H.263)"},
|
20
|
-
"36" => {:extension => "3gp", :name => "3gp Medium Quality - 320x240 (MPEG-4 Visual, AAC)"},
|
21
|
-
"17" => {:extension => "3gp", :name => "3gp Medium Quality - 176x144 (MPEG-4 Visual, AAC)"},
|
22
|
-
"13" => {:extension => "3gp", :name => "3gp Low Quality - 176x144 (MPEG-4 Visual, AAC)"},
|
23
|
-
"82" => {:extension => "mp4", :name => "MP4 360p (H.264 AAC)"},
|
24
|
-
"83" => {:extension => "mp4", :name => "MP4 240p (H.264 AAC)"},
|
25
|
-
"84" => {:extension => "mp4", :name => "MP4 720p (H.264 AAC)"},
|
26
|
-
"85" => {:extension => "mp4", :name => "MP4 520p (H.264 AAC)"},
|
27
|
-
"100" => {:extension => "webm", :name => "WebM 360p (VP8 Vorbis)"},
|
28
|
-
"101" => {:extension => "webm", :name => "WebM 360p (VP8 Vorbis)"},
|
29
|
-
"102" => {:extension => "webm", :name => "WebM 720p (VP8 Vorbis)"},
|
30
|
-
"120" => {:extension => "flv", :name => "FLV 720p (H.264 AAC)"},
|
31
|
-
"133" => {:extension => "mp4", :name => "MP4 240p (H.264)"},
|
32
|
-
"134" => {:extension => "mp4", :name => "MP4 360p (H.264)"},
|
33
|
-
"135" => {:extension => "mp4", :name => "MP4 480p (H.264)"},
|
34
|
-
"136" => {:extension => "mp4", :name => "MP4 720p (H.264)"},
|
35
|
-
"137" => {:extension => "mp4", :name => "MP4 1080p (H.264)"},
|
36
|
-
"139" => {:extension => "mp4", :name => "MP4 (AAC)"},
|
37
|
-
"140" => {:extension => "mp4", :name => "MP4 (AAC"},
|
38
|
-
"141" => {:extension => "mp4", :name => "MP4 (AAC)"},
|
39
|
-
"160" => {:extension => "mp4", :name => "MP4 (H.264)"},
|
40
|
-
"171" => {:extension => "webm", :name => "WebM (Vorbis)"},
|
41
|
-
"172" => {:extension => "webm", :name => "WebM (Vorbis)"}
|
42
|
-
}
|
43
|
-
|
44
|
-
DEFAULT_FORMAT_ORDER = %w[38 37 22 46 45 44 43 18 35 34 6 5 36 17 13 82 83 84 85 100 101 102 120 133 134 135 136 137 139 140 141 160 171 172]
|
45
|
-
VIDEO_INFO_URL = "http://www.youtube.com/get_video_info?video_id="
|
46
|
-
VIDEO_INFO_PARMS = "&ps=default&eurl=&gl=US&hl=en"
|
47
|
-
|
48
4
|
# this will be called by the main app to check whether this plugin is responsible for the url passed
|
49
5
|
def self.matches_provider?(url)
|
50
6
|
url.include?("youtube.com") || url.include?("youtu.be")
|
51
7
|
end
|
52
8
|
|
53
9
|
def self.get_urls_and_filenames(url, options = {})
|
54
|
-
@quality = options[:quality]
|
55
|
-
filter = options[:playlist_filter]
|
56
|
-
parser = PlaylistParser.new
|
57
|
-
return_vals = []
|
58
10
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
return_vals << grab_single_url_filename(url, options)
|
63
|
-
end
|
64
|
-
|
65
|
-
clean_return_values(return_vals)
|
66
|
-
end
|
11
|
+
@url_resolver = UrlResolver.new
|
12
|
+
@video_resolver = VideoResolver.new(Decipherer.new)
|
13
|
+
@format_picker = FormatPicker.new(options)
|
67
14
|
|
68
|
-
|
69
|
-
|
15
|
+
urls = @url_resolver.get_all_urls(url, options[:filter])
|
16
|
+
videos = get_videos(urls)
|
70
17
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
cleaned
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
def self.grab_single_url_filename(url, options)
|
79
|
-
UrlGrabber.new(url, self, options).process
|
80
|
-
end
|
81
|
-
|
82
|
-
class UrlGrabber
|
83
|
-
attr_accessor :url, :options, :plugin, :quality
|
84
|
-
|
85
|
-
def initialize(url, plugin, options)
|
86
|
-
@url = url
|
87
|
-
@plugin = plugin
|
88
|
-
@options = options
|
89
|
-
@quality = options[:quality]
|
90
|
-
end
|
91
|
-
|
92
|
-
def process
|
93
|
-
grab_url_embeddable(url) || grab_url_non_embeddable(url)
|
94
|
-
end
|
95
|
-
|
96
|
-
# VEVO video: http://www.youtube.com/watch?v=A_J7kEhY9sM
|
97
|
-
# Non-VEVO video: http://www.youtube.com/watch?v=WkkC9cK8Hz0
|
98
|
-
|
99
|
-
def grab_url_embeddable(url)
|
100
|
-
video_info = get_video_info(url)
|
101
|
-
video_params = extract_video_parameters(video_info)
|
102
|
-
|
103
|
-
unless video_params[:embeddable]
|
104
|
-
Youtube.notify("VIDEO IS NOT EMBEDDABLE")
|
105
|
-
return false
|
106
|
-
end
|
107
|
-
|
108
|
-
urls_formats = extract_urls_formats(video_info)
|
109
|
-
selected_format = choose_format(urls_formats)
|
110
|
-
title = video_params[:title]
|
111
|
-
file_name = PluginBase.make_filename_safe(title) + "." + VIDEO_FORMATS[selected_format][:extension]
|
112
|
-
|
113
|
-
{:url => urls_formats[selected_format], :name => file_name}
|
114
|
-
end
|
115
|
-
|
116
|
-
def grab_url_non_embeddable(url)
|
117
|
-
video_info = open(url).read
|
118
|
-
stream_map = video_info[/url_encoded_fmt_stream_map\" *: *\"([^\"]+)\"/,1]
|
119
|
-
|
120
|
-
# Video has been deleted!
|
121
|
-
if stream_map.nil? or !stream_map.index("been+removed").nil?
|
122
|
-
Youtube.notify("VIDEO IS REMOVED")
|
123
|
-
return false
|
124
|
-
end
|
125
|
-
|
126
|
-
urls_formats = parse_stream_map(url_decode(stream_map))
|
127
|
-
selected_format = choose_format(urls_formats)
|
128
|
-
title = video_info[/<meta name="title" content="([^"]*)">/, 1]
|
129
|
-
file_name = PluginBase.make_filename_safe(title) + "." + VIDEO_FORMATS[selected_format][:extension]
|
130
|
-
|
131
|
-
# cleaning
|
132
|
-
clean_url = urls_formats[selected_format].gsub(/\\u0026[^&]*/, "").split(',type=video').first
|
133
|
-
{:url => clean_url, :name => file_name}
|
134
|
-
end
|
135
|
-
|
136
|
-
def get_video_info(url)
|
137
|
-
id = extract_video_id(url)
|
138
|
-
request_url = VIDEO_INFO_URL + id + VIDEO_INFO_PARMS
|
139
|
-
open(request_url).read
|
140
|
-
end
|
141
|
-
|
142
|
-
def extract_video_parameters(video_info)
|
143
|
-
video_params = CGI.parse(url_decode(video_info))
|
144
|
-
|
145
|
-
{
|
146
|
-
:title => video_params["title"].first,
|
147
|
-
:length_sec => video_params["length_seconds"].first,
|
148
|
-
:author => video_params["author"].first,
|
149
|
-
:embeddable => (video_params["status"].first != "fail")
|
150
|
-
}
|
151
|
-
end
|
152
|
-
|
153
|
-
def extract_video_id(url)
|
154
|
-
# the youtube video ID looks like this: [...]v=abc5a5_afe5agae6g&[...], we only want the ID (the \w in the brackets)
|
155
|
-
# addition: might also look like this /v/abc5-a5afe5agae6g
|
156
|
-
# alternative: video_id = url[/v[\/=]([\w-]*)&?/, 1]
|
157
|
-
url = open(url).base_uri.to_s if url.include?("youtu.be")
|
158
|
-
video_id = url[/(v|embed)[=\/]([^\/\?\&]*)/, 2]
|
159
|
-
|
160
|
-
if video_id
|
161
|
-
Youtube.notify("ID FOUND: #{video_id}")
|
162
|
-
video_id
|
163
|
-
else
|
164
|
-
Youtube.download_error("No video id found.")
|
165
|
-
end
|
166
|
-
end
|
167
|
-
|
168
|
-
def extract_urls_formats(video_info)
|
169
|
-
stream_map = video_info[/url_encoded_fmt_stream_map=(.+?)(?:&|$)/, 1]
|
170
|
-
parse_stream_map(stream_map)
|
171
|
-
end
|
172
|
-
|
173
|
-
def choose_format(urls_formats)
|
174
|
-
available_formats = urls_formats.keys
|
175
|
-
|
176
|
-
if @quality #if the user specified a format
|
177
|
-
ext = @quality[:extension]
|
178
|
-
res = @quality[:resolution] || ""
|
179
|
-
#gets a nested array with all the formats of the same res as the user wanted
|
180
|
-
requested = VIDEO_FORMATS.select { |id, format| available_formats.include?(id) && format[:name].include?(res) }.to_a
|
181
|
-
|
182
|
-
if requested.empty?
|
183
|
-
Youtube.notify "Requested format \"#{res}:#{ext}\" not found. Downloading default format."
|
184
|
-
get_default_format(available_formats)
|
185
|
-
else
|
186
|
-
pick = requested.find { |format| format[1][:extension] == ext } # get requsted extension if possible
|
187
|
-
pick ? pick.first : get_default_format(requested.map { |req| req.first }) # else return the default format
|
188
|
-
end
|
189
|
-
else
|
190
|
-
get_default_format(available_formats)
|
191
|
-
end
|
192
|
-
end
|
193
|
-
|
194
|
-
def parse_stream_map(stream_map)
|
195
|
-
urls = extract_download_urls(stream_map)
|
196
|
-
formats_urls = {}
|
197
|
-
|
198
|
-
urls.each do |url|
|
199
|
-
format = url[/itag=(\d+)/, 1]
|
200
|
-
formats_urls[format] = url
|
201
|
-
end
|
202
|
-
|
203
|
-
formats_urls
|
204
|
-
end
|
205
|
-
|
206
|
-
def extract_download_urls(stream_map)
|
207
|
-
entries = stream_map.split("%2C")
|
208
|
-
decoded = entries.map { |entry| url_decode(entry) }
|
209
|
-
|
210
|
-
decoded.map do |entry|
|
211
|
-
url = entry[/url=(.*?itag=.+?)(?:itag=|;|$)/, 1]
|
212
|
-
sig = entry[/sig=(.+?)(?:&|$)/, 1]
|
213
|
-
|
214
|
-
url + "&signature=#{sig}"
|
215
|
-
end
|
216
|
-
end
|
217
|
-
|
218
|
-
def get_default_format(available)
|
219
|
-
DEFAULT_FORMAT_ORDER.find { |default| available.include?(default) }
|
220
|
-
end
|
221
|
-
|
222
|
-
def url_decode(text)
|
223
|
-
while text != (decoded = CGI::unescape(text)) do
|
224
|
-
text = decoded
|
225
|
-
end
|
226
|
-
text
|
18
|
+
return_value = videos.map do |video|
|
19
|
+
format = @format_picker.pick_format(video)
|
20
|
+
make_url_filname_hash(video, format)
|
227
21
|
end
|
228
22
|
|
23
|
+
return_value.empty? ? download_error("No videos could be downloaded.") : return_value
|
229
24
|
end
|
230
25
|
|
231
26
|
def self.notify(message)
|
@@ -236,83 +31,23 @@ class Youtube < PluginBase
|
|
236
31
|
raise CouldNotDownloadVideoError, message
|
237
32
|
end
|
238
33
|
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
def get_playlist_urls(url, filter = nil)
|
249
|
-
@filter = filter
|
250
|
-
|
251
|
-
if url.include?("view_play_list") || url.include?("playlist?list=") # if playlist URL
|
252
|
-
parse_playlist(url)
|
253
|
-
elsif username = url[/\/(?:user|channel)\/([\w\d]+)(?:\/|$)/, 1] # if user/channel URL
|
254
|
-
parse_user(username)
|
255
|
-
else # if neither return nil
|
256
|
-
nil
|
257
|
-
end
|
258
|
-
end
|
259
|
-
|
260
|
-
def parse_playlist(url)
|
261
|
-
#http://www.youtube.com/view_play_list?p=F96B063007B44E1E&search_query=welt+auf+schwäbisch
|
262
|
-
#http://www.youtube.com/watch?v=9WEP5nCxkEY&videos=jKY836_WMhE&playnext_from=TL&playnext=1
|
263
|
-
#http://www.youtube.com/watch?v=Tk78sr5JMIU&videos=jKY836_WMhE
|
264
|
-
|
265
|
-
playlist_ID = url[/(?:list=PL|p=)(.+?)(?:&|\/|$)/, 1]
|
266
|
-
Youtube.notify "Playlist ID: #{playlist_ID}"
|
267
|
-
feed_url = PLAYLIST_FEED % playlist_ID
|
268
|
-
url_array = get_video_urls(feed_url)
|
269
|
-
Youtube.notify "#{url_array.size} links found!"
|
270
|
-
url_array
|
271
|
-
end
|
272
|
-
|
273
|
-
def parse_user(username)
|
274
|
-
Youtube.notify "User: #{username}"
|
275
|
-
feed_url = USER_FEED % username
|
276
|
-
url_array = get_video_urls(feed_url)
|
277
|
-
Youtube.notify "#{url_array.size} links found!"
|
278
|
-
url_array
|
279
|
-
end
|
280
|
-
|
281
|
-
#get all videos and return their urls in an array
|
282
|
-
def get_video_urls(feed_url)
|
283
|
-
Youtube.notify "Retrieving videos..."
|
284
|
-
urls_titles = {}
|
285
|
-
result_feed = Nokogiri::XML(open(feed_url))
|
286
|
-
urls_titles.merge!(grab_urls_and_titles(result_feed))
|
287
|
-
|
288
|
-
#as long as the feed has a next link we follow it and add the resulting video urls
|
289
|
-
loop do
|
290
|
-
next_link = result_feed.search("//feed/link[@rel='next']").first
|
291
|
-
break if next_link.nil?
|
292
|
-
result_feed = Nokogiri::HTML(open(next_link["href"]))
|
293
|
-
urls_titles.merge!(grab_urls_and_titles(result_feed))
|
34
|
+
def self.get_videos(urls)
|
35
|
+
videos = urls.map do |url|
|
36
|
+
begin
|
37
|
+
@video_resolver.get_video(url)
|
38
|
+
rescue VideoResolver::VideoRemovedError
|
39
|
+
notify "The video #{url} has been removed."
|
40
|
+
rescue => e
|
41
|
+
notify "Error getting the video: #{e.message}"
|
294
42
|
end
|
295
|
-
|
296
|
-
filter_urls(urls_titles)
|
297
43
|
end
|
298
44
|
|
299
|
-
|
300
|
-
|
301
|
-
feed.remove_namespaces! #so that we can get to the titles easily
|
302
|
-
urls = feed.search("//entry/link[@rel='alternate']").map { |link| link["href"] }
|
303
|
-
titles = feed.search("//entry/group/title").map { |title| title.text }
|
304
|
-
Hash[urls.zip(titles)] #hash like this: url => title
|
305
|
-
end
|
45
|
+
videos.reject(&:nil?)
|
46
|
+
end
|
306
47
|
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
filtered = url_hash.select { |url, title| title =~ @filter }
|
312
|
-
filtered.keys
|
313
|
-
else
|
314
|
-
url_hash.keys
|
315
|
-
end
|
316
|
-
end
|
48
|
+
def self.make_url_filname_hash(video, format)
|
49
|
+
url = video.get_download_url(format.itag)
|
50
|
+
name = PluginBase.make_filename_safe(video.title) + ".#{format.extension}"
|
51
|
+
{url: url, name: name}
|
317
52
|
end
|
318
53
|
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
|
2
|
+
class Decipherer
|
3
|
+
|
4
|
+
class UnknownCipherVersionError < StandardError; end
|
5
|
+
class UnknownCipherOperationError < StandardError; end
|
6
|
+
|
7
|
+
CIPHERS = {
|
8
|
+
'vflNzKG7n' => 's3 r s2 r s1 r w67', # 30 Jan 2013, untested
|
9
|
+
'vfllMCQWM' => 's2 w46 r w27 s2 w43 s2 r', # 15 Feb 2013, untested
|
10
|
+
'vflJv8FA8' => 's1 w51 w52 r', # 12 Mar 2013, untested
|
11
|
+
'vflR_cX32' => 's2 w64 s3', # 11 Apr 2013, untested
|
12
|
+
'vflveGye9' => 'w21 w3 s1 r w44 w36 r w41 s1', # 02 May 2013, untested
|
13
|
+
'vflj7Fxxt' => 'r s3 w3 r w17 r w41 r s2', # 14 May 2013, untested
|
14
|
+
'vfltM3odl' => 'w60 s1 w49 r s1 w7 r s2 r', # 23 May 2013
|
15
|
+
'vflDG7-a-' => 'w52 r s3 w21 r s3 r', # 06 Jun 2013
|
16
|
+
'vfl39KBj1' => 'w52 r s3 w21 r s3 r', # 12 Jun 2013
|
17
|
+
'vflmOfVEX' => 'w52 r s3 w21 r s3 r', # 21 Jun 2013
|
18
|
+
'vflJwJuHJ' => 'r s3 w19 r s2', # 25 Jun 2013
|
19
|
+
'vfl_ymO4Z' => 'r s3 w19 r s2', # 26 Jun 2013
|
20
|
+
'vfl26ng3K' => 'r s2 r', # 08 Jul 2013
|
21
|
+
'vflcaqGO8' => 'w24 w53 s2 w31 w4', # 11 Jul 2013
|
22
|
+
'vflQw-fB4' => 's2 r s3 w9 s3 w43 s3 r w23', # 16 Jul 2013
|
23
|
+
'vflSAFCP9' => 'r s2 w17 w61 r s1 w7 s1', # 18 Jul 2013
|
24
|
+
'vflART1Nf' => 's3 r w63 s2 r s1', # 22 Jul 2013
|
25
|
+
'vflLC8JvQ' => 'w34 w29 w9 r w39 w24', # 25 Jul 2013
|
26
|
+
'vflm_D8eE' => 's2 r w39 w55 w49 s3 w56 w2', # 30 Jul 2013
|
27
|
+
'vflTWC9KW' => 'r s2 w65 r', # 31 Jul 2013
|
28
|
+
'vflRFcHMl' => 's3 w24 r', # 04 Aug 2013
|
29
|
+
'vflM2EmfJ' => 'w10 r s1 w45 s2 r s3 w50 r', # 06 Aug 2013
|
30
|
+
'vflz8giW0' => 's2 w18 s3', # 07 Aug 2013
|
31
|
+
'vfl_wGgYV' => 'w60 s1 r s1 w9 s3 r s3 r', # 08 Aug 2013
|
32
|
+
'vfl1HXdPb' => 'w52 r w18 r s1 w44 w51 r s1', # 12 Aug 2013
|
33
|
+
'vflkn6DAl' => 'w39 s2 w57 s2 w23 w35 s2', # 15 Aug 2013
|
34
|
+
'vfl2LOvBh' => 'w34 w19 r s1 r s3 w24 r', # 16 Aug 2013
|
35
|
+
'vfl-bxy_m' => 'w48 s3 w37 s2', # 20 Aug 2013
|
36
|
+
'vflZK4ZYR' => 'w19 w68 s1', # 21 Aug 2013
|
37
|
+
'vflh9ybst' => 'w48 s3 w37 s2', # 21 Aug 2013
|
38
|
+
'vflapUV9V' => 's2 w53 r w59 r s2 w41 s3', # 27 Aug 2013
|
39
|
+
'vflg0g8PQ' => 'w36 s3 r s2', # 28 Aug 2013
|
40
|
+
'vflHOr_nV' => 'w58 r w50 s1 r s1 r w11 s3', # 30 Aug 2013
|
41
|
+
'vfluy6kdb' => 'r w12 w32 r w34 s3 w35 w42 s2', # 05 Sep 2013
|
42
|
+
'vflkuzxcs' => 'w22 w43 s3 r s1 w43', # 10 Sep 2013
|
43
|
+
'vflGNjMhJ' => 'w43 w2 w54 r w8 s1', # 12 Sep 2013
|
44
|
+
'vfldJ8xgI' => 'w11 r w29 s1 r s3', # 17 Sep 2013
|
45
|
+
'vfl79wBKW' => 's3 r s1 r s3 r s3 w59 s2', # 19 Sep 2013
|
46
|
+
'vflg3FZfr' => 'r s3 w66 w10 w43 s2', # 24 Sep 2013
|
47
|
+
'vflUKrNpT' => 'r s2 r w63 r', # 25 Sep 2013
|
48
|
+
'vfldWnjUz' => 'r s1 w68', # 30 Sep 2013
|
49
|
+
'vflP7iCEe' => 'w7 w37 r s1', # 03 Oct 2013
|
50
|
+
'vflzVne63' => 'w59 s2 r', # 07 Oct 2013
|
51
|
+
'vflO-N-9M' => 'w9 s1 w67 r s3', # 09 Oct 2013
|
52
|
+
'vflZ4JlpT' => 's3 r s1 r w28 s1', # 11 Oct 2013
|
53
|
+
'vflDgXSDS' => 's3 r s1 r w28 s1', # 15 Oct 2013
|
54
|
+
'vflW444Sr' => 'r w9 r s1 w51 w27 r s1 r', # 17 Oct 2013
|
55
|
+
'vflK7RoTQ' => 'w44 r w36 r w45', # 21 Oct 2013
|
56
|
+
'vflKOCFq2' => 's1 r w41 r w41 s1 w15', # 23 Oct 2013
|
57
|
+
'vflcLL31E' => 's1 r w41 r w41 s1 w15', # 28 Oct 2013
|
58
|
+
'vflz9bT3N' => 's1 r w41 r w41 s1 w15', # 31 Oct 2013
|
59
|
+
'vfliZsE79' => 'r s3 w49 s3 r w58 s2 r s2', # 05 Nov 2013
|
60
|
+
'vfljOFtAt' => 'r s3 r s1 r w69 r', # 07 Nov 2013
|
61
|
+
'vflqSl9GX' => 'w32 r s2 w65 w26 w45 w24 w40 s2', # 14 Nov 2013
|
62
|
+
'vflFrKymJ' => 'w32 r s2 w65 w26 w45 w24 w40 s2', # 15 Nov 2013
|
63
|
+
'vflKz4WoM' => 'w50 w17 r w7 w65', # 19 Nov 2013
|
64
|
+
'vflhdWW8S' => 's2 w55 w10 s3 w57 r w25 w41', # 21 Nov 2013
|
65
|
+
'vfl66X2C5' => 'r s2 w34 s2 w39', # 26 Nov 2013
|
66
|
+
'vflCXG8Sm' => 'r s2 w34 s2 w39', # 02 Dec 2013
|
67
|
+
'vfl_3Uag6' => 'w3 w7 r s2 w27 s2 w42 r', # 04 Dec 2013
|
68
|
+
'vflQdXVwM' => 's1 r w66 s2 r w12', # 10 Dec 2013
|
69
|
+
'vflCtc3aO' => 's2 r w11 r s3 w28', # 12 Dec 2013
|
70
|
+
'vflCt6YZX' => 's2 r w11 r s3 w28', # 17 Dec 2013
|
71
|
+
'vflG49soT' => 'w32 r s3 r s1 r w19 w24 s3', # 18 Dec 2013
|
72
|
+
'vfl4cHApe' => 'w25 s1 r s1 w27 w21 s1 w39', # 06 Jan 2014
|
73
|
+
'vflwMrwdI' => 'w3 r w39 r w51 s1 w36 w14', # 06 Jan 2014
|
74
|
+
'vfl4AMHqP' => 'r s1 w1 r w43 r s1 r', # 09 Jan 2014
|
75
|
+
'vfln8xPyM' => 'w36 w14 s1 r s1 w54', # 10 Jan 2014
|
76
|
+
'vflVSLmnY' => 's3 w56 w10 r s2 r w28 w35', # 13 Jan 2014
|
77
|
+
'vflkLvpg7' => 'w4 s3 w53 s2', # 15 Jan 2014
|
78
|
+
'vflbxes4n' => 'w4 s3 w53 s2', # 15 Jan 2014
|
79
|
+
'vflmXMtFI' => 'w57 s3 w62 w41 s3 r w60 r', # 23 Jan 2014
|
80
|
+
'vflYDqEW1' => 'w24 s1 r s2 w31 w4 w11 r', # 24 Jan 2014
|
81
|
+
'vflapGX6Q' => 's3 w2 w59 s2 w68 r s3 r s1', # 28 Jan 2014
|
82
|
+
'vflLCYwkM' => 's3 w2 w59 s2 w68 r s3 r s1', # 29 Jan 2014
|
83
|
+
'vflcY_8N0' => 's2 w36 s1 r w18 r w19 r', # 30 Jan 2014
|
84
|
+
'vfl9qWoOL' => 'w68 w64 w28 r', # 03 Feb 2014
|
85
|
+
'vfle-mVwz' => 's3 w7 r s3 r w14 w59 s3 r', # 04 Feb 2014
|
86
|
+
'vfltdb6U3' => 'w61 w5 r s2 w69 s2 r', # 05 Feb 2014
|
87
|
+
'vflLjFx3B' => 'w40 w62 r s2 w21 s3 r w7 s3', # 10 Feb 2014
|
88
|
+
'vfliqjKfF' => 'w40 w62 r s2 w21 s3 r w7 s3', # 13 Feb 2014
|
89
|
+
'ima-vflxBu-5R' => 'w40 w62 r s2 w21 s3 r w7 s3', # 13 Feb 2014
|
90
|
+
'ima-vflrGwWV9' => 'w36 w45 r s2 r' # 20 Feb 2014
|
91
|
+
}
|
92
|
+
|
93
|
+
def decipher_with_version(cipher, cipher_version)
|
94
|
+
operations = CIPHERS[cipher_version]
|
95
|
+
raise UnknownCipherVersionError.new("Unknown cipher version: #{cipher_version}") unless operations
|
96
|
+
|
97
|
+
decipher_with_operations(cipher, operations.split)
|
98
|
+
end
|
99
|
+
|
100
|
+
def decipher_with_operations(cipher, operations)
|
101
|
+
cipher = cipher.dup
|
102
|
+
|
103
|
+
operations.each do |op|
|
104
|
+
cipher = apply_operation(cipher, op)
|
105
|
+
end
|
106
|
+
cipher
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def apply_operation(cipher, op)
|
112
|
+
op = check_operation(op)
|
113
|
+
|
114
|
+
case op[0].downcase
|
115
|
+
when "r"
|
116
|
+
cipher.reverse
|
117
|
+
when "w"
|
118
|
+
index = get_op_index(op)
|
119
|
+
swap_first_char(cipher, index)
|
120
|
+
when "s"
|
121
|
+
index = get_op_index(op)
|
122
|
+
cipher[index, cipher.length - 1] # slice from index to the end
|
123
|
+
else
|
124
|
+
raise_unknown_op_error(op)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def check_operation(op)
|
129
|
+
raise_unknown_op_error(op) if op.nil? || !op.respond_to?(:to_s)
|
130
|
+
op.to_s
|
131
|
+
end
|
132
|
+
|
133
|
+
def swap_first_char(string, index)
|
134
|
+
temp = string[0]
|
135
|
+
string[0] = string[index]
|
136
|
+
string[index] = temp
|
137
|
+
string
|
138
|
+
end
|
139
|
+
|
140
|
+
def get_op_index(op)
|
141
|
+
index = op[/.(\d+)/, 1]
|
142
|
+
raise_unknown_op_error(op) unless index
|
143
|
+
index.to_i
|
144
|
+
end
|
145
|
+
|
146
|
+
def raise_unknown_op_error(op)
|
147
|
+
raise UnknownCipherOperationError.new("Unkown operation: #{op}")
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
|
2
|
+
class FormatPicker
|
3
|
+
|
4
|
+
Format = Struct.new(:itag, :extension, :resolution, :name)
|
5
|
+
Resolution = Struct.new(:width, :height)
|
6
|
+
|
7
|
+
# see http://en.wikipedia.org/wiki/YouTube#Quality_and_codecs
|
8
|
+
# TODO: we don't have all the formats from the wiki article here
|
9
|
+
# :u means the resolution is unknown.
|
10
|
+
FORMATS = [
|
11
|
+
Format.new("38", "mp4", Resolution.new(4096, 3027), "MP4 Highest Quality 4096x3027 (H.264, AAC)"),
|
12
|
+
Format.new("37", "mp4", Resolution.new(1920, 1080), "MP4 Highest Quality 1920x1080 (H.264, AAC)"),
|
13
|
+
Format.new("22", "mp4", Resolution.new(1280, 720), "MP4 1280x720 (H.264, AAC)"),
|
14
|
+
Format.new("46", "webm", Resolution.new(1920, 1080), "WebM 1920x1080 (VP8, Vorbis)"),
|
15
|
+
Format.new("45", "webm", Resolution.new(1280, 720), "WebM 1280x720 (VP8, Vorbis)"),
|
16
|
+
Format.new("44", "webm", Resolution.new(854, 480), "WebM 854x480 (VP8, Vorbis)"),
|
17
|
+
Format.new("43", "webm", Resolution.new(480, 360), "WebM 480x360 (VP8, Vorbis)"),
|
18
|
+
Format.new("18", "mp4", Resolution.new(640, 360), "MP4 640x360 (H.264, AAC)"),
|
19
|
+
Format.new("35", "flv", Resolution.new(854, 480), "FLV 854x480 (H.264, AAC)"),
|
20
|
+
Format.new("34", "flv", Resolution.new(640, 360), "FLV 640x360 (H.264, AAC)"),
|
21
|
+
Format.new("6", "flv", Resolution.new(640, 360), "FLV 640x360 (Soerenson H.263)"),
|
22
|
+
Format.new("5", "flv", Resolution.new(400, 240), "FLV 400x240 (Soerenson H.263)"),
|
23
|
+
Format.new("36", "3gp", Resolution.new(320, 240), "3gp Medium Quality - 320x240 (MPEG-4 Visual, AAC)"),
|
24
|
+
Format.new("17", "3gp", Resolution.new(174, 144), "3gp Medium Quality - 176x144 (MPEG-4 Visual, AAC)"),
|
25
|
+
Format.new("13", "3gp", Resolution.new(176, 144), "3gp Low Quality - 176x144 (MPEG-4 Visual, AAC)"),
|
26
|
+
Format.new("82", "mp4", Resolution.new(480, 360), "MP4 360p (H.264 AAC)"),
|
27
|
+
Format.new("83", "mp4", Resolution.new(320, 240), "MP4 240p (H.264 AAC)"),
|
28
|
+
Format.new("84", "mp4", Resolution.new(1280, 720), "MP4 720p (H.264 AAC)"),
|
29
|
+
Format.new("85", "mp4", Resolution.new(960, 520), "MP4 520p (H.264 AAC)"),
|
30
|
+
Format.new("100", "webm", Resolution.new(480, 360), "WebM 360p (VP8 Vorbis)"),
|
31
|
+
Format.new("101", "webm", Resolution.new(480, 360), "WebM 360p (VP8 Vorbis)"),
|
32
|
+
Format.new("102", "webm", Resolution.new(1280, 720), "WebM 720p (VP8 Vorbis)"),
|
33
|
+
Format.new("120", "flv", Resolution.new(1280, 720), "FLV 720p (H.264 AAC)"),
|
34
|
+
Format.new("133", "mp4", Resolution.new(320, 240), "MP4 240p (H.264)"),
|
35
|
+
Format.new("134", "mp4", Resolution.new(480, 360), "MP4 360p (H.264)"),
|
36
|
+
Format.new("135", "mp4", Resolution.new(640, 480), "MP4 480p (H.264)"),
|
37
|
+
Format.new("136", "mp4", Resolution.new(1280, 720), "MP4 720p (H.264)"),
|
38
|
+
Format.new("137", "mp4", Resolution.new(1920, 1080), "MP4 1080p (H.264)"),
|
39
|
+
Format.new("139", "mp4", Resolution.new(:u, :u), "MP4 (AAC)"),
|
40
|
+
Format.new("140", "mp4", Resolution.new(:u, :u), "MP4 (AAC"),
|
41
|
+
Format.new("141", "mp4", Resolution.new(:u, :u), "MP4 (AAC)"),
|
42
|
+
Format.new("160", "mp4", Resolution.new(:u, :u), "MP4 (H.264)"),
|
43
|
+
Format.new("171", "webm", Resolution.new(:u, :u), "WebM (Vorbis)"),
|
44
|
+
Format.new("172", "webm", Resolution.new(:u, :u), "WebM (Vorbis)")
|
45
|
+
]
|
46
|
+
|
47
|
+
DEFAULT_FORMAT_ORDER = %w[38 37 22 46 45 44 43 18 35 34 6 5 36 17 13 82 83 84 85 100 101 102 120 133 134 135 136 137 139 140 141 160 171 172]
|
48
|
+
|
49
|
+
def initialize(options)
|
50
|
+
@options = options
|
51
|
+
end
|
52
|
+
|
53
|
+
def pick_format(video)
|
54
|
+
if quality = @options[:quality]
|
55
|
+
get_quality_format(video, quality)
|
56
|
+
else
|
57
|
+
get_default_format_for_video(video)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def get_default_format_for_video(video)
|
64
|
+
available = get_available_formats_for_video(video)
|
65
|
+
get_default_format(available)
|
66
|
+
end
|
67
|
+
|
68
|
+
def get_available_formats_for_video(video)
|
69
|
+
video.available_itags.map { |itag| get_format_by_itag(itag) }
|
70
|
+
end
|
71
|
+
|
72
|
+
def get_format_by_itag(itag)
|
73
|
+
FORMATS.find { |format| format.itag == itag }
|
74
|
+
end
|
75
|
+
|
76
|
+
def get_default_format(formats)
|
77
|
+
DEFAULT_FORMAT_ORDER.each do |itag|
|
78
|
+
default_format = formats.find { |format| format.itag == itag }
|
79
|
+
return default_format if default_format
|
80
|
+
end
|
81
|
+
nil
|
82
|
+
end
|
83
|
+
|
84
|
+
def get_quality_format(video, quality)
|
85
|
+
available = get_available_formats_for_video(video)
|
86
|
+
|
87
|
+
matches = available.select do |format|
|
88
|
+
matches_extension?(format, quality) && matches_resolution?(format, quality)
|
89
|
+
end
|
90
|
+
|
91
|
+
select_format(video, matches)
|
92
|
+
end
|
93
|
+
|
94
|
+
def matches_extension?(format, quality)
|
95
|
+
return false if quality[:extension] && quality[:extension] != format.extension
|
96
|
+
true
|
97
|
+
end
|
98
|
+
|
99
|
+
def matches_resolution?(format, quality)
|
100
|
+
return false if quality[:width] && quality[:width] != format.resolution.width
|
101
|
+
return false if quality[:height] && quality[:height] != format.resolution.height
|
102
|
+
true
|
103
|
+
end
|
104
|
+
|
105
|
+
def select_format(video, formats)
|
106
|
+
case formats.length
|
107
|
+
when 0
|
108
|
+
Youtube.notify "Requested format not found. Downloading default format."
|
109
|
+
get_default_format_for_video(video)
|
110
|
+
when 1
|
111
|
+
formats.first
|
112
|
+
else
|
113
|
+
get_default_format(matches_resolution)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
class UrlResolver
|
2
|
+
|
3
|
+
PLAYLIST_FEED = "http://gdata.youtube.com/feeds/api/playlists/%s?&max-results=50&v=2"
|
4
|
+
USER_FEED = "http://gdata.youtube.com/feeds/api/users/%s/uploads?&max-results=50&v=2"
|
5
|
+
|
6
|
+
def get_all_urls(url, filter = nil)
|
7
|
+
@filter = filter
|
8
|
+
|
9
|
+
if url.include?("view_play_list") || url.include?("playlist?list=") # if playlist URL
|
10
|
+
parse_playlist(url)
|
11
|
+
elsif username = url[/\/(?:user|channel)\/([\w\d]+)(?:\/|$)/, 1] # if user/channel URL
|
12
|
+
parse_user(username)
|
13
|
+
else # if neither return nil
|
14
|
+
[url]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def parse_playlist(url)
|
21
|
+
#http://www.youtube.com/view_play_list?p=F96B063007B44E1E&search_query=welt+auf+schwäbisch
|
22
|
+
#http://www.youtube.com/watch?v=9WEP5nCxkEY&videos=jKY836_WMhE&playnext_from=TL&playnext=1
|
23
|
+
#http://www.youtube.com/watch?v=Tk78sr5JMIU&videos=jKY836_WMhE
|
24
|
+
|
25
|
+
playlist_ID = url[/(?:list=PL|p=)(.+?)(?:&|\/|$)/, 1]
|
26
|
+
Youtube.notify "Playlist ID: #{playlist_ID}"
|
27
|
+
feed_url = PLAYLIST_FEED % playlist_ID
|
28
|
+
url_array = get_video_urls(feed_url)
|
29
|
+
Youtube.notify "#{url_array.size} links found!"
|
30
|
+
url_array
|
31
|
+
end
|
32
|
+
|
33
|
+
def parse_user(username)
|
34
|
+
Youtube.notify "User: #{username}"
|
35
|
+
feed_url = USER_FEED % username
|
36
|
+
url_array = get_video_urls(feed_url)
|
37
|
+
Youtube.notify "#{url_array.size} links found!"
|
38
|
+
url_array
|
39
|
+
end
|
40
|
+
|
41
|
+
#get all videos and return their urls in an array
|
42
|
+
def get_video_urls(feed_url)
|
43
|
+
Youtube.notify "Retrieving videos..."
|
44
|
+
urls_titles = {}
|
45
|
+
result_feed = Nokogiri::XML(open(feed_url))
|
46
|
+
urls_titles.merge!(grab_urls_and_titles(result_feed))
|
47
|
+
|
48
|
+
#as long as the feed has a next link we follow it and add the resulting video urls
|
49
|
+
loop do
|
50
|
+
next_link = result_feed.search("//feed/link[@rel='next']").first
|
51
|
+
break if next_link.nil?
|
52
|
+
result_feed = Nokogiri::HTML(open(next_link["href"]))
|
53
|
+
urls_titles.merge!(grab_urls_and_titles(result_feed))
|
54
|
+
end
|
55
|
+
|
56
|
+
filter_urls(urls_titles)
|
57
|
+
end
|
58
|
+
|
59
|
+
#extract all video urls and their titles from a feed and return in a hash
|
60
|
+
def grab_urls_and_titles(feed)
|
61
|
+
feed.remove_namespaces! #so that we can get to the titles easily
|
62
|
+
urls = feed.search("//entry/link[@rel='alternate']").map { |link| link["href"] }
|
63
|
+
titles = feed.search("//entry/group/title").map { |title| title.text }
|
64
|
+
Hash[urls.zip(titles)] #hash like this: url => title
|
65
|
+
end
|
66
|
+
|
67
|
+
#returns only the urls that match the --filter argument regex (if present)
|
68
|
+
def filter_urls(url_hash)
|
69
|
+
if @filter
|
70
|
+
Youtube.notify "Using filter: #{@filter}"
|
71
|
+
filtered = url_hash.select { |url, title| title =~ @filter }
|
72
|
+
filtered.keys
|
73
|
+
else
|
74
|
+
url_hash.keys
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
|
2
|
+
class VideoResolver
|
3
|
+
|
4
|
+
class VideoRemovedError < StandardError; end
|
5
|
+
|
6
|
+
CORRECT_SIGNATURE_LENGTH = 81
|
7
|
+
SIGNATURE_URL_PARAMETER = "signature"
|
8
|
+
|
9
|
+
def initialize(decipherer)
|
10
|
+
@decipherer = decipherer
|
11
|
+
end
|
12
|
+
|
13
|
+
def get_video(url)
|
14
|
+
@json = load_json(url)
|
15
|
+
Video.new(get_title, parse_stream_map(get_stream_map))
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def load_json(url)
|
21
|
+
html = open(url).read
|
22
|
+
json_data = html[/ytplayer\.config\s*=\s*(\{.+?\});/m, 1]
|
23
|
+
MultiJson.load(json_data)
|
24
|
+
end
|
25
|
+
|
26
|
+
def get_stream_map
|
27
|
+
stream_map = @json["args"]["url_encoded_fmt_stream_map"]
|
28
|
+
raise VideoRemovedError.new if stream_map.nil? || stream_map.include?("been+removed")
|
29
|
+
stream_map
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_html5player_version
|
33
|
+
@json["assets"]["js"][/html5player-(.+?)\.js/, 1]
|
34
|
+
end
|
35
|
+
|
36
|
+
def get_title
|
37
|
+
@json["args"]["title"]
|
38
|
+
end
|
39
|
+
|
40
|
+
#
|
41
|
+
# Returns a an array of hashes in the following format:
|
42
|
+
# [
|
43
|
+
# {format: format_id, url: download_url},
|
44
|
+
# {format: format_id, url: download_url}
|
45
|
+
# ...
|
46
|
+
# ]
|
47
|
+
#
|
48
|
+
def parse_stream_map(stream_map)
|
49
|
+
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
|
54
|
+
end
|
55
|
+
|
56
|
+
def parse_stream_map_entry(entry)
|
57
|
+
# Note: CGI.parse puts each value in an array.
|
58
|
+
params = CGI.parse((entry))
|
59
|
+
|
60
|
+
{
|
61
|
+
itag: params["itag"].first,
|
62
|
+
sig: fetch_signature(params),
|
63
|
+
url: url_decode(params["url"].first)
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
# 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
|
69
|
+
# applied and the the video can be downloaded directly.
|
70
|
+
def fetch_signature(params)
|
71
|
+
sig = params.fetch("sig", nil) || params.fetch("s", nil)
|
72
|
+
sig && sig.first
|
73
|
+
end
|
74
|
+
|
75
|
+
def url_decode(text)
|
76
|
+
while text != (decoded = CGI::unescape(text)) do
|
77
|
+
text = decoded
|
78
|
+
end
|
79
|
+
text
|
80
|
+
end
|
81
|
+
|
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
|
87
|
+
|
88
|
+
def get_deciphered_sig(sig)
|
89
|
+
return sig if sig.length == CORRECT_SIGNATURE_LENGTH
|
90
|
+
#crequire 'pry'; binding.pry; exit
|
91
|
+
@decipherer.decipher_with_version(sig, get_html5player_version)
|
92
|
+
end
|
93
|
+
|
94
|
+
class Video
|
95
|
+
attr_reader :title
|
96
|
+
|
97
|
+
def initialize(title, itags_urls)
|
98
|
+
@title = title
|
99
|
+
@itags_urls = itags_urls
|
100
|
+
end
|
101
|
+
|
102
|
+
def available_itags
|
103
|
+
@itags_urls.map { |iu| iu[:itag] }
|
104
|
+
end
|
105
|
+
|
106
|
+
def get_download_url(itag)
|
107
|
+
itag_url = @itags_urls.find { |iu| iu[:itag] == itag }
|
108
|
+
itag_url[:url] if itag_url
|
109
|
+
end
|
110
|
+
end
|
111
|
+
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.
|
4
|
+
version: "0.96"
|
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-02-
|
12
|
+
date: 2014-02-25 00:00:00 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: mime-types
|
@@ -95,6 +95,10 @@ files:
|
|
95
95
|
- plugins/soundcloud.rb
|
96
96
|
- plugins/veoh.rb
|
97
97
|
- plugins/vimeo.rb
|
98
|
+
- plugins/youtube/decipherer.rb
|
99
|
+
- plugins/youtube/format_picker.rb
|
100
|
+
- plugins/youtube/url_resolver.rb
|
101
|
+
- plugins/youtube/video_resolver.rb
|
98
102
|
- plugins/youtube.rb
|
99
103
|
- Gemfile
|
100
104
|
- Gemfile.lock
|
@@ -123,7 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
123
127
|
requirements: []
|
124
128
|
|
125
129
|
rubyforge_project: viddl-rb
|
126
|
-
rubygems_version: 2.
|
130
|
+
rubygems_version: 2.1.11
|
127
131
|
signing_key:
|
128
132
|
specification_version: 4
|
129
133
|
summary: An extendable commandline video downloader for flash video sites.
|