youtube 0.1.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/AUTHORS +2 -0
- data/CHANGELOG +50 -0
- data/README +56 -0
- data/Rakefile +48 -0
- data/TODO +15 -0
- data/examples/example.rb +34 -0
- data/lib/youtube.rb +299 -0
- data/test/test_api.rb +158 -0
- metadata +23 -16
- data/dirtywork.rb +0 -64
- data/youtube.rb +0 -204
data/AUTHORS
ADDED
data/CHANGELOG
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
* 2006/11/15
|
2
|
+
|
3
|
+
- [drummr77] Applied a modified version of the patch sent in by Lucas
|
4
|
+
Carlson that uses a more functional style of programming in the API
|
5
|
+
calls.
|
6
|
+
|
7
|
+
* 2006/11/14
|
8
|
+
|
9
|
+
- [drummr77] Changed video_details to take in a video id instead of
|
10
|
+
the whole object.
|
11
|
+
|
12
|
+
* 2006/11/04
|
13
|
+
|
14
|
+
- [shaper] Restructured to follow standard RubyGems directory structure.
|
15
|
+
|
16
|
+
- [shaper] Created a Rakefile.
|
17
|
+
|
18
|
+
- [shaper] Created initial unit tests.
|
19
|
+
|
20
|
+
- [shaper] Fixed bug wherein responses with only a single result
|
21
|
+
(e.g. from a videos_by_tag call) would fail to successfully parse.
|
22
|
+
|
23
|
+
- [shaper] Moved all classes within a YouTube module for cleaner namespace
|
24
|
+
management and in particular to avoid potential conflicts.
|
25
|
+
|
26
|
+
- [shaper] Added Video.embed_html method to allow easy retrieval of HTML
|
27
|
+
to embed the video in a web page conforming to the HTML specified on
|
28
|
+
YouTube video pages, with width/height of video optionally specifiable.
|
29
|
+
|
30
|
+
- [shaper] Merged dirtywork.rb into the main youtube.rb file and added
|
31
|
+
support for optionally specifying the host and api path for future
|
32
|
+
flexibility without requiring code modifications should YouTube change
|
33
|
+
their API access details.
|
34
|
+
|
35
|
+
- [shaper] Modified parsing response payload data to translate to the most
|
36
|
+
appropriate Ruby objects (e.g. integers via to_i(), boolean strings to
|
37
|
+
TrueClass/FalseClass, time strings to Time) wherever applicable.
|
38
|
+
|
39
|
+
- [shaper] Moved Video.details() into Client.video_details() for
|
40
|
+
consistency and to avoid having to store a reference to the client in
|
41
|
+
every Video object.
|
42
|
+
|
43
|
+
- [shaper] Updated existing and added more RDoc documentation.
|
44
|
+
|
45
|
+
- [shaper] Changed API hostname to fully-qualified www.youtube.com from
|
46
|
+
youtube.com.
|
47
|
+
|
48
|
+
* 2006/09/28
|
49
|
+
|
50
|
+
- [drummr77] Initial Release.
|
data/README
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
= YouTube
|
2
|
+
|
3
|
+
A pure Ruby object-oriented interface to the YouTube REST API documented
|
4
|
+
at http://www.youtube.com/dev.
|
5
|
+
|
6
|
+
API access requires a developer id. You can obtain one for free at
|
7
|
+
http://www.youtube.com/my_profile_dev.
|
8
|
+
|
9
|
+
The RubyForge project is at http://rubyforge.org/projects/youtube.
|
10
|
+
|
11
|
+
== About
|
12
|
+
|
13
|
+
Implements version 2 of YouTube's API.
|
14
|
+
|
15
|
+
== Installing
|
16
|
+
|
17
|
+
Install the gem via:
|
18
|
+
|
19
|
+
% gem install youtube
|
20
|
+
|
21
|
+
== Usage
|
22
|
+
|
23
|
+
An example as:
|
24
|
+
|
25
|
+
require 'rubygems'
|
26
|
+
require 'youtube'
|
27
|
+
|
28
|
+
youtube = YouTube::Client.new 'DEVELOPER_ID'
|
29
|
+
|
30
|
+
profile = youtube.profile('br0wnpunk')
|
31
|
+
puts "age: " + profile.age.to_s
|
32
|
+
|
33
|
+
favorites = youtube.favorite_videos('br0wnpunk')
|
34
|
+
puts "number of favorite videos: " + favorites.size.to_s
|
35
|
+
|
36
|
+
friends = youtube.friends('paolodona')
|
37
|
+
puts "number of friends: " + friends.size.to_s
|
38
|
+
puts "friend name: " + friends[0].user
|
39
|
+
|
40
|
+
videos = youtube.videos_by_tag('iron maiden')
|
41
|
+
puts "number of videos by tag iron maiden: " + videos.size.to_s
|
42
|
+
|
43
|
+
videos = youtube.videos_by_user('whytheluckystiff')
|
44
|
+
puts "number of videos by why: " + videos.size.to_s
|
45
|
+
puts "title: " + videos[0].title
|
46
|
+
|
47
|
+
videos = youtube.featured_videos
|
48
|
+
puts "number of featured videos: " + videos.size.to_s
|
49
|
+
puts "title: " + videos[0].title
|
50
|
+
puts "url: " + videos[0].url
|
51
|
+
puts "embed url: " + videos[0].embed_url
|
52
|
+
puts "embed html: \n" + videos[0].embed_html
|
53
|
+
|
54
|
+
details = youtube.video_details(videos[0])
|
55
|
+
puts "detailed description: " + details.description
|
56
|
+
puts "thumbnail url: " + details.thumbnail_url
|
data/Rakefile
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
require 'rake/testtask'
|
4
|
+
require 'rake/rdoctask'
|
5
|
+
require 'rake/gempackagetask'
|
6
|
+
|
7
|
+
spec = Gem::Specification.new do |s|
|
8
|
+
s.name = 'youtube'
|
9
|
+
s.version = '0.8.0'
|
10
|
+
s.author = 'Shane Vitarana'
|
11
|
+
s.email = 'shanev@gmail.com'
|
12
|
+
s.platform = Gem::Platform::RUBY
|
13
|
+
s.summary = 'A Ruby object-oriented interface to the YouTube REST API.'
|
14
|
+
s.rubyforge_project = 'youtube'
|
15
|
+
s.has_rdoc = true
|
16
|
+
s.extra_rdoc_files = [ 'README' ]
|
17
|
+
s.rdoc_options << '--main' << 'README'
|
18
|
+
s.files = Dir.glob("{examples,lib,test}/**/*") +
|
19
|
+
[ 'AUTHORS', 'CHANGELOG', 'README', 'Rakefile', 'TODO' ]
|
20
|
+
s.add_dependency("xml-simple", ">= 1.0.9")
|
21
|
+
end
|
22
|
+
|
23
|
+
desc 'Run tests'
|
24
|
+
task :default => [ :test ]
|
25
|
+
|
26
|
+
Rake::TestTask.new('test') do |t|
|
27
|
+
t.libs << 'test'
|
28
|
+
t.pattern = 'test/test_*.rb'
|
29
|
+
t.verbose = true
|
30
|
+
end
|
31
|
+
|
32
|
+
desc 'Generate RDoc'
|
33
|
+
Rake::RDocTask.new :rdoc do |rd|
|
34
|
+
rd.rdoc_dir = 'doc'
|
35
|
+
rd.rdoc_files.add 'lib', 'README'
|
36
|
+
rd.main = 'README'
|
37
|
+
end
|
38
|
+
|
39
|
+
desc 'Build Gem'
|
40
|
+
Rake::GemPackageTask.new spec do |pkg|
|
41
|
+
pkg.need_tar = true
|
42
|
+
end
|
43
|
+
|
44
|
+
desc 'Clean up'
|
45
|
+
task :clean => [ :clobber_rdoc, :clobber_package ]
|
46
|
+
|
47
|
+
desc 'Clean up'
|
48
|
+
task :clobber => [ :clean ]
|
data/TODO
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
- consider creating YouTubeException to raise on failures rather than just
|
2
|
+
a string so that we can pass our request params back up to higher
|
3
|
+
levels should they be interested.
|
4
|
+
|
5
|
+
- look into object types stored for comments and channel list and see if
|
6
|
+
we're doing the Right/Best Thing. It looks like we've got hashes with
|
7
|
+
one key e.g. "comments" that then map to a list of actual comments.
|
8
|
+
|
9
|
+
- look at what we store for fields with no actual value; appears we're
|
10
|
+
storing empty hashes in at least some cases.
|
11
|
+
|
12
|
+
- add unit tests for client.videos_by_tag page and per_page params.
|
13
|
+
|
14
|
+
- add unit tests for specifying alternate api host/path in client
|
15
|
+
constructor.
|
data/examples/example.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'youtube'
|
5
|
+
|
6
|
+
youtube = YouTube::Client.new 'DEVELOPER_ID' # Get one here: <http://youtube.com/my_profile_dev>.
|
7
|
+
|
8
|
+
profile = youtube.profile('br0wnpunk')
|
9
|
+
puts "age: " + profile.age.to_s
|
10
|
+
|
11
|
+
favorites = youtube.favorite_videos('br0wnpunk')
|
12
|
+
puts "number of favorite videos: " + favorites.size.to_s
|
13
|
+
|
14
|
+
friends = youtube.friends('paolodona')
|
15
|
+
puts "number of friends: " + friends.size.to_s
|
16
|
+
puts "friend name: " + friends.first.user
|
17
|
+
|
18
|
+
videos = youtube.videos_by_tag('iron maiden')
|
19
|
+
puts "number of videos by tag iron maiden: " + videos.size.to_s
|
20
|
+
|
21
|
+
videos = youtube.videos_by_user('whytheluckystiff')
|
22
|
+
puts "number of videos by why: " + videos.size.to_s
|
23
|
+
puts "title: " + videos.first.title
|
24
|
+
|
25
|
+
videos = youtube.featured_videos
|
26
|
+
puts "number of featured videos: " + videos.size.to_s
|
27
|
+
puts "title: " + videos.first.title
|
28
|
+
puts "url: " + videos.first.url
|
29
|
+
puts "embed url: " + videos.first.embed_url
|
30
|
+
puts "embed html: \n" + videos.first.embed_html
|
31
|
+
|
32
|
+
details = youtube.video_details(videos.first.id)
|
33
|
+
puts "detailed description: " + details.description
|
34
|
+
puts "thumbnail url: " + details.thumbnail_url
|
data/lib/youtube.rb
ADDED
@@ -0,0 +1,299 @@
|
|
1
|
+
#--
|
2
|
+
# Copyright (c) 2006 Shane Vitarana
|
3
|
+
#
|
4
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
5
|
+
# a copy of this software and associated documentation files (the
|
6
|
+
# "Software"), to deal in the Software without restriction, including
|
7
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
8
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
9
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
10
|
+
# the following conditions:
|
11
|
+
#
|
12
|
+
# The above copyright notice and this permission notice shall be
|
13
|
+
# included in all copies or substantial portions of the Software.
|
14
|
+
#
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
16
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
17
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
18
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
19
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
20
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
21
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
22
|
+
#++
|
23
|
+
|
24
|
+
require 'net/http'
|
25
|
+
require 'uri'
|
26
|
+
require 'xmlsimple'
|
27
|
+
|
28
|
+
module YouTube
|
29
|
+
|
30
|
+
# Main client class managing all interaction with the YouTube server.
|
31
|
+
# Server communication is handled via method_missing() emulating an
|
32
|
+
# RPC-like call and performing all of the work to send out the HTTP
|
33
|
+
# request and retrieve the XML response. Inspired by the Flickr
|
34
|
+
# interface by Scott Raymond <http://redgreenblu.com/flickr/>.
|
35
|
+
class Client
|
36
|
+
# the default hostname at which the YouTube API is hosted
|
37
|
+
DEFAULT_HOST = 'http://www.youtube.com'
|
38
|
+
# the default api path to the YouTube API
|
39
|
+
DEFAULT_API_PATH = '/api2_rest'
|
40
|
+
|
41
|
+
def initialize(dev_id = nil, host = DEFAULT_HOST, api_path = DEFAULT_API_PATH)
|
42
|
+
raise "developer id required" unless dev_id
|
43
|
+
|
44
|
+
@host = host
|
45
|
+
@api_path = api_path
|
46
|
+
@dev_id = dev_id
|
47
|
+
end
|
48
|
+
|
49
|
+
# Returns a YouTube::Profile object detailing the profile information
|
50
|
+
# regarding the supplied +username+.
|
51
|
+
def profile(username)
|
52
|
+
response = users_get_profile(:user => username)
|
53
|
+
Profile.new response['user_profile']
|
54
|
+
end
|
55
|
+
|
56
|
+
# Returns a list of YouTube::Video objects detailing the favorite
|
57
|
+
# videos of the supplied +username+.
|
58
|
+
def favorite_videos(username)
|
59
|
+
response = users_list_favorite_videos(:user => username)
|
60
|
+
response['video_list']['video'].compact.map { |video| Video.new(video) }
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns a list of YouTube::Friend objects detailing the friends of
|
64
|
+
# the supplied +username+.
|
65
|
+
def friends(username)
|
66
|
+
response = users_list_friends(:user => username)
|
67
|
+
response['friend_list']['friend'].compact.map { |friend| Friend.new(friend) }
|
68
|
+
end
|
69
|
+
|
70
|
+
# Returns a list of YouTube::Video objects detailing the videos
|
71
|
+
# matching the supplied +tag+.
|
72
|
+
#
|
73
|
+
# Optional parameters are:
|
74
|
+
# +page+ = the "page" of results to retrieve (e.g. 1, 2, 3)
|
75
|
+
# +per_page+ = the number of results per page (default: 20, max 100).
|
76
|
+
def videos_by_tag(tag, page = 1, per_page = 20)
|
77
|
+
response = videos_list_by_tag(:tag => tag, :page => page, :per_page => per_page)
|
78
|
+
response['video_list']['video'].compact.map { |video| Video.new(video) }
|
79
|
+
end
|
80
|
+
|
81
|
+
# Returns a list of YouTube::Video objects detailing the videos
|
82
|
+
# uploaded by the specified +username+.
|
83
|
+
def videos_by_user(username)
|
84
|
+
response = videos_list_by_user(:user => username)
|
85
|
+
response['video_list']['video'].compact.map { |video| Video.new(video) }
|
86
|
+
end
|
87
|
+
|
88
|
+
# Returns a list of YouTube::Video objects detailing the current
|
89
|
+
# global set of featured videos on YouTube.
|
90
|
+
def featured_videos
|
91
|
+
response = videos_list_featured
|
92
|
+
response['video_list']['video'].compact.map { |video| Video.new(video) }
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns a YouTube::VideoDetails object detailing additional
|
96
|
+
# information on the supplied video id, obtained from a
|
97
|
+
# YouTube::Video object from a previous client call.
|
98
|
+
def video_details(video_id)
|
99
|
+
raise ArgumentError.new("invalid video id parameter, must be string") unless video_id.is_a?(String)
|
100
|
+
response = videos_get_details(:video_id => video_id)
|
101
|
+
VideoDetails.new(response['video_details'])
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
# All API methods are implemented with this method. This method is
|
106
|
+
# like a remote method call, it encapsulates the request/response
|
107
|
+
# cycle to the remote host. It extracts the remote method API name
|
108
|
+
# based on the ruby method name.
|
109
|
+
def method_missing(method_id, *params)
|
110
|
+
_request(method_id.to_s.sub('_', '.'), *params)
|
111
|
+
end
|
112
|
+
|
113
|
+
def _request(method, *params)
|
114
|
+
url = _request_url(method, *params)
|
115
|
+
response = XmlSimple.xml_in(_http_get(url),
|
116
|
+
{ 'ForceArray' => [ 'video', 'friend' ] })
|
117
|
+
unless response['status'] == 'ok'
|
118
|
+
raise response['error']['description'] + " : url=#{url}"
|
119
|
+
end
|
120
|
+
response
|
121
|
+
end
|
122
|
+
|
123
|
+
def _request_url(method, *params)
|
124
|
+
param_list = String.new
|
125
|
+
unless params.empty?
|
126
|
+
params.first.each_pair { |k, v| param_list << "&#{k.to_s}=#{URI.encode(v.to_s)}" }
|
127
|
+
end
|
128
|
+
url = "#{@host}#{@api_path}?method=youtube.#{method}&dev_id=#{@dev_id}#{param_list}"
|
129
|
+
end
|
130
|
+
|
131
|
+
def _http_get(url)
|
132
|
+
Net::HTTP.get_response(URI.parse(url)).body.to_s
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
class Friend
|
137
|
+
attr_reader :favorite_count
|
138
|
+
attr_reader :friend_count
|
139
|
+
attr_reader :user
|
140
|
+
attr_reader :video_upload_count
|
141
|
+
|
142
|
+
def initialize(payload)
|
143
|
+
@favorite_count = payload['favorite_count'].to_i
|
144
|
+
@friend_count = payload['friend_count'].to_i
|
145
|
+
@user = payload['user']
|
146
|
+
@video_upload_count = payload['video_upload_count'].to_i
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
class Profile
|
151
|
+
attr_reader :about_me
|
152
|
+
attr_reader :age
|
153
|
+
attr_reader :books
|
154
|
+
attr_reader :city
|
155
|
+
attr_reader :companies
|
156
|
+
attr_reader :country
|
157
|
+
attr_reader :currently_on
|
158
|
+
attr_reader :favorite_video_count
|
159
|
+
attr_reader :first_name
|
160
|
+
attr_reader :friend_count
|
161
|
+
attr_reader :gender
|
162
|
+
attr_reader :hobbies
|
163
|
+
attr_reader :homepage
|
164
|
+
attr_reader :hometown
|
165
|
+
attr_reader :last_name
|
166
|
+
attr_reader :movies
|
167
|
+
attr_reader :occupations
|
168
|
+
attr_reader :relationship
|
169
|
+
attr_reader :video_upload_count
|
170
|
+
attr_reader :video_watch_count
|
171
|
+
|
172
|
+
def initialize(payload)
|
173
|
+
@about_me = payload['about_me']
|
174
|
+
@age = payload['age'].to_i
|
175
|
+
@books = payload['books']
|
176
|
+
@city = payload['city']
|
177
|
+
@companies = payload['companies']
|
178
|
+
@country = payload['country']
|
179
|
+
@currently_on = YouTube._string_to_boolean(payload['currently_on'])
|
180
|
+
@favorite_video_count = payload['favorite_video_count'].to_i
|
181
|
+
@first_name = payload['first_name']
|
182
|
+
@friend_count = payload['friend_count'].to_i
|
183
|
+
@gender = payload['gender']
|
184
|
+
@hobbies = payload['hobbies']
|
185
|
+
@homepage = payload['homepage']
|
186
|
+
@hometown = payload['hometown']
|
187
|
+
@last_name = payload['last_name']
|
188
|
+
@movies = payload['movies']
|
189
|
+
@occupations = payload['occupations']
|
190
|
+
@relationship = payload['relationship']
|
191
|
+
@video_upload_count = payload['video_upload_count'].to_i
|
192
|
+
@video_watch_count = payload['video_watch_count'].to_i
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
class Video
|
197
|
+
attr_reader :author
|
198
|
+
attr_reader :comment_count
|
199
|
+
attr_reader :description
|
200
|
+
attr_reader :embed_url
|
201
|
+
attr_reader :id
|
202
|
+
attr_reader :length
|
203
|
+
attr_reader :rating_avg
|
204
|
+
attr_reader :rating_count
|
205
|
+
attr_reader :tags
|
206
|
+
attr_reader :thumbnail_url
|
207
|
+
attr_reader :title
|
208
|
+
attr_reader :upload_time
|
209
|
+
attr_reader :url
|
210
|
+
attr_reader :view_count
|
211
|
+
|
212
|
+
def initialize(payload)
|
213
|
+
@author = payload['author']
|
214
|
+
@comment_count = payload['comment_count'].to_i
|
215
|
+
@description = payload['description']
|
216
|
+
@id = payload['id']
|
217
|
+
@length = payload['length']
|
218
|
+
@rating_avg = payload['rating_avg'].to_f
|
219
|
+
@rating_count = payload['rating_count'].to_i
|
220
|
+
@tags = payload['tags']
|
221
|
+
@thumbnail_url = payload['thumbnail_url']
|
222
|
+
@title = payload['title']
|
223
|
+
@upload_time = YouTube._string_to_time(payload['upload_time'])
|
224
|
+
@url = payload['url']
|
225
|
+
@view_count = payload['view_count'].to_i
|
226
|
+
|
227
|
+
# the url provided via the API links to the video page -- for
|
228
|
+
# convenience, generate the url used to embed in a page
|
229
|
+
@embed_url = @url.delete('?').sub('=', '/')
|
230
|
+
end
|
231
|
+
|
232
|
+
# Returns HTML analogous to that provided by the YouTube web site to
|
233
|
+
# allow for easy embedding of this video in a web page. Optional
|
234
|
+
# +width+ and +height+ parameters allow specifying the dimensions of
|
235
|
+
# the video for display.
|
236
|
+
def embed_html(width = 425, height = 350)
|
237
|
+
<<edoc
|
238
|
+
<object width="#{width}" height="#{height}">
|
239
|
+
<param name="movie" value="#{embed_url}"></param>
|
240
|
+
<param name="wmode" value="transparent"></param>
|
241
|
+
<embed src="#{embed_url}" type="application/x-shockwave-flash"
|
242
|
+
wmode="transparent" width="#{width}" height="#{height}"></embed>
|
243
|
+
</object>
|
244
|
+
edoc
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
class VideoDetails
|
249
|
+
attr_reader :author
|
250
|
+
attr_reader :channel_list
|
251
|
+
attr_reader :comment_list
|
252
|
+
attr_reader :description
|
253
|
+
attr_reader :length_seconds
|
254
|
+
attr_reader :rating_avg
|
255
|
+
attr_reader :rating_count
|
256
|
+
attr_reader :recoding_location
|
257
|
+
attr_reader :recording_country
|
258
|
+
attr_reader :recording_date
|
259
|
+
attr_reader :tags
|
260
|
+
attr_reader :thumbnail_url
|
261
|
+
attr_reader :title
|
262
|
+
attr_reader :update_time
|
263
|
+
attr_reader :upload_time
|
264
|
+
attr_reader :view_count
|
265
|
+
|
266
|
+
def initialize(payload)
|
267
|
+
@author = payload['author']
|
268
|
+
@channel_list = payload['channel_list']
|
269
|
+
@comment_list = payload['comment_list']
|
270
|
+
@description = payload['description']
|
271
|
+
@length_seconds = payload['length_seconds'].to_i
|
272
|
+
@rating_avg = payload['rating_avg'].to_f
|
273
|
+
@rating_count = payload['rating_count'].to_i
|
274
|
+
@recording_country = payload['recording_country']
|
275
|
+
@recording_date = payload['recording_date']
|
276
|
+
@recording_location = payload['recording_location']
|
277
|
+
@tags = payload['tags']
|
278
|
+
@thumbnail_url = payload['thumbnail_url']
|
279
|
+
@title = payload['title']
|
280
|
+
@update_time = YouTube._string_to_time(payload['update_time'])
|
281
|
+
@upload_time = YouTube._string_to_time(payload['upload_time'])
|
282
|
+
@view_count = payload['view_count'].to_i
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
private
|
287
|
+
# Returns the Ruby boolean object as TrueClass or FalseClass based on
|
288
|
+
# the supplied string value. TrueClass is returned if the value is
|
289
|
+
# non-nil and "true" (case-insensitive), else FalseClass is returned.
|
290
|
+
def self._string_to_boolean(bool_str)
|
291
|
+
(bool_str && bool_str.downcase == "true")
|
292
|
+
end
|
293
|
+
|
294
|
+
# Returns a Time object corresponding to the specified time string
|
295
|
+
# representing seconds since the epoch, or nil if the string is nil.
|
296
|
+
def self._string_to_time(time_str)
|
297
|
+
(time_str) ? Time.at(time_str.to_i) : nil
|
298
|
+
end
|
299
|
+
end
|
data/test/test_api.rb
ADDED
@@ -0,0 +1,158 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'test/unit'
|
3
|
+
require 'youtube'
|
4
|
+
|
5
|
+
# This test class assumes an active internet connection
|
6
|
+
class TestAPI < Test::Unit::TestCase
|
7
|
+
@@DEVELOPER_API_KEY = 'DEVELOPER_ID'
|
8
|
+
|
9
|
+
def setup
|
10
|
+
@client = YouTube::Client.new @@DEVELOPER_API_KEY
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_profile
|
14
|
+
profile = @client.profile('nutria42')
|
15
|
+
|
16
|
+
assert_kind_of YouTube::Profile, profile
|
17
|
+
assert_not_nil profile
|
18
|
+
assert (profile.age >= 33)
|
19
|
+
assert (profile.gender == 'm')
|
20
|
+
assert (profile.country == 'US')
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_favorite_videos
|
24
|
+
favorites = @client.favorite_videos('br0wnpunk')
|
25
|
+
|
26
|
+
# make sure we got some favorites
|
27
|
+
assert_not_nil favorites
|
28
|
+
assert (favorites.length > 0)
|
29
|
+
|
30
|
+
# pull out one to scrutinize
|
31
|
+
sample = favorites.first
|
32
|
+
_test_video(sample)
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_friends
|
36
|
+
friends = @client.friends('paolodona')
|
37
|
+
|
38
|
+
# make sure we got some friends
|
39
|
+
assert_not_nil friends
|
40
|
+
assert (friends.length > 0)
|
41
|
+
|
42
|
+
# pull one out to scrutinize
|
43
|
+
sample = friends.first
|
44
|
+
assert_kind_of YouTube::Friend, sample
|
45
|
+
|
46
|
+
# sanity-check some attributes to make sure we parsed properly
|
47
|
+
assert (sample.favorite_count > 0)
|
48
|
+
assert (sample.friend_count > 0)
|
49
|
+
assert (sample.user && sample.user.length > 0)
|
50
|
+
end
|
51
|
+
|
52
|
+
def test_videos_by_tag
|
53
|
+
videos = @client.videos_by_tag('iron maiden')
|
54
|
+
_test_video_list(videos)
|
55
|
+
|
56
|
+
videos = @client.videos_by_tag('caffe trieste')
|
57
|
+
_test_video_list(videos)
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_videos_by_user
|
61
|
+
videos = @client.videos_by_user('whytheluckystiff')
|
62
|
+
_test_video_list(videos)
|
63
|
+
end
|
64
|
+
|
65
|
+
def test_featured_videos
|
66
|
+
videos = @client.featured_videos
|
67
|
+
_test_video_list(videos)
|
68
|
+
end
|
69
|
+
|
70
|
+
def test_video_details
|
71
|
+
videos = @client.featured_videos
|
72
|
+
_test_video_list(videos)
|
73
|
+
|
74
|
+
videos.each do |video|
|
75
|
+
details = @client.video_details(video.id)
|
76
|
+
|
77
|
+
assert_not_nil details
|
78
|
+
assert_kind_of YouTube::VideoDetails, details
|
79
|
+
assert (details.author && details.author.length > 0)
|
80
|
+
assert (details.length_seconds > 0)
|
81
|
+
assert (details.title && details.title.length > 0)
|
82
|
+
assert (details.description && details.description.length > 0)
|
83
|
+
end
|
84
|
+
|
85
|
+
# make sure parameter validation is operating correctly
|
86
|
+
assert_raise ArgumentError do
|
87
|
+
@client.video_details(5)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_embed_html
|
92
|
+
videos = @client.videos_by_tag('iron maiden')
|
93
|
+
sample_video = videos.first
|
94
|
+
|
95
|
+
embed_html = sample_video.embed_html
|
96
|
+
embed_url = sample_video.embed_url
|
97
|
+
|
98
|
+
# make sure embed url is present twice in the html
|
99
|
+
assert (_match_count(embed_url, embed_html) == 2)
|
100
|
+
|
101
|
+
# make sure the default width and height are present
|
102
|
+
dimension_text = "width=\"425\" height=\"350\""
|
103
|
+
assert (_match_count(dimension_text, embed_html) == 2)
|
104
|
+
|
105
|
+
# try changing the width and height in the embed html
|
106
|
+
custom_embed_html = sample_video.embed_html(200, 100)
|
107
|
+
dimension_text = "width=\"200\" height=\"100\""
|
108
|
+
|
109
|
+
# make sure the customized width and height are present
|
110
|
+
entries = custom_embed_html.find_all { |t| t == dimension_text }
|
111
|
+
assert (_match_count(dimension_text, custom_embed_html) == 2)
|
112
|
+
end
|
113
|
+
|
114
|
+
private
|
115
|
+
|
116
|
+
# Returns the number of times +substr+ exists within +text+.
|
117
|
+
def _match_count (substr, text)
|
118
|
+
return 0 if (!text || !substr)
|
119
|
+
|
120
|
+
count = 0
|
121
|
+
offset = 0
|
122
|
+
while (result = text.index(substr, offset))
|
123
|
+
count += 1
|
124
|
+
offset = result + 1
|
125
|
+
end
|
126
|
+
count
|
127
|
+
end
|
128
|
+
|
129
|
+
def _assert_youtube_url (url)
|
130
|
+
(url =~ /^http:\/\/www.youtube.com\//)
|
131
|
+
end
|
132
|
+
|
133
|
+
def _test_video_list (videos)
|
134
|
+
# make sure we got some videos
|
135
|
+
assert_not_nil videos
|
136
|
+
assert (videos.length > 0)
|
137
|
+
|
138
|
+
# make sure all video records look good
|
139
|
+
videos.each { |video| _test_video(video) }
|
140
|
+
end
|
141
|
+
|
142
|
+
def _test_video (video)
|
143
|
+
assert_kind_of YouTube::Video, video
|
144
|
+
|
145
|
+
# sanity-check embed url to make sure it looks ok
|
146
|
+
assert_not_nil video.embed_url
|
147
|
+
_assert_youtube_url video.embed_url
|
148
|
+
|
149
|
+
# check other attributes
|
150
|
+
assert (video.thumbnail_url =~ /\.jpg$/)
|
151
|
+
assert (video.title && video.title.length > 0)
|
152
|
+
assert (video.upload_time && video.upload_time.is_a?(Time))
|
153
|
+
_assert_youtube_url video.url
|
154
|
+
assert (video.view_count > 0)
|
155
|
+
assert (video.tags && video.tags.length > 0)
|
156
|
+
assert (video.author && video.author.length > 0)
|
157
|
+
end
|
158
|
+
end
|
metadata
CHANGED
@@ -3,19 +3,19 @@ rubygems_version: 0.8.11
|
|
3
3
|
specification_version: 1
|
4
4
|
name: youtube
|
5
5
|
version: !ruby/object:Gem::Version
|
6
|
-
version: 0.
|
7
|
-
date: 2006-
|
8
|
-
summary:
|
6
|
+
version: 0.8.0
|
7
|
+
date: 2006-11-18 00:00:00 -06:00
|
8
|
+
summary: A Ruby object-oriented interface to the YouTube REST API.
|
9
9
|
require_paths:
|
10
|
-
-
|
10
|
+
- lib
|
11
11
|
email: shanev@gmail.com
|
12
|
-
homepage:
|
12
|
+
homepage:
|
13
13
|
rubyforge_project: youtube
|
14
14
|
description:
|
15
|
-
autorequire:
|
15
|
+
autorequire:
|
16
16
|
default_executable:
|
17
17
|
bindir: bin
|
18
|
-
has_rdoc:
|
18
|
+
has_rdoc: true
|
19
19
|
required_ruby_version: !ruby/object:Gem::Version::Requirement
|
20
20
|
requirements:
|
21
21
|
- - ">"
|
@@ -28,20 +28,27 @@ cert_chain:
|
|
28
28
|
authors:
|
29
29
|
- Shane Vitarana
|
30
30
|
files:
|
31
|
-
-
|
32
|
-
-
|
31
|
+
- examples/example.rb
|
32
|
+
- lib/youtube.rb
|
33
|
+
- test/test_api.rb
|
34
|
+
- AUTHORS
|
35
|
+
- CHANGELOG
|
36
|
+
- README
|
37
|
+
- Rakefile
|
38
|
+
- TODO
|
33
39
|
test_files: []
|
34
40
|
|
35
|
-
rdoc_options:
|
36
|
-
|
37
|
-
|
38
|
-
|
41
|
+
rdoc_options:
|
42
|
+
- --main
|
43
|
+
- README
|
44
|
+
extra_rdoc_files:
|
45
|
+
- README
|
39
46
|
executables: []
|
40
47
|
|
41
48
|
extensions: []
|
42
49
|
|
43
|
-
requirements:
|
44
|
-
|
50
|
+
requirements: []
|
51
|
+
|
45
52
|
dependencies:
|
46
53
|
- !ruby/object:Gem::Dependency
|
47
54
|
name: xml-simple
|
@@ -50,5 +57,5 @@ dependencies:
|
|
50
57
|
requirements:
|
51
58
|
- - ">="
|
52
59
|
- !ruby/object:Gem::Version
|
53
|
-
version: 1.0.
|
60
|
+
version: 1.0.9
|
54
61
|
version:
|
data/dirtywork.rb
DELETED
@@ -1,64 +0,0 @@
|
|
1
|
-
#--
|
2
|
-
# Copyright (c) 2006 Shane Vitarana
|
3
|
-
#
|
4
|
-
# Permission is hereby granted, free of charge, to any person obtaining
|
5
|
-
# a copy of this software and associated documentation files (the
|
6
|
-
# "Software"), to deal in the Software without restriction, including
|
7
|
-
# without limitation the rights to use, copy, modify, merge, publish,
|
8
|
-
# distribute, sublicense, and/or sell copies of the Software, and to
|
9
|
-
# permit persons to whom the Software is furnished to do so, subject to
|
10
|
-
# the following conditions:
|
11
|
-
#
|
12
|
-
# The above copyright notice and this permission notice shall be
|
13
|
-
# included in all copies or substantial portions of the Software.
|
14
|
-
#
|
15
|
-
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
16
|
-
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
17
|
-
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
18
|
-
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
19
|
-
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
20
|
-
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
21
|
-
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
22
|
-
#++
|
23
|
-
|
24
|
-
require 'net/http'
|
25
|
-
require 'xmlsimple'
|
26
|
-
require 'uri'
|
27
|
-
|
28
|
-
HOST = 'http://youtube.com'
|
29
|
-
API = '/api2_rest'
|
30
|
-
|
31
|
-
# Module that does all the work to send out the HTTP request and retrieve
|
32
|
-
# the XML response. Inspired by the Flickr interface by Scott Raymond
|
33
|
-
# <http://redgreenblu.com/flickr/>.
|
34
|
-
|
35
|
-
module DirtyWork
|
36
|
-
|
37
|
-
# All API methods are implemented with this method.
|
38
|
-
# This method is like a remote method call, it encapsulates
|
39
|
-
# the request/response cycle to the remote host. It extracts
|
40
|
-
# the remote method API name based on the ruby method name.
|
41
|
-
private
|
42
|
-
def method_missing(method_id, *params)
|
43
|
-
request(method_id.to_s.sub('_', '.'), *params)
|
44
|
-
end
|
45
|
-
|
46
|
-
private
|
47
|
-
def request(method, *params)
|
48
|
-
response = XmlSimple.xml_in(http_get(request_url(method, *params)), { 'ForceArray' => false })
|
49
|
-
raise response['error']['description'] if response['status'] != 'ok'
|
50
|
-
response
|
51
|
-
end
|
52
|
-
|
53
|
-
private
|
54
|
-
def request_url(method, *params)
|
55
|
-
param_list = String.new
|
56
|
-
params[0].each_pair { |k,v| param_list << "&"+k.to_s+"="+URI.encode(v.to_s) } if !params.empty?
|
57
|
-
url = "#{HOST}#{API}?method=youtube."+method+"&dev_id="+@dev_id+param_list
|
58
|
-
end
|
59
|
-
|
60
|
-
private
|
61
|
-
def http_get(url)
|
62
|
-
Net::HTTP.get_response(URI.parse(url)).body.to_s
|
63
|
-
end
|
64
|
-
end
|
data/youtube.rb
DELETED
@@ -1,204 +0,0 @@
|
|
1
|
-
#--
|
2
|
-
# Copyright (c) 2006 Shane Vitarana
|
3
|
-
#
|
4
|
-
# Permission is hereby granted, free of charge, to any person obtaining
|
5
|
-
# a copy of this software and associated documentation files (the
|
6
|
-
# "Software"), to deal in the Software without restriction, including
|
7
|
-
# without limitation the rights to use, copy, modify, merge, publish,
|
8
|
-
# distribute, sublicense, and/or sell copies of the Software, and to
|
9
|
-
# permit persons to whom the Software is furnished to do so, subject to
|
10
|
-
# the following conditions:
|
11
|
-
#
|
12
|
-
# The above copyright notice and this permission notice shall be
|
13
|
-
# included in all copies or substantial portions of the Software.
|
14
|
-
#
|
15
|
-
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
16
|
-
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
17
|
-
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
18
|
-
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
19
|
-
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
20
|
-
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
21
|
-
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
22
|
-
#++
|
23
|
-
|
24
|
-
require 'dirtywork'
|
25
|
-
|
26
|
-
# YouTube client class. Requires a Developer ID.
|
27
|
-
# Get one here: <http://youtube.com/my_profile_dev>.
|
28
|
-
|
29
|
-
class YouTube
|
30
|
-
include DirtyWork
|
31
|
-
|
32
|
-
def initialize(dev_id)
|
33
|
-
@dev_id = dev_id
|
34
|
-
end
|
35
|
-
|
36
|
-
# username = the user to get the profile for
|
37
|
-
def profile(username)
|
38
|
-
response = users_get_profile(:user => username)
|
39
|
-
Profile.new response['user_profile']
|
40
|
-
end
|
41
|
-
|
42
|
-
# username = the user to get favorite videos for
|
43
|
-
def favorite_videos(username)
|
44
|
-
videos = Array.new
|
45
|
-
response = users_list_favorite_videos(:user => username)
|
46
|
-
if !response['video_list'].empty?
|
47
|
-
response['video_list']['video'].each do |video|
|
48
|
-
videos << Video.new(video, @dev_id)
|
49
|
-
end
|
50
|
-
end
|
51
|
-
videos
|
52
|
-
end
|
53
|
-
|
54
|
-
#username = the user to get list of friends from
|
55
|
-
def friends(username)
|
56
|
-
friends = Array.new
|
57
|
-
response = users_list_friends(:user => username)
|
58
|
-
if !response['friend_list'].empty?
|
59
|
-
response['friend_list'].each do |friend|
|
60
|
-
friends << Friend.new(friend[1])
|
61
|
-
end
|
62
|
-
end
|
63
|
-
friends
|
64
|
-
end
|
65
|
-
|
66
|
-
# tag = the tag to search for, must be one word
|
67
|
-
# optional: page = the "page" of results to retrieve (e.g. 1, 2, 3)
|
68
|
-
# optional: per_page = the number of results per page (default: 20, max 100)
|
69
|
-
def videos_by_tag(tag, page=1, per_page=20)
|
70
|
-
videos = Array.new
|
71
|
-
response = videos_list_by_tag(:tag => tag, :page => page, :per_page => per_page)
|
72
|
-
if !response['video_list'].empty?
|
73
|
-
response['video_list']['video'].each do |video|
|
74
|
-
videos << Video.new(video, @dev_id)
|
75
|
-
end
|
76
|
-
end
|
77
|
-
videos
|
78
|
-
end
|
79
|
-
|
80
|
-
# username = the user to get videos for
|
81
|
-
def videos_by_user(username)
|
82
|
-
videos = Array.new
|
83
|
-
response = videos_list_by_user(:user => username)
|
84
|
-
if !response['video_list'].empty?
|
85
|
-
response['video_list']['video'].each do |video|
|
86
|
-
videos << Video.new(video, @dev_id)
|
87
|
-
end
|
88
|
-
end
|
89
|
-
videos
|
90
|
-
end
|
91
|
-
|
92
|
-
# this method takes no arguments
|
93
|
-
def featured_videos
|
94
|
-
videos = Array.new
|
95
|
-
response = videos_list_featured
|
96
|
-
if !response['video_list'].empty?
|
97
|
-
response['video_list']['video'].each do |video|
|
98
|
-
videos << Video.new(video, @dev_id)
|
99
|
-
end
|
100
|
-
end
|
101
|
-
videos
|
102
|
-
end
|
103
|
-
|
104
|
-
class Friend
|
105
|
-
attr_reader :user, :video_upload_count, :favorite_count, :friend_count
|
106
|
-
|
107
|
-
def initialize(friend)
|
108
|
-
@user = friend['user']
|
109
|
-
@video_upload_count = friend['video_upload_count']
|
110
|
-
@favorite_count = friend['favorite_count']
|
111
|
-
@friend_count = friend['friend_count']
|
112
|
-
end
|
113
|
-
end
|
114
|
-
|
115
|
-
class Profile
|
116
|
-
attr_reader :first_name, :last_name, :about_me, :age, :video_upload_count,
|
117
|
-
:video_watch_count, :homepage, :hometown, :gender, :occupations,
|
118
|
-
:companies, :city, :country, :books, :hobbies, :movies,
|
119
|
-
:relationship, :friend_count, :favorite_video_count, :currently_on
|
120
|
-
|
121
|
-
def initialize(profile)
|
122
|
-
@first_name = profile['first_name']
|
123
|
-
@last_name = profile['last_name']
|
124
|
-
@about_me = profile['about_me']
|
125
|
-
@age = profile['age']
|
126
|
-
@video_upload_count = profile['video_upload_count']
|
127
|
-
@video_watch_count = profile['video_watch_count']
|
128
|
-
@homepage = profile['homepage']
|
129
|
-
@hometown = profile['hometown']
|
130
|
-
@gender = profile['gender']
|
131
|
-
@occupations = profile['occupations']
|
132
|
-
@companies = profile['companies']
|
133
|
-
@city = profile['city']
|
134
|
-
@country = profile['country']
|
135
|
-
@books = profile['books']
|
136
|
-
@hobbies = profile['hobbies']
|
137
|
-
@movies = profile['movies']
|
138
|
-
@relationship = profile['relationship']
|
139
|
-
@friend_count = profile['friend_count']
|
140
|
-
@favorite_video_count = profile['favorite_video_count']
|
141
|
-
@currently_on = profile['currently_on']
|
142
|
-
end
|
143
|
-
end
|
144
|
-
|
145
|
-
class Video
|
146
|
-
include DirtyWork
|
147
|
-
attr_reader :author, :id, :title, :length, :rating_avg, :rating_count,
|
148
|
-
:description, :view_count, :upload_time, :comment_count,
|
149
|
-
:tags, :url, :thumbnail_url, :embed_url
|
150
|
-
|
151
|
-
def initialize(video, dev_id)
|
152
|
-
@dev_id = dev_id
|
153
|
-
@author = video['author']
|
154
|
-
@id = video['id']
|
155
|
-
@title = video['title']
|
156
|
-
@length = video['length']
|
157
|
-
@rating_avg = video['rating_avg']
|
158
|
-
@rating_count = video['rating_count']
|
159
|
-
@description = video['description']
|
160
|
-
@view_count = video['view_count']
|
161
|
-
@upload_time = video['upload_time']
|
162
|
-
@comment_count = video['comment_count']
|
163
|
-
@tags = video['tags']
|
164
|
-
@url = video['url']
|
165
|
-
@thumbnail_url = video['thumbnail_url']
|
166
|
-
|
167
|
-
# the url from the API is made for viewing, not for embedding
|
168
|
-
# fix url to allow embedding
|
169
|
-
@embed_url = @url.delete('?').sub('=', '/')
|
170
|
-
end
|
171
|
-
|
172
|
-
def details
|
173
|
-
response = videos_get_details(:video_id => @id)
|
174
|
-
VideoDetails.new response['video_details']
|
175
|
-
end
|
176
|
-
end
|
177
|
-
|
178
|
-
class VideoDetails
|
179
|
-
attr_reader :author, :title, :rating_avg, :rating_count, :tags, :description,
|
180
|
-
:update_time, :view_count, :upload_time, :length_seconds,
|
181
|
-
:recording_date, :recoding_location, :recording_country,
|
182
|
-
:comment_list, :channel_list, :thumbnail_url
|
183
|
-
|
184
|
-
def initialize(details)
|
185
|
-
@author = details['author']
|
186
|
-
@title = details['title']
|
187
|
-
@rating_avg = details['rating_avg']
|
188
|
-
@rating_count = details['rating_count']
|
189
|
-
@tags = details['tags']
|
190
|
-
@description = details['description']
|
191
|
-
@update_time = details['update_time']
|
192
|
-
@view_count = details['view_count']
|
193
|
-
@upload_time = details['upload_time']
|
194
|
-
@length_seconds = details['length_seconds']
|
195
|
-
@recording_date = details['recording_date']
|
196
|
-
@recording_location = details['recording_location']
|
197
|
-
@recording_country = details['recording_country']
|
198
|
-
@comment_list = details['comment_list']
|
199
|
-
@channel_list = details['channel_list']
|
200
|
-
@thumbnail_url = details['thumbnail_url']
|
201
|
-
end
|
202
|
-
end
|
203
|
-
|
204
|
-
end
|