Merge branch 'master' into api-only

This commit is contained in:
Omar Roth 2019-08-22 09:15:33 -05:00
commit 507f924c5b
No known key found for this signature in database
GPG Key ID: B8254FB7EC3D37F2
31 changed files with 395 additions and 276 deletions

28
.travis.yml Normal file
View File

@ -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

View File

@ -1,5 +1,7 @@
# Invidious # 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 ## Invidious is an alternative front-end to YouTube
- Audio-only mode (and no need to keep window open on mobile) - Audio-only mode (and no need to keep window open on mobile)

View File

@ -1,15 +1,28 @@
FROM archlinux/base FROM alpine:edge AS builder
RUN apk add -u crystal shards libc-dev \
RUN pacman -Sy --noconfirm shards crystal imagemagick librsvg \ yaml-dev libxml2-dev sqlite-dev sqlite-static zlib-dev openssl-dev
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
WORKDIR /invidious 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 && \ FROM alpine:latest
shards update && shards install && \ RUN apk add -u imagemagick ttf-opensans
crystal build src/invidious.cr 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" ] CMD [ "/invidious/invidious" ]

View File

@ -68,7 +68,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: ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟",

View File

@ -68,7 +68,11 @@
"Show related videos: ": "Ähnliche Videos anzeigen? ", "Show related videos: ": "Ähnliche Videos anzeigen? ",
"Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ", "Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ",
"Visual preferences": "Anzeigeeinstellungen", "Visual preferences": "Anzeigeeinstellungen",
"Player style: ": "",
"Dark mode: ": "Nachtmodus: ", "Dark mode: ": "Nachtmodus: ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Schlanker Modus: ", "Thin mode: ": "Schlanker Modus: ",
"Subscription preferences": "Abonnementeinstellungen", "Subscription preferences": "Abonnementeinstellungen",
"Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ", "Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ",

View File

@ -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: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ",

View File

@ -74,7 +74,11 @@
"Show related videos: ": "Show related videos: ", "Show related videos: ": "Show related videos: ",
"Show annotations by default: ": "Show annotations by default: ", "Show annotations by default: ": "Show annotations by default: ",
"Visual preferences": "Visual preferences", "Visual preferences": "Visual preferences",
"Player style: ": "",
"Dark mode: ": "Dark mode: ", "Dark mode: ": "Dark mode: ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Thin mode: ", "Thin mode: ": "Thin mode: ",
"Subscription preferences": "Subscription preferences", "Subscription preferences": "Subscription preferences",
"Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ",

View File

@ -68,7 +68,11 @@
"Show related videos: ": "Ĉu montri rilatajn videojn? ", "Show related videos: ": "Ĉu montri rilatajn videojn? ",
"Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ", "Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ",
"Visual preferences": "Vidaj preferoj", "Visual preferences": "Vidaj preferoj",
"Player style: ": "",
"Dark mode: ": "Malhela reĝimo: ", "Dark mode: ": "Malhela reĝimo: ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Maldika reĝimo: ", "Thin mode: ": "Maldika reĝimo: ",
"Subscription preferences": "Abonaj agordoj", "Subscription preferences": "Abonaj agordoj",
"Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", "Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ",

View File

@ -68,7 +68,11 @@
"Show related videos: ": "¿Mostrar vídeos relacionados? ", "Show related videos: ": "¿Mostrar vídeos relacionados? ",
"Show annotations by default: ": "¿Mostrar anotaciones por defecto? ", "Show annotations by default: ": "¿Mostrar anotaciones por defecto? ",
"Visual preferences": "Preferencias visuales", "Visual preferences": "Preferencias visuales",
"Player style: ": "",
"Dark mode: ": "Modo oscuro: ", "Dark mode: ": "Modo oscuro: ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Modo compacto: ", "Thin mode: ": "Modo compacto: ",
"Subscription preferences": "Preferencias de la suscripción", "Subscription preferences": "Preferencias de la suscripción",
"Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ", "Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ",

View File

@ -68,7 +68,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: ": "",

View File

@ -68,7 +68,11 @@
"Show related videos: ": "Voir les vidéos liées : ", "Show related videos: ": "Voir les vidéos liées : ",
"Show annotations by default: ": "Voir les annotations par défaut : ", "Show annotations by default: ": "Voir les annotations par défaut : ",
"Visual preferences": "Préférences du site", "Visual preferences": "Préférences du site",
"Player style: ": "",
"Dark mode: ": "Mode Sombre : ", "Dark mode: ": "Mode Sombre : ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Mode Simplifié : ", "Thin mode: ": "Mode Simplifié : ",
"Subscription preferences": "Préférences de la page d'abonnements", "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 : ", "Show annotations by default for subscribed channels: ": "Voir les annotations par défaut sur les chaînes suivies : ",

View File

@ -68,7 +68,11 @@
"Show related videos: ": "Sýna tengd myndbönd? ", "Show related videos: ": "Sýna tengd myndbönd? ",
"Show annotations by default: ": "Á að sýna glósur sjálfgefið? ", "Show annotations by default: ": "Á að sýna glósur sjálfgefið? ",
"Visual preferences": "Sjónrænar stillingar", "Visual preferences": "Sjónrænar stillingar",
"Player style: ": "",
"Dark mode: ": "Myrkur ham: ", "Dark mode: ": "Myrkur ham: ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Þunnt ham: ", "Thin mode: ": "Þunnt ham: ",
"Subscription preferences": "Áskriftarstillingar", "Subscription preferences": "Áskriftarstillingar",
"Show annotations by default for subscribed channels: ": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ", "Show annotations by default for subscribed channels: ": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ",

View File

@ -68,7 +68,11 @@
"Show related videos: ": "Mostra video correlati? ", "Show related videos: ": "Mostra video correlati? ",
"Show annotations by default: ": "Mostra le annotazioni per impostazione predefinita? ", "Show annotations by default: ": "Mostra le annotazioni per impostazione predefinita? ",
"Visual preferences": "Preferenze grafiche", "Visual preferences": "Preferenze grafiche",
"Player style: ": "",
"Dark mode: ": "Tema scuro: ", "Dark mode: ": "Tema scuro: ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Modalità per connessioni lente: ", "Thin mode: ": "Modalità per connessioni lente: ",
"Subscription preferences": "Preferenze iscrizioni", "Subscription preferences": "Preferenze iscrizioni",
"Show annotations by default for subscribed channels: ": "", "Show annotations by default for subscribed channels: ": "",

View File

@ -68,7 +68,11 @@
"Show related videos: ": "Vis relaterte videoer? ", "Show related videos: ": "Vis relaterte videoer? ",
"Show annotations by default: ": "Vis merknader som forvalg? ", "Show annotations by default: ": "Vis merknader som forvalg? ",
"Visual preferences": "Visuelle innstillinger", "Visual preferences": "Visuelle innstillinger",
"Player style: ": "",
"Dark mode: ": "Mørk drakt: ", "Dark mode: ": "Mørk drakt: ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Tynt modus: ", "Thin mode: ": "Tynt modus: ",
"Subscription preferences": "Abonnementsinnstillinger", "Subscription preferences": "Abonnementsinnstillinger",
"Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ", "Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ",

View File

@ -68,7 +68,11 @@
"Show related videos: ": "Gerelateerde video's tonen? ", "Show related videos: ": "Gerelateerde video's tonen? ",
"Show annotations by default: ": "Standaard annotaties tonen? ", "Show annotations by default: ": "Standaard annotaties tonen? ",
"Visual preferences": "Visuele instellingen", "Visual preferences": "Visuele instellingen",
"Player style: ": "",
"Dark mode: ": "Donkere modus: ", "Dark mode: ": "Donkere modus: ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Smalle modus: ", "Thin mode: ": "Smalle modus: ",
"Subscription preferences": "Abonnementsinstellingen", "Subscription preferences": "Abonnementsinstellingen",
"Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ", "Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ",

View File

@ -68,7 +68,11 @@
"Show related videos: ": "Pokaż powiązane filmy? ", "Show related videos: ": "Pokaż powiązane filmy? ",
"Show annotations by default: ": "", "Show annotations by default: ": "",
"Visual preferences": "Preferencje Wizualne", "Visual preferences": "Preferencje Wizualne",
"Player style: ": "",
"Dark mode: ": "Ciemny motyw: ", "Dark mode: ": "Ciemny motyw: ",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "Tryb minimalny: ", "Thin mode: ": "Tryb minimalny: ",
"Subscription preferences": "Preferencje subskrybcji", "Subscription preferences": "Preferencje subskrybcji",
"Show annotations by default for subscribed channels: ": "", "Show annotations by default for subscribed channels: ": "",

View File

@ -68,7 +68,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: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",

View File

@ -68,7 +68,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: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ",

View File

@ -68,7 +68,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: ": "在订阅频道的视频默认显示注释?",

View File

@ -1,5 +1,5 @@
name: invidious name: invidious
version: 0.19.0 version: 0.19.1
authors: authors:
- Omar Roth <omarroth@protonmail.com> - Omar Roth <omarroth@protonmail.com>
@ -11,11 +11,14 @@ targets:
dependencies: dependencies:
pg: pg:
github: will/crystal-pg github: will/crystal-pg
version: ~> 0.18.1
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
version: ~> 0.13.0
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
version: ~> 0.26.0
crystal: 0.29.0 crystal: 0.30.1
license: AGPLv3 license: AGPLv3

View File

@ -1025,8 +1025,10 @@ get "/api/v1/playlists/:plid" do |env|
response = JSON.build do |json| response = JSON.build do |json|
json.object do json.object do
json.field "type", "playlist"
json.field "title", playlist.title json.field "title", playlist.title
json.field "playlistId", playlist.id json.field "playlistId", playlist.id
json.field "playlistThumbnail", playlist.thumbnail
json.field "author", playlist.author json.field "author", playlist.author
json.field "authorId", playlist.ucid json.field "authorId", playlist.ucid
@ -1216,7 +1218,7 @@ get "/api/manifest/dash/id/:id" do |env|
end end
audio_streams = video.audio_streams(adaptive_fmts) 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.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011",
@ -1772,6 +1774,43 @@ get "/sb/:id/:storyboard/:index" do |env|
end end
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| get "/vi/:id/:name" do |env|
id = env.params.url["id"] id = env.params.url["id"]
name = env.params.url["name"] name = env.params.url["name"]

View File

@ -387,14 +387,15 @@ def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
html = XML.parse_html(json["content_html"].as_s) html = XML.parse_html(json["content_html"].as_s)
nodeset = html.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])) nodeset = html.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
else elsif auto_generated
url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list" url = "/channel/#{ucid}"
if auto_generated response = client.get(url)
url += "&view=50" html = XML.parse_html(response.body)
else
url += "&view=1" nodeset = html.xpath_nodes(%q(//ul[@id="browse-items-primary"]/li[contains(@class, "feed-item-container")]))
end else
url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list&view=1"
case sort_by case sort_by
when "last", "last_added" when "last", "last_added"

View File

@ -69,20 +69,20 @@ class FilteredCompressHandler < Kemal::Handler
return call_next env if exclude_match? env return call_next env if exclude_match? env
{% if flag?(:without_zlib) %} {% if flag?(:without_zlib) %}
call_next env call_next env
{% else %} {% else %}
request_headers = env.request.headers request_headers = env.request.headers
if request_headers.includes_word?("Accept-Encoding", "gzip") if request_headers.includes_word?("Accept-Encoding", "gzip")
env.response.headers["Content-Encoding"] = "gzip" env.response.headers["Content-Encoding"] = "gzip"
env.response.output = Gzip::Writer.new(env.response.output, sync_close: true) env.response.output = Gzip::Writer.new(env.response.output, sync_close: true)
elsif request_headers.includes_word?("Accept-Encoding", "deflate") elsif request_headers.includes_word?("Accept-Encoding", "deflate")
env.response.headers["Content-Encoding"] = "deflate" env.response.headers["Content-Encoding"] = "deflate"
env.response.output = Flate::Writer.new(env.response.output, sync_close: true) env.response.output = Flate::Writer.new(env.response.output, sync_close: true)
end end
call_next env call_next env
{% end %} {% end %}
end end
end end

View File

@ -24,6 +24,27 @@ end
struct ConfigPreferences struct ConfigPreferences
module StringToArray 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) def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder)
yaml.sequence do yaml.sequence do
value.each do |element| value.each do |element|
@ -44,11 +65,11 @@ struct ConfigPreferences
node.raise "Expected scalar, not #{item.class}" node.raise "Expected scalar, not #{item.class}"
end end
result << item.value result << HTML.escape(item.value[0, 100])
end end
rescue ex rescue ex
if node.is_a?(YAML::Nodes::Scalar) if node.is_a?(YAML::Nodes::Scalar)
result = [node.value, ""] result = [HTML.escape(node.value[0, 100]), ""]
else else
result = ["", ""] result = ["", ""]
end end
@ -58,6 +79,53 @@ struct ConfigPreferences
end end
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({ yaml_mapping({
annotations: {type: Bool, default: false}, annotations: {type: Bool, default: false},
annotations_subscribed: {type: Bool, default: false}, annotations_subscribed: {type: Bool, default: false},
@ -66,13 +134,14 @@ struct ConfigPreferences
comments: {type: Array(String), default: ["youtube", ""], converter: StringToArray}, comments: {type: Array(String), default: ["youtube", ""], converter: StringToArray},
continue: {type: Bool, default: false}, continue: {type: Bool, default: false},
continue_autoplay: {type: Bool, default: true}, 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}, latest_only: {type: Bool, default: false},
listen: {type: Bool, default: false}, listen: {type: Bool, default: false},
local: {type: Bool, default: false}, local: {type: Bool, default: false},
locale: {type: String, default: "en-US"}, locale: {type: String, default: "en-US"},
max_results: {type: Int32, default: 40}, max_results: {type: Int32, default: 40},
notifications_only: {type: Bool, default: false}, notifications_only: {type: Bool, default: false},
player_style: {type: String, default: "invidious"},
quality: {type: String, default: "hd720"}, quality: {type: String, default: "hd720"},
redirect_feed: {type: Bool, default: false}, redirect_feed: {type: Bool, default: false},
related_videos: {type: Bool, default: true}, related_videos: {type: Bool, default: true},
@ -243,8 +312,7 @@ end
def extract_videos(nodeset, ucid = nil, author_name = nil) def extract_videos(nodeset, ucid = nil, author_name = nil)
videos = extract_items(nodeset, ucid, author_name) videos = extract_items(nodeset, ucid, author_name)
videos.select! { |item| !item.is_a?(SearchChannel | SearchPlaylist) } videos.select { |item| item.is_a?(SearchVideo) }.map { |video| video.as(SearchVideo) }
videos.map { |video| video.as(SearchVideo) }
end end
def extract_items(nodeset, ucid = nil, author_name = nil) def extract_items(nodeset, ucid = nil, author_name = nil)
@ -263,18 +331,8 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
next next
end end
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)) author_id = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)).try &.["href"].split("/")[-1] || ucid || ""
if anchor author = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)).try &.content.strip || author_name || ""
author = anchor.content.strip
author_id = anchor["href"].split("/")[-1]
end
author ||= author_name
author_id ||= ucid
author ||= ""
author_id ||= ""
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")])).try &.to_s || "" 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")])) 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)) anchor = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li/a))
end 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 if video_count
video_count = video_count.content video_count = video_count.content
if video_count == "50+" if video_count == "50+"
author = "YouTube" author = "YouTube"
author_id = "UC-9-kyTW8ZkZNDHQJ6FgpwQ" author_id = "UC-9-kyTW8ZkZNDHQJ6FgpwQ"
video_count = video_count.rchop("+")
end end
video_count = video_count.gsub(/\D/, "").to_i? video_count = video_count.gsub(/\D/, "").to_i?
@ -329,22 +387,17 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
) )
end end
playlist_thumbnail = node.xpath_node(%q(.//div/span/img)).try &.["data-thumb"]? playlist_thumbnail = node.xpath_node(%q(.//span/img)).try &.["data-thumb"]?
playlist_thumbnail ||= node.xpath_node(%q(.//div/span/img)).try &.["src"] playlist_thumbnail ||= 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\/(?<video_id>[a-zA-Z0-9_-]{11})\/\w+\.jpg/).try &.["video_id"]
end
items << SearchPlaylist.new( items << SearchPlaylist.new(
title, title: title,
plid, id: plid,
author, author: author,
author_id, ucid: author_id,
video_count, video_count: video_count,
videos, videos: videos,
thumbnail_id thumbnail: playlist_thumbnail
) )
when .includes? "yt-lockup-channel" when .includes? "yt-lockup-channel"
author = title.strip author = title.strip
@ -379,47 +432,20 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
else else
id = id.lchop("/watch?v=") 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 = metadata.try &.xpath_node(%q(.//li[contains(text(), " ago")])).try { |node| decode_date(node.content.sub(/^[a-zA-Z]+ /, "")) }
published = decode_date(metadata[0].content.lchop("Streamed ").lchop("Starts ")) published ||= metadata.try &.xpath_node(%q(.//span[@data-timestamp])).try { |node| Time.unix(node["data-timestamp"].to_i64) }
rescue ex
end
begin
published ||= Time.unix(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
rescue ex
end
published ||= Time.utc published ||= Time.utc
begin view_count = metadata.try &.xpath_node(%q(.//li[contains(text(), " views")])).try &.content.gsub(/\D/, "").to_i64?
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 ||= 0_i64 view_count ||= 0_i64
length_seconds = node.xpath_node(%q(.//span[@class="video-time"])) length_seconds = node.xpath_node(%q(.//span[@class="video-time"])).try { |node| decode_length_seconds(node.content) }
if length_seconds length_seconds ||= -1
length_seconds = decode_length_seconds(length_seconds.content)
else
length_seconds = -1
end
live_now = node.xpath_node(%q(.//span[contains(@class, "yt-badge-live")])) live_now = node.xpath_node(%q(.//span[contains(@class, "yt-badge-live")])) ? true : false
if live_now premium = node.xpath_node(%q(.//span[text()="Premium"])) ? true : false
live_now = true
else
live_now = false
end
if node.xpath_node(%q(.//span[text()="Premium"]))
premium = true
else
premium = false
end
if !premium || node.xpath_node(%q(.//span[contains(text(), "Free episode")])) if !premium || node.xpath_node(%q(.//span[contains(text(), "Free episode")]))
paid = false paid = false
@ -457,26 +483,18 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
nodeset.each do |shelf| nodeset.each do |shelf|
shelf_anchor = shelf.xpath_node(%q(.//h2[contains(@class, "branded-page-module-title")])) shelf_anchor = shelf.xpath_node(%q(.//h2[contains(@class, "branded-page-module-title")]))
next if !shelf_anchor
if !shelf_anchor title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")])).try &.content.strip
next
end
title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")]))
if title
title = title.content.strip
end
title ||= "" title ||= ""
id = shelf_anchor.xpath_node(%q(.//a)).try &.["href"] id = shelf_anchor.xpath_node(%q(.//a)).try &.["href"]
if !id next if !id
next
end
is_playlist = false shelf_is_playlist = false
videos = [] of SearchPlaylistVideo 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)) type = child_node.xpath_node(%q(./div))
if !type if !type
next next
@ -484,7 +502,7 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
case type["class"] case type["class"]
when .includes? "yt-lockup-video" 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)) anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
if anchor 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 &.["data-thumb"]?
playlist_thumbnail ||= child_node.xpath_node(%q(.//span/img)).try &.["src"] 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\/(?<video_id>[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"])) video_count = child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"]/b)) ||
if video_count_label child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"]))
video_count = video_count_label.content.gsub(/\D/, "").to_i? if video_count
video_count = video_count.content.gsub(/\D/, "").to_i?
end end
video_count ||= 50 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( items << SearchPlaylist.new(
playlist_title, title: playlist_title,
plid, id: plid,
author_name, author: author_name,
ucid, ucid: ucid,
video_count, video_count: video_count,
Array(SearchPlaylistVideo).new, videos: videos,
thumbnail_id thumbnail: playlist_thumbnail
) )
end end
end end
if is_playlist if shelf_is_playlist
plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"] plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"]
items << SearchPlaylist.new( items << SearchPlaylist.new(
title, title: title,
plid, id: plid,
author_name, author: author_name,
ucid, ucid: ucid,
videos.size, video_count: videos.size,
videos, videos: videos,
videos[0].try &.id thumbnail: "https://i.ytimg.com/vi/#{videos[0].id}/mqdefault.jpg"
) )
end end
end end

View File

@ -4,7 +4,7 @@ def Object.from_json(string_or_io, default) : self
new parser, default new parser, default
end end
# Adds configurable 'default' to # Adds configurable 'default'
macro patched_json_mapping(_properties_, strict = false) macro patched_json_mapping(_properties_, strict = false)
{% for key, value in _properties_ %} {% for key, value in _properties_ %}
{% _properties_[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %} {% _properties_[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %}

View File

@ -31,10 +31,10 @@ class HTTPProxy
if resp[:code]? == 200 if resp[:code]? == 200
{% if !flag?(:without_openssl) %} {% if !flag?(:without_openssl) %}
if tls if tls
tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host) tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host)
socket = tls_socket socket = tls_socket
end end
{% end %} {% end %}
return socket return socket

View File

@ -356,3 +356,16 @@ def parse_range(range)
return 0_i64, nil return 0_i64, nil
end end
def convert_theme(theme)
case theme
when "true"
"dark"
when "false"
"light"
when "", nil
nil
else
theme
end
end

View File

@ -51,6 +51,7 @@ struct Playlist
video_count: Int32, video_count: Int32,
views: Int64, views: Int64,
updated: Time, updated: Time,
thumbnail: String?,
}) })
end 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 || 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 || "" 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 # YouTube allows anonymous playlists, so most of this can be empty or optional
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"])) anchor = document.xpath_node(%q(//ul[@class="pl-header-details"]))
author = anchor.try &.xpath_node(%q(.//li[1]/a)).try &.content 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 = anchor.try &.xpath_node(%q(.//li[2])).try &.content.gsub(/\D/, "").to_i?
video_count ||= 0 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 views ||= 0_i64
updated = anchor.try &.xpath_node(%q(.//li[4])).try &.content.lchop("Last updated on ").lchop("Updated ") updated = anchor.try &.xpath_node(%q(.//li[4])).try &.content.lchop("Last updated on ").lchop("Updated ").try { |date| decode_date(date) }
if updated updated ||= Time.utc
updated = decode_date(updated)
else
updated = Time.utc
end
playlist = Playlist.new( playlist = Playlist.new(
title: title, title: title,
@ -253,7 +254,8 @@ def fetch_playlist(plid, locale)
description_html: description_html, description_html: description_html,
video_count: video_count, video_count: video_count,
views: views, views: views,
updated: updated updated: updated,
thumbnail: playlist_thumbnail,
) )
return playlist return playlist

View File

@ -117,6 +117,7 @@ struct SearchPlaylist
json.field "type", "playlist" json.field "type", "playlist"
json.field "title", self.title json.field "title", self.title
json.field "playlistId", self.id json.field "playlistId", self.id
json.field "playlistThumbnail", self.thumbnail
json.field "author", self.author json.field "author", self.author
json.field "authorId", self.ucid json.field "authorId", self.ucid
@ -152,13 +153,13 @@ struct SearchPlaylist
end end
db_mapping({ db_mapping({
title: String, title: String,
id: String, id: String,
author: String, author: String,
ucid: String, ucid: String,
video_count: Int32, video_count: Int32,
videos: Array(SearchPlaylistVideo), videos: Array(SearchPlaylistVideo),
thumbnail_id: String?, thumbnail: String?,
}) })
end end

View File

@ -31,62 +31,6 @@ struct User
end end
struct Preferences 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 module ProcessString
def self.to_json(value : String, json : JSON::Builder) def self.to_json(value : String, json : JSON::Builder)
json.string value json.string value
@ -127,17 +71,18 @@ struct Preferences
annotations: {type: Bool, default: CONFIG.default_user_preferences.annotations}, annotations: {type: Bool, default: CONFIG.default_user_preferences.annotations},
annotations_subscribed: {type: Bool, default: CONFIG.default_user_preferences.annotations_subscribed}, annotations_subscribed: {type: Bool, default: CONFIG.default_user_preferences.annotations_subscribed},
autoplay: {type: Bool, default: CONFIG.default_user_preferences.autoplay}, autoplay: {type: Bool, default: CONFIG.default_user_preferences.autoplay},
captions: {type: Array(String), default: CONFIG.default_user_preferences.captions, 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: StringToArray}, comments: {type: Array(String), default: CONFIG.default_user_preferences.comments, converter: ConfigPreferences::StringToArray},
continue: {type: Bool, default: CONFIG.default_user_preferences.continue}, continue: {type: Bool, default: CONFIG.default_user_preferences.continue},
continue_autoplay: {type: Bool, default: CONFIG.default_user_preferences.continue_autoplay}, 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}, latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only},
listen: {type: Bool, default: CONFIG.default_user_preferences.listen}, listen: {type: Bool, default: CONFIG.default_user_preferences.listen},
local: {type: Bool, default: CONFIG.default_user_preferences.local}, local: {type: Bool, default: CONFIG.default_user_preferences.local},
locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: ProcessString}, locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: ProcessString},
max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results, converter: ClampInt}, max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results, converter: ClampInt},
notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only}, 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}, quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: ProcessString},
redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed}, redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed},
related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos}, related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos},

View File

@ -108,33 +108,7 @@ CAPTION_LANGUAGES = {
"Zulu", "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"} 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",
}
# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 # See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476
VIDEO_FORMATS = { VIDEO_FORMATS = {
@ -258,6 +232,7 @@ struct VideoPreferences
listen: Bool, listen: Bool,
local: Bool, local: Bool,
preferred_captions: Array(String), preferred_captions: Array(String),
player_style: String,
quality: String, quality: String,
raw: Bool, raw: Bool,
region: String?, region: String?,
@ -803,8 +778,11 @@ struct Video
end end
def premium def premium
premium = self.player_response.to_s.includes? "Get YouTube without the ads." if info["premium"]?
return premium self.info["premium"] == "true"
else
false
end
end end
def captions def captions
@ -1128,34 +1106,24 @@ def fetch_video(id, region)
info = extract_player_config(response.body, html) info = extract_player_config(response.body, html)
info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ") info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ")
# Try to use proxies for region-blocked videos allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",")
if info["reason"]? && info["reason"].includes? "your country" if !allowed_regions || allowed_regions == [""]
bypass_channel = Channel({XML::Node, HTTP::Params} | Nil).new allowed_regions = [] of String
end
PROXY_LIST.each do |proxy_region, list| # Check for region-blocks
spawn do if info["reason"]? && info["reason"].includes?("your country")
client = make_client(YT_URL, proxy_region) bypass_regions = PROXY_LIST.keys & allowed_regions
proxy_response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") 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) html = XML.parse_html(response.body)
proxy_info = extract_player_config(proxy_response.body, proxy_html) info = extract_player_config(response.body, html)
if !proxy_info["reason"]? info["region"] = region if region
proxy_info["region"] = proxy_region info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ")
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
end end
end end
@ -1175,7 +1143,7 @@ def fetch_video(id, region)
end end
end end
if info["errorcode"]?.try &.== "2" || !info["player_response"] if !info["player_response"]? || info["errorcode"]?.try &.== "2"
raise "Video unavailable." raise "Video unavailable."
end end
@ -1189,6 +1157,8 @@ def fetch_video(id, region)
author = player_json["videoDetails"]["author"]?.try &.as_s || "" author = player_json["videoDetails"]["author"]?.try &.as_s || ""
ucid = player_json["videoDetails"]["channelId"]?.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"])) views = html.xpath_node(%q(//meta[@itemprop="interactionCount"]))
.try &.["content"].to_i64? || 0_i64 .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.utc.to_s("%Y-%m-%d")
published = Time.parse(published, "%Y-%m-%d", Time::Location.local) 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 = html.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).try &.["content"] == "True"
is_family_friendly ||= true is_family_friendly ||= true
@ -1259,6 +1226,7 @@ def process_video_params(query, preferences)
continue_autoplay = query["continue_autoplay"]?.try &.to_i? continue_autoplay = query["continue_autoplay"]?.try &.to_i?
listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe
local = query["local"]? && (query["local"] == "true" || query["local"] == "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 } preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]? quality = query["quality"]?
region = query["region"]? region = query["region"]?
@ -1276,6 +1244,7 @@ def process_video_params(query, preferences)
continue_autoplay ||= preferences.continue_autoplay.to_unsafe continue_autoplay ||= preferences.continue_autoplay.to_unsafe
listen ||= preferences.listen.to_unsafe listen ||= preferences.listen.to_unsafe
local ||= preferences.local.to_unsafe local ||= preferences.local.to_unsafe
player_style ||= preferences.player_style
preferred_captions ||= preferences.captions preferred_captions ||= preferences.captions
quality ||= preferences.quality quality ||= preferences.quality
related_videos ||= preferences.related_videos.to_unsafe 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 continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe
listen ||= CONFIG.default_user_preferences.listen.to_unsafe listen ||= CONFIG.default_user_preferences.listen.to_unsafe
local ||= CONFIG.default_user_preferences.local.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 preferred_captions ||= CONFIG.default_user_preferences.captions
quality ||= CONFIG.default_user_preferences.quality quality ||= CONFIG.default_user_preferences.quality
related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe
@ -1349,6 +1319,7 @@ def process_video_params(query, preferences)
controls: controls, controls: controls,
listen: listen, listen: listen,
local: local, local: local,
player_style: player_style,
preferred_captions: preferred_captions, preferred_captions: preferred_captions,
quality: quality, quality: quality,
raw: raw, raw: raw,