mirror of
				https://github.com/iv-org/invidious.git
				synced 2025-11-03 21:58:29 -06:00 
			
		
		
		
	Merge branch 'master' into api-only
This commit is contained in:
		
						commit
						507f924c5b
					
				
							
								
								
									
										28
									
								
								.travis.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								.travis.yml
									
									
									
									
									
										Normal 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
 | 
			
		||||
@ -1,5 +1,7 @@
 | 
			
		||||
# Invidious
 | 
			
		||||
 | 
			
		||||
[](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)
 | 
			
		||||
 | 
			
		||||
@ -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" ]
 | 
			
		||||
 | 
			
		||||
@ -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: ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟",
 | 
			
		||||
 | 
			
		||||
@ -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? ",
 | 
			
		||||
 | 
			
		||||
@ -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: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ",
 | 
			
		||||
 | 
			
		||||
@ -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? ",
 | 
			
		||||
 | 
			
		||||
@ -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? ",
 | 
			
		||||
 | 
			
		||||
@ -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? ",
 | 
			
		||||
 | 
			
		||||
@ -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: ": "",
 | 
			
		||||
 | 
			
		||||
@ -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 : ",
 | 
			
		||||
 | 
			
		||||
@ -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? ",
 | 
			
		||||
 | 
			
		||||
@ -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: ": "",
 | 
			
		||||
 | 
			
		||||
@ -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å? ",
 | 
			
		||||
 | 
			
		||||
@ -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? ",
 | 
			
		||||
 | 
			
		||||
@ -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: ": "",
 | 
			
		||||
 | 
			
		||||
@ -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: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
 | 
			
		||||
 | 
			
		||||
@ -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: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ",
 | 
			
		||||
 | 
			
		||||
@ -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: ": "在订阅频道的视频默认显示注释?",
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
name: invidious
 | 
			
		||||
version: 0.19.0
 | 
			
		||||
version: 0.19.1
 | 
			
		||||
 | 
			
		||||
authors:
 | 
			
		||||
  - Omar Roth <omarroth@protonmail.com>
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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"]
 | 
			
		||||
 | 
			
		||||
@ -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"
 | 
			
		||||
    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 += "&view=1"
 | 
			
		||||
    end
 | 
			
		||||
    url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list&view=1"
 | 
			
		||||
 | 
			
		||||
    case sort_by
 | 
			
		||||
    when "last", "last_added"
 | 
			
		||||
 | 
			
		||||
@ -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\/(?<video_id>[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\/(?<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"]))
 | 
			
		||||
        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
 | 
			
		||||
 | 
			
		||||
@ -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) %}
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
@ -158,7 +159,7 @@ struct SearchPlaylist
 | 
			
		||||
    ucid:        String,
 | 
			
		||||
    video_count: Int32,
 | 
			
		||||
    videos:      Array(SearchPlaylistVideo),
 | 
			
		||||
    thumbnail_id: String?,
 | 
			
		||||
    thumbnail:   String?,
 | 
			
		||||
  })
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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},
 | 
			
		||||
 | 
			
		||||
@ -109,32 +109,6 @@ CAPTION_LANGUAGES = {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
    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")
 | 
			
		||||
 | 
			
		||||
        proxy_html = XML.parse_html(proxy_response.body)
 | 
			
		||||
        proxy_info = extract_player_config(proxy_response.body, proxy_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
 | 
			
		||||
  allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",")
 | 
			
		||||
  if !allowed_regions || allowed_regions == [""]
 | 
			
		||||
    allowed_regions = [] of String
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
    PROXY_LIST.size.times do
 | 
			
		||||
      response = bypass_channel.receive
 | 
			
		||||
      if response
 | 
			
		||||
        html, info = response
 | 
			
		||||
        break
 | 
			
		||||
      end
 | 
			
		||||
  # 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")
 | 
			
		||||
 | 
			
		||||
      html = XML.parse_html(response.body)
 | 
			
		||||
      info = extract_player_config(response.body, html)
 | 
			
		||||
 | 
			
		||||
      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,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user