mirror of https://github.com/iv-org/invidious.git
Extract API routes from invidious.cr (1/?)
This commit is contained in:
parent
0b0036813f
commit
cbf3d75087
713
src/invidious.cr
713
src/invidious.cr
|
@ -363,6 +363,8 @@ Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :sho
|
||||||
Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
|
Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
|
||||||
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
|
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
|
||||||
|
|
||||||
|
define_v1_api_routes()
|
||||||
|
|
||||||
# Users
|
# Users
|
||||||
|
|
||||||
post "/watch_ajax" do |env|
|
post "/watch_ajax" do |env|
|
||||||
|
@ -1637,365 +1639,6 @@ end
|
||||||
|
|
||||||
# API Endpoints
|
# API Endpoints
|
||||||
|
|
||||||
get "/api/v1/stats" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
if !CONFIG.statistics_enabled
|
|
||||||
next error_json(400, "Statistics are not enabled.")
|
|
||||||
end
|
|
||||||
|
|
||||||
Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json
|
|
||||||
end
|
|
||||||
|
|
||||||
# YouTube provides "storyboards", which are sprites containing x * y
|
|
||||||
# preview thumbnails for individual scenes in a video.
|
|
||||||
# See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails
|
|
||||||
get "/api/v1/storyboards/:id" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
id = env.params.url["id"]
|
|
||||||
region = env.params.query["region"]?
|
|
||||||
|
|
||||||
begin
|
|
||||||
video = get_video(id, PG_DB, region: region)
|
|
||||||
rescue ex : VideoRedirect
|
|
||||||
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
|
|
||||||
next error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
|
|
||||||
rescue ex
|
|
||||||
env.response.status_code = 500
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
storyboards = video.storyboards
|
|
||||||
width = env.params.query["width"]?
|
|
||||||
height = env.params.query["height"]?
|
|
||||||
|
|
||||||
if !width && !height
|
|
||||||
response = JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
json.field "storyboards" do
|
|
||||||
generate_storyboards(json, id, storyboards)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
next response
|
|
||||||
end
|
|
||||||
|
|
||||||
env.response.content_type = "text/vtt"
|
|
||||||
|
|
||||||
storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" }
|
|
||||||
|
|
||||||
if storyboard.empty?
|
|
||||||
env.response.status_code = 404
|
|
||||||
next
|
|
||||||
else
|
|
||||||
storyboard = storyboard[0]
|
|
||||||
end
|
|
||||||
|
|
||||||
String.build do |str|
|
|
||||||
str << <<-END_VTT
|
|
||||||
WEBVTT
|
|
||||||
|
|
||||||
|
|
||||||
END_VTT
|
|
||||||
|
|
||||||
start_time = 0.milliseconds
|
|
||||||
end_time = storyboard[:interval].milliseconds
|
|
||||||
|
|
||||||
storyboard[:storyboard_count].times do |i|
|
|
||||||
url = storyboard[:url]
|
|
||||||
authority = /(i\d?).ytimg.com/.match(url).not_nil![1]?
|
|
||||||
url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
|
|
||||||
url = "#{HOST_URL}/sb/#{authority}/#{url}"
|
|
||||||
|
|
||||||
storyboard[:storyboard_height].times do |j|
|
|
||||||
storyboard[:storyboard_width].times do |k|
|
|
||||||
str << <<-END_CUE
|
|
||||||
#{start_time}.000 --> #{end_time}.000
|
|
||||||
#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}
|
|
||||||
|
|
||||||
|
|
||||||
END_CUE
|
|
||||||
|
|
||||||
start_time += storyboard[:interval].milliseconds
|
|
||||||
end_time += storyboard[:interval].milliseconds
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/v1/captions/:id" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
id = env.params.url["id"]
|
|
||||||
region = env.params.query["region"]?
|
|
||||||
|
|
||||||
# See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354
|
|
||||||
# It is possible to use `/api/timedtext?type=list&v=#{id}` and
|
|
||||||
# `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly,
|
|
||||||
# but this does not provide links for auto-generated captions.
|
|
||||||
#
|
|
||||||
# In future this should be investigated as an alternative, since it does not require
|
|
||||||
# getting video info.
|
|
||||||
|
|
||||||
begin
|
|
||||||
video = get_video(id, PG_DB, region: region)
|
|
||||||
rescue ex : VideoRedirect
|
|
||||||
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
|
|
||||||
next error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
|
|
||||||
rescue ex
|
|
||||||
env.response.status_code = 500
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
captions = video.captions
|
|
||||||
|
|
||||||
label = env.params.query["label"]?
|
|
||||||
lang = env.params.query["lang"]?
|
|
||||||
tlang = env.params.query["tlang"]?
|
|
||||||
|
|
||||||
if !label && !lang
|
|
||||||
response = JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
json.field "captions" do
|
|
||||||
json.array do
|
|
||||||
captions.each do |caption|
|
|
||||||
json.object do
|
|
||||||
json.field "label", caption.name
|
|
||||||
json.field "languageCode", caption.languageCode
|
|
||||||
json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
next response
|
|
||||||
end
|
|
||||||
|
|
||||||
env.response.content_type = "text/vtt; charset=UTF-8"
|
|
||||||
|
|
||||||
if lang
|
|
||||||
caption = captions.select { |caption| caption.languageCode == lang }
|
|
||||||
else
|
|
||||||
caption = captions.select { |caption| caption.name == label }
|
|
||||||
end
|
|
||||||
|
|
||||||
if caption.empty?
|
|
||||||
env.response.status_code = 404
|
|
||||||
next
|
|
||||||
else
|
|
||||||
caption = caption[0]
|
|
||||||
end
|
|
||||||
|
|
||||||
url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target
|
|
||||||
|
|
||||||
# Auto-generated captions often have cues that aren't aligned properly with the video,
|
|
||||||
# as well as some other markup that makes it cumbersome, so we try to fix that here
|
|
||||||
if caption.name.includes? "auto-generated"
|
|
||||||
caption_xml = YT_POOL.client &.get(url).body
|
|
||||||
caption_xml = XML.parse(caption_xml)
|
|
||||||
|
|
||||||
webvtt = String.build do |str|
|
|
||||||
str << <<-END_VTT
|
|
||||||
WEBVTT
|
|
||||||
Kind: captions
|
|
||||||
Language: #{tlang || caption.languageCode}
|
|
||||||
|
|
||||||
|
|
||||||
END_VTT
|
|
||||||
|
|
||||||
caption_nodes = caption_xml.xpath_nodes("//transcript/text")
|
|
||||||
caption_nodes.each_with_index do |node, i|
|
|
||||||
start_time = node["start"].to_f.seconds
|
|
||||||
duration = node["dur"]?.try &.to_f.seconds
|
|
||||||
duration ||= start_time
|
|
||||||
|
|
||||||
if caption_nodes.size > i + 1
|
|
||||||
end_time = caption_nodes[i + 1]["start"].to_f.seconds
|
|
||||||
else
|
|
||||||
end_time = start_time + duration
|
|
||||||
end
|
|
||||||
|
|
||||||
start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}"
|
|
||||||
end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}"
|
|
||||||
|
|
||||||
text = HTML.unescape(node.content)
|
|
||||||
text = text.gsub(/<font color="#[a-fA-F0-9]{6}">/, "")
|
|
||||||
text = text.gsub(/<\/font>/, "")
|
|
||||||
if md = text.match(/(?<name>.*) : (?<text>.*)/)
|
|
||||||
text = "<v #{md["name"]}>#{md["text"]}</v>"
|
|
||||||
end
|
|
||||||
|
|
||||||
str << <<-END_CUE
|
|
||||||
#{start_time} --> #{end_time}
|
|
||||||
#{text}
|
|
||||||
|
|
||||||
|
|
||||||
END_CUE
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
webvtt = YT_POOL.client &.get("#{url}&format=vtt").body
|
|
||||||
end
|
|
||||||
|
|
||||||
if title = env.params.query["title"]?
|
|
||||||
# https://blog.fastmail.com/2011/06/24/download-non-english-filenames/
|
|
||||||
env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}"
|
|
||||||
end
|
|
||||||
|
|
||||||
webvtt
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/v1/comments/:id" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
region = env.params.query["region"]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
id = env.params.url["id"]
|
|
||||||
|
|
||||||
source = env.params.query["source"]?
|
|
||||||
source ||= "youtube"
|
|
||||||
|
|
||||||
thin_mode = env.params.query["thin_mode"]?
|
|
||||||
thin_mode = thin_mode == "true"
|
|
||||||
|
|
||||||
format = env.params.query["format"]?
|
|
||||||
format ||= "json"
|
|
||||||
|
|
||||||
continuation = env.params.query["continuation"]?
|
|
||||||
sort_by = env.params.query["sort_by"]?.try &.downcase
|
|
||||||
|
|
||||||
if source == "youtube"
|
|
||||||
sort_by ||= "top"
|
|
||||||
|
|
||||||
begin
|
|
||||||
comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
|
|
||||||
next comments
|
|
||||||
elsif source == "reddit"
|
|
||||||
sort_by ||= "confidence"
|
|
||||||
|
|
||||||
begin
|
|
||||||
comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by)
|
|
||||||
content_html = template_reddit_comments(comments, locale)
|
|
||||||
|
|
||||||
content_html = fill_links(content_html, "https", "www.reddit.com")
|
|
||||||
content_html = replace_links(content_html)
|
|
||||||
rescue ex
|
|
||||||
comments = nil
|
|
||||||
reddit_thread = nil
|
|
||||||
content_html = ""
|
|
||||||
end
|
|
||||||
|
|
||||||
if !reddit_thread || !comments
|
|
||||||
env.response.status_code = 404
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
if format == "json"
|
|
||||||
reddit_thread = JSON.parse(reddit_thread.to_json).as_h
|
|
||||||
reddit_thread["comments"] = JSON.parse(comments.to_json)
|
|
||||||
|
|
||||||
next reddit_thread.to_json
|
|
||||||
else
|
|
||||||
response = {
|
|
||||||
"title" => reddit_thread.title,
|
|
||||||
"permalink" => reddit_thread.permalink,
|
|
||||||
"contentHtml" => content_html,
|
|
||||||
}
|
|
||||||
|
|
||||||
next response.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/v1/annotations/:id" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "text/xml"
|
|
||||||
|
|
||||||
id = env.params.url["id"]
|
|
||||||
source = env.params.query["source"]?
|
|
||||||
source ||= "archive"
|
|
||||||
|
|
||||||
if !id.match(/[a-zA-Z0-9_-]{11}/)
|
|
||||||
env.response.status_code = 400
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
annotations = ""
|
|
||||||
|
|
||||||
case source
|
|
||||||
when "archive"
|
|
||||||
if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation))
|
|
||||||
annotations = cached_annotation.annotations
|
|
||||||
else
|
|
||||||
index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0')
|
|
||||||
|
|
||||||
# IA doesn't handle leading hyphens,
|
|
||||||
# so we use https://archive.org/details/youtubeannotations_64
|
|
||||||
if index == "62"
|
|
||||||
index = "64"
|
|
||||||
id = id.sub(/^-/, 'A')
|
|
||||||
end
|
|
||||||
|
|
||||||
file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml")
|
|
||||||
|
|
||||||
location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
|
|
||||||
|
|
||||||
if !location.headers["Location"]?
|
|
||||||
env.response.status_code = location.status_code
|
|
||||||
end
|
|
||||||
|
|
||||||
response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"]))
|
|
||||||
|
|
||||||
if response.body.empty?
|
|
||||||
env.response.status_code = 404
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
if response.status_code != 200
|
|
||||||
env.response.status_code = response.status_code
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
annotations = response.body
|
|
||||||
|
|
||||||
cache_annotation(PG_DB, id, annotations)
|
|
||||||
end
|
|
||||||
else # "youtube"
|
|
||||||
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
|
|
||||||
|
|
||||||
if response.status_code != 200
|
|
||||||
env.response.status_code = response.status_code
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
annotations = response.body
|
|
||||||
end
|
|
||||||
|
|
||||||
etag = sha256(annotations)[0, 16]
|
|
||||||
if env.request.headers["If-None-Match"]?.try &.== etag
|
|
||||||
env.response.status_code = 304
|
|
||||||
else
|
|
||||||
env.response.headers["ETag"] = etag
|
|
||||||
annotations
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/v1/videos/:id" do |env|
|
get "/api/v1/videos/:id" do |env|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
@ -2016,324 +1659,6 @@ get "/api/v1/videos/:id" do |env|
|
||||||
video.to_json(locale)
|
video.to_json(locale)
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/trending" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
region = env.params.query["region"]?
|
|
||||||
trending_type = env.params.query["type"]?
|
|
||||||
|
|
||||||
begin
|
|
||||||
trending, plid = fetch_trending(trending_type, region, locale)
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
|
|
||||||
videos = JSON.build do |json|
|
|
||||||
json.array do
|
|
||||||
trending.each do |video|
|
|
||||||
video.to_json(locale, json)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
videos
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/v1/popular" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
if !CONFIG.popular_enabled
|
|
||||||
error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
|
|
||||||
env.response.status_code = 400
|
|
||||||
next error_message
|
|
||||||
end
|
|
||||||
|
|
||||||
JSON.build do |json|
|
|
||||||
json.array do
|
|
||||||
popular_videos.each do |video|
|
|
||||||
video.to_json(locale, json)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/v1/channels/:ucid" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
ucid = env.params.url["ucid"]
|
|
||||||
sort_by = env.params.query["sort_by"]?.try &.downcase
|
|
||||||
sort_by ||= "newest"
|
|
||||||
|
|
||||||
begin
|
|
||||||
channel = get_about_info(ucid, locale)
|
|
||||||
rescue ex : ChannelRedirect
|
|
||||||
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
|
||||||
next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
|
|
||||||
page = 1
|
|
||||||
if channel.auto_generated
|
|
||||||
videos = [] of SearchVideo
|
|
||||||
count = 0
|
|
||||||
else
|
|
||||||
begin
|
|
||||||
count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by)
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
JSON.build do |json|
|
|
||||||
# TODO: Refactor into `to_json` for InvidiousChannel
|
|
||||||
json.object do
|
|
||||||
json.field "author", channel.author
|
|
||||||
json.field "authorId", channel.ucid
|
|
||||||
json.field "authorUrl", channel.author_url
|
|
||||||
|
|
||||||
json.field "authorBanners" do
|
|
||||||
json.array do
|
|
||||||
if channel.banner
|
|
||||||
qualities = {
|
|
||||||
{width: 2560, height: 424},
|
|
||||||
{width: 2120, height: 351},
|
|
||||||
{width: 1060, height: 175},
|
|
||||||
}
|
|
||||||
qualities.each do |quality|
|
|
||||||
json.object do
|
|
||||||
json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-")
|
|
||||||
json.field "width", quality[:width]
|
|
||||||
json.field "height", quality[:height]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
json.object do
|
|
||||||
json.field "url", channel.banner.not_nil!.split("=w1060-")[0]
|
|
||||||
json.field "width", 512
|
|
||||||
json.field "height", 288
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
json.field "authorThumbnails" do
|
|
||||||
json.array do
|
|
||||||
qualities = {32, 48, 76, 100, 176, 512}
|
|
||||||
|
|
||||||
qualities.each do |quality|
|
|
||||||
json.object do
|
|
||||||
json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
|
|
||||||
json.field "width", quality
|
|
||||||
json.field "height", quality
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
json.field "subCount", channel.sub_count
|
|
||||||
json.field "totalViews", channel.total_views
|
|
||||||
json.field "joined", channel.joined.to_unix
|
|
||||||
|
|
||||||
json.field "autoGenerated", channel.auto_generated
|
|
||||||
json.field "isFamilyFriendly", channel.is_family_friendly
|
|
||||||
json.field "description", html_to_content(channel.description_html)
|
|
||||||
json.field "descriptionHtml", channel.description_html
|
|
||||||
|
|
||||||
json.field "allowedRegions", channel.allowed_regions
|
|
||||||
|
|
||||||
json.field "latestVideos" do
|
|
||||||
json.array do
|
|
||||||
videos.each do |video|
|
|
||||||
video.to_json(locale, json)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
json.field "relatedChannels" do
|
|
||||||
json.array do
|
|
||||||
channel.related_channels.each do |related_channel|
|
|
||||||
json.object do
|
|
||||||
json.field "author", related_channel.author
|
|
||||||
json.field "authorId", related_channel.ucid
|
|
||||||
json.field "authorUrl", related_channel.author_url
|
|
||||||
|
|
||||||
json.field "authorThumbnails" do
|
|
||||||
json.array do
|
|
||||||
qualities = {32, 48, 76, 100, 176, 512}
|
|
||||||
|
|
||||||
qualities.each do |quality|
|
|
||||||
json.object do
|
|
||||||
json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
|
|
||||||
json.field "width", quality
|
|
||||||
json.field "height", quality
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
{"/api/v1/channels/:ucid/videos", "/api/v1/channels/videos/:ucid"}.each do |route|
|
|
||||||
get route do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
ucid = env.params.url["ucid"]
|
|
||||||
page = env.params.query["page"]?.try &.to_i?
|
|
||||||
page ||= 1
|
|
||||||
sort_by = env.params.query["sort"]?.try &.downcase
|
|
||||||
sort_by ||= env.params.query["sort_by"]?.try &.downcase
|
|
||||||
sort_by ||= "newest"
|
|
||||||
|
|
||||||
begin
|
|
||||||
channel = get_about_info(ucid, locale)
|
|
||||||
rescue ex : ChannelRedirect
|
|
||||||
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
|
||||||
next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by)
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
|
|
||||||
JSON.build do |json|
|
|
||||||
json.array do
|
|
||||||
videos.each do |video|
|
|
||||||
video.to_json(locale, json)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
{"/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"}.each do |route|
|
|
||||||
get route do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
ucid = env.params.url["ucid"]
|
|
||||||
|
|
||||||
begin
|
|
||||||
videos = get_latest_videos(ucid)
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
|
|
||||||
JSON.build do |json|
|
|
||||||
json.array do
|
|
||||||
videos.each do |video|
|
|
||||||
video.to_json(locale, json)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
{"/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"}.each do |route|
|
|
||||||
get route do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
ucid = env.params.url["ucid"]
|
|
||||||
continuation = env.params.query["continuation"]?
|
|
||||||
sort_by = env.params.query["sort"]?.try &.downcase ||
|
|
||||||
env.params.query["sort_by"]?.try &.downcase ||
|
|
||||||
"last"
|
|
||||||
|
|
||||||
begin
|
|
||||||
channel = get_about_info(ucid, locale)
|
|
||||||
rescue ex : ChannelRedirect
|
|
||||||
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
|
||||||
next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
|
|
||||||
items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
|
|
||||||
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
json.field "playlists" do
|
|
||||||
json.array do
|
|
||||||
items.each do |item|
|
|
||||||
item.to_json(locale, json) if item.is_a?(SearchPlaylist)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
json.field "continuation", continuation
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
{"/api/v1/channels/:ucid/comments", "/api/v1/channels/comments/:ucid"}.each do |route|
|
|
||||||
get route do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
ucid = env.params.url["ucid"]
|
|
||||||
|
|
||||||
thin_mode = env.params.query["thin_mode"]?
|
|
||||||
thin_mode = thin_mode == "true"
|
|
||||||
|
|
||||||
format = env.params.query["format"]?
|
|
||||||
format ||= "json"
|
|
||||||
|
|
||||||
continuation = env.params.query["continuation"]?
|
|
||||||
# sort_by = env.params.query["sort_by"]?.try &.downcase
|
|
||||||
|
|
||||||
begin
|
|
||||||
fetch_channel_community(ucid, continuation, locale, format, thin_mode)
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/v1/channels/search/:ucid" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
ucid = env.params.url["ucid"]
|
|
||||||
|
|
||||||
query = env.params.query["q"]?
|
|
||||||
query ||= ""
|
|
||||||
|
|
||||||
page = env.params.query["page"]?.try &.to_i?
|
|
||||||
page ||= 1
|
|
||||||
|
|
||||||
count, search_results = channel_search(query, page, ucid)
|
|
||||||
JSON.build do |json|
|
|
||||||
json.array do
|
|
||||||
search_results.each do |item|
|
|
||||||
item.to_json(locale, json)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/v1/search" do |env|
|
get "/api/v1/search" do |env|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
region = env.params.query["region"]?
|
region = env.params.query["region"]?
|
||||||
|
@ -2377,40 +1702,6 @@ get "/api/v1/search" do |env|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/search/suggestions" do |env|
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
|
||||||
region = env.params.query["region"]?
|
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
|
|
||||||
query = env.params.query["q"]?
|
|
||||||
query ||= ""
|
|
||||||
|
|
||||||
begin
|
|
||||||
headers = HTTP::Headers{":authority" => "suggestqueries.google.com"}
|
|
||||||
response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body
|
|
||||||
|
|
||||||
body = response[35..-2]
|
|
||||||
body = JSON.parse(body).as_a
|
|
||||||
suggestions = body[1].as_a[0..-2]
|
|
||||||
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
json.field "query", body[0].as_s
|
|
||||||
json.field "suggestions" do
|
|
||||||
json.array do
|
|
||||||
suggestions.each do |suggestion|
|
|
||||||
json.string suggestion[0].as_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue ex
|
|
||||||
next error_json(500, ex)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
{"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route|
|
{"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route|
|
||||||
get route do |env|
|
get route do |env|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
|
@ -0,0 +1,267 @@
|
||||||
|
class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute
|
||||||
|
def home(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
ucid = env.params.url["ucid"]
|
||||||
|
sort_by = env.params.query["sort_by"]?.try &.downcase
|
||||||
|
sort_by ||= "newest"
|
||||||
|
|
||||||
|
begin
|
||||||
|
channel = get_about_info(ucid, locale)
|
||||||
|
rescue ex : ChannelRedirect
|
||||||
|
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
||||||
|
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
if channel.auto_generated
|
||||||
|
videos = [] of SearchVideo
|
||||||
|
count = 0
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by)
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.build do |json|
|
||||||
|
# TODO: Refactor into `to_json` for InvidiousChannel
|
||||||
|
json.object do
|
||||||
|
json.field "author", channel.author
|
||||||
|
json.field "authorId", channel.ucid
|
||||||
|
json.field "authorUrl", channel.author_url
|
||||||
|
|
||||||
|
json.field "authorBanners" do
|
||||||
|
json.array do
|
||||||
|
if channel.banner
|
||||||
|
qualities = {
|
||||||
|
{width: 2560, height: 424},
|
||||||
|
{width: 2120, height: 351},
|
||||||
|
{width: 1060, height: 175},
|
||||||
|
}
|
||||||
|
qualities.each do |quality|
|
||||||
|
json.object do
|
||||||
|
json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-")
|
||||||
|
json.field "width", quality[:width]
|
||||||
|
json.field "height", quality[:height]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.object do
|
||||||
|
json.field "url", channel.banner.not_nil!.split("=w1060-")[0]
|
||||||
|
json.field "width", 512
|
||||||
|
json.field "height", 288
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "authorThumbnails" do
|
||||||
|
json.array do
|
||||||
|
qualities = {32, 48, 76, 100, 176, 512}
|
||||||
|
|
||||||
|
qualities.each do |quality|
|
||||||
|
json.object do
|
||||||
|
json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
|
||||||
|
json.field "width", quality
|
||||||
|
json.field "height", quality
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "subCount", channel.sub_count
|
||||||
|
json.field "totalViews", channel.total_views
|
||||||
|
json.field "joined", channel.joined.to_unix
|
||||||
|
json.field "paid", channel.paid
|
||||||
|
|
||||||
|
json.field "autoGenerated", channel.auto_generated
|
||||||
|
json.field "isFamilyFriendly", channel.is_family_friendly
|
||||||
|
json.field "description", html_to_content(channel.description_html)
|
||||||
|
json.field "descriptionHtml", channel.description_html
|
||||||
|
|
||||||
|
json.field "allowedRegions", channel.allowed_regions
|
||||||
|
|
||||||
|
json.field "latestVideos" do
|
||||||
|
json.array do
|
||||||
|
videos.each do |video|
|
||||||
|
video.to_json(locale, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "relatedChannels" do
|
||||||
|
json.array do
|
||||||
|
channel.related_channels.each do |related_channel|
|
||||||
|
json.object do
|
||||||
|
json.field "author", related_channel.author
|
||||||
|
json.field "authorId", related_channel.ucid
|
||||||
|
json.field "authorUrl", related_channel.author_url
|
||||||
|
|
||||||
|
json.field "authorThumbnails" do
|
||||||
|
json.array do
|
||||||
|
qualities = {32, 48, 76, 100, 176, 512}
|
||||||
|
|
||||||
|
qualities.each do |quality|
|
||||||
|
json.object do
|
||||||
|
json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
|
||||||
|
json.field "width", quality
|
||||||
|
json.field "height", quality
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
ucid = env.params.url["ucid"]
|
||||||
|
|
||||||
|
begin
|
||||||
|
videos = get_latest_videos(ucid)
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.build do |json|
|
||||||
|
json.array do
|
||||||
|
videos.each do |video|
|
||||||
|
video.to_json(locale, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def videos(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
ucid = env.params.url["ucid"]
|
||||||
|
page = env.params.query["page"]?.try &.to_i?
|
||||||
|
page ||= 1
|
||||||
|
sort_by = env.params.query["sort"]?.try &.downcase
|
||||||
|
sort_by ||= env.params.query["sort_by"]?.try &.downcase
|
||||||
|
sort_by ||= "newest"
|
||||||
|
|
||||||
|
begin
|
||||||
|
channel = get_about_info(ucid, locale)
|
||||||
|
rescue ex : ChannelRedirect
|
||||||
|
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
||||||
|
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by)
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.build do |json|
|
||||||
|
json.array do
|
||||||
|
videos.each do |video|
|
||||||
|
video.to_json(locale, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def playlists(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
ucid = env.params.url["ucid"]
|
||||||
|
continuation = env.params.query["continuation"]?
|
||||||
|
sort_by = env.params.query["sort"]?.try &.downcase ||
|
||||||
|
env.params.query["sort_by"]?.try &.downcase ||
|
||||||
|
"last"
|
||||||
|
|
||||||
|
begin
|
||||||
|
channel = get_about_info(ucid, locale)
|
||||||
|
rescue ex : ChannelRedirect
|
||||||
|
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
||||||
|
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
|
||||||
|
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "playlists" do
|
||||||
|
json.array do
|
||||||
|
items.each do |item|
|
||||||
|
item.to_json(locale, json) if item.is_a?(SearchPlaylist)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "continuation", continuation
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def community(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
ucid = env.params.url["ucid"]
|
||||||
|
|
||||||
|
thin_mode = env.params.query["thin_mode"]?
|
||||||
|
thin_mode = thin_mode == "true"
|
||||||
|
|
||||||
|
format = env.params.query["format"]?
|
||||||
|
format ||= "json"
|
||||||
|
|
||||||
|
continuation = env.params.query["continuation"]?
|
||||||
|
# sort_by = env.params.query["sort_by"]?.try &.downcase
|
||||||
|
|
||||||
|
begin
|
||||||
|
fetch_channel_community(ucid, continuation, locale, format, thin_mode)
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def channel_search(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
ucid = env.params.url["ucid"]
|
||||||
|
|
||||||
|
query = env.params.query["q"]?
|
||||||
|
query ||= ""
|
||||||
|
|
||||||
|
page = env.params.query["page"]?.try &.to_i?
|
||||||
|
page ||= 1
|
||||||
|
|
||||||
|
count, search_results = channel_search(query, page, ucid)
|
||||||
|
JSON.build do |json|
|
||||||
|
json.array do
|
||||||
|
search_results.each do |item|
|
||||||
|
item.to_json(locale, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,116 @@
|
||||||
|
class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute
|
||||||
|
def comments(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
region = env.params.query["region"]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
id = env.params.url["id"]
|
||||||
|
|
||||||
|
source = env.params.query["source"]?
|
||||||
|
source ||= "youtube"
|
||||||
|
|
||||||
|
thin_mode = env.params.query["thin_mode"]?
|
||||||
|
thin_mode = thin_mode == "true"
|
||||||
|
|
||||||
|
format = env.params.query["format"]?
|
||||||
|
format ||= "json"
|
||||||
|
|
||||||
|
action = env.params.query["action"]?
|
||||||
|
action ||= "action_get_comments"
|
||||||
|
|
||||||
|
continuation = env.params.query["continuation"]?
|
||||||
|
sort_by = env.params.query["sort_by"]?.try &.downcase
|
||||||
|
|
||||||
|
if source == "youtube"
|
||||||
|
sort_by ||= "top"
|
||||||
|
|
||||||
|
begin
|
||||||
|
comments = fetch_youtube_comments(id, PG_DB, continuation, format, locale, thin_mode, region, sort_by: sort_by, action: action)
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
return comments
|
||||||
|
elsif source == "reddit"
|
||||||
|
sort_by ||= "confidence"
|
||||||
|
|
||||||
|
begin
|
||||||
|
comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by)
|
||||||
|
content_html = template_reddit_comments(comments, locale)
|
||||||
|
|
||||||
|
content_html = fill_links(content_html, "https", "www.reddit.com")
|
||||||
|
content_html = replace_links(content_html)
|
||||||
|
rescue ex
|
||||||
|
comments = nil
|
||||||
|
reddit_thread = nil
|
||||||
|
content_html = ""
|
||||||
|
end
|
||||||
|
|
||||||
|
if !reddit_thread || !comments
|
||||||
|
env.response.status_code = 404
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if format == "json"
|
||||||
|
reddit_thread = JSON.parse(reddit_thread.to_json).as_h
|
||||||
|
reddit_thread["comments"] = JSON.parse(comments.to_json)
|
||||||
|
|
||||||
|
return reddit_thread.to_json
|
||||||
|
else
|
||||||
|
response = {
|
||||||
|
"title" => reddit_thread.title,
|
||||||
|
"permalink" => reddit_thread.permalink,
|
||||||
|
"contentHtml" => content_html,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def trending(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
region = env.params.query["region"]?
|
||||||
|
trending_type = env.params.query["type"]?
|
||||||
|
|
||||||
|
begin
|
||||||
|
trending, plid = fetch_trending(trending_type, region, locale)
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
videos = JSON.build do |json|
|
||||||
|
json.array do
|
||||||
|
trending.each do |video|
|
||||||
|
video.to_json(locale, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
videos
|
||||||
|
end
|
||||||
|
|
||||||
|
def popular(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
if !CONFIG.popular_enabled
|
||||||
|
error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
|
||||||
|
env.response.status_code = 400
|
||||||
|
return error_message
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.build do |json|
|
||||||
|
json.array do
|
||||||
|
popular_videos.each do |video|
|
||||||
|
video.to_json(locale, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,13 @@
|
||||||
|
class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute
|
||||||
|
# Stats API endpoint for Invidious
|
||||||
|
def stats(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
if !CONFIG.statistics_enabled
|
||||||
|
return error_json(400, "Statistics are not enabled.")
|
||||||
|
end
|
||||||
|
|
||||||
|
Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,30 @@
|
||||||
|
# There is far too many API routes to define in invidious.cr
|
||||||
|
# so we'll just do it here instead with a macro.
|
||||||
|
macro define_v1_api_routes(base_url = "/api/v1")
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::V1Api, :stats
|
||||||
|
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::V1Api, :storyboards
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::V1Api, :captions
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::V1Api, :annotations
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::V1Api, :search_suggestions
|
||||||
|
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::V1Api, :comments
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::V1Api, :trending
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::V1Api, :popular
|
||||||
|
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::V1Api, :home
|
||||||
|
|
||||||
|
{% for route in {
|
||||||
|
{"home", "home"},
|
||||||
|
{"videos", "videos"},
|
||||||
|
{"latest", "latest"},
|
||||||
|
{"playlists", "playlists"},
|
||||||
|
{"comments", "community"}, # Why is the route for the community API `comments`?,
|
||||||
|
{"search", "channel_search"},
|
||||||
|
} %}
|
||||||
|
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::V1Api, :{{route[1]}}
|
||||||
|
Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::V1Api, :{{route[1]}}
|
||||||
|
|
||||||
|
{% end %}
|
||||||
|
end
|
|
@ -0,0 +1,316 @@
|
||||||
|
class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute
|
||||||
|
# Fetches YouTube storyboards
|
||||||
|
#
|
||||||
|
# Which are sprites containing x * y preview
|
||||||
|
# thumbnails for individual scenes in a video.
|
||||||
|
# See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails
|
||||||
|
def storyboards(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
id = env.params.url["id"]
|
||||||
|
region = env.params.query["region"]?
|
||||||
|
|
||||||
|
begin
|
||||||
|
video = get_video(id, PG_DB, region: region)
|
||||||
|
rescue ex : VideoRedirect
|
||||||
|
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
|
||||||
|
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
|
||||||
|
rescue ex
|
||||||
|
env.response.status_code = 500
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
storyboards = video.storyboards
|
||||||
|
width = env.params.query["width"]?
|
||||||
|
height = env.params.query["height"]?
|
||||||
|
|
||||||
|
if !width && !height
|
||||||
|
response = JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "storyboards" do
|
||||||
|
generate_storyboards(json, id, storyboards)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return response
|
||||||
|
end
|
||||||
|
|
||||||
|
env.response.content_type = "text/vtt"
|
||||||
|
|
||||||
|
storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" }
|
||||||
|
|
||||||
|
if storyboard.empty?
|
||||||
|
env.response.status_code = 404
|
||||||
|
return
|
||||||
|
else
|
||||||
|
storyboard = storyboard[0]
|
||||||
|
end
|
||||||
|
|
||||||
|
String.build do |str|
|
||||||
|
str << <<-END_VTT
|
||||||
|
WEBVTT
|
||||||
|
END_VTT
|
||||||
|
|
||||||
|
start_time = 0.milliseconds
|
||||||
|
end_time = storyboard[:interval].milliseconds
|
||||||
|
|
||||||
|
storyboard[:storyboard_count].times do |i|
|
||||||
|
url = storyboard[:url]
|
||||||
|
authority = /(i\d?).ytimg.com/.match(url).not_nil![1]?
|
||||||
|
url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
|
||||||
|
url = "#{HOST_URL}/sb/#{authority}/#{url}"
|
||||||
|
|
||||||
|
storyboard[:storyboard_height].times do |j|
|
||||||
|
storyboard[:storyboard_width].times do |k|
|
||||||
|
str << <<-END_CUE
|
||||||
|
#{start_time}.000 --> #{end_time}.000
|
||||||
|
#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}
|
||||||
|
|
||||||
|
|
||||||
|
END_CUE
|
||||||
|
|
||||||
|
start_time += storyboard[:interval].milliseconds
|
||||||
|
end_time += storyboard[:interval].milliseconds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def captions(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
id = env.params.url["id"]
|
||||||
|
region = env.params.query["region"]?
|
||||||
|
|
||||||
|
# See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354
|
||||||
|
# It is possible to use `/api/timedtext?type=list&v=#{id}` and
|
||||||
|
# `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly,
|
||||||
|
# but this does not provide links for auto-generated captions.
|
||||||
|
#
|
||||||
|
# In future this should be investigated as an alternative, since it does not require
|
||||||
|
# getting video info.
|
||||||
|
|
||||||
|
begin
|
||||||
|
video = get_video(id, PG_DB, region: region)
|
||||||
|
rescue ex : VideoRedirect
|
||||||
|
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
|
||||||
|
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
|
||||||
|
rescue ex
|
||||||
|
env.response.status_code = 500
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
captions = video.captions
|
||||||
|
|
||||||
|
label = env.params.query["label"]?
|
||||||
|
lang = env.params.query["lang"]?
|
||||||
|
tlang = env.params.query["tlang"]?
|
||||||
|
|
||||||
|
if !label && !lang
|
||||||
|
response = JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "captions" do
|
||||||
|
json.array do
|
||||||
|
captions.each do |caption|
|
||||||
|
json.object do
|
||||||
|
json.field "label", caption.name
|
||||||
|
json.field "languageCode", caption.languageCode
|
||||||
|
json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return response
|
||||||
|
end
|
||||||
|
|
||||||
|
env.response.content_type = "text/vtt; charset=UTF-8"
|
||||||
|
|
||||||
|
if lang
|
||||||
|
caption = captions.select { |caption| caption.languageCode == lang }
|
||||||
|
else
|
||||||
|
caption = captions.select { |caption| caption.name == label }
|
||||||
|
end
|
||||||
|
|
||||||
|
if caption.empty?
|
||||||
|
env.response.status_code = 404
|
||||||
|
return
|
||||||
|
else
|
||||||
|
caption = caption[0]
|
||||||
|
end
|
||||||
|
|
||||||
|
url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target
|
||||||
|
|
||||||
|
# Auto-generated captions often have cues that aren't aligned properly with the video,
|
||||||
|
# as well as some other markup that makes it cumbersome, so we try to fix that here
|
||||||
|
if caption.name.includes? "auto-generated"
|
||||||
|
caption_xml = YT_POOL.client &.get(url).body
|
||||||
|
caption_xml = XML.parse(caption_xml)
|
||||||
|
|
||||||
|
webvtt = String.build do |str|
|
||||||
|
str << <<-END_VTT
|
||||||
|
WEBVTT
|
||||||
|
Kind: captions
|
||||||
|
Language: #{tlang || caption.languageCode}
|
||||||
|
|
||||||
|
|
||||||
|
END_VTT
|
||||||
|
|
||||||
|
caption_nodes = caption_xml.xpath_nodes("//transcript/text")
|
||||||
|
caption_nodes.each_with_index do |node, i|
|
||||||
|
start_time = node["start"].to_f.seconds
|
||||||
|
duration = node["dur"]?.try &.to_f.seconds
|
||||||
|
duration ||= start_time
|
||||||
|
|
||||||
|
if caption_nodes.size > i + 1
|
||||||
|
end_time = caption_nodes[i + 1]["start"].to_f.seconds
|
||||||
|
else
|
||||||
|
end_time = start_time + duration
|
||||||
|
end
|
||||||
|
|
||||||
|
start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}"
|
||||||
|
end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}"
|
||||||
|
|
||||||
|
text = HTML.unescape(node.content)
|
||||||
|
text = text.gsub(/<font color="#[a-fA-F0-9]{6}">/, "")
|
||||||
|
text = text.gsub(/<\/font>/, "")
|
||||||
|
if md = text.match(/(?<name>.*) : (?<text>.*)/)
|
||||||
|
text = "<v #{md["name"]}>#{md["text"]}</v>"
|
||||||
|
end
|
||||||
|
|
||||||
|
str << <<-END_CUE
|
||||||
|
#{start_time} --> #{end_time}
|
||||||
|
#{text}
|
||||||
|
|
||||||
|
|
||||||
|
END_CUE
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
webvtt = YT_POOL.client &.get("#{url}&format=vtt").body
|
||||||
|
end
|
||||||
|
|
||||||
|
if title = env.params.query["title"]?
|
||||||
|
# https://blog.fastmail.com/2011/06/24/download-non-english-filenames/
|
||||||
|
env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
webvtt
|
||||||
|
end
|
||||||
|
|
||||||
|
def annotations(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
|
env.response.content_type = "text/xml"
|
||||||
|
|
||||||
|
id = env.params.url["id"]
|
||||||
|
source = env.params.query["source"]?
|
||||||
|
source ||= "archive"
|
||||||
|
|
||||||
|
if !id.match(/[a-zA-Z0-9_-]{11}/)
|
||||||
|
env.response.status_code = 400
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
annotations = ""
|
||||||
|
|
||||||
|
case source
|
||||||
|
when "archive"
|
||||||
|
if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation))
|
||||||
|
annotations = cached_annotation.annotations
|
||||||
|
else
|
||||||
|
index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0')
|
||||||
|
|
||||||
|
# IA doesn't handle leading hyphens,
|
||||||
|
# so we use https://archive.org/details/youtubeannotations_64
|
||||||
|
if index == "62"
|
||||||
|
index = "64"
|
||||||
|
id = id.sub(/^-/, 'A')
|
||||||
|
end
|
||||||
|
|
||||||
|
file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml")
|
||||||
|
|
||||||
|
location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
|
||||||
|
|
||||||
|
if !location.headers["Location"]?
|
||||||
|
env.response.status_code = location.status_code
|
||||||
|
end
|
||||||
|
|
||||||
|
response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"]))
|
||||||
|
|
||||||
|
if response.body.empty?
|
||||||
|
env.response.status_code = 404
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if response.status_code != 200
|
||||||
|
env.response.status_code = response.status_code
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
annotations = response.body
|
||||||
|
|
||||||
|
cache_annotation(PG_DB, id, annotations)
|
||||||
|
end
|
||||||
|
else # "youtube"
|
||||||
|
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
|
||||||
|
|
||||||
|
if response.status_code != 200
|
||||||
|
env.response.status_code = response.status_code
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
annotations = response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
etag = sha256(annotations)[0, 16]
|
||||||
|
if env.request.headers["If-None-Match"]?.try &.== etag
|
||||||
|
env.response.status_code = 304
|
||||||
|
else
|
||||||
|
env.response.headers["ETag"] = etag
|
||||||
|
annotations
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_suggestions(env)
|
||||||
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
region = env.params.query["region"]?
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
query = env.params.query["q"]?
|
||||||
|
query ||= ""
|
||||||
|
|
||||||
|
begin
|
||||||
|
headers = HTTP::Headers{":authority" => "suggestqueries.google.com"}
|
||||||
|
response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body
|
||||||
|
|
||||||
|
body = response[35..-2]
|
||||||
|
body = JSON.parse(body).as_a
|
||||||
|
suggestions = body[1].as_a[0..-2]
|
||||||
|
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "query", body[0].as_s
|
||||||
|
json.field "suggestions" do
|
||||||
|
json.array do
|
||||||
|
suggestions.each do |suggestion|
|
||||||
|
json.string suggestion[0].as_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue