Merge branch 'master' of github.com:iv-org/invidious
このコミットが含まれているのは:
コミット
f21f438795
|
@ -314,6 +314,15 @@ https_only: false
|
|||
##
|
||||
channel_threads: 1
|
||||
|
||||
##
|
||||
## Time interval between two executions of the job that crawls
|
||||
## channel videos (subscriptions update).
|
||||
##
|
||||
## Accepted values: a valid time interval (like 1h30m or 90m)
|
||||
## Default: 30m
|
||||
##
|
||||
#channel_refresh_interval: 30m
|
||||
|
||||
##
|
||||
## Forcefully dump and re-download the entire list of uploaded
|
||||
## videos when crawling channel (during subscriptions update).
|
||||
|
@ -847,3 +856,13 @@ default_user_preferences:
|
|||
## Default: false
|
||||
##
|
||||
#automatic_instance_redirect: false
|
||||
|
||||
##
|
||||
## Show the entire video description by default (when set to 'false',
|
||||
## only the first few lines of the description are shown and a
|
||||
## "show more" button allows to expand it).
|
||||
##
|
||||
## Accepted values: true, false
|
||||
## Default: false
|
||||
##
|
||||
#extend_desc: false
|
||||
|
|
|
@ -1,18 +1,12 @@
|
|||
version: '3'
|
||||
# Warning: This docker-compose file is made for development purposes.
|
||||
# Using it will build an image from the locally cloned repository.
|
||||
#
|
||||
# If you want to use Invidious in production, see the docker-compose.yml file provided
|
||||
# in the installation documentation: https://docs.invidious.io/Installation.md
|
||||
|
||||
version: "3"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:10
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgresdata:/var/lib/postgresql/data
|
||||
- ./config/sql:/config/sql
|
||||
- ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
|
||||
environment:
|
||||
POSTGRES_DB: invidious
|
||||
POSTGRES_PASSWORD: kemal
|
||||
POSTGRES_USER: kemal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
|
||||
invidious:
|
||||
build:
|
||||
context: .
|
||||
|
@ -21,27 +15,42 @@ services:
|
|||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
# Adapted from ./config/config.yml
|
||||
# Please read the following file for a comprehensive list of all available
|
||||
# configuration options and their associated syntax:
|
||||
# https://github.com/iv-org/invidious/blob/master/config/config.example.yml
|
||||
INVIDIOUS_CONFIG: |
|
||||
channel_threads: 1
|
||||
check_tables: true
|
||||
feed_threads: 1
|
||||
db:
|
||||
dbname: invidious
|
||||
user: kemal
|
||||
password: kemal
|
||||
host: postgres
|
||||
host: invidious-db
|
||||
port: 5432
|
||||
dbname: invidious
|
||||
full_refresh: false
|
||||
https_only: false
|
||||
domain:
|
||||
check_tables: true
|
||||
# external_port:
|
||||
# domain:
|
||||
# https_only: false
|
||||
# statistics_enabled: false
|
||||
healthcheck:
|
||||
test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 2
|
||||
depends_on:
|
||||
- postgres
|
||||
- invidious-db
|
||||
|
||||
invidious-db:
|
||||
image: docker.io/library/postgres:14
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgresdata:/var/lib/postgresql/data
|
||||
- ./config/sql:/config/sql
|
||||
- ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
|
||||
environment:
|
||||
POSTGRES_DB: invidious
|
||||
POSTGRES_USER: kemal
|
||||
POSTGRES_PASSWORD: kemal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
|
||||
volumes:
|
||||
postgresdata:
|
||||
|
|
|
@ -42,7 +42,7 @@ RUN addgroup -g 1000 -S invidious && \
|
|||
adduser -u 1000 -S invidious -G invidious
|
||||
COPY --chown=invidious ./config/config.* ./config/
|
||||
RUN mv -n config/config.example.yml config/config.yml
|
||||
RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: postgres/' config/config.yml
|
||||
RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml
|
||||
COPY ./config/sql/ ./config/sql/
|
||||
COPY ./locales/ ./locales/
|
||||
COPY --from=builder /invidious/assets ./assets/
|
||||
|
|
|
@ -41,7 +41,7 @@ RUN addgroup -g 1000 -S invidious && \
|
|||
adduser -u 1000 -S invidious -G invidious
|
||||
COPY --chown=invidious ./config/config.* ./config/
|
||||
RUN mv -n config/config.example.yml config/config.yml
|
||||
RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: postgres/' config/config.yml
|
||||
RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml
|
||||
COPY ./config/sql/ ./config/sql/
|
||||
COPY ./locales/ ./locales/
|
||||
COPY --from=builder /invidious/assets ./assets/
|
||||
|
|
|
@ -65,6 +65,7 @@
|
|||
"preferences_continue_autoplay_label": "Autoplay next video: ",
|
||||
"preferences_listen_label": "Listen by default: ",
|
||||
"preferences_local_label": "Proxy videos: ",
|
||||
"preferences_watch_history_label": "Enable watch history: ",
|
||||
"preferences_speed_label": "Default speed: ",
|
||||
"preferences_quality_label": "Preferred video quality: ",
|
||||
"preferences_quality_option_dash": "DASH (adaptative quality)",
|
||||
|
|
|
@ -458,5 +458,6 @@
|
|||
"Chinese (China)": "Chino (China)",
|
||||
"Korean (auto-generated)": "Coreano (generados automáticamente)",
|
||||
"Spanish (Mexico)": "Español (Méjico)",
|
||||
"Spanish (auto-generated)": "Español (generados automáticamente)"
|
||||
"Spanish (auto-generated)": "Español (generados automáticamente)",
|
||||
"preferences_watch_history_label": "Habilitar historial de reproducciones: "
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
"Export": "Exporter",
|
||||
"Export subscriptions as OPML": "Exporter les abonnements au format OPML",
|
||||
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements au format OPML (pour NewPipe & FreeTube)",
|
||||
"Export data as JSON": "Exporter les données au format JSON",
|
||||
"Export data as JSON": "Exporter les données Invidious au format JSON",
|
||||
"Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?",
|
||||
"History": "Historique",
|
||||
"An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
|
||||
|
@ -460,5 +460,6 @@
|
|||
"Italian (auto-generated)": "Italien (auto-généré)",
|
||||
"Vietnamese (auto-generated)": "Vietnamien (auto-généré)",
|
||||
"Russian (auto-generated)": "Russe (auto-généré)",
|
||||
"Spanish (Spain)": "Espagnol (Espagne)"
|
||||
"Spanish (Spain)": "Espagnol (Espagne)",
|
||||
"preferences_watch_history_label": "Activer l'historique de visionnage : "
|
||||
}
|
||||
|
|
|
@ -318,5 +318,6 @@
|
|||
"Videos": "Myndbönd",
|
||||
"Playlists": "Spilunarlistar",
|
||||
"Community": "Samfélag",
|
||||
"Current version: ": "Núverandi útgáfa: "
|
||||
"Current version: ": "Núverandi útgáfa: ",
|
||||
"preferences_watch_history_label": "Virkja áhorfssögu: "
|
||||
}
|
||||
|
|
|
@ -21,15 +21,15 @@
|
|||
"No": "Nie",
|
||||
"Import and Export Data": "Import i eksport danych",
|
||||
"Import": "Import",
|
||||
"Import Invidious data": "Importuj dane Invidious",
|
||||
"Import YouTube subscriptions": "Importuj subskrybcje z YouTube",
|
||||
"Import Invidious data": "Importuj dane JSON Invidious",
|
||||
"Import YouTube subscriptions": "Importuj subskrybcje z YouTube/OPML",
|
||||
"Import FreeTube subscriptions (.db)": "Importuj subskrybcje z FreeTube (.db)",
|
||||
"Import NewPipe subscriptions (.json)": "Importuj subskrybcje z NewPipe (.json)",
|
||||
"Import NewPipe data (.zip)": "Importuj dane NewPipe (.zip)",
|
||||
"Export": "Eksport",
|
||||
"Export subscriptions as OPML": "Eksportuj subskrybcje jako OPML",
|
||||
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuj subskrybcje jako OPML (dla NewPipe i FreeTube)",
|
||||
"Export data as JSON": "Eksportuj dane jako JSON",
|
||||
"Export data as JSON": "Eksportuj dane Invidious jako JSON",
|
||||
"Delete account?": "Usunąć konto?",
|
||||
"History": "Historia",
|
||||
"An alternative front-end to YouTube": "Alternatywny front-end dla YouTube",
|
||||
|
@ -66,7 +66,7 @@
|
|||
"preferences_related_videos_label": "Pokaż powiązane filmy? ",
|
||||
"preferences_annotations_label": "Domyślnie pokazuj adnotacje: ",
|
||||
"preferences_extend_desc_label": "Automatycznie rozwijaj opisy filmów: ",
|
||||
"preferences_vr_mode_label": "Interaktywne filmy 360 stopni: ",
|
||||
"preferences_vr_mode_label": "Interaktywne filmy 360 stopni (wymaga WebGL): ",
|
||||
"preferences_category_visual": "Preferencje Wizualne",
|
||||
"preferences_player_style_label": "Styl odtwarzacza: ",
|
||||
"Dark mode: ": "Ciemny motyw: ",
|
||||
|
@ -446,12 +446,35 @@
|
|||
"Video unavailable": "Film niedostępny",
|
||||
"preferences_save_player_pos_label": "Zapisz pozycję odtwarzania: ",
|
||||
"preferences_region_label": "Region zawartości: ",
|
||||
"Released under the AGPLv3 on Github.": "Wydane na licencji AGPLv3 na Github'ie.",
|
||||
"Released under the AGPLv3 on Github.": "Wydany na licencji AGPLv3 na Github.",
|
||||
"short": "Krótkie (< 4 minutes)",
|
||||
"long": "Długie (> 20 minutes)",
|
||||
"footer_documentation": "Dokumentacja",
|
||||
"footer_source_code": "Kod źródłowy",
|
||||
"footer_modfied_source_code": "Zmodyfikowany Kod źródłowy",
|
||||
"footer_original_source_code": "Oryginalny kod źródłowy",
|
||||
"adminprefs_modified_source_code_url_label": "Adres URL do repozytorium z zmodyfikowanym kodem źródłowym"
|
||||
"adminprefs_modified_source_code_url_label": "Adres URL do repozytorium z zmodyfikowanym kodem źródłowym",
|
||||
"English (United Kingdom)": "angielski (Wielka Brytania)",
|
||||
"English (United States)": "angielski (Stany Zjednoczone)",
|
||||
"Cantonese (Hong Kong)": "kantoński (Hong Kong)",
|
||||
"Chinese": "chiński",
|
||||
"Chinese (China)": "chiński (Chiny)",
|
||||
"Chinese (Hong Kong)": "chiński (Hong Kong)",
|
||||
"Chinese (Taiwan)": "chiński (Tajwan)",
|
||||
"Dutch (auto-generated)": "niderlandzki (wygenerowany automatycznie)",
|
||||
"French (auto-generated)": "francuski (wygenerowany automatycznie)",
|
||||
"German (auto-generated)": "niemiecki (wygenerowany automatycznie)",
|
||||
"Indonesian (auto-generated)": "indonezyjski (wygenerowany automatycznie)",
|
||||
"Interlingue": "interlingue",
|
||||
"Italian (auto-generated)": "włoski (wygenerowany automatycznie)",
|
||||
"Korean (auto-generated)": "koreański (wygenerowany automatycznie)",
|
||||
"Spanish (auto-generated)": "hiszpański (wygenerowany automatycznie)",
|
||||
"Spanish (Mexico)": "hiszpański (Meksyk)",
|
||||
"Spanish (Spain)": "hiszpański (Hiszpania)",
|
||||
"Turkish (auto-generated)": "turecki (wygenerowany automatycznie)",
|
||||
"Vietnamese (auto-generated)": "wietnamski (wygenerowany automatycznie)",
|
||||
"Japanese (auto-generated)": "japoński (wygenerowany automatycznie)",
|
||||
"Russian (auto-generated)": "rosyjski (wygenerowany automatycznie)",
|
||||
"Portuguese (auto-generated)": "portugalski (wygenerowany automatycznie)",
|
||||
"Portuguese (Brazil)": "portugalski (Brazylia)"
|
||||
}
|
||||
|
|
|
@ -460,5 +460,6 @@
|
|||
"German (auto-generated)": "Almanca (otomatik oluşturuldu)",
|
||||
"Portuguese (auto-generated)": "Portekizce (otomatik oluşturuldu)",
|
||||
"Spanish (Spain)": "İspanyolca (İspanya)",
|
||||
"Vietnamese (auto-generated)": "Vietnamca (otomatik oluşturuldu)"
|
||||
"Vietnamese (auto-generated)": "Vietnamca (otomatik oluşturuldu)",
|
||||
"preferences_watch_history_label": "İzleme geçmişini etkinleştir: "
|
||||
}
|
||||
|
|
|
@ -444,5 +444,6 @@
|
|||
"Dutch (auto-generated)": "荷兰语 (自动生成)",
|
||||
"French (auto-generated)": "法语 (自动生成)",
|
||||
"Turkish (auto-generated)": "土耳其语 (自动生成)",
|
||||
"Spanish (Spain)": "西班牙语 (西班牙)"
|
||||
"Spanish (Spain)": "西班牙语 (西班牙)",
|
||||
"preferences_watch_history_label": "启用观看历史: "
|
||||
}
|
||||
|
|
|
@ -444,5 +444,6 @@
|
|||
"Indonesian (auto-generated)": "印尼語(自動產生)",
|
||||
"Portuguese (Brazil)": "葡萄牙語(巴西)",
|
||||
"Japanese (auto-generated)": "日語(自動產生)",
|
||||
"Portuguese (auto-generated)": "葡萄牙語(自動產生)"
|
||||
"Portuguese (auto-generated)": "葡萄牙語(自動產生)",
|
||||
"preferences_watch_history_label": "啟用觀看紀錄: "
|
||||
}
|
||||
|
|
|
@ -29,6 +29,8 @@ require "protodec/utils"
|
|||
require "./invidious/database/*"
|
||||
require "./invidious/helpers/*"
|
||||
require "./invidious/yt_backend/*"
|
||||
require "./invidious/frontend/*"
|
||||
|
||||
require "./invidious/*"
|
||||
require "./invidious/channels/*"
|
||||
require "./invidious/user/*"
|
||||
|
@ -154,8 +156,8 @@ if CONFIG.popular_enabled
|
|||
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
|
||||
end
|
||||
|
||||
connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32)
|
||||
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url)
|
||||
CONNECTION_CHANNEL = Channel({Bool, Channel(PQ::Notification)}).new(32)
|
||||
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url)
|
||||
|
||||
Invidious::Jobs.start_all
|
||||
|
||||
|
@ -234,6 +236,7 @@ before_all do |env|
|
|||
"/api/manifest/",
|
||||
"/videoplayback",
|
||||
"/latest_version",
|
||||
"/download",
|
||||
}.any? { |r| env.request.resource.starts_with? r }
|
||||
|
||||
if env.request.cookies.has_key? "SID"
|
||||
|
@ -324,6 +327,9 @@ end
|
|||
Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists
|
||||
Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community
|
||||
Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
|
||||
Invidious::Routing.get "/channel/:ucid/live", Invidious::Routes::Channels, :live
|
||||
Invidious::Routing.get "/user/:user/live", Invidious::Routes::Channels, :live
|
||||
Invidious::Routing.get "/c/:user/live", Invidious::Routes::Channels, :live
|
||||
|
||||
["", "/videos", "/playlists", "/community", "/about"].each do |path|
|
||||
# /c/LinusTechTips
|
||||
|
@ -346,6 +352,8 @@ end
|
|||
Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect
|
||||
Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect
|
||||
|
||||
Invidious::Routing.post "/download", Invidious::Routes::Watch, :download
|
||||
|
||||
Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect
|
||||
Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show
|
||||
|
||||
|
@ -360,6 +368,7 @@ end
|
|||
Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
|
||||
Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
|
||||
Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
|
||||
Invidious::Routing.get "/watch_videos", Invidious::Routes::Playlists, :watch_videos
|
||||
|
||||
Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
|
||||
Invidious::Routing.get "/results", Invidious::Routes::Search, :results
|
||||
|
@ -406,85 +415,6 @@ define_v1_api_routes()
|
|||
define_api_manifest_routes()
|
||||
define_video_playback_routes()
|
||||
|
||||
# Channels
|
||||
|
||||
{"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route|
|
||||
get route do |env|
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
# Appears to be a bug in routing, having several routes configured
|
||||
# as `/a/:a`, `/b/:a`, `/c/:a` results in 404
|
||||
value = env.request.resource.split("/")[2]
|
||||
body = ""
|
||||
{"channel", "user", "c"}.each do |type|
|
||||
response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1")
|
||||
if response.status_code == 200
|
||||
body = response.body
|
||||
end
|
||||
end
|
||||
|
||||
video_id = body.match(/'VIDEO_ID': "(?<id>[a-zA-Z0-9_-]{11})"/).try &.["id"]?
|
||||
if video_id
|
||||
params = [] of String
|
||||
env.params.query.each do |k, v|
|
||||
params << "#{k}=#{v}"
|
||||
end
|
||||
params = params.join("&")
|
||||
|
||||
url = "/watch?v=#{video_id}"
|
||||
if !params.empty?
|
||||
url += "&#{params}"
|
||||
end
|
||||
|
||||
env.redirect url
|
||||
else
|
||||
env.redirect "/channel/#{value}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Authenticated endpoints
|
||||
|
||||
# The notification APIs can't be extracted yet
|
||||
# due to the requirement of the `connection_channel`
|
||||
# used by the `NotificationJob`
|
||||
|
||||
get "/api/v1/auth/notifications" do |env|
|
||||
env.response.content_type = "text/event-stream"
|
||||
|
||||
topics = env.params.query["topics"]?.try &.split(",").uniq.first(1000)
|
||||
topics ||= [] of String
|
||||
|
||||
create_notification_stream(env, topics, connection_channel)
|
||||
end
|
||||
|
||||
post "/api/v1/auth/notifications" do |env|
|
||||
env.response.content_type = "text/event-stream"
|
||||
|
||||
topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000)
|
||||
topics ||= [] of String
|
||||
|
||||
create_notification_stream(env, topics, connection_channel)
|
||||
end
|
||||
|
||||
get "/Captcha" do |env|
|
||||
headers = HTTP::Headers{":authority" => "accounts.google.com"}
|
||||
response = YT_POOL.client &.get(env.request.resource, headers)
|
||||
env.response.headers["Content-Type"] = response.headers["Content-Type"]
|
||||
response.body
|
||||
end
|
||||
|
||||
# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos
|
||||
get "/watch_videos" do |env|
|
||||
response = YT_POOL.client &.get(env.request.resource)
|
||||
if url = response.headers["Location"]?
|
||||
url = URI.parse(url).request_target
|
||||
next env.redirect url
|
||||
end
|
||||
|
||||
env.response.status_code = response.status_code
|
||||
end
|
||||
|
||||
error 404 do |env|
|
||||
if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/)
|
||||
item = md["id"]
|
||||
|
|
|
@ -23,6 +23,7 @@ struct ConfigPreferences
|
|||
property listen : Bool = false
|
||||
property local : Bool = false
|
||||
property locale : String = "en-US"
|
||||
property watch_history : Bool = true
|
||||
property max_results : Int32 = 40
|
||||
property notifications_only : Bool = false
|
||||
property player_style : String = "invidious"
|
||||
|
@ -56,20 +57,35 @@ end
|
|||
class Config
|
||||
include YAML::Serializable
|
||||
|
||||
property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions)
|
||||
property feed_threads : Int32 = 1 # Number of threads to use for updating feeds
|
||||
property output : String = "STDOUT" # Log file path or STDOUT
|
||||
property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
|
||||
property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc)
|
||||
# Number of threads to use for crawling videos from channels (for updating subscriptions)
|
||||
property channel_threads : Int32 = 1
|
||||
# Time interval between two executions of the job that crawls channel videos (subscriptions update).
|
||||
@[YAML::Field(converter: Preferences::TimeSpanConverter)]
|
||||
property channel_refresh_interval : Time::Span = 30.minutes
|
||||
# Number of threads to use for updating feeds
|
||||
property feed_threads : Int32 = 1
|
||||
# Log file path or STDOUT
|
||||
property output : String = "STDOUT"
|
||||
# Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
|
||||
property log_level : LogLevel = LogLevel::Info
|
||||
# Database configuration with separate parameters (username, hostname, etc)
|
||||
property db : DBConfig? = nil
|
||||
|
||||
# Database configuration using 12-Factor "Database URL" syntax
|
||||
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||
property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax
|
||||
property decrypt_polling : Bool = true # Use polling to keep decryption function up to date
|
||||
property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel
|
||||
property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
||||
property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
||||
property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required
|
||||
property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
||||
property database_url : URI = URI.parse("")
|
||||
# Use polling to keep decryption function up to date
|
||||
property decrypt_polling : Bool = true
|
||||
# Used for crawling channels: threads should check all videos uploaded by a channel
|
||||
property full_refresh : Bool = false
|
||||
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
||||
property https_only : Bool?
|
||||
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
||||
property hmac_key : String?
|
||||
# Domain to be used for links to resources on the site where an absolute URL is required
|
||||
property domain : String?
|
||||
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
||||
property use_pubsub_feeds : Bool | Int32 = false
|
||||
property popular_enabled : Bool = true
|
||||
property captcha_enabled : Bool = true
|
||||
property login_enabled : Bool = true
|
||||
|
@ -78,28 +94,42 @@ class Config
|
|||
property admins : Array(String) = [] of String
|
||||
property external_port : Int32? = nil
|
||||
property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("")
|
||||
property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs
|
||||
property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc.
|
||||
property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
|
||||
property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc.
|
||||
property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
|
||||
property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
|
||||
# For compliance with DMCA, disables download widget using list of video IDs
|
||||
property dmca_content : Array(String) = [] of String
|
||||
# Check table integrity, automatically try to add any missing columns, create tables, etc.
|
||||
property check_tables : Bool = false
|
||||
# Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
|
||||
property cache_annotations : Bool = false
|
||||
# Optional banner to be displayed along top of page for announcements, etc.
|
||||
property banner : String? = nil
|
||||
# Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
|
||||
property hsts : Bool? = true
|
||||
# Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
|
||||
property disable_proxy : Bool? | Array(String)? = false
|
||||
|
||||
# URL to the modified source code to be easily AGPL compliant
|
||||
# Will display in the footer, next to the main source code link
|
||||
property modified_source_code_url : String? = nil
|
||||
|
||||
# Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
|
||||
@[YAML::Field(converter: Preferences::FamilyConverter)]
|
||||
property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
|
||||
property port : Int32 = 3000 # Port to listen for connections (overridden by command line argument)
|
||||
property host_binding : String = "0.0.0.0" # Host to bind (overridden by command line argument)
|
||||
property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
|
||||
property use_quic : Bool = false # Use quic transport for youtube api
|
||||
property force_resolve : Socket::Family = Socket::Family::UNSPEC
|
||||
# Port to listen for connections (overridden by command line argument)
|
||||
property port : Int32 = 3000
|
||||
# Host to bind (overridden by command line argument)
|
||||
property host_binding : String = "0.0.0.0"
|
||||
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
|
||||
property pool_size : Int32 = 100
|
||||
# Use quic transport for youtube api
|
||||
property use_quic : Bool = false
|
||||
|
||||
# Saved cookies in "name1=value1; name2=value2..." format
|
||||
@[YAML::Field(converter: Preferences::StringToCookies)]
|
||||
property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format
|
||||
property captcha_key : String? = nil # Key for Anti-Captcha
|
||||
property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha
|
||||
property cookies : HTTP::Cookies = HTTP::Cookies.new
|
||||
# Key for Anti-Captcha
|
||||
property captcha_key : String? = nil
|
||||
# API URL for Anti-Captcha
|
||||
property captcha_api_url : String = "https://api.anti-captcha.com"
|
||||
|
||||
def disabled?(option)
|
||||
case disabled = CONFIG.disable_proxy
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
module Invidious::Frontend::WatchPage
|
||||
extend self
|
||||
|
||||
# A handy structure to pass many elements at
|
||||
# once to the download widget function
|
||||
struct VideoAssets
|
||||
getter full_videos : Array(Hash(String, JSON::Any))
|
||||
getter video_streams : Array(Hash(String, JSON::Any))
|
||||
getter audio_streams : Array(Hash(String, JSON::Any))
|
||||
getter captions : Array(Caption)
|
||||
|
||||
def initialize(
|
||||
@full_videos,
|
||||
@video_streams,
|
||||
@audio_streams,
|
||||
@captions
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String
|
||||
if CONFIG.disabled?("downloads")
|
||||
return "<p id=\"download\">#{translate(locale, "Download is disabled.")}</p>"
|
||||
end
|
||||
|
||||
return String.build(4000) do |str|
|
||||
str << "<form"
|
||||
str << " class=\"pure-form pure-form-stacked\""
|
||||
str << " action='/download'"
|
||||
str << " method='post'"
|
||||
str << " rel='noopener'"
|
||||
str << " target='_blank'>"
|
||||
str << '\n'
|
||||
|
||||
# Hidden inputs for video id and title
|
||||
str << "<input type='hidden' name='id' value='" << video.id << "'/>\n"
|
||||
str << "<input type='hidden' name='title' value='" << HTML.escape(video.title) << "'/>\n"
|
||||
|
||||
str << "\t<div class=\"pure-control-group\">\n"
|
||||
|
||||
str << "\t\t<label for='download_widget'>"
|
||||
str << translate(locale, "Download as: ")
|
||||
str << "</label>\n"
|
||||
|
||||
# TODO: remove inline style
|
||||
str << "\t\t<select style=\"width:100%\" name='download_widget' id='download_widget'>\n"
|
||||
|
||||
# Non-DASH videos (audio+video)
|
||||
|
||||
video_assets.full_videos.each do |option|
|
||||
mimetype = option["mimeType"].as_s.split(";")[0]
|
||||
|
||||
height = itag_to_metadata?(option["itag"]).try &.["height"]?
|
||||
|
||||
value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json
|
||||
|
||||
str << "\t\t\t<option value='" << value << "'>"
|
||||
str << (height || "~240") << "p - " << mimetype
|
||||
str << "</option>\n"
|
||||
end
|
||||
|
||||
# DASH video streams
|
||||
|
||||
video_assets.video_streams.each do |option|
|
||||
mimetype = option["mimeType"].as_s.split(";")[0]
|
||||
|
||||
value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json
|
||||
|
||||
str << "\t\t\t<option value='" << value << "'>"
|
||||
str << option["qualityLabel"] << " - " << mimetype << " @ " << option["fps"] << "fps - video only"
|
||||
str << "</option>\n"
|
||||
end
|
||||
|
||||
# DASH audio streams
|
||||
|
||||
video_assets.audio_streams.each do |option|
|
||||
mimetype = option["mimeType"].as_s.split(";")[0]
|
||||
|
||||
value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json
|
||||
|
||||
str << "\t\t\t<option value='" << value << "'>"
|
||||
str << mimetype << " @ " << (option["bitrate"]?.try &.as_i./ 1000) << "k - audio only"
|
||||
str << "</option>\n"
|
||||
end
|
||||
|
||||
# Subtitles (a.k.a "closed captions")
|
||||
|
||||
video_assets.captions.each do |caption|
|
||||
value = {"label": caption.name, "ext": "#{caption.language_code}.vtt"}.to_json
|
||||
|
||||
str << "\t\t\t<option value='" << value << "'>"
|
||||
str << translate(locale, "download_subtitles", translate(locale, caption.name))
|
||||
str << "</option>\n"
|
||||
end
|
||||
|
||||
# End of form
|
||||
|
||||
str << "\t\t</select>\n"
|
||||
str << "\t</div>\n"
|
||||
|
||||
str << "\t<button type=\"submit\" class=\"pure-button pure-button-primary\">\n"
|
||||
str << "\t\t<b>" << translate(locale, "Download") << "</b>\n"
|
||||
str << "\t</button>\n"
|
||||
|
||||
str << "</form>\n"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -51,6 +51,24 @@ def recode_length_seconds(time)
|
|||
end
|
||||
end
|
||||
|
||||
def decode_interval(string : String) : Time::Span
|
||||
rawMinutes = string.try &.to_i32?
|
||||
|
||||
if !rawMinutes
|
||||
hours = /(?<hours>\d+)h/.match(string).try &.["hours"].try &.to_i32
|
||||
hours ||= 0
|
||||
|
||||
minutes = /(?<minutes>\d+)m(?!s)/.match(string).try &.["minutes"].try &.to_i32
|
||||
minutes ||= 0
|
||||
|
||||
time = Time::Span.new(hours: hours, minutes: minutes)
|
||||
else
|
||||
time = Time::Span.new(minutes: rawMinutes)
|
||||
end
|
||||
|
||||
return time
|
||||
end
|
||||
|
||||
def decode_time(string)
|
||||
time = string.try &.to_f?
|
||||
|
||||
|
|
|
@ -58,9 +58,8 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
|
|||
end
|
||||
end
|
||||
|
||||
# TODO: make this configurable
|
||||
LOGGER.debug("RefreshChannelsJob: Done, sleeping for thirty minutes")
|
||||
sleep 30.minutes
|
||||
LOGGER.debug("RefreshChannelsJob: Done, sleeping for #{CONFIG.channel_refresh_interval}")
|
||||
sleep CONFIG.channel_refresh_interval
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
|
|
@ -397,4 +397,14 @@ module Invidious::Routes::API::V1::Authenticated
|
|||
|
||||
env.response.status_code = 204
|
||||
end
|
||||
|
||||
def self.notifications(env)
|
||||
env.response.content_type = "text/event-stream"
|
||||
|
||||
raw_topics = env.params.body["topics"]? || env.params.query["topics"]?
|
||||
topics = raw_topics.try &.split(",").uniq.first(1000)
|
||||
topics ||= [] of String
|
||||
|
||||
create_notification_stream(env, topics, CONNECTION_CHANNEL)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,7 +23,11 @@ module Invidious::Routes::API::V1::Videos
|
|||
env.response.content_type = "application/json"
|
||||
|
||||
id = env.params.url["id"]
|
||||
region = env.params.query["region"]?
|
||||
region = env.params.query["region"]? || env.params.body["region"]?
|
||||
|
||||
if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/)
|
||||
return error_json(400, "Invalid video ID")
|
||||
end
|
||||
|
||||
# See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354
|
||||
# It is possible to use `/api/timedtext?type=list&v=#{id}` and
|
||||
|
|
|
@ -147,6 +147,39 @@ module Invidious::Routes::Channels
|
|||
end
|
||||
end
|
||||
|
||||
def self.live(env)
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
# Appears to be a bug in routing, having several routes configured
|
||||
# as `/a/:a`, `/b/:a`, `/c/:a` results in 404
|
||||
value = env.request.resource.split("/")[2]
|
||||
body = ""
|
||||
{"channel", "user", "c"}.each do |type|
|
||||
response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1")
|
||||
if response.status_code == 200
|
||||
body = response.body
|
||||
end
|
||||
end
|
||||
|
||||
video_id = body.match(/'VIDEO_ID': "(?<id>[a-zA-Z0-9_-]{11})"/).try &.["id"]?
|
||||
if video_id
|
||||
params = [] of String
|
||||
env.params.query.each do |k, v|
|
||||
params << "#{k}=#{v}"
|
||||
end
|
||||
params = params.join("&")
|
||||
|
||||
url = "/watch?v=#{video_id}"
|
||||
if !params.empty?
|
||||
url += "&#{params}"
|
||||
end
|
||||
|
||||
env.redirect url
|
||||
else
|
||||
env.redirect "/channel/#{value}"
|
||||
end
|
||||
end
|
||||
|
||||
private def self.fetch_basic_information(env)
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
|
|
|
@ -481,4 +481,11 @@ module Invidious::Routes::Login
|
|||
|
||||
env.redirect referer
|
||||
end
|
||||
|
||||
def self.captcha(env)
|
||||
headers = HTTP::Headers{":authority" => "accounts.google.com"}
|
||||
response = YT_POOL.client &.get(env.request.resource, headers)
|
||||
env.response.headers["Content-Type"] = response.headers["Content-Type"]
|
||||
response.body
|
||||
end
|
||||
end
|
||||
|
|
|
@ -443,4 +443,15 @@ module Invidious::Routes::Playlists
|
|||
|
||||
templated "mix"
|
||||
end
|
||||
|
||||
# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos
|
||||
def self.watch_videos(env)
|
||||
response = YT_POOL.client &.get(env.request.resource)
|
||||
if url = response.headers["Location"]?
|
||||
url = URI.parse(url).request_target
|
||||
return env.redirect url
|
||||
end
|
||||
|
||||
env.response.status_code = response.status_code
|
||||
end
|
||||
end
|
||||
|
|
|
@ -47,6 +47,10 @@ module Invidious::Routes::PreferencesRoute
|
|||
local ||= "off"
|
||||
local = local == "on"
|
||||
|
||||
watch_history = env.params.body["watch_history"]?.try &.as(String)
|
||||
watch_history ||= "off"
|
||||
watch_history = watch_history == "on"
|
||||
|
||||
speed = env.params.body["speed"]?.try &.as(String).to_f32?
|
||||
speed ||= CONFIG.default_user_preferences.speed
|
||||
|
||||
|
@ -149,6 +153,7 @@ module Invidious::Routes::PreferencesRoute
|
|||
latest_only: latest_only,
|
||||
listen: listen,
|
||||
local: local,
|
||||
watch_history: watch_history,
|
||||
locale: locale,
|
||||
max_results: max_results,
|
||||
notifications_only: notifications_only,
|
||||
|
|
|
@ -164,7 +164,9 @@ module Invidious::Routes::VideoPlayback
|
|||
|
||||
if title = query_params["title"]?
|
||||
# https://blog.fastmail.com/2011/06/24/download-non-english-filenames/
|
||||
env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}"
|
||||
filename = URI.encode_www_form(title, space_to_plus: false)
|
||||
header = "attachment; filename=\"#{filename}\"; filename*=UTF-8''#{filename}"
|
||||
env.response.headers["Content-Disposition"] = header
|
||||
end
|
||||
|
||||
if !resp.headers.includes_word?("Transfer-Encoding", "chunked")
|
||||
|
@ -242,31 +244,25 @@ module Invidious::Routes::VideoPlayback
|
|||
# YouTube /videoplayback links expire after 6 hours,
|
||||
# so we have a mechanism here to redirect to the latest version
|
||||
def self.latest_version(env)
|
||||
if env.params.query["download_widget"]?
|
||||
download_widget = JSON.parse(env.params.query["download_widget"])
|
||||
id = env.params.query["id"]?
|
||||
itag = env.params.query["itag"]?.try &.to_i?
|
||||
|
||||
id = download_widget["id"].as_s
|
||||
title = URI.decode_www_form(download_widget["title"].as_s)
|
||||
|
||||
if label = download_widget["label"]?
|
||||
return env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}"
|
||||
else
|
||||
itag = download_widget["itag"].as_s.to_i
|
||||
local = "true"
|
||||
end
|
||||
# Sanity checks
|
||||
if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/)
|
||||
return error_template(400, "Invalid video ID")
|
||||
end
|
||||
|
||||
id ||= env.params.query["id"]?
|
||||
itag ||= env.params.query["itag"]?.try &.to_i
|
||||
if itag.nil? || itag <= 0 || itag >= 1000
|
||||
return error_template(400, "Invalid itag")
|
||||
end
|
||||
|
||||
region = env.params.query["region"]?
|
||||
local = (env.params.query["local"]? == "true")
|
||||
|
||||
local ||= env.params.query["local"]?
|
||||
local ||= "false"
|
||||
local = local == "true"
|
||||
title = env.params.query["title"]?
|
||||
|
||||
if !id || !itag
|
||||
haltf env, status_code: 400, response: "TESTING"
|
||||
if title && CONFIG.disabled?("downloads")
|
||||
return error_template(403, "Administrator has disabled this endpoint.")
|
||||
end
|
||||
|
||||
video = get_video(id, region: region)
|
||||
|
@ -278,8 +274,10 @@ module Invidious::Routes::VideoPlayback
|
|||
haltf env, status_code: 404
|
||||
end
|
||||
|
||||
url = URI.parse(url).request_target.not_nil! if local
|
||||
url = "#{url}&title=#{title}" if title
|
||||
if local
|
||||
url = URI.parse(url).request_target.not_nil!
|
||||
url += "&title=#{URI.encode_www_form(title, space_to_plus: false)}" if title
|
||||
end
|
||||
|
||||
return env.redirect url
|
||||
end
|
||||
|
|
|
@ -75,7 +75,7 @@ module Invidious::Routes::Watch
|
|||
end
|
||||
env.params.query.delete_all("iv_load_policy")
|
||||
|
||||
if watched && !watched.includes? id
|
||||
if watched && preferences.watch_history && !watched.includes? id
|
||||
Invidious::Database::Users.mark_watched(user.as(User), id)
|
||||
end
|
||||
|
||||
|
@ -189,6 +189,14 @@ module Invidious::Routes::Watch
|
|||
return env.redirect url
|
||||
end
|
||||
|
||||
# Structure used for the download widget
|
||||
video_assets = Invidious::Frontend::WatchPage::VideoAssets.new(
|
||||
full_videos: fmt_stream,
|
||||
video_streams: video_streams,
|
||||
audio_streams: audio_streams,
|
||||
captions: video.captions
|
||||
)
|
||||
|
||||
templated "watch"
|
||||
end
|
||||
|
||||
|
@ -281,4 +289,49 @@ module Invidious::Routes::Watch
|
|||
return error_template(404, "The requested clip doesn't exist")
|
||||
end
|
||||
end
|
||||
|
||||
def self.download(env)
|
||||
if CONFIG.disabled?("downloads")
|
||||
return error_template(403, "Administrator has disabled this endpoint.")
|
||||
end
|
||||
|
||||
title = env.params.body["title"]? || ""
|
||||
video_id = env.params.body["id"]? || ""
|
||||
selection = env.params.body["download_widget"]?
|
||||
|
||||
if title.empty? || video_id.empty? || selection.nil?
|
||||
return error_template(400, "Missing form data")
|
||||
end
|
||||
|
||||
download_widget = JSON.parse(selection)
|
||||
|
||||
extension = download_widget["ext"].as_s
|
||||
filename = "#{video_id}-#{title}.#{extension}"
|
||||
|
||||
# Pass form parameters as URL parameters for the handlers of both
|
||||
# /latest_version and /api/v1/captions. This avoids an un-necessary
|
||||
# redirect and duplicated (and hazardous) sanity checks.
|
||||
env.params.query["id"] = video_id
|
||||
env.params.query["title"] = filename
|
||||
|
||||
# Delete the useless ones
|
||||
env.params.body.delete("id")
|
||||
env.params.body.delete("title")
|
||||
env.params.body.delete("download_widget")
|
||||
|
||||
if label = download_widget["label"]?
|
||||
# URL params specific to /api/v1/captions/:id
|
||||
env.params.query["label"] = URI.encode_www_form(label.as_s, space_to_plus: false)
|
||||
|
||||
return Invidious::Routes::API::V1::Videos.captions(env)
|
||||
elsif itag = download_widget["itag"]?.try &.as_i
|
||||
# URL params specific to /latest_version
|
||||
env.params.query["itag"] = itag.to_s
|
||||
env.params.query["local"] = "true"
|
||||
|
||||
return Invidious::Routes::VideoPlayback.latest_version(env)
|
||||
else
|
||||
return error_template(400, "Invalid label or itag")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -15,6 +15,7 @@ macro define_user_routes
|
|||
Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
|
||||
Invidious::Routing.post "/login", Invidious::Routes::Login, :login
|
||||
Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
|
||||
Invidious::Routing.get "/Captcha", Invidious::Routes::Login, :captcha
|
||||
|
||||
# User preferences
|
||||
Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
|
||||
|
@ -95,6 +96,9 @@ macro define_v1_api_routes
|
|||
Invidious::Routing.post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token
|
||||
Invidious::Routing.post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token
|
||||
|
||||
Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
|
||||
Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
|
||||
|
||||
# Misc
|
||||
Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats
|
||||
Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
|
||||
|
|
|
@ -23,6 +23,7 @@ struct Preferences
|
|||
property latest_only : Bool = CONFIG.default_user_preferences.latest_only
|
||||
property listen : Bool = CONFIG.default_user_preferences.listen
|
||||
property local : Bool = CONFIG.default_user_preferences.local
|
||||
property watch_history : Bool = CONFIG.default_user_preferences.watch_history
|
||||
property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode
|
||||
property show_nick : Bool = CONFIG.default_user_preferences.show_nick
|
||||
|
||||
|
@ -256,4 +257,18 @@ struct Preferences
|
|||
cookies
|
||||
end
|
||||
end
|
||||
|
||||
module TimeSpanConverter
|
||||
def self.to_yaml(value : Time::Span, yaml : YAML::Nodes::Builder)
|
||||
return yaml.scalar value.total_minutes.to_i32
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Span
|
||||
if node.is_a?(YAML::Nodes::Scalar)
|
||||
return decode_interval(node.value)
|
||||
else
|
||||
node.raise "Expected scalar, not #{node.class}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -206,6 +206,11 @@
|
|||
<% if env.get? "user" %>
|
||||
<legend><%= translate(locale, "preferences_category_subscription") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="watch_history"><%= translate(locale, "preferences_watch_history_label") %></label>
|
||||
<input name="watch_history" id="watch_history" type="checkbox" <% if preferences.watch_history %>checked<% end %>>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="annotations_subscribed"><%= translate(locale, "preferences_annotations_subscribed_label") %></label>
|
||||
<input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>>
|
||||
|
|
|
@ -168,41 +168,7 @@ we're going to need to do it here in order to allow for translations.
|
|||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if CONFIG.dmca_content.includes?(video.id) || CONFIG.disabled?("downloads") %>
|
||||
<p id="download"><%= translate(locale, "Download is disabled.") %></p>
|
||||
<% else %>
|
||||
<form class="pure-form pure-form-stacked" action="/latest_version" method="get" rel="noopener" target="_blank">
|
||||
<div class="pure-control-group">
|
||||
<label for="download_widget"><%= translate(locale, "Download as: ") %></label>
|
||||
<select style="width:100%" name="download_widget" id="download_widget">
|
||||
<% fmt_stream.each do |option| %>
|
||||
<option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'>
|
||||
<%= itag_to_metadata?(option["itag"]).try &.["height"]? || "~240" %>p - <%= option["mimeType"].as_s.split(";")[0] %>
|
||||
</option>
|
||||
<% end %>
|
||||
<% video_streams.each do |option| %>
|
||||
<option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'>
|
||||
<%= option["qualityLabel"] %> - <%= option["mimeType"].as_s.split(";")[0] %> @ <%= option["fps"] %>fps - video only
|
||||
</option>
|
||||
<% end %>
|
||||
<% audio_streams.each do |option| %>
|
||||
<option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'>
|
||||
<%= option["mimeType"].as_s.split(";")[0] %> @ <%= option["bitrate"]?.try &.as_i./ 1000 %>k - audio only
|
||||
</option>
|
||||
<% end %>
|
||||
<% captions.each do |caption| %>
|
||||
<option value='{"id":"<%= video.id %>","label":"<%= caption.name %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= caption.language_code %>.vtt"}'>
|
||||
<%= translate(locale, "download_subtitles", translate(locale, caption.name)) %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pure-button pure-button-primary">
|
||||
<b><%= translate(locale, "Download") %></b>
|
||||
</button>
|
||||
</form>
|
||||
<% end %>
|
||||
<%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %>
|
||||
|
||||
<p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
|
||||
<p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
|
||||
|
|
読み込み中…
新しいイシューから参照