diff options
| author | Samantaz Fox <coding@samantaz.fr> | 2022-02-24 22:53:06 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-02-24 22:53:06 +0100 |
| commit | 575b039170b7e93b194f628906a238bd930d9e95 (patch) | |
| tree | b432c93a3f40522c92924bb8b55ccbcaba5e8e08 /src | |
| parent | 7a32269d7f0c617dd3221739f5ae9c5910bef607 (diff) | |
| parent | 004e37105106b980736aa026c65ae86c8dc8a828 (diff) | |
| download | invidious-575b039170b7e93b194f628906a238bd930d9e95.tar.gz invidious-575b039170b7e93b194f628906a238bd930d9e95.tar.bz2 invidious-575b039170b7e93b194f628906a238bd930d9e95.zip | |
Merge pull request #2922 from SamantazFox/download-widget-fix
Download widget fix
Diffstat (limited to 'src')
| -rw-r--r-- | src/invidious.cr | 5 | ||||
| -rw-r--r-- | src/invidious/frontend/watch_page.cr | 108 | ||||
| -rw-r--r-- | src/invidious/routes/api/v1/videos.cr | 6 | ||||
| -rw-r--r-- | src/invidious/routes/video_playback.cr | 40 | ||||
| -rw-r--r-- | src/invidious/routes/watch.cr | 53 | ||||
| -rw-r--r-- | src/invidious/views/watch.ecr | 36 |
6 files changed, 191 insertions, 57 deletions
diff --git a/src/invidious.cr b/src/invidious.cr index db3921f6..1bdf3097 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -29,6 +29,8 @@ require "protodec/utils" require "./invidious/database/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" +require "./invidious/frontend/*" + require "./invidious/*" require "./invidious/channels/*" require "./invidious/user/*" @@ -234,6 +236,7 @@ before_all do |env| "/api/manifest/", "/videoplayback", "/latest_version", + "/download", }.any? { |r| env.request.resource.starts_with? r } if env.request.cookies.has_key? "SID" @@ -349,6 +352,8 @@ end Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect + Invidious::Routing.post "/download", Invidious::Routes::Watch, :download + Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr new file mode 100644 index 00000000..80b67641 --- /dev/null +++ b/src/invidious/frontend/watch_page.cr @@ -0,0 +1,108 @@ +module Invidious::Frontend::WatchPage + extend self + + # A handy structure to pass many elements at + # once to the download widget function + struct VideoAssets + getter full_videos : Array(Hash(String, JSON::Any)) + getter video_streams : Array(Hash(String, JSON::Any)) + getter audio_streams : Array(Hash(String, JSON::Any)) + getter captions : Array(Caption) + + def initialize( + @full_videos, + @video_streams, + @audio_streams, + @captions + ) + end + end + + def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String + if CONFIG.disabled?("downloads") + return "<p id=\"download\">#{translate(locale, "Download is disabled.")}</p>" + end + + return String.build(4000) do |str| + str << "<form" + str << " class=\"pure-form pure-form-stacked\"" + str << " action='/download'" + str << " method='post'" + str << " rel='noopener'" + str << " target='_blank'>" + str << '\n' + + # Hidden inputs for video id and title + str << "<input type='hidden' name='id' value='" << video.id << "'/>\n" + str << "<input type='hidden' name='title' value='" << HTML.escape(video.title) << "'/>\n" + + str << "\t<div class=\"pure-control-group\">\n" + + str << "\t\t<label for='download_widget'>" + str << translate(locale, "Download as: ") + str << "</label>\n" + + # TODO: remove inline style + str << "\t\t<select style=\"width:100%\" name='download_widget' id='download_widget'>\n" + + # Non-DASH videos (audio+video) + + video_assets.full_videos.each do |option| + mimetype = option["mimeType"].as_s.split(";")[0] + + height = itag_to_metadata?(option["itag"]).try &.["height"]? + + value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json + + str << "\t\t\t<option value='" << value << "'>" + str << (height || "~240") << "p - " << mimetype + str << "</option>\n" + end + + # DASH video streams + + video_assets.video_streams.each do |option| + mimetype = option["mimeType"].as_s.split(";")[0] + + value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json + + str << "\t\t\t<option value='" << value << "'>" + str << option["qualityLabel"] << " - " << mimetype << " @ " << option["fps"] << "fps - video only" + str << "</option>\n" + end + + # DASH audio streams + + video_assets.audio_streams.each do |option| + mimetype = option["mimeType"].as_s.split(";")[0] + + value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json + + str << "\t\t\t<option value='" << value << "'>" + str << mimetype << " @ " << (option["bitrate"]?.try &.as_i./ 1000) << "k - audio only" + str << "</option>\n" + end + + # Subtitles (a.k.a "closed captions") + + video_assets.captions.each do |caption| + value = {"label": caption.name, "ext": "#{caption.language_code}.vtt"}.to_json + + str << "\t\t\t<option value='" << value << "'>" + str << translate(locale, "download_subtitles", translate(locale, caption.name)) + str << "</option>\n" + end + + # End of form + + str << "\t\t</select>\n" + str << "\t</div>\n" + + str << "\t<button type=\"submit\" class=\"pure-button pure-button-primary\">\n" + str << "\t\t<b>" << translate(locale, "Download") << "</b>\n" + str << "\t</button>\n" + + str << "</form>\n" + end + end +end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 2a4911db..a9f891f5 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -23,7 +23,11 @@ module Invidious::Routes::API::V1::Videos env.response.content_type = "application/json" id = env.params.url["id"] - region = env.params.query["region"]? + region = env.params.query["region"]? || env.params.body["region"]? + + if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/) + return error_json(400, "Invalid video ID") + end # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 # It is possible to use `/api/timedtext?type=list&v=#{id}` and diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 6ac1e780..3a92ef96 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -164,7 +164,9 @@ module Invidious::Routes::VideoPlayback if title = query_params["title"]? # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" + filename = URI.encode_www_form(title, space_to_plus: false) + header = "attachment; filename=\"#{filename}\"; filename*=UTF-8''#{filename}" + env.response.headers["Content-Disposition"] = header end if !resp.headers.includes_word?("Transfer-Encoding", "chunked") @@ -242,31 +244,25 @@ module Invidious::Routes::VideoPlayback # YouTube /videoplayback links expire after 6 hours, # so we have a mechanism here to redirect to the latest version def self.latest_version(env) - if env.params.query["download_widget"]? - download_widget = JSON.parse(env.params.query["download_widget"]) + id = env.params.query["id"]? + itag = env.params.query["itag"]?.try &.to_i? - id = download_widget["id"].as_s - title = URI.decode_www_form(download_widget["title"].as_s) - - if label = download_widget["label"]? - return env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}" - else - itag = download_widget["itag"].as_s.to_i - local = "true" - end + # Sanity checks + if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/) + return error_template(400, "Invalid video ID") end - id ||= env.params.query["id"]? - itag ||= env.params.query["itag"]?.try &.to_i + if itag.nil? || itag <= 0 || itag >= 1000 + return error_template(400, "Invalid itag") + end region = env.params.query["region"]? + local = (env.params.query["local"]? == "true") - local ||= env.params.query["local"]? - local ||= "false" - local = local == "true" + title = env.params.query["title"]? - if !id || !itag - haltf env, status_code: 400, response: "TESTING" + if title && CONFIG.disabled?("downloads") + return error_template(403, "Administrator has disabled this endpoint.") end video = get_video(id, region: region) @@ -278,8 +274,10 @@ module Invidious::Routes::VideoPlayback haltf env, status_code: 404 end - url = URI.parse(url).request_target.not_nil! if local - url = "#{url}&title=#{title}" if title + if local + url = URI.parse(url).request_target.not_nil! + url += "&title=#{URI.encode_www_form(title, space_to_plus: false)}" if title + end return env.redirect url end diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index f5454bb5..867ffa6a 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -189,6 +189,14 @@ module Invidious::Routes::Watch return env.redirect url end + # Structure used for the download widget + video_assets = Invidious::Frontend::WatchPage::VideoAssets.new( + full_videos: fmt_stream, + video_streams: video_streams, + audio_streams: audio_streams, + captions: video.captions + ) + templated "watch" end @@ -281,4 +289,49 @@ module Invidious::Routes::Watch return error_template(404, "The requested clip doesn't exist") end end + + def self.download(env) + if CONFIG.disabled?("downloads") + return error_template(403, "Administrator has disabled this endpoint.") + end + + title = env.params.body["title"]? || "" + video_id = env.params.body["id"]? || "" + selection = env.params.body["download_widget"]? + + if title.empty? || video_id.empty? || selection.nil? + return error_template(400, "Missing form data") + end + + download_widget = JSON.parse(selection) + + extension = download_widget["ext"].as_s + filename = "#{video_id}-#{title}.#{extension}" + + # Pass form parameters as URL parameters for the handlers of both + # /latest_version and /api/v1/captions. This avoids an un-necessary + # redirect and duplicated (and hazardous) sanity checks. + env.params.query["id"] = video_id + env.params.query["title"] = filename + + # Delete the useless ones + env.params.body.delete("id") + env.params.body.delete("title") + env.params.body.delete("download_widget") + + if label = download_widget["label"]? + # URL params specific to /api/v1/captions/:id + env.params.query["label"] = URI.encode_www_form(label.as_s, space_to_plus: false) + + return Invidious::Routes::API::V1::Videos.captions(env) + elsif itag = download_widget["itag"]?.try &.as_i + # URL params specific to /latest_version + env.params.query["itag"] = itag.to_s + env.params.query["local"] = "true" + + return Invidious::Routes::VideoPlayback.latest_version(env) + else + return error_template(400, "Invalid label or itag") + end + end end diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 2e0aee99..0e4af3ab 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -168,41 +168,7 @@ we're going to need to do it here in order to allow for translations. <% end %> <% end %> - <% if CONFIG.dmca_content.includes?(video.id) || CONFIG.disabled?("downloads") %> - <p id="download"><%= translate(locale, "Download is disabled.") %></p> - <% else %> - <form class="pure-form pure-form-stacked" action="/latest_version" method="get" rel="noopener" target="_blank"> - <div class="pure-control-group"> - <label for="download_widget"><%= translate(locale, "Download as: ") %></label> - <select style="width:100%" name="download_widget" id="download_widget"> - <% fmt_stream.each do |option| %> - <option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'> - <%= itag_to_metadata?(option["itag"]).try &.["height"]? || "~240" %>p - <%= option["mimeType"].as_s.split(";")[0] %> - </option> - <% end %> - <% video_streams.each do |option| %> - <option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'> - <%= option["qualityLabel"] %> - <%= option["mimeType"].as_s.split(";")[0] %> @ <%= option["fps"] %>fps - video only - </option> - <% end %> - <% audio_streams.each do |option| %> - <option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'> - <%= option["mimeType"].as_s.split(";")[0] %> @ <%= option["bitrate"]?.try &.as_i./ 1000 %>k - audio only - </option> - <% end %> - <% captions.each do |caption| %> - <option value='{"id":"<%= video.id %>","label":"<%= caption.name %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= caption.language_code %>.vtt"}'> - <%= translate(locale, "download_subtitles", translate(locale, caption.name)) %> - </option> - <% end %> - </select> - </div> - - <button type="submit" class="pure-button pure-button-primary"> - <b><%= translate(locale, "Download") %></b> - </button> - </form> - <% end %> + <%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %> <p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p> <p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p> |
