require "crypto/bcrypt/password" # Materialized views may not be defined using bound parameters (`$1` as used elsewhere) MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" } def get_user(sid, headers, refresh = true) if email = Invidious::Database::SessionIDs.select_email(sid) user = Invidious::Database::Users.select!(email: email) if refresh && Time.utc - user.updated > 1.minute user, sid = fetch_user(sid, headers) Invidious::Database::Users.insert(user, update_on_conflict: true) Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) begin view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") rescue ex end end else user, sid = fetch_user(sid, headers) Invidious::Database::Users.insert(user, update_on_conflict: true) Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) begin view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") rescue ex end end return user, sid end def fetch_user(sid, headers) feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) feed = XML.parse_html(feed.body) channels = feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).compact_map do |channel| if {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"] nil else channel["href"].lstrip("/channel/") end end channels = get_batch_channels(channels) email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) if email email = email.content.strip else email = "" end token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) user = Invidious::User.new({ updated: Time.utc, notifications: [] of String, subscriptions: channels, email: email, preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple), password: nil, token: token, watched: [] of String, feed_needs_update: true, }) return user, sid end def create_user(sid, email, password) password = Crypto::Bcrypt::Password.create(password, cost: 10) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) user = Invidious::User.new({ updated: Time.utc, notifications: [] of String, subscriptions: [] of String, email: email, preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple), password: password.to_s, token: token, watched: [] of String, feed_needs_update: true, }) return user, sid end def subscribe_ajax(channel_id, action, env_headers) headers = HTTP::Headers.new headers["Cookie"] = env_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 = match["session_token"] headers["content-type"] = "application/x-www-form-urlencoded" post_req = { session_token: session_token, } post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}" YT_POOL.client &.post(post_url, headers, form: post_req) end end def get_subscription_feed(user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit notifications = Invidious::Database::Users.select_notifications(user) view_name = "subscriptions_#{sha256(user.email)}" if user.preferences.notifications_only && !notifications.empty? # Only show notifications notifications = Invidious::Database::ChannelVideos.select(notifications) videos = [] of ChannelVideo notifications.sort_by!(&.published).reverse! case user.preferences.sort when "alphabetically" notifications.sort_by!(&.title) when "alphabetically - reverse" notifications.sort_by!(&.title).reverse! when "channel name" notifications.sort_by!(&.author) when "channel name - reverse" notifications.sort_by!(&.author).reverse! else nil # Ignore end else if user.preferences.latest_only if user.preferences.unseen_only # Show latest video from a channel that a user hasn't watched # "unseen_only" isn't really correct here, more accurate would be "unwatched_only" if user.watched.empty? values = "'{}'" else values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}" end videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo) else # Show latest video from each channel videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo) end videos.sort_by!(&.published).reverse! else if user.preferences.unseen_only # Only show unwatched if user.watched.empty? values = "'{}'" else values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}" end videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo) else # Sort subscriptions as normal videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo) end end case user.preferences.sort when "published - reverse" videos.sort_by!(&.published) when "alphabetically" videos.sort_by!(&.title) when "alphabetically - reverse" videos.sort_by!(&.title).reverse! when "channel name" videos.sort_by!(&.author) when "channel name - reverse" videos.sort_by!(&.author).reverse! else nil # Ignore end notifications = Invidious::Database::Users.select_notifications(user) notifications = videos.select { |v| notifications.includes? v.id } videos = videos - notifications end return videos, notifications end