Major cleanup

This commit is contained in:
Omar Roth 2018-08-04 23:07:38 -05:00
parent 5d4198c700
commit b9315bc534
10 changed files with 340 additions and 483 deletions

View File

@ -189,42 +189,14 @@ get "/watch" do |env|
end end
preferences = user.preferences preferences = user.preferences
subscriptions = user.subscriptions.as(Array(String)) subscriptions = user.subscriptions
end end
subscriptions ||= [] of String subscriptions ||= [] of String
autoplay = env.params.query["autoplay"]?.try &.to_i? autoplay, video_loop, video_start, video_end, listen = process_video_params(env.params.query, preferences)
video_loop = env.params.query["loop"]?.try &.to_i? if listen
if preferences
autoplay ||= preferences.autoplay.to_unsafe
video_loop ||= preferences.video_loop.to_unsafe
end
autoplay ||= 0
video_loop ||= 0
autoplay = autoplay == 1
video_loop = video_loop == 1
if env.params.query["start"]?
video_start = decode_time(env.params.query["start"])
end
if env.params.query["t"]?
video_start = decode_time(env.params.query["t"])
end
video_start ||= 0
if env.params.query["end"]?
video_end = decode_time(env.params.query["end"])
end
video_end ||= -1
if env.params.query["listen"]? && (env.params.query["listen"] == "true" || env.params.query["listen"] == "1")
listen = true
env.params.query.delete_all("listen") env.params.query.delete_all("listen")
end end
listen ||= false
begin begin
video = get_video(id, PG_DB) video = get_video(id, PG_DB)
@ -234,58 +206,27 @@ get "/watch" do |env|
next templated "error" next templated "error"
end end
fmt_stream = [] of HTTP::Params fmt_stream = video.fmt_stream(decrypt_function)
video.info["url_encoded_fmt_stream_map"].split(",") do |string| adaptive_fmts = video.adaptive_fmts(decrypt_function)
if !string.empty? audio_streams = video.audio_streams(adaptive_fmts)
fmt_stream << HTTP::Params.parse(string)
end
end
fmt_stream.each { |s| s.add("label", "#{s["quality"]} - #{s["type"].split(";")[0].split("/")[1]}") } captions = video.captions
fmt_stream = fmt_stream.uniq { |s| s["label"] }
adaptive_fmts = [] of HTTP::Params video.description = fill_links(video.description, "https", "www.youtube.com")
if video.info.has_key?("adaptive_fmts") video.description = add_alt_links(video.description)
video.info["adaptive_fmts"].split(",") do |string| description = video.short_description
adaptive_fmts << HTTP::Params.parse(string)
end
end
if fmt_stream[0]? && fmt_stream[0]["s"]? host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"])
adaptive_fmts.each do |fmt| host_params = env.request.query_params
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function) host_params.delete_all("v")
end
fmt_stream.each do |fmt|
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
end
audio_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("audio") ? s : nil }
audio_streams.sort_by! { |s| s["bitrate"].to_i }.reverse!
audio_streams.each do |stream|
stream["bitrate"] = (stream["bitrate"].to_f64/1000).to_i.to_s
end
player_response = JSON.parse(video.info["player_response"])
if player_response["captions"]?
captions = player_response["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]?.try &.as_a
end
captions ||= [] of JSON::Any
if video.info["hlsvp"]? if video.info["hlsvp"]?
hlsvp = video.info["hlsvp"] hlsvp = video.info["hlsvp"]
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
if Kemal.config.ssl || CONFIG.https_only
scheme = "https://"
else
scheme = "http://"
end end
host = env.request.headers["Host"]
url = "#{scheme}#{host}"
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", url) # TODO: Find highest resolution thumbnail automatically
end thumbnail = "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg"
rvs = [] of Hash(String, String) rvs = [] of Hash(String, String)
if video.info.has_key?("rvs") if video.info.has_key?("rvs")
@ -295,14 +236,8 @@ get "/watch" do |env|
end end
rating = video.info["avg_rating"].to_f64 rating = video.info["avg_rating"].to_f64
engagement = ((video.dislikes.to_f + video.likes.to_f)/video.views * 100) engagement = ((video.dislikes.to_f + video.likes.to_f)/video.views * 100)
if video.likes > 0 || video.dislikes > 0
calculated_rating = (video.likes.to_f/(video.likes.to_f + video.dislikes.to_f) * 4 + 1)
end
calculated_rating ||= 0.0
if video.info["ad_slots"]? if video.info["ad_slots"]?
ad_slots = video.info["ad_slots"].split(",") ad_slots = video.info["ad_slots"].split(",")
ad_slots = ad_slots.join(", ") ad_slots = ad_slots.join(", ")
@ -326,32 +261,6 @@ get "/watch" do |env|
k2 = k2.join(", ") k2 = k2.join(", ")
end end
video.description = fill_links(video.description, "https", "www.youtube.com")
video.description = add_alt_links(video.description)
description = video.description.gsub("<br>", " ")
description = description.gsub("<br/>", " ")
description = XML.parse_html(description).content[0..200].gsub('"', "&quot;").gsub("\n", " ").strip(" ")
if description.empty?
description = " "
end
if Kemal.config.ssl || CONFIG.https_only
scheme = "https://"
else
scheme = "http://"
end
host = env.request.headers["Host"]
host_url = "#{scheme}#{host}"
host_params = env.request.query_params
host_params.delete_all("v")
if fmt_stream.select { |x| x["label"].starts_with? "hd720" }.size != 0
thumbnail = "https://i.ytimg.com/vi/#{video.id}/maxresdefault.jpg"
else
thumbnail = "https://i.ytimg.com/vi/#{video.id}/hqdefault.jpg"
end
templated "watch" templated "watch"
end end
@ -366,44 +275,7 @@ get "/embed/:id" do |env|
next env.redirect "/" next env.redirect "/"
end end
if env.params.query["start"]? autoplay, video_loop, video_start, video_end, listen, raw, quality, autoplay, controls = process_video_params(env.params.query, nil)
video_start = decode_time(env.params.query["start"])
end
if env.params.query["t"]?
video_start = decode_time(env.params.query["t"])
end
video_start ||= 0
if env.params.query["end"]?
video_end = decode_time(env.params.query["end"])
end
video_end ||= -1
if env.params.query["listen"]? && (env.params.query["listen"] == "true" || env.params.query["listen"] == "1")
listen = true
env.params.query.delete_all("listen")
end
listen ||= false
raw = env.params.query["raw"]?.try &.to_i?
raw ||= 0
raw = raw == 1
quality = env.params.query["quality"]?
quality ||= "hd720"
autoplay = env.params.query["autoplay"]?.try &.to_i?
autoplay ||= 0
autoplay = autoplay == 1
controls = env.params.query["controls"]?.try &.to_i?
controls ||= 1
controls = controls == 1
video_loop = env.params.query["loop"]?.try &.to_i?
video_loop ||= 0
video_loop = video_loop == 1
begin begin
video = get_video(id, PG_DB) video = get_video(id, PG_DB)
@ -412,58 +284,27 @@ get "/embed/:id" do |env|
next templated "error" next templated "error"
end end
player_response = JSON.parse(video.info["player_response"]) fmt_stream = video.fmt_stream(decrypt_function)
if player_response["captions"]? adaptive_fmts = video.adaptive_fmts(decrypt_function)
captions = player_response["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]?.try &.as_a audio_streams = video.audio_streams(adaptive_fmts)
end
captions ||= [] of JSON::Any captions = video.captions
video.description = fill_links(video.description, "https", "www.youtube.com")
video.description = add_alt_links(video.description)
description = video.short_description
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"])
host_params = env.request.query_params
host_params.delete_all("v")
if video.info["hlsvp"]? if video.info["hlsvp"]?
hlsvp = video.info["hlsvp"] hlsvp = video.info["hlsvp"]
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
if Kemal.config.ssl || CONFIG.https_only
scheme = "https://"
else
scheme = "http://"
end
host = env.request.headers["Host"]
url = "#{scheme}#{host}"
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", url)
end end
fmt_stream = [] of HTTP::Params # TODO: Find highest resolution thumbnail automatically
video.info["url_encoded_fmt_stream_map"].split(",") do |string| thumbnail = "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg"
if !string.empty?
fmt_stream << HTTP::Params.parse(string)
end
end
fmt_stream.each { |s| s.add("label", "#{s["quality"]} - #{s["type"].split(";")[0].split("/")[1]}") }
fmt_stream = fmt_stream.uniq { |s| s["label"] }
adaptive_fmts = [] of HTTP::Params
if video.info.has_key?("adaptive_fmts")
video.info["adaptive_fmts"].split(",") do |string|
adaptive_fmts << HTTP::Params.parse(string)
end
end
if adaptive_fmts[0]? && adaptive_fmts[0]["s"]?
adaptive_fmts.each do |fmt|
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
fmt_stream.each do |fmt|
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
end
audio_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("audio") ? s : nil }
audio_streams.sort_by! { |s| s["bitrate"].to_i }.reverse!
audio_streams.each do |stream|
stream["bitrate"] = (stream["bitrate"].to_f64/1000).to_i.to_s
end
if raw if raw
url = fmt_stream[0]["url"] url = fmt_stream[0]["url"]
@ -477,32 +318,6 @@ get "/embed/:id" do |env|
next env.redirect url next env.redirect url
end end
video.description = fill_links(video.description, "https", "www.youtube.com")
video.description = add_alt_links(video.description)
description = video.description.gsub("<br>", " ")
description = description.gsub("<br/>", " ")
description = XML.parse_html(description).content[0..200].gsub('"', "&quot;").gsub("\n", " ").strip(" ")
if description.empty?
description = " "
end
if Kemal.config.ssl || CONFIG.https_only
scheme = "https://"
else
scheme = "http://"
end
host = env.request.headers["Host"]
host_url = "#{scheme}#{host}"
host_params = env.request.query_params
host_params.delete_all("v")
if fmt_stream.select { |x| x["label"].starts_with? "hd720" }.size != 0
thumbnail = "https://i.ytimg.com/vi/#{video.id}/maxresdefault.jpg"
else
thumbnail = "https://i.ytimg.com/vi/#{video.id}/hqdefault.jpg"
end
rendered "embed" rendered "embed"
end end
@ -510,24 +325,25 @@ end
get "/results" do |env| get "/results" do |env|
search_query = env.params.query["search_query"]? search_query = env.params.query["search_query"]?
page = env.params.query["page"]?.try &.to_i?
page ||= 1
if search_query if search_query
env.redirect "/search?q=#{URI.escape(search_query)}" env.redirect "/search?q=#{URI.escape(search_query)}&page=#{page}"
else else
env.redirect "/" env.redirect "/"
end end
end end
get "/search" do |env| get "/search" do |env|
if env.params.query["q"]? query = env.params.query["q"]?
query = env.params.query["q"] query ||= ""
else
next env.redirect "/"
end
page = env.params.query["page"]?.try &.to_i? page = env.params.query["page"]?.try &.to_i?
page ||= 1 page ||= 1
videos = search(query, page) search_params = build_search_params(sort_by: "relevance", content_type: "video")
videos = search(query, page, search_params)
templated "search" templated "search"
end end
@ -930,11 +746,8 @@ post "/preferences" do |env|
env.redirect referer env.redirect referer
end end
# Function that is useful if you have multiple channels that don't have # /modify_notifications
# the bell dinged. Request parameters are fairly self-explanatory, # will "ding" all subscriptions.
# receive_all_updates = true and receive_post_updates = true will ding all
# channels. Calling /modify_notifications without any arguments will
# request all notifications from all channels.
# /modify_notifications?receive_all_updates=false&receive_no_updates=false # /modify_notifications?receive_all_updates=false&receive_no_updates=false
# will "unding" all subscriptions. # will "unding" all subscriptions.
get "/modify_notifications" do |env| get "/modify_notifications" do |env|
@ -1022,14 +835,7 @@ get "/subscription_manager" do |env|
subscriptions.sort_by! { |channel| channel.author.downcase } subscriptions.sort_by! { |channel| channel.author.downcase }
if action_takeout if action_takeout
if Kemal.config.ssl || CONFIG.https_only host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"])
scheme = "https://"
else
scheme = "http://"
end
host = env.request.headers["Host"]
url = "#{scheme}#{host}"
if format == "json" if format == "json"
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -1056,7 +862,7 @@ get "/subscription_manager" do |env|
if format == "newpipe" if format == "newpipe"
xmlUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}" xmlUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}"
else else
xmlUrl = "#{url}/feed/channel/#{channel.id}" xmlUrl = "#{host_url}/feed/channel/#{channel.id}"
end end
xml.element("outline", text: channel.author, title: channel.author, xml.element("outline", text: channel.author, title: channel.author,
@ -1444,26 +1250,21 @@ get "/feed/channel/:ucid" do |env|
end end
document = XML.parse_html(content_html) document = XML.parse_html(content_html)
if Kemal.config.ssl || CONFIG.https_only host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"])
scheme = "https://"
else
scheme = "http://"
end
host = env.request.headers["Host"]
path = env.request.path path = env.request.path
feed = XML.build(indent: " ", encoding: "UTF-8") do |xml| feed = XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
"xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom") do "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom") do
xml.element("link", rel: "self", href: "#{scheme}#{host}#{path}") xml.element("link", rel: "self", href: "#{host_url}#{path}")
xml.element("id") { xml.text "yt:channel:#{ucid}" } xml.element("id") { xml.text "yt:channel:#{ucid}" }
xml.element("yt:channelId") { xml.text ucid } xml.element("yt:channelId") { xml.text ucid }
xml.element("title") { xml.text channel.author } xml.element("title") { xml.text channel.author }
xml.element("link", rel: "alternate", href: "#{scheme}#{host}/channel/#{ucid}") xml.element("link", rel: "alternate", href: "#{host_url}/channel/#{ucid}")
xml.element("author") do xml.element("author") do
xml.element("name") { xml.text channel.author } xml.element("name") { xml.text channel.author }
xml.element("uri") { xml.text "#{scheme}#{host}/channel/#{ucid}" } xml.element("uri") { xml.text "#{host_url}/channel/#{ucid}" }
end end
document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])).each do |node| document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])).each do |node|
@ -1502,11 +1303,11 @@ get "/feed/channel/:ucid" do |env|
xml.element("yt:videoId") { xml.text video_id } xml.element("yt:videoId") { xml.text video_id }
xml.element("yt:channelId") { xml.text ucid } xml.element("yt:channelId") { xml.text ucid }
xml.element("title") { xml.text title } xml.element("title") { xml.text title }
xml.element("link", rel: "alternate", href: "#{scheme}#{host}/watch?v=#{video_id}") xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{video_id}")
xml.element("author") do xml.element("author") do
xml.element("name") { xml.text channel.author } xml.element("name") { xml.text channel.author }
xml.element("uri") { xml.text "#{scheme}#{host}/channel/#{ucid}" } xml.element("uri") { xml.text "#{host_url}/channel/#{ucid}" }
end end
xml.element("published") { xml.text published.to_s("%Y-%m-%dT%H:%M:%S%:z") } xml.element("published") { xml.text published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
@ -1585,25 +1386,19 @@ get "/feed/private" do |env|
videos.sort_by! { |video| video.author }.reverse! videos.sort_by! { |video| video.author }.reverse!
end end
if Kemal.config.ssl || CONFIG.https_only
scheme = "https://"
else
scheme = "http://"
end
if !limit if !limit
videos = videos[0..max_results] videos = videos[0..max_results]
end end
host = env.request.headers["Host"] host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"])
path = env.request.path path = env.request.path
query = env.request.query.not_nil! query = env.request.query.not_nil!
feed = XML.build(indent: " ", encoding: "UTF-8") do |xml| feed = XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("feed", xmlns: "http://www.w3.org/2005/Atom", "xmlns:media": "http://search.yahoo.com/mrss/", xml.element("feed", xmlns: "http://www.w3.org/2005/Atom", "xmlns:media": "http://search.yahoo.com/mrss/",
"xml:lang": "en-US") do "xml:lang": "en-US") do
xml.element("link", "type": "text/html", rel: "alternate", href: "#{scheme}#{host}/feed/subscriptions") xml.element("link", "type": "text/html", rel: "alternate", href: "#{host_url}/feed/subscriptions")
xml.element("link", "type": "application/atom+xml", rel: "self", href: "#{scheme}#{host}#{path}?#{query}") xml.element("link", "type": "application/atom+xml", rel: "self", href: "#{host_url}#{path}?#{query}")
xml.element("title") { xml.text "Invidious Private Feed for #{user.email}" } xml.element("title") { xml.text "Invidious Private Feed for #{user.email}" }
videos.each do |video| videos.each do |video|
@ -1612,11 +1407,11 @@ get "/feed/private" do |env|
xml.element("yt:videoId") { xml.text video.id } xml.element("yt:videoId") { xml.text video.id }
xml.element("yt:channelId") { xml.text video.ucid } xml.element("yt:channelId") { xml.text video.ucid }
xml.element("title") { xml.text video.title } xml.element("title") { xml.text video.title }
xml.element("link", rel: "alternate", href: "#{scheme}#{host}/watch?v=#{video.id}") xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{video.id}")
xml.element("author") do xml.element("author") do
xml.element("name") { xml.text video.author } xml.element("name") { xml.text video.author }
xml.element("uri") { xml.text "#{scheme}#{host}/channel/#{video.ucid}" } xml.element("uri") { xml.text "#{host_url}/channel/#{video.ucid}" }
end end
xml.element("published") { xml.text video.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } xml.element("published") { xml.text video.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
@ -1732,16 +1527,7 @@ get "/api/v1/captions/:id" do |env|
halt env, status_code: 403 halt env, status_code: 403
end end
player_response = JSON.parse(video.info["player_response"]) captions = video.captions
if !player_response["captions"]?
env.response.content_type = "application/json"
next {
"captions" => [] of String,
}.to_json
end
tracks = player_response["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]?.try &.as_a
tracks ||= [] of JSON::Any
label = env.params.query["label"]? label = env.params.query["label"]?
if !label if !label
@ -1751,7 +1537,7 @@ get "/api/v1/captions/:id" do |env|
json.object do json.object do
json.field "captions" do json.field "captions" do
json.array do json.array do
tracks.each do |caption| captions.each do |caption|
json.object do json.object do
json.field "label", caption["name"]["simpleText"] json.field "label", caption["name"]["simpleText"]
json.field "languageCode", caption["languageCode"] json.field "languageCode", caption["languageCode"]
@ -1765,27 +1551,27 @@ get "/api/v1/captions/:id" do |env|
next response next response
end end
track = tracks.select { |tracks| tracks["name"]["simpleText"] == label } caption = captions.select { |caption| caption["name"]["simpleText"] == label }
env.response.content_type = "text/vtt" env.response.content_type = "text/vtt"
if track.empty? if caption.empty?
halt env, status_code: 403 halt env, status_code: 403
else else
track = track[0] caption = caption[0]
end end
track_xml = client.get(track["baseUrl"].as_s).body caption_xml = client.get(caption["baseUrl"].as_s).body
track_xml = XML.parse(track_xml) caption_xml = XML.parse(caption_xml)
webvtt = <<-END_VTT webvtt = <<-END_VTT
WEBVTT WEBVTT
Kind: captions Kind: captions
Language: #{track["languageCode"]} Language: #{caption["languageCode"]}
END_VTT END_VTT
track_xml.xpath_nodes("//transcript/text").each do |node| caption_xml.xpath_nodes("//transcript/text").each do |node|
start_time = node["start"].to_f.seconds start_time = node["start"].to_f.seconds
duration = node["dur"]?.try &.to_f.seconds duration = node["dur"]?.try &.to_f.seconds
duration ||= start_time duration ||= start_time
@ -1889,30 +1675,30 @@ get "/api/v1/comments/:id" do |env|
json.field "comments" do json.field "comments" do
json.array do json.array do
contents.as_a.each do |item| contents.as_a.each do |node|
json.object do json.object do
if !response["commentRepliesContinuation"]? if !response["commentRepliesContinuation"]?
item = item["commentThreadRenderer"] node = node["commentThreadRenderer"]
end end
if item["replies"]? if node["replies"]?
item_replies = item["replies"]["commentRepliesRenderer"] node_replies = node["replies"]["commentRepliesRenderer"]
end end
if !response["commentRepliesContinuation"]? if !response["commentRepliesContinuation"]?
item_comment = item["comment"]["commentRenderer"] node_comment = node["comment"]["commentRenderer"]
else else
item_comment = item["commentRenderer"] node_comment = node["commentRenderer"]
end end
content_text = item_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff') content_text = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff')
content_text ||= item_comment["contentText"]["runs"].as_a.map { |comment| comment["text"] } content_text ||= node_comment["contentText"]["runs"].as_a.map { |comment| comment["text"] }
.join("").rchop('\ufeff') .join("").rchop('\ufeff')
json.field "author", item_comment["authorText"]["simpleText"] json.field "author", node_comment["authorText"]["simpleText"]
json.field "authorThumbnails" do json.field "authorThumbnails" do
json.array do json.array do
item_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail|
json.object do json.object do
json.field "url", thumbnail["url"] json.field "url", thumbnail["url"]
json.field "width", thumbnail["width"] json.field "width", thumbnail["width"]
@ -1921,19 +1707,19 @@ get "/api/v1/comments/:id" do |env|
end end
end end
end end
json.field "authorId", item_comment["authorEndpoint"]["browseEndpoint"]["browseId"] json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"]
json.field "authorUrl", item_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"]
json.field "content", content_text json.field "content", content_text
json.field "published", item_comment["publishedTimeText"]["runs"][0]["text"] json.field "published", node_comment["publishedTimeText"]["runs"][0]["text"]
json.field "likeCount", item_comment["likeCount"] json.field "likeCount", node_comment["likeCount"]
json.field "commentId", item_comment["commentId"] json.field "commentId", node_comment["commentId"]
if item_replies && !response["commentRepliesContinuation"]? if node_replies && !response["commentRepliesContinuation"]?
reply_count = item_replies["moreText"]["simpleText"].as_s.match(/View all (?<count>\d+) replies/) reply_count = node_replies["moreText"]["simpleText"].as_s.match(/View all (?<count>\d+) replies/)
.try &.["count"].to_i? .try &.["count"].to_i?
reply_count ||= 1 reply_count ||= 1
continuation = item_replies["continuations"].as_a[0]["nextContinuationData"]["continuation"].as_s continuation = node_replies["continuations"].as_a[0]["nextContinuationData"]["continuation"].as_s
json.field "replies" do json.field "replies" do
json.object do json.object do
@ -1996,35 +1782,10 @@ get "/api/v1/videos/:id" do |env|
halt env, status_code: 403 halt env, status_code: 403
end end
adaptive_fmts = [] of HTTP::Params fmt_stream = video.fmt_stream(decrypt_function)
if video.info.has_key?("adaptive_fmts") adaptive_fmts = video.adaptive_fmts(decrypt_function)
video.info["adaptive_fmts"].split(",") do |string|
adaptive_fmts << HTTP::Params.parse(string)
end
end
fmt_stream = [] of HTTP::Params captions = video.captions
video.info["url_encoded_fmt_stream_map"].split(",") do |string|
if !string.empty?
fmt_stream << HTTP::Params.parse(string)
end
end
if adaptive_fmts[0]? && adaptive_fmts[0]["s"]?
adaptive_fmts.each do |fmt|
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
fmt_stream.each do |fmt|
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
end
player_response = JSON.parse(video.info["player_response"])
if player_response["captions"]?
captions = player_response["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]?.try &.as_a
end
captions ||= [] of JSON::Any
env.response.content_type = "application/json" env.response.content_type = "application/json"
video_info = JSON.build do |json| video_info = JSON.build do |json|
@ -2567,11 +2328,8 @@ get "/api/v1/channels/:ucid/videos" do |env|
end end
get "/api/v1/search" do |env| get "/api/v1/search" do |env|
if env.params.query["q"]? query = env.params.query["q"]?
query = env.params.query["q"] query ||= ""
else
next env.redirect "/"
end
page = env.params.query["page"]?.try &.to_i? page = env.params.query["page"]?.try &.to_i?
page ||= 1 page ||= 1
@ -2591,10 +2349,11 @@ get "/api/v1/search" do |env|
# TODO: Support other content types # TODO: Support other content types
content_type = "video" content_type = "video"
env.response.content_type = "application/json"
begin begin
search_params = build_search_params(sort_by, date, content_type, duration, features) search_params = build_search_params(sort_by, date, content_type, duration, features)
rescue ex rescue ex
env.response.content_type = "application/json"
next JSON.build do |json| next JSON.build do |json|
json.object do json.object do
json.field "error", ex.message json.field "error", ex.message
@ -2602,67 +2361,16 @@ get "/api/v1/search" do |env|
end end
end end
client = make_client(YT_URL) response = JSON.build do |json|
html = client.get("/results?q=#{URI.escape(query)}&page=#{page}&sp=#{search_params}&disable_polymer=1").body
html = XML.parse_html(html)
results = JSON.build do |json|
json.array do json.array do
html.xpath_nodes(%q(//ol[@class="item-section"]/li)).each do |node| search_results = search(query, page, search_params)
anchor = node.xpath_node(%q(.//h3[contains(@class,"yt-lockup-title")]/a)) search_results.each do |video|
if !anchor
next
end
if anchor["href"].starts_with? "https://www.googleadservices.com"
next
end
title = anchor.content.strip
video_id = anchor["href"].lchop("/watch?v=")
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)).not_nil!
author = anchor.content
author_url = anchor["href"]
published = node.xpath_node(%q(.//div[contains(@class,"yt-lockup-meta")]/ul/li[1]))
if !published
next
end
published = published.content
if published.ends_with? "watching"
next
end
published = decode_date(published).epoch
view_count = node.xpath_node(%q(.//div[contains(@class,"yt-lockup-meta")]/ul/li[2])).not_nil!
puts view_count
view_count = view_count.content.rchop(" views")
if view_count == "No"
view_count = 0
else
view_count = view_count.delete(",").to_i64
end
descriptionHtml = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
if !descriptionHtml
description = ""
descriptionHtml = ""
else
descriptionHtml = descriptionHtml.to_s
description = descriptionHtml.gsub("<br>", "\n")
description = description.gsub("<br/>", "\n")
description = XML.parse_html(description).content.strip("\n ")
end
length_seconds = decode_length_seconds(node.xpath_node(%q(.//span[@class="video-time"])).not_nil!.content)
json.object do json.object do
json.field "title", title json.field "title", video.title
json.field "videoId", video_id json.field "videoId", video.id
json.field "author", author json.field "author", video.author
json.field "authorUrl", author_url json.field "authorUrl", "/channel/#{video.ucid}"
json.field "videoThumbnails" do json.field "videoThumbnails" do
json.object do json.object do
@ -2673,7 +2381,7 @@ get "/api/v1/search" do |env|
qualities.each do |quality| qualities.each do |quality|
json.field quality[:name] do json.field quality[:name] do
json.object do json.object do
json.field "url", "https://i.ytimg.com/vi/#{video_id}/#{quality["url"]}.jpg" json.field "url", "https://i.ytimg.com/vi/#{video.id}/#{quality["url"]}.jpg"
json.field "width", quality[:width] json.field "width", quality[:width]
json.field "height", quality[:height] json.field "height", quality[:height]
end end
@ -2682,19 +2390,18 @@ get "/api/v1/search" do |env|
end end
end end
json.field "description", description json.field "description", video.description
json.field "descriptionHtml", descriptionHtml json.field "descriptionHtml", video.description_html
json.field "viewCount", view_count json.field "viewCount", video.view_count
json.field "published", published json.field "published", video.published.epoch
json.field "lengthSeconds", length_seconds json.field "lengthSeconds", video.length_seconds
end end
end end
end end
end end
env.response.content_type = "application/json" response
results
end end
get "/api/manifest/dash/id/:id" do |env| get "/api/manifest/dash/id/:id" do |env|
@ -2733,40 +2440,34 @@ get "/api/manifest/dash/id/:id" do |env|
next manifest next manifest
end end
adaptive_fmts = [] of HTTP::Params adaptive_fmts = video.adaptive_fmts(decrypt_function)
if video.info.has_key?("adaptive_fmts")
video.info["adaptive_fmts"].split(",") do |string|
adaptive_fmts << HTTP::Params.parse(string)
end
else
halt env, status_code: 403
end
if local if local
adaptive_fmts.each do |fmt| adaptive_fmts.each do |fmt|
if Kemal.config.ssl || CONFIG.https_only if Kemal.config.ssl || CONFIG.https_only
scheme = "https://" scheme = "https://"
else
scheme = "http://"
end end
scheme ||= "http://"
fmt["url"] = scheme + env.request.headers["Host"] + URI.parse(fmt["url"]).full_path fmt["url"] = scheme + env.request.headers["Host"] + URI.parse(fmt["url"]).full_path
end end
end end
if adaptive_fmts[0]? && adaptive_fmts[0]["s"]?
adaptive_fmts.each do |fmt|
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
end
video_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("video/mp4") ? s : nil } video_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("video/mp4") ? s : nil }
audio_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("audio/mp4") ? s : nil } audio_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("audio/mp4") ? s : nil }
audio_streams.sort_by! { |s| s["bitrate"].to_i }.reverse! audio_streams.sort_by! { |s| s["bitrate"].to_i }.reverse!
audio_streams.each do |fmt| audio_streams.each do |fmt|
fmt["bitrate"] = (fmt["bitrate"].to_f64/1000).to_i.to_s fmt["bitrate"] = (fmt["bitrate"].to_f64/1000).to_i.to_s
end end
(audio_streams + video_streams).each do |fmt|
fmt["url"] = fmt["url"].gsub('?', '/')
fmt["url"] = fmt["url"].gsub('&', '/')
fmt["url"] = fmt["url"].gsub('=', '/')
end
manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml| manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("MPD", "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", "xmlns": "urn:mpeg:DASH:schema:MPD:2011", xml.element("MPD", "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", "xmlns": "urn:mpeg:DASH:schema:MPD:2011",
"xmlns:yt": "http://youtube.com/yt/2012/10/10", "xsi:schemaLocation": "urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd", "xmlns:yt": "http://youtube.com/yt/2012/10/10", "xsi:schemaLocation": "urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd",
@ -2782,9 +2483,6 @@ get "/api/manifest/dash/id/:id" do |env|
bandwidth = fmt["bitrate"] bandwidth = fmt["bitrate"]
itag = fmt["itag"] itag = fmt["itag"]
url = fmt["url"] url = fmt["url"]
url = url.gsub("?", "/")
url = url.gsub("&", "/")
url = url.gsub("=", "/")
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
@ -2805,9 +2503,6 @@ get "/api/manifest/dash/id/:id" do |env|
bandwidth = fmt["bitrate"] bandwidth = fmt["bitrate"]
itag = fmt["itag"] itag = fmt["itag"]
url = fmt["url"] url = fmt["url"]
url = url.gsub("?", "/")
url = url.gsub("&", "/")
url = url.gsub("=", "/")
height, width = fmt["size"].split("x") height, width = fmt["size"].split("x")
xml.element("Representation", id: itag, codecs: codecs, width: width, startWithSAP: "1", maxPlayoutRate: "1", xml.element("Representation", id: itag, codecs: codecs, width: width, startWithSAP: "1", maxPlayoutRate: "1",
@ -2833,23 +2528,15 @@ get "/api/manifest/hls_variant/*" do |env|
manifest = client.get(env.request.path) manifest = client.get(env.request.path)
if manifest.status_code != 200 if manifest.status_code != 200
halt env, status_code: 403 halt env, status_code: manifest.status_code
end end
manifest = manifest.body
if Kemal.config.ssl || CONFIG.https_only
scheme = "https://"
else
scheme = "http://"
end
host = env.request.headers["Host"]
url = "#{scheme}#{host}"
env.response.content_type = "application/x-mpegURL" env.response.content_type = "application/x-mpegURL"
env.response.headers.add("Access-Control-Allow-Origin", "*") env.response.headers.add("Access-Control-Allow-Origin", "*")
manifest.gsub("https://www.youtube.com", url)
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"])
manifest = manifest.body
manifest.gsub("https://www.youtube.com", host_url)
end end
get "/api/manifest/hls_playlist/*" do |env| get "/api/manifest/hls_playlist/*" do |env|
@ -2857,20 +2544,13 @@ get "/api/manifest/hls_playlist/*" do |env|
manifest = client.get(env.request.path) manifest = client.get(env.request.path)
if manifest.status_code != 200 if manifest.status_code != 200
halt env, status_code: 403 halt env, status_code: manifest.status_code
end end
if Kemal.config.ssl || CONFIG.https_only host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"])
scheme = "https://"
else
scheme = "http://"
end
host = env.request.headers["Host"]
url = "#{scheme}#{host}" manifest = manifest.body.gsub("https://www.youtube.com", host_url)
manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, host_url)
manifest = manifest.body.gsub("https://www.youtube.com", url)
manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, url)
fvip = manifest.match(/hls_chunk_host\/r(?<fvip>\d)---/).not_nil!["fvip"] fvip = manifest.match(/hls_chunk_host\/r(?<fvip>\d)---/).not_nil!["fvip"]
manifest = manifest.gsub("seg.ts", "seg.ts/fvip/#{fvip}") manifest = manifest.gsub("seg.ts", "seg.ts/fvip/#{fvip}")

View File

@ -127,3 +127,13 @@ def arg_array(array, start = 1)
return args return args
end end
def make_host_url(ssl, host)
if ssl
scheme = "https://"
else
scheme = "http://"
end
return "#{scheme}#{host}"
end

View File

@ -1,28 +1,92 @@
class SearchVideo
add_mapping({
title: String,
id: String,
author: String,
ucid: String,
published: Time,
view_count: Int64,
description: String,
description_html: String,
length_seconds: Int32,
})
end
def search(query, page = 1, search_params = build_search_params(content_type: "video")) def search(query, page = 1, search_params = build_search_params(content_type: "video"))
client = make_client(YT_URL) client = make_client(YT_URL)
html = client.get("/results?q=#{URI.escape(query)}&page=#{page}&sp=#{search_params}").body html = client.get("/results?q=#{URI.escape(query)}&page=#{page}&sp=#{search_params}").body
if html.empty?
return [] of SearchVideo
end
html = XML.parse_html(html) html = XML.parse_html(html)
videos = [] of SearchVideo
videos = [] of ChannelVideo html.xpath_nodes(%q(//ol[@class="item-section"]/li)).each do |node|
anchor = node.xpath_node(%q(.//h3[contains(@class,"yt-lockup-title")]/a))
html.xpath_nodes(%q(//ol[@class="item-section"]/li)).each do |item| if !anchor
root = item.xpath_node(%q(div[contains(@class,"yt-lockup-video")]/div))
if !root
next next
end end
id = root.xpath_node(%q(.//div[contains(@class,"yt-lockup-thumbnail")]/a/@href)).not_nil!.content.lchop("/watch?v=") if anchor["href"].starts_with? "https://www.googleadservices.com"
next
end
title = root.xpath_node(%q(.//div[@class="yt-lockup-content"]/h3/a)).not_nil!.content title = anchor.content.strip
id = anchor["href"].lchop("/watch?v=")
author = root.xpath_node(%q(.//div[@class="yt-lockup-content"]/div/a)).not_nil! anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)).not_nil!
ucid = author["href"].rpartition("/")[-1] author = anchor.content
author = author.content author_url = anchor["href"]
ucid = author_url.split("/")[-1]
published = root.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li[1])).not_nil!.content metadata = node.xpath_nodes(%q(.//div[contains(@class,"yt-lockup-meta")]/ul/li))
published = decode_date(published) if metadata.size == 0
next
elsif metadata.size == 1
view_count = metadata[0].content.rchop(" watching").delete(",").to_i64
published = Time.now
else
published = decode_date(metadata[0].content)
view_count = metadata[1].content.rchop(" views")
if view_count == "No"
view_count = 0_i64
else
view_count = view_count.delete(",").to_i64
end
end
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
if !description_html
description = ""
description_html = ""
else
description_html = description_html.to_s
description = description_html.gsub("<br>", "\n")
description = description.gsub("<br/>", "\n")
description = XML.parse_html(description).content.strip("\n ")
end
length_seconds = node.xpath_node(%q(.//span[@class="video-time"]))
if length_seconds
length_seconds = decode_length_seconds(length_seconds.content)
else
length_seconds = -1
end
video = SearchVideo.new(
title,
id,
author,
ucid,
published,
view_count,
description,
description_html,
length_seconds,
)
video = ChannelVideo.new(id, title, published, Time.now, ucid, author)
videos << video videos << video
end end

View File

@ -5,6 +5,75 @@ class Video
end end
end end
def fmt_stream(decrypt_function)
streams = [] of HTTP::Params
self.info["url_encoded_fmt_stream_map"].split(",") do |string|
if !string.empty?
streams << HTTP::Params.parse(string)
end
end
streams.each { |s| s.add("label", "#{s["quality"]} - #{s["type"].split(";")[0].split("/")[1]}") }
streams = streams.uniq { |s| s["label"] }
if streams[0]? && streams[0]["s"]?
streams.each do |fmt|
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
end
return streams
end
def adaptive_fmts(decrypt_function)
adaptive_fmts = [] of HTTP::Params
if self.info.has_key?("adaptive_fmts")
self.info["adaptive_fmts"].split(",") do |string|
adaptive_fmts << HTTP::Params.parse(string)
end
end
if adaptive_fmts[0]? && adaptive_fmts[0]["s"]?
adaptive_fmts.each do |fmt|
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
end
return adaptive_fmts
end
def audio_streams(adaptive_fmts)
audio_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("audio") ? s : nil }
audio_streams.sort_by! { |s| s["bitrate"].to_i }.reverse!
audio_streams.each do |stream|
stream["bitrate"] = (stream["bitrate"].to_f64/1000).to_i.to_s
end
return audio_streams
end
def captions
player_response = JSON.parse(self.info["player_response"])
if player_response["captions"]?
captions = player_response["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]?.try &.as_a
end
captions ||= [] of JSON::Any
return captions
end
def short_description
description = self.description.gsub("<br>", " ")
description = description.gsub("<br/>", " ")
description = XML.parse_html(description).content[0..200].gsub('"', "&quot;").gsub("\n", " ").strip(" ")
if description.empty?
description = " "
end
return description
end
add_mapping({ add_mapping({
id: String, id: String,
info: { info: {
@ -221,3 +290,53 @@ def itag_to_metadata(itag : String)
return formats[itag] return formats[itag]
end end
def process_video_params(query, preferences)
autoplay = query["autoplay"]?.try &.to_i?
video_loop = query["loop"]?.try &.to_i?
if preferences
autoplay ||= preferences.autoplay.to_unsafe
video_loop ||= preferences.video_loop.to_unsafe
end
autoplay ||= 0
autoplay = autoplay == 1
video_loop ||= 0
video_loop = video_loop == 1
if query["start"]?
video_start = decode_time(query["start"])
end
if query["t"]?
video_start = decode_time(query["t"])
end
video_start ||= 0
if query["end"]?
video_end = decode_time(query["end"])
end
video_end ||= -1
if query["listen"]? && (query["listen"] == "true" || query["listen"] == "1")
listen = true
end
listen ||= false
raw = query["raw"]?.try &.to_i?
raw ||= 0
raw = raw == 1
quality = query["quality"]?
quality ||= "hd720"
autoplay = query["autoplay"]?.try &.to_i?
autoplay ||= 0
autoplay = autoplay == 1
controls = query["controls"]?.try &.to_i?
controls ||= 1
controls = controls == 1
return autoplay, video_loop, video_start, video_end, listen, raw, quality, autoplay, controls
end

View File

@ -39,7 +39,7 @@
</div> </div>
<% end %> <% end %>
<div class="pure-g"> <div class="pure-g h-box">
<div class="pure-u-1 pure-u-md-1-5"> <div class="pure-u-1 pure-u-md-1-5">
<% if page > 2 %> <% if page > 2 %>
<a href="/channel/<%= ucid %>?page=<%= page - 1 %>">Previous page</a> <a href="/channel/<%= ucid %>?page=<%= page - 1 %>">Previous page</a>

View File

@ -1,17 +0,0 @@
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<a style="width:100%;" href="/watch?v=<%= video.id %>">
<% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %>
<% else %>
<img style="width:100%;" src="https://i.ytimg.com/vi/<%= video.id %>/mqdefault.jpg"/>
<% end %>
<p><%= video.title %></p>
</a>
<p>
<b><a style="width:100%;" href="/channel/<%= video.ucid %>"><%= video.author %></a></b>
</p>
<p>
<h5>Shared <%= video.published.to_s("%B %-d, %Y at %r UTC") %></h5>
</p>
</div>
</div>

View File

@ -10,8 +10,9 @@
<p> <p>
<b><a style="width:100%;" href="/channel/<%= video.ucid %>"><%= video.author %></a></b> <b><a style="width:100%;" href="/channel/<%= video.ucid %>"><%= video.author %></a></b>
</p> </p>
<p>
<% if Time.now - video.published > 1.minute %>
<h5>Shared <%= recode_date(video.published) %> ago</h5> <h5>Shared <%= recode_date(video.published) %> ago</h5>
</p> <% end %>
</div> </div>
</div> </div>

View File

@ -20,7 +20,7 @@
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1 pure-u-md-1-5"></div> <div class="pure-u-1 pure-u-md-1-5"></div>
<div class="pure-u-1 pure-u-md-3-5"> <div class="pure-u-1 pure-u-md-3-5">
<div class="pure-g navbar"> <div class="pure-g navbar h-box">
<div class="pure-u-1 pure-u-md-4-24"> <div class="pure-u-1 pure-u-md-4-24">
<a href="/" class="index-link pure-menu-heading">Invidious</a> <a href="/" class="index-link pure-menu-heading">Invidious</a>
</div> </div>

View File

@ -1,5 +1,5 @@
<% content_for "header" do %> <% content_for "header" do %>
<title><%= query.size > 30 ? query[0,30].rstrip(".") + "..." : query %> - Invidious</title> <title><%= query.not_nil!.size > 30 ? query.not_nil![0,30].rstrip(".") + "..." : query.not_nil! %> - Invidious</title>
<% end %> <% end %>
<% videos.each_slice(4) do |slice| %> <% videos.each_slice(4) do |slice| %>
@ -10,7 +10,7 @@
</div> </div>
<% end %> <% end %>
<div class="pure-g"> <div class="pure-g h-box">
<div class="pure-u-1 pure-u-md-1-5"> <div class="pure-u-1 pure-u-md-1-5">
<% if page > 2 %> <% if page > 2 %>
<a href="/search?q=<%= query %>&page=<%= page - 1 %>">Previous page</a> <a href="/search?q=<%= query %>&page=<%= page - 1 %>">Previous page</a>

View File

@ -19,7 +19,7 @@
<% notifications.each_slice(4) do |slice| %> <% notifications.each_slice(4) do |slice| %>
<div class="pure-g"> <div class="pure-g">
<% slice.each do |video| %> <% slice.each do |video| %>
<%= rendered "components/subscription_video" %> <%= rendered "components/video" %>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
@ -29,7 +29,7 @@
<% videos.each_slice(4) do |slice| %> <% videos.each_slice(4) do |slice| %>
<div class="pure-g"> <div class="pure-g">
<% slice.each do |video| %> <% slice.each do |video| %>
<%= rendered "components/subscription_video" %> <%= rendered "components/video" %>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>