From c7876d564f09995244186f57d61cedfeb63038b6 Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 19:50:35 +0200
Subject: [PATCH 01/10] Comments: add 'require' statement for a dedicated
 folder

---
 src/invidious.cr | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/invidious.cr b/src/invidious.cr
index d4f8e0fb..b5abd5c7 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -43,6 +43,7 @@ require "./invidious/videos/*"
 require "./invidious/jsonify/**"
 
 require "./invidious/*"
+require "./invidious/comments/*"
 require "./invidious/channels/*"
 require "./invidious/user/*"
 require "./invidious/search/*"

From 8dd18248692726e8db05138c4ce2b01f39ad62f6 Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 19:51:49 +0200
Subject: [PATCH 02/10] Comments: Move reddit type definitions to their own
 file

---
 src/invidious/comments.cr              | 58 --------------------------
 src/invidious/comments/reddit_types.cr | 57 +++++++++++++++++++++++++
 2 files changed, 57 insertions(+), 58 deletions(-)
 create mode 100644 src/invidious/comments/reddit_types.cr

diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index 466c9fe5..00e8d399 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -1,61 +1,3 @@
-class RedditThing
-  include JSON::Serializable
-
-  property kind : String
-  property data : RedditComment | RedditLink | RedditMore | RedditListing
-end
-
-class RedditComment
-  include JSON::Serializable
-
-  property author : String
-  property body_html : String
-  property replies : RedditThing | String
-  property score : Int32
-  property depth : Int32
-  property permalink : String
-
-  @[JSON::Field(converter: RedditComment::TimeConverter)]
-  property created_utc : Time
-
-  module TimeConverter
-    def self.from_json(value : JSON::PullParser) : Time
-      Time.unix(value.read_float.to_i)
-    end
-
-    def self.to_json(value : Time, json : JSON::Builder)
-      json.number(value.to_unix)
-    end
-  end
-end
-
-struct RedditLink
-  include JSON::Serializable
-
-  property author : String
-  property score : Int32
-  property subreddit : String
-  property num_comments : Int32
-  property id : String
-  property permalink : String
-  property title : String
-end
-
-struct RedditMore
-  include JSON::Serializable
-
-  property children : Array(String)
-  property count : Int32
-  property depth : Int32
-end
-
-class RedditListing
-  include JSON::Serializable
-
-  property children : Array(RedditThing)
-  property modhash : String
-end
-
 def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_by = "top")
   case cursor
   when nil, ""
diff --git a/src/invidious/comments/reddit_types.cr b/src/invidious/comments/reddit_types.cr
new file mode 100644
index 00000000..796a1183
--- /dev/null
+++ b/src/invidious/comments/reddit_types.cr
@@ -0,0 +1,57 @@
+class RedditThing
+  include JSON::Serializable
+
+  property kind : String
+  property data : RedditComment | RedditLink | RedditMore | RedditListing
+end
+
+class RedditComment
+  include JSON::Serializable
+
+  property author : String
+  property body_html : String
+  property replies : RedditThing | String
+  property score : Int32
+  property depth : Int32
+  property permalink : String
+
+  @[JSON::Field(converter: RedditComment::TimeConverter)]
+  property created_utc : Time
+
+  module TimeConverter
+    def self.from_json(value : JSON::PullParser) : Time
+      Time.unix(value.read_float.to_i)
+    end
+
+    def self.to_json(value : Time, json : JSON::Builder)
+      json.number(value.to_unix)
+    end
+  end
+end
+
+struct RedditLink
+  include JSON::Serializable
+
+  property author : String
+  property score : Int32
+  property subreddit : String
+  property num_comments : Int32
+  property id : String
+  property permalink : String
+  property title : String
+end
+
+struct RedditMore
+  include JSON::Serializable
+
+  property children : Array(String)
+  property count : Int32
+  property depth : Int32
+end
+
+class RedditListing
+  include JSON::Serializable
+
+  property children : Array(RedditThing)
+  property modhash : String
+end

From 1b25737b013d0589f396fa938ba2747e9a76af93 Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 19:56:30 +0200
Subject: [PATCH 03/10] Comments: Move 'fetch_youtube' function to own file +
 module

---
 src/invidious/comments.cr             | 203 -------------------------
 src/invidious/comments/youtube.cr     | 206 ++++++++++++++++++++++++++
 src/invidious/routes/api/v1/videos.cr |   2 +-
 src/invidious/routes/watch.cr         |   6 +-
 4 files changed, 210 insertions(+), 207 deletions(-)
 create mode 100644 src/invidious/comments/youtube.cr

diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index 00e8d399..07579cf3 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -1,206 +1,3 @@
-def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_by = "top")
-  case cursor
-  when nil, ""
-    ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by)
-  when .starts_with? "ADSJ"
-    ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by)
-  else
-    ctoken = cursor
-  end
-
-  client_config = YoutubeAPI::ClientConfig.new(region: region)
-  response = YoutubeAPI.next(continuation: ctoken, client_config: client_config)
-  contents = nil
-
-  if on_response_received_endpoints = response["onResponseReceivedEndpoints"]?
-    header = nil
-    on_response_received_endpoints.as_a.each do |item|
-      if item["reloadContinuationItemsCommand"]?
-        case item["reloadContinuationItemsCommand"]["slot"]
-        when "RELOAD_CONTINUATION_SLOT_HEADER"
-          header = item["reloadContinuationItemsCommand"]["continuationItems"][0]
-        when "RELOAD_CONTINUATION_SLOT_BODY"
-          # continuationItems is nil when video has no comments
-          contents = item["reloadContinuationItemsCommand"]["continuationItems"]?
-        end
-      elsif item["appendContinuationItemsAction"]?
-        contents = item["appendContinuationItemsAction"]["continuationItems"]
-      end
-    end
-  elsif response["continuationContents"]?
-    response = response["continuationContents"]
-    if response["commentRepliesContinuation"]?
-      body = response["commentRepliesContinuation"]
-    else
-      body = response["itemSectionContinuation"]
-    end
-    contents = body["contents"]?
-    header = body["header"]?
-  else
-    raise NotFoundException.new("Comments not found.")
-  end
-
-  if !contents
-    if format == "json"
-      return {"comments" => [] of String}.to_json
-    else
-      return {"contentHtml" => "", "commentCount" => 0}.to_json
-    end
-  end
-
-  continuation_item_renderer = nil
-  contents.as_a.reject! do |item|
-    if item["continuationItemRenderer"]?
-      continuation_item_renderer = item["continuationItemRenderer"]
-      true
-    end
-  end
-
-  response = JSON.build do |json|
-    json.object do
-      if header
-        count_text = header["commentsHeaderRenderer"]["countText"]
-        comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?)
-          .try &.as_s.gsub(/\D/, "").to_i? || 0
-        json.field "commentCount", comment_count
-      end
-
-      json.field "videoId", id
-
-      json.field "comments" do
-        json.array do
-          contents.as_a.each do |node|
-            json.object do
-              if node["commentThreadRenderer"]?
-                node = node["commentThreadRenderer"]
-              end
-
-              if node["replies"]?
-                node_replies = node["replies"]["commentRepliesRenderer"]
-              end
-
-              if node["comment"]?
-                node_comment = node["comment"]["commentRenderer"]
-              else
-                node_comment = node["commentRenderer"]
-              end
-
-              content_html = node_comment["contentText"]?.try { |t| parse_content(t, id) } || ""
-              author = node_comment["authorText"]?.try &.["simpleText"]? || ""
-
-              json.field "verified", (node_comment["authorCommentBadge"]? != nil)
-
-              json.field "author", author
-              json.field "authorThumbnails" do
-                json.array do
-                  node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail|
-                    json.object do
-                      json.field "url", thumbnail["url"]
-                      json.field "width", thumbnail["width"]
-                      json.field "height", thumbnail["height"]
-                    end
-                  end
-                end
-              end
-
-              if node_comment["authorEndpoint"]?
-                json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"]
-                json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"]
-              else
-                json.field "authorId", ""
-                json.field "authorUrl", ""
-              end
-
-              published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s
-              published = decode_date(published_text.rchop(" (edited)"))
-
-              if published_text.includes?(" (edited)")
-                json.field "isEdited", true
-              else
-                json.field "isEdited", false
-              end
-
-              json.field "content", html_to_content(content_html)
-              json.field "contentHtml", content_html
-
-              json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil)
-              json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil)
-              if node_comment["sponsorCommentBadge"]?
-                # Sponsor icon thumbnails always have one object and there's only ever the url property in it
-                json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s
-              end
-              json.field "published", published.to_unix
-              json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
-
-              comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"]
-
-              json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i
-              json.field "commentId", node_comment["commentId"]
-              json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
-
-              if comment_action_buttons_renderer["creatorHeart"]?
-                hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"]
-                json.field "creatorHeart" do
-                  json.object do
-                    json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"]
-                    json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"]
-                  end
-                end
-              end
-
-              if node_replies && !response["commentRepliesContinuation"]?
-                if node_replies["continuations"]?
-                  continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
-                elsif node_replies["contents"]?
-                  continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s
-                end
-                continuation ||= ""
-
-                json.field "replies" do
-                  json.object do
-                    json.field "replyCount", node_comment["replyCount"]? || 1
-                    json.field "continuation", continuation
-                  end
-                end
-              end
-            end
-          end
-        end
-      end
-
-      if continuation_item_renderer
-        if continuation_item_renderer["continuationEndpoint"]?
-          continuation_endpoint = continuation_item_renderer["continuationEndpoint"]
-        elsif continuation_item_renderer["button"]?
-          continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"]
-        end
-        if continuation_endpoint
-          json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s
-        end
-      end
-    end
-  end
-
-  if format == "html"
-    response = JSON.parse(response)
-    content_html = template_youtube_comments(response, locale, thin_mode)
-
-    response = JSON.build do |json|
-      json.object do
-        json.field "contentHtml", content_html
-
-        if response["commentCount"]?
-          json.field "commentCount", response["commentCount"]
-        else
-          json.field "commentCount", 0
-        end
-      end
-    end
-  end
-
-  return response
-end
-
 def fetch_reddit_comments(id, sort_by = "confidence")
   client = make_client(REDDIT_URL)
   headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"}
diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr
new file mode 100644
index 00000000..7e0c8d24
--- /dev/null
+++ b/src/invidious/comments/youtube.cr
@@ -0,0 +1,206 @@
+module Invidious::Comments
+  extend self
+
+  def fetch_youtube(id, cursor, format, locale, thin_mode, region, sort_by = "top")
+    case cursor
+    when nil, ""
+      ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by)
+    when .starts_with? "ADSJ"
+      ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by)
+    else
+      ctoken = cursor
+    end
+
+    client_config = YoutubeAPI::ClientConfig.new(region: region)
+    response = YoutubeAPI.next(continuation: ctoken, client_config: client_config)
+    contents = nil
+
+    if on_response_received_endpoints = response["onResponseReceivedEndpoints"]?
+      header = nil
+      on_response_received_endpoints.as_a.each do |item|
+        if item["reloadContinuationItemsCommand"]?
+          case item["reloadContinuationItemsCommand"]["slot"]
+          when "RELOAD_CONTINUATION_SLOT_HEADER"
+            header = item["reloadContinuationItemsCommand"]["continuationItems"][0]
+          when "RELOAD_CONTINUATION_SLOT_BODY"
+            # continuationItems is nil when video has no comments
+            contents = item["reloadContinuationItemsCommand"]["continuationItems"]?
+          end
+        elsif item["appendContinuationItemsAction"]?
+          contents = item["appendContinuationItemsAction"]["continuationItems"]
+        end
+      end
+    elsif response["continuationContents"]?
+      response = response["continuationContents"]
+      if response["commentRepliesContinuation"]?
+        body = response["commentRepliesContinuation"]
+      else
+        body = response["itemSectionContinuation"]
+      end
+      contents = body["contents"]?
+      header = body["header"]?
+    else
+      raise NotFoundException.new("Comments not found.")
+    end
+
+    if !contents
+      if format == "json"
+        return {"comments" => [] of String}.to_json
+      else
+        return {"contentHtml" => "", "commentCount" => 0}.to_json
+      end
+    end
+
+    continuation_item_renderer = nil
+    contents.as_a.reject! do |item|
+      if item["continuationItemRenderer"]?
+        continuation_item_renderer = item["continuationItemRenderer"]
+        true
+      end
+    end
+
+    response = JSON.build do |json|
+      json.object do
+        if header
+          count_text = header["commentsHeaderRenderer"]["countText"]
+          comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?)
+            .try &.as_s.gsub(/\D/, "").to_i? || 0
+          json.field "commentCount", comment_count
+        end
+
+        json.field "videoId", id
+
+        json.field "comments" do
+          json.array do
+            contents.as_a.each do |node|
+              json.object do
+                if node["commentThreadRenderer"]?
+                  node = node["commentThreadRenderer"]
+                end
+
+                if node["replies"]?
+                  node_replies = node["replies"]["commentRepliesRenderer"]
+                end
+
+                if node["comment"]?
+                  node_comment = node["comment"]["commentRenderer"]
+                else
+                  node_comment = node["commentRenderer"]
+                end
+
+                content_html = node_comment["contentText"]?.try { |t| parse_content(t, id) } || ""
+                author = node_comment["authorText"]?.try &.["simpleText"]? || ""
+
+                json.field "verified", (node_comment["authorCommentBadge"]? != nil)
+
+                json.field "author", author
+                json.field "authorThumbnails" do
+                  json.array do
+                    node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail|
+                      json.object do
+                        json.field "url", thumbnail["url"]
+                        json.field "width", thumbnail["width"]
+                        json.field "height", thumbnail["height"]
+                      end
+                    end
+                  end
+                end
+
+                if node_comment["authorEndpoint"]?
+                  json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"]
+                  json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"]
+                else
+                  json.field "authorId", ""
+                  json.field "authorUrl", ""
+                end
+
+                published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s
+                published = decode_date(published_text.rchop(" (edited)"))
+
+                if published_text.includes?(" (edited)")
+                  json.field "isEdited", true
+                else
+                  json.field "isEdited", false
+                end
+
+                json.field "content", html_to_content(content_html)
+                json.field "contentHtml", content_html
+
+                json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil)
+                json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil)
+                if node_comment["sponsorCommentBadge"]?
+                  # Sponsor icon thumbnails always have one object and there's only ever the url property in it
+                  json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s
+                end
+                json.field "published", published.to_unix
+                json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
+
+                comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"]
+
+                json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i
+                json.field "commentId", node_comment["commentId"]
+                json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
+
+                if comment_action_buttons_renderer["creatorHeart"]?
+                  hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"]
+                  json.field "creatorHeart" do
+                    json.object do
+                      json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"]
+                      json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"]
+                    end
+                  end
+                end
+
+                if node_replies && !response["commentRepliesContinuation"]?
+                  if node_replies["continuations"]?
+                    continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
+                  elsif node_replies["contents"]?
+                    continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s
+                  end
+                  continuation ||= ""
+
+                  json.field "replies" do
+                    json.object do
+                      json.field "replyCount", node_comment["replyCount"]? || 1
+                      json.field "continuation", continuation
+                    end
+                  end
+                end
+              end
+            end
+          end
+        end
+
+        if continuation_item_renderer
+          if continuation_item_renderer["continuationEndpoint"]?
+            continuation_endpoint = continuation_item_renderer["continuationEndpoint"]
+          elsif continuation_item_renderer["button"]?
+            continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"]
+          end
+          if continuation_endpoint
+            json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s
+          end
+        end
+      end
+    end
+
+    if format == "html"
+      response = JSON.parse(response)
+      content_html = template_youtube_comments(response, locale, thin_mode)
+
+      response = JSON.build do |json|
+        json.object do
+          json.field "contentHtml", content_html
+
+          if response["commentCount"]?
+            json.field "commentCount", response["commentCount"]
+          else
+            json.field "commentCount", 0
+          end
+        end
+      end
+    end
+
+    return response
+  end
+end
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index f312211e..ce3e96d2 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -333,7 +333,7 @@ module Invidious::Routes::API::V1::Videos
       sort_by ||= "top"
 
       begin
-        comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
+        comments = Comments.fetch_youtube(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
       rescue ex : NotFoundException
         return error_json(404, ex)
       rescue ex
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index 813cb0f4..861b25c2 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -95,7 +95,7 @@ module Invidious::Routes::Watch
 
         if source == "youtube"
           begin
-            comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
+            comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
           rescue ex
             if preferences.comments[1] == "reddit"
               comments, reddit_thread = fetch_reddit_comments(id)
@@ -114,12 +114,12 @@ module Invidious::Routes::Watch
             comment_html = replace_links(comment_html)
           rescue ex
             if preferences.comments[1] == "youtube"
-              comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
+              comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
             end
           end
         end
       else
-        comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
+        comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
       end
 
       comment_html ||= ""

From 634e913da9381f5212a1017e2f4a37e7d7075204 Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 20:02:42 +0200
Subject: [PATCH 04/10] Comments: Move 'fetch_reddit' function to own file +
 module

---
 src/invidious/comments.cr             | 38 -------------------------
 src/invidious/comments/reddit.cr      | 41 +++++++++++++++++++++++++++
 src/invidious/routes/api/v1/videos.cr |  2 +-
 src/invidious/routes/watch.cr         |  4 +--
 4 files changed, 44 insertions(+), 41 deletions(-)
 create mode 100644 src/invidious/comments/reddit.cr

diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index 07579cf3..07b92786 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -1,41 +1,3 @@
-def fetch_reddit_comments(id, sort_by = "confidence")
-  client = make_client(REDDIT_URL)
-  headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"}
-
-  # TODO: Use something like #479 for a static list of instances to use here
-  query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"})
-  search_results = client.get("/search.json?#{query}", headers)
-
-  if search_results.status_code == 200
-    search_results = RedditThing.from_json(search_results.body)
-
-    # For videos that have more than one thread, choose the one with the highest score
-    threads = search_results.data.as(RedditListing).children
-    thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink))
-    result = thread.try do |t|
-      body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body
-      Array(RedditThing).from_json(body)
-    end
-    result ||= [] of RedditThing
-  elsif search_results.status_code == 302
-    # Previously, if there was only one result then the API would redirect to that result.
-    # Now, it appears it will still return a listing so this section is likely unnecessary.
-
-    result = client.get(search_results.headers["Location"], headers).body
-    result = Array(RedditThing).from_json(result)
-
-    thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink)
-  else
-    raise NotFoundException.new("Comments not found.")
-  end
-
-  client.close
-
-  comments = result[1]?.try(&.data.as(RedditListing).children)
-  comments ||= [] of RedditThing
-  return comments, thread
-end
-
 def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
   String.build do |html|
     root = comments["comments"].as_a
diff --git a/src/invidious/comments/reddit.cr b/src/invidious/comments/reddit.cr
new file mode 100644
index 00000000..ba9c19f1
--- /dev/null
+++ b/src/invidious/comments/reddit.cr
@@ -0,0 +1,41 @@
+module Invidious::Comments
+  extend self
+
+  def fetch_reddit(id, sort_by = "confidence")
+    client = make_client(REDDIT_URL)
+    headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"}
+
+    # TODO: Use something like #479 for a static list of instances to use here
+    query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"})
+    search_results = client.get("/search.json?#{query}", headers)
+
+    if search_results.status_code == 200
+      search_results = RedditThing.from_json(search_results.body)
+
+      # For videos that have more than one thread, choose the one with the highest score
+      threads = search_results.data.as(RedditListing).children
+      thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink))
+      result = thread.try do |t|
+        body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body
+        Array(RedditThing).from_json(body)
+      end
+      result ||= [] of RedditThing
+    elsif search_results.status_code == 302
+      # Previously, if there was only one result then the API would redirect to that result.
+      # Now, it appears it will still return a listing so this section is likely unnecessary.
+
+      result = client.get(search_results.headers["Location"], headers).body
+      result = Array(RedditThing).from_json(result)
+
+      thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink)
+    else
+      raise NotFoundException.new("Comments not found.")
+    end
+
+    client.close
+
+    comments = result[1]?.try(&.data.as(RedditListing).children)
+    comments ||= [] of RedditThing
+    return comments, thread
+  end
+end
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index ce3e96d2..cb1008ac 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -345,7 +345,7 @@ module Invidious::Routes::API::V1::Videos
       sort_by ||= "confidence"
 
       begin
-        comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by)
+        comments, reddit_thread = Comments.fetch_reddit(id, sort_by: sort_by)
       rescue ex
         comments = nil
         reddit_thread = nil
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index 861b25c2..b08e6fbe 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -98,7 +98,7 @@ module Invidious::Routes::Watch
             comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
           rescue ex
             if preferences.comments[1] == "reddit"
-              comments, reddit_thread = fetch_reddit_comments(id)
+              comments, reddit_thread = Comments.fetch_reddit(id)
               comment_html = template_reddit_comments(comments, locale)
 
               comment_html = fill_links(comment_html, "https", "www.reddit.com")
@@ -107,7 +107,7 @@ module Invidious::Routes::Watch
           end
         elsif source == "reddit"
           begin
-            comments, reddit_thread = fetch_reddit_comments(id)
+            comments, reddit_thread = Comments.fetch_reddit(id)
             comment_html = template_reddit_comments(comments, locale)
 
             comment_html = fill_links(comment_html, "https", "www.reddit.com")

From e10f6b6626bfe462861980184b09b7350499c889 Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 20:07:13 +0200
Subject: [PATCH 05/10] Comments: Move 'template_youtube' function to own file
 + module

---
 src/invidious/channels/community.cr        |   2 +-
 src/invidious/comments.cr                  | 157 --------------------
 src/invidious/comments/youtube.cr          |   2 +-
 src/invidious/frontend/comments_youtube.cr | 160 +++++++++++++++++++++
 src/invidious/views/community.ecr          |   2 +-
 5 files changed, 163 insertions(+), 160 deletions(-)
 create mode 100644 src/invidious/frontend/comments_youtube.cr

diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr
index 2c7b9fec..aac4bc8a 100644
--- a/src/invidious/channels/community.cr
+++ b/src/invidious/channels/community.cr
@@ -250,7 +250,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
 
   if format == "html"
     response = JSON.parse(response)
-    content_html = template_youtube_comments(response, locale, thin_mode)
+    content_html = IV::Frontend::Comments.template_youtube(response, locale, thin_mode)
 
     response = JSON.build do |json|
       json.object do
diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index 07b92786..8943b1da 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -1,160 +1,3 @@
-def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
-  String.build do |html|
-    root = comments["comments"].as_a
-    root.each do |child|
-      if child["replies"]?
-        replies_count_text = translate_count(locale,
-          "comments_view_x_replies",
-          child["replies"]["replyCount"].as_i64 || 0,
-          NumberFormatting::Separator
-        )
-
-        replies_html = <<-END_HTML
-        <div id="replies" class="pure-g">
-          <div class="pure-u-1-24"></div>
-          <div class="pure-u-23-24">
-            <p>
-              <a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
-                data-onclick="get_youtube_replies" data-load-replies>#{replies_count_text}</a>
-            </p>
-          </div>
-        </div>
-        END_HTML
-      end
-
-      if !thin_mode
-        author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}"
-      else
-        author_thumbnail = ""
-      end
-
-      author_name = HTML.escape(child["author"].as_s)
-      sponsor_icon = ""
-      if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool
-        author_name += "&nbsp;<i class=\"icon ion ion-md-checkmark-circle\"></i>"
-      elsif child["verified"]?.try &.as_bool
-        author_name += "&nbsp;<i class=\"icon ion ion-md-checkmark\"></i>"
-      end
-
-      if child["isSponsor"]?.try &.as_bool
-        sponsor_icon = String.build do |str|
-          str << %(<img alt="" )
-          str << %(src="/ggpht) << URI.parse(child["sponsorIconUrl"].as_s).request_target << "\" "
-          str << %(title=") << translate(locale, "Channel Sponsor") << "\" "
-          str << %(width="16" height="16" />)
-        end
-      end
-      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}" alt="" />
-        </div>
-        <div class="pure-u-20-24 pure-u-md-22-24">
-          <p>
-            <b>
-              <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{author_name}</a>
-            </b>
-            #{sponsor_icon}
-            <p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
-      END_HTML
-
-      if child["attachment"]?
-        attachment = child["attachment"]
-
-        case attachment["type"]
-        when "image"
-          attachment = attachment["imageThumbnails"][1]
-
-          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}" alt="" />
-            </div>
-          </div>
-          END_HTML
-        when "video"
-          if attachment["error"]?
-            html << <<-END_HTML
-            <div class="pure-g video-iframe-wrapper">
-              <p>#{attachment["error"]}</p>
-            </div>
-            END_HTML
-          else
-            html << <<-END_HTML
-            <div class="pure-g video-iframe-wrapper">
-              <iframe class="video-iframe" src='/embed/#{attachment["videoId"]?}?autoplay=0'></iframe>
-            </div>
-            END_HTML
-          end
-        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
-
-      if comments["videoId"]?
-        html << <<-END_HTML
-          <a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
-          |
-        END_HTML
-      elsif comments["authorId"]?
-        html << <<-END_HTML
-          <a href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
-          |
-        END_HTML
-      end
-
-      html << <<-END_HTML
-        <i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
-      END_HTML
-
-      if child["creatorHeart"]?
-        if !thin_mode
-          creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}"
-        else
-          creator_thumbnail = ""
-        end
-
-        html << <<-END_HTML
-          &nbsp;
-          <span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
-              <span class="creator-heart">
-                  <img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}" alt="" />
-                  <span class="creator-heart-small-hearted">
-                      <span class="icon ion-ios-heart creator-heart-small-container"></span>
-                  </span>
-              </span>
-          </span>
-        END_HTML
-      end
-
-      html << <<-END_HTML
-          </p>
-          #{replies_html}
-        </div>
-      </div>
-      END_HTML
-    end
-
-    if comments["continuation"]?
-      html << <<-END_HTML
-      <div class="pure-g">
-        <div class="pure-u-1">
-          <p>
-            <a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
-              data-onclick="get_youtube_replies" data-load-more #{"data-load-replies" if is_replies}>#{translate(locale, "Load more")}</a>
-          </p>
-        </div>
-      </div>
-      END_HTML
-    end
-  end
-end
-
 def template_reddit_comments(root, locale)
   String.build do |html|
     root.each do |child|
diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr
index 7e0c8d24..c262876e 100644
--- a/src/invidious/comments/youtube.cr
+++ b/src/invidious/comments/youtube.cr
@@ -186,7 +186,7 @@ module Invidious::Comments
 
     if format == "html"
       response = JSON.parse(response)
-      content_html = template_youtube_comments(response, locale, thin_mode)
+      content_html = Frontend::Comments.template_youtube(response, locale, thin_mode)
 
       response = JSON.build do |json|
         json.object do
diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr
new file mode 100644
index 00000000..41f43f04
--- /dev/null
+++ b/src/invidious/frontend/comments_youtube.cr
@@ -0,0 +1,160 @@
+module Invidious::Frontend::Comments
+  extend self
+
+  def template_youtube(comments, locale, thin_mode, is_replies = false)
+    String.build do |html|
+      root = comments["comments"].as_a
+      root.each do |child|
+        if child["replies"]?
+          replies_count_text = translate_count(locale,
+            "comments_view_x_replies",
+            child["replies"]["replyCount"].as_i64 || 0,
+            NumberFormatting::Separator
+          )
+
+          replies_html = <<-END_HTML
+          <div id="replies" class="pure-g">
+            <div class="pure-u-1-24"></div>
+            <div class="pure-u-23-24">
+              <p>
+                <a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
+                  data-onclick="get_youtube_replies" data-load-replies>#{replies_count_text}</a>
+              </p>
+            </div>
+          </div>
+          END_HTML
+        end
+
+        if !thin_mode
+          author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}"
+        else
+          author_thumbnail = ""
+        end
+
+        author_name = HTML.escape(child["author"].as_s)
+        sponsor_icon = ""
+        if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool
+          author_name += "&nbsp;<i class=\"icon ion ion-md-checkmark-circle\"></i>"
+        elsif child["verified"]?.try &.as_bool
+          author_name += "&nbsp;<i class=\"icon ion ion-md-checkmark\"></i>"
+        end
+
+        if child["isSponsor"]?.try &.as_bool
+          sponsor_icon = String.build do |str|
+            str << %(<img alt="" )
+            str << %(src="/ggpht) << URI.parse(child["sponsorIconUrl"].as_s).request_target << "\" "
+            str << %(title=") << translate(locale, "Channel Sponsor") << "\" "
+            str << %(width="16" height="16" />)
+          end
+        end
+        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}" alt="" />
+          </div>
+          <div class="pure-u-20-24 pure-u-md-22-24">
+            <p>
+              <b>
+                <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{author_name}</a>
+              </b>
+              #{sponsor_icon}
+              <p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
+        END_HTML
+
+        if child["attachment"]?
+          attachment = child["attachment"]
+
+          case attachment["type"]
+          when "image"
+            attachment = attachment["imageThumbnails"][1]
+
+            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}" alt="" />
+              </div>
+            </div>
+            END_HTML
+          when "video"
+            if attachment["error"]?
+              html << <<-END_HTML
+              <div class="pure-g video-iframe-wrapper">
+                <p>#{attachment["error"]}</p>
+              </div>
+              END_HTML
+            else
+              html << <<-END_HTML
+              <div class="pure-g video-iframe-wrapper">
+                <iframe class="video-iframe" src='/embed/#{attachment["videoId"]?}?autoplay=0'></iframe>
+              </div>
+              END_HTML
+            end
+          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
+
+        if comments["videoId"]?
+          html << <<-END_HTML
+            <a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
+            |
+          END_HTML
+        elsif comments["authorId"]?
+          html << <<-END_HTML
+            <a href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
+            |
+          END_HTML
+        end
+
+        html << <<-END_HTML
+          <i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
+        END_HTML
+
+        if child["creatorHeart"]?
+          if !thin_mode
+            creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}"
+          else
+            creator_thumbnail = ""
+          end
+
+          html << <<-END_HTML
+            &nbsp;
+            <span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
+                <span class="creator-heart">
+                    <img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}" alt="" />
+                    <span class="creator-heart-small-hearted">
+                        <span class="icon ion-ios-heart creator-heart-small-container"></span>
+                    </span>
+                </span>
+            </span>
+          END_HTML
+        end
+
+        html << <<-END_HTML
+            </p>
+            #{replies_html}
+          </div>
+        </div>
+        END_HTML
+      end
+
+      if comments["continuation"]?
+        html << <<-END_HTML
+        <div class="pure-g">
+          <div class="pure-u-1">
+            <p>
+              <a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
+                data-onclick="get_youtube_replies" data-load-more #{"data-load-replies" if is_replies}>#{translate(locale, "Load more")}</a>
+            </p>
+          </div>
+        </div>
+        END_HTML
+      end
+    end
+  end
+end
diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr
index 9e11d562..24efc34e 100644
--- a/src/invidious/views/community.ecr
+++ b/src/invidious/views/community.ecr
@@ -27,7 +27,7 @@
     </div>
 <% else %>
     <div class="h-box pure-g" id="comments">
-        <%= template_youtube_comments(items.not_nil!, locale, thin_mode) %>
+        <%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %>
     </div>
 <% end %>
 

From de78848039c2e5e8dea25b6013f3e24797a0b1ce Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 20:12:02 +0200
Subject: [PATCH 06/10] Comments: Move 'template_reddit' function to own file +
 module

---
 src/invidious/comments.cr                 | 47 ---------------------
 src/invidious/frontend/comments_reddit.cr | 50 +++++++++++++++++++++++
 src/invidious/routes/api/v1/videos.cr     |  2 +-
 src/invidious/routes/watch.cr             |  4 +-
 4 files changed, 53 insertions(+), 50 deletions(-)
 create mode 100644 src/invidious/frontend/comments_reddit.cr

diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index 8943b1da..6a3aa4c2 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -1,50 +1,3 @@
-def template_reddit_comments(root, locale)
-  String.build do |html|
-    root.each do |child|
-      if child.data.is_a?(RedditComment)
-        child = child.data.as(RedditComment)
-        body_html = HTML.unescape(child.body_html)
-
-        replies_html = ""
-        if child.replies.is_a?(RedditThing)
-          replies = child.replies.as(RedditThing)
-          replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale)
-        end
-
-        if child.depth > 0
-          html << <<-END_HTML
-          <div class="pure-g">
-          <div class="pure-u-1-24">
-          </div>
-          <div class="pure-u-23-24">
-          END_HTML
-        else
-          html << <<-END_HTML
-          <div class="pure-g">
-          <div class="pure-u-1">
-          END_HTML
-        end
-
-        html << <<-END_HTML
-        <p>
-          <a href="javascript:void(0)" data-onclick="toggle_parent">[ − ]</a>
-          <b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b>
-          #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)}
-          <span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span>
-          <a href="https://www.reddit.com#{child.permalink}" title="#{translate(locale, "permalink")}">#{translate(locale, "permalink")}</a>
-          </p>
-          <div>
-          #{body_html}
-          #{replies_html}
-        </div>
-        </div>
-        </div>
-        END_HTML
-      end
-    end
-  end
-end
-
 def replace_links(html)
   # Check if the document is empty
   # Prevents edge-case bug with Reddit comments, see issue #3115
diff --git a/src/invidious/frontend/comments_reddit.cr b/src/invidious/frontend/comments_reddit.cr
new file mode 100644
index 00000000..b5647bae
--- /dev/null
+++ b/src/invidious/frontend/comments_reddit.cr
@@ -0,0 +1,50 @@
+module Invidious::Frontend::Comments
+  extend self
+
+  def template_reddit(root, locale)
+    String.build do |html|
+      root.each do |child|
+        if child.data.is_a?(RedditComment)
+          child = child.data.as(RedditComment)
+          body_html = HTML.unescape(child.body_html)
+
+          replies_html = ""
+          if child.replies.is_a?(RedditThing)
+            replies = child.replies.as(RedditThing)
+            replies_html = self.template_reddit(replies.data.as(RedditListing).children, locale)
+          end
+
+          if child.depth > 0
+            html << <<-END_HTML
+            <div class="pure-g">
+            <div class="pure-u-1-24">
+            </div>
+            <div class="pure-u-23-24">
+            END_HTML
+          else
+            html << <<-END_HTML
+            <div class="pure-g">
+            <div class="pure-u-1">
+            END_HTML
+          end
+
+          html << <<-END_HTML
+          <p>
+            <a href="javascript:void(0)" data-onclick="toggle_parent">[ − ]</a>
+            <b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b>
+            #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)}
+            <span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span>
+            <a href="https://www.reddit.com#{child.permalink}" title="#{translate(locale, "permalink")}">#{translate(locale, "permalink")}</a>
+            </p>
+            <div>
+            #{body_html}
+            #{replies_html}
+          </div>
+          </div>
+          </div>
+          END_HTML
+        end
+      end
+    end
+  end
+end
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index cb1008ac..6feaaef4 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -361,7 +361,7 @@ module Invidious::Routes::API::V1::Videos
 
         return reddit_thread.to_json
       else
-        content_html = template_reddit_comments(comments, locale)
+        content_html = Frontend::Comments.template_reddit(comments, locale)
         content_html = fill_links(content_html, "https", "www.reddit.com")
         content_html = replace_links(content_html)
         response = {
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index b08e6fbe..6b441a48 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -99,7 +99,7 @@ module Invidious::Routes::Watch
           rescue ex
             if preferences.comments[1] == "reddit"
               comments, reddit_thread = Comments.fetch_reddit(id)
-              comment_html = template_reddit_comments(comments, locale)
+              comment_html = Frontend::Comments.template_reddit(comments, locale)
 
               comment_html = fill_links(comment_html, "https", "www.reddit.com")
               comment_html = replace_links(comment_html)
@@ -108,7 +108,7 @@ module Invidious::Routes::Watch
         elsif source == "reddit"
           begin
             comments, reddit_thread = Comments.fetch_reddit(id)
-            comment_html = template_reddit_comments(comments, locale)
+            comment_html = Frontend::Comments.template_reddit(comments, locale)
 
             comment_html = fill_links(comment_html, "https", "www.reddit.com")
             comment_html = replace_links(comment_html)

From df8526545383f4def3605fb61551edbd851c18c7 Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 20:20:27 +0200
Subject: [PATCH 07/10] Comments: Move link utility functions to own file +
 module

---
 src/invidious/comments.cr             | 73 -------------------------
 src/invidious/comments/links_util.cr  | 76 +++++++++++++++++++++++++++
 src/invidious/routes/api/v1/videos.cr |  4 +-
 src/invidious/routes/watch.cr         |  8 +--
 4 files changed, 82 insertions(+), 79 deletions(-)
 create mode 100644 src/invidious/comments/links_util.cr

diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index 6a3aa4c2..3c7e2bb4 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -1,76 +1,3 @@
-def replace_links(html)
-  # Check if the document is empty
-  # Prevents edge-case bug with Reddit comments, see issue #3115
-  if html.nil? || html.empty?
-    return html
-  end
-
-  html = XML.parse_html(html)
-
-  html.xpath_nodes(%q(//a)).each do |anchor|
-    url = URI.parse(anchor["href"])
-
-    if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be")
-      if url.host.try &.ends_with? "youtu.be"
-        url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}"
-      else
-        if url.path == "/redirect"
-          params = HTTP::Params.parse(url.query.not_nil!)
-          anchor["href"] = params["q"]?
-        else
-          anchor["href"] = url.request_target
-        end
-      end
-    elsif url.to_s == "#"
-      begin
-        length_seconds = decode_length_seconds(anchor.content)
-      rescue ex
-        length_seconds = decode_time(anchor.content)
-      end
-
-      if length_seconds > 0
-        anchor["href"] = "javascript:void(0)"
-        anchor["onclick"] = "player.currentTime(#{length_seconds})"
-      else
-        anchor["href"] = url.request_target
-      end
-    end
-  end
-
-  html = html.xpath_node(%q(//body)).not_nil!
-  if node = html.xpath_node(%q(./p))
-    html = node
-  end
-
-  return html.to_xml(options: XML::SaveOptions::NO_DECL)
-end
-
-def fill_links(html, scheme, host)
-  # Check if the document is empty
-  # Prevents edge-case bug with Reddit comments, see issue #3115
-  if html.nil? || html.empty?
-    return html
-  end
-
-  html = XML.parse_html(html)
-
-  html.xpath_nodes("//a").each do |match|
-    url = URI.parse(match["href"])
-    # Reddit links don't have host
-    if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#"
-      url.scheme = scheme
-      url.host = host
-      match["href"] = url
-    end
-  end
-
-  if host == "www.youtube.com"
-    html = html.xpath_node(%q(//body/p)).not_nil!
-  end
-
-  return html.to_xml(options: XML::SaveOptions::NO_DECL)
-end
-
 def text_to_parsed_content(text : String) : JSON::Any
   nodes = [] of JSON::Any
   # For each line convert line to array of nodes
diff --git a/src/invidious/comments/links_util.cr b/src/invidious/comments/links_util.cr
new file mode 100644
index 00000000..f89b86d3
--- /dev/null
+++ b/src/invidious/comments/links_util.cr
@@ -0,0 +1,76 @@
+module Invidious::Comments
+  extend self
+
+  def replace_links(html)
+    # Check if the document is empty
+    # Prevents edge-case bug with Reddit comments, see issue #3115
+    if html.nil? || html.empty?
+      return html
+    end
+
+    html = XML.parse_html(html)
+
+    html.xpath_nodes(%q(//a)).each do |anchor|
+      url = URI.parse(anchor["href"])
+
+      if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be")
+        if url.host.try &.ends_with? "youtu.be"
+          url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}"
+        else
+          if url.path == "/redirect"
+            params = HTTP::Params.parse(url.query.not_nil!)
+            anchor["href"] = params["q"]?
+          else
+            anchor["href"] = url.request_target
+          end
+        end
+      elsif url.to_s == "#"
+        begin
+          length_seconds = decode_length_seconds(anchor.content)
+        rescue ex
+          length_seconds = decode_time(anchor.content)
+        end
+
+        if length_seconds > 0
+          anchor["href"] = "javascript:void(0)"
+          anchor["onclick"] = "player.currentTime(#{length_seconds})"
+        else
+          anchor["href"] = url.request_target
+        end
+      end
+    end
+
+    html = html.xpath_node(%q(//body)).not_nil!
+    if node = html.xpath_node(%q(./p))
+      html = node
+    end
+
+    return html.to_xml(options: XML::SaveOptions::NO_DECL)
+  end
+
+  def fill_links(html, scheme, host)
+    # Check if the document is empty
+    # Prevents edge-case bug with Reddit comments, see issue #3115
+    if html.nil? || html.empty?
+      return html
+    end
+
+    html = XML.parse_html(html)
+
+    html.xpath_nodes("//a").each do |match|
+      url = URI.parse(match["href"])
+      # Reddit links don't have host
+      if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#"
+        url.scheme = scheme
+        url.host = host
+        match["href"] = url
+      end
+    end
+
+    if host == "www.youtube.com"
+      html = html.xpath_node(%q(//body/p)).not_nil!
+    end
+
+    return html.to_xml(options: XML::SaveOptions::NO_DECL)
+  end
+end
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index 6feaaef4..af4fc806 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -362,8 +362,8 @@ module Invidious::Routes::API::V1::Videos
         return reddit_thread.to_json
       else
         content_html = Frontend::Comments.template_reddit(comments, locale)
-        content_html = fill_links(content_html, "https", "www.reddit.com")
-        content_html = replace_links(content_html)
+        content_html = Comments.fill_links(content_html, "https", "www.reddit.com")
+        content_html = Comments.replace_links(content_html)
         response = {
           "title"       => reddit_thread.title,
           "permalink"   => reddit_thread.permalink,
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index 6b441a48..e5cf3716 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -101,8 +101,8 @@ module Invidious::Routes::Watch
               comments, reddit_thread = Comments.fetch_reddit(id)
               comment_html = Frontend::Comments.template_reddit(comments, locale)
 
-              comment_html = fill_links(comment_html, "https", "www.reddit.com")
-              comment_html = replace_links(comment_html)
+              comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com")
+              comment_html = Comments.replace_links(comment_html)
             end
           end
         elsif source == "reddit"
@@ -110,8 +110,8 @@ module Invidious::Routes::Watch
             comments, reddit_thread = Comments.fetch_reddit(id)
             comment_html = Frontend::Comments.template_reddit(comments, locale)
 
-            comment_html = fill_links(comment_html, "https", "www.reddit.com")
-            comment_html = replace_links(comment_html)
+            comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com")
+            comment_html = Comments.replace_links(comment_html)
           rescue ex
             if preferences.comments[1] == "youtube"
               comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]

From 4379a3d873540460859ec30845dfba66a33d0aea Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 20:23:47 +0200
Subject: [PATCH 08/10] Comments: Move ctoken functions to youtube.cr

---
 spec/invidious/helpers_spec.cr    | 12 --------
 src/invidious/comments.cr         | 44 ----------------------------
 src/invidious/comments/youtube.cr | 48 +++++++++++++++++++++++++++++--
 3 files changed, 46 insertions(+), 58 deletions(-)

diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr
index f81cd29a..142e1653 100644
--- a/spec/invidious/helpers_spec.cr
+++ b/spec/invidious/helpers_spec.cr
@@ -23,18 +23,6 @@ Spectator.describe "Helper" do
     end
   end
 
-  describe "#produce_comment_continuation" do
-    it "correctly produces a continuation token for comments" do
-      expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D")
-
-      expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i1yz21HI4xrtsYXVC-2_kfZ6kx1yjYQumXAAxqH3CAd7ZxKxfLdZS1__fqhCtOASRbbpSBGH_tH1J96Dxux-Qfjk-lUbupMqv08Q3aHzGu7p70VoUMHhI2-GoJpnbpmcOxkGzeIuenRS_ym2Y8fkDowhqLPFgsS0n4djnZ2UmC17F3Ch3N1S1UYf1ZVOc991qOC1iW9kJDzyvRQTWCPsJUPneSaAKW-Rr97pdesOkR4i8cNvHZRnQKe2HEfsvlJOb2C3lF1dJBfJeNfnQYeh5hv6_fZN7bt3-JL1Xk3Qc9NXNxmmbDpwAC_yFR8dthFfUJdyIO9Nu1D79MLYeR-H5HxqUJokkJiGIz4lTE_CXXbhAI")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyiQMK8wJBRFNKX2kxeXoyMUhJNHhydHNZWFZDLTJfa2ZaNmt4MXlqWVF1bVhBQXhxSDNDQWQ3WnhLeGZMZFpTMV9fZnFoQ3RPQVNSYmJwU0JHSF90SDFKOTZEeHV4LVFmamstbFVidXBNcXYwOFEzYUh6R3U3cDcwVm9VTUhoSTItR29KcG5icG1jT3hrR3plSXVlblJTX3ltMlk4ZmtEb3docUxQRmdzUzBuNGRqbloyVW1DMTdGM0NoM04xUzFVWWYxWlZPYzk5MXFPQzFpVzlrSkR6eXZSUVRXQ1BzSlVQbmVTYUFLVy1Scjk3cGRlc09rUjRpOGNOdkhaUm5RS2UySEVmc3ZsSk9iMkMzbEYxZEpCZkplTmZuUVllaDVodjZfZlpON2J0My1KTDFYazNRYzlOWE54bW1iRHB3QUNfeUZSOGR0aEZmVUpkeUlPOU51MUQ3OU1MWWVSLUg1SHhxVUpva2tKaUdJejRsVEVfQ1hYYmhBSSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D")
-
-      expect(produce_comment_continuation("29-q7YnyUmY", "")).to eq("EkMSCzI5LXE3WW55VW1ZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iCzI5LXE3WW55VW1ZMAAoFA%3D%3D")
-
-      expect(produce_comment_continuation("CvFH_6DNRCY", "")).to eq("EkMSC0N2RkhfNkROUkNZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iC0N2RkhfNkROUkNZMAAoFA%3D%3D")
-    end
-  end
-
   describe "#produce_channel_community_continuation" do
     it "correctly produces a continuation token for a channel community" do
       expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4")).to eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D")
diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index 3c7e2bb4..c8cdc2df 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -87,47 +87,3 @@ def content_to_comment_html(content, video_id : String? = "")
 
   return html_array.join("").delete('\ufeff')
 end
-
-def produce_comment_continuation(video_id, cursor = "", sort_by = "top")
-  object = {
-    "2:embedded" => {
-      "2:string"    => video_id,
-      "25:varint"   => 0_i64,
-      "28:varint"   => 1_i64,
-      "36:embedded" => {
-        "5:varint" => -1_i64,
-        "8:varint" => 0_i64,
-      },
-      "40:embedded" => {
-        "1:varint" => 4_i64,
-        "3:string" => "https://www.youtube.com",
-        "4:string" => "",
-      },
-    },
-    "3:varint"   => 6_i64,
-    "6:embedded" => {
-      "1:string"   => cursor,
-      "4:embedded" => {
-        "4:string" => video_id,
-        "6:varint" => 0_i64,
-      },
-      "5:varint" => 20_i64,
-    },
-  }
-
-  case sort_by
-  when "top"
-    object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64
-  when "new", "newest"
-    object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64
-  else # top
-    object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64
-  end
-
-  continuation = object.try { |i| Protodec::Any.cast_json(i) }
-    .try { |i| Protodec::Any.from_json(i) }
-    .try { |i| Base64.urlsafe_encode(i) }
-    .try { |i| URI.encode_www_form(i) }
-
-  return continuation
-end
diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr
index c262876e..1ba1b534 100644
--- a/src/invidious/comments/youtube.cr
+++ b/src/invidious/comments/youtube.cr
@@ -4,9 +4,9 @@ module Invidious::Comments
   def fetch_youtube(id, cursor, format, locale, thin_mode, region, sort_by = "top")
     case cursor
     when nil, ""
-      ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by)
+      ctoken = Comments.produce_continuation(id, cursor: "", sort_by: sort_by)
     when .starts_with? "ADSJ"
-      ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by)
+      ctoken = Comments.produce_continuation(id, cursor: cursor, sort_by: sort_by)
     else
       ctoken = cursor
     end
@@ -203,4 +203,48 @@ module Invidious::Comments
 
     return response
   end
+
+  def produce_continuation(video_id, cursor = "", sort_by = "top")
+    object = {
+      "2:embedded" => {
+        "2:string"    => video_id,
+        "25:varint"   => 0_i64,
+        "28:varint"   => 1_i64,
+        "36:embedded" => {
+          "5:varint" => -1_i64,
+          "8:varint" => 0_i64,
+        },
+        "40:embedded" => {
+          "1:varint" => 4_i64,
+          "3:string" => "https://www.youtube.com",
+          "4:string" => "",
+        },
+      },
+      "3:varint"   => 6_i64,
+      "6:embedded" => {
+        "1:string"   => cursor,
+        "4:embedded" => {
+          "4:string" => video_id,
+          "6:varint" => 0_i64,
+        },
+        "5:varint" => 20_i64,
+      },
+    }
+
+    case sort_by
+    when "top"
+      object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64
+    when "new", "newest"
+      object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64
+    else # top
+      object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64
+    end
+
+    continuation = object.try { |i| Protodec::Any.cast_json(i) }
+      .try { |i| Protodec::Any.from_json(i) }
+      .try { |i| Base64.urlsafe_encode(i) }
+      .try { |i| URI.encode_www_form(i) }
+
+    return continuation
+  end
 end

From f0c8477905e6aae5c3979a64dab964dc4b353fe0 Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 20:27:02 +0200
Subject: [PATCH 09/10] Comments: Move content-related functions to their own
 file

---
 src/invidious/{comments.cr => comments/content.cr} | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename src/invidious/{comments.cr => comments/content.cr} (100%)

diff --git a/src/invidious/comments.cr b/src/invidious/comments/content.cr
similarity index 100%
rename from src/invidious/comments.cr
rename to src/invidious/comments/content.cr

From 193c510c65cfc6c56f4409180b798c9eb8ef3efd Mon Sep 17 00:00:00 2001
From: Samantaz Fox <coding@samantaz.fr>
Date: Sat, 6 May 2023 20:53:39 +0200
Subject: [PATCH 10/10] Spec: Update require to point to new files

---
 spec/parsers_helper.cr | 2 +-
 spec/spec_helper.cr    | 1 -
 2 files changed, 1 insertion(+), 2 deletions(-)

diff --git a/spec/parsers_helper.cr b/spec/parsers_helper.cr
index bf05f9ec..6589acad 100644
--- a/spec/parsers_helper.cr
+++ b/spec/parsers_helper.cr
@@ -13,7 +13,7 @@ require "../src/invidious/helpers/utils"
 
 require "../src/invidious/videos"
 require "../src/invidious/videos/*"
-require "../src/invidious/comments"
+require "../src/invidious/comments/content"
 
 require "../src/invidious/helpers/serialized_yt_data"
 require "../src/invidious/yt_backend/extractors"
diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr
index f8bfa718..b3060acf 100644
--- a/spec/spec_helper.cr
+++ b/spec/spec_helper.cr
@@ -7,7 +7,6 @@ require "../src/invidious/helpers/*"
 require "../src/invidious/channels/*"
 require "../src/invidious/videos/caption"
 require "../src/invidious/videos"
-require "../src/invidious/comments"
 require "../src/invidious/playlists"
 require "../src/invidious/search/ctoken"
 require "../src/invidious/trending"