summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/invidious/channels/channels.cr36
-rw-r--r--src/invidious/channels/community.cr80
-rw-r--r--src/invidious/channels/videos.cr31
-rw-r--r--src/invidious/comments.cr81
-rw-r--r--src/invidious/database/users.cr2
-rw-r--r--src/invidious/hashtag.cr23
-rw-r--r--src/invidious/helpers/serialized_yt_data.cr1
-rw-r--r--src/invidious/helpers/utils.cr53
-rw-r--r--src/invidious/mixes.cr2
-rw-r--r--src/invidious/playlists.cr2
-rw-r--r--src/invidious/routes/api/v1/search.cr28
-rw-r--r--src/invidious/routes/watch.cr6
-rw-r--r--src/invidious/routing.cr1
-rw-r--r--src/invidious/search/processors.cr4
-rw-r--r--src/invidious/search/query.cr23
-rw-r--r--src/invidious/trending.cr21
-rw-r--r--src/invidious/user/imports.cr2
-rw-r--r--src/invidious/videos/description.cr64
-rw-r--r--src/invidious/videos/parser.cr6
-rw-r--r--src/invidious/views/components/channel_info.ecr4
-rw-r--r--src/invidious/views/components/item.ecr10
-rw-r--r--src/invidious/views/feeds/history.ecr2
-rw-r--r--src/invidious/views/watch.ecr4
-rw-r--r--src/invidious/yt_backend/extractors.cr57
-rw-r--r--src/invidious/yt_backend/extractors_utils.cr20
25 files changed, 304 insertions, 259 deletions
diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr
index 63dd2194..c3d6124f 100644
--- a/src/invidious/channels/channels.cr
+++ b/src/invidious/channels/channels.cr
@@ -159,12 +159,18 @@ def fetch_channel(ucid, pull_all_videos : Bool)
LOGGER.debug("fetch_channel: #{ucid}")
LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}")
+ namespaces = {
+ "yt" => "http://www.youtube.com/xml/schemas/2015",
+ "media" => "http://search.yahoo.com/mrss/",
+ "default" => "http://www.w3.org/2005/Atom",
+ }
+
LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed")
rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body
LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed")
- rss = XML.parse_html(rss)
+ rss = XML.parse(rss)
- author = rss.xpath_node(%q(//feed/title))
+ author = rss.xpath_node("//default:feed/default:title", namespaces)
if !author
raise InfoException.new("Deleted or invalid channel")
end
@@ -192,15 +198,23 @@ def fetch_channel(ucid, pull_all_videos : Bool)
videos, continuation = IV::Channel::Tabs.get_videos(channel)
LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed")
- rss.xpath_nodes("//feed/entry").each do |entry|
- video_id = entry.xpath_node("videoid").not_nil!.content
- title = entry.xpath_node("title").not_nil!.content
- published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content)
- updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content)
- author = entry.xpath_node("author/name").not_nil!.content
- ucid = entry.xpath_node("channelid").not_nil!.content
- views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64?
- views ||= 0_i64
+ rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry|
+ video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content
+ title = entry.xpath_node("default:title", namespaces).not_nil!.content
+
+ published = Time.parse_rfc3339(
+ entry.xpath_node("default:published", namespaces).not_nil!.content
+ )
+ updated = Time.parse_rfc3339(
+ entry.xpath_node("default:updated", namespaces).not_nil!.content
+ )
+
+ author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
+ ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
+
+ views = entry
+ .xpath_node("media:group/media:community/media:statistics", namespaces)
+ .try &.["views"]?.try &.to_i64? || 0_i64
channel_video = videos
.select(SearchVideo)
diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr
index ce34ff82..2c7b9fec 100644
--- a/src/invidious/channels/community.cr
+++ b/src/invidious/channels/community.cr
@@ -31,18 +31,16 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
session_token: session_token,
}
- response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req)
- body = JSON.parse(response.body)
+ body = YoutubeAPI.browse(continuation)
- body = body["response"]["continuationContents"]["itemSectionContinuation"]? ||
- body["response"]["continuationContents"]["backstageCommentsContinuation"]?
+ body = body.dig?("continuationContents", "itemSectionContinuation") ||
+ body.dig?("continuationContents", "backstageCommentsContinuation")
if !body
raise InfoException.new("Could not extract continuation.")
end
end
- continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s
posts = body["contents"].as_a
if message = posts[0]["messageRenderer"]?
@@ -125,49 +123,13 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
if attachment = post["backstageAttachment"]?
json.field "attachment" do
- json.object do
- case attachment.as_h
- when .has_key?("videoRenderer")
- attachment = attachment["videoRenderer"]
- json.field "type", "video"
-
- if !attachment["videoId"]?
- error_message = (attachment["title"]["simpleText"]? ||
- attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?)
-
- json.field "error", error_message
- else
- video_id = attachment["videoId"].as_s
-
- video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?
- json.field "title", video_title
- json.field "videoId", video_id
- json.field "videoThumbnails" do
- Invidious::JSONify::APIv1.thumbnails(json, video_id)
- end
-
- json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s)
-
- author_info = attachment["ownerText"]["runs"][0].as_h
-
- json.field "author", author_info["text"].as_s
- json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"]
- json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"]
-
- # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers"
- # TODO: json.field "authorVerified", "ownerBadges"
-
- published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s)
-
- json.field "published", published.to_unix
- json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
-
- view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64
-
- json.field "viewCount", view_count
- json.field "viewCountText", translate_count(locale, "generic_views_count", view_count, NumberFormatting::Short)
- end
- when .has_key?("backstageImageRenderer")
+ case attachment.as_h
+ when .has_key?("videoRenderer")
+ parse_item(attachment)
+ .as(SearchVideo)
+ .to_json(locale, json)
+ when .has_key?("backstageImageRenderer")
+ json.object do
attachment = attachment["backstageImageRenderer"]
json.field "type", "image"
@@ -188,7 +150,9 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
end
end
end
- when .has_key?("pollRenderer")
+ end
+ when .has_key?("pollRenderer")
+ json.object do
attachment = attachment["pollRenderer"]
json.field "type", "poll"
json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0])
@@ -221,7 +185,9 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
end
end
end
- when .has_key?("postMultiImageRenderer")
+ end
+ when .has_key?("postMultiImageRenderer")
+ json.object do
attachment = attachment["postMultiImageRenderer"]
json.field "type", "multiImage"
json.field "images" do
@@ -245,7 +211,13 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
end
end
end
- else
+ end
+ when .has_key?("playlistRenderer")
+ parse_item(attachment)
+ .as(SearchPlaylist)
+ .to_json(locale, json)
+ else
+ json.object do
json.field "type", "unknown"
json.field "error", "Unrecognized attachment type."
end
@@ -270,10 +242,8 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
end
end
end
-
- if body["continuations"]?
- continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s
- json.field "continuation", extract_channel_community_cursor(continuation)
+ if cont = posts.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token")
+ json.field "continuation", extract_channel_community_cursor(cont.as_s)
end
end
end
diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr
index 3d53f2ab..12ed4a7d 100644
--- a/src/invidious/channels/videos.cr
+++ b/src/invidious/channels/videos.cr
@@ -129,38 +129,15 @@ module Invidious::Channel::Tabs
# Shorts
# -------------------
- private def fetch_shorts_data(ucid : String, continuation : String? = nil)
+ def get_shorts(channel : AboutChannel, continuation : String? = nil)
if continuation.nil?
# EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts"
# TODO: try to extract the continuation tokens that allows other sorting options
- return YoutubeAPI.browse(ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D")
+ initial_data = YoutubeAPI.browse(channel.ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D")
else
- return YoutubeAPI.browse(continuation: continuation)
- end
- end
-
- def get_shorts(channel : AboutChannel, continuation : String? = nil)
- initial_data = self.fetch_shorts_data(channel.ucid, continuation)
-
- begin
- # Try to parse the initial data fetched above
- return extract_items(initial_data, channel.author, channel.ucid)
- rescue ex : RetryOnceException
- # Sometimes, for a completely unknown reason, the "reelItemRenderer"
- # object is missing some critical information (it happens once in about
- # 20 subsequent requests). Refreshing the page is required to properly
- # show the "shorts" tab.
- #
- # In order to make the experience smoother for the user, we simulate
- # said page refresh by fetching again the JSON. If that still doesn't
- # work, we raise a BrokenTubeException, as something is really broken.
- begin
- initial_data = self.fetch_shorts_data(channel.ucid, continuation)
- return extract_items(initial_data, channel.author, channel.ucid)
- rescue ex : RetryOnceException
- raise BrokenTubeException.new "reelPlayerHeaderSupportedRenderers"
- end
+ initial_data = YoutubeAPI.browse(continuation: continuation)
end
+ return extract_items(initial_data, channel.author, channel.ucid)
end
# -------------------
diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index b15d63d4..01556099 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -346,7 +346,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
html << <<-END_HTML
<div class="pure-g" style="width:100%">
<div class="channel-profile pure-u-4-24 pure-u-md-2-24">
- <img loading="lazy" style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}">
+ <img loading="lazy" style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}" alt="" />
</div>
<div class="pure-u-20-24 pure-u-md-22-24">
<p>
@@ -367,37 +367,30 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
html << <<-END_HTML
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
- <img loading="lazy" style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}">
+ <img loading="lazy" style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}" alt="" />
</div>
</div>
END_HTML
when "video"
- html << <<-END_HTML
- <div class="pure-g">
- <div class="pure-u-1 pure-u-md-1-2">
- <div style="position:relative;width:100%;height:0;padding-bottom:56.25%;margin-bottom:5px">
- END_HTML
-
if attachment["error"]?
html << <<-END_HTML
+ <div class="pure-g video-iframe-wrapper">
<p>#{attachment["error"]}</p>
+ </div>
END_HTML
else
html << <<-END_HTML
- <iframe id='ivplayer' style='position:absolute;width:100%;height:100%;left:0;top:0' src='/embed/#{attachment["videoId"]?}?autoplay=0' style='border:none;'></iframe>
+ <div class="pure-g video-iframe-wrapper">
+ <iframe class="video-iframe" src='/embed/#{attachment["videoId"]?}?autoplay=0'></iframe>
+ </div>
END_HTML
end
-
- html << <<-END_HTML
- </div>
- </div>
- </div>
- END_HTML
else nil # Ignore
end
end
html << <<-END_HTML
+ <p>
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
|
END_HTML
@@ -416,6 +409,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
html << <<-END_HTML
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
+ </p>
END_HTML
if child["creatorHeart"]?
@@ -428,7 +422,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
html << <<-END_HTML
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
<div class="creator-heart">
- <img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
+ <img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}" alt="" />
<div class="creator-heart-small-hearted">
<div class="icon ion-ios-heart creator-heart-small-container"></div>
</div>
@@ -604,7 +598,7 @@ def text_to_parsed_content(text : String) : JSON::Any
currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}}
currentNodes << (JSON.parse(currentNode.to_json))
# If text remain after match create new simple node with text after match
- afterNode = {"text" => splittedLastNode.size > 0 ? splittedLastNode[1] : ""}
+ afterNode = {"text" => splittedLastNode.size > 1 ? splittedLastNode[1] : ""}
currentNodes << (JSON.parse(afterNode.to_json))
end
@@ -635,55 +629,8 @@ def content_to_comment_html(content, video_id : String? = "")
text = HTML.escape(run["text"].as_s)
- if run["navigationEndpoint"]?
- if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s
- url = URI.parse(url)
- displayed_url = text
-
- if url.host == "youtu.be"
- url = "/watch?v=#{url.request_target.lstrip('/')}"
- elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com")
- if url.path == "/redirect"
- # Sometimes, links can be corrupted (why?) so make sure to fallback
- # nicely. See https://github.com/iv-org/invidious/issues/2682
- url = url.query_params["q"]? || ""
- displayed_url = url
- else
- url = url.request_target
- displayed_url = "youtube.com#{url}"
- end
- end
-
- text = %(<a href="#{url}">#{reduce_uri(displayed_url)}</a>)
- elsif watch_endpoint = run["navigationEndpoint"]["watchEndpoint"]?
- start_time = watch_endpoint["startTimeSeconds"]?.try &.as_i
- link_video_id = watch_endpoint["videoId"].as_s
-
- url = "/watch?v=#{link_video_id}"
- url += "&t=#{start_time}" if !start_time.nil?
-
- # If the current video ID (passed through from the caller function)
- # is the same as the video ID in the link, add HTML attributes for
- # the JS handler function that bypasses page reload.
- #
- # See: https://github.com/iv-org/invidious/issues/3063
- if link_video_id == video_id
- start_time ||= 0
- text = %(<a href="#{url}" data-onclick="jump_to_time" data-jump-time="#{start_time}">#{reduce_uri(text)}</a>)
- else
- text = %(<a href="#{url}">#{text}</a>)
- end
- elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s
- if text.starts_with?(/\s?[@#]/)
- # Handle "pings" in comments and hasthags differently
- # See:
- # - https://github.com/iv-org/invidious/issues/3038
- # - https://github.com/iv-org/invidious/issues/3062
- text = %(<a href="#{url}">#{text}</a>)
- else
- text = %(<a href="#{url}">#{reduce_uri(url)}</a>)
- end
- end
+ if navigationEndpoint = run.dig?("navigationEndpoint")
+ text = parse_link_endpoint(navigationEndpoint, text, video_id)
end
text = "<b>#{text}</b>" if run["bold"]?
@@ -702,7 +649,7 @@ def content_to_comment_html(content, video_id : String? = "")
str << %(title=") << emojiAlt << "\" "
str << %(width=") << emojiThumb["width"] << "\" "
str << %(height=") << emojiThumb["height"] << "\" "
- str << %(class="channel-emoji"/>)
+ str << %(class="channel-emoji" />)
end
else
# Hide deleted channel emoji
diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr
index 0a4a4fd8..d54e6a76 100644
--- a/src/invidious/database/users.cr
+++ b/src/invidious/database/users.cr
@@ -52,7 +52,7 @@ module Invidious::Database::Users
def mark_watched(user : User, vid : String)
request = <<-SQL
UPDATE users
- SET watched = array_append(watched, $1)
+ SET watched = array_append(array_remove(watched, $1), $1)
WHERE email = $2
SQL
diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr
index bc329205..d9d584c9 100644
--- a/src/invidious/hashtag.cr
+++ b/src/invidious/hashtag.cr
@@ -17,21 +17,18 @@ module Invidious::Hashtag
"80226972:embedded" => {
"2:string" => "FEhashtag",
"3:base64" => {
- "1:varint" => cursor.to_i64,
- },
- "7:base64" => {
- "325477796:embedded" => {
- "1:embedded" => {
- "2:0:embedded" => {
- "2:string" => '#' + hashtag,
- "4:varint" => 0_i64,
- "11:string" => "",
- },
- "4:string" => "browse-feedFEhashtag",
- },
- "2:string" => hashtag,
+ "1:varint" => 60_i64, # result count
+ "15:base64" => {
+ "1:varint" => cursor.to_i64,
+ "2:varint" => 0_i64,
+ },
+ "93:2:embedded" => {
+ "1:string" => hashtag,
+ "2:varint" => 0_i64,
+ "3:varint" => 1_i64,
},
},
+ "35:string" => "browse-feedFEhashtag",
},
}
diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr
index c1874780..7c12ad0e 100644
--- a/src/invidious/helpers/serialized_yt_data.cr
+++ b/src/invidious/helpers/serialized_yt_data.cr
@@ -84,6 +84,7 @@ struct SearchVideo
json.field "descriptionHtml", self.description_html
json.field "viewCount", self.views
+ json.field "viewCountText", translate_count(locale, "generic_views_count", self.views, NumberFormatting::Short)
json.field "published", self.published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
json.field "lengthSeconds", self.length_seconds
diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr
index 500a2582..bcf7c963 100644
--- a/src/invidious/helpers/utils.cr
+++ b/src/invidious/helpers/utils.cr
@@ -389,3 +389,56 @@ def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "
end
return str
end
+
+# Get the html link from a NavigationEndpoint or an innertubeCommand
+def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
+ if url = endpoint.dig?("urlEndpoint", "url").try &.as_s
+ url = URI.parse(url)
+ displayed_url = text
+
+ if url.host == "youtu.be"
+ url = "/watch?v=#{url.request_target.lstrip('/')}"
+ elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com")
+ if url.path == "/redirect"
+ # Sometimes, links can be corrupted (why?) so make sure to fallback
+ # nicely. See https://github.com/iv-org/invidious/issues/2682
+ url = url.query_params["q"]? || ""
+ displayed_url = url
+ else
+ url = url.request_target
+ displayed_url = "youtube.com#{url}"
+ end
+ end
+
+ text = %(<a href="#{url}">#{reduce_uri(displayed_url)}</a>)
+ elsif watch_endpoint = endpoint.dig?("watchEndpoint")
+ start_time = watch_endpoint["startTimeSeconds"]?.try &.as_i
+ link_video_id = watch_endpoint["videoId"].as_s
+
+ url = "/watch?v=#{link_video_id}"
+ url += "&t=#{start_time}" if !start_time.nil?
+
+ # If the current video ID (passed through from the caller function)
+ # is the same as the video ID in the link, add HTML attributes for
+ # the JS handler function that bypasses page reload.
+ #
+ # See: https://github.com/iv-org/invidious/issues/3063
+ if link_video_id == video_id
+ start_time ||= 0
+ text = %(<a href="#{url}" data-onclick="jump_to_time" data-jump-time="#{start_time}">#{reduce_uri(text)}</a>)
+ else
+ text = %(<a href="#{url}">#{text}</a>)
+ end
+ elsif url = endpoint.dig?("commandMetadata", "webCommandMetadata", "url").try &.as_s
+ if text.starts_with?(/\s?[@#]/)
+ # Handle "pings" in comments and hasthags differently
+ # See:
+ # - https://github.com/iv-org/invidious/issues/3038
+ # - https://github.com/iv-org/invidious/issues/3062
+ text = %(<a href="#{url}">#{text}</a>)
+ else
+ text = %(<a href="#{url}">#{reduce_uri(url)}</a>)
+ end
+ end
+ return text
+end
diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr
index 3f342b92..823ca85b 100644
--- a/src/invidious/mixes.cr
+++ b/src/invidious/mixes.cr
@@ -97,7 +97,7 @@ def template_mix(mix)
<li class="pure-menu-item">
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
+ <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
</div>
<p style="width:100%">#{video["title"]}</p>
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index 57f1f53e..013be268 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -507,7 +507,7 @@ def template_playlist(playlist)
<li class="pure-menu-item" id="#{video["videoId"]}">
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
+ <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
</div>
<p style="width:100%">#{video["title"]}</p>
diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr
index 21451d33..9fb283c2 100644
--- a/src/invidious/routes/api/v1/search.cr
+++ b/src/invidious/routes/api/v1/search.cr
@@ -55,4 +55,32 @@ module Invidious::Routes::API::V1::Search
return error_json(500, ex)
end
end
+
+ def self.hashtag(env)
+ hashtag = env.params.url["hashtag"]
+
+ page = env.params.query["page"]?.try &.to_i? || 1
+
+ locale = env.get("preferences").as(Preferences).locale
+ region = env.params.query["region"]?
+ env.response.content_type = "application/json"
+
+ begin
+ results = Invidious::Hashtag.fetch(hashtag, page, region)
+ rescue ex
+ return error_json(400, ex)
+ end
+
+ JSON.build do |json|
+ json.object do
+ json.field "results" do
+ json.array do
+ results.each do |item|
+ item.to_json(locale, json)
+ end
+ end
+ end
+ end
+ end
+ end
end
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index 5d3845c3..813cb0f4 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -76,7 +76,7 @@ module Invidious::Routes::Watch
end
env.params.query.delete_all("iv_load_policy")
- if watched && preferences.watch_history && !watched.includes? id
+ if watched && preferences.watch_history
Invidious::Database::Users.mark_watched(user.as(User), id)
end
@@ -259,9 +259,7 @@ module Invidious::Routes::Watch
case action
when "action_mark_watched"
- if !user.watched.includes? id
- Invidious::Database::Users.mark_watched(user, id)
- end
+ Invidious::Database::Users.mark_watched(user, id)
when "action_mark_unwatched"
Invidious::Database::Users.mark_unwatched(user, id)
else
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index 9e2ade3d..72ee9194 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -243,6 +243,7 @@ module Invidious::Routing
# Search
get "/api/v1/search", {{namespace}}::Search, :search
get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions
+ get "/api/v1/hashtag/:hashtag", {{namespace}}::Search, :hashtag
# Authenticated
diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr
index 7e909590..25edb936 100644
--- a/src/invidious/search/processors.cr
+++ b/src/invidious/search/processors.cr
@@ -10,7 +10,7 @@ module Invidious::Search
initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config)
items, _ = extract_items(initial_data)
- return items
+ return items.reject!(Category)
end
# Search a youtube channel
@@ -32,7 +32,7 @@ module Invidious::Search
response_json = YoutubeAPI.browse(continuation)
items, _ = extract_items(response_json, "", ucid)
- return items
+ return items.reject!(Category)
end
# Search inside of user subscriptions
diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr
index 24e79609..e38845d9 100644
--- a/src/invidious/search/query.cr
+++ b/src/invidious/search/query.cr
@@ -113,7 +113,7 @@ module Invidious::Search
case @type
when .regular?, .playlist?
- items = unnest_items(Processors.regular(self))
+ items = Processors.regular(self)
#
when .channel?
items = Processors.channel(self)
@@ -136,26 +136,5 @@ module Invidious::Search
return params
end
-
- # TODO: clean code
- private def unnest_items(all_items) : Array(SearchItem)
- items = [] of SearchItem
-
- # Light processing to flatten search results out of Categories.
- # They should ideally be supported in the future.
- all_items.each do |i|
- if i.is_a? Category
- i.contents.each do |nest_i|
- if !nest_i.is_a? Video
- items << nest_i
- end
- end
- else
- items << i
- end
- end
-
- return items
- end
end
end
diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr
index 134eb437..2d9f8a83 100644
--- a/src/invidious/trending.cr
+++ b/src/invidious/trending.cr
@@ -17,7 +17,24 @@ def fetch_trending(trending_type, region, locale)
client_config = YoutubeAPI::ClientConfig.new(region: region)
initial_data = YoutubeAPI.browse("FEtrending", params: params, client_config: client_config)
- trending = extract_videos(initial_data)
- return {trending, plid}
+ items, _ = extract_items(initial_data)
+
+ extracted = [] of SearchItem
+
+ items.each do |itm|
+ if itm.is_a?(Category)
+ # Ignore the smaller categories, as they generally contain a sponsored
+ # channel, which brings a lot of noise on the trending page.
+ # See: https://github.com/iv-org/invidious/issues/2989
+ next if itm.contents.size < 24
+
+ extracted.concat extract_category(itm)
+ else
+ extracted << itm
+ end
+ end
+
+ # Deduplicate items before returning results
+ return extracted.select(SearchVideo).uniq!(&.id), plid
end
diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr
index 673991f7..e4b25156 100644
--- a/src/invidious/user/imports.cr
+++ b/src/invidious/user/imports.cr
@@ -102,7 +102,7 @@ struct Invidious::User
if data["watch_history"]?
user.watched += data["watch_history"].as_a.map(&.as_s)
- user.watched.uniq!
+ user.watched.reverse!.uniq!.reverse!
Invidious::Database::Users.update_watch_history(user)
end
diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr
new file mode 100644
index 00000000..542cb416
--- /dev/null
+++ b/src/invidious/videos/description.cr
@@ -0,0 +1,64 @@
+require "json"
+require "uri"
+
+private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int
+ copied = 0
+ while copied < count
+ cp = iter.next
+ break if cp.is_a?(Iterator::Stop)
+
+ str << cp.chr
+
+ # A codepoint from the SMP counts twice
+ copied += 1 if cp > 0xFFFF
+ copied += 1
+ end
+
+ return copied
+end
+
+def parse_description(desc, video_id : String) : String?
+ return "" if desc.nil?
+
+ content = desc["content"].as_s
+ return "" if content.empty?
+
+ commands = desc["commandRuns"]?.try &.as_a
+ return content if commands.nil?
+
+ # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
+ # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
+ # automatically decoded by the JSON parser. It means that we need to count
+ # copied byte in a special manner, preventing the use of regular string copy.
+ iter = content.each_codepoint
+
+ index = 0
+
+ return String.build do |str|
+ commands.each do |command|
+ cmd_start = command["startIndex"].as_i
+ cmd_length = command["length"].as_i
+
+ # Copy the text chunk between this command and the previous if needed.
+ length = cmd_start - index
+ index += copy_string(str, iter, length)
+
+ # We need to copy the command's text using the iterator
+ # and the special function defined above.
+ cmd_content = String.build(cmd_length) do |str2|
+ copy_string(str2, iter, cmd_length)
+ end
+
+ link = cmd_content
+ if on_tap = command.dig?("onTap", "innertubeCommand")
+ link = parse_link_endpoint(on_tap, cmd_content, video_id)
+ end
+ str << link
+ index += cmd_length
+ end
+
+ # Copy the end of the string (past the last command).
+ remaining_length = content.size - index
+ copy_string(str, iter, remaining_length) if remaining_length > 0
+ end
+end
diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr
index 13ee5f65..2e8eecc3 100644
--- a/src/invidious/videos/parser.cr
+++ b/src/invidious/videos/parser.cr
@@ -284,8 +284,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
description = microformat.dig?("description", "simpleText").try &.as_s || ""
short_description = player_response.dig?("videoDetails", "shortDescription")
- description_html = video_secondary_renderer.try &.dig?("description", "runs")
- .try &.as_a.try { |t| content_to_comment_html(t, video_id) }
+ # description_html = video_secondary_renderer.try &.dig?("description", "runs")
+ # .try &.as_a.try { |t| content_to_comment_html(t, video_id) }
+
+ description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), video_id)
# Video metadata
diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr
index f216359f..59888760 100644
--- a/src/invidious/views/components/channel_info.ecr
+++ b/src/invidious/views/components/channel_info.ecr
@@ -1,6 +1,6 @@
<% if channel.banner %>
<div class="h-box">
- <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>">
+ <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>" alt="" />
</div>
<div class="h-box">
@@ -11,7 +11,7 @@
<div class="pure-g h-box">
<div class="pure-u-2-3">
<div class="channel-profile">
- <img src="/ggpht<%= channel_profile_pic %>">
+ <img src="/ggpht<%= channel_profile_pic %>" alt="" />
<span><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
</div>
</div>
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index fa12374f..7cfd38db 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -7,7 +7,7 @@
<a href="/channel/<%= item.ucid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<center>
- <img loading="lazy" tabindex="-1" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
+ <img loading="lazy" tabindex="-1" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" />
</center>
<% end %>
<p dir="auto"><%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></p>
@@ -25,7 +25,7 @@
<a style="width:100%" href="<%= url %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>" alt="" />
<p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
</div>
<% end %>
@@ -38,7 +38,7 @@
<a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
<% if item.length_seconds != 0 %>
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
<% end %>
@@ -58,7 +58,7 @@
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
<% if plid_form = env.get?("remove_playlist_items") %>
<form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post">
@@ -112,7 +112,7 @@
<a style="width:100%" href="/watch?v=<%= item.id %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
<% if env.get? "show_watched" %>
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr
index 471d21db..2234b297 100644
--- a/src/invidious/views/feeds/history.ecr
+++ b/src/invidious/views/feeds/history.ecr
@@ -34,7 +34,7 @@
<a style="width:100%" href="/watch?v=<%= item %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg"/>
+ <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" />
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched">
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index a3ec94e8..5b3190f3 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -208,7 +208,7 @@ we're going to need to do it here in order to allow for translations.
<a href="/channel/<%= video.ucid %>" style="display:block;width:fit-content;width:-moz-fit-content">
<div class="channel-profile">
<% if !video.author_thumbnail.empty? %>
- <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>">
+ <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" />
<% end %>
<span id="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></span>
</div>
@@ -298,7 +298,7 @@ we're going to need to do it here in order to allow for translations.
<a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg">
+ <img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg" alt="" />
<p class="length"><%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %></p>
</div>
<% end %>
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
index 1a37d606..8ff4c1f9 100644
--- a/src/invidious/yt_backend/extractors.cr
+++ b/src/invidious/yt_backend/extractors.cr
@@ -268,7 +268,7 @@ private module Parsers
end
private def self.parse(item_contents, author_fallback)
- title = item_contents["title"]["simpleText"]?.try &.as_s || ""
+ title = extract_text(item_contents["title"]) || ""
plid = item_contents["playlistId"]?.try &.as_s || ""
video_count = HelperExtractors.get_video_count(item_contents)
@@ -448,44 +448,43 @@ private module Parsers
"overlay", "reelPlayerOverlayRenderer"
)
- # Sometimes, the "reelPlayerOverlayRenderer" object is missing the
- # important part of the response. We use this exception to tell
- # the calling function to fetch the content again.
- if !reel_player_overlay.as_h.has_key?("reelPlayerHeaderSupportedRenderers")
- raise RetryOnceException.new
- end
-
- video_details_container = reel_player_overlay.dig(
- "reelPlayerHeaderSupportedRenderers",
- "reelPlayerHeaderRenderer"
- )
-
- # Author infos
+ if video_details_container = reel_player_overlay.dig?(
+ "reelPlayerHeaderSupportedRenderers",
+ "reelPlayerHeaderRenderer"
+ )
+ # Author infos
- author = video_details_container
- .dig?("channelTitleText", "runs", 0, "text")
- .try &.as_s || author_fallback.name
+ author = video_details_container
+ .dig?("channelTitleText", "runs", 0, "text")
+ .try &.as_s || author_fallback.name
- ucid = video_details_container
- .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId")
- .try &.as_s || author_fallback.id
+ ucid = video_details_container
+ .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId")
+ .try &.as_s || author_fallback.id
- # Title & publication date
+ # Title & publication date
- title = video_details_container.dig?("reelTitleText")
- .try { |t| extract_text(t) } || ""
+ title = video_details_container.dig?("reelTitleText")
+ .try { |t| extract_text(t) } || ""
- published = video_details_container
- .dig?("timestampText", "simpleText")
- .try { |t| decode_date(t.as_s) } || Time.utc
+ published = video_details_container
+ .dig?("timestampText", "simpleText")
+ .try { |t| decode_date(t.as_s) } || Time.utc
+ # View count
+ view_count_text = video_details_container.dig?("viewCountText", "simpleText")
+ else
+ author = author_fallback.name
+ ucid = author_fallback.id
+ published = Time.utc
+ title = item_contents.dig?("headline", "simpleText").try &.as_s || ""
+ end
# View count
# View count used to be in the reelWatchEndpoint, but that changed?
- view_count_text = item_contents.dig?("viewCountText", "simpleText")
- view_count_text ||= video_details_container.dig?("viewCountText", "simpleText")
+ view_count_text ||= item_contents.dig?("viewCountText", "simpleText")
- view_count = view_count_text.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64
+ view_count = short_text_to_number(view_count_text.try &.as_s || "0")
# Duration
diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr
index 0cb3c079..11d95958 100644
--- a/src/invidious/yt_backend/extractors_utils.cr
+++ b/src/invidious/yt_backend/extractors_utils.cr
@@ -68,19 +68,17 @@ rescue ex
return false
end
-def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) : Array(SearchVideo)
- extracted, _ = extract_items(initial_data, author_fallback, author_id_fallback)
+# This function extracts SearchVideo items from a Category.
+# Categories are commonly returned in search results and trending pages.
+def extract_category(category : Category) : Array(SearchVideo)
+ return category.contents.select(SearchVideo)
+end
- target = [] of (SearchItem | Continuation)
- extracted.each do |i|
- if i.is_a?(Category)
- i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video }
- else
- target << i
- end
+# :ditto:
+def extract_category(category : Category, &)
+ category.contents.select(SearchVideo).each do |item|
+ yield item
end
-
- return target.select(SearchVideo)
end
def extract_selected_tab(tabs)