diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index c3d6124f..41212b64 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -50,46 +50,6 @@ struct ChannelVideo end end - def to_xml(locale, query_params, xml : XML::Builder) - query_params["v"] = self.id - - xml.element("entry") do - xml.element("id") { xml.text "yt:video:#{self.id}" } - xml.element("yt:videoId") { xml.text self.id } - xml.element("yt:channelId") { xml.text self.ucid } - xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") - - xml.element("author") do - xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } - end - - xml.element("content", type: "xhtml") do - xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do - xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") - end - end - end - - xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } - xml.element("updated") { xml.text self.updated.to_s("%Y-%m-%dT%H:%M:%S%:z") } - - xml.element("media:group") do - xml.element("media:title") { xml.text self.title } - xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", - width: "320", height: "180") - end - end - end - - def to_xml(locale, _xml : Nil = nil) - XML.build do |xml| - to_xml(locale, xml) - end - end - def to_tuple {% begin %} { diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 7c12ad0e..cb5accea 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -14,57 +14,6 @@ struct SearchVideo property premiere_timestamp : Time? property author_verified : Bool - def to_xml(auto_generated, query_params, xml : XML::Builder) - query_params["v"] = self.id - - xml.element("entry") do - xml.element("id") { xml.text "yt:video:#{self.id}" } - xml.element("yt:videoId") { xml.text self.id } - xml.element("yt:channelId") { xml.text self.ucid } - xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") - - xml.element("author") do - if auto_generated - xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } - else - xml.element("name") { xml.text author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } - end - end - - xml.element("content", type: "xhtml") do - xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do - xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") - end - - xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) } - end - end - - xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } - - xml.element("media:group") do - xml.element("media:title") { xml.text self.title } - xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", - width: "320", height: "180") - xml.element("media:description") { xml.text html_to_content(self.description_html) } - end - - xml.element("media:community") do - xml.element("media:statistics", views: self.views) - end - end - end - - def to_xml(auto_generated, query_params, _xml : Nil) - XML.build do |xml| - to_xml(auto_generated, query_params, xml) - end - end - def to_json(locale : String?, json : JSON::Builder) json.object do json.field "type", "video" diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 013be268..753d2945 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -11,41 +11,6 @@ struct PlaylistVideo property index : Int64 property live_now : Bool - def to_xml(xml : XML::Builder) - xml.element("entry") do - xml.element("id") { xml.text "yt:video:#{self.id}" } - xml.element("yt:videoId") { xml.text self.id } - xml.element("yt:channelId") { xml.text self.ucid } - xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?v=#{self.id}") - - xml.element("author") do - xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } - end - - xml.element("content", type: "xhtml") do - xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{HOST_URL}/watch?v=#{self.id}") do - xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") - end - end - end - - xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } - - xml.element("media:group") do - xml.element("media:title") { xml.text self.title } - xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", - width: "320", height: "180") - end - end - end - - def to_xml(_xml : Nil = nil) - XML.build { |xml| to_xml(xml) } - end - def to_json(json : JSON::Builder, index : Int32? = nil) json.object do json.field "title", self.title diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index fb482e33..c6f2b4fe 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -146,8 +146,6 @@ module Invidious::Routes::Feeds ucid = env.params.url["ucid"] - params = HTTP::Params.parse(env.params.query["params"]? || "") - begin channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect @@ -161,12 +159,13 @@ module Invidious::Routes::Feeds response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") rss = XML.parse_html(response.body) + updated = [] of String videos = rss.xpath_nodes("//feed/entry").map 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) + updated << entry.xpath_node("updated").not_nil!.content author = entry.xpath_node("author/name").not_nil!.content ucid = entry.xpath_node("channelid").not_nil!.content @@ -190,33 +189,27 @@ module Invidious::Routes::Feeds }) end - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", - "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", - "xml:lang": "en-US") do - xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") - xml.element("id") { xml.text "yt:channel:#{channel.ucid}" } - xml.element("yt:channelId") { xml.text channel.ucid } - xml.element("icon") { xml.text channel.author_thumbnail } - xml.element("title") { xml.text channel.author } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") + feed_created = rss.xpath_node("//feed/published").not_nil!.content + feed_updated = updated.size > 0 ? updated[0] : feed_created - xml.element("author") do - xml.element("name") { xml.text channel.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } - end + # Generate Atom feed - xml.element("image") do - xml.element("url") { xml.text channel.author_thumbnail } - xml.element("title") { xml.text channel.author } - xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") - end + properties = Invidious::RssAtom::AtomProperties.new( + title: "", # Use channel author name + icon_url: channel.author_thumbnail, + author: channel.author, + author_url: "#{HOST_URL}/channel/#{channel.ucid}", + date_updated: feed_updated, + date_published: feed_created, + alt_links: [{ + type: "text/html", + url: "#{HOST_URL}/channel/#{channel.ucid}", + }] + ) - videos.each do |video| - video.to_xml(channel.auto_generated, params, xml) - end - end - end + return Invidious::RssAtom.atom_feed_builder( + env, videos, "channel/#{channel.ucid}", properties + ) end def self.rss_private(env) @@ -247,20 +240,20 @@ module Invidious::Routes::Feeds videos, notifications = get_subscription_feed(user, max_results, page) - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", - "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", - "xml:lang": "en-US") do - xml.element("link", "type": "text/html", rel: "alternate", href: "#{HOST_URL}/feed/subscriptions") - xml.element("link", "type": "application/atom+xml", rel: "self", - href: "#{HOST_URL}#{env.request.resource}") - xml.element("title") { xml.text translate(locale, "Invidious Private Feed for `x`", user.email) } + # Generate Atom feed - (notifications + videos).each do |video| - video.to_xml(locale, params, xml) - end - end - end + properties = Invidious::RssAtom::AtomProperties.new( + title: translate(locale, "Invidious Private Feed for `x`", user.email), + author: user.email, + alt_links: [{ + type: "text/html", + url: "#{HOST_URL}/feed/subscriptions", + }] + ) + + return Invidious::RssAtom.atom_feed_builder( + env, (notifications + videos), "subscriptions/#{user.email}", properties + ) end def self.rss_playlist(env) @@ -278,23 +271,20 @@ module Invidious::Routes::Feeds if playlist = Invidious::Database::Playlists.select(id: plid) videos = get_playlist_videos(playlist, offset: 0) - return XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", - "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", - "xml:lang": "en-US") do - xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") - xml.element("id") { xml.text "iv:playlist:#{plid}" } - xml.element("iv:playlistId") { xml.text plid } - xml.element("title") { xml.text playlist.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/playlist?list=#{plid}") + # Generate Atom feed - xml.element("author") do - xml.element("name") { xml.text playlist.author } - end + properties = Invidious::RssAtom::AtomProperties.new( + title: playlist.title, + author: playlist.author, + alt_links: [{ + type: "text/html", + url: "#{HOST_URL}/playlist?list=#{plid}", + }] + ) - videos.each &.to_xml(xml) - end - end + return Invidious::RssAtom.atom_feed_builder( + env, videos, "playlist/#{plid}", properties + ) else haltf env, status_code: 404 end diff --git a/src/invidious/rss_atom.cr b/src/invidious/rss_atom.cr new file mode 100644 index 00000000..2f1e44ea --- /dev/null +++ b/src/invidious/rss_atom.cr @@ -0,0 +1,169 @@ +require "xml" +require "http/server" + +module Invidious::RssAtom + extend self + + # TODO: Merge all of those in a single type + alias AnyVideo = SearchVideo | ChannelVideo | PlaylistVideo + + # + # Feed properties structure + # + + alias AltLink = NamedTuple(type: String, url: String) + + struct AtomProperties + getter title : String + getter icon_url : String + getter author : String + getter author_url : String + + getter date_published : String + getter date_updated : String + + getter alt_links : Array(AltLink) + + def initialize( + *, # All parameters must be named + @title = "", @icon_url = "", + @author = "", @author_url = "", + date_updated : Time | String = Time.utc, + date_published : Time | String = "", + @alt_links = [] of AltLink + ) + # Convert publication date if needed + if date_published.is_a?(Time) + @date_published = date_published.to_rfc3339 + else + @date_published = date_published + end + + # Convert update date if needed + if date_updated.is_a?(Time) + @date_updated = date_updated.to_rfc3339 + else + @date_updated = date_updated + end + end + end + + # + # Atom Feed builder + # + + def atom_feed_builder( + # Mandatory parameters + env : HTTP::Server::Context, + videos : Array(AnyVideo), + id : String, + properties : AtomProperties + ) + return XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("feed", + xmlns: "http://www.w3.org/2005/Atom", + "xmlns:media": "http://search.yahoo.com/mrss/", + "xml:lang": "en-US" + ) do + # The id must be unique, and an IANA-approved IRI, so use "ni://" + # Relevant RFC documents: + # - https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.6 + # - https://datatracker.ietf.org/doc/html/rfc6920 + # + xml.element("id") { xml.text "ni://invidious/sha-256;" + sha256(id) } + + # Feed title. Use author name if no title was provided + xml.element("title") do + xml.text(properties.title.empty? ? properties.author : properties.title) + end + + if !properties.icon_url.empty? + icon_url = "#{HOST_URL}/gghpt/#{URI.parse(properties.icon_url).request_target}" + xml.element("icon") { xml.text icon_url } + xml.element("logo") { xml.text icon_url } + end + + # Feed creation (if available) and update (mandatory) dates + if !properties.date_published.empty? + xml.element("published") { xml.text properties.date_published } + end + + xml.element("updated") { xml.text properties.date_updated } + + # Links + xml.element("link", rel: "self", + type: "application/atom+xml", + href: "#{HOST_URL}#{env.request.resource}" + ) + + properties.alt_links.each do |link| + xml.element("link", rel: "alternate", type: link[:type], href: link[:url]) + end + + # Author infos + xml.element("author") do + xml.element("name") { xml.text properties.author } + xml.element("uri") { xml.text properties.author_url } if !properties.author_url.empty? + end + + # Video entries + videos.each do |video| + xml.element("entry") { atom_video(xml, video, env.params.query) } + end + end + end + end + + def atom_video(xml : XML::Builder, video : AnyVideo, query_params : HTTP::Params) + # URLs that are reused below + video_url = "#{HOST_URL}/watch?v=#{video.id}&#{query_params}" + video_thumb = "#{HOST_URL}/vi/#{video.id}/mqdefault.jpg" + + description = video.is_a?(SearchVideo) ? video.description_html.gsub('\n', "
\n") : "" + + xml.element("id") { xml.text "ni://invidious/sha-256;" + sha256("video/#{video.id}") } + xml.element("title") { xml.text video.title } + xml.element("link", rel: "alternate", href: video_url) + + xml.element("author") do + xml.element("name") { xml.text video.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{video.ucid}" } + end + + xml.element("content", type: "xhtml") do + xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do + # Link to video + xml.element("a", href: video_url) do + xml.element("img", src: video_thumb) + end + + # Video sescription (SearchVideo only) + if video.is_a?(SearchVideo) + xml.element("p", style: "white-space:pre-wrap") { xml.text description } + end + end + end + + # Feed creation (if available) and update (ChannelVideo only) dates + xml.element("published") { xml.text video.published.to_rfc3339 } + xml.element("updated") { xml.text video.updated.to_rfc3339 } if video.is_a?(ChannelVideo) + + # Media properties + xml.element("media:group") do + xml.element("media:title") { xml.text video.title } + xml.element("media:thumbnail", url: video_thumb, width: "320", height: "180") + + # Video sescription (SearchVideo only) + if video.is_a?(SearchVideo) + xml.element("media:description") { xml.text description } + end + end + + # Views count (all except PlaylistVideo) + if !video.is_a?(PlaylistVideo) + xml.element("media:community") do + xml.element("media:statistics", views: video.views) + end + end + end +end