diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..f5918bb1 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,28 @@ +dist: bionic + +jobs: + include: + - stage: build + language: crystal + crystal: latest + before_install: + - shards update + - shards install + install: + - crystal build --error-on-warnings src/invidious.cr + script: + - crystal tool format --check + - crystal spec + + - stage: build_docker + language: minimal + services: + - docker + install: + - docker-compose build + script: + - docker-compose up -d + - sleep 15 # Wait for cluster to become ready, TODO: do not sleep + - HEADERS="$(curl -I -s http://localhost:3000/)" + - STATUS="$(echo $HEADERS | head -n1)" + - if [[ "$STATUS" != *"200 OK"* ]]; then echo "$HEADERS"; exit 1; fi diff --git a/README.md b/README.md index 44254c62..be7c5580 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Invidious +[![Build Status](https://travis-ci.org/omarroth/invidious.svg?branch=master)](https://travis-ci.org/omarroth/invidious) + ## Invidious is an alternative front-end to YouTube - Audio-only mode (and no need to keep window open on mobile) diff --git a/docker/Dockerfile b/docker/Dockerfile index 043d950e..45fade57 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,15 +1,28 @@ -FROM archlinux/base - -RUN pacman -Sy --noconfirm shards crystal imagemagick librsvg \ - which pkgconf gcc ttf-liberation glibc -# base-devel contains many other basic packages, that are normally assumed to already exist on a clean arch system - -ADD . /invidious - +FROM alpine:edge AS builder +RUN apk add -u crystal shards libc-dev \ + yaml-dev libxml2-dev sqlite-dev sqlite-static zlib-dev openssl-dev WORKDIR /invidious +COPY ./shard.yml ./shard.yml +RUN shards update && shards install +COPY ./src/ ./src/ +# TODO: .git folder is required for building – this is destructive. +# See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. +COPY ./.git/ ./.git/ +RUN crystal build --static --release \ +# TODO: Remove next line, see https://github.com/crystal-lang/crystal/issues/7946 + -Dmusl \ + ./src/invidious.cr -RUN sed -i 's/host: localhost/host: postgres/' config/config.yml && \ - shards update && shards install && \ - crystal build src/invidious.cr - +FROM alpine:latest +RUN apk add -u imagemagick ttf-opensans +WORKDIR /invidious +RUN addgroup -g 1000 -S invidious && \ + adduser -u 1000 -S invidious -G invidious +COPY ./assets/ ./assets/ +COPY ./config/config.yml ./config/config.yml +COPY ./config/sql/ ./config/sql/ +COPY ./locales/ ./locales/ +RUN sed -i 's/host: localhost/host: postgres/' config/config.yml +COPY --from=builder /invidious/invidious . +USER invidious CMD [ "/invidious/invidious" ] diff --git a/locales/ar.json b/locales/ar.json index e3716008..f2ed450c 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -68,7 +68,11 @@ "Show related videos: ": "عرض مقاطع الفيديو ذات الصلة؟", "Show annotations by default: ": "عرض الملاحظات فى الفيديو تلقائيا ؟", "Visual preferences": "التفضيلات المرئية", + "Player style: ": "", "Dark mode: ": "الوضع الليلى: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "الوضع الخفيف: ", "Subscription preferences": "تفضيلات الإشتراك", "Show annotations by default for subscribed channels: ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟", diff --git a/locales/de.json b/locales/de.json index 33edb706..e0ef9d67 100644 --- a/locales/de.json +++ b/locales/de.json @@ -68,7 +68,11 @@ "Show related videos: ": "Ähnliche Videos anzeigen? ", "Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ", "Visual preferences": "Anzeigeeinstellungen", + "Player style: ": "", "Dark mode: ": "Nachtmodus: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Schlanker Modus: ", "Subscription preferences": "Abonnementeinstellungen", "Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ", diff --git a/locales/el.json b/locales/el.json index 03d25533..32f154a1 100644 --- a/locales/el.json +++ b/locales/el.json @@ -74,7 +74,11 @@ "Show related videos: ": "Προβολή σχετικών βίντεο; ", "Show annotations by default: ": "Αυτόματη προβολή σημειώσεων; :", "Visual preferences": "Προτιμήσεις εμφάνισης", + "Player style: ": "", "Dark mode: ": "Σκοτεινή λειτουργία: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Ελαφριά λειτουργία: ", "Subscription preferences": "Προτιμήσεις συνδρομών", "Show annotations by default for subscribed channels: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ", diff --git a/locales/en-US.json b/locales/en-US.json index 16301b1d..580d9ead 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -74,7 +74,11 @@ "Show related videos: ": "Show related videos: ", "Show annotations by default: ": "Show annotations by default: ", "Visual preferences": "Visual preferences", + "Player style: ": "", "Dark mode: ": "Dark mode: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Thin mode: ", "Subscription preferences": "Subscription preferences", "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", diff --git a/locales/eo.json b/locales/eo.json index fe85edf2..2c22af59 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -68,7 +68,11 @@ "Show related videos: ": "Ĉu montri rilatajn videojn? ", "Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ", "Visual preferences": "Vidaj preferoj", + "Player style: ": "", "Dark mode: ": "Malhela reĝimo: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Maldika reĝimo: ", "Subscription preferences": "Abonaj agordoj", "Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", diff --git a/locales/es.json b/locales/es.json index fdbf2fb2..5860b882 100644 --- a/locales/es.json +++ b/locales/es.json @@ -68,7 +68,11 @@ "Show related videos: ": "¿Mostrar vídeos relacionados? ", "Show annotations by default: ": "¿Mostrar anotaciones por defecto? ", "Visual preferences": "Preferencias visuales", + "Player style: ": "", "Dark mode: ": "Modo oscuro: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Modo compacto: ", "Subscription preferences": "Preferencias de la suscripción", "Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ", diff --git a/locales/eu.json b/locales/eu.json index 5a756154..cbdbbefc 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -68,7 +68,11 @@ "Show related videos: ": "", "Show annotations by default: ": "", "Visual preferences": "", + "Player style: ": "", "Dark mode: ": "", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "", "Subscription preferences": "", "Show annotations by default for subscribed channels: ": "", diff --git a/locales/fr.json b/locales/fr.json index 37a773f1..af561a0c 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -68,7 +68,11 @@ "Show related videos: ": "Voir les vidéos liées : ", "Show annotations by default: ": "Voir les annotations par défaut : ", "Visual preferences": "Préférences du site", + "Player style: ": "", "Dark mode: ": "Mode Sombre : ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Mode Simplifié : ", "Subscription preferences": "Préférences de la page d'abonnements", "Show annotations by default for subscribed channels: ": "Voir les annotations par défaut sur les chaînes suivies : ", diff --git a/locales/is.json b/locales/is.json index 43ba26e9..808063c4 100644 --- a/locales/is.json +++ b/locales/is.json @@ -68,7 +68,11 @@ "Show related videos: ": "Sýna tengd myndbönd? ", "Show annotations by default: ": "Á að sýna glósur sjálfgefið? ", "Visual preferences": "Sjónrænar stillingar", + "Player style: ": "", "Dark mode: ": "Myrkur ham: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Þunnt ham: ", "Subscription preferences": "Áskriftarstillingar", "Show annotations by default for subscribed channels: ": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ", diff --git a/locales/it.json b/locales/it.json index bf028c91..7f532d0d 100644 --- a/locales/it.json +++ b/locales/it.json @@ -68,7 +68,11 @@ "Show related videos: ": "Mostra video correlati? ", "Show annotations by default: ": "Mostra le annotazioni per impostazione predefinita? ", "Visual preferences": "Preferenze grafiche", + "Player style: ": "", "Dark mode: ": "Tema scuro: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Modalità per connessioni lente: ", "Subscription preferences": "Preferenze iscrizioni", "Show annotations by default for subscribed channels: ": "", diff --git a/locales/nb_NO.json b/locales/nb_NO.json index 589512d4..f50e2290 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -68,7 +68,11 @@ "Show related videos: ": "Vis relaterte videoer? ", "Show annotations by default: ": "Vis merknader som forvalg? ", "Visual preferences": "Visuelle innstillinger", + "Player style: ": "", "Dark mode: ": "Mørk drakt: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Tynt modus: ", "Subscription preferences": "Abonnementsinnstillinger", "Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ", diff --git a/locales/nl.json b/locales/nl.json index b24b13b8..3e2c6c64 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -68,7 +68,11 @@ "Show related videos: ": "Gerelateerde video's tonen? ", "Show annotations by default: ": "Standaard annotaties tonen? ", "Visual preferences": "Visuele instellingen", + "Player style: ": "", "Dark mode: ": "Donkere modus: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Smalle modus: ", "Subscription preferences": "Abonnementsinstellingen", "Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ", diff --git a/locales/pl.json b/locales/pl.json index 550e664b..1e3a2068 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -68,7 +68,11 @@ "Show related videos: ": "Pokaż powiązane filmy? ", "Show annotations by default: ": "", "Visual preferences": "Preferencje Wizualne", + "Player style: ": "", "Dark mode: ": "Ciemny motyw: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Tryb minimalny: ", "Subscription preferences": "Preferencje subskrybcji", "Show annotations by default for subscribed channels: ": "", diff --git a/locales/ru.json b/locales/ru.json index f9cc1e2d..90aa4a3b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -68,7 +68,11 @@ "Show related videos: ": "Показывать похожие видео? ", "Show annotations by default: ": "Всегда показывать аннотации? ", "Visual preferences": "Настройки сайта", + "Player style: ": "", "Dark mode: ": "Тёмное оформление: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Облегчённое оформление: ", "Subscription preferences": "Настройки подписок", "Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", diff --git a/locales/uk.json b/locales/uk.json index 95e5798d..e537008c 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -68,7 +68,11 @@ "Show related videos: ": "Показувати схожі відео? ", "Show annotations by default: ": "Завжди показувати анотації? ", "Visual preferences": "Налаштування сайту", + "Player style: ": "", "Dark mode: ": "Темне оформлення: ", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "Полегшене оформлення: ", "Subscription preferences": "Налаштування підписок", "Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 0a3f53d9..23617d04 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -68,7 +68,11 @@ "Show related videos: ": "显示相关视频?", "Show annotations by default: ": "默认显示视频注释?", "Visual preferences": "视觉选项", + "Player style: ": "", "Dark mode: ": "暗色模式:", + "Theme: ": "", + "dark": "", + "light": "", "Thin mode: ": "窄页模式:", "Subscription preferences": "订阅设置", "Show annotations by default for subscribed channels: ": "在订阅频道的视频默认显示注释?", diff --git a/shard.yml b/shard.yml index 3e2b3d03..0f9beaf2 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: invidious -version: 0.19.0 +version: 0.19.1 authors: - Omar Roth @@ -11,11 +11,14 @@ targets: dependencies: pg: github: will/crystal-pg + version: ~> 0.18.1 sqlite3: github: crystal-lang/crystal-sqlite3 + version: ~> 0.13.0 kemal: github: kemalcr/kemal + version: ~> 0.26.0 -crystal: 0.29.0 +crystal: 0.30.1 license: AGPLv3 diff --git a/src/invidious.cr b/src/invidious.cr index f1a20ab1..082c05c5 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -1025,8 +1025,10 @@ get "/api/v1/playlists/:plid" do |env| response = JSON.build do |json| json.object do + json.field "type", "playlist" json.field "title", playlist.title json.field "playlistId", playlist.id + json.field "playlistThumbnail", playlist.thumbnail json.field "author", playlist.author json.field "authorId", playlist.ucid @@ -1216,7 +1218,7 @@ get "/api/manifest/dash/id/:id" do |env| end audio_streams = video.audio_streams(adaptive_fmts) - video_streams = video.video_streams(adaptive_fmts) + video_streams = video.video_streams(adaptive_fmts).sort_by { |stream| stream["fps"].to_i }.reverse XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", @@ -1772,6 +1774,43 @@ get "/sb/:id/:storyboard/:index" do |env| end end +get "/s_p/:id/:name" do |env| + id = env.params.url["id"] + name = env.params.url["name"] + + host = "https://i9.ytimg.com" + client = make_client(URI.parse(host)) + url = env.request.resource + + headers = HTTP::Headers.new + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + client.get(url, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes? key + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end +end + get "/vi/:id/:name" do |env| id = env.params.url["id"] name = env.params.url["name"] diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 5e01cef2..107039a6 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -387,14 +387,15 @@ def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by) html = XML.parse_html(json["content_html"].as_s) nodeset = html.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])) - else - url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list" + elsif auto_generated + url = "/channel/#{ucid}" - if auto_generated - url += "&view=50" - else - url += "&view=1" - end + response = client.get(url) + html = XML.parse_html(response.body) + + nodeset = html.xpath_nodes(%q(//ul[@id="browse-items-primary"]/li[contains(@class, "feed-item-container")])) + else + url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list&view=1" case sort_by when "last", "last_added" diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 119c7d3b..95936199 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -69,20 +69,20 @@ class FilteredCompressHandler < Kemal::Handler return call_next env if exclude_match? env {% if flag?(:without_zlib) %} - call_next env - {% else %} - request_headers = env.request.headers + call_next env + {% else %} + request_headers = env.request.headers - if request_headers.includes_word?("Accept-Encoding", "gzip") - env.response.headers["Content-Encoding"] = "gzip" - env.response.output = Gzip::Writer.new(env.response.output, sync_close: true) - elsif request_headers.includes_word?("Accept-Encoding", "deflate") - env.response.headers["Content-Encoding"] = "deflate" - env.response.output = Flate::Writer.new(env.response.output, sync_close: true) - end + if request_headers.includes_word?("Accept-Encoding", "gzip") + env.response.headers["Content-Encoding"] = "gzip" + env.response.output = Gzip::Writer.new(env.response.output, sync_close: true) + elsif request_headers.includes_word?("Accept-Encoding", "deflate") + env.response.headers["Content-Encoding"] = "deflate" + env.response.output = Flate::Writer.new(env.response.output, sync_close: true) + end - call_next env - {% end %} + call_next env + {% end %} end end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 9cefcf14..331f6360 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -24,6 +24,27 @@ end struct ConfigPreferences module StringToArray + def self.to_json(value : Array(String), json : JSON::Builder) + json.array do + value.each do |element| + json.string element + end + end + end + + def self.from_json(value : JSON::PullParser) : Array(String) + begin + result = [] of String + value.read_array do + result << HTML.escape(value.read_string[0, 100]) + end + rescue ex + result = [HTML.escape(value.read_string[0, 100]), ""] + end + + result + end + def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) yaml.sequence do value.each do |element| @@ -44,11 +65,11 @@ struct ConfigPreferences node.raise "Expected scalar, not #{item.class}" end - result << item.value + result << HTML.escape(item.value[0, 100]) end rescue ex if node.is_a?(YAML::Nodes::Scalar) - result = [node.value, ""] + result = [HTML.escape(node.value[0, 100]), ""] else result = ["", ""] end @@ -58,6 +79,53 @@ struct ConfigPreferences end end + module BoolToString + def self.to_json(value : String, json : JSON::Builder) + json.string value + end + + def self.from_json(value : JSON::PullParser) : String + begin + result = value.read_string + + if result.empty? + CONFIG.default_user_preferences.dark_mode + else + result + end + rescue ex + result = value.read_bool + + if result + "dark" + else + "light" + end + end + end + + def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) + yaml.scalar value + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String + unless node.is_a?(YAML::Nodes::Scalar) + node.raise "Expected sequence, not #{node.class}" + end + + case node.value + when "true" + "dark" + when "false" + "light" + when "" + CONFIG.default_user_preferences.dark_mode + else + node.value + end + end + end + yaml_mapping({ annotations: {type: Bool, default: false}, annotations_subscribed: {type: Bool, default: false}, @@ -66,13 +134,14 @@ struct ConfigPreferences comments: {type: Array(String), default: ["youtube", ""], converter: StringToArray}, continue: {type: Bool, default: false}, continue_autoplay: {type: Bool, default: true}, - dark_mode: {type: Bool, default: false}, + dark_mode: {type: String, default: "", converter: BoolToString}, latest_only: {type: Bool, default: false}, listen: {type: Bool, default: false}, local: {type: Bool, default: false}, locale: {type: String, default: "en-US"}, max_results: {type: Int32, default: 40}, notifications_only: {type: Bool, default: false}, + player_style: {type: String, default: "invidious"}, quality: {type: String, default: "hd720"}, redirect_feed: {type: Bool, default: false}, related_videos: {type: Bool, default: true}, @@ -243,8 +312,7 @@ end def extract_videos(nodeset, ucid = nil, author_name = nil) videos = extract_items(nodeset, ucid, author_name) - videos.select! { |item| !item.is_a?(SearchChannel | SearchPlaylist) } - videos.map { |video| video.as(SearchVideo) } + videos.select { |item| item.is_a?(SearchVideo) }.map { |video| video.as(SearchVideo) } end def extract_items(nodeset, ucid = nil, author_name = nil) @@ -263,18 +331,8 @@ def extract_items(nodeset, ucid = nil, author_name = nil) next end - anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)) - if anchor - author = anchor.content.strip - author_id = anchor["href"].split("/")[-1] - end - - author ||= author_name - author_id ||= ucid - - author ||= "" - author_id ||= "" - + author_id = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)).try &.["href"].split("/")[-1] || ucid || "" + author = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)).try &.content.strip || author_name || "" description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")])).try &.to_s || "" tile = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-tile")])) @@ -292,14 +350,14 @@ def extract_items(nodeset, ucid = nil, author_name = nil) anchor = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li/a)) end - video_count = node.xpath_node(%q(.//span[@class="formatted-video-count-label"]/b)) + video_count = node.xpath_node(%q(.//span[@class="formatted-video-count-label"]/b)) || + node.xpath_node(%q(.//span[@class="formatted-video-count-label"])) if video_count video_count = video_count.content if video_count == "50+" author = "YouTube" author_id = "UC-9-kyTW8ZkZNDHQJ6FgpwQ" - video_count = video_count.rchop("+") end video_count = video_count.gsub(/\D/, "").to_i? @@ -329,22 +387,17 @@ def extract_items(nodeset, ucid = nil, author_name = nil) ) end - playlist_thumbnail = node.xpath_node(%q(.//div/span/img)).try &.["data-thumb"]? - playlist_thumbnail ||= node.xpath_node(%q(.//div/span/img)).try &.["src"] - if !playlist_thumbnail || playlist_thumbnail.empty? - thumbnail_id = videos[0]?.try &.id - else - thumbnail_id = playlist_thumbnail.match(/\/vi\/(?[a-zA-Z0-9_-]{11})\/\w+\.jpg/).try &.["video_id"] - end + playlist_thumbnail = node.xpath_node(%q(.//span/img)).try &.["data-thumb"]? + playlist_thumbnail ||= node.xpath_node(%q(.//span/img)).try &.["src"] items << SearchPlaylist.new( - title, - plid, - author, - author_id, - video_count, - videos, - thumbnail_id + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail ) when .includes? "yt-lockup-channel" author = title.strip @@ -379,47 +432,20 @@ def extract_items(nodeset, ucid = nil, author_name = nil) else id = id.lchop("/watch?v=") - metadata = node.xpath_nodes(%q(.//div[contains(@class,"yt-lockup-meta")]/ul/li)) + metadata = node.xpath_node(%q(.//div[contains(@class,"yt-lockup-meta")]/ul)) - begin - published = decode_date(metadata[0].content.lchop("Streamed ").lchop("Starts ")) - rescue ex - end - begin - published ||= Time.unix(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64) - rescue ex - end + published = metadata.try &.xpath_node(%q(.//li[contains(text(), " ago")])).try { |node| decode_date(node.content.sub(/^[a-zA-Z]+ /, "")) } + published ||= metadata.try &.xpath_node(%q(.//span[@data-timestamp])).try { |node| Time.unix(node["data-timestamp"].to_i64) } published ||= Time.utc - begin - view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64? - rescue ex - end - begin - view_count ||= metadata.try &.[1].content.delete("No views,").try &.to_i64? - rescue ex - end + view_count = metadata.try &.xpath_node(%q(.//li[contains(text(), " views")])).try &.content.gsub(/\D/, "").to_i64? view_count ||= 0_i64 - length_seconds = node.xpath_node(%q(.//span[@class="video-time"])) - if length_seconds - length_seconds = decode_length_seconds(length_seconds.content) - else - length_seconds = -1 - end + length_seconds = node.xpath_node(%q(.//span[@class="video-time"])).try { |node| decode_length_seconds(node.content) } + length_seconds ||= -1 - live_now = node.xpath_node(%q(.//span[contains(@class, "yt-badge-live")])) - if live_now - live_now = true - else - live_now = false - end - - if node.xpath_node(%q(.//span[text()="Premium"])) - premium = true - else - premium = false - end + live_now = node.xpath_node(%q(.//span[contains(@class, "yt-badge-live")])) ? true : false + premium = node.xpath_node(%q(.//span[text()="Premium"])) ? true : false if !premium || node.xpath_node(%q(.//span[contains(text(), "Free episode")])) paid = false @@ -457,26 +483,18 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil) nodeset.each do |shelf| shelf_anchor = shelf.xpath_node(%q(.//h2[contains(@class, "branded-page-module-title")])) + next if !shelf_anchor - if !shelf_anchor - next - end - - title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")])) - if title - title = title.content.strip - end + title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")])).try &.content.strip title ||= "" id = shelf_anchor.xpath_node(%q(.//a)).try &.["href"] - if !id - next - end + next if !id - is_playlist = false + shelf_is_playlist = false videos = [] of SearchPlaylistVideo - shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list")]/li)).each do |child_node| + shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list") or contains(@class, "expanded-shelf-content-list")]/li)).each do |child_node| type = child_node.xpath_node(%q(./div)) if !type next @@ -484,7 +502,7 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil) case type["class"] when .includes? "yt-lockup-video" - is_playlist = true + shelf_is_playlist = true anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a)) if anchor @@ -517,41 +535,60 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil) playlist_thumbnail = child_node.xpath_node(%q(.//span/img)).try &.["data-thumb"]? playlist_thumbnail ||= child_node.xpath_node(%q(.//span/img)).try &.["src"] - if !playlist_thumbnail || playlist_thumbnail.empty? - thumbnail_id = videos[0]?.try &.id - else - thumbnail_id = playlist_thumbnail.match(/\/vi\/(?[a-zA-Z0-9_-]{11})\/\w+\.jpg/).try &.["video_id"] - end - video_count_label = child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"])) - if video_count_label - video_count = video_count_label.content.gsub(/\D/, "").to_i? + video_count = child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"]/b)) || + child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"])) + if video_count + video_count = video_count.content.gsub(/\D/, "").to_i? end video_count ||= 50 + videos = [] of SearchPlaylistVideo + child_node.xpath_nodes(%q(.//*[contains(@class, "yt-lockup-playlist-items")]/li)).each do |video| + anchor = video.xpath_node(%q(.//a)) + if anchor + video_title = anchor.content.strip + id = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)["v"] + end + video_title ||= "" + id ||= "" + + anchor = video.xpath_node(%q(.//span/span)) + if anchor + length_seconds = decode_length_seconds(anchor.content) + end + length_seconds ||= 0 + + videos << SearchPlaylistVideo.new( + video_title, + id, + length_seconds + ) + end + items << SearchPlaylist.new( - playlist_title, - plid, - author_name, - ucid, - video_count, - Array(SearchPlaylistVideo).new, - thumbnail_id + title: playlist_title, + id: plid, + author: author_name, + ucid: ucid, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail ) end end - if is_playlist + if shelf_is_playlist plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"] items << SearchPlaylist.new( - title, - plid, - author_name, - ucid, - videos.size, - videos, - videos[0].try &.id + title: title, + id: plid, + author: author_name, + ucid: ucid, + video_count: videos.size, + videos: videos, + thumbnail: "https://i.ytimg.com/vi/#{videos[0].id}/mqdefault.jpg" ) end end diff --git a/src/invidious/helpers/patch_mapping.cr b/src/invidious/helpers/patch_mapping.cr index 8360caa6..e138aa1c 100644 --- a/src/invidious/helpers/patch_mapping.cr +++ b/src/invidious/helpers/patch_mapping.cr @@ -4,7 +4,7 @@ def Object.from_json(string_or_io, default) : self new parser, default end -# Adds configurable 'default' to +# Adds configurable 'default' macro patched_json_mapping(_properties_, strict = false) {% for key, value in _properties_ %} {% _properties_[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %} diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/helpers/proxy.cr index e3c9d2f5..fde282cd 100644 --- a/src/invidious/helpers/proxy.cr +++ b/src/invidious/helpers/proxy.cr @@ -31,10 +31,10 @@ class HTTPProxy if resp[:code]? == 200 {% if !flag?(:without_openssl) %} - if tls - tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host) - socket = tls_socket - end + if tls + tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host) + socket = tls_socket + end {% end %} return socket diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 69aae839..b39f65c5 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -356,3 +356,16 @@ def parse_range(range) return 0_i64, nil end + +def convert_theme(theme) + case theme + when "true" + "dark" + when "false" + "light" + when "", nil + nil + else + theme + end +end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index d28a4149..7965d990 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -51,6 +51,7 @@ struct Playlist video_count: Int32, views: Int64, updated: Time, + thumbnail: String?, }) end @@ -223,6 +224,9 @@ def fetch_playlist(plid, locale) description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1])).try &.to_s || document.xpath_node(%q(//span[@class="pl-header-description-text"])).try &.to_s || "" + playlist_thumbnail = document.xpath_node(%q(//div[@class="pl-header-thumb"]/img)).try &.["data-thumb"]? || + document.xpath_node(%q(//div[@class="pl-header-thumb"]/img)).try &.["src"] + # YouTube allows anonymous playlists, so most of this can be empty or optional anchor = document.xpath_node(%q(//ul[@class="pl-header-details"])) author = anchor.try &.xpath_node(%q(.//li[1]/a)).try &.content @@ -234,15 +238,12 @@ def fetch_playlist(plid, locale) video_count = anchor.try &.xpath_node(%q(.//li[2])).try &.content.gsub(/\D/, "").to_i? video_count ||= 0 - views = anchor.try &.xpath_node(%q(.//li[3])).try &.content.delete("No views, ").to_i64? + + views = anchor.try &.xpath_node(%q(.//li[3])).try &.content.gsub(/\D/, "").to_i64? views ||= 0_i64 - updated = anchor.try &.xpath_node(%q(.//li[4])).try &.content.lchop("Last updated on ").lchop("Updated ") - if updated - updated = decode_date(updated) - else - updated = Time.utc - end + updated = anchor.try &.xpath_node(%q(.//li[4])).try &.content.lchop("Last updated on ").lchop("Updated ").try { |date| decode_date(date) } + updated ||= Time.utc playlist = Playlist.new( title: title, @@ -253,7 +254,8 @@ def fetch_playlist(plid, locale) description_html: description_html, video_count: video_count, views: views, - updated: updated + updated: updated, + thumbnail: playlist_thumbnail, ) return playlist diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 1d4805bf..7a36f32e 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -117,6 +117,7 @@ struct SearchPlaylist json.field "type", "playlist" json.field "title", self.title json.field "playlistId", self.id + json.field "playlistThumbnail", self.thumbnail json.field "author", self.author json.field "authorId", self.ucid @@ -152,13 +153,13 @@ struct SearchPlaylist end db_mapping({ - title: String, - id: String, - author: String, - ucid: String, - video_count: Int32, - videos: Array(SearchPlaylistVideo), - thumbnail_id: String?, + title: String, + id: String, + author: String, + ucid: String, + video_count: Int32, + videos: Array(SearchPlaylistVideo), + thumbnail: String?, }) end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 1b5d34c6..8bd82bf1 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -31,62 +31,6 @@ struct User end struct Preferences - module StringToArray - def self.to_json(value : Array(String), json : JSON::Builder) - json.array do - value.each do |element| - json.string element - end - end - end - - def self.from_json(value : JSON::PullParser) : Array(String) - begin - result = [] of String - value.read_array do - result << HTML.escape(value.read_string[0, 100]) - end - rescue ex - result = [HTML.escape(value.read_string[0, 100]), ""] - end - - result - end - - def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) - yaml.sequence do - value.each do |element| - yaml.scalar element - end - end - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) - begin - unless node.is_a?(YAML::Nodes::Sequence) - node.raise "Expected sequence, not #{node.class}" - end - - result = [] of String - node.nodes.each do |item| - unless item.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{item.class}" - end - - result << HTML.escape(item.value[0, 100]) - end - rescue ex - if node.is_a?(YAML::Nodes::Scalar) - result = [HTML.escape(node.value[0, 100]), ""] - else - result = ["", ""] - end - end - - result - end - end - module ProcessString def self.to_json(value : String, json : JSON::Builder) json.string value @@ -127,17 +71,18 @@ struct Preferences annotations: {type: Bool, default: CONFIG.default_user_preferences.annotations}, annotations_subscribed: {type: Bool, default: CONFIG.default_user_preferences.annotations_subscribed}, autoplay: {type: Bool, default: CONFIG.default_user_preferences.autoplay}, - captions: {type: Array(String), default: CONFIG.default_user_preferences.captions, converter: StringToArray}, - comments: {type: Array(String), default: CONFIG.default_user_preferences.comments, converter: StringToArray}, + captions: {type: Array(String), default: CONFIG.default_user_preferences.captions, converter: ConfigPreferences::StringToArray}, + comments: {type: Array(String), default: CONFIG.default_user_preferences.comments, converter: ConfigPreferences::StringToArray}, continue: {type: Bool, default: CONFIG.default_user_preferences.continue}, continue_autoplay: {type: Bool, default: CONFIG.default_user_preferences.continue_autoplay}, - dark_mode: {type: Bool, default: CONFIG.default_user_preferences.dark_mode}, + dark_mode: {type: String, default: CONFIG.default_user_preferences.dark_mode, converter: ConfigPreferences::BoolToString}, latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only}, listen: {type: Bool, default: CONFIG.default_user_preferences.listen}, local: {type: Bool, default: CONFIG.default_user_preferences.local}, locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: ProcessString}, max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results, converter: ClampInt}, notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only}, + player_style: {type: String, default: CONFIG.default_user_preferences.player_style, converter: ProcessString}, quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: ProcessString}, redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed}, related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos}, diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 49ff0494..03fe9a26 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -108,33 +108,7 @@ CAPTION_LANGUAGES = { "Zulu", } -REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"} -BYPASS_REGIONS = { - "GB", - "DE", - "FR", - "IN", - "CN", - "RU", - "CA", - "JP", - "IT", - "TH", - "ES", - "AE", - "KR", - "IR", - "BR", - "PK", - "ID", - "BD", - "MX", - "PH", - "EG", - "VN", - "CD", - "TR", -} +REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"} # See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 VIDEO_FORMATS = { @@ -258,6 +232,7 @@ struct VideoPreferences listen: Bool, local: Bool, preferred_captions: Array(String), + player_style: String, quality: String, raw: Bool, region: String?, @@ -803,8 +778,11 @@ struct Video end def premium - premium = self.player_response.to_s.includes? "Get YouTube without the ads." - return premium + if info["premium"]? + self.info["premium"] == "true" + else + false + end end def captions @@ -1128,34 +1106,24 @@ def fetch_video(id, region) info = extract_player_config(response.body, html) info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ") - # Try to use proxies for region-blocked videos - if info["reason"]? && info["reason"].includes? "your country" - bypass_channel = Channel({XML::Node, HTTP::Params} | Nil).new + allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",") + if !allowed_regions || allowed_regions == [""] + allowed_regions = [] of String + end - PROXY_LIST.each do |proxy_region, list| - spawn do - client = make_client(YT_URL, proxy_region) - proxy_response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") + # Check for region-blocks + if info["reason"]? && info["reason"].includes?("your country") + bypass_regions = PROXY_LIST.keys & allowed_regions + if !bypass_regions.empty? + region = bypass_regions[rand(bypass_regions.size)] + client = make_client(YT_URL, region) + response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") - proxy_html = XML.parse_html(proxy_response.body) - proxy_info = extract_player_config(proxy_response.body, proxy_html) + html = XML.parse_html(response.body) + info = extract_player_config(response.body, html) - if !proxy_info["reason"]? - proxy_info["region"] = proxy_region - proxy_info["cookie"] = proxy_response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ") - bypass_channel.send({proxy_html, proxy_info}) - else - bypass_channel.send(nil) - end - end - end - - PROXY_LIST.size.times do - response = bypass_channel.receive - if response - html, info = response - break - end + info["region"] = region if region + info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ") end end @@ -1175,7 +1143,7 @@ def fetch_video(id, region) end end - if info["errorcode"]?.try &.== "2" || !info["player_response"] + if !info["player_response"]? || info["errorcode"]?.try &.== "2" raise "Video unavailable." end @@ -1189,6 +1157,8 @@ def fetch_video(id, region) author = player_json["videoDetails"]["author"]?.try &.as_s || "" ucid = player_json["videoDetails"]["channelId"]?.try &.as_s || "" + info["premium"] = html.xpath_node(%q(.//span[text()="Premium"])) ? "true" : "false" + views = html.xpath_node(%q(//meta[@itemprop="interactionCount"])) .try &.["content"].to_i64? || 0_i64 @@ -1209,9 +1179,6 @@ def fetch_video(id, region) published ||= Time.utc.to_s("%Y-%m-%d") published = Time.parse(published, "%Y-%m-%d", Time::Location.local) - allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",") - allowed_regions ||= [] of String - is_family_friendly = html.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).try &.["content"] == "True" is_family_friendly ||= true @@ -1259,6 +1226,7 @@ def process_video_params(query, preferences) continue_autoplay = query["continue_autoplay"]?.try &.to_i? listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe local = query["local"]? && (query["local"] == "true" || query["local"] == "1").to_unsafe + player_style = query["player_style"]? preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase } quality = query["quality"]? region = query["region"]? @@ -1276,6 +1244,7 @@ def process_video_params(query, preferences) continue_autoplay ||= preferences.continue_autoplay.to_unsafe listen ||= preferences.listen.to_unsafe local ||= preferences.local.to_unsafe + player_style ||= preferences.player_style preferred_captions ||= preferences.captions quality ||= preferences.quality related_videos ||= preferences.related_videos.to_unsafe @@ -1291,6 +1260,7 @@ def process_video_params(query, preferences) continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe listen ||= CONFIG.default_user_preferences.listen.to_unsafe local ||= CONFIG.default_user_preferences.local.to_unsafe + player_style ||= CONFIG.default_user_preferences.player_style preferred_captions ||= CONFIG.default_user_preferences.captions quality ||= CONFIG.default_user_preferences.quality related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe @@ -1349,6 +1319,7 @@ def process_video_params(query, preferences) controls: controls, listen: listen, local: local, + player_style: player_style, preferred_captions: preferred_captions, quality: quality, raw: raw,