mirror of
				https://github.com/iv-org/invidious.git
				synced 2025-10-31 04:32:02 +00:00 
			
		
		
		
	Extract channel routes (#2227)
* Extract primary channel routes from invidious.cr Also removes timedtext_video stub since all it does is redirect to the homepage. However, Invidious's 404 handler already does this. -- As the template for the channel about page doesn't exist yet, the behavior for the /channel/:ucid/about endpoint has been changed to be the same as what's currently present on Invidious (cherry picked from commit8fad19d805) * Manually extract brand_redirect from1b569bbc99This commit manually extracts the brand_redirect function from the commit mentioned. However, the redirect to the `.../about` endpoint is removed due to the fact that it doesn't exist yet. This commit is also mainly just a bridge for the next few cherry picks from \#2215 * Update brand_redirect to use youtubei resolve_url (cherry picked from commit53335fe7cf) * Add additional channel endpoints to brand_redirect (cherry picked from commit8fc6f3add6) * Add separate handler for /profile endpoint * Add /channel/:ucid/home route * Document all channel brand_urls
This commit is contained in:
		
							
								
								
									
										229
									
								
								src/invidious.cr
									
									
									
									
									
								
							
							
						
						
									
										229
									
								
								src/invidious.cr
									
									
									
									
									
								
							| @@ -309,6 +309,24 @@ Invidious::Routing.get "/", Invidious::Routes::Misc, :home | ||||
| Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy | ||||
| Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses | ||||
|  | ||||
| Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home | ||||
| Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home | ||||
| Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos | ||||
| Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists | ||||
| Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community | ||||
| Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about | ||||
|  | ||||
| ["", "/videos", "/playlists", "/community", "/about"].each do |path| | ||||
|   # /c/LinusTechTips | ||||
|   Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect | ||||
|   # /user/linustechtips | Not always the same as /c/ | ||||
|   Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect | ||||
|   # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow | ||||
|   Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect | ||||
|   # /profile?user=linustechtips | ||||
|   Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile | ||||
| end | ||||
|  | ||||
| Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle | ||||
| Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect | ||||
| Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect | ||||
| @@ -1618,217 +1636,6 @@ end | ||||
|   end | ||||
| end | ||||
|  | ||||
| # YouTube appears to let users set a "brand" URL that | ||||
| # is different from their username, so we convert that here | ||||
| get "/c/:user" do |env| | ||||
|   locale = LOCALES[env.get("preferences").as(Preferences).locale]? | ||||
|  | ||||
|   user = env.params.url["user"] | ||||
|  | ||||
|   response = YT_POOL.client &.get("/c/#{user}") | ||||
|   html = XML.parse_html(response.body) | ||||
|  | ||||
|   ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] | ||||
|   next env.redirect "/" if !ucid | ||||
|  | ||||
|   env.redirect "/channel/#{ucid}" | ||||
| end | ||||
|  | ||||
| # Legacy endpoint for /user/:username | ||||
| get "/profile" do |env| | ||||
|   user = env.params.query["user"]? | ||||
|   if !user | ||||
|     env.redirect "/" | ||||
|   else | ||||
|     env.redirect "/user/#{user}" | ||||
|   end | ||||
| end | ||||
|  | ||||
| get "/attribution_link" do |env| | ||||
|   if query = env.params.query["u"]? | ||||
|     url = URI.parse(query).request_target | ||||
|   else | ||||
|     url = "/" | ||||
|   end | ||||
|  | ||||
|   env.redirect url | ||||
| end | ||||
|  | ||||
| # Page used by YouTube to provide captioning widget, since we | ||||
| # don't support it we redirect to '/' | ||||
| get "/timedtext_video" do |env| | ||||
|   env.redirect "/" | ||||
| end | ||||
|  | ||||
| get "/user/:user" do |env| | ||||
|   user = env.params.url["user"] | ||||
|   env.redirect "/channel/#{user}" | ||||
| end | ||||
|  | ||||
| get "/user/:user/videos" do |env| | ||||
|   user = env.params.url["user"] | ||||
|   env.redirect "/channel/#{user}/videos" | ||||
| end | ||||
|  | ||||
| get "/user/:user/about" do |env| | ||||
|   user = env.params.url["user"] | ||||
|   env.redirect "/channel/#{user}" | ||||
| end | ||||
|  | ||||
| get "/channel/:ucid/about" do |env| | ||||
|   ucid = env.params.url["ucid"] | ||||
|   env.redirect "/channel/#{ucid}" | ||||
| end | ||||
|  | ||||
| get "/channel/:ucid" do |env| | ||||
|   locale = LOCALES[env.get("preferences").as(Preferences).locale]? | ||||
|  | ||||
|   user = env.get? "user" | ||||
|   if user | ||||
|     user = user.as(User) | ||||
|     subscriptions = user.subscriptions | ||||
|   end | ||||
|   subscriptions ||= [] of String | ||||
|  | ||||
|   ucid = env.params.url["ucid"] | ||||
|  | ||||
|   page = env.params.query["page"]?.try &.to_i? | ||||
|   page ||= 1 | ||||
|  | ||||
|   continuation = env.params.query["continuation"]? | ||||
|  | ||||
|   sort_by = env.params.query["sort_by"]?.try &.downcase | ||||
|  | ||||
|   begin | ||||
|     channel = get_about_info(ucid, locale) | ||||
|   rescue ex : ChannelRedirect | ||||
|     next env.redirect env.request.resource.gsub(ucid, ex.channel_id) | ||||
|   rescue ex | ||||
|     next error_template(500, ex) | ||||
|   end | ||||
|  | ||||
|   if channel.auto_generated | ||||
|     sort_options = {"last", "oldest", "newest"} | ||||
|     sort_by ||= "last" | ||||
|  | ||||
|     items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) | ||||
|     items.uniq! do |item| | ||||
|       if item.responds_to?(:title) | ||||
|         item.title | ||||
|       elsif item.responds_to?(:author) | ||||
|         item.author | ||||
|       end | ||||
|     end | ||||
|     items = items.select(&.is_a?(SearchPlaylist)).map(&.as(SearchPlaylist)) | ||||
|     items.each { |item| item.author = "" } | ||||
|   else | ||||
|     sort_options = {"newest", "oldest", "popular"} | ||||
|     sort_by ||= "newest" | ||||
|  | ||||
|     count, items = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) | ||||
|     items.reject! &.paid | ||||
|  | ||||
|     env.set "search", "channel:#{channel.ucid} " | ||||
|   end | ||||
|  | ||||
|   templated "channel" | ||||
| end | ||||
|  | ||||
| get "/channel/:ucid/videos" do |env| | ||||
|   locale = LOCALES[env.get("preferences").as(Preferences).locale]? | ||||
|  | ||||
|   ucid = env.params.url["ucid"] | ||||
|   params = env.request.query | ||||
|  | ||||
|   if !params || params.empty? | ||||
|     params = "" | ||||
|   else | ||||
|     params = "?#{params}" | ||||
|   end | ||||
|  | ||||
|   env.redirect "/channel/#{ucid}#{params}" | ||||
| end | ||||
|  | ||||
| get "/channel/:ucid/playlists" do |env| | ||||
|   locale = LOCALES[env.get("preferences").as(Preferences).locale]? | ||||
|  | ||||
|   user = env.get? "user" | ||||
|   if user | ||||
|     user = user.as(User) | ||||
|     subscriptions = user.subscriptions | ||||
|   end | ||||
|   subscriptions ||= [] of String | ||||
|  | ||||
|   ucid = env.params.url["ucid"] | ||||
|  | ||||
|   continuation = env.params.query["continuation"]? | ||||
|  | ||||
|   sort_by = env.params.query["sort_by"]?.try &.downcase | ||||
|   sort_by ||= "last" | ||||
|  | ||||
|   begin | ||||
|     channel = get_about_info(ucid, locale) | ||||
|   rescue ex : ChannelRedirect | ||||
|     next env.redirect env.request.resource.gsub(ucid, ex.channel_id) | ||||
|   rescue ex | ||||
|     next error_template(500, ex) | ||||
|   end | ||||
|  | ||||
|   if channel.auto_generated | ||||
|     next env.redirect "/channel/#{channel.ucid}" | ||||
|   end | ||||
|  | ||||
|   items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) | ||||
|   items = items.select { |item| item.is_a?(SearchPlaylist) }.map { |item| item.as(SearchPlaylist) } | ||||
|   items.each { |item| item.author = "" } | ||||
|  | ||||
|   env.set "search", "channel:#{channel.ucid} " | ||||
|   templated "playlists" | ||||
| end | ||||
|  | ||||
| get "/channel/:ucid/community" do |env| | ||||
|   locale = LOCALES[env.get("preferences").as(Preferences).locale]? | ||||
|  | ||||
|   user = env.get? "user" | ||||
|   if user | ||||
|     user = user.as(User) | ||||
|     subscriptions = user.subscriptions | ||||
|   end | ||||
|   subscriptions ||= [] of String | ||||
|  | ||||
|   ucid = env.params.url["ucid"] | ||||
|  | ||||
|   thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode | ||||
|   thin_mode = thin_mode == "true" | ||||
|  | ||||
|   continuation = env.params.query["continuation"]? | ||||
|   # sort_by = env.params.query["sort_by"]?.try &.downcase | ||||
|  | ||||
|   begin | ||||
|     channel = get_about_info(ucid, locale) | ||||
|   rescue ex : ChannelRedirect | ||||
|     next env.redirect env.request.resource.gsub(ucid, ex.channel_id) | ||||
|   rescue ex | ||||
|     next error_template(500, ex) | ||||
|   end | ||||
|  | ||||
|   if !channel.tabs.includes? "community" | ||||
|     next env.redirect "/channel/#{channel.ucid}" | ||||
|   end | ||||
|  | ||||
|   begin | ||||
|     items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) | ||||
|   rescue ex : InfoException | ||||
|     env.response.status_code = 500 | ||||
|     error_message = ex.message | ||||
|   rescue ex | ||||
|     next error_template(500, ex) | ||||
|   end | ||||
|  | ||||
|   env.set "search", "channel:#{channel.ucid} " | ||||
|   templated "community" | ||||
| end | ||||
|  | ||||
| # API Endpoints | ||||
|  | ||||
| get "/api/v1/stats" do |env| | ||||
|   | ||||
							
								
								
									
										172
									
								
								src/invidious/routes/channels.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								src/invidious/routes/channels.cr
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| class Invidious::Routes::Channels < Invidious::Routes::BaseRoute | ||||
|   def home(env) | ||||
|     self.videos(env) | ||||
|   end | ||||
|  | ||||
|   def videos(env) | ||||
|     data = self.fetch_basic_information(env) | ||||
|     if !data.is_a?(Tuple) | ||||
|       return data | ||||
|     end | ||||
|     locale, user, subscriptions, continuation, ucid, channel = data | ||||
|  | ||||
|     page = env.params.query["page"]?.try &.to_i? | ||||
|     page ||= 1 | ||||
|  | ||||
|     sort_by = env.params.query["sort_by"]?.try &.downcase | ||||
|  | ||||
|     if channel.auto_generated | ||||
|       sort_options = {"last", "oldest", "newest"} | ||||
|       sort_by ||= "last" | ||||
|  | ||||
|       items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) | ||||
|       items.uniq! do |item| | ||||
|         if item.responds_to?(:title) | ||||
|           item.title | ||||
|         elsif item.responds_to?(:author) | ||||
|           item.author | ||||
|         end | ||||
|       end | ||||
|       items = items.select(&.is_a?(SearchPlaylist)).map(&.as(SearchPlaylist)) | ||||
|       items.each { |item| item.author = "" } | ||||
|     else | ||||
|       sort_options = {"newest", "oldest", "popular"} | ||||
|       sort_by ||= "newest" | ||||
|  | ||||
|       count, items = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) | ||||
|       items.reject! &.paid | ||||
|     end | ||||
|  | ||||
|     templated "channel" | ||||
|   end | ||||
|  | ||||
|   def playlists(env) | ||||
|     data = self.fetch_basic_information(env) | ||||
|     if !data.is_a?(Tuple) | ||||
|       return data | ||||
|     end | ||||
|     locale, user, subscriptions, continuation, ucid, channel = data | ||||
|  | ||||
|     sort_options = {"last", "oldest", "newest"} | ||||
|     sort_by = env.params.query["sort_by"]?.try &.downcase | ||||
|     sort_by ||= "last" | ||||
|  | ||||
|     if channel.auto_generated | ||||
|       return env.redirect "/channel/#{channel.ucid}" | ||||
|     end | ||||
|  | ||||
|     items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) | ||||
|     items = items.select { |item| item.is_a?(SearchPlaylist) }.map { |item| item.as(SearchPlaylist) } | ||||
|     items.each { |item| item.author = "" } | ||||
|  | ||||
|     templated "playlists" | ||||
|   end | ||||
|  | ||||
|   def community(env) | ||||
|     data = self.fetch_basic_information(env) | ||||
|     if !data.is_a?(Tuple) | ||||
|       return data | ||||
|     end | ||||
|     locale, user, subscriptions, continuation, ucid, channel = data | ||||
|  | ||||
|     thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode | ||||
|     thin_mode = thin_mode == "true" | ||||
|  | ||||
|     continuation = env.params.query["continuation"]? | ||||
|     # sort_by = env.params.query["sort_by"]?.try &.downcase | ||||
|  | ||||
|     if !channel.tabs.includes? "community" | ||||
|       return env.redirect "/channel/#{channel.ucid}" | ||||
|     end | ||||
|  | ||||
|     begin | ||||
|       items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) | ||||
|     rescue ex : InfoException | ||||
|       env.response.status_code = 500 | ||||
|       error_message = ex.message | ||||
|     rescue ex | ||||
|       return error_template(500, ex) | ||||
|     end | ||||
|  | ||||
|     templated "community" | ||||
|   end | ||||
|  | ||||
|   def about(env) | ||||
|     data = self.fetch_basic_information(env) | ||||
|     if !data.is_a?(Tuple) | ||||
|       return data | ||||
|     end | ||||
|     locale, user, subscriptions, continuation, ucid, channel = data | ||||
|  | ||||
|     env.redirect "/channel/#{ucid}" | ||||
|   end | ||||
|  | ||||
|   # Redirects brand url channels to a normal /channel/:ucid route | ||||
|   def brand_redirect(env) | ||||
|     locale = LOCALES[env.get("preferences").as(Preferences).locale]? | ||||
|  | ||||
|     # /attribution_link endpoint needs both the `a` and `u` parameter | ||||
|     # and in order to avoid detection from YouTube we should only send the required ones | ||||
|     # without any of the additional url parameters that only Invidious uses. | ||||
|     yt_url_params = URI::Params.encode(env.params.query.to_h.select(["a", "u", "user"])) | ||||
|  | ||||
|     # Retrieves URL params that only Invidious uses | ||||
|     invidious_url_params = URI::Params.encode(env.params.query.to_h.select!(["a", "u", "user"])) | ||||
|  | ||||
|     begin | ||||
|       resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}") | ||||
|       ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"] | ||||
|     rescue ex : InfoException | KeyError | ||||
|       raise InfoException.new(translate(locale, "This channel does not exist.")) | ||||
|     end | ||||
|  | ||||
|     selected_tab = env.request.path.split("/")[-1] | ||||
|     if ["home", "videos", "playlists", "community", "channels", "about"].includes? selected_tab | ||||
|       url = "/channel/#{ucid}/#{selected_tab}" | ||||
|     else | ||||
|       url = "/channel/#{ucid}" | ||||
|     end | ||||
|  | ||||
|     env.redirect url | ||||
|   end | ||||
|  | ||||
|   # Handles redirects for the /profile endpoint | ||||
|   def profile(env) | ||||
|     # The /profile endpoint is special. If passed into the resolve_url | ||||
|     # endpoint YouTube would return a sign in page instead of an /channel/:ucid | ||||
|     # thus we'll add an edge case and handle it here. | ||||
|  | ||||
|     uri_params = env.params.query.size > 0 ? "?#{env.params.query}" : "" | ||||
|  | ||||
|     user = env.params.query["user"]? | ||||
|     if !user | ||||
|       raise InfoException.new("This channel does not exist.") | ||||
|     else | ||||
|       env.redirect "/user/#{user}#{uri_params}" | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   private def fetch_basic_information(env) | ||||
|     locale = LOCALES[env.get("preferences").as(Preferences).locale]? | ||||
|  | ||||
|     user = env.get? "user" | ||||
|     if user | ||||
|       user = user.as(User) | ||||
|       subscriptions = user.subscriptions | ||||
|     end | ||||
|     subscriptions ||= [] of String | ||||
|  | ||||
|     ucid = env.params.url["ucid"] | ||||
|     continuation = env.params.query["continuation"]? | ||||
|  | ||||
|     begin | ||||
|       channel = get_about_info(ucid, locale) | ||||
|     rescue ex : ChannelRedirect | ||||
|       return env.redirect env.request.resource.gsub(ucid, ex.channel_id) | ||||
|     rescue ex | ||||
|       return error_template(500, ex) | ||||
|     end | ||||
|  | ||||
|     return {locale, user, subscriptions, continuation, ucid, channel} | ||||
|   end | ||||
| end | ||||
		Reference in New Issue
	
	Block a user
	 syeopite
					syeopite