agiley-youtube-g 0.6.2
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +52 -0
- data/Manifest.txt +34 -0
- data/README.txt +102 -0
- data/Rakefile +24 -0
- data/TODO.txt +16 -0
- data/lib/youtube_g.rb +70 -0
- data/lib/youtube_g/chain_io.rb +71 -0
- data/lib/youtube_g/client.rb +100 -0
- data/lib/youtube_g/model/author.rb +11 -0
- data/lib/youtube_g/model/category.rb +11 -0
- data/lib/youtube_g/model/comment.rb +20 -0
- data/lib/youtube_g/model/contact.rb +16 -0
- data/lib/youtube_g/model/content.rb +18 -0
- data/lib/youtube_g/model/playlist.rb +8 -0
- data/lib/youtube_g/model/rating.rb +17 -0
- data/lib/youtube_g/model/thumbnail.rb +17 -0
- data/lib/youtube_g/model/user.rb +20 -0
- data/lib/youtube_g/model/video.rb +204 -0
- data/lib/youtube_g/parser.rb +252 -0
- data/lib/youtube_g/record.rb +12 -0
- data/lib/youtube_g/request/base_search.rb +26 -0
- data/lib/youtube_g/request/comments_search.rb +41 -0
- data/lib/youtube_g/request/standard_search.rb +40 -0
- data/lib/youtube_g/request/user_search.rb +43 -0
- data/lib/youtube_g/request/video_search.rb +93 -0
- data/lib/youtube_g/request/video_upload.rb +218 -0
- data/lib/youtube_g/response/comments_search.rb +41 -0
- data/lib/youtube_g/response/video_search.rb +41 -0
- data/lib/youtube_g/version.rb +3 -0
- data/test/helper.rb +6 -0
- data/test/test_chain_io.rb +63 -0
- data/test/test_client.rb +265 -0
- data/test/test_video.rb +38 -0
- data/test/test_video_search.rb +134 -0
- metadata +140 -0
@@ -0,0 +1,26 @@
|
|
1
|
+
class YouTubeG
|
2
|
+
module Request #:nodoc:
|
3
|
+
class BaseSearch #:nodoc:
|
4
|
+
attr_reader :url
|
5
|
+
|
6
|
+
private
|
7
|
+
|
8
|
+
def base_url
|
9
|
+
"http://gdata.youtube.com/feeds/api/"
|
10
|
+
end
|
11
|
+
|
12
|
+
def set_instance_variables( variables )
|
13
|
+
variables.each do |key, value|
|
14
|
+
name = key.to_s
|
15
|
+
instance_variable_set("@#{name}", value) if respond_to?(name)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def build_query_params(params)
|
20
|
+
qs = params.to_a.map { | k, v | v.nil? ? nil : "#{YouTubeG.esc(k)}=#{YouTubeG.esc(v)}" }.compact.sort.join('&')
|
21
|
+
qs.empty? ? '' : "?#{qs}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
class YouTubeG
|
2
|
+
module Request #:nodoc:
|
3
|
+
class CommentsSearch < BaseSearch #:nodoc:
|
4
|
+
attr_reader :max_results # max_results
|
5
|
+
attr_reader :offset # start-index
|
6
|
+
|
7
|
+
def initialize(video_id, options={})
|
8
|
+
@max_results, @order_by, @offset = nil
|
9
|
+
@url = base_url
|
10
|
+
|
11
|
+
@url << "videos/#{video_id}/comments"
|
12
|
+
set_instance_variables(options)
|
13
|
+
|
14
|
+
if options.has_key?(:per_page)
|
15
|
+
@max_results = options[:per_page]
|
16
|
+
end
|
17
|
+
|
18
|
+
if options.has_key?(:page)
|
19
|
+
per_page = @max_results || 25
|
20
|
+
@offset = calculate_offset(options[:page], per_page)
|
21
|
+
end
|
22
|
+
|
23
|
+
@url << build_query_params(to_youtube_params)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def to_youtube_params
|
29
|
+
{
|
30
|
+
'max-results' => @max_results,
|
31
|
+
'start-index' => @offset
|
32
|
+
}
|
33
|
+
end
|
34
|
+
private
|
35
|
+
def calculate_offset(page, per_page)
|
36
|
+
page == 1 ? 1 : ((per_page * page) - per_page + 1)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
class YouTubeG
|
2
|
+
module Request #:nodoc:
|
3
|
+
class StandardSearch < BaseSearch #:nodoc:
|
4
|
+
attr_reader :max_results # max_results
|
5
|
+
attr_reader :order_by # orderby, ([relevance], viewCount, published, rating)
|
6
|
+
attr_reader :offset # start-index
|
7
|
+
attr_reader :time # time
|
8
|
+
|
9
|
+
TYPES = [ :top_rated, :top_favorites, :most_viewed, :most_popular,
|
10
|
+
:most_recent, :most_discussed, :most_linked, :most_responded,
|
11
|
+
:recently_featured, :watch_on_mobile ]
|
12
|
+
|
13
|
+
def initialize(type, options={})
|
14
|
+
if TYPES.include?(type)
|
15
|
+
@max_results, @order_by, @offset, @time = nil
|
16
|
+
set_instance_variables(options)
|
17
|
+
@url = base_url + type.to_s << build_query_params(to_youtube_params)
|
18
|
+
else
|
19
|
+
raise "Invalid type, must be one of: #{ TYPES.map { |t| t.to_s }.join(", ") }"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def base_url
|
26
|
+
super << "standardfeeds/"
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_youtube_params
|
30
|
+
{
|
31
|
+
'max-results' => @max_results,
|
32
|
+
'orderby' => @order_by,
|
33
|
+
'start-index' => @offset,
|
34
|
+
'time' => @time
|
35
|
+
}
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
class YouTubeG
|
2
|
+
module Request #:nodoc:
|
3
|
+
class UserSearch < BaseSearch #:nodoc:
|
4
|
+
attr_reader :max_results # max_results
|
5
|
+
attr_reader :order_by # orderby, ([relevance], viewCount, published, rating)
|
6
|
+
attr_reader :offset # start-index
|
7
|
+
|
8
|
+
def initialize(params, options={})
|
9
|
+
@max_results, @order_by, @offset = nil
|
10
|
+
@url = base_url
|
11
|
+
|
12
|
+
if params == :favorites
|
13
|
+
@url << "#{options[:user]}/favorites"
|
14
|
+
set_instance_variables(options)
|
15
|
+
elsif params[:user] && options[:favorites]
|
16
|
+
@url << "#{params[:user]}/favorites"
|
17
|
+
set_instance_variables(params)
|
18
|
+
return
|
19
|
+
elsif params[:user]
|
20
|
+
@url << "#{params[:user]}/uploads"
|
21
|
+
set_instance_variables(params)
|
22
|
+
end
|
23
|
+
|
24
|
+
@url << build_query_params(to_youtube_params)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def base_url
|
30
|
+
super << "users/"
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_youtube_params
|
34
|
+
{
|
35
|
+
'max-results' => @max_results,
|
36
|
+
'orderby' => @order_by,
|
37
|
+
'start-index' => @offset
|
38
|
+
}
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
class YouTubeG
|
2
|
+
module Request #:nodoc:
|
3
|
+
class VideoSearch < BaseSearch #:nodoc:
|
4
|
+
# From here: http://code.google.com/apis/youtube/reference.html#yt_format
|
5
|
+
ONLY_EMBEDDABLE = 5
|
6
|
+
|
7
|
+
attr_reader :max_results # max_results
|
8
|
+
attr_reader :order_by # orderby, ([relevance], viewCount, published, rating)
|
9
|
+
attr_reader :offset # start-index
|
10
|
+
attr_reader :query # vq
|
11
|
+
attr_reader :response_format # alt, ([atom], rss, json)
|
12
|
+
attr_reader :tags # /-/tag1/tag2
|
13
|
+
attr_reader :categories # /-/Category1/Category2
|
14
|
+
attr_reader :video_format # format (1=mobile devices)
|
15
|
+
attr_reader :racy # racy ([exclude], include)
|
16
|
+
attr_reader :author
|
17
|
+
|
18
|
+
def initialize(params={})
|
19
|
+
# Initialize our various member data to avoid warnings and so we'll
|
20
|
+
# automatically fall back to the youtube api defaults
|
21
|
+
@max_results, @order_by,
|
22
|
+
@offset, @query,
|
23
|
+
@response_format, @video_format,
|
24
|
+
@racy, @author = nil
|
25
|
+
@url = base_url
|
26
|
+
|
27
|
+
# Return a single video (base_url + /T7YazwP8GtY)
|
28
|
+
return @url << "/" << params[:video_id] if params[:video_id]
|
29
|
+
|
30
|
+
@url << "/-/" if (params[:categories] || params[:tags])
|
31
|
+
@url << categories_to_params(params.delete(:categories)) if params[:categories]
|
32
|
+
@url << tags_to_params(params.delete(:tags)) if params[:tags]
|
33
|
+
|
34
|
+
set_instance_variables(params)
|
35
|
+
|
36
|
+
if( params[ :only_embeddable ] )
|
37
|
+
@video_format = ONLY_EMBEDDABLE
|
38
|
+
end
|
39
|
+
|
40
|
+
@url << build_query_params(to_youtube_params)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def base_url
|
46
|
+
super << "videos"
|
47
|
+
end
|
48
|
+
|
49
|
+
def to_youtube_params
|
50
|
+
{
|
51
|
+
'max-results' => @max_results,
|
52
|
+
'orderby' => @order_by,
|
53
|
+
'start-index' => @offset,
|
54
|
+
'vq' => @query,
|
55
|
+
'alt' => @response_format,
|
56
|
+
'format' => @video_format,
|
57
|
+
'racy' => @racy,
|
58
|
+
'author' => @author
|
59
|
+
}
|
60
|
+
end
|
61
|
+
|
62
|
+
# Convert category symbols into strings and build the URL. GData requires categories to be capitalized.
|
63
|
+
# Categories defined like: categories => { :include => [:news], :exclude => [:sports], :either => [..] }
|
64
|
+
# or like: categories => [:news, :sports]
|
65
|
+
def categories_to_params(categories)
|
66
|
+
if categories.respond_to?(:keys) and categories.respond_to?(:[])
|
67
|
+
s = ""
|
68
|
+
s << categories[:either].map { |c| c.to_s.capitalize }.join("%7C") << '/' if categories[:either]
|
69
|
+
s << categories[:include].map { |c| c.to_s.capitalize }.join("/") << '/' if categories[:include]
|
70
|
+
s << ("-" << categories[:exclude].map { |c| c.to_s.capitalize }.join("/-")) << '/' if categories[:exclude]
|
71
|
+
s
|
72
|
+
else
|
73
|
+
categories.map { |c| c.to_s.capitalize }.join("/") << '/'
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Tags defined like: tags => { :include => [:football], :exclude => [:soccer], :either => [:polo, :tennis] }
|
78
|
+
# or tags => [:football, :soccer]
|
79
|
+
def tags_to_params(tags)
|
80
|
+
if tags.respond_to?(:keys) and tags.respond_to?(:[])
|
81
|
+
s = ""
|
82
|
+
s << tags[:either].map { |t| YouTubeG.esc(t.to_s) }.join("%7C") << '/' if tags[:either]
|
83
|
+
s << tags[:include].map { |t| YouTubeG.esc(t.to_s) }.join("/") << '/' if tags[:include]
|
84
|
+
s << ("-" << tags[:exclude].map { |t| YouTubeG.esc(t.to_s) }.join("/-")) << '/' if tags[:exclude]
|
85
|
+
s
|
86
|
+
else
|
87
|
+
tags.map { |t| YouTubeG.esc(t.to_s) }.join("/") << '/'
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,218 @@
|
|
1
|
+
class YouTubeG
|
2
|
+
|
3
|
+
module Upload
|
4
|
+
class UploadError < YouTubeG::Error; end
|
5
|
+
class AuthenticationError < YouTubeG::Error; end
|
6
|
+
|
7
|
+
# Implements video uploads/updates/deletions
|
8
|
+
#
|
9
|
+
# require 'youtube_g'
|
10
|
+
#
|
11
|
+
# uploader = YouTubeG::Upload::VideoUpload.new("user", "pass", "dev-key")
|
12
|
+
# uploader.upload File.open("test.m4v"), :title => 'test',
|
13
|
+
# :description => 'cool vid d00d',
|
14
|
+
# :category => 'People',
|
15
|
+
# :keywords => %w[cool blah test]
|
16
|
+
#
|
17
|
+
class VideoUpload
|
18
|
+
|
19
|
+
def initialize user, pass, dev_key, client_id = 'youtube_g'
|
20
|
+
@user, @pass, @dev_key, @client_id = user, pass, dev_key, client_id
|
21
|
+
end
|
22
|
+
|
23
|
+
#
|
24
|
+
# Upload "data" to youtube, where data is either an IO object or
|
25
|
+
# raw file data.
|
26
|
+
# The hash keys for opts (which specify video info) are as follows:
|
27
|
+
# :mime_type
|
28
|
+
# :filename
|
29
|
+
# :title
|
30
|
+
# :description
|
31
|
+
# :category
|
32
|
+
# :keywords
|
33
|
+
# :private
|
34
|
+
# Specifying :private will make the video private, otherwise it will be public.
|
35
|
+
#
|
36
|
+
# When one of the fields is invalid according to YouTube,
|
37
|
+
# an UploadError will be raised. Its message contains a list of newline separated
|
38
|
+
# errors, containing the key and its error code.
|
39
|
+
#
|
40
|
+
# When the authentication credentials are incorrect, an AuthenticationError will be raised.
|
41
|
+
def upload data, opts = {}
|
42
|
+
@opts = { :mime_type => 'video/mp4',
|
43
|
+
:title => '',
|
44
|
+
:description => '',
|
45
|
+
:category => '',
|
46
|
+
:keywords => [] }.merge(opts)
|
47
|
+
|
48
|
+
@opts[:filename] ||= generate_uniq_filename_from(data)
|
49
|
+
|
50
|
+
post_body_io = generate_upload_io(video_xml, data)
|
51
|
+
|
52
|
+
upload_headers = authorization_headers.merge({
|
53
|
+
"Slug" => "#{@opts[:filename]}",
|
54
|
+
"Content-Type" => "multipart/related; boundary=#{boundary}",
|
55
|
+
"Content-Length" => "#{post_body_io.expected_length}", # required per YouTube spec
|
56
|
+
# "Transfer-Encoding" => "chunked" # We will stream instead of posting at once
|
57
|
+
})
|
58
|
+
|
59
|
+
path = '/feeds/api/users/%s/uploads' % @user
|
60
|
+
|
61
|
+
Net::HTTP.start(uploads_url) do | session |
|
62
|
+
|
63
|
+
# Use the chained IO as body so that Net::HTTP reads into the socket for us
|
64
|
+
post = Net::HTTP::Post.new(path, upload_headers)
|
65
|
+
post.body_stream = post_body_io
|
66
|
+
|
67
|
+
response = session.request(post)
|
68
|
+
raise_on_faulty_response(response)
|
69
|
+
|
70
|
+
return uploaded_video_id_from(response.body)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Updates a video in YouTube. Requires:
|
75
|
+
# :title
|
76
|
+
# :description
|
77
|
+
# :category
|
78
|
+
# :keywords
|
79
|
+
# The following are optional attributes:
|
80
|
+
# :private
|
81
|
+
# When the authentication credentials are incorrect, an AuthenticationError will be raised.
|
82
|
+
def update(video_id, options)
|
83
|
+
@opts = options
|
84
|
+
|
85
|
+
update_body = video_xml
|
86
|
+
|
87
|
+
update_header = authorization_headers.merge({
|
88
|
+
"Content-Type" => "application/atom+xml",
|
89
|
+
"Content-Length" => "#{update_body.length}",
|
90
|
+
})
|
91
|
+
|
92
|
+
update_url = "/feeds/api/users/#{@user}/uploads/#{video_id}"
|
93
|
+
|
94
|
+
Net::HTTP.start(base_url) do | session |
|
95
|
+
response = session.put(update_url, update_body, update_header)
|
96
|
+
raise_on_faulty_response(response)
|
97
|
+
|
98
|
+
return YouTubeG::Parser::VideoFeedParser.new(response.body).parse
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Delete a video on YouTube
|
103
|
+
def delete(video_id)
|
104
|
+
delete_header = authorization_headers.merge({
|
105
|
+
"Content-Type" => "application/atom+xml",
|
106
|
+
"Content-Length" => "0",
|
107
|
+
})
|
108
|
+
|
109
|
+
delete_url = "/feeds/api/users/#{@user}/uploads/#{video_id}"
|
110
|
+
|
111
|
+
Net::HTTP.start(base_url) do |session|
|
112
|
+
response = session.delete(delete_url, delete_header)
|
113
|
+
raise_on_faulty_response(response)
|
114
|
+
return true
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def uploads_url
|
121
|
+
["uploads", base_url].join('.')
|
122
|
+
end
|
123
|
+
|
124
|
+
def base_url
|
125
|
+
"gdata.youtube.com"
|
126
|
+
end
|
127
|
+
|
128
|
+
def boundary
|
129
|
+
"An43094fu"
|
130
|
+
end
|
131
|
+
|
132
|
+
def authorization_headers
|
133
|
+
{
|
134
|
+
"Authorization" => "GoogleLogin auth=#{auth_token}",
|
135
|
+
"X-GData-Client" => "#{@client_id}",
|
136
|
+
"X-GData-Key" => "key=#{@dev_key}"
|
137
|
+
}
|
138
|
+
end
|
139
|
+
|
140
|
+
def parse_upload_error_from(string)
|
141
|
+
REXML::Document.new(string).elements["//errors"].inject('') do | all_faults, error|
|
142
|
+
location = error.elements["location"].text[/media:group\/media:(.*)\/text\(\)/,1]
|
143
|
+
code = error.elements["code"].text
|
144
|
+
all_faults + sprintf("%s: %s\n", location, code)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def raise_on_faulty_response(response)
|
149
|
+
if response.code.to_i == 403
|
150
|
+
raise AuthenticationError, response.body[/<TITLE>(.+)<\/TITLE>/, 1]
|
151
|
+
elsif response.code.to_i / 10 != 20 # Response in 20x means success
|
152
|
+
raise UploadError, parse_upload_error_from(response.body)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def uploaded_video_id_from(string)
|
157
|
+
xml = REXML::Document.new(string)
|
158
|
+
xml.elements["//id"].text[/videos\/(.+)/, 1]
|
159
|
+
end
|
160
|
+
|
161
|
+
# If data can be read, use the first 1024 bytes as filename. If data
|
162
|
+
# is a file, use path. If data is a string, checksum it
|
163
|
+
def generate_uniq_filename_from(data)
|
164
|
+
if data.respond_to?(:path)
|
165
|
+
Digest::MD5.hexdigest(data.path)
|
166
|
+
elsif data.respond_to?(:read)
|
167
|
+
chunk = data.read(1024)
|
168
|
+
data.rewind
|
169
|
+
Digest::MD5.hexdigest(chunk)
|
170
|
+
else
|
171
|
+
Digest::MD5.hexdigest(data)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def auth_token
|
176
|
+
@auth_token ||= begin
|
177
|
+
http = Net::HTTP.new("www.google.com", 443)
|
178
|
+
http.use_ssl = true
|
179
|
+
body = "Email=#{YouTubeG.esc @user}&Passwd=#{YouTubeG.esc @pass}&service=youtube&source=#{YouTubeG.esc @client_id}"
|
180
|
+
response = http.post("/youtube/accounts/ClientLogin", body, "Content-Type" => "application/x-www-form-urlencoded")
|
181
|
+
raise UploadError, response.body[/Error=(.+)/,1] if response.code.to_i != 200
|
182
|
+
@auth_token = response.body[/Auth=(.+)/, 1]
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
# TODO: isn't there a cleaner way to output top-notch XML without requiring stuff all over the place?
|
187
|
+
def video_xml
|
188
|
+
b = Builder::XmlMarkup.new
|
189
|
+
b.instruct!
|
190
|
+
b.entry(:xmlns => "http://www.w3.org/2005/Atom", 'xmlns:media' => "http://search.yahoo.com/mrss/", 'xmlns:yt' => "http://gdata.youtube.com/schemas/2007") do | m |
|
191
|
+
m.tag!("media:group") do | mg |
|
192
|
+
mg.tag!("media:title", @opts[:title], :type => "plain")
|
193
|
+
mg.tag!("media:description", @opts[:description], :type => "plain")
|
194
|
+
mg.tag!("media:keywords", @opts[:keywords].join(","))
|
195
|
+
mg.tag!('media:category', @opts[:category], :scheme => "http://gdata.youtube.com/schemas/2007/categories.cat")
|
196
|
+
mg.tag!('yt:private') if @opts[:private]
|
197
|
+
end
|
198
|
+
end.to_s
|
199
|
+
end
|
200
|
+
|
201
|
+
def generate_upload_io(video_xml, data)
|
202
|
+
post_body = [
|
203
|
+
"--#{boundary}\r\n",
|
204
|
+
"Content-Type: application/atom+xml; charset=UTF-8\r\n\r\n",
|
205
|
+
video_xml,
|
206
|
+
"\r\n--#{boundary}\r\n",
|
207
|
+
"Content-Type: #{@opts[:mime_type]}\r\nContent-Transfer-Encoding: binary\r\n\r\n",
|
208
|
+
data,
|
209
|
+
"\r\n--#{boundary}--\r\n",
|
210
|
+
]
|
211
|
+
|
212
|
+
# Use Greedy IO to not be limited by 1K chunks
|
213
|
+
YouTubeG::GreedyChainIO.new(post_body)
|
214
|
+
end
|
215
|
+
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|