msp-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.
@@ -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,126 @@
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
+ upload_body = generate_upload_body(boundary, video_xml, data)
47
+
48
+ upload_header = {
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" => "#{upload_body.length}",
55
+ }
56
+ puts("MSP upload_header [#{upload_header}]")
57
+
58
+ direct_upload_url = "/feeds/api/users/#{@user}/uploads"
59
+ puts("MSP direct_upload_url [#{direct_upload_url}]")
60
+
61
+ Net::HTTP.start(base_url) do |upload|
62
+ response = upload.post(direct_upload_url, upload_body, upload_header)
63
+ xml = REXML::Document.new(response.body)
64
+ if (xml.elements["//id"])
65
+ puts("MSP response xml [#{xml}]")
66
+ return xml.elements["//id"].text[/videos\/(.+)/, 1]
67
+ else
68
+ return xml
69
+ end
70
+
71
+
72
+ end
73
+
74
+ end
75
+
76
+ private
77
+
78
+ def base_url
79
+ "uploads.gdata.youtube.com"
80
+ end
81
+
82
+ def boundary
83
+ "An43094fu"
84
+ end
85
+
86
+ def auth_token
87
+ unless @auth_token
88
+ http = Net::HTTP.new("www.google.com", 443)
89
+ http.use_ssl = true
90
+ body = "Email=#{CGI::escape @user}&Passwd=#{CGI::escape @pass}&service=youtube&source=#{CGI::escape @client_id}"
91
+ puts("MSP auth body [#{body}]")
92
+ response = http.post("/youtube/accounts/ClientLogin", body, "Content-Type" => "application/x-www-form-urlencoded")
93
+ raise UploadError, "MSP "+response.body[/Error=(.+)/,1] if response.code.to_i != 200
94
+ puts("MSP response.body [#{response.body}]")
95
+ @auth_token = response.body[/Auth=(.+)/, 1]
96
+
97
+ end
98
+ puts "MSP auth_token [#{@auth_token}]"
99
+ @auth_token
100
+ end
101
+
102
+ def video_xml
103
+ %[<?xml version="1.0"?>
104
+ <entry xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:yt="http://gdata.youtube.com/schemas/2007">
105
+ <media:group>
106
+ <media:title type="plain">#{@opts[:title]}</media:title>
107
+ <media:description type="plain">#{@opts[:description]}</media:description>
108
+ <media:category scheme="http://gdata.youtube.com/schemas/2007/categories.cat">#{@opts[:category]}</media:category>
109
+ <media:keywords>#{@opts[:keywords].join ","}</media:keywords>
110
+ </media:group></entry> ]
111
+ end
112
+
113
+ def generate_upload_body(boundary, video_xml, data)
114
+ upload_body = ""
115
+ upload_body << "--#{boundary}\r\n"
116
+ upload_body << "Content-Type: application/atom+xml; charset=UTF-8\r\n\r\n"
117
+ upload_body << video_xml
118
+ upload_body << "\r\n--#{boundary}\r\n"
119
+ upload_body << "Content-Type: #{@opts[:mime_type]}\r\nContent-Transfer-Encoding: binary\r\n\r\n"
120
+ upload_body << data
121
+ upload_body << "\r\n--#{boundary}--\r\n"
122
+ end
123
+
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,23 @@
1
+ class YouTubeG
2
+ module Response
3
+ class VideoSearch < YouTubeG::Record
4
+ # the unique feed identifying url
5
+ attr_reader :feed_id
6
+
7
+ # the number of results per page
8
+ attr_reader :max_result_count
9
+
10
+ # the 1-based offset index into the full result set
11
+ attr_reader :offset
12
+
13
+ # the total number of results available for the original request
14
+ attr_reader :total_result_count
15
+
16
+ # the date and time at which the feed was last updated
17
+ attr_reader :updated_at
18
+
19
+ # the list of Video records
20
+ attr_reader :videos
21
+ end
22
+ end
23
+ end
data/lib/youtube_g.rb ADDED
@@ -0,0 +1,19 @@
1
+ require File.dirname(__FILE__) + '/youtube_g/client'
2
+ require File.dirname(__FILE__) + '/youtube_g/record'
3
+ require File.dirname(__FILE__) + '/youtube_g/parser'
4
+ require File.dirname(__FILE__) + '/youtube_g/model/author'
5
+ require File.dirname(__FILE__) + '/youtube_g/model/category'
6
+ require File.dirname(__FILE__) + '/youtube_g/model/contact'
7
+ require File.dirname(__FILE__) + '/youtube_g/model/content'
8
+ require File.dirname(__FILE__) + '/youtube_g/model/playlist'
9
+ require File.dirname(__FILE__) + '/youtube_g/model/rating'
10
+ require File.dirname(__FILE__) + '/youtube_g/model/thumbnail'
11
+ require File.dirname(__FILE__) + '/youtube_g/model/user'
12
+ require File.dirname(__FILE__) + '/youtube_g/model/video'
13
+ require File.dirname(__FILE__) + '/youtube_g/request/video_upload'
14
+ require File.dirname(__FILE__) + '/youtube_g/request/video_search'
15
+ require File.dirname(__FILE__) + '/youtube_g/response/video_search'
16
+
17
+ class YouTubeG
18
+ VERSION = '0.4.5'
19
+ end
@@ -0,0 +1,226 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'pp'
4
+
5
+ require 'youtube_g'
6
+
7
+ class TestClient < Test::Unit::TestCase
8
+ def setup
9
+ @client = YouTubeG::Client.new
10
+ end
11
+
12
+ def test_should_respond_to_a_basic_query
13
+ response = @client.videos_by(:query => "penguin")
14
+
15
+ assert_equal "http://gdata.youtube.com/feeds/api/videos?start-index=1&max-results=25&vq=penguin", response.feed_id
16
+ assert_equal 25, response.max_result_count
17
+ assert_equal 25, response.videos.length
18
+ assert_equal 1, response.offset
19
+ assert(response.total_result_count > 100)
20
+ assert_instance_of Time, response.updated_at
21
+
22
+ response.videos.each { |v| assert_valid_video v }
23
+ end
24
+
25
+ def test_should_get_videos_for_multiword_metasearch_query
26
+ response = @client.videos_by(:query => 'christina ricci')
27
+
28
+ assert_equal "http://gdata.youtube.com/feeds/api/videos?start-index=1&max-results=25&vq=christina+ricci", response.feed_id
29
+ assert_equal 25, response.max_result_count
30
+ assert_equal 25, response.videos.length
31
+ assert_equal 1, response.offset
32
+ assert(response.total_result_count > 100)
33
+ assert_instance_of Time, response.updated_at
34
+
35
+ response.videos.each { |v| assert_valid_video v }
36
+ end
37
+
38
+ def test_should_handle_video_not_yet_viewed
39
+ response = @client.videos_by(:query => "YnqHZDh_t2Q")
40
+
41
+ assert_equal 1, response.videos.length
42
+ response.videos.each { |v| assert_valid_video v }
43
+ end
44
+
45
+ # TODO: this doesn't work because the returned feed is in an unknown format
46
+ # def test_should_get_video_for_search_by_video_id
47
+ # response = @client.videos_by(:video_id => "T7YazwP8GtY")
48
+ # response.videos.each { |v| assert_valid_video v }
49
+ # end
50
+
51
+ def test_should_get_videos_for_one_tag
52
+ response = @client.videos_by(:tags => ['panther'])
53
+ response.videos.each { |v| assert_valid_video v }
54
+ end
55
+
56
+ def test_should_get_videos_for_multiple_tags
57
+ response = @client.videos_by(:tags => ['tiger', 'leopard'])
58
+ response.videos.each { |v| assert_valid_video v }
59
+ end
60
+
61
+ def test_should_get_videos_for_one_category
62
+ response = @client.videos_by(:categories => [:news])
63
+ response.videos.each { |v| assert_valid_video v }
64
+ end
65
+
66
+ def test_should_get_videos_for_multiple_categories
67
+ response = @client.videos_by(:categories => [:news, :sports])
68
+ response.videos.each { |v| assert_valid_video v }
69
+ end
70
+
71
+ # TODO: Need to do more specific checking in these tests
72
+ # Currently, if a URL is valid, and videos are found, the test passes regardless of search criteria
73
+ def test_should_get_videos_for_categories_and_tags
74
+ response = @client.videos_by(:categories => [:news, :sports], :tags => ['soccer', 'football'])
75
+ response.videos.each { |v| assert_valid_video v }
76
+ end
77
+
78
+ def test_should_get_most_viewed_videos
79
+ response = @client.videos_by(:most_viewed)
80
+ response.videos.each { |v| assert_valid_video v }
81
+ end
82
+
83
+ def test_should_get_top_rated_videos_for_today
84
+ response = @client.videos_by(:top_rated, :time => :today)
85
+ response.videos.each { |v| assert_valid_video v }
86
+ end
87
+
88
+ def test_should_get_videos_for_categories_and_tags_with_category_boolean_operators
89
+ response = @client.videos_by(:categories => { :either => [:news, :sports], :exclude => [:comedy] },
90
+ :tags => { :include => ['football'], :exclude => ['soccer'] })
91
+ response.videos.each { |v| assert_valid_video v }
92
+ end
93
+
94
+ def test_should_get_videos_for_categories_and_tags_with_tag_boolean_operators
95
+ response = @client.videos_by(:categories => { :either => [:news, :sports], :exclude => [:comedy] },
96
+ :tags => { :either => ['football', 'soccer', 'polo'] })
97
+ response.videos.each { |v| assert_valid_video v }
98
+ end
99
+
100
+ def test_should_get_videos_by_user
101
+ response = @client.videos_by(:user => 'liz')
102
+ response.videos.each { |v| assert_valid_video v }
103
+ end
104
+
105
+ # HTTP 403 Error
106
+ # def test_should_get_favorite_videos_by_user
107
+ # response = @client.videos_by(:favorites, :user => 'liz')
108
+ # response.videos.each { |v| assert_valid_video v }
109
+ # end
110
+
111
+ def test_should_get_videos_for_query_search_with_categories_excluded
112
+ response = @client.videos_by(:query => 'bench press', :categories => { :exclude => [:comedy, :entertainment] },
113
+ :max_results => 10)
114
+ assert_equal "<object width=\"425\" height=\"350\">\n <param name=\"movie\" value=\"http://www.youtube.com/v/BlDWdfTAx8o\"></param>\n <param name=\"wmode\" value=\"transparent\"></param>\n <embed src=\"http://www.youtube.com/v/BlDWdfTAx8o\" type=\"application/x-shockwave-flash\" \n wmode=\"transparent\" width=\"425\" height=\"350\"></embed>\n</object>\n", response.videos.first.embed_html
115
+ response.videos.each { |v| assert_valid_video v }
116
+ end
117
+
118
+ def test_should_be_able_to_pass_in_logger
119
+ @client = YouTubeG::Client.new(Logger.new(STDOUT))
120
+ assert_not_nil @client.logger
121
+ end
122
+
123
+ def test_should_create_logger_if_not_passed_in
124
+ @client = YouTubeG::Client.new
125
+ assert_not_nil @client.logger
126
+ end
127
+
128
+ def test_should_determine_if_nonembeddable_video_is_embeddable
129
+ response = @client.videos_by(:query => "avril lavigne girlfriend")
130
+
131
+ video = response.videos.first
132
+ assert !video.can_embed?
133
+ end
134
+
135
+ def test_should_determine_if_embeddable_video_is_embeddable
136
+ response = @client.videos_by(:query => "strongbad")
137
+
138
+ video = response.videos.first
139
+ assert video.can_embed?
140
+ end
141
+
142
+ def test_should_retrieve_video_by_id
143
+ video = @client.video_by("http://gdata.youtube.com/feeds/videos/EkF4JD2rO3Q")
144
+ assert_valid_video video
145
+
146
+ video = @client.video_by("EkF4JD2rO3Q")
147
+ assert_valid_video video
148
+ end
149
+
150
+ private
151
+
152
+ def assert_valid_video (video)
153
+ # pp video
154
+
155
+ # check general attributes
156
+ assert_instance_of YouTubeG::Model::Video, video
157
+ assert_instance_of Fixnum, video.duration
158
+ assert(video.duration > 0)
159
+ #assert_match(/^<div style=.*?<\/div>/m, video.html_content)
160
+ assert_instance_of String, video.html_content
161
+
162
+ # validate media content records
163
+ video.media_content.each do |media_content|
164
+ # http://www.youtube.com/v/IHVaXG1thXM
165
+ assert_valid_url media_content.url
166
+ assert(media_content.duration > 0)
167
+ assert_instance_of YouTubeG::Model::Video::Format, media_content.format
168
+ assert_instance_of String, media_content.mime_type
169
+ assert_match(/^[^\/]+\/[^\/]+$/, media_content.mime_type)
170
+ end
171
+
172
+ default_content = video.default_media_content
173
+ if default_content
174
+ assert_instance_of YouTubeG::Model::Content, default_content
175
+ assert default_content.is_default?
176
+ end
177
+
178
+ # validate keywords
179
+ video.keywords.each { |kw| assert_instance_of(String, kw) }
180
+
181
+ # http://www.youtube.com/watch?v=IHVaXG1thXM
182
+ assert_valid_url video.player_url
183
+ assert_instance_of Time, video.published_at
184
+
185
+ # validate optionally-present rating
186
+ if video.rating
187
+ assert_instance_of YouTubeG::Model::Rating, video.rating
188
+ assert_instance_of Float, video.rating.average
189
+ assert_instance_of Fixnum, video.rating.max
190
+ assert_instance_of Fixnum, video.rating.min
191
+ assert_instance_of Fixnum, video.rating.rater_count
192
+ end
193
+
194
+ # validate thumbnails
195
+ assert(video.thumbnails.size > 0)
196
+
197
+ assert_not_nil video.title
198
+ assert_instance_of String, video.title
199
+ assert(video.title.length > 0)
200
+
201
+ assert_instance_of Time, video.updated_at
202
+ # http://gdata.youtube.com/feeds/videos/IHVaXG1thXM
203
+ assert_valid_url video.video_id
204
+ assert_instance_of Fixnum, video.view_count
205
+
206
+ # validate author
207
+ assert_instance_of YouTubeG::Model::Author, video.author
208
+ assert_instance_of String, video.author.name
209
+ assert(video.author.name.length > 0)
210
+ assert_valid_url video.author.uri
211
+
212
+ # validate categories
213
+ video.categories.each do |cat|
214
+ assert_instance_of YouTubeG::Model::Category, cat
215
+ assert_instance_of String, cat.label
216
+ assert_instance_of String, cat.term
217
+ end
218
+ end
219
+
220
+ def assert_valid_url (url)
221
+ URI::parse(url)
222
+ return true
223
+ rescue
224
+ return false
225
+ end
226
+ end
@@ -0,0 +1,30 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'pp'
4
+
5
+ require 'youtube_g'
6
+
7
+ class TestVideo < Test::Unit::TestCase
8
+ def test_should_extract_unique_id_from_video_id
9
+ video = YouTubeG::Model::Video.new(:video_id => "http://gdata.youtube.com/feeds/videos/ZTUVgYoeN_o")
10
+ assert_equal "ZTUVgYoeN_o", video.unique_id
11
+ end
12
+
13
+ def test_should_extract_unique_id_with_hypen_from_video_id
14
+ video = YouTubeG::Model::Video.new(:video_id => "http://gdata.youtube.com/feeds/videos/BDqs-OZWw9o")
15
+ assert_equal "BDqs-OZWw9o", video.unique_id
16
+ end
17
+
18
+ def test_should_have_related_videos
19
+ video = YouTubeG::Model::Video.new(:video_id => "http://gdata.youtube.com/feeds/videos/BDqs-OZWw9o")
20
+ response = video.related
21
+
22
+ assert_equal "http://gdata.youtube.com/feeds/api/videos/BDqs-OZWw9o/related", response.feed_id
23
+ assert_equal 25, response.max_result_count
24
+ assert_equal 25, response.videos.length
25
+ assert_equal 1, response.offset
26
+ puts response.total_result_count
27
+ assert(response.total_result_count > 0)
28
+ assert_instance_of Time, response.updated_at
29
+ end
30
+ end