mirror of https://github.com/iv-org/invidious.git
Merge pull request #2666 from matthewmcgarvey/extract-stuff-1
Move more routes to new pattern
This commit is contained in:
commit
508f137b30
507
src/invidious.cr
507
src/invidious.cr
|
@ -339,6 +339,7 @@ end
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
|
Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
|
||||||
|
Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched
|
||||||
Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
|
Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
|
||||||
Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
|
Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
|
||||||
Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect
|
Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect
|
||||||
|
@ -372,6 +373,8 @@ end
|
||||||
Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
|
Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
|
||||||
Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
|
Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
|
||||||
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
|
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
|
||||||
|
Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control
|
||||||
|
Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
|
||||||
|
|
||||||
# Feeds
|
# Feeds
|
||||||
Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
|
Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
|
||||||
|
@ -390,6 +393,11 @@ end
|
||||||
# Support push notifications via PubSubHubbub
|
# Support push notifications via PubSubHubbub
|
||||||
Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get
|
Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get
|
||||||
Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post
|
Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post
|
||||||
|
|
||||||
|
Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify
|
||||||
|
|
||||||
|
Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription
|
||||||
|
Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht
|
Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht
|
||||||
|
@ -406,505 +414,6 @@ define_v1_api_routes()
|
||||||
define_api_manifest_routes()
|
define_api_manifest_routes()
|
||||||
define_video_playback_routes()
|
define_video_playback_routes()
|
||||||
|
|
||||||
# Users
|
|
||||||
|
|
||||||
post "/watch_ajax" do |env|
|
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
|
||||||
|
|
||||||
user = env.get? "user"
|
|
||||||
sid = env.get? "sid"
|
|
||||||
referer = get_referer(env, "/feed/subscriptions")
|
|
||||||
|
|
||||||
redirect = env.params.query["redirect"]?
|
|
||||||
redirect ||= "true"
|
|
||||||
redirect = redirect == "true"
|
|
||||||
|
|
||||||
if !user
|
|
||||||
if redirect
|
|
||||||
next env.redirect referer
|
|
||||||
else
|
|
||||||
next error_json(403, "No such user")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
user = user.as(User)
|
|
||||||
sid = sid.as(String)
|
|
||||||
token = env.params.body["csrf_token"]?
|
|
||||||
|
|
||||||
id = env.params.query["id"]?
|
|
||||||
if !id
|
|
||||||
env.response.status_code = 400
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
|
||||||
rescue ex
|
|
||||||
if redirect
|
|
||||||
next error_template(400, ex)
|
|
||||||
else
|
|
||||||
next error_json(400, ex)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if env.params.query["action_mark_watched"]?
|
|
||||||
action = "action_mark_watched"
|
|
||||||
elsif env.params.query["action_mark_unwatched"]?
|
|
||||||
action = "action_mark_unwatched"
|
|
||||||
else
|
|
||||||
next env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
case action
|
|
||||||
when "action_mark_watched"
|
|
||||||
if !user.watched.includes? id
|
|
||||||
Invidious::Database::Users.mark_watched(user, id)
|
|
||||||
end
|
|
||||||
when "action_mark_unwatched"
|
|
||||||
Invidious::Database::Users.mark_unwatched(user, id)
|
|
||||||
else
|
|
||||||
next error_json(400, "Unsupported action #{action}")
|
|
||||||
end
|
|
||||||
|
|
||||||
if redirect
|
|
||||||
env.redirect referer
|
|
||||||
else
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
"{}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# /modify_notifications
|
|
||||||
# will "ding" all subscriptions.
|
|
||||||
# /modify_notifications?receive_all_updates=false&receive_no_updates=false
|
|
||||||
# will "unding" all subscriptions.
|
|
||||||
get "/modify_notifications" do |env|
|
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
|
||||||
|
|
||||||
user = env.get? "user"
|
|
||||||
sid = env.get? "sid"
|
|
||||||
referer = get_referer(env, "/")
|
|
||||||
|
|
||||||
redirect = env.params.query["redirect"]?
|
|
||||||
redirect ||= "false"
|
|
||||||
redirect = redirect == "true"
|
|
||||||
|
|
||||||
if !user
|
|
||||||
if redirect
|
|
||||||
next env.redirect referer
|
|
||||||
else
|
|
||||||
next error_json(403, "No such user")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
user = user.as(User)
|
|
||||||
|
|
||||||
if !user.password
|
|
||||||
channel_req = {} of String => String
|
|
||||||
|
|
||||||
channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true"
|
|
||||||
channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || ""
|
|
||||||
channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true"
|
|
||||||
|
|
||||||
channel_req.reject! { |k, v| v != "true" && v != "false" }
|
|
||||||
|
|
||||||
headers = HTTP::Headers.new
|
|
||||||
headers["Cookie"] = env.request.headers["Cookie"]
|
|
||||||
|
|
||||||
html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
|
|
||||||
|
|
||||||
cookies = HTTP::Cookies.from_client_headers(headers)
|
|
||||||
html.cookies.each do |cookie|
|
|
||||||
if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name
|
|
||||||
if cookies[cookie.name]?
|
|
||||||
cookies[cookie.name] = cookie
|
|
||||||
else
|
|
||||||
cookies << cookie
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
headers = cookies.add_request_headers(headers)
|
|
||||||
|
|
||||||
if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/)
|
|
||||||
session_token = match["session_token"]
|
|
||||||
else
|
|
||||||
next env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
headers["content-type"] = "application/x-www-form-urlencoded"
|
|
||||||
channel_req["session_token"] = session_token
|
|
||||||
|
|
||||||
subs = XML.parse_html(html.body)
|
|
||||||
subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel|
|
|
||||||
channel_id = channel.content.lstrip("/channel/").not_nil!
|
|
||||||
channel_req["channel_id"] = channel_id
|
|
||||||
|
|
||||||
YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if redirect
|
|
||||||
env.redirect referer
|
|
||||||
else
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
"{}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/subscription_ajax" do |env|
|
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
|
||||||
|
|
||||||
user = env.get? "user"
|
|
||||||
sid = env.get? "sid"
|
|
||||||
referer = get_referer(env, "/")
|
|
||||||
|
|
||||||
redirect = env.params.query["redirect"]?
|
|
||||||
redirect ||= "true"
|
|
||||||
redirect = redirect == "true"
|
|
||||||
|
|
||||||
if !user
|
|
||||||
if redirect
|
|
||||||
next env.redirect referer
|
|
||||||
else
|
|
||||||
next error_json(403, "No such user")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
user = user.as(User)
|
|
||||||
sid = sid.as(String)
|
|
||||||
token = env.params.body["csrf_token"]?
|
|
||||||
|
|
||||||
begin
|
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
|
||||||
rescue ex
|
|
||||||
if redirect
|
|
||||||
next error_template(400, ex)
|
|
||||||
else
|
|
||||||
next error_json(400, ex)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1
|
|
||||||
action = "action_create_subscription_to_channel"
|
|
||||||
elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1
|
|
||||||
action = "action_remove_subscriptions"
|
|
||||||
else
|
|
||||||
next env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
channel_id = env.params.query["c"]?
|
|
||||||
channel_id ||= ""
|
|
||||||
|
|
||||||
if !user.password
|
|
||||||
# Sync subscriptions with YouTube
|
|
||||||
subscribe_ajax(channel_id, action, env.request.headers)
|
|
||||||
end
|
|
||||||
|
|
||||||
case action
|
|
||||||
when "action_create_subscription_to_channel"
|
|
||||||
if !user.subscriptions.includes? channel_id
|
|
||||||
get_channel(channel_id, false, false)
|
|
||||||
Invidious::Database::Users.subscribe_channel(user, channel_id)
|
|
||||||
end
|
|
||||||
when "action_remove_subscriptions"
|
|
||||||
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
|
|
||||||
else
|
|
||||||
next error_json(400, "Unsupported action #{action}")
|
|
||||||
end
|
|
||||||
|
|
||||||
if redirect
|
|
||||||
env.redirect referer
|
|
||||||
else
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
"{}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/subscription_manager" do |env|
|
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
|
||||||
|
|
||||||
user = env.get? "user"
|
|
||||||
sid = env.get? "sid"
|
|
||||||
referer = get_referer(env)
|
|
||||||
|
|
||||||
if !user
|
|
||||||
next env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
user = user.as(User)
|
|
||||||
sid = sid.as(String)
|
|
||||||
|
|
||||||
if !user.password
|
|
||||||
# Refresh account
|
|
||||||
headers = HTTP::Headers.new
|
|
||||||
headers["Cookie"] = env.request.headers["Cookie"]
|
|
||||||
|
|
||||||
user, sid = get_user(sid, headers)
|
|
||||||
end
|
|
||||||
|
|
||||||
action_takeout = env.params.query["action_takeout"]?.try &.to_i?
|
|
||||||
action_takeout ||= 0
|
|
||||||
action_takeout = action_takeout == 1
|
|
||||||
|
|
||||||
format = env.params.query["format"]?
|
|
||||||
format ||= "rss"
|
|
||||||
|
|
||||||
subscriptions = Invidious::Database::Channels.select(user.subscriptions)
|
|
||||||
subscriptions.sort_by!(&.author.downcase)
|
|
||||||
|
|
||||||
if action_takeout
|
|
||||||
if format == "json"
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
env.response.headers["content-disposition"] = "attachment"
|
|
||||||
playlists = Invidious::Database::Playlists.select_like_iv(user.email)
|
|
||||||
|
|
||||||
next JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
json.field "subscriptions", user.subscriptions
|
|
||||||
json.field "watch_history", user.watched
|
|
||||||
json.field "preferences", user.preferences
|
|
||||||
json.field "playlists" do
|
|
||||||
json.array do
|
|
||||||
playlists.each do |playlist|
|
|
||||||
json.object do
|
|
||||||
json.field "title", playlist.title
|
|
||||||
json.field "description", html_to_content(playlist.description_html)
|
|
||||||
json.field "privacy", playlist.privacy.to_s
|
|
||||||
json.field "videos" do
|
|
||||||
json.array do
|
|
||||||
Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id|
|
|
||||||
json.string video_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
env.response.content_type = "application/xml"
|
|
||||||
env.response.headers["content-disposition"] = "attachment"
|
|
||||||
export = XML.build do |xml|
|
|
||||||
xml.element("opml", version: "1.1") do
|
|
||||||
xml.element("body") do
|
|
||||||
if format == "newpipe"
|
|
||||||
title = "YouTube Subscriptions"
|
|
||||||
else
|
|
||||||
title = "Invidious Subscriptions"
|
|
||||||
end
|
|
||||||
|
|
||||||
xml.element("outline", text: title, title: title) do
|
|
||||||
subscriptions.each do |channel|
|
|
||||||
if format == "newpipe"
|
|
||||||
xml_url = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}"
|
|
||||||
else
|
|
||||||
xml_url = "#{HOST_URL}/feed/channel/#{channel.id}"
|
|
||||||
end
|
|
||||||
|
|
||||||
xml.element("outline", text: channel.author, title: channel.author,
|
|
||||||
"type": "rss", xmlUrl: xml_url)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
next export.gsub(%(<?xml version="1.0"?>\n), "")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
templated "subscription_manager"
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/data_control" do |env|
|
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
|
||||||
|
|
||||||
user = env.get? "user"
|
|
||||||
referer = get_referer(env)
|
|
||||||
|
|
||||||
if !user
|
|
||||||
next env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
user = user.as(User)
|
|
||||||
|
|
||||||
templated "data_control"
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/data_control" do |env|
|
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
|
||||||
|
|
||||||
user = env.get? "user"
|
|
||||||
referer = get_referer(env)
|
|
||||||
|
|
||||||
if user
|
|
||||||
user = user.as(User)
|
|
||||||
|
|
||||||
# TODO: Find a way to prevent browser timeout
|
|
||||||
|
|
||||||
HTTP::FormData.parse(env.request) do |part|
|
|
||||||
body = part.body.gets_to_end
|
|
||||||
type = part.headers["Content-Type"]
|
|
||||||
|
|
||||||
next if body.empty?
|
|
||||||
|
|
||||||
# TODO: Unify into single import based on content-type
|
|
||||||
case part.name
|
|
||||||
when "import_invidious"
|
|
||||||
body = JSON.parse(body)
|
|
||||||
|
|
||||||
if body["subscriptions"]?
|
|
||||||
user.subscriptions += body["subscriptions"].as_a.map(&.as_s)
|
|
||||||
user.subscriptions.uniq!
|
|
||||||
|
|
||||||
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
|
||||||
|
|
||||||
Invidious::Database::Users.update_subscriptions(user)
|
|
||||||
end
|
|
||||||
|
|
||||||
if body["watch_history"]?
|
|
||||||
user.watched += body["watch_history"].as_a.map(&.as_s)
|
|
||||||
user.watched.uniq!
|
|
||||||
Invidious::Database::Users.update_watch_history(user)
|
|
||||||
end
|
|
||||||
|
|
||||||
if body["preferences"]?
|
|
||||||
user.preferences = Preferences.from_json(body["preferences"].to_json)
|
|
||||||
Invidious::Database::Users.update_preferences(user)
|
|
||||||
end
|
|
||||||
|
|
||||||
if playlists = body["playlists"]?.try &.as_a?
|
|
||||||
playlists.each do |item|
|
|
||||||
title = item["title"]?.try &.as_s?.try &.delete("<>")
|
|
||||||
description = item["description"]?.try &.as_s?.try &.delete("\r")
|
|
||||||
privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
|
|
||||||
|
|
||||||
next if !title
|
|
||||||
next if !description
|
|
||||||
next if !privacy
|
|
||||||
|
|
||||||
playlist = create_playlist(title, privacy, user)
|
|
||||||
Invidious::Database::Playlists.update_description(playlist.id, description)
|
|
||||||
|
|
||||||
videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
|
|
||||||
raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
|
|
||||||
|
|
||||||
video_id = video_id.try &.as_s?
|
|
||||||
next if !video_id
|
|
||||||
|
|
||||||
begin
|
|
||||||
video = get_video(video_id)
|
|
||||||
rescue ex
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
playlist_video = PlaylistVideo.new({
|
|
||||||
title: video.title,
|
|
||||||
id: video.id,
|
|
||||||
author: video.author,
|
|
||||||
ucid: video.ucid,
|
|
||||||
length_seconds: video.length_seconds,
|
|
||||||
published: video.published,
|
|
||||||
plid: playlist.id,
|
|
||||||
live_now: video.live_now,
|
|
||||||
index: Random::Secure.rand(0_i64..Int64::MAX),
|
|
||||||
})
|
|
||||||
|
|
||||||
Invidious::Database::PlaylistVideos.insert(playlist_video)
|
|
||||||
Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
when "import_youtube"
|
|
||||||
filename = part.filename || ""
|
|
||||||
extension = filename.split(".").last
|
|
||||||
|
|
||||||
if extension == "xml" || type == "application/xml" || type == "text/xml"
|
|
||||||
subscriptions = XML.parse(body)
|
|
||||||
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
|
|
||||||
channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
|
|
||||||
end
|
|
||||||
elsif extension == "json" || type == "application/json"
|
|
||||||
subscriptions = JSON.parse(body)
|
|
||||||
user.subscriptions += subscriptions.as_a.compact_map do |entry|
|
|
||||||
entry["snippet"]["resourceId"]["channelId"].as_s
|
|
||||||
end
|
|
||||||
elsif extension == "csv" || type == "text/csv"
|
|
||||||
subscriptions = parse_subscription_export_csv(body)
|
|
||||||
user.subscriptions += subscriptions
|
|
||||||
else
|
|
||||||
halt(env, status_code: 415,
|
|
||||||
response: error_template(415, "Invalid subscription file uploaded")
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
user.subscriptions.uniq!
|
|
||||||
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
|
||||||
|
|
||||||
Invidious::Database::Users.update_subscriptions(user)
|
|
||||||
when "import_freetube"
|
|
||||||
user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md|
|
|
||||||
md["channel_id"]
|
|
||||||
end
|
|
||||||
user.subscriptions.uniq!
|
|
||||||
|
|
||||||
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
|
||||||
|
|
||||||
Invidious::Database::Users.update_subscriptions(user)
|
|
||||||
when "import_newpipe_subscriptions"
|
|
||||||
body = JSON.parse(body)
|
|
||||||
user.subscriptions += body["subscriptions"].as_a.compact_map do |channel|
|
|
||||||
if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/)
|
|
||||||
next match["channel"]
|
|
||||||
elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
|
|
||||||
response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US")
|
|
||||||
html = XML.parse_html(response.body)
|
|
||||||
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
|
|
||||||
next ucid if ucid
|
|
||||||
end
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
user.subscriptions.uniq!
|
|
||||||
|
|
||||||
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
|
||||||
|
|
||||||
Invidious::Database::Users.update_subscriptions(user)
|
|
||||||
when "import_newpipe"
|
|
||||||
Compress::Zip::Reader.open(IO::Memory.new(body)) do |file|
|
|
||||||
file.each_entry do |entry|
|
|
||||||
if entry.filename == "newpipe.db"
|
|
||||||
tempfile = File.tempfile(".db")
|
|
||||||
File.write(tempfile.path, entry.io.gets_to_end)
|
|
||||||
db = DB.open("sqlite3://" + tempfile.path)
|
|
||||||
|
|
||||||
user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v="))
|
|
||||||
user.watched.uniq!
|
|
||||||
|
|
||||||
Invidious::Database::Users.update_watch_history(user)
|
|
||||||
|
|
||||||
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/"))
|
|
||||||
user.subscriptions.uniq!
|
|
||||||
|
|
||||||
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
|
||||||
|
|
||||||
Invidious::Database::Users.update_subscriptions(user)
|
|
||||||
|
|
||||||
db.close
|
|
||||||
tempfile.delete
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else nil # Ignore
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/change_password" do |env|
|
get "/change_password" do |env|
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
module Invidious::Routes::Notifications
|
||||||
|
# /modify_notifications
|
||||||
|
# will "ding" all subscriptions.
|
||||||
|
# /modify_notifications?receive_all_updates=false&receive_no_updates=false
|
||||||
|
# will "unding" all subscriptions.
|
||||||
|
def self.modify(env)
|
||||||
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
user = env.get? "user"
|
||||||
|
sid = env.get? "sid"
|
||||||
|
referer = get_referer(env, "/")
|
||||||
|
|
||||||
|
redirect = env.params.query["redirect"]?
|
||||||
|
redirect ||= "false"
|
||||||
|
redirect = redirect == "true"
|
||||||
|
|
||||||
|
if !user
|
||||||
|
if redirect
|
||||||
|
return env.redirect referer
|
||||||
|
else
|
||||||
|
return error_json(403, "No such user")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
user = user.as(User)
|
||||||
|
|
||||||
|
if !user.password
|
||||||
|
channel_req = {} of String => String
|
||||||
|
|
||||||
|
channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true"
|
||||||
|
channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || ""
|
||||||
|
channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true"
|
||||||
|
|
||||||
|
channel_req.reject! { |k, v| v != "true" && v != "false" }
|
||||||
|
|
||||||
|
headers = HTTP::Headers.new
|
||||||
|
headers["Cookie"] = env.request.headers["Cookie"]
|
||||||
|
|
||||||
|
html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
|
||||||
|
|
||||||
|
cookies = HTTP::Cookies.from_client_headers(headers)
|
||||||
|
html.cookies.each do |cookie|
|
||||||
|
if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name
|
||||||
|
if cookies[cookie.name]?
|
||||||
|
cookies[cookie.name] = cookie
|
||||||
|
else
|
||||||
|
cookies << cookie
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
headers = cookies.add_request_headers(headers)
|
||||||
|
|
||||||
|
if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/)
|
||||||
|
session_token = match["session_token"]
|
||||||
|
else
|
||||||
|
return env.redirect referer
|
||||||
|
end
|
||||||
|
|
||||||
|
headers["content-type"] = "application/x-www-form-urlencoded"
|
||||||
|
channel_req["session_token"] = session_token
|
||||||
|
|
||||||
|
subs = XML.parse_html(html.body)
|
||||||
|
subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel|
|
||||||
|
channel_id = channel.content.lstrip("/channel/").not_nil!
|
||||||
|
channel_req["channel_id"] = channel_id
|
||||||
|
|
||||||
|
YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if redirect
|
||||||
|
env.redirect referer
|
||||||
|
else
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
"{}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -285,4 +285,191 @@ module Invidious::Routes::PreferencesRoute
|
||||||
"{}"
|
"{}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.data_control(env)
|
||||||
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
user = env.get? "user"
|
||||||
|
referer = get_referer(env)
|
||||||
|
|
||||||
|
if !user
|
||||||
|
return env.redirect referer
|
||||||
|
end
|
||||||
|
|
||||||
|
user = user.as(User)
|
||||||
|
|
||||||
|
templated "data_control"
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.update_data_control(env)
|
||||||
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
user = env.get? "user"
|
||||||
|
referer = get_referer(env)
|
||||||
|
|
||||||
|
if user
|
||||||
|
user = user.as(User)
|
||||||
|
|
||||||
|
# TODO: Find a way to prevent browser timeout
|
||||||
|
|
||||||
|
HTTP::FormData.parse(env.request) do |part|
|
||||||
|
body = part.body.gets_to_end
|
||||||
|
type = part.headers["Content-Type"]
|
||||||
|
|
||||||
|
next if body.empty?
|
||||||
|
|
||||||
|
# TODO: Unify into single import based on content-type
|
||||||
|
case part.name
|
||||||
|
when "import_invidious"
|
||||||
|
body = JSON.parse(body)
|
||||||
|
|
||||||
|
if body["subscriptions"]?
|
||||||
|
user.subscriptions += body["subscriptions"].as_a.map(&.as_s)
|
||||||
|
user.subscriptions.uniq!
|
||||||
|
|
||||||
|
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
||||||
|
|
||||||
|
Invidious::Database::Users.update_subscriptions(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
if body["watch_history"]?
|
||||||
|
user.watched += body["watch_history"].as_a.map(&.as_s)
|
||||||
|
user.watched.uniq!
|
||||||
|
Invidious::Database::Users.update_watch_history(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
if body["preferences"]?
|
||||||
|
user.preferences = Preferences.from_json(body["preferences"].to_json)
|
||||||
|
Invidious::Database::Users.update_preferences(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
if playlists = body["playlists"]?.try &.as_a?
|
||||||
|
playlists.each do |item|
|
||||||
|
title = item["title"]?.try &.as_s?.try &.delete("<>")
|
||||||
|
description = item["description"]?.try &.as_s?.try &.delete("\r")
|
||||||
|
privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
|
||||||
|
|
||||||
|
next if !title
|
||||||
|
next if !description
|
||||||
|
next if !privacy
|
||||||
|
|
||||||
|
playlist = create_playlist(title, privacy, user)
|
||||||
|
Invidious::Database::Playlists.update_description(playlist.id, description)
|
||||||
|
|
||||||
|
videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
|
||||||
|
raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
|
||||||
|
|
||||||
|
video_id = video_id.try &.as_s?
|
||||||
|
next if !video_id
|
||||||
|
|
||||||
|
begin
|
||||||
|
video = get_video(video_id)
|
||||||
|
rescue ex
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
playlist_video = PlaylistVideo.new({
|
||||||
|
title: video.title,
|
||||||
|
id: video.id,
|
||||||
|
author: video.author,
|
||||||
|
ucid: video.ucid,
|
||||||
|
length_seconds: video.length_seconds,
|
||||||
|
published: video.published,
|
||||||
|
plid: playlist.id,
|
||||||
|
live_now: video.live_now,
|
||||||
|
index: Random::Secure.rand(0_i64..Int64::MAX),
|
||||||
|
})
|
||||||
|
|
||||||
|
Invidious::Database::PlaylistVideos.insert(playlist_video)
|
||||||
|
Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
when "import_youtube"
|
||||||
|
filename = part.filename || ""
|
||||||
|
extension = filename.split(".").last
|
||||||
|
|
||||||
|
if extension == "xml" || type == "application/xml" || type == "text/xml"
|
||||||
|
subscriptions = XML.parse(body)
|
||||||
|
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
|
||||||
|
channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
|
||||||
|
end
|
||||||
|
elsif extension == "json" || type == "application/json"
|
||||||
|
subscriptions = JSON.parse(body)
|
||||||
|
user.subscriptions += subscriptions.as_a.compact_map do |entry|
|
||||||
|
entry["snippet"]["resourceId"]["channelId"].as_s
|
||||||
|
end
|
||||||
|
elsif extension == "csv" || type == "text/csv"
|
||||||
|
subscriptions = parse_subscription_export_csv(body)
|
||||||
|
user.subscriptions += subscriptions
|
||||||
|
else
|
||||||
|
haltf(env, status_code: 415,
|
||||||
|
response: error_template(415, "Invalid subscription file uploaded")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
user.subscriptions.uniq!
|
||||||
|
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
||||||
|
|
||||||
|
Invidious::Database::Users.update_subscriptions(user)
|
||||||
|
when "import_freetube"
|
||||||
|
user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md|
|
||||||
|
md["channel_id"]
|
||||||
|
end
|
||||||
|
user.subscriptions.uniq!
|
||||||
|
|
||||||
|
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
||||||
|
|
||||||
|
Invidious::Database::Users.update_subscriptions(user)
|
||||||
|
when "import_newpipe_subscriptions"
|
||||||
|
body = JSON.parse(body)
|
||||||
|
user.subscriptions += body["subscriptions"].as_a.compact_map do |channel|
|
||||||
|
if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/)
|
||||||
|
next match["channel"]
|
||||||
|
elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
|
||||||
|
response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US")
|
||||||
|
html = XML.parse_html(response.body)
|
||||||
|
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
|
||||||
|
next ucid if ucid
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
user.subscriptions.uniq!
|
||||||
|
|
||||||
|
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
||||||
|
|
||||||
|
Invidious::Database::Users.update_subscriptions(user)
|
||||||
|
when "import_newpipe"
|
||||||
|
Compress::Zip::Reader.open(IO::Memory.new(body)) do |file|
|
||||||
|
file.each_entry do |entry|
|
||||||
|
if entry.filename == "newpipe.db"
|
||||||
|
tempfile = File.tempfile(".db")
|
||||||
|
File.write(tempfile.path, entry.io.gets_to_end)
|
||||||
|
db = DB.open("sqlite3://" + tempfile.path)
|
||||||
|
|
||||||
|
user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v="))
|
||||||
|
user.watched.uniq!
|
||||||
|
|
||||||
|
Invidious::Database::Users.update_watch_history(user)
|
||||||
|
|
||||||
|
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/"))
|
||||||
|
user.subscriptions.uniq!
|
||||||
|
|
||||||
|
user.subscriptions = get_batch_channels(user.subscriptions, false, false)
|
||||||
|
|
||||||
|
Invidious::Database::Users.update_subscriptions(user)
|
||||||
|
|
||||||
|
db.close
|
||||||
|
tempfile.delete
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else nil # Ignore
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
env.redirect referer
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,168 @@
|
||||||
|
module Invidious::Routes::Subscriptions
|
||||||
|
def self.toggle_subscription(env)
|
||||||
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
user = env.get? "user"
|
||||||
|
sid = env.get? "sid"
|
||||||
|
referer = get_referer(env, "/")
|
||||||
|
|
||||||
|
redirect = env.params.query["redirect"]?
|
||||||
|
redirect ||= "true"
|
||||||
|
redirect = redirect == "true"
|
||||||
|
|
||||||
|
if !user
|
||||||
|
if redirect
|
||||||
|
return env.redirect referer
|
||||||
|
else
|
||||||
|
return error_json(403, "No such user")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
user = user.as(User)
|
||||||
|
sid = sid.as(String)
|
||||||
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
|
begin
|
||||||
|
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
|
rescue ex
|
||||||
|
if redirect
|
||||||
|
return error_template(400, ex)
|
||||||
|
else
|
||||||
|
return error_json(400, ex)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1
|
||||||
|
action = "action_create_subscription_to_channel"
|
||||||
|
elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1
|
||||||
|
action = "action_remove_subscriptions"
|
||||||
|
else
|
||||||
|
return env.redirect referer
|
||||||
|
end
|
||||||
|
|
||||||
|
channel_id = env.params.query["c"]?
|
||||||
|
channel_id ||= ""
|
||||||
|
|
||||||
|
if !user.password
|
||||||
|
# Sync subscriptions with YouTube
|
||||||
|
subscribe_ajax(channel_id, action, env.request.headers)
|
||||||
|
end
|
||||||
|
|
||||||
|
case action
|
||||||
|
when "action_create_subscription_to_channel"
|
||||||
|
if !user.subscriptions.includes? channel_id
|
||||||
|
get_channel(channel_id, false, false)
|
||||||
|
Invidious::Database::Users.subscribe_channel(user, channel_id)
|
||||||
|
end
|
||||||
|
when "action_remove_subscriptions"
|
||||||
|
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
|
||||||
|
else
|
||||||
|
return error_json(400, "Unsupported action #{action}")
|
||||||
|
end
|
||||||
|
|
||||||
|
if redirect
|
||||||
|
env.redirect referer
|
||||||
|
else
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
"{}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.subscription_manager(env)
|
||||||
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
user = env.get? "user"
|
||||||
|
sid = env.get? "sid"
|
||||||
|
referer = get_referer(env)
|
||||||
|
|
||||||
|
if !user
|
||||||
|
return env.redirect referer
|
||||||
|
end
|
||||||
|
|
||||||
|
user = user.as(User)
|
||||||
|
sid = sid.as(String)
|
||||||
|
|
||||||
|
if !user.password
|
||||||
|
# Refresh account
|
||||||
|
headers = HTTP::Headers.new
|
||||||
|
headers["Cookie"] = env.request.headers["Cookie"]
|
||||||
|
|
||||||
|
user, sid = get_user(sid, headers)
|
||||||
|
end
|
||||||
|
|
||||||
|
action_takeout = env.params.query["action_takeout"]?.try &.to_i?
|
||||||
|
action_takeout ||= 0
|
||||||
|
action_takeout = action_takeout == 1
|
||||||
|
|
||||||
|
format = env.params.query["format"]?
|
||||||
|
format ||= "rss"
|
||||||
|
|
||||||
|
subscriptions = Invidious::Database::Channels.select(user.subscriptions)
|
||||||
|
subscriptions.sort_by!(&.author.downcase)
|
||||||
|
|
||||||
|
if action_takeout
|
||||||
|
if format == "json"
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
env.response.headers["content-disposition"] = "attachment"
|
||||||
|
playlists = Invidious::Database::Playlists.select_like_iv(user.email)
|
||||||
|
|
||||||
|
return JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "subscriptions", user.subscriptions
|
||||||
|
json.field "watch_history", user.watched
|
||||||
|
json.field "preferences", user.preferences
|
||||||
|
json.field "playlists" do
|
||||||
|
json.array do
|
||||||
|
playlists.each do |playlist|
|
||||||
|
json.object do
|
||||||
|
json.field "title", playlist.title
|
||||||
|
json.field "description", html_to_content(playlist.description_html)
|
||||||
|
json.field "privacy", playlist.privacy.to_s
|
||||||
|
json.field "videos" do
|
||||||
|
json.array do
|
||||||
|
Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id|
|
||||||
|
json.string video_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
env.response.content_type = "application/xml"
|
||||||
|
env.response.headers["content-disposition"] = "attachment"
|
||||||
|
export = XML.build do |xml|
|
||||||
|
xml.element("opml", version: "1.1") do
|
||||||
|
xml.element("body") do
|
||||||
|
if format == "newpipe"
|
||||||
|
title = "YouTube Subscriptions"
|
||||||
|
else
|
||||||
|
title = "Invidious Subscriptions"
|
||||||
|
end
|
||||||
|
|
||||||
|
xml.element("outline", text: title, title: title) do
|
||||||
|
subscriptions.each do |channel|
|
||||||
|
if format == "newpipe"
|
||||||
|
xml_url = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}"
|
||||||
|
else
|
||||||
|
xml_url = "#{HOST_URL}/feed/channel/#{channel.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
xml.element("outline", text: channel.author, title: channel.author,
|
||||||
|
"type": "rss", xmlUrl: xml_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return export.gsub(%(<?xml version="1.0"?>\n), "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
templated "subscription_manager"
|
||||||
|
end
|
||||||
|
end
|
|
@ -200,4 +200,70 @@ module Invidious::Routes::Watch
|
||||||
|
|
||||||
return env.redirect url
|
return env.redirect url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.mark_watched(env)
|
||||||
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
user = env.get? "user"
|
||||||
|
sid = env.get? "sid"
|
||||||
|
referer = get_referer(env, "/feed/subscriptions")
|
||||||
|
|
||||||
|
redirect = env.params.query["redirect"]?
|
||||||
|
redirect ||= "true"
|
||||||
|
redirect = redirect == "true"
|
||||||
|
|
||||||
|
if !user
|
||||||
|
if redirect
|
||||||
|
return env.redirect referer
|
||||||
|
else
|
||||||
|
return error_json(403, "No such user")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
user = user.as(User)
|
||||||
|
sid = sid.as(String)
|
||||||
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
|
id = env.params.query["id"]?
|
||||||
|
if !id
|
||||||
|
env.response.status_code = 400
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
|
rescue ex
|
||||||
|
if redirect
|
||||||
|
return error_template(400, ex)
|
||||||
|
else
|
||||||
|
return error_json(400, ex)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if env.params.query["action_mark_watched"]?
|
||||||
|
action = "action_mark_watched"
|
||||||
|
elsif env.params.query["action_mark_unwatched"]?
|
||||||
|
action = "action_mark_unwatched"
|
||||||
|
else
|
||||||
|
return env.redirect referer
|
||||||
|
end
|
||||||
|
|
||||||
|
case action
|
||||||
|
when "action_mark_watched"
|
||||||
|
if !user.watched.includes? id
|
||||||
|
Invidious::Database::Users.mark_watched(user, id)
|
||||||
|
end
|
||||||
|
when "action_mark_unwatched"
|
||||||
|
Invidious::Database::Users.mark_unwatched(user, id)
|
||||||
|
else
|
||||||
|
return error_json(400, "Unsupported action #{action}")
|
||||||
|
end
|
||||||
|
|
||||||
|
if redirect
|
||||||
|
env.redirect referer
|
||||||
|
else
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
"{}"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue