class YtRecent
Constants
- CHANNEL_ID_RE
- COMPACT_TIME_AGO_PATTERN
- DECORATED_COMPACT_TIME_AGO_RE
- DECORATED_VERBOSE_TIME_AGO_RE
- EPISODE_COUNT
- TIME_AGO_PATTERN
- USER_AGENT
Public Class Methods
Source
# File plugins/meidas.rb, line 50 def self.channel_videos_url(channel) input = channel.to_s.strip return nil if input.empty? if input.match?(%r{\Ahttps?://}i) uri = URI.parse(input) path = uri.path.to_s.chomp("/") base = "#{uri.scheme}://#{uri.host}#{path}" return base if base.end_with?("/videos") return "#{base}/videos" end if input.match?(CHANNEL_ID_RE) return "https://www.youtube.com/channel/#{input}/videos" end handle = input.start_with?("@") ? input : "@#{input}" "https://www.youtube.com/#{handle}/videos" rescue URI::InvalidURIError nil end
Source
# File plugins/meidas.rb, line 20 def self.command_pattern /ytrecent (.+)/ end
Source
# File plugins/meidas.rb, line 147 def self.compact_time_ago_in_line(line) replace_verbose_time_ago(line) { |verbose| compact_verbose_time_ago(verbose) } end
Source
# File plugins/meidas.rb, line 119 def self.compact_verbose_time_ago(verbose) verbose.gsub(/\A(\d+)y:(\d+)w:(\d+)d:(\d+)h:(\d+)m:(\d+)s ago\z/) do years = Regexp.last_match(1).to_i weeks = Regexp.last_match(2).to_i days = Regexp.last_match(3).to_i hours = Regexp.last_match(4).to_i minutes = Regexp.last_match(5).to_i seconds = Regexp.last_match(6).to_i parts = [] parts << "#{years}y" if years.positive? parts << "#{weeks}w" if weeks.positive? include_zero_days = years.positive? || weeks.positive? parts << format_day_hour_tail(days, hours, minutes, seconds, include_zero_days: include_zero_days) parts.join(":") end end
Source
# File plugins/meidas.rb, line 111 def self.decorate_time_ago(text) chip = Yt::STYLE_CHIP reset = Yt::IRC_RESET # mIRC colour codes can swallow a leading digit after ",14" (e.g. \x030,14 + 0d -> bg 140). value = text.match?(/\A\d/) ? " #{text}" : text "#{chip}#{value}#{reset}" end
Source
# File plugins/meidas.rb, line 212 def self.extract_video_ids(html, limit: EPISODE_COUNT) json = Yt.extract_json_object(html, "var ytInitialData = ") return [] unless json data = JSON.parse(json) ids = [] seen = {} Yt.walk_nodes(data) do |node| video_id = video_id_from_node(node) next if video_id.nil? || video_id.empty? next if seen[video_id] seen[video_id] = true ids << video_id end ids.first(limit) rescue JSON::ParserError, StandardError [] end
Source
# File plugins/meidas.rb, line 101 def self.format_day_hour_tail(days, hours, minutes, seconds, include_zero_days: false) if days.positive? || include_zero_days "#{days}d:#{hours}h:#{minutes}m:#{seconds}s ago" elsif hours.zero? && minutes.zero? "0d:0h:0m:#{seconds}s ago" else "#{hours}h:#{minutes}m:#{seconds}s ago" end end
Source
# File plugins/meidas.rb, line 245 def self.format_recent_entry(title:, duration: nil, duration_text: nil, views: nil, published: nil, now: Time.now) meta_parts = [] duration_str = Yt.format_duration(duration, duration_text) meta_parts << duration_str unless duration_str.empty? views_str = Yt.format_views(views) meta_parts << views_str unless views_str.empty? || views_str == "0 views" time_ago = Yt.format_time_ago(published, now: now) meta_parts << time_ago unless time_ago.empty? meta = IrcFormat.meta_join(*meta_parts) return title.to_s if meta.empty? "#{title} (#{meta})" end
Source
# File plugins/meidas.rb, line 262 def self.format_recent_line(entries, now: Time.now) entries.map do |entry| format_recent_entry( title: entry[:title], duration: entry[:duration], duration_text: entry[:duration_text], views: entry[:views], published: entry[:published], now: now ) end.join(" | ") end
Source
# File plugins/meidas.rb, line 73 def self.format_time_ago(published, now: Time.now) from_time = published.is_a?(Time) ? published : Yt.parse_published_time(published) return "" unless from_time to_time = now return "" if to_time < from_time remaining = (to_time - from_time).to_i year_secs = 365 * 24 * 3600 week_secs = 7 * 24 * 3600 day_secs = 24 * 3600 hour_secs = 3600 minute_secs = 60 years, remaining = remaining.divmod(year_secs) weeks, remaining = remaining.divmod(week_secs) days, remaining = remaining.divmod(day_secs) hours, remaining = remaining.divmod(hour_secs) minutes, seconds = remaining.divmod(minute_secs) parts = [] parts << "#{years}y" if years.positive? parts << "#{weeks}w" if weeks.positive? include_zero_days = years.positive? || weeks.positive? parts << format_day_hour_tail(days, hours, minutes, seconds, include_zero_days: include_zero_days) parts.join(":") end
Source
# File plugins/meidas.rb, line 158 def self.lookup_video(url, fetch_oembed:, fetch_page:, now: Time.now) video_id = Yt.extract_video_id(url) return "Could not parse YouTube URL" unless video_id oembed = JSON.parse(fetch_oembed.call(video_id)) title = oembed["title"].to_s.strip channel = oembed["author_name"].to_s.strip return "Could not fetch video info" if title.empty? html = fetch_page.call(video_id) metadata = Yt.merge_metadata(Yt.parse_initial_data(html), Yt.parse_microformat(html)) if metadata[:duration].to_i <= 0 || metadata[:quality].to_s.empty? innertube_meta = Yt.fetch_innertube_metadata(video_id, html) if metadata[:duration].to_i <= 0 && innertube_meta[:duration] metadata[:duration] = innertube_meta[:duration] end metadata[:quality] ||= innertube_meta[:quality] end line = Yt.format_message( title: title, channel: channel, views: metadata[:views] || "0", subscribers: metadata[:subscribers], likes: metadata[:likes], duration: metadata[:duration], duration_text: metadata[:duration_text], quality: metadata[:quality], published: metadata[:published], description: metadata[:description] || Yt.parse_description(html) ) rewrite_time_ago_in_line(line, metadata[:published], now: now) rescue StandardError "Could not fetch video info" end
Source
# File plugins/meidas.rb, line 275 def self.lookup_video_info(url, fetch_oembed:, fetch_page:) video_id = Yt.extract_video_id(url) return nil unless video_id oembed = JSON.parse(fetch_oembed.call(video_id)) title = oembed["title"].to_s.strip return nil if title.empty? html = fetch_page.call(video_id) metadata = Yt.merge_metadata(Yt.parse_initial_data(html), Yt.parse_microformat(html)) if metadata[:duration].to_i <= 0 || metadata[:quality].to_s.empty? innertube_meta = Yt.fetch_innertube_metadata(video_id, html) if metadata[:duration].to_i <= 0 && innertube_meta[:duration] metadata[:duration] = innertube_meta[:duration] end metadata[:quality] ||= innertube_meta[:quality] end duration = metadata[:duration].to_i duration = nil if duration <= 0 { title: title, duration: duration, duration_text: metadata[:duration_text], published: metadata[:published], views: metadata[:views] } rescue StandardError nil end
Source
# File plugins/meidas.rb, line 234 def self.published_timestamp(published) return published.to_i if published.is_a?(Time) time = Yt.parse_published_time(published) time ? time.to_i : 0 end
Source
# File plugins/meidas.rb, line 307 def self.recent_videos(channel:, limit: EPISODE_COUNT, fetch_channel:, lookup:, now: Time.now) channel_url = channel_videos_url(channel) return "Could not parse channel" unless channel_url html = fetch_channel.call(channel_url) ids = extract_video_ids(html, limit: limit) return "No episodes found" if ids.empty? entries = ids.filter_map { |id| lookup.call(video_watch_url(id)) } return "No episodes found" if entries.empty? body = format_recent_line(sort_videos_newest_first(entries), now: now) MeidasReport.format_recent(channel_url: channel_url, body_line: body) rescue StandardError "Could not fetch recent videos" end
Source
# File plugins/meidas.rb, line 136 def self.replace_verbose_time_ago(line) line = line.gsub(DECORATED_VERBOSE_TIME_AGO_RE) do decorate_time_ago(yield Regexp.last_match(1)) end line = line.gsub(DECORATED_COMPACT_TIME_AGO_RE) do decorate_time_ago(yield Regexp.last_match(1)) end line = line.gsub(TIME_AGO_PATTERN) { |match| yield match } line.gsub(COMPACT_TIME_AGO_PATTERN) { |match| yield match } end
Source
# File plugins/meidas.rb, line 151 def self.rewrite_time_ago_in_line(line, published, now: Time.now) compact = format_time_ago(published, now: now) return line if compact.empty? replace_verbose_time_ago(line) { compact } end
Source
# File plugins/meidas.rb, line 241 def self.sort_videos_newest_first(videos) videos.sort_by { |video| -published_timestamp(video[:published]) } end
Source
# File plugins/meidas.rb, line 24 def self.usage_message "Usage: !ytrecent <channel> — latest 5 videos from a YouTube channel (handle, URL, or channel ID)" end
Source
# File plugins/meidas.rb, line 194 def self.video_id_from_node(node) return nil unless node.is_a?(Hash) if (renderer = node["videoRenderer"]) return renderer["videoId"] end if node["contentId"] && node["contentType"] == "LOCKUP_CONTENT_TYPE_VIDEO" return node["contentId"] end if (lockup = node["lockupViewModel"]) return lockup["contentId"] if lockup["contentId"] end nil end
Source
# File plugins/meidas.rb, line 46 def self.video_watch_url(video_id) "https://www.youtube.com/watch?v=#{video_id}" end
Public Instance Methods
Source
# File plugins/meidas.rb, line 358 def execute(m, channel) themed_flood_safe_reply(m, recent_videos(channel: channel)) end
Source
# File plugins/meidas.rb, line 354 def fetch_channel_page(url) URI.parse(url).open("User-Agent" => USER_AGENT).read end
Source
# File plugins/meidas.rb, line 344 def fetch_oembed(video_id) url = "https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=#{video_id}&format=json" URI.parse(url).open("User-Agent" => USER_AGENT).read end
Source
# File plugins/meidas.rb, line 349 def fetch_page(video_id) url = "https://www.youtube.com/watch?v=#{video_id}" URI.parse(url).open("User-Agent" => USER_AGENT).read end
Source
# File plugins/meidas.rb, line 336 def lookup_video(url) self.class.lookup_video( url, fetch_oembed: method(:fetch_oembed), fetch_page: method(:fetch_page) ) end
Source
# File plugins/meidas.rb, line 328 def lookup_video_info(url) self.class.lookup_video_info( url, fetch_oembed: method(:fetch_oembed), fetch_page: method(:fetch_page) ) end
Source
# File plugins/meidas.rb, line 324 def recent_videos(channel:, limit: EPISODE_COUNT, fetch_channel: method(:fetch_channel_page), lookup: method(:lookup_video_info), now: Time.now) self.class.recent_videos(channel: channel, limit: limit, fetch_channel: fetch_channel, lookup: lookup, now: now) end