tmm1-youtube-g 0.4.5
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 +30 -0
- data/Manifest.txt +25 -0
- data/README.txt +81 -0
- data/Rakefile +20 -0
- data/TODO.txt +18 -0
- data/lib/youtube_g.rb +19 -0
- data/lib/youtube_g/client.rb +36 -0
- data/lib/youtube_g/logger.rb +27 -0
- data/lib/youtube_g/model/author.rb +8 -0
- data/lib/youtube_g/model/category.rb +8 -0
- data/lib/youtube_g/model/contact.rb +8 -0
- data/lib/youtube_g/model/content.rb +13 -0
- data/lib/youtube_g/model/playlist.rb +7 -0
- data/lib/youtube_g/model/rating.rb +10 -0
- data/lib/youtube_g/model/thumbnail.rb +10 -0
- data/lib/youtube_g/model/user.rb +20 -0
- data/lib/youtube_g/model/video.rb +106 -0
- data/lib/youtube_g/parser.rb +165 -0
- data/lib/youtube_g/record.rb +12 -0
- data/lib/youtube_g/request/video_search.rb +168 -0
- data/lib/youtube_g/request/video_upload.rb +112 -0
- data/lib/youtube_g/response/video_search.rb +23 -0
- data/test/test_client.rb +226 -0
- data/test/test_video.rb +30 -0
- data/test/test_video_search.rb +118 -0
- metadata +85 -0
@@ -0,0 +1,165 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
require 'open-uri'
|
3
|
+
require 'rexml/document'
|
4
|
+
|
5
|
+
class YouTubeG
|
6
|
+
module Parser
|
7
|
+
class FeedParser
|
8
|
+
def initialize(url)
|
9
|
+
@url = url
|
10
|
+
end
|
11
|
+
|
12
|
+
def parse
|
13
|
+
parse_content open(@url).read
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class VideoFeedParser < FeedParser
|
18
|
+
|
19
|
+
def parse_content(content)
|
20
|
+
doc = REXML::Document.new(content)
|
21
|
+
entry = doc.elements["entry"]
|
22
|
+
|
23
|
+
parse_entry(entry)
|
24
|
+
end
|
25
|
+
|
26
|
+
protected
|
27
|
+
def parse_entry(entry)
|
28
|
+
video_id = entry.elements["id"].text
|
29
|
+
published_at = Time.parse(entry.elements["published"].text)
|
30
|
+
updated_at = Time.parse(entry.elements["updated"].text)
|
31
|
+
|
32
|
+
# parse the category and keyword lists
|
33
|
+
categories = []
|
34
|
+
keywords = []
|
35
|
+
entry.elements.each("category") do |category|
|
36
|
+
# determine if it's really a category, or just a keyword
|
37
|
+
scheme = category.attributes["scheme"]
|
38
|
+
if (scheme =~ /\/categories\.cat$/)
|
39
|
+
# it's a category
|
40
|
+
categories << YouTubeG::Model::Category.new(
|
41
|
+
:term => category.attributes["term"],
|
42
|
+
:label => category.attributes["label"])
|
43
|
+
|
44
|
+
elsif (scheme =~ /\/keywords\.cat$/)
|
45
|
+
# it's a keyword
|
46
|
+
keywords << category.attributes["term"]
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
title = entry.elements["title"].text
|
51
|
+
html_content = entry.elements["content"].text
|
52
|
+
|
53
|
+
# parse the author
|
54
|
+
author_element = entry.elements["author"]
|
55
|
+
author = nil
|
56
|
+
if author_element
|
57
|
+
author = YouTubeG::Model::Author.new(
|
58
|
+
:name => author_element.elements["name"].text,
|
59
|
+
:uri => author_element.elements["uri"].text)
|
60
|
+
end
|
61
|
+
|
62
|
+
media_group = entry.elements["media:group"]
|
63
|
+
description = media_group.elements["media:description"].text
|
64
|
+
duration = media_group.elements["yt:duration"].attributes["seconds"].to_i
|
65
|
+
|
66
|
+
media_content = []
|
67
|
+
media_group.elements.each("media:content") do |mce|
|
68
|
+
media_content << parse_media_content(mce)
|
69
|
+
end
|
70
|
+
|
71
|
+
player_url = media_group.elements["media:player"].attributes["url"]
|
72
|
+
|
73
|
+
# parse thumbnails
|
74
|
+
thumbnails = []
|
75
|
+
media_group.elements.each("media:thumbnail") do |thumb_element|
|
76
|
+
# TODO: convert time HH:MM:ss string to seconds?
|
77
|
+
thumbnails << YouTubeG::Model::Thumbnail.new(
|
78
|
+
:url => thumb_element.attributes["url"],
|
79
|
+
:height => thumb_element.attributes["height"].to_i,
|
80
|
+
:width => thumb_element.attributes["width"].to_i,
|
81
|
+
:time => thumb_element.attributes["time"])
|
82
|
+
end
|
83
|
+
|
84
|
+
rating_element = entry.elements["gd:rating"]
|
85
|
+
rating = nil
|
86
|
+
if rating_element
|
87
|
+
rating = YouTubeG::Model::Rating.new(
|
88
|
+
:min => rating_element.attributes["min"].to_i,
|
89
|
+
:max => rating_element.attributes["max"].to_i,
|
90
|
+
:rater_count => rating_element.attributes["numRaters"].to_i,
|
91
|
+
:average => rating_element.attributes["average"].to_f)
|
92
|
+
end
|
93
|
+
|
94
|
+
view_count = (el = entry.elements["yt:statistics"]) ? el.attributes["viewCount"].to_i : 0
|
95
|
+
|
96
|
+
noembed = entry.elements["yt:noembed"] ? true : false
|
97
|
+
racy = entry.elements["yt:racy"] ? true : false
|
98
|
+
|
99
|
+
YouTubeG::Model::Video.new(
|
100
|
+
:video_id => video_id,
|
101
|
+
:published_at => published_at,
|
102
|
+
:updated_at => updated_at,
|
103
|
+
:categories => categories,
|
104
|
+
:keywords => keywords,
|
105
|
+
:title => title,
|
106
|
+
:html_content => html_content,
|
107
|
+
:author => author,
|
108
|
+
:description => description,
|
109
|
+
:duration => duration,
|
110
|
+
:media_content => media_content,
|
111
|
+
:player_url => player_url,
|
112
|
+
:thumbnails => thumbnails,
|
113
|
+
:rating => rating,
|
114
|
+
:view_count => view_count,
|
115
|
+
:noembed => noembed,
|
116
|
+
:racy => racy)
|
117
|
+
end
|
118
|
+
|
119
|
+
def parse_media_content (media_content_element)
|
120
|
+
content_url = media_content_element.attributes["url"]
|
121
|
+
format_code = media_content_element.attributes["yt:format"].to_i
|
122
|
+
format = YouTubeG::Model::Video::Format.by_code(format_code)
|
123
|
+
duration = media_content_element.attributes["duration"].to_i
|
124
|
+
mime_type = media_content_element.attributes["type"]
|
125
|
+
default = (media_content_element.attributes["isDefault"] == "true")
|
126
|
+
|
127
|
+
YouTubeG::Model::Content.new(
|
128
|
+
:url => content_url,
|
129
|
+
:format => format,
|
130
|
+
:duration => duration,
|
131
|
+
:mime_type => mime_type,
|
132
|
+
:default => default)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
class VideosFeedParser < VideoFeedParser
|
137
|
+
|
138
|
+
private
|
139
|
+
def parse_content(content)
|
140
|
+
doc = REXML::Document.new(content)
|
141
|
+
feed = doc.elements["feed"]
|
142
|
+
|
143
|
+
feed_id = feed.elements["id"].text
|
144
|
+
updated_at = Time.parse(feed.elements["updated"].text)
|
145
|
+
total_result_count = feed.elements["openSearch:totalResults"].text.to_i
|
146
|
+
offset = feed.elements["openSearch:startIndex"].text.to_i
|
147
|
+
max_result_count = feed.elements["openSearch:itemsPerPage"].text.to_i
|
148
|
+
|
149
|
+
videos = []
|
150
|
+
feed.elements.each("entry") do |entry|
|
151
|
+
videos << parse_entry(entry)
|
152
|
+
end
|
153
|
+
|
154
|
+
YouTubeG::Response::VideoSearch.new(
|
155
|
+
:feed_id => feed_id,
|
156
|
+
:updated_at => updated_at,
|
157
|
+
:total_result_count => total_result_count,
|
158
|
+
:offset => offset,
|
159
|
+
:max_result_count => max_result_count,
|
160
|
+
:videos => videos)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
end
|
165
|
+
end
|
@@ -0,0 +1,168 @@
|
|
1
|
+
class YouTubeG
|
2
|
+
|
3
|
+
# The goal of the classes in this module is to build the request URLs for each type of search
|
4
|
+
module Request
|
5
|
+
|
6
|
+
class BaseSearch
|
7
|
+
attr_reader :url
|
8
|
+
|
9
|
+
def base_url
|
10
|
+
"http://gdata.youtube.com/feeds/api/"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class UserSearch < BaseSearch
|
15
|
+
|
16
|
+
def initialize(params, options={})
|
17
|
+
@url = base_url
|
18
|
+
return @url << "#{options[:user]}/favorites" if params == :favorites
|
19
|
+
@url << "#{params[:user]}/uploads" if params[:user]
|
20
|
+
end
|
21
|
+
|
22
|
+
def base_url
|
23
|
+
super << "users/"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class StandardSearch < BaseSearch
|
28
|
+
TYPES = [ :most_viewed, :top_rated, :recently_featured, :watch_on_mobile ]
|
29
|
+
TIMES = [ :all_time, :today, :this_week, :this_month ]
|
30
|
+
|
31
|
+
def initialize(type, options={})
|
32
|
+
if TYPES.include?(type)
|
33
|
+
@url = base_url << type.to_s
|
34
|
+
@url << "?time=#{CGI.escape(options.delete(:time).to_s)}" if TIMES.include?(options[:time])
|
35
|
+
else
|
36
|
+
raise "Invalid type, must be one of: #{ TYPES.map { |t| t.to_s }.join(", ") }"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def base_url
|
41
|
+
super << "standardfeeds/"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class VideoSearch < BaseSearch
|
46
|
+
# From here: http://code.google.com/apis/youtube/reference.html#yt_format
|
47
|
+
ONLY_EMBEDDABLE = 5
|
48
|
+
|
49
|
+
attr_reader :max_results # max_results
|
50
|
+
attr_reader :order_by # orderby, ([relevance], viewCount, published, rating)
|
51
|
+
attr_reader :offset # start-index
|
52
|
+
attr_reader :query # vq
|
53
|
+
attr_reader :response_format # alt, ([atom], rss, json)
|
54
|
+
attr_reader :tags # /-/tag1/tag2
|
55
|
+
attr_reader :categories # /-/Category1/Category2
|
56
|
+
attr_reader :video_format # format (1=mobile devices)
|
57
|
+
attr_reader :racy # racy ([exclude], include)
|
58
|
+
attr_reader :author
|
59
|
+
|
60
|
+
def initialize(params={})
|
61
|
+
# XXX I think we want to delete the line below
|
62
|
+
return if params.nil?
|
63
|
+
|
64
|
+
# initialize our various member data to avoid warnings and so we'll
|
65
|
+
# automatically fall back to the youtube api defaults
|
66
|
+
@max_results = nil
|
67
|
+
@order_by = nil
|
68
|
+
@offset = nil
|
69
|
+
@query = nil
|
70
|
+
@response_format = nil
|
71
|
+
@video_format = nil
|
72
|
+
@racy = nil
|
73
|
+
@author = nil
|
74
|
+
|
75
|
+
# build up the url corresponding to this request
|
76
|
+
@url = base_url
|
77
|
+
|
78
|
+
# http://gdata.youtube.com/feeds/videos/T7YazwP8GtY
|
79
|
+
return @url << "/" << params[:video_id] if params[:video_id]
|
80
|
+
|
81
|
+
@url << "/-/" if (params[:categories] || params[:tags])
|
82
|
+
@url << categories_to_params(params.delete(:categories)) if params[:categories]
|
83
|
+
@url << tags_to_params(params.delete(:tags)) if params[:tags]
|
84
|
+
|
85
|
+
params.each do |key, value|
|
86
|
+
name = key.to_s
|
87
|
+
instance_variable_set("@#{name}", value) if respond_to?(name)
|
88
|
+
end
|
89
|
+
|
90
|
+
if( params[ :only_embeddable ] )
|
91
|
+
@video_format = ONLY_EMBEDDABLE
|
92
|
+
end
|
93
|
+
|
94
|
+
@url << build_query_params(to_youtube_params)
|
95
|
+
end
|
96
|
+
|
97
|
+
def base_url
|
98
|
+
super << "videos"
|
99
|
+
end
|
100
|
+
|
101
|
+
def to_youtube_params
|
102
|
+
{
|
103
|
+
'max-results' => @max_results,
|
104
|
+
'orderby' => @order_by,
|
105
|
+
'start-index' => @offset,
|
106
|
+
'vq' => @query,
|
107
|
+
'alt' => @response_format,
|
108
|
+
'format' => @video_format,
|
109
|
+
'racy' => @racy,
|
110
|
+
'author' => @author
|
111
|
+
}
|
112
|
+
end
|
113
|
+
|
114
|
+
private
|
115
|
+
# Convert category symbols into strings and build the URL. GData requires categories to be capitalized.
|
116
|
+
# Categories defined like: categories => { :include => [:news], :exclude => [:sports], :either => [..] }
|
117
|
+
# or like: categories => [:news, :sports]
|
118
|
+
def categories_to_params(categories)
|
119
|
+
if categories.respond_to?(:keys) and categories.respond_to?(:[])
|
120
|
+
s = ""
|
121
|
+
s << categories[:either].map { |c| c.to_s.capitalize }.join("%7C") << '/' if categories[:either]
|
122
|
+
s << categories[:include].map { |c| c.to_s.capitalize }.join("/") << '/' if categories[:include]
|
123
|
+
s << ("-" << categories[:exclude].map { |c| c.to_s.capitalize }.join("/-")) << '/' if categories[:exclude]
|
124
|
+
s
|
125
|
+
else
|
126
|
+
categories.map { |c| c.to_s.capitalize }.join("/") << '/'
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Tags defined like: tags => { :include => [:football], :exclude => [:soccer], :either => [:polo, :tennis] }
|
131
|
+
# or tags => [:football, :soccer]
|
132
|
+
def tags_to_params(tags)
|
133
|
+
if tags.respond_to?(:keys) and tags.respond_to?(:[])
|
134
|
+
s = ""
|
135
|
+
s << tags[:either].map { |t| CGI.escape(t.to_s) }.join("%7C") << '/' if tags[:either]
|
136
|
+
s << tags[:include].map { |t| CGI.escape(t.to_s) }.join("/") << '/' if tags[:include]
|
137
|
+
s << ("-" << tags[:exclude].map { |t| CGI.escape(t.to_s) }.join("/-")) << '/' if tags[:exclude]
|
138
|
+
s
|
139
|
+
else
|
140
|
+
tags.map { |t| CGI.escape(t.to_s) }.join("/") << '/'
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def build_query_params(params)
|
145
|
+
# nothing to do if there are no params
|
146
|
+
return '' if (!params || params.empty?)
|
147
|
+
|
148
|
+
# build up the query param string, tacking on every key/value
|
149
|
+
# pair for which the value is non-nil
|
150
|
+
u = '?'
|
151
|
+
item_count = 0
|
152
|
+
params.keys.each do |key|
|
153
|
+
value = params[key]
|
154
|
+
next if value.nil?
|
155
|
+
|
156
|
+
u << '&' if (item_count > 0)
|
157
|
+
u << "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
|
158
|
+
item_count += 1
|
159
|
+
end
|
160
|
+
|
161
|
+
# if we found no non-nil values, we've got no params so just
|
162
|
+
# return an empty string
|
163
|
+
(item_count == 0) ? '' : u
|
164
|
+
end
|
165
|
+
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'net/https'
|
2
|
+
require 'digest/md5'
|
3
|
+
require 'rexml/document'
|
4
|
+
require 'cgi'
|
5
|
+
|
6
|
+
class YouTubeG
|
7
|
+
|
8
|
+
module Upload
|
9
|
+
class UploadError < Exception; end
|
10
|
+
|
11
|
+
# require 'youtube_g'
|
12
|
+
#
|
13
|
+
# uploader = YouTubeG::Upload::VideoUpload.new("user", "pass", "dev-key")
|
14
|
+
# uploader.upload File.open("test.m4v"), :title => 'test',
|
15
|
+
# :description => 'cool vid d00d',
|
16
|
+
# :category => 'People',
|
17
|
+
# :keywords => %w[cool blah test]
|
18
|
+
|
19
|
+
class VideoUpload
|
20
|
+
|
21
|
+
def initialize user, pass, dev_key, client_id = 'youtube_g'
|
22
|
+
@user, @pass, @dev_key, @client_id = user, pass, dev_key, client_id
|
23
|
+
end
|
24
|
+
|
25
|
+
#
|
26
|
+
# Upload "data" to youtube, where data is either an IO object or
|
27
|
+
# raw file data.
|
28
|
+
# The hash keys for opts (which specify video info) are as follows:
|
29
|
+
# :mime_type
|
30
|
+
# :filename
|
31
|
+
# :title
|
32
|
+
# :description
|
33
|
+
# :category
|
34
|
+
# :keywords
|
35
|
+
#
|
36
|
+
|
37
|
+
def upload data, opts = {}
|
38
|
+
data = data.respond_to?(:read) ? data.read : data
|
39
|
+
@opts = { :mime_type => 'video/mp4',
|
40
|
+
:filename => Digest::MD5.hexdigest(data),
|
41
|
+
:title => '',
|
42
|
+
:description => '',
|
43
|
+
:category => '',
|
44
|
+
:keywords => [] }.merge(opts)
|
45
|
+
|
46
|
+
uploadBody = generate_upload_body(boundary, video_xml, data)
|
47
|
+
|
48
|
+
uploadHeader = {
|
49
|
+
"Authorization" => "GoogleLogin auth=#{auth_token}",
|
50
|
+
"X-GData-Client" => "#{@client_id}",
|
51
|
+
"X-GData-Key" => "key=#{@dev_key}",
|
52
|
+
"Slug" => "#{@opts[:filename]}",
|
53
|
+
"Content-Type" => "multipart/related; boundary=#{boundary}",
|
54
|
+
"Content-Length" => "#{uploadBody.length}"
|
55
|
+
}
|
56
|
+
|
57
|
+
Net::HTTP.start(base_url) do |upload|
|
58
|
+
response = upload.post('/feeds/api/users/' << @user << '/uploads', uploadBody, uploadHeader)
|
59
|
+
xml = REXML::Document.new(response.body)
|
60
|
+
return xml.elements["//id"].text[/videos\/(.+)/, 1]
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def base_url
|
68
|
+
"uploads.gdata.youtube.com"
|
69
|
+
end
|
70
|
+
|
71
|
+
def boundary
|
72
|
+
"An43094fu"
|
73
|
+
end
|
74
|
+
|
75
|
+
def auth_token
|
76
|
+
unless @auth_token
|
77
|
+
http = Net::HTTP.new("www.google.com", 443)
|
78
|
+
http.use_ssl = true
|
79
|
+
body = "Email=#{CGI::escape @user}&Passwd=#{CGI::escape @pass}&service=youtube&source=#{CGI::escape @client_id}"
|
80
|
+
response = http.post("/youtube/accounts/ClientLogin", body, "Content-Type" => "application/x-www-form-urlencoded")
|
81
|
+
raise UploadError, response.body[/Error=(.+)/,1] if response.code.to_i != 200
|
82
|
+
@auth_token = response.body[/Auth=(.+)/, 1]
|
83
|
+
|
84
|
+
end
|
85
|
+
@auth_token
|
86
|
+
end
|
87
|
+
|
88
|
+
def video_xml
|
89
|
+
%[<?xml version="1.0"?>
|
90
|
+
<entry xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:yt="http://gdata.youtube.com/schemas/2007">
|
91
|
+
<media:group>
|
92
|
+
<media:title type="plain">#{@opts[:title]}</media:title>
|
93
|
+
<media:description type="plain">#{@opts[:description]}</media:description>
|
94
|
+
<media:category scheme="http://gdata.youtube.com/schemas/2007/categories.cat">#{@opts[:category]}</media:category>
|
95
|
+
<media:keywords>#{@opts[:keywords].join ","}</media:keywords>
|
96
|
+
</media:group></entry> ]
|
97
|
+
end
|
98
|
+
|
99
|
+
def generate_upload_body(boundary, video_xml, data)
|
100
|
+
uploadBody = ""
|
101
|
+
uploadBody << "--#{boundary}\r\n"
|
102
|
+
uploadBody << "Content-Type: application/atom+xml; charset=UTF-8\r\n\r\n"
|
103
|
+
uploadBody << video_xml
|
104
|
+
uploadBody << "\r\n--#{boundary}\r\n"
|
105
|
+
uploadBody << "Content-Type: #{@opts[:mime_type]}\r\nContent-Transfer-Encoding: binary\r\n\r\n"
|
106
|
+
uploadBody << data
|
107
|
+
uploadBody << "\r\n--#{boundary}--\r\n"
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|