pius-youtube-g 0.5.1
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.
- data/History.txt +52 -0
- data/Manifest.txt +31 -0
- data/README.txt +97 -0
- data/Rakefile +24 -0
- data/TODO.txt +16 -0
- data/lib/youtube_g.rb +67 -0
- data/lib/youtube_g/chain_io.rb +71 -0
- data/lib/youtube_g/client.rb +85 -0
- data/lib/youtube_g/model/author.rb +11 -0
- data/lib/youtube_g/model/category.rb +11 -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 +190 -0
- data/lib/youtube_g/parser.rb +169 -0
- data/lib/youtube_g/record.rb +12 -0
- data/lib/youtube_g/request/base_search.rb +26 -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/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 +263 -0
- data/test/test_video.rb +38 -0
- data/test/test_video_search.rb +134 -0
- metadata +113 -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,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
|
+
@url << build_query_params(to_youtube_params)
|
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 != 200
|
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::XML.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", :type => "plain") { @opts[:title] }
|
193
|
+
mg.tag!("media:description", :type => "plain") { @opts[:description] }
|
194
|
+
mg.tag!("media:keywords") { @opts[:keywords].join(",") }
|
195
|
+
mg.tag!('media:category', :scheme => "http://gdata.youtube.com/schemas/2007/categories.cat") { @opts[:category] }
|
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
|
@@ -0,0 +1,41 @@
|
|
1
|
+
class YouTubeG
|
2
|
+
module Response
|
3
|
+
class VideoSearch < YouTubeG::Record
|
4
|
+
# *String*:: Unique feed identifying url.
|
5
|
+
attr_reader :feed_id
|
6
|
+
|
7
|
+
# *Fixnum*:: Number of results per page.
|
8
|
+
attr_reader :max_result_count
|
9
|
+
|
10
|
+
# *Fixnum*:: 1-based offset index into the full result set.
|
11
|
+
attr_reader :offset
|
12
|
+
|
13
|
+
# *Fixnum*:: Total number of results available for the original request.
|
14
|
+
attr_reader :total_result_count
|
15
|
+
|
16
|
+
# *Time*:: Date and time at which the feed was last updated
|
17
|
+
attr_reader :updated_at
|
18
|
+
|
19
|
+
# *Array*:: Array of YouTubeG::Model::Video records
|
20
|
+
attr_reader :videos
|
21
|
+
|
22
|
+
def current_page
|
23
|
+
((offset - 1) / max_result_count) + 1
|
24
|
+
end
|
25
|
+
|
26
|
+
# current_page + 1 or nil if there is no next page
|
27
|
+
def next_page
|
28
|
+
current_page < total_pages ? (current_page + 1) : nil
|
29
|
+
end
|
30
|
+
|
31
|
+
# current_page - 1 or nil if there is no previous page
|
32
|
+
def previous_page
|
33
|
+
current_page > 1 ? (current_page - 1) : nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def total_pages
|
37
|
+
(total_result_count / max_result_count.to_f).ceil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|