diff --git a/.ameba.yml b/.ameba.yml index 36d7c48f..247705e8 100644 --- a/.ameba.yml +++ b/.ameba.yml @@ -20,13 +20,6 @@ Lint/ShadowingOuterLocalVar: Excluded: - src/invidious/helpers/tokens.cr -Lint/NotNil: - Enabled: false - -Lint/SpecFilename: - Excluded: - - spec/parsers_helper.cr - # # Style @@ -38,29 +31,6 @@ Style/RedundantBegin: Style/RedundantReturn: Enabled: false -Style/RedundantNext: - Enabled: false - -Style/ParenthesesAroundCondition: - Enabled: false - -# This requires a rewrite of most data structs (and their usage) in Invidious. -Naming/QueryBoolMethods: - Enabled: false - -Naming/AccessorMethodName: - Enabled: false - -Naming/BlockParameterName: - Enabled: false - -# Hides TODO comment warnings. -# -# Call `bin/ameba --only Documentation/DocumentationAdmonition` to -# list them -Documentation/DocumentationAdmonition: - Enabled: false - # # Metrics @@ -69,4 +39,54 @@ Documentation/DocumentationAdmonition: # Ignore function complexity (number of if/else & case/when branches) # For some functions that can hardly be simplified for now Metrics/CyclomaticComplexity: - Enabled: false + Excluded: + # get_about_info(ucid, locale) => [17/10] + - src/invidious/channels/about.cr + + # fetch_channel_community(ucid, continuation, ...) => [34/10] + - src/invidious/channels/community.cr + + # create_notification_stream(env, topics, connection_channel) => [14/10] + - src/invidious/helpers/helpers.cr:84:5 + + # get_index(plural_form, count) => [25/10] + - src/invidious/helpers/i18next.cr + + # call(context) => [18/10] + - src/invidious/helpers/static_file_handler.cr + + # show(env) => [38/10] + - src/invidious/routes/embed.cr + + # get_video_playback(env) => [45/10] + - src/invidious/routes/video_playback.cr + + # handle(env) => [40/10] + - src/invidious/routes/watch.cr + + # playlist_ajax(env) => [24/10] + - src/invidious/routes/playlists.cr + + # fetch_youtube_comments(id, cursor, ....) => [40/10] + # template_youtube_comments(comments, locale, ...) => [16/10] + # content_to_comment_html(content) => [14/10] + - src/invidious/comments.cr + + # to_json(locale, json) => [21/10] + # extract_video_info(video_id, ...) => [44/10] + # process_video_params(query, preferences) => [20/10] + - src/invidious/videos.cr + + # produce_search_params(page, sort, ...) => [29/10] + # process_search_query(query, page, ...) => [14/10] + - src/invidious/search.cr + + + +#src/invidious/playlists.cr:327:5 +#[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [19/10] +# fetch_playlist(plid : String) + +#src/invidious/playlists.cr:436:5 +#[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [11/10] +# extract_playlist_videos(initial_data : Hash(String, JSON::Any)) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9f17bb40..7a2c3760 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,12 @@ +# Default and lowest precedence. If none of the below matches, @iv-org/developers would be requested for review. +* @iv-org/developers + docker-compose.yml @unixfox docker/ @unixfox kubernetes/ @unixfox README.md @thefrenchghosty -config/config.example.yml @SamantazFox @unixfox +config/config.example.yml @thefrenchghosty @SamantazFox @unixfox scripts/ @syeopite shards.lock @syeopite diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 02bc3795..4c1a6330 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,10 +10,8 @@ assignees: '' 00:00:01.000", - "Line 1", - "", - "00:00:01.000 --> 00:00:02.000", - "Line 2", - "", - "", - ].join('\n')) - end - - it "correctly builds a vtt file with setting fields" do - setting_fields = { - "Kind" => "captions", - "Language" => "en", - } - - result = WebVTT.build(setting_fields) do |vtt| - 2.times do |i| - vtt.cue( - Time::Span.new(seconds: i), - Time::Span.new(seconds: i + 1), - MockLines[i] - ) - end - end - - expect(result).to eq([ - "WEBVTT", - "Kind: captions", - "Language: en", - "", - "00:00:00.000 --> 00:00:01.000", - "Line 1", - "", - "00:00:01.000 --> 00:00:02.000", - "Line 2", - "", - "", - ].join('\n')) - end - - it "properly escapes characters" do - result = WebVTT.build do |vtt| - 4.times do |i| - vtt.cue(Time::Span.new(seconds: i), Time::Span.new(seconds: i + 1), MockLinesWithEscapableCharacter[i]) - end - end - - expect(result).to eq([ - "WEBVTT", - "", - "00:00:00.000 --> 00:00:01.000", - "<Line 1>", - "", - "00:00:01.000 --> 00:00:02.000", - "&Line 2>", - "", - "00:00:02.000 --> 00:00:03.000", - "‎Line‏ 3", - "", - "00:00:03.000 --> 00:00:04.000", - " Line 4", - "", - "", - ].join('\n')) - end -end diff --git a/spec/i18next_plurals_spec.cr b/spec/i18next_plurals_spec.cr index dcd0f5ec..ee9ff394 100644 --- a/spec/i18next_plurals_spec.cr +++ b/spec/i18next_plurals_spec.cr @@ -15,15 +15,12 @@ FORM_TESTS = { "ar" => I18next::Plurals::PluralForms::Special_Arabic, "be" => I18next::Plurals::PluralForms::Dual_Slavic, "cy" => I18next::Plurals::PluralForms::Special_Welsh, - "fr" => I18next::Plurals::PluralForms::Special_French_Portuguese, "en" => I18next::Plurals::PluralForms::Single_not_one, - "es" => I18next::Plurals::PluralForms::Special_Spanish_Italian, + "fr" => I18next::Plurals::PluralForms::Single_gt_one, "ga" => I18next::Plurals::PluralForms::Special_Irish, "gd" => I18next::Plurals::PluralForms::Special_Scottish_Gaelic, "he" => I18next::Plurals::PluralForms::Special_Hebrew, - "hr" => I18next::Plurals::PluralForms::Special_Hungarian_Serbian, "is" => I18next::Plurals::PluralForms::Special_Icelandic, - "it" => I18next::Plurals::PluralForms::Special_Spanish_Italian, "jv" => I18next::Plurals::PluralForms::Special_Javanese, "kw" => I18next::Plurals::PluralForms::Special_Cornish, "lt" => I18next::Plurals::PluralForms::Special_Lithuanian, @@ -33,14 +30,13 @@ FORM_TESTS = { "mt" => I18next::Plurals::PluralForms::Special_Maltese, "or" => I18next::Plurals::PluralForms::Special_Odia, "pl" => I18next::Plurals::PluralForms::Special_Polish_Kashubian, - "pt" => I18next::Plurals::PluralForms::Special_French_Portuguese, - "pt-PT" => I18next::Plurals::PluralForms::Single_gt_one, - "pt-BR" => I18next::Plurals::PluralForms::Special_French_Portuguese, + "pt" => I18next::Plurals::PluralForms::Single_gt_one, + "pt-PT" => I18next::Plurals::PluralForms::Single_not_one, + "pt-BR" => I18next::Plurals::PluralForms::Single_gt_one, "ro" => I18next::Plurals::PluralForms::Special_Romanian, + "su" => I18next::Plurals::PluralForms::None, "sk" => I18next::Plurals::PluralForms::Special_Czech_Slovak, "sl" => I18next::Plurals::PluralForms::Special_Slovenian, - "su" => I18next::Plurals::PluralForms::None, - "sr" => I18next::Plurals::PluralForms::Special_Hungarian_Serbian, } SUFFIX_TESTS = { @@ -77,18 +73,10 @@ SUFFIX_TESTS = { {num: 1, suffix: ""}, {num: 10, suffix: "_plural"}, ], - "es" => [ - {num: 0, suffix: "_2"}, - {num: 1, suffix: "_0"}, - {num: 10, suffix: "_2"}, - {num: 6_000_000, suffix: "_1"}, - ], "fr" => [ - {num: 0, suffix: "_0"}, - {num: 1, suffix: "_0"}, - {num: 10, suffix: "_2"}, - {num: 4_000_000, suffix: "_1"}, - {num: 6_260_000, suffix: "_2"}, + {num: 0, suffix: ""}, + {num: 1, suffix: ""}, + {num: 10, suffix: "_plural"}, ], "ga" => [ {num: 1, suffix: "_0"}, @@ -167,24 +155,31 @@ SUFFIX_TESTS = { {num: 1, suffix: "_0"}, {num: 5, suffix: "_2"}, ], - "pt-BR" => [ - {num: 0, suffix: "_0"}, - {num: 1, suffix: "_0"}, - {num: 10, suffix: "_2"}, - {num: 42, suffix: "_2"}, - {num: 9_000_000, suffix: "_1"}, - ], - "pt-PT" => [ + "pt" => [ + {num: 0, suffix: ""}, + {num: 1, suffix: ""}, + {num: 10, suffix: "_plural"}, + ], + "pt-PT" => [ + {num: 0, suffix: "_plural"}, + {num: 1, suffix: ""}, + {num: 10, suffix: "_plural"}, + ], + "pt-BR" => [ {num: 0, suffix: ""}, {num: 1, suffix: ""}, {num: 10, suffix: "_plural"}, - {num: 9_000_000, suffix: "_plural"}, ], "ro" => [ {num: 0, suffix: "_1"}, {num: 1, suffix: "_0"}, {num: 20, suffix: "_2"}, ], + "su" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_0"}, + {num: 10, suffix: "_0"}, + ], "sk" => [ {num: 0, suffix: "_2"}, {num: 1, suffix: "_0"}, @@ -196,18 +191,6 @@ SUFFIX_TESTS = { {num: 2, suffix: "_2"}, {num: 3, suffix: "_3"}, ], - "su" => [ - {num: 0, suffix: "_0"}, - {num: 1, suffix: "_0"}, - {num: 10, suffix: "_0"}, - ], - "sr" => [ - {num: 1, suffix: "_0"}, - {num: 51, suffix: "_0"}, - {num: 32, suffix: "_1"}, - {num: 100, suffix: "_2"}, - {num: 100_000, suffix: "_2"}, - ], } Spectator.describe "i18next_Plural_Resolver" do diff --git a/spec/invidious/hashtag_spec.cr b/spec/invidious/hashtag_spec.cr deleted file mode 100644 index abc81225..00000000 --- a/spec/invidious/hashtag_spec.cr +++ /dev/null @@ -1,109 +0,0 @@ -require "../parsers_helper.cr" - -Spectator.describe Invidious::Hashtag do - it "parses richItemRenderer containers (test 1)" do - # Enable mock - test_content = load_mock("hashtag/martingarrix_page1") - videos, _ = extract_items(test_content) - - expect(typeof(videos)).to eq(Array(SearchItem)) - expect(videos.size).to eq(60) - - # - # Random video check 1 - # - expect(typeof(videos[11])).to eq(SearchItem) - - video_11 = videos[11].as(SearchVideo) - - expect(video_11.id).to eq("06eSsOWcKYA") - expect(video_11.title).to eq("Martin Garrix - Live @ Tomorrowland 2018") - - expect(video_11.ucid).to eq("UC5H_KXkPbEsGs0tFt8R35mA") - expect(video_11.author).to eq("Martin Garrix") - expect(video_11.author_verified).to be_true - - expect(video_11.published).to be_close(Time.utc - 3.years, 1.second) - expect(video_11.length_seconds).to eq((56.minutes + 41.seconds).total_seconds.to_i32) - expect(video_11.views).to eq(40_504_893) - - expect(video_11.badges.live_now?).to be_false - expect(video_11.badges.premium?).to be_false - expect(video_11.premiere_timestamp).to be_nil - - # - # Random video check 2 - # - expect(typeof(videos[35])).to eq(SearchItem) - - video_35 = videos[35].as(SearchVideo) - - expect(video_35.id).to eq("b9HpOAYjY9I") - expect(video_35.title).to eq("Martin Garrix feat. Mike Yung - Dreamer (Official Video)") - - expect(video_35.ucid).to eq("UC5H_KXkPbEsGs0tFt8R35mA") - expect(video_35.author).to eq("Martin Garrix") - expect(video_35.author_verified).to be_true - - expect(video_35.published).to be_close(Time.utc - 3.years, 1.second) - expect(video_35.length_seconds).to eq((3.minutes + 14.seconds).total_seconds.to_i32) - expect(video_35.views).to eq(30_790_049) - - expect(video_35.badges.live_now?).to be_false - expect(video_35.badges.premium?).to be_false - expect(video_35.premiere_timestamp).to be_nil - end - - it "parses richItemRenderer containers (test 2)" do - # Enable mock - test_content = load_mock("hashtag/martingarrix_page2") - videos, _ = extract_items(test_content) - - expect(typeof(videos)).to eq(Array(SearchItem)) - expect(videos.size).to eq(60) - - # - # Random video check 1 - # - expect(typeof(videos[41])).to eq(SearchItem) - - video_41 = videos[41].as(SearchVideo) - - expect(video_41.id).to eq("qhstH17zAjs") - expect(video_41.title).to eq("Martin Garrix Radio - Episode 391") - - expect(video_41.ucid).to eq("UC5H_KXkPbEsGs0tFt8R35mA") - expect(video_41.author).to eq("Martin Garrix") - expect(video_41.author_verified).to be_true - - expect(video_41.published).to be_close(Time.utc - 2.months, 1.second) - expect(video_41.length_seconds).to eq((1.hour).total_seconds.to_i32) - expect(video_41.views).to eq(63_240) - - expect(video_41.badges.live_now?).to be_false - expect(video_41.badges.premium?).to be_false - expect(video_41.premiere_timestamp).to be_nil - - # - # Random video check 2 - # - expect(typeof(videos[48])).to eq(SearchItem) - - video_48 = videos[48].as(SearchVideo) - - expect(video_48.id).to eq("lqGvW0NIfdc") - expect(video_48.title).to eq("Martin Garrix SENTIO Full Album Mix by Sakul") - - expect(video_48.ucid).to eq("UC3833PXeLTS6yRpwGMQpp4Q") - expect(video_48.author).to eq("SAKUL") - expect(video_48.author_verified).to be_false - - expect(video_48.published).to be_close(Time.utc - 3.weeks, 1.second) - expect(video_48.length_seconds).to eq((35.minutes + 46.seconds).total_seconds.to_i32) - expect(video_48.views).to eq(68_704) - - expect(video_48.badges.live_now?).to be_false - expect(video_48.badges.premium?).to be_false - expect(video_48.premiere_timestamp).to be_nil - end -end diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr index 9fbb6d6f..b2436989 100644 --- a/spec/invidious/helpers_spec.cr +++ b/spec/invidious/helpers_spec.cr @@ -3,6 +3,18 @@ require "../spec_helper" CONFIG = Config.from_yaml(File.open("config/config.example.yml")) Spectator.describe "Helper" do + describe "#produce_channel_videos_url" do + it "correctly produces url for requesting page `x` of a channel's videos" do + expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw")).to eq("/browse_ajax?continuation=4qmFsgI8EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4&gl=US&hl=en") + + expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4R0FFPQ%3D%3D&gl=US&hl=en") + + expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", page: 20)).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUE9PQ%3D%3D&gl=US&hl=en") + + expect(produce_channel_videos_url(ucid: "UC-9-kyTW8ZkZNDHQJ6FgpwQ", page: 20, sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQy05LWt5VFc4WmtaTkRIUUo2Rmdwd1EaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUJnQg%3D%3D&gl=US&hl=en") + end + end + describe "#produce_channel_search_continuation" do it "correctly produces token for searching a specific channel" do expect(produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100)).to eq("4qmFsgJqEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FUZ0JZQUY2QkVkS2IxaTRBUUE9WgCaAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D") @@ -11,6 +23,38 @@ Spectator.describe "Helper" do end end + describe "#produce_channel_playlists_url" do + it "correctly produces a /browse_ajax URL with the given UCID and cursor" do + expect(produce_channel_playlists_url("UCCj956IF62FbT7Gouszaj9w", "AIOkY9EQpi_gyn1_QrFuZ1reN81_MMmI1YmlBblw8j7JHItEFG5h7qcJTNd4W9x5Quk_CVZ028gW")).to eq("/browse_ajax?continuation=4qmFsgLNARIYVUNDajk1NklGNjJGYlQ3R291c3phajl3GrABRWdsd2JHRjViR2x6ZEhNd0FqZ0JZQUZxQUxnQkFIcG1VVlZzVUdFeGF6VlNWa1ozWVZZNWJtVlhOSGhZTVVaNVVtNVdZVTFZU214VWFtZDRXREF4VG1KVmEzaFhWekZ6VVcxS2MyUjZhSEZPTUhCSlUxaFNSbEpyWXpGaFJHUjRXVEJ3VlZSdFVUQldlbXcwVGxaR01XRXhPVVJXYkc5M1RXcG9ibFozSUFFWUF3PT0%3D&gl=US&hl=en") + end + end + + describe "#produce_search_params" do + it "correctly produces token for searching with specified filters" do + expect(produce_search_params).to eq("CAASAhABSAA%3D") + + expect(produce_search_params(sort: "upload_date", content_type: "video")).to eq("CAISAhABSAA%3D") + + expect(produce_search_params(content_type: "playlist")).to eq("CAASAhADSAA%3D") + + expect(produce_search_params(sort: "date", content_type: "video", features: ["hd", "cc", "purchased", "hdr"])).to eq("CAISCxABIAEwAUgByAEBSAA%3D") + + expect(produce_search_params(content_type: "channel")).to eq("CAASAhACSAA%3D") + end + end + + describe "#produce_comment_continuation" do + it "correctly produces a continuation token for comments" do + expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") + + expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i1yz21HI4xrtsYXVC-2_kfZ6kx1yjYQumXAAxqH3CAd7ZxKxfLdZS1__fqhCtOASRbbpSBGH_tH1J96Dxux-Qfjk-lUbupMqv08Q3aHzGu7p70VoUMHhI2-GoJpnbpmcOxkGzeIuenRS_ym2Y8fkDowhqLPFgsS0n4djnZ2UmC17F3Ch3N1S1UYf1ZVOc991qOC1iW9kJDzyvRQTWCPsJUPneSaAKW-Rr97pdesOkR4i8cNvHZRnQKe2HEfsvlJOb2C3lF1dJBfJeNfnQYeh5hv6_fZN7bt3-JL1Xk3Qc9NXNxmmbDpwAC_yFR8dthFfUJdyIO9Nu1D79MLYeR-H5HxqUJokkJiGIz4lTE_CXXbhAI")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyiQMK8wJBRFNKX2kxeXoyMUhJNHhydHNZWFZDLTJfa2ZaNmt4MXlqWVF1bVhBQXhxSDNDQWQ3WnhLeGZMZFpTMV9fZnFoQ3RPQVNSYmJwU0JHSF90SDFKOTZEeHV4LVFmamstbFVidXBNcXYwOFEzYUh6R3U3cDcwVm9VTUhoSTItR29KcG5icG1jT3hrR3plSXVlblJTX3ltMlk4ZmtEb3docUxQRmdzUzBuNGRqbloyVW1DMTdGM0NoM04xUzFVWWYxWlZPYzk5MXFPQzFpVzlrSkR6eXZSUVRXQ1BzSlVQbmVTYUFLVy1Scjk3cGRlc09rUjRpOGNOdkhaUm5RS2UySEVmc3ZsSk9iMkMzbEYxZEpCZkplTmZuUVllaDVodjZfZlpON2J0My1KTDFYazNRYzlOWE54bW1iRHB3QUNfeUZSOGR0aEZmVUpkeUlPOU51MUQ3OU1MWWVSLUg1SHhxVUpva2tKaUdJejRsVEVfQ1hYYmhBSSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") + + expect(produce_comment_continuation("29-q7YnyUmY", "")).to eq("EkMSCzI5LXE3WW55VW1ZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iCzI5LXE3WW55VW1ZMAAoFA%3D%3D") + + expect(produce_comment_continuation("CvFH_6DNRCY", "")).to eq("EkMSC0N2RkhfNkROUkNZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iC0N2RkhfNkROUkNZMAAoFA%3D%3D") + end + end + describe "#produce_channel_community_continuation" do it "correctly produces a continuation token for a channel community" do expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4")).to eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D") diff --git a/spec/invidious/search/iv_filters_spec.cr b/spec/invidious/search/iv_filters_spec.cr deleted file mode 100644 index 3cefafa1..00000000 --- a/spec/invidious/search/iv_filters_spec.cr +++ /dev/null @@ -1,370 +0,0 @@ -require "../../../src/invidious/search/filters" - -require "http/params" -require "spectator" - -Spectator.configure do |config| - config.fail_blank - config.randomize -end - -FEATURES_TEXT = { - Invidious::Search::Filters::Features::Live => "live", - Invidious::Search::Filters::Features::FourK => "4k", - Invidious::Search::Filters::Features::HD => "hd", - Invidious::Search::Filters::Features::Subtitles => "subtitles", - Invidious::Search::Filters::Features::CCommons => "commons", - Invidious::Search::Filters::Features::ThreeSixty => "360", - Invidious::Search::Filters::Features::VR180 => "vr180", - Invidious::Search::Filters::Features::ThreeD => "3d", - Invidious::Search::Filters::Features::HDR => "hdr", - Invidious::Search::Filters::Features::Location => "location", - Invidious::Search::Filters::Features::Purchased => "purchased", -} - -Spectator.describe Invidious::Search::Filters do - # ------------------- - # Decode (legacy) - # ------------------- - - describe "#from_legacy_filters" do - it "Decodes channel: filter" do - query = "test channel:UC123456 request" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new) - expect(chan).to eq("UC123456") - expect(qury).to eq("test request") - expect(subs).to be_false - end - - it "Decodes user: filter" do - query = "user:LinusTechTips broke something (again)" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new) - expect(chan).to eq("LinusTechTips") - expect(qury).to eq("broke something (again)") - expect(subs).to be_false - end - - it "Decodes type: filter" do - Invidious::Search::Filters::Type.each do |value| - query = "Eiffel 65 - Blue [1 Hour] type:#{value}" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new(type: value)) - expect(chan).to eq("") - expect(qury).to eq("Eiffel 65 - Blue [1 Hour]") - expect(subs).to be_false - end - end - - it "Decodes content_type: filter" do - Invidious::Search::Filters::Type.each do |value| - query = "I like to watch content_type:#{value}" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new(type: value)) - expect(chan).to eq("") - expect(qury).to eq("I like to watch") - expect(subs).to be_false - end - end - - it "Decodes date: filter" do - Invidious::Search::Filters::Date.each do |value| - query = "This date:#{value} is old!" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new(date: value)) - expect(chan).to eq("") - expect(qury).to eq("This is old!") - expect(subs).to be_false - end - end - - it "Decodes duration: filter" do - Invidious::Search::Filters::Duration.each do |value| - query = "This duration:#{value} is old!" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new(duration: value)) - expect(chan).to eq("") - expect(qury).to eq("This is old!") - expect(subs).to be_false - end - end - - it "Decodes feature: filter" do - Invidious::Search::Filters::Features.each do |value| - string = FEATURES_TEXT[value] - query = "I like my precious feature:#{string} ^^" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new(features: value)) - expect(chan).to eq("") - expect(qury).to eq("I like my precious ^^") - expect(subs).to be_false - end - end - - it "Decodes features: filter" do - query = "This search has many features:vr180,cc,hdr :o" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - features = Invidious::Search::Filters::Features.flags(HDR, VR180, CCommons) - - expect(fltr).to eq(described_class.new(features: features)) - expect(chan).to eq("") - expect(qury).to eq("This search has many :o") - expect(subs).to be_false - end - - it "Decodes sort: filter" do - Invidious::Search::Filters::Sort.each do |value| - query = "Computer? sort:#{value} my files!" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new(sort: value)) - expect(chan).to eq("") - expect(qury).to eq("Computer? my files!") - expect(subs).to be_false - end - end - - it "Decodes subscriptions: filter" do - query = "enable subscriptions:true" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new) - expect(chan).to eq("") - expect(qury).to eq("enable") - expect(subs).to be_true - end - - it "Ignores junk data" do - query = "duration:I sort:like type:cleaning features:stuff date:up!" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new) - expect(chan).to eq("") - expect(qury).to eq("") - expect(subs).to be_false - end - - it "Keeps unknown keys" do - query = "to:be or:not to:be" - - fltr, chan, qury, subs = described_class.from_legacy_filters(query) - - expect(fltr).to eq(described_class.new) - expect(chan).to eq("") - expect(qury).to eq("to:be or:not to:be") - expect(subs).to be_false - end - end - - # ------------------- - # Decode (URL) - # ------------------- - - describe "#from_iv_params" do - it "Decodes type= filter" do - Invidious::Search::Filters::Type.each do |value| - params = HTTP::Params.parse("type=#{value}") - - expect(described_class.from_iv_params(params)) - .to eq(described_class.new(type: value)) - end - end - - it "Decodes date= filter" do - Invidious::Search::Filters::Date.each do |value| - params = HTTP::Params.parse("date=#{value}") - - expect(described_class.from_iv_params(params)) - .to eq(described_class.new(date: value)) - end - end - - it "Decodes duration= filter" do - Invidious::Search::Filters::Duration.each do |value| - params = HTTP::Params.parse("duration=#{value}") - - expect(described_class.from_iv_params(params)) - .to eq(described_class.new(duration: value)) - end - end - - it "Decodes features= filter (single)" do - Invidious::Search::Filters::Features.each do |value| - string = described_class.format_features(value) - params = HTTP::Params.parse("features=#{string}") - - expect(described_class.from_iv_params(params)) - .to eq(described_class.new(features: value)) - end - end - - it "Decodes features= filter (multiple - comma separated)" do - features = Invidious::Search::Filters::Features.flags(HDR, VR180, CCommons) - params = HTTP::Params.parse("features=vr180%2Ccc%2Chdr") # %2C is a comma - - expect(described_class.from_iv_params(params)) - .to eq(described_class.new(features: features)) - end - - it "Decodes features= filter (multiple - URL parameters)" do - features = Invidious::Search::Filters::Features.flags(ThreeSixty, HD, FourK) - params = HTTP::Params.parse("features=4k&features=360&features=hd") - - expect(described_class.from_iv_params(params)) - .to eq(described_class.new(features: features)) - end - - it "Decodes sort= filter" do - Invidious::Search::Filters::Sort.each do |value| - params = HTTP::Params.parse("sort=#{value}") - - expect(described_class.from_iv_params(params)) - .to eq(described_class.new(sort: value)) - end - end - - it "Ignores junk data" do - params = HTTP::Params.parse("foo=bar&sort=views&answer=42&type=channel") - - expect(described_class.from_iv_params(params)).to eq( - described_class.new( - sort: Invidious::Search::Filters::Sort::Views, - type: Invidious::Search::Filters::Type::Channel - ) - ) - end - end - - # ------------------- - # Encode (URL) - # ------------------- - - describe "#to_iv_params" do - it "Encodes date filter" do - Invidious::Search::Filters::Date.each do |value| - filters = described_class.new(date: value) - params = filters.to_iv_params - - if value.none? - expect("#{params}").to eq("") - else - expect("#{params}").to eq("date=#{value.to_s.underscore}") - end - end - end - - it "Encodes type filter" do - Invidious::Search::Filters::Type.each do |value| - filters = described_class.new(type: value) - params = filters.to_iv_params - - if value.all? - expect("#{params}").to eq("") - else - expect("#{params}").to eq("type=#{value.to_s.underscore}") - end - end - end - - it "Encodes duration filter" do - Invidious::Search::Filters::Duration.each do |value| - filters = described_class.new(duration: value) - params = filters.to_iv_params - - if value.none? - expect("#{params}").to eq("") - else - expect("#{params}").to eq("duration=#{value.to_s.underscore}") - end - end - end - - it "Encodes features filter (single)" do - Invidious::Search::Filters::Features.each do |value| - filters = described_class.new(features: value) - - expect("#{filters.to_iv_params}") - .to eq("features=" + FEATURES_TEXT[value]) - end - end - - it "Encodes features filter (multiple)" do - features = Invidious::Search::Filters::Features.flags(Subtitles, Live, ThreeSixty) - filters = described_class.new(features: features) - - expect("#{filters.to_iv_params}") - .to eq("features=live%2Csubtitles%2C360") # %2C is a comma - end - - it "Encodes sort filter" do - Invidious::Search::Filters::Sort.each do |value| - filters = described_class.new(sort: value) - params = filters.to_iv_params - - if value.relevance? - expect("#{params}").to eq("") - else - expect("#{params}").to eq("sort=#{value.to_s.underscore}") - end - end - end - - it "Encodes multiple filters" do - filters = described_class.new( - date: Invidious::Search::Filters::Date::Today, - duration: Invidious::Search::Filters::Duration::Medium, - features: Invidious::Search::Filters::Features.flags(Location, Purchased), - sort: Invidious::Search::Filters::Sort::Relevance - ) - - params = filters.to_iv_params - - # Check the `date` param - expect(params).to have_key("date") - expect(params.fetch_all("date")).to contain_exactly("today") - - # Check the `type` param - expect(params).to_not have_key("type") - expect(params["type"]?).to be_nil - - # Check the `duration` param - expect(params).to have_key("duration") - expect(params.fetch_all("duration")).to contain_exactly("medium") - - # Check the `features` param - expect(params).to have_key("features") - expect(params.fetch_all("features")).to contain_exactly("location,purchased") - - # Check the `sort` param - expect(params).to_not have_key("sort") - expect(params["sort"]?).to be_nil - - # Check if there aren't other parameters - params.delete("date") - params.delete("duration") - params.delete("features") - - expect(params).to be_empty - end - end -end diff --git a/spec/invidious/search/query_spec.cr b/spec/invidious/search/query_spec.cr deleted file mode 100644 index 063b69f1..00000000 --- a/spec/invidious/search/query_spec.cr +++ /dev/null @@ -1,242 +0,0 @@ -require "../../../src/invidious/search/filters" -require "../../../src/invidious/search/query" - -require "http/params" -require "spectator" - -Spectator.configure do |config| - config.fail_blank - config.randomize -end - -Spectator.describe Invidious::Search::Query do - describe Type::Regular do - # ------------------- - # Query parsing - # ------------------- - - it "parses query with URL prameters (q)" do - query = described_class.new( - HTTP::Params.parse("q=What+is+Love+10+hour&type=video&duration=long"), - Invidious::Search::Query::Type::Regular, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Regular) - expect(query.channel).to be_empty - expect(query.text).to eq("What is Love 10 hour") - - expect(query.filters).to eq( - Invidious::Search::Filters.new( - type: Invidious::Search::Filters::Type::Video, - duration: Invidious::Search::Filters::Duration::Long - ) - ) - end - - it "parses query with URL prameters (search_query)" do - query = described_class.new( - HTTP::Params.parse("search_query=What+is+Love+10+hour&type=video&duration=long"), - Invidious::Search::Query::Type::Regular, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Regular) - expect(query.channel).to be_empty - expect(query.text).to eq("What is Love 10 hour") - - expect(query.filters).to eq( - Invidious::Search::Filters.new( - type: Invidious::Search::Filters::Type::Video, - duration: Invidious::Search::Filters::Duration::Long - ) - ) - end - - it "parses query with legacy filters (q)" do - query = described_class.new( - HTTP::Params.parse("q=Nyan+cat+duration:long"), - Invidious::Search::Query::Type::Regular, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Regular) - expect(query.channel).to be_empty - expect(query.text).to eq("Nyan cat") - - expect(query.filters).to eq( - Invidious::Search::Filters.new( - duration: Invidious::Search::Filters::Duration::Long - ) - ) - end - - it "parses query with legacy filters (search_query)" do - query = described_class.new( - HTTP::Params.parse("search_query=Nyan+cat+duration:long"), - Invidious::Search::Query::Type::Regular, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Regular) - expect(query.channel).to be_empty - expect(query.text).to eq("Nyan cat") - - expect(query.filters).to eq( - Invidious::Search::Filters.new( - duration: Invidious::Search::Filters::Duration::Long - ) - ) - end - - it "parses query with both URL params and legacy filters" do - query = described_class.new( - HTTP::Params.parse("q=Vamos+a+la+playa+duration:long&type=Video&date=year"), - Invidious::Search::Query::Type::Regular, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Regular) - expect(query.channel).to be_empty - expect(query.text).to eq("Vamos a la playa duration:long") - - expect(query.filters).to eq( - Invidious::Search::Filters.new( - type: Invidious::Search::Filters::Type::Video, - date: Invidious::Search::Filters::Date::Year - ) - ) - end - - # ------------------- - # Type switching - # ------------------- - - it "switches to channel search (URL param)" do - query = described_class.new( - HTTP::Params.parse("q=thunderbolt+4&channel=UC0vBXGSyV14uvJ4hECDOl0Q"), - Invidious::Search::Query::Type::Regular, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Channel) - expect(query.channel).to eq("UC0vBXGSyV14uvJ4hECDOl0Q") - expect(query.text).to eq("thunderbolt 4") - expect(query.filters.default?).to be_true - end - - it "switches to channel search (legacy)" do - query = described_class.new( - HTTP::Params.parse("q=channel%3AUCRPdsCVuH53rcbTcEkuY4uQ+rdna3"), - Invidious::Search::Query::Type::Regular, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Channel) - expect(query.channel).to eq("UCRPdsCVuH53rcbTcEkuY4uQ") - expect(query.text).to eq("rdna3") - expect(query.filters.default?).to be_true - end - - it "switches to subscriptions search" do - query = described_class.new( - HTTP::Params.parse("q=subscriptions:true+tunak+tunak+tun"), - Invidious::Search::Query::Type::Regular, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Subscriptions) - expect(query.channel).to be_empty - expect(query.text).to eq("tunak tunak tun") - expect(query.filters.default?).to be_true - end - end - - describe Type::Channel do - it "ignores extra parameters" do - query = described_class.new( - HTTP::Params.parse("q=Take+on+me+channel%3AUC12345679&type=video&date=year"), - Invidious::Search::Query::Type::Channel, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Channel) - expect(query.channel).to be_empty - expect(query.text).to eq("Take on me") - expect(query.filters.default?).to be_true - end - end - - describe Type::Subscriptions do - it "works" do - query = described_class.new( - HTTP::Params.parse("q=Harlem+shake&type=video&date=year"), - Invidious::Search::Query::Type::Subscriptions, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Subscriptions) - expect(query.channel).to be_empty - expect(query.text).to eq("Harlem shake") - - expect(query.filters).to eq( - Invidious::Search::Filters.new( - type: Invidious::Search::Filters::Type::Video, - date: Invidious::Search::Filters::Date::Year - ) - ) - end - end - - describe Type::Playlist do - it "ignores extra parameters" do - query = described_class.new( - HTTP::Params.parse("q=Harlem+shake+type:video+date:year&channel=UC12345679"), - Invidious::Search::Query::Type::Playlist, nil - ) - - expect(query.type).to eq(Invidious::Search::Query::Type::Playlist) - expect(query.channel).to be_empty - expect(query.text).to eq("Harlem shake") - - expect(query.filters).to eq( - Invidious::Search::Filters.new( - type: Invidious::Search::Filters::Type::Video, - date: Invidious::Search::Filters::Date::Year - ) - ) - end - end - - describe "#to_http_params" do - it "formats regular search" do - query = described_class.new( - HTTP::Params.parse("q=The+Simpsons+hiding+in+bush&duration=short"), - Invidious::Search::Query::Type::Regular, nil - ) - - params = query.to_http_params - - expect(params).to have_key("duration") - expect(params["duration"]?).to eq("short") - - expect(params).to have_key("q") - expect(params["q"]?).to eq("The Simpsons hiding in bush") - - # Check if there aren't other parameters - params.delete("duration") - params.delete("q") - expect(params).to be_empty - end - - it "formats channel search" do - query = described_class.new( - HTTP::Params.parse("q=channel:UC2DjFE7Xf11URZqWBigcVOQ%20multimeter"), - Invidious::Search::Query::Type::Regular, nil - ) - - params = query.to_http_params - - expect(params).to have_key("channel") - expect(params["channel"]?).to eq("UC2DjFE7Xf11URZqWBigcVOQ") - - expect(params).to have_key("q") - expect(params["q"]?).to eq("multimeter") - - # Check if there aren't other parameters - params.delete("channel") - params.delete("q") - expect(params).to be_empty - end - end -end diff --git a/spec/invidious/search/yt_filters_spec.cr b/spec/invidious/search/yt_filters_spec.cr deleted file mode 100644 index 8abed5ce..00000000 --- a/spec/invidious/search/yt_filters_spec.cr +++ /dev/null @@ -1,143 +0,0 @@ -require "../../../src/invidious/search/filters" - -require "http/params" -require "spectator" - -Spectator.configure do |config| - config.fail_blank - config.randomize -end - -# Encoded filter values are extracted from the search -# page of Youtube with any browser devtools HTML inspector. - -DATE_FILTERS = { - Invidious::Search::Filters::Date::Hour => "EgIIAfABAQ%3D%3D", - Invidious::Search::Filters::Date::Today => "EgIIAvABAQ%3D%3D", - Invidious::Search::Filters::Date::Week => "EgIIA_ABAQ%3D%3D", - Invidious::Search::Filters::Date::Month => "EgIIBPABAQ%3D%3D", - Invidious::Search::Filters::Date::Year => "EgIIBfABAQ%3D%3D", -} - -TYPE_FILTERS = { - Invidious::Search::Filters::Type::Video => "EgIQAfABAQ%3D%3D", - Invidious::Search::Filters::Type::Channel => "EgIQAvABAQ%3D%3D", - Invidious::Search::Filters::Type::Playlist => "EgIQA_ABAQ%3D%3D", - Invidious::Search::Filters::Type::Movie => "EgIQBPABAQ%3D%3D", -} - -DURATION_FILTERS = { - Invidious::Search::Filters::Duration::Short => "EgIYAfABAQ%3D%3D", - Invidious::Search::Filters::Duration::Medium => "EgIYA_ABAQ%3D%3D", - Invidious::Search::Filters::Duration::Long => "EgIYAvABAQ%3D%3D", -} - -FEATURE_FILTERS = { - Invidious::Search::Filters::Features::Live => "EgJAAfABAQ%3D%3D", - Invidious::Search::Filters::Features::FourK => "EgJwAfABAQ%3D%3D", - Invidious::Search::Filters::Features::HD => "EgIgAfABAQ%3D%3D", - Invidious::Search::Filters::Features::Subtitles => "EgIoAfABAQ%3D%3D", - Invidious::Search::Filters::Features::CCommons => "EgIwAfABAQ%3D%3D", - Invidious::Search::Filters::Features::ThreeSixty => "EgJ4AfABAQ%3D%3D", - Invidious::Search::Filters::Features::VR180 => "EgPQAQHwAQE%3D", - Invidious::Search::Filters::Features::ThreeD => "EgI4AfABAQ%3D%3D", - Invidious::Search::Filters::Features::HDR => "EgPIAQHwAQE%3D", - Invidious::Search::Filters::Features::Location => "EgO4AQHwAQE%3D", - Invidious::Search::Filters::Features::Purchased => "EgJIAfABAQ%3D%3D", -} - -SORT_FILTERS = { - Invidious::Search::Filters::Sort::Relevance => "8AEB", - Invidious::Search::Filters::Sort::Date => "CALwAQE%3D", - Invidious::Search::Filters::Sort::Views => "CAPwAQE%3D", - Invidious::Search::Filters::Sort::Rating => "CAHwAQE%3D", -} - -Spectator.describe Invidious::Search::Filters do - # ------------------- - # Encode YT params - # ------------------- - - describe "#to_yt_params" do - sample DATE_FILTERS do |value, result| - it "Encodes upload date filter '#{value}'" do - expect(described_class.new(date: value).to_yt_params).to eq(result) - end - end - - sample TYPE_FILTERS do |value, result| - it "Encodes content type filter '#{value}'" do - expect(described_class.new(type: value).to_yt_params).to eq(result) - end - end - - sample DURATION_FILTERS do |value, result| - it "Encodes duration filter '#{value}'" do - expect(described_class.new(duration: value).to_yt_params).to eq(result) - end - end - - sample FEATURE_FILTERS do |value, result| - it "Encodes feature filter '#{value}'" do - expect(described_class.new(features: value).to_yt_params).to eq(result) - end - end - - sample SORT_FILTERS do |value, result| - it "Encodes sort filter '#{value}'" do - expect(described_class.new(sort: value).to_yt_params).to eq(result) - end - end - end - - # ------------------- - # Decode YT params - # ------------------- - - describe "#from_yt_params" do - sample DATE_FILTERS do |value, encoded| - it "Decodes upload date filter '#{value}'" do - params = HTTP::Params.parse("sp=#{encoded}") - - expect(described_class.from_yt_params(params)) - .to eq(described_class.new(date: value)) - end - end - - sample TYPE_FILTERS do |value, encoded| - it "Decodes content type filter '#{value}'" do - params = HTTP::Params.parse("sp=#{encoded}") - - expect(described_class.from_yt_params(params)) - .to eq(described_class.new(type: value)) - end - end - - sample DURATION_FILTERS do |value, encoded| - it "Decodes duration filter '#{value}'" do - params = HTTP::Params.parse("sp=#{encoded}") - - expect(described_class.from_yt_params(params)) - .to eq(described_class.new(duration: value)) - end - end - - sample FEATURE_FILTERS do |value, encoded| - it "Decodes feature filter '#{value}'" do - params = HTTP::Params.parse("sp=#{encoded}") - - expect(described_class.from_yt_params(params)) - .to eq(described_class.new(features: value)) - end - end - - sample SORT_FILTERS do |value, encoded| - it "Decodes sort filter '#{value}'" do - params = HTTP::Params.parse("sp=#{encoded}") - - expect(described_class.from_yt_params(params)) - .to eq(described_class.new(sort: value)) - end - end - end -end diff --git a/spec/invidious/utils_spec.cr b/spec/invidious/utils_spec.cr deleted file mode 100644 index 7c2c2711..00000000 --- a/spec/invidious/utils_spec.cr +++ /dev/null @@ -1,46 +0,0 @@ -require "../spec_helper" - -Spectator.describe "Utils" do - describe "decode_date" do - it "parses short dates (en-US)" do - expect(decode_date("1s ago")).to be_close(Time.utc - 1.second, 500.milliseconds) - expect(decode_date("2min ago")).to be_close(Time.utc - 2.minutes, 500.milliseconds) - expect(decode_date("3h ago")).to be_close(Time.utc - 3.hours, 500.milliseconds) - expect(decode_date("4d ago")).to be_close(Time.utc - 4.days, 500.milliseconds) - expect(decode_date("5w ago")).to be_close(Time.utc - 5.weeks, 500.milliseconds) - expect(decode_date("6mo ago")).to be_close(Time.utc - 6.months, 500.milliseconds) - expect(decode_date("7y ago")).to be_close(Time.utc - 7.years, 500.milliseconds) - end - - it "parses short dates (en-GB)" do - expect(decode_date("55s ago")).to be_close(Time.utc - 55.seconds, 500.milliseconds) - expect(decode_date("44min ago")).to be_close(Time.utc - 44.minutes, 500.milliseconds) - expect(decode_date("22hr ago")).to be_close(Time.utc - 22.hours, 500.milliseconds) - expect(decode_date("1day ago")).to be_close(Time.utc - 1.day, 500.milliseconds) - expect(decode_date("2days ago")).to be_close(Time.utc - 2.days, 500.milliseconds) - expect(decode_date("3wk ago")).to be_close(Time.utc - 3.weeks, 500.milliseconds) - expect(decode_date("11mo ago")).to be_close(Time.utc - 11.months, 500.milliseconds) - expect(decode_date("11yr ago")).to be_close(Time.utc - 11.years, 500.milliseconds) - end - - it "parses long forms (singular)" do - expect(decode_date("1 second ago")).to be_close(Time.utc - 1.second, 500.milliseconds) - expect(decode_date("1 minute ago")).to be_close(Time.utc - 1.minute, 500.milliseconds) - expect(decode_date("1 hour ago")).to be_close(Time.utc - 1.hour, 500.milliseconds) - expect(decode_date("1 day ago")).to be_close(Time.utc - 1.day, 500.milliseconds) - expect(decode_date("1 week ago")).to be_close(Time.utc - 1.week, 500.milliseconds) - expect(decode_date("1 month ago")).to be_close(Time.utc - 1.month, 500.milliseconds) - expect(decode_date("1 year ago")).to be_close(Time.utc - 1.year, 500.milliseconds) - end - - it "parses long forms (plural)" do - expect(decode_date("5 seconds ago")).to be_close(Time.utc - 5.seconds, 500.milliseconds) - expect(decode_date("17 minutes ago")).to be_close(Time.utc - 17.minutes, 500.milliseconds) - expect(decode_date("23 hours ago")).to be_close(Time.utc - 23.hours, 500.milliseconds) - expect(decode_date("3 days ago")).to be_close(Time.utc - 3.days, 500.milliseconds) - expect(decode_date("2 weeks ago")).to be_close(Time.utc - 2.weeks, 500.milliseconds) - expect(decode_date("9 months ago")).to be_close(Time.utc - 9.months, 500.milliseconds) - expect(decode_date("8 years ago")).to be_close(Time.utc - 8.years, 500.milliseconds) - end - end -end diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr deleted file mode 100644 index f96703f6..00000000 --- a/spec/invidious/videos/regular_videos_extract_spec.cr +++ /dev/null @@ -1,168 +0,0 @@ -require "../../parsers_helper.cr" - -Spectator.describe "parse_video_info" do - it "parses a regular video" do - # Enable mock - _player = load_mock("video/regular_mrbeast.player") - _next = load_mock("video/regular_mrbeast.next") - - raw_data = _player.merge!(_next) - info = parse_video_info("2isYuQZMbdU", raw_data) - - # Some basic verifications - expect(typeof(info)).to eq(Hash(String, JSON::Any)) - - expect(info["videoType"].as_s).to eq("Video") - - # Basic video infos - - expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island") - expect(info["views"].as_i).to eq(220_226_287) - expect(info["likes"].as_i).to eq(6_870_691) - - # For some reason the video length from VideoDetails and the - # one from microformat differs by 1s... - expect(info["lengthSeconds"].as_i).to be_between(930_i64, 931_i64) - - expect(info["published"].as_s).to eq("2022-08-04T00:00:00Z") - - # Extra video infos - - expect(info["allowedRegions"].as_a).to_not be_empty - expect(info["allowedRegions"].as_a.size).to eq(249) - - expect(info["allowedRegions"].as_a).to contain( - "AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR", - "TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", - "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" - ) - - expect(info["keywords"].as_a).to be_empty - - expect(info["allowRatings"].as_bool).to be_true - expect(info["isFamilyFriendly"].as_bool).to be_true - expect(info["isListed"].as_bool).to be_true - expect(info["isUpcoming"].as_bool).to be_false - - # Related videos - - expect(info["relatedVideos"].as_a.size).to eq(20) - - expect(info["relatedVideos"][0]["id"]).to eq("krsBRQbOPQ4") - expect(info["relatedVideos"][0]["title"]).to eq("$1 vs $250,000,000 Private Island!") - expect(info["relatedVideos"][0]["author"]).to eq("MrBeast") - expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") - expect(info["relatedVideos"][0]["view_count"]).to eq("230617484") - expect(info["relatedVideos"][0]["short_view_count"]).to eq("230M") - expect(info["relatedVideos"][0]["author_verified"]).to eq("true") - - # Description - - description = "🚀Launch a store on Shopify, I’ll buy from 100 random stores that do ▸ " - - expect(info["description"].as_s).to start_with(description) - expect(info["shortDescription"].as_s).to start_with(description) - expect(info["descriptionHtml"].as_s).to start_with(description) - - # Video metadata - - expect(info["genre"].as_s).to eq("Entertainment") - expect(info["genreUcid"].as_s?).to be_nil - expect(info["license"].as_s).to be_empty - - # Author infos - - expect(info["author"].as_s).to eq("MrBeast") - expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") - - expect(info["authorThumbnail"].as_s).to eq( - "https://yt3.ggpht.com/fxGKYucJAVme-Yz4fsdCroCFCrANWqw0ql4GYuvx8Uq4l_euNJHgE-w9MTkLQA805vWCi-kE0g=s48-c-k-c0x00ffffff-no-rj" - ) - - expect(info["authorVerified"].as_bool).to be_true - expect(info["subCountText"].as_s).to eq("320M") - end - - it "parses a regular video with no descrition/comments" do - # Enable mock - _player = load_mock("video/regular_no-description.player") - _next = load_mock("video/regular_no-description.next") - - raw_data = _player.merge!(_next) - info = parse_video_info("iuevw6218F0", raw_data) - - # Some basic verifications - expect(typeof(info)).to eq(Hash(String, JSON::Any)) - - expect(info["videoType"].as_s).to eq("Video") - - # Basic video infos - - expect(info["title"].as_s).to eq("Chris Rea - Auberge") - expect(info["views"].as_i).to eq(14_324_584) - expect(info["likes"].as_i).to eq(35_870) - expect(info["lengthSeconds"].as_i).to eq(283_i64) - expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z") - - # Extra video infos - - expect(info["allowedRegions"].as_a).to_not be_empty - expect(info["allowedRegions"].as_a.size).to eq(249) - - expect(info["allowedRegions"].as_a).to contain( - "AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR", - "TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", - "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" - ) - - expect(info["keywords"].as_a).to_not be_empty - expect(info["keywords"].as_a.size).to eq(4) - - expect(info["keywords"].as_a).to contain_exactly( - "Chris", - "Rea", - "Auberge", - "1991" - ).in_any_order - - expect(info["allowRatings"].as_bool).to be_true - expect(info["isFamilyFriendly"].as_bool).to be_true - expect(info["isListed"].as_bool).to be_true - expect(info["isUpcoming"].as_bool).to be_false - - # Related videos - - expect(info["relatedVideos"].as_a.size).to eq(20) - - expect(info["relatedVideos"][0]["id"]).to eq("gUUdQfnshJ4") - expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea - The Road To Hell 1989 Full Version") - expect(info["relatedVideos"][0]["author"]).to eq("NEA ZIXNH") - expect(info["relatedVideos"][0]["ucid"]).to eq("UCYMEOGcvav3gCgImK2J07CQ") - expect(info["relatedVideos"][0]["view_count"]).to eq("53298661") - expect(info["relatedVideos"][0]["short_view_count"]).to eq("53M") - expect(info["relatedVideos"][0]["author_verified"]).to eq("false") - - # Description - - expect(info["description"].as_s).to eq(" ") - expect(info["shortDescription"].as_s).to be_empty - expect(info["descriptionHtml"].as_s).to eq("") - - # Video metadata - - expect(info["genre"].as_s).to eq("Music") - expect(info["genreUcid"].as_s?).to be_nil - expect(info["license"].as_s).to be_empty - - # Author infos - - expect(info["author"].as_s).to eq("ChrisReaVideos") - expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA") - - expect(info["authorThumbnail"].as_s).to eq( - "https://yt3.ggpht.com/ytc/AIdro_n71nsegpKfjeRKwn1JJmK5IVMh_7j5m_h3_1KnUUg=s48-c-k-c0x00ffffff-no-rj" - ) - expect(info["authorVerified"].as_bool).to be_false - expect(info["subCountText"].as_s).to eq("3.11K") - end -end diff --git a/spec/invidious/videos/scheduled_live_extract_spec.cr b/spec/invidious/videos/scheduled_live_extract_spec.cr deleted file mode 100644 index c3a9b228..00000000 --- a/spec/invidious/videos/scheduled_live_extract_spec.cr +++ /dev/null @@ -1,111 +0,0 @@ -require "../../parsers_helper.cr" - -Spectator.describe "parse_video_info" do - it "parses scheduled livestreams data" do - # Enable mock - _player = load_mock("video/scheduled_live_PBD-Podcast.player") - _next = load_mock("video/scheduled_live_PBD-Podcast.next") - - raw_data = _player.merge!(_next) - info = parse_video_info("N-yVic7BbY0", raw_data) - - # Some basic verifications - expect(typeof(info)).to eq(Hash(String, JSON::Any)) - - expect(info["videoType"].as_s).to eq("Scheduled") - - # Basic video infos - - expect(info["title"].as_s).to eq("Home Team | PBD Podcast | Ep. 241") - expect(info["views"].as_i).to eq(6) - expect(info["likes"].as_i).to eq(7) - expect(info["lengthSeconds"].as_i).to eq(0_i64) - expect(info["published"].as_s).to eq("2023-02-28T14:00:00Z") # Unix 1677592800 - - # Extra video infos - - expect(info["allowedRegions"].as_a).to_not be_empty - expect(info["allowedRegions"].as_a.size).to eq(249) - - expect(info["allowedRegions"].as_a).to contain( - "AD", "AR", "BA", "BT", "CZ", "FO", "GL", "IO", "KE", "KH", "LS", - "LT", "MP", "NO", "PR", "RO", "SE", "SK", "SS", "SX", "SZ", "ZW" - ) - - expect(info["keywords"].as_a).to_not be_empty - expect(info["keywords"].as_a.size).to eq(25) - - expect(info["keywords"].as_a).to contain_exactly( - "Patrick Bet-David", - "Valeutainment", - "The BetDavid Podcast", - "The BetDavid Show", - "Betdavid", - "PBD", - "BetDavid show", - "Betdavid podcast", - "podcast betdavid", - "podcast patrick", - "patrick bet david podcast", - "Valuetainment podcast", - "Entrepreneurs", - "Entrepreneurship", - "Entrepreneur Motivation", - "Entrepreneur Advice", - "Startup Entrepreneurs", - "valuetainment", - "patrick bet david", - "PBD podcast", - "Betdavid show", - "Betdavid Podcast", - "Podcast Betdavid", - "Show Betdavid", - "PBDPodcast" - ).in_any_order - - expect(info["allowRatings"].as_bool).to be_true - expect(info["isFamilyFriendly"].as_bool).to be_true - expect(info["isListed"].as_bool).to be_true - expect(info["isUpcoming"].as_bool).to be_true - - # Related videos - - expect(info["relatedVideos"].as_a.size).to eq(20) - - expect(info["relatedVideos"][0]["id"]).to eq("j7jPzzjbVuk") - expect(info["relatedVideos"][0]["author"]).to eq("Democracy Now!") - expect(info["relatedVideos"][0]["ucid"]).to eq("UCzuqE7-t13O4NIDYJfakrhw") - expect(info["relatedVideos"][0]["view_count"]).to eq("7576") - expect(info["relatedVideos"][0]["short_view_count"]).to eq("7.5K") - expect(info["relatedVideos"][0]["author_verified"]).to eq("true") - - # Description - - description_start_text = "PBD Podcast Episode 241. The home team is ready and at it again with the latest news, interesting topics and trending conversations on topics that matter. Try our sponsor Aura for 14 days free - https://aura.com/pbd" - - expect(info["description"].as_s).to start_with(description_start_text) - expect(info["shortDescription"].as_s).to start_with(description_start_text) - - # TODO: Update mocks right before the start of PDB podcast, either on friday or saturday (time unknown) - # expect(info["descriptionHtml"].as_s).to start_with( - # "PBD Podcast Episode 241. The home team is ready and at it again with the latest news, interesting topics and trending conversations on topics that matter. Try our sponsor Aura for 14 days free - aura.com/pbd" - # ) - - # Video metadata - - expect(info["genre"].as_s).to eq("Entertainment") - expect(info["genreUcid"].as_s?).to be_nil - expect(info["license"].as_s).to be_empty - - # Author infos - - expect(info["author"].as_s).to eq("PBD Podcast") - expect(info["ucid"].as_s).to eq("UCGX7nGXpz-CmO_Arg-cgJ7A") - - expect(info["authorThumbnail"].as_s).to eq( - "https://yt3.ggpht.com/61ArDiQshJrvSXcGLhpFfIO3hlMabe2fksitcf6oGob0Mdr5gztdkXxRljICUodL4iuTSrtxW4A=s48-c-k-c0x00ffffff-no-rj" - ) - expect(info["authorVerified"].as_bool).to be_false - expect(info["subCountText"].as_s).to eq("594K") - end -end diff --git a/spec/parsers_helper.cr b/spec/parsers_helper.cr deleted file mode 100644 index 6589acad..00000000 --- a/spec/parsers_helper.cr +++ /dev/null @@ -1,35 +0,0 @@ -require "db" -require "json" -require "kemal" - -require "protodec/utils" - -require "spectator" - -require "../src/invidious/exceptions" -require "../src/invidious/helpers/macros" -require "../src/invidious/helpers/logger" -require "../src/invidious/helpers/utils" - -require "../src/invidious/videos" -require "../src/invidious/videos/*" -require "../src/invidious/comments/content" - -require "../src/invidious/helpers/serialized_yt_data" -require "../src/invidious/yt_backend/extractors" -require "../src/invidious/yt_backend/extractors_utils" - -OUTPUT = File.open(File::NULL, "w") -LOGGER = Invidious::LogHandler.new(OUTPUT, LogLevel::Off) - -def load_mock(file) : Hash(String, JSON::Any) - file = File.join(__DIR__, "..", "mocks", file + ".json") - content = File.read(file) - - return JSON.parse(content).as_h -end - -Spectator.configure do |config| - config.fail_blank - config.randomize -end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index b3060acf..09320750 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -5,10 +5,10 @@ require "protodec/utils" require "yaml" require "../src/invidious/helpers/*" require "../src/invidious/channels/*" -require "../src/invidious/videos/caption" require "../src/invidious/videos" +require "../src/invidious/comments" require "../src/invidious/playlists" -require "../src/invidious/search/ctoken" +require "../src/invidious/search" require "../src/invidious/trending" require "spectator" diff --git a/src/invidious.cr b/src/invidious.cr index 69f8a26c..1bdf3097 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -16,12 +16,7 @@ require "digest/md5" require "file_utils" - -# Require kemal, then our own overrides require "kemal" -require "./ext/kemal_static_file_handler.cr" - -require "http_proxy" require "athena-negotiation" require "openssl/hmac" require "option_parser" @@ -32,36 +27,22 @@ require "compress/zip" require "protodec/utils" require "./invidious/database/*" -require "./invidious/database/migrations/*" -require "./invidious/http_server/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" require "./invidious/frontend/*" -require "./invidious/videos/*" - -require "./invidious/jsonify/**" require "./invidious/*" -require "./invidious/comments/*" require "./invidious/channels/*" require "./invidious/user/*" -require "./invidious/search/*" require "./invidious/routes/**" -require "./invidious/jobs/base_job" -require "./invidious/jobs/*" - -# Declare the base namespace for invidious -module Invidious -end - -# Simple alias to make code easier to read -alias IV = Invidious +require "./invidious/jobs/**" CONFIG = Config.load -HMAC_KEY = CONFIG.hmac_key +HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) PG_DB = DB.open CONFIG.database_url ARCHIVE_URL = URI.parse("https://archive.org") +LOGIN_URL = URI.parse("https://accounts.google.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") REDDIT_URL = URI.parse("https://www.reddit.com") YT_URL = URI.parse("https://www.youtube.com") @@ -90,15 +71,7 @@ SOFTWARE = { "branch" => "#{CURRENT_BRANCH}", } -YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size) - -# Image request pool - -GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size) - -COMPANION_POOL = CompanionConnectionPool.new( - capacity: CONFIG.pool_size -) +YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, use_quic: CONFIG.use_quic) # CLI Kemal.config.extra_options do |parser| @@ -125,17 +98,10 @@ Kemal.config.extra_options do |parser| parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{CONFIG.log_level})") do |log_level| CONFIG.log_level = LogLevel.parse(log_level) end - parser.on("-k", "--colorize", "Colorize logs") do - CONFIG.colorize_logs = true - end parser.on("-v", "--version", "Print version") do puts SOFTWARE.to_pretty_json exit end - parser.on("--migrate", "Run any migrations (beta, use at your own risk!!") do - Invidious::Database::Migrator.new(PG_DB).migrate - exit - end end Kemal::CLI.new ARGV @@ -144,7 +110,7 @@ if CONFIG.output.upcase != "STDOUT" FileUtils.mkdir_p(File.dirname(CONFIG.output)) end OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a") -LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_logs) +LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) # Check table integrity Invidious::Database.check_integrity(CONFIG) @@ -155,24 +121,14 @@ Invidious::Database.check_integrity(CONFIG) # Running the script by itself would show some colorful feedback while this doesn't. # Perhaps we should just move the script to runtime in order to get that feedback? - {% puts "\nChecking player dependencies, this may take more than 20 minutes... If it is stuck, check your internet connection.\n" %} + {% puts "\nChecking player dependencies...\n" %} {% if flag?(:minified_player_dependencies) %} {% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %} {% else %} {% puts run("../scripts/fetch-player-dependencies.cr").stringify %} {% end %} - {% puts "\nDone checking player dependencies, now compiling Invidious...\n" %} {% end %} -# Misc - -DECRYPT_FUNCTION = - if sig_helper_address = CONFIG.signature_server.presence - IV::DecryptFunction.new(sig_helper_address) - else - nil - end - # Start jobs if CONFIG.channel_threads > 0 @@ -183,6 +139,11 @@ if CONFIG.feed_threads > 0 Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB) end +DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling) +if CONFIG.decrypt_polling + Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new +end + if CONFIG.statistics_enabled Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE) end @@ -195,13 +156,8 @@ if CONFIG.popular_enabled Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) end -NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(32) -CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32) -Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(NOTIFICATION_CHANNEL, CONNECTION_CHANNEL, CONFIG.database_url) - -Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new - -Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new +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 @@ -209,28 +165,311 @@ def popular_videos Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get end -# Routing - before_all do |env| - Invidious::Routes::BeforeAll.handle(env) + preferences = Preferences.from_json("{}") + + begin + if prefs_cookie = env.request.cookies["PREFS"]? + preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value)) + else + if language_header = env.request.headers["Accept-Language"]? + if language = ANG.language_negotiator.best(language_header, LOCALES.keys) + preferences.locale = language.header + end + end + end + rescue + preferences = Preferences.from_json("{}") + end + + env.set "preferences", preferences + env.response.headers["X-XSS-Protection"] = "1; mode=block" + env.response.headers["X-Content-Type-Options"] = "nosniff" + + # Allow media resources to be loaded from google servers + # TODO: check if *.youtube.com can be removed + if CONFIG.disabled?("local") || !preferences.local + extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443" + else + extra_media_csp = "" + end + + # Only allow the pages at /embed/* to be embedded + if env.request.resource.starts_with?("/embed") + frame_ancestors = "'self' http: https:" + else + frame_ancestors = "'none'" + end + + # TODO: Remove style-src's 'unsafe-inline', requires to remove all + # inline styles (, style=" [..] ") + env.response.headers["Content-Security-Policy"] = { + "default-src 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self' data:", + "connect-src 'self'", + "manifest-src 'self'", + "media-src 'self' blob:" + extra_media_csp, + "child-src 'self' blob:", + "frame-src 'self'", + "frame-ancestors " + frame_ancestors, + }.join("; ") + + env.response.headers["Referrer-Policy"] = "same-origin" + + # Ask the chrom*-based browsers to disable FLoC + # See: https://blog.runcloud.io/google-floc/ + env.response.headers["Permissions-Policy"] = "interest-cohort=()" + + if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts + env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" + end + + next if { + "/sb/", + "/vi/", + "/s_p/", + "/yts/", + "/ggpht/", + "/api/manifest/", + "/videoplayback", + "/latest_version", + "/download", + }.any? { |r| env.request.resource.starts_with? r } + + if env.request.cookies.has_key? "SID" + sid = env.request.cookies["SID"].value + + if sid.starts_with? "v1:" + raise "Cannot use token as SID" + end + + # Invidious users only have SID + if !env.request.cookies.has_key? "SSID" + if email = Invidious::Database::SessionIDs.select_email(sid) + user = Invidious::Database::Users.select!(email: email) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) + + preferences = user.preferences + env.set "preferences", preferences + + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user + end + else + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + begin + user, sid = get_user(sid, headers, false) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) + + preferences = user.preferences + env.set "preferences", preferences + + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user + rescue ex + end + end + end + + dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s + thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s + thin_mode = thin_mode == "true" + locale = env.params.query["hl"]? || preferences.locale + + preferences.dark_mode = dark_mode + preferences.thin_mode = thin_mode + preferences.locale = locale + env.set "preferences", preferences + + current_page = env.request.path + if env.request.query + query = HTTP::Params.parse(env.request.query.not_nil!) + + if query["referer"]? + query["referer"] = get_referer(env, "/") + end + + current_page += "?#{query}" + end + + env.set "current_page", URI.encode_www_form(current_page) end -Invidious::Routing.register_all +{% unless flag?(:api_only) %} + Invidious::Routing.get "/", Invidious::Routes::Misc, :home + Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy + Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses + + Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home + Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home + Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos + 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 + Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect + # /user/linustechtips | Not always the same as /c/ + Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect + # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow + Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect + # /profile?user=linustechtips + Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile + end + + Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle + Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched + Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect + Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect + Invidious::Routing.get "/clip/:clip", Invidious::Routes::Watch, :clip + Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect + Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect + 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 + + Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new + Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create + Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe + Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page + Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete + Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit + Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update + Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page + 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 + Invidious::Routing.get "/search", Invidious::Routes::Search, :search + + # User routes + define_user_routes() + + # Feeds + Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect + Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists + Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular + Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending + Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions + Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history + + # RSS Feeds + Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel + Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private + Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist + Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos + + # Support push notifications via PubSubHubbub + Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get + Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post + + Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify + + Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription + Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager +{% end %} + +Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht +Invidious::Routing.options "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :options_storyboard +Invidious::Routing.get "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :get_storyboard +Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image +Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image +Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails + +# API routes (macro) +define_v1_api_routes() + +# Video playback (macros) +define_api_manifest_routes() +define_video_playback_routes() error 404 do |env| - Invidious::Routes::ErrorRoutes.error_404(env) + if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) + item = md["id"] + + # Check if item is branding URL e.g. https://youtube.com/gaming + response = YT_POOL.client &.get("/#{item}") + + if response.status_code == 301 + response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) + end + + if response.body.empty? + env.response.headers["Location"] = "/" + halt env, status_code: 302 + end + + html = XML.parse_html(response.body) + ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] + + if ucid + env.response.headers["Location"] = "/channel/#{ucid}" + halt env, status_code: 302 + end + + params = [] of String + env.params.query.each do |k, v| + params << "#{k}=#{v}" + end + params = params.join("&") + + url = "/watch?v=#{item}" + if !params.empty? + url += "&#{params}" + end + + # Check if item is video ID + if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 + env.response.headers["Location"] = url + halt env, status_code: 302 + end + end + + env.response.headers["Location"] = "/" + halt env, status_code: 302 end error 500 do |env, ex| + locale = env.get("preferences").as(Preferences).locale error_template(500, ex) end -static_headers do |env| - env.response.headers.add("Cache-Control", "max-age=2629800") +static_headers do |response| + response.headers.add("Cache-Control", "max-age=2629800") end -# Init Kemal - public_folder "assets" Kemal.config.powered_by_header = false @@ -243,6 +482,8 @@ add_context_storage_type(Preferences) add_context_storage_type(Invidious::User) Kemal.config.logger = LOGGER +Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding +Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port Kemal.config.app_name = "Invidious" # Use in kemal's production mode. @@ -251,16 +492,4 @@ Kemal.config.app_name = "Invidious" Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV") {% end %} -Kemal.run do |config| - if socket_binding = CONFIG.socket_binding - File.delete?(socket_binding.path) - # Create a socket and set its desired permissions - server = UNIXServer.new(socket_binding.path) - perms = socket_binding.permissions.to_i(base: 8) - File.chmod(socket_binding.path, perms) - config.server.not_nil!.bind server - else - Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding - Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port - end -end +Kemal.run diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 13909527..4f82a0f1 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -6,33 +6,30 @@ record AboutChannel, author_url : String, author_thumbnail : String, banner : String?, - description : String, description_html : String, total_views : Int64, sub_count : Int32, joined : Time, is_family_friendly : Bool, allowed_regions : Array(String), - tabs : Array(String), - tags : Array(String), - verified : Bool, - is_age_gated : Bool + tabs : Array(String) + +record AboutRelatedChannel, + ucid : String, + author : String, + author_url : String, + author_thumbnail : String def get_about_info(ucid, locale) : AboutChannel begin - # Fetch channel information from channel home page - initdata = YoutubeAPI.browse(browse_id: ucid, params: "") + # "EgVhYm91dA==" is the base64-encoded protobuf object {"2:string":"about"} + initdata = YoutubeAPI.browse(browse_id: ucid, params: "EgVhYm91dA==") rescue raise InfoException.new("Could not get channel info.") end if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR" - error_message = initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s - if error_message == "This channel does not exist." - raise NotFoundException.new(error_message) - else - raise InfoException.new(error_message) - end + raise InfoException.new(initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s) end if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? @@ -45,131 +42,77 @@ def get_about_info(ucid, locale) : AboutChannel auto_generated = true end - tags = [] of String - tab_names = [] of String + if auto_generated + author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s + author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s + author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s + + # Raises a KeyError on failure. + banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banner = banners.try &.[-1]?.try &.["url"].as_s? + + description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s + description_html = HTML.escape(description) + + is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool + allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) + else + author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s + author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s + author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s + + ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s + + # Raises a KeyError on failure. + banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banner = banners.try &.[-1]?.try &.["url"].as_s? + + # if banner.includes? "channels/c4/default_banner" + # banner = nil + # end + + description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" + description_html = HTML.escape(description) + + is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool + allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) + end + total_views = 0_i64 joined = Time.unix(0) - if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer") - description_node = nil - author = age_gate_renderer["channelTitle"].as_s - ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s - author_url = "https://www.youtube.com/channel/#{ucid}" - author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s - banner = nil - is_family_friendly = false - is_age_gated = true - tab_names = ["videos", "shorts", "streams"] - auto_generated = false - else - if auto_generated - author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s - author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s - author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s + tabs = [] of String - # Raises a KeyError on failure. - banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banner = banners.try &.[-1]?.try &.["url"].as_s? - - description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"] - # some channels have the description in a simpleText - # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/ - description_node = description_base_node.dig?("simpleText") || description_base_node - - tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges") - .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String - else - author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s - author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s - author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s - author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges")) - - ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s - - # Raises a KeyError on failure. - banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources") - banner = banners.try &.[-1]?.try &.["url"].as_s? - - # if banner.includes? "channels/c4/default_banner" - # banner = nil - # end - - description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]? - tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String - end - - is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool - if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]? - # Get the name of the tabs available on this channel - tab_names = tabs_json.as_a.compact_map do |entry| - name = entry.dig?("tabRenderer", "title").try &.as_s.downcase - - # This is a small fix to not add extra code on the HTML side - # I.e, the URL for the "live" tab is .../streams, so use "streams" - # everywhere for the sake of simplicity - (name == "live") ? "streams" : name - end - - # Get the currently active tab ("About") - about_tab = extract_selected_tab(tabs_json) - - # Try to find the about metadata section - channel_about_meta = about_tab.dig?( - "content", - "sectionListRenderer", "contents", 0, - "itemSectionRenderer", "contents", 0, - "channelAboutFullMetadataRenderer" - ) + tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?.try &.as_a? + if !tabs_json.nil? + # Retrieve information from the tabs array. The index we are looking for varies between channels. + tabs_json.each do |node| + # Try to find the about section which is located in only one of the tabs. + channel_about_meta = node["tabRenderer"]?.try &.["content"]?.try &.["sectionListRenderer"]? + .try &.["contents"]?.try &.[0]?.try &.["itemSectionRenderer"]?.try &.["contents"]? + .try &.[0]?.try &.["channelAboutFullMetadataRenderer"]? if !channel_about_meta.nil? - total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 + total_views = channel_about_meta["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. - joined = extract_text(channel_about_meta["joinedDateText"]?) + joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, nd| acc + nd["text"].as_s } .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) # Normal Auto-generated channels # https://support.google.com/youtube/answer/2579942 - # For auto-generated channels, channel_about_meta only has - # ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] - auto_generated = ( - (channel_about_meta["primaryLinks"]?.try &.size) == 1 && \ - extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" || - channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube" - ) + # For auto-generated channels, channel_about_meta only has ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] + if (channel_about_meta["primaryLinks"]?.try &.size || 0) == 1 && (channel_about_meta["primaryLinks"][0]?) && + (channel_about_meta["primaryLinks"][0]["title"]?.try &.["simpleText"]?.try &.as_s? || "") == "Auto-generated by YouTube" + auto_generated = true + end end end + tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"]["title"].as_s.downcase) end - allowed_regions = initdata - .dig?("microformat", "microformatDataRenderer", "availableCountries") - .try &.as_a.map(&.as_s) || [] of String - - description = !description_node.nil? ? description_node.as_s : "" - description_html = HTML.escape(description) - - if !description_node.nil? - if description_node.as_h?.nil? - description_node = text_to_parsed_content(description_node.as_s) - end - description_html = parse_content(description_node) - if description_html == "" && description != "" - description_html = HTML.escape(description) - end - end - - sub_count = 0 - - if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a) - metadata_rows.each do |row| - metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") } - if !metadata_part.nil? - sub_count = short_text_to_number(metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32 - end - break if sub_count != 0 - end - end + sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? + .try { |text| short_text_to_number(text.split(" ")[0]) } || 0 AboutChannel.new( ucid: ucid, @@ -178,29 +121,51 @@ def get_about_info(ucid, locale) : AboutChannel author_url: author_url, author_thumbnail: author_thumbnail, banner: banner, - description: description, description_html: description_html, total_views: total_views, sub_count: sub_count, joined: joined, is_family_friendly: is_family_friendly, allowed_regions: allowed_regions, - tabs: tab_names, - tags: tags, - verified: author_verified || false, - is_age_gated: is_age_gated || false, + tabs: tabs, ) end -def fetch_related_channels(about_channel : AboutChannel, continuation : String? = nil) : {Array(SearchChannel), String?} - if continuation.nil? - # params is {"2:string":"channels"} encoded - initial_data = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") - else - initial_data = YoutubeAPI.browse(continuation) +def fetch_related_channels(about_channel : AboutChannel) : Array(AboutRelatedChannel) + # params is {"2:string":"channels"} encoded + channels = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") + + tabs = channels.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs").try(&.as_a?) || [] of JSON::Any + tab = tabs.find(&.dig?("tabRenderer", "title").try(&.as_s?).try(&.== "Channels")) + + return [] of AboutRelatedChannel if tab.nil? + + items = tab.dig?( + "tabRenderer", "content", + "sectionListRenderer", "contents", 0, + "itemSectionRenderer", "contents", 0, + "gridRenderer", "items" + ).try &.as_a? + + related = [] of AboutRelatedChannel + return related if (items.nil? || items.empty?) + + items.each do |item| + renderer = item["gridChannelRenderer"]? + next if !renderer + + related_id = renderer.dig("channelId").as_s + related_title = renderer.dig("title", "simpleText").as_s + related_author_url = renderer.dig("navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s + related_author_thumbnail = HelperExtractors.get_thumbnails(renderer) + + related << AboutRelatedChannel.new( + ucid: related_id, + author: related_title, + author_url: related_author_url, + author_thumbnail: related_author_thumbnail, + ) end - items, continuation = extract_items(initial_data) - - return items.select(SearchChannel), continuation + return related end diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 65982325..54634534 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -29,7 +29,7 @@ struct ChannelVideo json.field "title", self.title json.field "videoId", self.id json.field "videoThumbnails" do - Invidious::JSONify::APIv1.thumbnails(json, self.id) + generate_thumbnails(json, self.id) end json.field "lengthSeconds", self.length_seconds @@ -93,7 +93,7 @@ struct ChannelVideo def to_tuple {% begin %} { - {{@type.instance_vars.map(&.name).splat}} + {{*@type.instance_vars.map(&.name)}} } {% end %} end @@ -159,18 +159,12 @@ def fetch_channel(ucid, pull_all_videos : Bool) LOGGER.debug("fetch_channel: #{ucid}") LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}") - namespaces = { - "yt" => "http://www.youtube.com/xml/schemas/2015", - "media" => "http://search.yahoo.com/mrss/", - "default" => "http://www.w3.org/2005/Atom", - } - LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed") - rss = XML.parse(rss) + rss = XML.parse_html(rss) - author = rss.xpath_node("//default:feed/default:title", namespaces) + author = rss.xpath_node(%q(//feed/title)) if !author raise InfoException.new("Deleted or invalid channel") end @@ -186,44 +180,29 @@ def fetch_channel(ucid, pull_all_videos : Bool) LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") - channel = InvidiousChannel.new({ - id: ucid, - author: author, - updated: Time.utc, - deleted: false, - subscribed: nil, - }) + page = 1 LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") - videos, continuation = IV::Channel::Tabs.get_videos(channel) + initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) + videos = extract_videos(initial_data, author, ucid) LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") - rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry| - video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content - title = entry.xpath_node("default:title", namespaces).not_nil!.content + rss.xpath_nodes("//feed/entry").each do |entry| + video_id = entry.xpath_node("videoid").not_nil!.content + title = entry.xpath_node("title").not_nil!.content + published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) + author = entry.xpath_node("author/name").not_nil!.content + ucid = entry.xpath_node("channelid").not_nil!.content + views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64? + views ||= 0_i64 - published = Time.parse_rfc3339( - entry.xpath_node("default:published", namespaces).not_nil!.content - ) - updated = Time.parse_rfc3339( - entry.xpath_node("default:updated", namespaces).not_nil!.content - ) - - author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content - ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content - - views = entry - .xpath_node("media:group/media:community/media:statistics", namespaces) - .try &.["views"]?.try &.to_i64? || 0_i64 - - channel_video = videos - .select(SearchVideo) - .select(&.id.== video_id)[0]? + channel_video = videos.select { |video| video.id == video_id }[0]? length_seconds = channel_video.try &.length_seconds length_seconds ||= 0 - live_now = channel_video.try &.badges.live_now? + live_now = channel_video.try &.live_now live_now ||= false premiere_timestamp = channel_video.try &.premiere_timestamp @@ -232,7 +211,7 @@ def fetch_channel(ucid, pull_all_videos : Bool) id: video_id, title: title, published: published, - updated: updated, + updated: Time.utc, ucid: ucid, author: author, length_seconds: length_seconds, @@ -247,50 +226,62 @@ def fetch_channel(ucid, pull_all_videos : Bool) # meaning the above timestamp is always null was_insert = Invidious::Database::ChannelVideos.insert(video) - if was_insert + if preferences.notifications && was_insert LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") - NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video)) + Invidious::Database::Users.add_notification(video) else LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") end end if pull_all_videos - loop do - # Keep fetching videos using the continuation token retrieved earlier - videos, continuation = IV::Channel::Tabs.get_videos(channel, continuation: continuation) + page += 1 - count = 0 - videos.select(SearchVideo).each do |video| - count += 1 - video = ChannelVideo.new({ - id: video.id, - title: video.title, - published: video.published, - updated: Time.utc, - ucid: video.ucid, - author: video.author, - length_seconds: video.length_seconds, - live_now: video.badges.live_now?, - premiere_timestamp: video.premiere_timestamp, - views: video.views, - }) + ids = [] of String + + loop do + initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) + videos = extract_videos(initial_data, author, ucid) + + count = videos.size + videos = videos.map { |video| ChannelVideo.new({ + id: video.id, + title: video.title, + published: video.published, + updated: Time.utc, + ucid: video.ucid, + author: video.author, + length_seconds: video.length_seconds, + live_now: video.live_now, + premiere_timestamp: video.premiere_timestamp, + views: video.views, + }) } + + videos.each do |video| + ids << video.id # We are notified of Red videos elsewhere (PubSub), which includes a correct published date, # so since they don't provide a published date here we can safely ignore them. if Time.utc - video.published > 1.minute was_insert = Invidious::Database::ChannelVideos.insert(video) - if was_insert - NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video)) + if preferences.notifications && was_insert + Invidious::Database::Users.add_notification(video) end end end break if count < 25 - sleep 500.milliseconds + page += 1 end end - channel.updated = Time.utc + channel = InvidiousChannel.new({ + id: ucid, + author: author, + updated: Time.utc, + deleted: false, + subscribed: nil, + }) + return channel end diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 49ffd990..4701ecbd 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -1,76 +1,63 @@ -private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000} - # TODO: Add "sort_by" -def fetch_channel_community(ucid, cursor, locale, format, thin_mode) - if cursor.nil? - # Egljb21tdW5pdHk%3D is the protobuf object to load "community" - initial_data = YoutubeAPI.browse(ucid, params: "Egljb21tdW5pdHk%3D") +def fetch_channel_community(ucid, continuation, locale, format, thin_mode) + response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") + if response.status_code != 200 + response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") + end - items = [] of JSON::Any - extract_items(initial_data) do |item| - items << item + if response.status_code != 200 + raise InfoException.new("This channel does not exist.") + end + + ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] + + if !continuation || continuation.empty? + initial_data = extract_initial_data(response.body) + body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]? + + if !body + raise InfoException.new("Could not extract community tab.") end + + body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] else - continuation = produce_channel_community_continuation(ucid, cursor) - initial_data = YoutubeAPI.browse(continuation: continuation) + continuation = produce_channel_community_continuation(ucid, continuation) - container = initial_data.dig?("continuationContents", "itemSectionContinuation", "contents") + headers = HTTP::Headers.new + headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"] - raise InfoException.new("Can't extract community data") if container.nil? + session_token = response.body.match(/"XSRF_TOKEN":"(?[^"]+)"/).try &.["session_token"]? || "" + post_req = { + session_token: session_token, + } - items = container.as_a + response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req) + body = JSON.parse(response.body) + + body = body["response"]["continuationContents"]["itemSectionContinuation"]? || + body["response"]["continuationContents"]["backstageCommentsContinuation"]? + + if !body + raise InfoException.new("Could not extract continuation.") + end end - return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode) -end + continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s + posts = body["contents"].as_a -def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode) - object = { - "2:string" => "community", - "25:embedded" => { - "22:string" => post_id.to_s, - }, - "45:embedded" => { - "2:varint" => 1_i64, - "3:varint" => 1_i64, - }, - } - params = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - initial_data = YoutubeAPI.browse(ucid, params: params) - - items = [] of JSON::Any - extract_items(initial_data) do |item| - items << item - end - - return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode, is_single_post: true) -end - -def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_single_post : Bool = false) - if message = items[0]["messageRenderer"]? + if message = posts[0]["messageRenderer"]? error_message = (message["text"]["simpleText"]? || message["text"]["runs"]?.try &.[0]?.try &.["text"]?) .try &.as_s || "" - if error_message == "This channel does not exist." - raise NotFoundException.new(error_message) - else - raise InfoException.new(error_message) - end + raise InfoException.new(error_message) end response = JSON.build do |json| json.object do json.field "authorId", ucid - if is_single_post - json.field "singlePost", true - end json.field "comments" do json.array do - items.each do |post| + posts.each do |post| comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? || post["backstageCommentsContinuation"]? @@ -80,7 +67,7 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing next if !post content_html = post["contentText"]?.try { |t| parse_content(t) } || "" - author = post["authorText"]["runs"]?.try &.[0]?.try &.["text"]? || "" + author = post["authorText"]?.try &.["simpleText"]? || "" json.object do json.field "author", author @@ -119,8 +106,6 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"] .try &.as_s.gsub(/\D/, "").to_i? || 0 - reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0") - json.field "content", html_to_content(content_html) json.field "contentHtml", content_html @@ -128,19 +113,54 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) json.field "likeCount", like_count - json.field "replyCount", reply_count json.field "commentId", post["postId"]? || post["commentId"]? || "" json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid if attachment = post["backstageAttachment"]? json.field "attachment" do - case attachment.as_h - when .has_key?("videoRenderer") - parse_item(attachment) - .as(SearchVideo) - .to_json(locale, json) - when .has_key?("backstageImageRenderer") - json.object do + json.object do + case attachment.as_h + when .has_key?("videoRenderer") + attachment = attachment["videoRenderer"] + json.field "type", "video" + + if !attachment["videoId"]? + error_message = (attachment["title"]["simpleText"]? || + attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?) + + json.field "error", error_message + else + video_id = attachment["videoId"].as_s + + video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]? + json.field "title", video_title + json.field "videoId", video_id + json.field "videoThumbnails" do + generate_thumbnails(json, video_id) + end + + json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) + + author_info = attachment["ownerText"]["runs"][0].as_h + + json.field "author", author_info["text"].as_s + json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"] + + # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers" + # TODO: json.field "authorVerified", "ownerBadges" + + published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s) + + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + + view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 + + json.field "viewCount", view_count + json.field "viewCountText", translate_count(locale, "generic_views_count", view_count, NumberFormatting::Short) + end + when .has_key?("backstageImageRenderer") attachment = attachment["backstageImageRenderer"] json.field "type", "image" @@ -152,7 +172,9 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing aspect_ratio = (width.to_f / height.to_f) url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - IMAGE_QUALITIES.each do |quality| + qualities = {320, 560, 640, 1280, 2000} + + qualities.each do |quality| json.object do json.field "url", url.gsub(/=s\d+/, "=s#{quality}") json.field "width", quality @@ -161,90 +183,11 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing end end end - end - when .has_key?("pollRenderer") - json.object do - attachment = attachment["pollRenderer"] - json.field "type", "poll" - json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) - json.field "choices" do - json.array do - attachment["choices"].as_a.each do |choice| - json.object do - json.field "text", choice.dig("text", "runs", 0, "text").as_s - # A choice can have an image associated with it. - # Ex post: https://www.youtube.com/post/UgkxD4XavXUD4NQiddJXXdohbwOwcVqrH9Re - if choice["image"]? - thumbnail = choice["image"]["thumbnails"][0].as_h - width = thumbnail["width"].as_i - height = thumbnail["height"].as_i - aspect_ratio = (width.to_f / height.to_f) - url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - json.field "image" do - json.array do - IMAGE_QUALITIES.each do |quality| - json.object do - json.field "url", url.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", (quality / aspect_ratio).ceil.to_i - end - end - end - end - end - end - end - end - end - end - when .has_key?("postMultiImageRenderer") - json.object do - attachment = attachment["postMultiImageRenderer"] - json.field "type", "multiImage" - json.field "images" do - json.array do - attachment["images"].as_a.each do |image| - json.array do - thumbnail = image["backstageImageRenderer"]["image"]["thumbnails"][0].as_h - width = thumbnail["width"].as_i - height = thumbnail["height"].as_i - aspect_ratio = (width.to_f / height.to_f) - url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - - IMAGE_QUALITIES.each do |quality| - json.object do - json.field "url", url.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", (quality / aspect_ratio).ceil.to_i - end - end - end - end - end - end - end - when .has_key?("playlistRenderer") - parse_item(attachment) - .as(SearchPlaylist) - .to_json(locale, json) - when .has_key?("quizRenderer") - json.object do - attachment = attachment["quizRenderer"] - json.field "type", "quiz" - json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) - json.field "choices" do - json.array do - attachment["choices"].as_a.each do |choice| - json.object do - json.field "text", choice.dig("text", "runs", 0, "text").as_s - json.field "isCorrect", choice["isCorrect"].as_bool - end - end - end - end - end - else - json.object do + # TODO + # when .has_key?("pollRenderer") + # attachment = attachment["pollRenderer"] + # json.field "type", "poll" + else json.field "type", "unknown" json.field "error", "Unrecognized attachment type." end @@ -269,17 +212,17 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing end end end - if !is_single_post - if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") - json.field "continuation", extract_channel_community_cursor(cont.as_s) - end + + if body["continuations"]? + continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s + json.field "continuation", extract_channel_community_cursor(continuation) end end end if format == "html" response = JSON.parse(response) - content_html = IV::Frontend::Comments.template_youtube(response, locale, thin_mode) + content_html = template_youtube_comments(response, locale, thin_mode) response = JSON.build do |json| json.object do diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index 9b45d0c8..d5628f6a 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -1,55 +1,93 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) if continuation - initial_data = YoutubeAPI.browse(continuation) - else - params = - case sort_by - when "last", "last_added" - # Equivalent to "&sort=lad" - # {"2:string": "playlists", "3:varint": 4, "4:varint": 1, "6:varint": 1} - "EglwbGF5bGlzdHMYBCABMAE%3D" - when "oldest", "oldest_created" - # formerly "&sort=da" - # Not available anymore :c or maybe ?? - # {"2:string": "playlists", "3:varint": 2, "4:varint": 1, "6:varint": 1} - "EglwbGF5bGlzdHMYAiABMAE%3D" - # {"2:string": "playlists", "3:varint": 1, "4:varint": 1, "6:varint": 1} - # "EglwbGF5bGlzdHMYASABMAE%3D" - when "newest", "newest_created" - # Formerly "&sort=dd" - # {"2:string": "playlists", "3:varint": 3, "4:varint": 1, "6:varint": 1} - "EglwbGF5bGlzdHMYAyABMAE%3D" - end + response_json = YoutubeAPI.browse(continuation) + continuation_items = response_json["onResponseReceivedActions"]? + .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - initial_data = YoutubeAPI.browse(ucid, params: params || "") + return [] of SearchItem, nil if !continuation_items + + items = [] of SearchItem + continuation_items.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| + extract_item(item, author, ucid).try { |t| items << t } + } + + continuation = continuation_items.as_a.last["continuationItemRenderer"]? + .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s + else + url = "/channel/#{ucid}/playlists?flow=list&view=1" + + case sort_by + when "last", "last_added" + # + when "oldest", "oldest_created" + url += "&sort=da" + when "newest", "newest_created" + url += "&sort=dd" + else nil # Ignore + end + + response = YT_POOL.client &.get(url) + initial_data = extract_initial_data(response.body) + return [] of SearchItem, nil if !initial_data + + items = extract_items(initial_data, author, ucid) + continuation = response.body.match(/"token":"(?[^"]+)"/).try &.["continuation"]? end - return extract_items(initial_data, author, ucid) + return items, continuation end -def fetch_channel_podcasts(ucid, author, continuation) - if continuation - initial_data = YoutubeAPI.browse(continuation) - else - initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA") - end - return extract_items(initial_data, author, ucid) -end +# ## NOTE: DEPRECATED +# Reason -> Unstable +# The Protobuf object must be provided with an id of the last playlist from the current "page" +# in order to fetch the next one accurately +# (if the id isn't included, entries shift around erratically between pages, +# leading to repetitions and skip overs) +# +# Since it's impossible to produce the appropriate Protobuf without an id being provided by the user, +# it's better to stick to continuation tokens provided by the first request and onward +def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false) + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:base64" => { + "2:string" => "playlists", + "6:varint" => 2_i64, + "7:varint" => 1_i64, + "12:varint" => 1_i64, + "13:string" => "", + "23:varint" => 0_i64, + }, + }, + } -def fetch_channel_releases(ucid, author, continuation) - if continuation - initial_data = YoutubeAPI.browse(continuation) - else - initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA") + if cursor + cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated + object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor end - return extract_items(initial_data, author, ucid) -end -def fetch_channel_courses(ucid, author, continuation) - if continuation - initial_data = YoutubeAPI.browse(continuation) + if auto_generated + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64 else - initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D") + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64 + case sort + when "oldest", "oldest_created" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64 + when "newest", "newest_created" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64 + when "last", "last_added" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64 + else nil # Ignore + end end - return extract_items(initial_data, author, ucid) + + object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) + object["80226972:embedded"].delete("3:base64") + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" end diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 96400f47..48453bb7 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -1,192 +1,89 @@ -module Invidious::Channel::Tabs - extend self - - # ------------------- - # Regular videos - # ------------------- - - # Wrapper for AboutChannel, as we still need to call get_videos with - # an author name and ucid directly (e.g in RSS feeds). - # TODO: figure out how to get rid of that - def get_videos(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") - return get_videos( - channel.author, channel.ucid, - continuation: continuation, sort_by: sort_by - ) - end - - # Wrapper for InvidiousChannel, as we still need to call get_videos with - # an author name and ucid directly (e.g in RSS feeds). - # TODO: figure out how to get rid of that - def get_videos(channel : InvidiousChannel, *, continuation : String? = nil, sort_by = "newest") - return get_videos( - channel.author, channel.id, - continuation: continuation, sort_by: sort_by - ) - end - - def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest") - continuation ||= make_initial_videos_ctoken(ucid, sort_by) - initial_data = YoutubeAPI.browse(continuation: continuation) - - return extract_items(initial_data, author, ucid) - end - - def get_60_videos(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") - if continuation.nil? - # Fetch the first "page" of video - items, next_continuation = get_videos(channel, sort_by: sort_by) - else - # Fetch a "page" of videos using the given continuation token - items, next_continuation = get_videos(channel, continuation: continuation) - end - - # If there is more to load, then load a second "page" - # and replace the previous continuation token - if !next_continuation.nil? - items_2, next_continuation = get_videos(channel, continuation: next_continuation) - items.concat items_2 - end - - return items, next_continuation - end - - # ------------------- - # Shorts - # ------------------- - - def get_shorts(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") - continuation ||= make_initial_shorts_ctoken(channel.ucid, sort_by) - initial_data = YoutubeAPI.browse(continuation: continuation) - - return extract_items(initial_data, channel.author, channel.ucid) - end - - # ------------------- - # Livestreams - # ------------------- - - def get_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") - continuation ||= make_initial_livestreams_ctoken(channel.ucid, sort_by) - initial_data = YoutubeAPI.browse(continuation: continuation) - - return extract_items(initial_data, channel.author, channel.ucid) - end - - def get_60_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") - if continuation.nil? - # Fetch the first "page" of stream - items, next_continuation = get_livestreams(channel, sort_by: sort_by) - else - # Fetch a "page" of streams using the given continuation token - items, next_continuation = get_livestreams(channel, continuation: continuation) - end - - # If there is more to load, then load a second "page" - # and replace the previous continuation token - if !next_continuation.nil? - items_2, next_continuation = get_livestreams(channel, continuation: next_continuation) - items.concat items_2 - end - - return items, next_continuation - end - - # ------------------- - # C-tokens - # ------------------- - - private def sort_options_videos_short(sort_by : String) - case sort_by - when "newest" then return 4_i64 - when "popular" then return 2_i64 - when "oldest" then return 5_i64 - else return 4_i64 # Fallback to "newest" - end - end - - # Generate the initial "continuation token" to get the first page of the - # "videos" tab. The following page requires the ctoken provided in that - # first page, and so on. - private def make_initial_videos_ctoken(ucid : String, sort_by = "newest") - object = { - "15:embedded" => { - "2:embedded" => { - "1:string" => "00000000-0000-0000-0000-000000000000", - }, - "4:varint" => sort_options_videos_short(sort_by), +def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:base64" => { + "2:string" => "videos", + "6:varint" => 2_i64, + "7:varint" => 1_i64, + "12:varint" => 1_i64, + "13:string" => "", + "23:varint" => 0_i64, }, - } + }, + } - return channel_ctoken_wrap(ucid, object) - end - - # Generate the initial "continuation token" to get the first page of the - # "shorts" tab. The following page requires the ctoken provided in that - # first page, and so on. - private def make_initial_shorts_ctoken(ucid : String, sort_by = "newest") - object = { - "10:embedded" => { - "2:embedded" => { - "1:string" => "00000000-0000-0000-0000-000000000000", - }, - "4:varint" => sort_options_videos_short(sort_by), - }, - } - - return channel_ctoken_wrap(ucid, object) - end - - # Generate the initial "continuation token" to get the first page of the - # "livestreams" tab. The following page requires the ctoken provided in that - # first page, and so on. - private def make_initial_livestreams_ctoken(ucid : String, sort_by = "newest") - sort_by_numerical = - case sort_by - when "newest" then 12_i64 - when "popular" then 14_i64 - when "oldest" then 13_i64 - else 12_i64 # Fallback to "newest" + if !v2 + if auto_generated + seed = Time.unix(1525757349) + until seed >= Time.utc + seed += 1.month end + timestamp = seed - (page - 1).months - object = { - "14:embedded" => { - "2:embedded" => { - "1:string" => "00000000-0000-0000-0000-000000000000", - }, - "5:varint" => sort_by_numerical, - }, - } + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x36_i64 + object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{timestamp.to_unix}" + else + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 + object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{page}" + end + else + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 - return channel_ctoken_wrap(ucid, object) + object["80226972:embedded"]["3:base64"].as(Hash)["61:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ + "1:string" => Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ + "1:varint" => 30_i64 * (page - 1), + }))), + }))) end - # The protobuf structure common between videos/shorts/livestreams - private def channel_ctoken_wrap(ucid : String, object) - object_inner = { - "110:embedded" => { - "3:embedded" => object, - }, - } - - object_inner_encoded = object_inner - .try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:string" => object_inner_encoded, - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation + case sort_by + when "newest" + when "popular" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64 + when "oldest" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64 + else nil # Ignore end + + object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) + object["80226972:embedded"].delete("3:base64") + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end + +def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest") + continuation = produce_channel_videos_continuation(ucid, page, + auto_generated: auto_generated, sort_by: sort_by, v2: true) + + return YoutubeAPI.browse(continuation) +end + +def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") + videos = [] of SearchVideo + + 2.times do |i| + initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) + videos.concat extract_videos(initial_data, author, ucid) + end + + return videos.size, videos +end + +def get_latest_videos(ucid) + initial_data = get_channel_videos_response(ucid) + author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s + + return extract_videos(initial_data, author, ucid) +end + +# Used in bypass_captcha_job.cr +def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) + continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) + return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" end diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr new file mode 100644 index 00000000..ab9fcc8b --- /dev/null +++ b/src/invidious/comments.cr @@ -0,0 +1,653 @@ +class RedditThing + include JSON::Serializable + + property kind : String + property data : RedditComment | RedditLink | RedditMore | RedditListing +end + +class RedditComment + include JSON::Serializable + + property author : String + property body_html : String + property replies : RedditThing | String + property score : Int32 + property depth : Int32 + property permalink : String + + @[JSON::Field(converter: RedditComment::TimeConverter)] + property created_utc : Time + + module TimeConverter + def self.from_json(value : JSON::PullParser) : Time + Time.unix(value.read_float.to_i) + end + + def self.to_json(value : Time, json : JSON::Builder) + json.number(value.to_unix) + end + end +end + +struct RedditLink + include JSON::Serializable + + property author : String + property score : Int32 + property subreddit : String + property num_comments : Int32 + property id : String + property permalink : String + property title : String +end + +struct RedditMore + include JSON::Serializable + + property children : Array(String) + property count : Int32 + property depth : Int32 +end + +class RedditListing + include JSON::Serializable + + property children : Array(RedditThing) + property modhash : String +end + +def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_by = "top") + case cursor + when nil, "" + ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by) + when .starts_with? "ADSJ" + ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by) + else + ctoken = cursor + end + + client_config = YoutubeAPI::ClientConfig.new(region: region) + response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) + contents = nil + + if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? + header = nil + on_response_received_endpoints.as_a.each do |item| + if item["reloadContinuationItemsCommand"]? + case item["reloadContinuationItemsCommand"]["slot"] + when "RELOAD_CONTINUATION_SLOT_HEADER" + header = item["reloadContinuationItemsCommand"]["continuationItems"][0] + when "RELOAD_CONTINUATION_SLOT_BODY" + # continuationItems is nil when video has no comments + contents = item["reloadContinuationItemsCommand"]["continuationItems"]? + end + elsif item["appendContinuationItemsAction"]? + contents = item["appendContinuationItemsAction"]["continuationItems"] + end + end + elsif response["continuationContents"]? + response = response["continuationContents"] + if response["commentRepliesContinuation"]? + body = response["commentRepliesContinuation"] + else + body = response["itemSectionContinuation"] + end + contents = body["contents"]? + header = body["header"]? + else + raise InfoException.new("Could not fetch comments") + end + + if !contents + if format == "json" + return {"comments" => [] of String}.to_json + else + return {"contentHtml" => "", "commentCount" => 0}.to_json + end + end + + continuation_item_renderer = nil + contents.as_a.reject! do |item| + if item["continuationItemRenderer"]? + continuation_item_renderer = item["continuationItemRenderer"] + true + end + end + + response = JSON.build do |json| + json.object do + if header + count_text = header["commentsHeaderRenderer"]["countText"] + comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s.gsub(/\D/, "").to_i? || 0 + json.field "commentCount", comment_count + end + + json.field "videoId", id + + json.field "comments" do + json.array do + contents.as_a.each do |node| + json.object do + if node["commentThreadRenderer"]? + node = node["commentThreadRenderer"] + end + + if node["replies"]? + node_replies = node["replies"]["commentRepliesRenderer"] + end + + if node["comment"]? + node_comment = node["comment"]["commentRenderer"] + else + node_comment = node["commentRenderer"] + end + + content_html = node_comment["contentText"]?.try { |t| parse_content(t) } || "" + author = node_comment["authorText"]?.try &.["simpleText"]? || "" + + json.field "author", author + json.field "authorThumbnails" do + json.array do + node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| + json.object do + json.field "url", thumbnail["url"] + json.field "width", thumbnail["width"] + json.field "height", thumbnail["height"] + end + end + end + end + + if node_comment["authorEndpoint"]? + json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] + else + json.field "authorId", "" + json.field "authorUrl", "" + end + + published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s + published = decode_date(published_text.rchop(" (edited)")) + + if published_text.includes?(" (edited)") + json.field "isEdited", true + else + json.field "isEdited", false + end + + json.field "content", html_to_content(content_html) + json.field "contentHtml", content_html + + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + + comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"] + + json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i + json.field "commentId", node_comment["commentId"] + json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] + + if comment_action_buttons_renderer["creatorHeart"]? + hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"] + json.field "creatorHeart" do + json.object do + json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"] + json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"] + end + end + end + + if node_replies && !response["commentRepliesContinuation"]? + if node_replies["moreText"]? + reply_count = (node_replies["moreText"]["simpleText"]? || node_replies["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s.gsub(/\D/, "").to_i? || 1 + elsif node_replies["viewReplies"]? + reply_count = node_replies["viewReplies"]["buttonRenderer"]["text"]?.try &.["runs"][1]?.try &.["text"]?.try &.as_s.to_i? || 1 + else + reply_count = 1 + end + + if node_replies["continuations"]? + continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s + elsif node_replies["contents"]? + continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s + end + continuation ||= "" + + json.field "replies" do + json.object do + json.field "replyCount", reply_count + json.field "continuation", continuation + end + end + end + end + end + end + end + + if continuation_item_renderer + if continuation_item_renderer["continuationEndpoint"]? + continuation_endpoint = continuation_item_renderer["continuationEndpoint"] + elsif continuation_item_renderer["button"]? + continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"] + end + if continuation_endpoint + json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s + end + end + end + end + + if format == "html" + response = JSON.parse(response) + content_html = template_youtube_comments(response, locale, thin_mode) + + response = JSON.build do |json| + json.object do + json.field "contentHtml", content_html + + if response["commentCount"]? + json.field "commentCount", response["commentCount"] + else + json.field "commentCount", 0 + end + end + end + end + + return response +end + +def fetch_reddit_comments(id, sort_by = "confidence") + client = make_client(REDDIT_URL) + headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} + + # TODO: Use something like #479 for a static list of instances to use here + query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"}) + search_results = client.get("/search.json?#{query}", headers) + + if search_results.status_code == 200 + search_results = RedditThing.from_json(search_results.body) + + # For videos that have more than one thread, choose the one with the highest score + threads = search_results.data.as(RedditListing).children + thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink)) + result = thread.try do |t| + body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body + Array(RedditThing).from_json(body) + end + result ||= [] of RedditThing + elsif search_results.status_code == 302 + # Previously, if there was only one result then the API would redirect to that result. + # Now, it appears it will still return a listing so this section is likely unnecessary. + + result = client.get(search_results.headers["Location"], headers).body + result = Array(RedditThing).from_json(result) + + thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) + else + raise InfoException.new("Could not fetch comments") + end + + client.close + + comments = result[1]?.try(&.data.as(RedditListing).children) + comments ||= [] of RedditThing + return comments, thread +end + +def template_youtube_comments(comments, locale, thin_mode, is_replies = false) + String.build do |html| + root = comments["comments"].as_a + root.each do |child| + if child["replies"]? + replies_count_text = translate_count(locale, + "comments_view_x_replies", + child["replies"]["replyCount"].as_i64 || 0, + NumberFormatting::Separator + ) + + replies_html = <<-END_HTML +
+
+ +
+ END_HTML + end + + if !thin_mode + author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}" + else + author_thumbnail = "" + end + + author_name = HTML.escape(child["author"].as_s) + + html << <<-END_HTML +
+
+ +
+
+

+ + #{author_name} + +

#{child["contentHtml"]}

+ END_HTML + + if child["attachment"]? + attachment = child["attachment"] + + case attachment["type"] + when "image" + attachment = attachment["imageThumbnails"][1] + + html << <<-END_HTML +
+
+ +
+
+ END_HTML + when "video" + html << <<-END_HTML +
+
+
+ END_HTML + + if attachment["error"]? + html << <<-END_HTML +

#{attachment["error"]}

+ END_HTML + else + html << <<-END_HTML + + END_HTML + end + + html << <<-END_HTML +
+
+
+ END_HTML + else nil # Ignore + end + end + + html << <<-END_HTML + #{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} + | + END_HTML + + if comments["videoId"]? + html << <<-END_HTML + [YT] + | + END_HTML + elsif comments["authorId"]? + html << <<-END_HTML + [YT] + | + END_HTML + end + + html << <<-END_HTML + #{number_with_separator(child["likeCount"])} + END_HTML + + if child["creatorHeart"]? + if !thin_mode + creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}" + else + creator_thumbnail = "" + end + + html << <<-END_HTML + +
+ +
+
+
+
+
+ END_HTML + end + + html << <<-END_HTML +

+ #{replies_html} +
+
+ END_HTML + end + + if comments["continuation"]? + html << <<-END_HTML + + END_HTML + end + end +end + +def template_reddit_comments(root, locale) + String.build do |html| + root.each do |child| + if child.data.is_a?(RedditComment) + child = child.data.as(RedditComment) + body_html = HTML.unescape(child.body_html) + + replies_html = "" + if child.replies.is_a?(RedditThing) + replies = child.replies.as(RedditThing) + replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale) + end + + if child.depth > 0 + html << <<-END_HTML +
+
+
+
+ END_HTML + else + html << <<-END_HTML +
+
+ END_HTML + end + + html << <<-END_HTML +

+ [ - ] + #{child.author} + #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)} + #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))} + #{translate(locale, "permalink")} +

+
+ #{body_html} + #{replies_html} +
+
+
+ END_HTML + end + end + end +end + +def replace_links(html) + html = XML.parse_html(html) + + html.xpath_nodes(%q(//a)).each do |anchor| + url = URI.parse(anchor["href"]) + + if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") + if url.host.try &.ends_with? "youtu.be" + url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" + else + if url.path == "/redirect" + params = HTTP::Params.parse(url.query.not_nil!) + anchor["href"] = params["q"]? + else + anchor["href"] = url.request_target + end + end + elsif url.to_s == "#" + begin + length_seconds = decode_length_seconds(anchor.content) + rescue ex + length_seconds = decode_time(anchor.content) + end + + if length_seconds > 0 + anchor["href"] = "javascript:void(0)" + anchor["onclick"] = "player.currentTime(#{length_seconds})" + else + anchor["href"] = url.request_target + end + end + end + + html = html.xpath_node(%q(//body)).not_nil! + if node = html.xpath_node(%q(./p)) + html = node + end + + return html.to_xml(options: XML::SaveOptions::NO_DECL) +end + +def fill_links(html, scheme, host) + html = XML.parse_html(html) + + html.xpath_nodes("//a").each do |match| + url = URI.parse(match["href"]) + # Reddit links don't have host + if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#" + url.scheme = scheme + url.host = host + match["href"] = url + end + end + + if host == "www.youtube.com" + html = html.xpath_node(%q(//body/p)).not_nil! + end + + return html.to_xml(options: XML::SaveOptions::NO_DECL) +end + +def parse_content(content : JSON::Any) : String + content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || + content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s.gsub("\n", "
") } || "" +end + +def content_to_comment_html(content) + comment_html = content.map do |run| + text = HTML.escape(run["text"].as_s) + + if run["bold"]? + text = "#{text}" + end + + if run["italics"]? + text = "#{text}" + end + + if run["navigationEndpoint"]? + if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s + url = URI.parse(url) + + if url.host == "youtu.be" + url = "/watch?v=#{url.request_target.lstrip('/')}" + elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") + if url.path == "/redirect" + # Sometimes, links can be corrupted (why?) so make sure to fallback + # nicely. See https://github.com/iv-org/invidious/issues/2682 + url = HTTP::Params.parse(url.query.not_nil!)["q"]? || "" + else + url = url.request_target + end + end + + text = %(#{text}) + elsif watch_endpoint = run["navigationEndpoint"]["watchEndpoint"]? + length_seconds = watch_endpoint["startTimeSeconds"]? + video_id = watch_endpoint["videoId"].as_s + + if length_seconds && length_seconds.as_i > 0 + text = %(#{text}) + else + text = %(#{text}) + end + elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s + text = %(#{text}) + end + end + + text + end.join("").delete('\ufeff') + + return comment_html +end + +def produce_comment_continuation(video_id, cursor = "", sort_by = "top") + object = { + "2:embedded" => { + "2:string" => video_id, + "25:varint" => 0_i64, + "28:varint" => 1_i64, + "36:embedded" => { + "5:varint" => -1_i64, + "8:varint" => 0_i64, + }, + "40:embedded" => { + "1:varint" => 4_i64, + "3:string" => "https://www.youtube.com", + "4:string" => "", + }, + }, + "3:varint" => 6_i64, + "6:embedded" => { + "1:string" => cursor, + "4:embedded" => { + "4:string" => video_id, + "6:varint" => 0_i64, + }, + "5:varint" => 20_i64, + }, + } + + case sort_by + when "top" + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 + when "new", "newest" + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64 + else # top + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 + end + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end diff --git a/src/invidious/comments/content.cr b/src/invidious/comments/content.cr deleted file mode 100644 index 1f55bfe6..00000000 --- a/src/invidious/comments/content.cr +++ /dev/null @@ -1,89 +0,0 @@ -def text_to_parsed_content(text : String) : JSON::Any - nodes = [] of JSON::Any - # For each line convert line to array of nodes - text.split('\n').each do |line| - # In first case line is just a simple node before - # check patterns inside line - # { 'text': line } - current_nodes = [] of JSON::Any - initial_node = {"text" => line} - current_nodes << (JSON.parse(initial_node.to_json)) - - # For each match with url pattern, get last node and preserve - # last node before create new node with url information - # { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } } - line.scan(/https?:\/\/[^ ]*/).each do |url_match| - # Retrieve last node and update node without match - last_node = current_nodes[-1].as_h - splitted_last_node = last_node["text"].as_s.split(url_match[0]) - last_node["text"] = JSON.parse(splitted_last_node[0].to_json) - current_nodes[-1] = JSON.parse(last_node.to_json) - # Create new node with match and navigation infos - current_node = {"text" => url_match[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => url_match[0]}}} - current_nodes << (JSON.parse(current_node.to_json)) - # If text remain after match create new simple node with text after match - after_node = {"text" => splitted_last_node.size > 1 ? splitted_last_node[1] : ""} - current_nodes << (JSON.parse(after_node.to_json)) - end - - # After processing of matches inside line - # Add \n at end of last node for preserve carriage return - last_node = current_nodes[-1].as_h - last_node["text"] = JSON.parse("#{last_node["text"]}\n".to_json) - current_nodes[-1] = JSON.parse(last_node.to_json) - - # Finally add final nodes to nodes returned - current_nodes.each do |node| - nodes << (node) - end - end - return JSON.parse({"runs" => nodes}.to_json) -end - -def parse_content(content : JSON::Any, video_id : String? = "") : String - content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || - content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r, video_id).try &.to_s.gsub("\n", "
") } || "" -end - -def content_to_comment_html(content, video_id : String? = "") - html_array = content.map do |run| - # Sometimes, there is an empty element. - # See: https://github.com/iv-org/invidious/issues/3096 - next if run.as_h.empty? - - text = HTML.escape(run["text"].as_s) - - if navigation_endpoint = run.dig?("navigationEndpoint") - text = parse_link_endpoint(navigation_endpoint, text, video_id) - end - - text = "#{text}" if run["bold"]? - text = "#{text}" if run["strikethrough"]? - text = "#{text}" if run["italics"]? - - # check for custom emojis - if run["emoji"]? - if run["emoji"]["isCustomEmoji"]?.try &.as_bool - if emoji_image = run.dig?("emoji", "image") - emoji_alt = emoji_image.dig?("accessibility", "accessibilityData", "label").try &.as_s || text - emoji_thumb = emoji_image["thumbnails"][0] - text = String.build do |str| - str << %() << emoji_alt << ) - end - else - # Hide deleted channel emoji - text = "" - end - end - end - - text - end - - return html_array.join("").delete('\ufeff') -end diff --git a/src/invidious/comments/links_util.cr b/src/invidious/comments/links_util.cr deleted file mode 100644 index f89b86d3..00000000 --- a/src/invidious/comments/links_util.cr +++ /dev/null @@ -1,76 +0,0 @@ -module Invidious::Comments - extend self - - def replace_links(html) - # Check if the document is empty - # Prevents edge-case bug with Reddit comments, see issue #3115 - if html.nil? || html.empty? - return html - end - - html = XML.parse_html(html) - - html.xpath_nodes(%q(//a)).each do |anchor| - url = URI.parse(anchor["href"]) - - if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") - if url.host.try &.ends_with? "youtu.be" - url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" - else - if url.path == "/redirect" - params = HTTP::Params.parse(url.query.not_nil!) - anchor["href"] = params["q"]? - else - anchor["href"] = url.request_target - end - end - elsif url.to_s == "#" - begin - length_seconds = decode_length_seconds(anchor.content) - rescue ex - length_seconds = decode_time(anchor.content) - end - - if length_seconds > 0 - anchor["href"] = "javascript:void(0)" - anchor["onclick"] = "player.currentTime(#{length_seconds})" - else - anchor["href"] = url.request_target - end - end - end - - html = html.xpath_node(%q(//body)).not_nil! - if node = html.xpath_node(%q(./p)) - html = node - end - - return html.to_xml(options: XML::SaveOptions::NO_DECL) - end - - def fill_links(html, scheme, host) - # Check if the document is empty - # Prevents edge-case bug with Reddit comments, see issue #3115 - if html.nil? || html.empty? - return html - end - - html = XML.parse_html(html) - - html.xpath_nodes("//a").each do |match| - url = URI.parse(match["href"]) - # Reddit links don't have host - if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#" - url.scheme = scheme - url.host = host - match["href"] = url - end - end - - if host == "www.youtube.com" - html = html.xpath_node(%q(//body/p)).not_nil! - end - - return html.to_xml(options: XML::SaveOptions::NO_DECL) - end -end diff --git a/src/invidious/comments/reddit.cr b/src/invidious/comments/reddit.cr deleted file mode 100644 index ba9c19f1..00000000 --- a/src/invidious/comments/reddit.cr +++ /dev/null @@ -1,41 +0,0 @@ -module Invidious::Comments - extend self - - def fetch_reddit(id, sort_by = "confidence") - client = make_client(REDDIT_URL) - headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} - - # TODO: Use something like #479 for a static list of instances to use here - query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"}) - search_results = client.get("/search.json?#{query}", headers) - - if search_results.status_code == 200 - search_results = RedditThing.from_json(search_results.body) - - # For videos that have more than one thread, choose the one with the highest score - threads = search_results.data.as(RedditListing).children - thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink)) - result = thread.try do |t| - body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body - Array(RedditThing).from_json(body) - end - result ||= [] of RedditThing - elsif search_results.status_code == 302 - # Previously, if there was only one result then the API would redirect to that result. - # Now, it appears it will still return a listing so this section is likely unnecessary. - - result = client.get(search_results.headers["Location"], headers).body - result = Array(RedditThing).from_json(result) - - thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) - else - raise NotFoundException.new("Comments not found.") - end - - client.close - - comments = result[1]?.try(&.data.as(RedditListing).children) - comments ||= [] of RedditThing - return comments, thread - end -end diff --git a/src/invidious/comments/reddit_types.cr b/src/invidious/comments/reddit_types.cr deleted file mode 100644 index 796a1183..00000000 --- a/src/invidious/comments/reddit_types.cr +++ /dev/null @@ -1,57 +0,0 @@ -class RedditThing - include JSON::Serializable - - property kind : String - property data : RedditComment | RedditLink | RedditMore | RedditListing -end - -class RedditComment - include JSON::Serializable - - property author : String - property body_html : String - property replies : RedditThing | String - property score : Int32 - property depth : Int32 - property permalink : String - - @[JSON::Field(converter: RedditComment::TimeConverter)] - property created_utc : Time - - module TimeConverter - def self.from_json(value : JSON::PullParser) : Time - Time.unix(value.read_float.to_i) - end - - def self.to_json(value : Time, json : JSON::Builder) - json.number(value.to_unix) - end - end -end - -struct RedditLink - include JSON::Serializable - - property author : String - property score : Int32 - property subreddit : String - property num_comments : Int32 - property id : String - property permalink : String - property title : String -end - -struct RedditMore - include JSON::Serializable - - property children : Array(String) - property count : Int32 - property depth : Int32 -end - -class RedditListing - include JSON::Serializable - - property children : Array(RedditThing) - property modhash : String -end diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr deleted file mode 100644 index 0716fcde..00000000 --- a/src/invidious/comments/youtube.cr +++ /dev/null @@ -1,365 +0,0 @@ -module Invidious::Comments - extend self - - def fetch_youtube(id, cursor, format, locale, thin_mode, region, sort_by = "top") - case cursor - when nil, "" - ctoken = Comments.produce_continuation(id, cursor: "", sort_by: sort_by) - when .starts_with? "ADSJ" - ctoken = Comments.produce_continuation(id, cursor: cursor, sort_by: sort_by) - else - ctoken = cursor - end - - client_config = YoutubeAPI::ClientConfig.new(region: region) - response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) - return parse_youtube(id, response, format, locale, thin_mode, sort_by) - end - - def fetch_community_post_comments(ucid, post_id) - object = { - "2:string" => "community", - "25:embedded" => { - "22:string" => post_id, - }, - "45:embedded" => { - "2:varint" => 1_i64, - "3:varint" => 1_i64, - }, - "53:embedded" => { - "4:embedded" => { - "6:varint" => 0_i64, - "27:varint" => 1_i64, - "29:string" => post_id, - "30:string" => ucid, - }, - "8:string" => "comments-section", - }, - } - - object_parsed = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - - object2 = { - "80226972:embedded" => { - "2:string" => ucid, - "3:string" => object_parsed, - }, - } - - continuation = object2.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - initial_data = YoutubeAPI.browse(continuation: continuation) - return initial_data - end - - def parse_youtube(id, response, format, locale, thin_mode, sort_by = "top", is_post = false) - contents = nil - - if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? - header = nil - on_response_received_endpoints.as_a.each do |item| - if item["reloadContinuationItemsCommand"]? - case item["reloadContinuationItemsCommand"]["slot"] - when "RELOAD_CONTINUATION_SLOT_HEADER" - header = item["reloadContinuationItemsCommand"]["continuationItems"][0] - when "RELOAD_CONTINUATION_SLOT_BODY" - # continuationItems is nil when video has no comments - contents = item["reloadContinuationItemsCommand"]["continuationItems"]? - end - elsif item["appendContinuationItemsAction"]? - contents = item["appendContinuationItemsAction"]["continuationItems"] - end - end - elsif response["continuationContents"]? - response = response["continuationContents"] - if response["commentRepliesContinuation"]? - body = response["commentRepliesContinuation"] - else - body = response["itemSectionContinuation"] - end - contents = body["contents"]? - header = body["header"]? - else - raise NotFoundException.new("Comments not found.") - end - - if !contents - if format == "json" - return {"comments" => [] of String}.to_json - else - return {"contentHtml" => "", "commentCount" => 0}.to_json - end - end - - continuation_item_renderer = nil - contents.as_a.reject! do |item| - if item["continuationItemRenderer"]? - continuation_item_renderer = item["continuationItemRenderer"] - true - end - end - - mutations = response.dig?("frameworkUpdates", "entityBatchUpdate", "mutations").try &.as_a || [] of JSON::Any - - response = JSON.build do |json| - json.object do - if header - count_text = header["commentsHeaderRenderer"]["countText"] - comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i? || 0 - json.field "commentCount", comment_count - end - - if is_post - json.field "postId", id - else - json.field "videoId", id - end - - json.field "comments" do - json.array do - contents.as_a.each do |node| - json.object do - if node["commentThreadRenderer"]? - node = node["commentThreadRenderer"] - end - - if node["replies"]? - node_replies = node["replies"]["commentRepliesRenderer"] - end - - if cvm = node["commentViewModel"]? - # two commentViewModels for inital request - # one commentViewModel when getting a replies to a comment - cvm = cvm["commentViewModel"] if cvm["commentViewModel"]? - - comment_key = cvm["commentKey"] - toolbar_key = cvm["toolbarStateKey"] - comment_mutation = mutations.find { |i| i.dig?("payload", "commentEntityPayload", "key") == comment_key } - toolbar_mutation = mutations.find { |i| i.dig?("entityKey") == toolbar_key } - - if !comment_mutation.nil? && !toolbar_mutation.nil? - # todo parse styleRuns, commandRuns and attachmentRuns for comments - html_content = parse_description(comment_mutation.dig("payload", "commentEntityPayload", "properties", "content"), id) - comment_author = comment_mutation.dig("payload", "commentEntityPayload", "author") - json.field "authorId", comment_author["channelId"].as_s - json.field "authorUrl", "/channel/#{comment_author["channelId"].as_s}" - json.field "author", comment_author["displayName"].as_s - json.field "verified", comment_author["isVerified"].as_bool - json.field "authorThumbnails" do - json.array do - comment_mutation.dig?("payload", "commentEntityPayload", "avatar", "image", "sources").try &.as_a.each do |thumbnail| - json.object do - json.field "url", thumbnail["url"] - json.field "width", thumbnail["width"] - json.field "height", thumbnail["height"] - end - end - end - end - - json.field "authorIsChannelOwner", comment_author["isCreator"].as_bool - json.field "isSponsor", (comment_author["sponsorBadgeUrl"]? != nil) - - if sponsor_badge_url = comment_author["sponsorBadgeUrl"]? - # Sponsor icon thumbnails always have one object and there's only ever the url property in it - json.field "sponsorIconUrl", sponsor_badge_url - end - - comment_toolbar = comment_mutation.dig("payload", "commentEntityPayload", "toolbar") - json.field "likeCount", short_text_to_number(comment_toolbar["likeCountNotliked"].as_s) - reply_count = short_text_to_number(comment_toolbar["replyCount"]?.try &.as_s || "0") - - if heart_state = toolbar_mutation.dig?("payload", "engagementToolbarStateEntityPayload", "heartState") - if heart_state.as_s == "TOOLBAR_HEART_STATE_HEARTED" - json.field "creatorHeart" do - json.object do - json.field "creatorThumbnail", comment_toolbar["creatorThumbnailUrl"].as_s - json.field "creatorName", comment_toolbar["heartActiveTooltip"].as_s.sub("❤ by ", "") - end - end - end - end - - published_text = comment_mutation.dig?("payload", "commentEntityPayload", "properties", "publishedTime").try &.as_s - end - - json.field "isPinned", (cvm.dig?("pinnedText") != nil) - json.field "commentId", cvm["commentId"] - else - if node["comment"]? - node_comment = node["comment"]["commentRenderer"] - else - node_comment = node["commentRenderer"] - end - json.field "commentId", node_comment["commentId"] - html_content = node_comment["contentText"]?.try { |t| parse_content(t, id) } - - json.field "verified", (node_comment["authorCommentBadge"]? != nil) - - json.field "author", node_comment["authorText"]?.try &.["simpleText"]? || "" - json.field "authorThumbnails" do - json.array do - node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| - json.object do - json.field "url", thumbnail["url"] - json.field "width", thumbnail["width"] - json.field "height", thumbnail["height"] - end - end - end - end - - if comment_action_buttons_renderer = node_comment.dig?("actionButtons", "commentActionButtonsRenderer") - json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i - if comment_action_buttons_renderer["creatorHeart"]? - heart_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"] - json.field "creatorHeart" do - json.object do - json.field "creatorThumbnail", heart_data["thumbnails"][-1]["url"] - json.field "creatorName", heart_data["accessibility"]["accessibilityData"]["label"] - end - end - end - end - - if node_comment["authorEndpoint"]? - json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] - else - json.field "authorId", "" - json.field "authorUrl", "" - end - - json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] - json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) - published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s - - json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil) - if node_comment["sponsorCommentBadge"]? - # Sponsor icon thumbnails always have one object and there's only ever the url property in it - json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s - end - - reply_count = node_comment["replyCount"]? - end - - content_html = html_content || "" - json.field "content", html_to_content(content_html) - json.field "contentHtml", content_html - - if published_text != nil - published_text = published_text.to_s - if published_text.includes?(" (edited)") - json.field "isEdited", true - published = decode_date(published_text.rchop(" (edited)")) - else - json.field "isEdited", false - published = decode_date(published_text) - end - - json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - end - - if node_replies && !response["commentRepliesContinuation"]? - if node_replies["continuations"]? - continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s - elsif node_replies["contents"]? - continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s - end - continuation ||= "" - - json.field "replies" do - json.object do - json.field "replyCount", reply_count || 1 - json.field "continuation", continuation - end - end - end - end - end - end - end - - if continuation_item_renderer - if continuation_item_renderer["continuationEndpoint"]? - continuation_endpoint = continuation_item_renderer["continuationEndpoint"] - elsif continuation_item_renderer["button"]? - continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"] - end - if continuation_endpoint - json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s - end - end - end - end - - if format == "html" - response = JSON.parse(response) - content_html = Frontend::Comments.template_youtube(response, locale, thin_mode) - response = JSON.build do |json| - json.object do - json.field "contentHtml", content_html - - if response["commentCount"]? - json.field "commentCount", response["commentCount"] - else - json.field "commentCount", 0 - end - end - end - end - - return response - end - - def produce_continuation(video_id, cursor = "", sort_by = "top") - object = { - "2:embedded" => { - "2:string" => video_id, - "25:varint" => 0_i64, - "28:varint" => 1_i64, - "36:embedded" => { - "5:varint" => -1_i64, - "8:varint" => 0_i64, - }, - "40:embedded" => { - "1:varint" => 4_i64, - "3:string" => "https://www.youtube.com", - "4:string" => "", - }, - }, - "3:varint" => 6_i64, - "6:embedded" => { - "1:string" => cursor, - "4:embedded" => { - "4:string" => video_id, - "6:varint" => 0_i64, - }, - "5:varint" => 20_i64, - }, - } - - case sort_by - when "top" - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 - when "new", "newest" - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64 - else # top - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 - end - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation - end -end diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 4d69854c..ff959300 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -8,19 +8,11 @@ struct DBConfig property dbname : String end -struct SocketBindingConfig - include YAML::Serializable - - property path : String - property permissions : String -end - struct ConfigPreferences include YAML::Serializable property annotations : Bool = false property annotations_subscribed : Bool = false - property preload : Bool = true property autoplay : Bool = false property captions : Array(String) = ["", "", ""] property comments : Array(String) = ["youtube", ""] @@ -32,10 +24,11 @@ struct ConfigPreferences property local : Bool = false property locale : String = "en-US" property watch_history : Bool = true + property notifications : Bool = true property max_results : Int32 = 40 property notifications_only : Bool = false property player_style : String = "invidious" - property quality : String = "dash" + property quality : String = "hd720" property quality_dash : String = "auto" property default_home : String? = "Popular" property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] @@ -56,34 +49,15 @@ struct ConfigPreferences def to_tuple {% begin %} { - {{(@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }).splat}} + {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}} } {% end %} end end -struct HTTPProxyConfig - include YAML::Serializable - - property user : String - property password : String - property host : String - property port : Int32 -end - class Config include YAML::Serializable - class CompanionConfig - include YAML::Serializable - - @[YAML::Field(converter: Preferences::URIConverter)] - property private_url : URI = URI.parse("") - - @[YAML::Field(converter: Preferences::URIConverter)] - property public_url : URI = URI.parse("") - end - # 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). @@ -95,24 +69,20 @@ class Config 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 - # Enables colors in logs. Useful for debugging purposes - property colorize_logs : Bool = false # 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("") + # 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 - - # Jobs config structure. See jobs.cr and jobs/base_job.cr - property jobs = Invidious::Jobs::JobsConfig.new - # 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 = "" + 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) @@ -137,8 +107,6 @@ class Config property hsts : Bool? = true # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' property disable_proxy : Bool? | Array(String)? = false - # Enable the user notifications for all users - property enable_user_notifications : Bool = true # URL to the modified source code to be easily AGPL compliant # Will display in the footer, next to the main source code link @@ -147,41 +115,22 @@ class Config # 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 - - # External signature solver server socket (either a path to a UNIX domain socket or ":") - property signature_server : String? = nil - # 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" - # Path and permissions to make Invidious listen on a UNIX socket instead of a TCP port - property socket_binding : SocketBindingConfig? = nil # 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 - # HTTP Proxy configuration - property http_proxy : HTTPProxyConfig? = nil - - # Use Innertube's transcripts API instead of timedtext for closed captions - property use_innertube_for_captions : Bool = false - - # visitor data ID for Google session - property visitor_data : String? = nil - # poToken for passing bot attestation - property po_token : String? = nil - - # Invidious companion - property invidious_companion : Array(CompanionConfig) = [] of CompanionConfig - - # Invidious companion API key - property invidious_companion_key : String = "" + # 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 - - # Playlist length limit - property playlist_length_limit : Int32 = 500 + # 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 @@ -209,20 +158,20 @@ class Config config = Config.from_yaml(config_yaml) # Update config from env vars (upcased and prefixed with "INVIDIOUS_") - # - # Also checks if any top-level config options are set to "CHANGE_ME!!" - # TODO: Support non-top-level config options such as the ones in DBConfig {% for ivar in Config.instance_vars %} {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} if ENV.has_key?({{env_id}}) + # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}}) env_value = ENV.fetch({{env_id}}) success = false # Use YAML converter if specified {% ann = ivar.annotation(::YAML::Field) %} {% if ann && ann[:converter] %} + puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter) config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0]) + puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}}) success = true # Use regular YAML parser otherwise @@ -233,7 +182,9 @@ class Config {{ivar_types}}.each do |ivar_type| if !success begin + # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type}) config.{{ivar.id}} = ivar_type.from_yaml(env_value) + puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type})) success = true rescue # nop @@ -248,42 +199,8 @@ class Config exit(1) end end - - # Warn when any config attribute is set to "CHANGE_ME!!" - if config.{{ivar.id}} == "CHANGE_ME!!" - puts "Config: The value of '#{ {{ivar.stringify}} }' needs to be changed!!" - exit(1) - end {% end %} - if config.invidious_companion.present? - # invidious_companion and signature_server can't work together - if config.signature_server - puts "Config: You can not run inv_sig_helper and invidious_companion at the same time." - exit(1) - elsif config.invidious_companion_key.empty? - puts "Config: Please configure a key if you are using invidious companion." - exit(1) - elsif config.invidious_companion_key == "CHANGE_ME!!" - puts "Config: The value of 'invidious_companion_key' needs to be changed!!" - exit(1) - elsif config.invidious_companion_key.size != 16 - puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 characters." - exit(1) - end - elsif config.signature_server - puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/companion-installation/") - else - puts("WARNING: Invidious companion is required to view and playback videos. For more information see https://docs.invidious.io/companion-installation/") - end - - # HMAC_key is mandatory - # See: https://github.com/iv-org/invidious/issues/3854 - if config.hmac_key.empty? - puts "Config: 'hmac_key' is required/can't be empty" - exit(1) - end - # Build database_url from db.* if it's not set directly if config.database_url.to_s.empty? if db = config.db @@ -296,25 +213,7 @@ class Config path: db.dbname, ) else - puts "Config: Either database_url or db.* is required" - exit(1) - end - end - - # Check if the socket configuration is valid - if sb = config.socket_binding - if sb.path.ends_with?("/") || File.directory?(sb.path) - puts "Config: The socket path " + sb.path + " must not be a directory!" - exit(1) - end - d = File.dirname(sb.path) - if !File.directory?(d) - puts "Config: Socket directory " + sb.path + " does not exist or is not a directory!" - exit(1) - end - p = sb.permissions.to_i?(base: 8) - if !p || p < 0 || p > 0o777 - puts "Config: Socket permissions must be an octal between 0 and 777!" + puts "Config : Either database_url or db.* is required" exit(1) end end diff --git a/src/invidious/database/migration.cr b/src/invidious/database/migration.cr deleted file mode 100644 index 921d8f38..00000000 --- a/src/invidious/database/migration.cr +++ /dev/null @@ -1,38 +0,0 @@ -abstract class Invidious::Database::Migration - macro inherited - Migrator.migrations << self - end - - @@version : Int64? - - def self.version(version : Int32 | Int64) - @@version = version.to_i64 - end - - getter? completed = false - - def initialize(@db : DB::Database) - end - - abstract def up(conn : DB::Connection) - - def migrate - # migrator already ignores completed migrations - # but this is an extra check to make sure a migration doesn't run twice - return if completed? - - @db.transaction do |txn| - up(txn.connection) - track(txn.connection) - @completed = true - end - end - - def version : Int64 - @@version.not_nil! - end - - private def track(conn : DB::Connection) - conn.exec("INSERT INTO #{Migrator::MIGRATIONS_TABLE} (version) VALUES ($1)", version) - end -end diff --git a/src/invidious/database/migrations/0001_create_channels_table.cr b/src/invidious/database/migrations/0001_create_channels_table.cr deleted file mode 100644 index a1362bcf..00000000 --- a/src/invidious/database/migrations/0001_create_channels_table.cr +++ /dev/null @@ -1,30 +0,0 @@ -module Invidious::Database::Migrations - class CreateChannelsTable < Migration - version 1 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.channels - ( - id text NOT NULL, - author text, - updated timestamp with time zone, - deleted boolean, - subscribed timestamp with time zone, - CONSTRAINT channels_id_key UNIQUE (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.channels TO current_user; - SQL - - conn.exec <<-SQL - CREATE INDEX IF NOT EXISTS channels_id_idx - ON public.channels - USING btree - (id COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/database/migrations/0002_create_videos_table.cr b/src/invidious/database/migrations/0002_create_videos_table.cr deleted file mode 100644 index c2ac84f8..00000000 --- a/src/invidious/database/migrations/0002_create_videos_table.cr +++ /dev/null @@ -1,28 +0,0 @@ -module Invidious::Database::Migrations - class CreateVideosTable < Migration - version 2 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE UNLOGGED TABLE IF NOT EXISTS public.videos - ( - id text NOT NULL, - info text, - updated timestamp with time zone, - CONSTRAINT videos_pkey PRIMARY KEY (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.videos TO current_user; - SQL - - conn.exec <<-SQL - CREATE UNIQUE INDEX IF NOT EXISTS id_idx - ON public.videos - USING btree - (id COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/database/migrations/0003_create_channel_videos_table.cr b/src/invidious/database/migrations/0003_create_channel_videos_table.cr deleted file mode 100644 index c9b62e4c..00000000 --- a/src/invidious/database/migrations/0003_create_channel_videos_table.cr +++ /dev/null @@ -1,35 +0,0 @@ -module Invidious::Database::Migrations - class CreateChannelVideosTable < Migration - version 3 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.channel_videos - ( - id text NOT NULL, - title text, - published timestamp with time zone, - updated timestamp with time zone, - ucid text, - author text, - length_seconds integer, - live_now boolean, - premiere_timestamp timestamp with time zone, - views bigint, - CONSTRAINT channel_videos_id_key UNIQUE (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.channel_videos TO current_user; - SQL - - conn.exec <<-SQL - CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx - ON public.channel_videos - USING btree - (ucid COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/database/migrations/0004_create_users_table.cr b/src/invidious/database/migrations/0004_create_users_table.cr deleted file mode 100644 index a13ba15f..00000000 --- a/src/invidious/database/migrations/0004_create_users_table.cr +++ /dev/null @@ -1,34 +0,0 @@ -module Invidious::Database::Migrations - class CreateUsersTable < Migration - version 4 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.users - ( - updated timestamp with time zone, - notifications text[], - subscriptions text[], - email text NOT NULL, - preferences text, - password text, - token text, - watched text[], - feed_needs_update boolean, - CONSTRAINT users_email_key UNIQUE (email) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.users TO current_user; - SQL - - conn.exec <<-SQL - CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx - ON public.users - USING btree - (lower(email) COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/database/migrations/0005_create_session_ids_table.cr b/src/invidious/database/migrations/0005_create_session_ids_table.cr deleted file mode 100644 index 13c2228d..00000000 --- a/src/invidious/database/migrations/0005_create_session_ids_table.cr +++ /dev/null @@ -1,28 +0,0 @@ -module Invidious::Database::Migrations - class CreateSessionIdsTable < Migration - version 5 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.session_ids - ( - id text NOT NULL, - email text, - issued timestamp with time zone, - CONSTRAINT session_ids_pkey PRIMARY KEY (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.session_ids TO current_user; - SQL - - conn.exec <<-SQL - CREATE INDEX IF NOT EXISTS session_ids_id_idx - ON public.session_ids - USING btree - (id COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/database/migrations/0006_create_nonces_table.cr b/src/invidious/database/migrations/0006_create_nonces_table.cr deleted file mode 100644 index cf1229e1..00000000 --- a/src/invidious/database/migrations/0006_create_nonces_table.cr +++ /dev/null @@ -1,27 +0,0 @@ -module Invidious::Database::Migrations - class CreateNoncesTable < Migration - version 6 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.nonces - ( - nonce text, - expire timestamp with time zone, - CONSTRAINT nonces_id_key UNIQUE (nonce) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.nonces TO current_user; - SQL - - conn.exec <<-SQL - CREATE INDEX IF NOT EXISTS nonces_nonce_idx - ON public.nonces - USING btree - (nonce COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/database/migrations/0007_create_annotations_table.cr b/src/invidious/database/migrations/0007_create_annotations_table.cr deleted file mode 100644 index dcecbc3b..00000000 --- a/src/invidious/database/migrations/0007_create_annotations_table.cr +++ /dev/null @@ -1,20 +0,0 @@ -module Invidious::Database::Migrations - class CreateAnnotationsTable < Migration - version 7 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.annotations - ( - id text NOT NULL, - annotations xml, - CONSTRAINT annotations_id_key UNIQUE (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.annotations TO current_user; - SQL - end - end -end diff --git a/src/invidious/database/migrations/0008_create_playlists_table.cr b/src/invidious/database/migrations/0008_create_playlists_table.cr deleted file mode 100644 index 6aa16e1a..00000000 --- a/src/invidious/database/migrations/0008_create_playlists_table.cr +++ /dev/null @@ -1,50 +0,0 @@ -module Invidious::Database::Migrations - class CreatePlaylistsTable < Migration - version 8 - - def up(conn : DB::Connection) - if !privacy_type_exists?(conn) - conn.exec <<-SQL - CREATE TYPE public.privacy AS ENUM - ( - 'Public', - 'Unlisted', - 'Private' - ); - SQL - end - - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.playlists - ( - title text, - id text primary key, - author text, - description text, - video_count integer, - created timestamptz, - updated timestamptz, - privacy privacy, - index int8[] - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON public.playlists TO current_user; - SQL - end - - private def privacy_type_exists?(conn : DB::Connection) : Bool - request = <<-SQL - SELECT 1 AS one - FROM pg_type - INNER JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace - WHERE pg_namespace.nspname = 'public' - AND pg_type.typname = 'privacy' - LIMIT 1; - SQL - - !conn.query_one?(request, as: Int32).nil? - end - end -end diff --git a/src/invidious/database/migrations/0009_create_playlist_videos_table.cr b/src/invidious/database/migrations/0009_create_playlist_videos_table.cr deleted file mode 100644 index 84938b9b..00000000 --- a/src/invidious/database/migrations/0009_create_playlist_videos_table.cr +++ /dev/null @@ -1,27 +0,0 @@ -module Invidious::Database::Migrations - class CreatePlaylistVideosTable < Migration - version 9 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.playlist_videos - ( - title text, - id text, - author text, - ucid text, - length_seconds integer, - published timestamptz, - plid text references playlists(id), - index int8, - live_now boolean, - PRIMARY KEY (index,plid) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.playlist_videos TO current_user; - SQL - end - end -end diff --git a/src/invidious/database/migrations/0010_make_videos_unlogged.cr b/src/invidious/database/migrations/0010_make_videos_unlogged.cr deleted file mode 100644 index f5d19683..00000000 --- a/src/invidious/database/migrations/0010_make_videos_unlogged.cr +++ /dev/null @@ -1,11 +0,0 @@ -module Invidious::Database::Migrations - class MakeVideosUnlogged < Migration - version 10 - - def up(conn : DB::Connection) - conn.exec <<-SQL - ALTER TABLE public.videos SET UNLOGGED; - SQL - end - end -end diff --git a/src/invidious/database/migrator.cr b/src/invidious/database/migrator.cr deleted file mode 100644 index 660c3203..00000000 --- a/src/invidious/database/migrator.cr +++ /dev/null @@ -1,49 +0,0 @@ -class Invidious::Database::Migrator - MIGRATIONS_TABLE = "public.invidious_migrations" - - class_getter migrations = [] of Invidious::Database::Migration.class - - def initialize(@db : DB::Database) - end - - def migrate - versions = load_versions - - ran_migration = false - load_migrations.sort_by(&.version) - .each do |migration| - next if versions.includes?(migration.version) - - puts "Running migration: #{migration.class.name}" - migration.migrate - ran_migration = true - end - - puts "No migrations to run." unless ran_migration - end - - def pending_migrations? : Bool - versions = load_versions - - load_migrations.sort_by(&.version) - .any? { |migration| !versions.includes?(migration.version) } - end - - private def load_migrations : Array(Invidious::Database::Migration) - self.class.migrations.map(&.new(@db)) - end - - private def load_versions : Array(Int64) - create_migrations_table - @db.query_all("SELECT version FROM #{MIGRATIONS_TABLE}", as: Int64) - end - - private def create_migrations_table - @db.exec <<-SQL - CREATE TABLE IF NOT EXISTS #{MIGRATIONS_TABLE} ( - id bigserial PRIMARY KEY, - version bigint NOT NULL - ) - SQL - end -end diff --git a/src/invidious/database/nonces.cr b/src/invidious/database/nonces.cr index b87c81ec..469fcbd8 100644 --- a/src/invidious/database/nonces.cr +++ b/src/invidious/database/nonces.cr @@ -4,7 +4,7 @@ module Invidious::Database::Nonces extend self # ------------------- - # Insert / Delete + # Insert # ------------------- def insert(nonce : String, expire : Time) @@ -17,15 +17,6 @@ module Invidious::Database::Nonces PG_DB.exec(request, nonce, expire) end - def delete_expired - request = <<-SQL - DELETE FROM nonces * - WHERE expire < now() - SQL - - PG_DB.exec(request) - end - # ------------------- # Update # ------------------- diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 6dbcaa05..c6754a1e 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -91,7 +91,7 @@ module Invidious::Database::Playlists end # ------------------- - # Select + # Salect # ------------------- def select(*, id : String) : InvidiousPlaylist? @@ -113,7 +113,7 @@ module Invidious::Database::Playlists end # ------------------- - # Select (filtered) + # Salect (filtered) # ------------------- def select_like_iv(email : String) : Array(InvidiousPlaylist) @@ -140,7 +140,6 @@ module Invidious::Database::Playlists request = <<-SQL SELECT id,title FROM playlists WHERE author = $1 AND id LIKE 'IV%' - ORDER BY title SQL PG_DB.query_all(request, email, as: {String, String}) @@ -213,7 +212,7 @@ module Invidious::Database::PlaylistVideos end # ------------------- - # Select + # Salect # ------------------- def select(plid : String, index : VideoIndex, offset, limit = 100) : Array(PlaylistVideo) diff --git a/src/invidious/database/statistics.cr b/src/invidious/database/statistics.cr index 9e4963fd..1df549e2 100644 --- a/src/invidious/database/statistics.cr +++ b/src/invidious/database/statistics.cr @@ -15,7 +15,7 @@ module Invidious::Database::Statistics PG_DB.query_one(request, as: Int64) end - def count_users_active_6m : Int64 + def count_users_active_1m : Int64 request = <<-SQL SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '6 months' @@ -24,7 +24,7 @@ module Invidious::Database::Statistics PG_DB.query_one(request, as: Int64) end - def count_users_active_1m : Int64 + def count_users_active_6m : Int64 request = <<-SQL SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '1 month' diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index 4a3056ea..f62b43ea 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -52,7 +52,7 @@ module Invidious::Database::Users def mark_watched(user : User, vid : String) request = <<-SQL UPDATE users - SET watched = array_append(array_remove(watched, $1), $1) + SET watched = array_append(watched, $1) WHERE email = $2 SQL @@ -119,15 +119,15 @@ module Invidious::Database::Users # Update (notifs) # ------------------- - def add_multiple_notifications(channel_id : String, video_ids : Array(String)) + def add_notification(video : ChannelVideo) request = <<-SQL UPDATE users - SET notifications = array_cat(notifications, $1), + SET notifications = array_append(notifications, $1), feed_needs_update = true WHERE $2 = ANY(subscriptions) SQL - PG_DB.exec(request, video_ids, channel_id) + PG_DB.exec(request, video.id, video.ucid) end def remove_notification(user : User, vid : String) @@ -154,16 +154,6 @@ module Invidious::Database::Users # Update (misc) # ------------------- - def feed_needs_update(channel_id : String) - request = <<-SQL - UPDATE users - SET feed_needs_update = true - WHERE $1 = ANY(subscriptions) - SQL - - PG_DB.exec(request, channel_id) - end - def update_preferences(user : User) request = <<-SQL UPDATE users diff --git a/src/invidious/database/videos.cr b/src/invidious/database/videos.cr index 695f5b33..e1fa01c3 100644 --- a/src/invidious/database/videos.cr +++ b/src/invidious/database/videos.cr @@ -22,15 +22,6 @@ module Invidious::Database::Videos PG_DB.exec(request, id) end - def delete_expired - request = <<-SQL - DELETE FROM videos * - WHERE updated < (now() - interval '6 hours') - SQL - - PG_DB.exec(request) - end - def update(video : Video) request = <<-SQL UPDATE videos diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 690db907..490d98cd 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -1,19 +1,3 @@ -# InfoExceptions are for displaying information to the user. -# -# An InfoException might or might not indicate that something went wrong. -# Historically Invidious didn't differentiate between these two options, so to -# maintain previous functionality InfoExceptions do not print backtraces. -class InfoException < Exception -end - -# Exception used to hold the bogus UCID during a channel search. -class ChannelSearchException < InfoException - getter channel : String - - def initialize(@channel) - end -end - # Exception used to hold the name of the missing item # Should be used in all parsing functions class BrokenTubeException < Exception @@ -26,15 +10,3 @@ class BrokenTubeException < Exception return "Missing JSON element \"#{@element}\"" end end - -# Exception threw when an element is not found. -class NotFoundException < InfoException -end - -class VideoNotAvailableException < Exception -end - -# Exception used to indicate that the JSON response from YT is missing -# some important informations, and that the query should be sent again. -class RetryOnceException < Exception -end diff --git a/src/invidious/frontend/channel_page.cr b/src/invidious/frontend/channel_page.cr deleted file mode 100644 index 4fe21b96..00000000 --- a/src/invidious/frontend/channel_page.cr +++ /dev/null @@ -1,47 +0,0 @@ -module Invidious::Frontend::ChannelPage - extend self - - enum TabsAvailable - Videos - Shorts - Streams - Podcasts - Releases - Courses - Playlists - Posts - Channels - end - - def generate_tabs_links(locale : String, channel : AboutChannel, selected_tab : TabsAvailable) - return String.build(1500) do |str| - base_url = "/channel/#{channel.ucid}" - - TabsAvailable.each do |tab| - # Ignore playlists, as it is not supported for auto-generated channels yet - next if (tab.playlists? && channel.auto_generated) - - tab_name = tab.to_s.downcase - - if channel.tabs.includes? tab_name - str << %(
\n) - - if tab == selected_tab - str << "\t" - str << translate(locale, "channel_tab_#{tab_name}_label") - str << "\n" - else - # Video tab doesn't have the last path component - url = tab.videos? ? base_url : "#{base_url}/#{tab_name}" - - str << %(\t) - str << translate(locale, "channel_tab_#{tab_name}_label") - str << "\n" - end - - str << "
" - end - end - end - end -end diff --git a/src/invidious/frontend/comments_reddit.cr b/src/invidious/frontend/comments_reddit.cr deleted file mode 100644 index 4dda683e..00000000 --- a/src/invidious/frontend/comments_reddit.cr +++ /dev/null @@ -1,50 +0,0 @@ -module Invidious::Frontend::Comments - extend self - - def template_reddit(root, locale) - String.build do |html| - root.each do |child| - if child.data.is_a?(RedditComment) - child = child.data.as(RedditComment) - body_html = HTML.unescape(child.body_html) - - replies_html = "" - if child.replies.is_a?(RedditThing) - replies = child.replies.as(RedditThing) - replies_html = self.template_reddit(replies.data.as(RedditListing).children, locale) - end - - if child.depth > 0 - html << <<-END_HTML -
-
-
-
- END_HTML - else - html << <<-END_HTML -
-
- END_HTML - end - - html << <<-END_HTML -

- [ − ] - #{child.author} - #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)} - #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))} - #{translate(locale, "permalink")} -

-
- #{body_html} - #{replies_html} -
-
-
- END_HTML - end - end - end - end -end diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr deleted file mode 100644 index a0e1d783..00000000 --- a/src/invidious/frontend/comments_youtube.cr +++ /dev/null @@ -1,208 +0,0 @@ -module Invidious::Frontend::Comments - extend self - - def template_youtube(comments, locale, thin_mode, is_replies = false) - String.build do |html| - root = comments["comments"].as_a - root.each do |child| - if child["replies"]? - replies_count_text = translate_count(locale, - "comments_view_x_replies", - child["replies"]["replyCount"].as_i64 || 0, - NumberFormatting::Separator - ) - - replies_html = <<-END_HTML -
-
- -
- END_HTML - elsif comments["authorId"]? && !comments["singlePost"]? - # for posts we should display a link to the post - replies_count_text = translate_count(locale, - "comments_view_x_replies", - child["replyCount"].as_i64 || 0, - NumberFormatting::Separator - ) - - replies_html = <<-END_HTML -
-
- -
- END_HTML - end - - if !thin_mode - author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}" - else - author_thumbnail = "" - end - - author_name = HTML.escape(child["author"].as_s) - sponsor_icon = "" - if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool - author_name += " " - elsif child["verified"]?.try &.as_bool - author_name += " " - end - - if child["isSponsor"]?.try &.as_bool - sponsor_icon = String.build do |str| - str << %() - end - end - html << <<-END_HTML -
-
- -
-
-

- - #{author_name} - - #{sponsor_icon} -

#{child["contentHtml"]}

- END_HTML - - if child["attachment"]? - attachment = child["attachment"] - - case attachment["type"] - when "image" - attachment = attachment["imageThumbnails"][1] - - html << <<-END_HTML -
-
- -
-
- END_HTML - when "video" - if attachment["error"]? - html << <<-END_HTML -
-

#{attachment["error"]}

-
- END_HTML - else - html << <<-END_HTML -
- -
- END_HTML - end - when "multiImage" - html << <<-END_HTML - - END_HTML - else nil # Ignore - end - end - - html << <<-END_HTML -

- #{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} - | - END_HTML - - if comments["videoId"]? - html << <<-END_HTML - [YT] - | - END_HTML - elsif comments["authorId"]? - html << <<-END_HTML - [YT] - | - END_HTML - end - - html << <<-END_HTML - #{number_with_separator(child["likeCount"])} - END_HTML - - if child["creatorHeart"]? - if !thin_mode - creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}" - else - creator_thumbnail = "" - end - - html << <<-END_HTML -   - - - - - - - - - END_HTML - end - - html << <<-END_HTML -

- #{replies_html} -
-
- END_HTML - end - - if comments["continuation"]? - html << <<-END_HTML - - END_HTML - end - end - end -end diff --git a/src/invidious/frontend/misc.cr b/src/invidious/frontend/misc.cr deleted file mode 100644 index 7a6cf79d..00000000 --- a/src/invidious/frontend/misc.cr +++ /dev/null @@ -1,14 +0,0 @@ -module Invidious::Frontend::Misc - extend self - - def redirect_url(env : HTTP::Server::Context) - prefs = env.get("preferences").as(Preferences) - - if prefs.automatic_instance_redirect - current_page = env.get?("current_page").as(String) - return "/redirect?referer=#{current_page}" - else - return "https://redirect.invidious.io#{env.request.resource}" - end - end -end diff --git a/src/invidious/frontend/pagination.cr b/src/invidious/frontend/pagination.cr deleted file mode 100644 index a29f5936..00000000 --- a/src/invidious/frontend/pagination.cr +++ /dev/null @@ -1,121 +0,0 @@ -require "uri" - -module Invidious::Frontend::Pagination - extend self - - private def first_page(str : String::Builder, locale : String?, url : String) - str << %() - - if locale_is_rtl?(locale) - # Inverted arrow ("first" points to the right) - str << translate(locale, "First page") - str << "  " - str << %() - else - # Regular arrow ("first" points to the left) - str << %() - str << "  " - str << translate(locale, "First page") - end - - str << "" - end - - private def previous_page(str : String::Builder, locale : String?, url : String) - # Link - str << %() - - if locale_is_rtl?(locale) - # Inverted arrow ("previous" points to the right) - str << translate(locale, "Previous page") - str << "  " - str << %() - else - # Regular arrow ("previous" points to the left) - str << %() - str << "  " - str << translate(locale, "Previous page") - end - - str << "" - end - - private def next_page(str : String::Builder, locale : String?, url : String) - # Link - str << %() - - if locale_is_rtl?(locale) - # Inverted arrow ("next" points to the left) - str << %() - str << "  " - str << translate(locale, "Next page") - else - # Regular arrow ("next" points to the right) - str << translate(locale, "Next page") - str << "  " - str << %() - end - - str << "" - end - - def nav_numeric(locale : String?, *, base_url : String | URI, current_page : Int, show_next : Bool = true) - return String.build do |str| - str << %(
\n) - str << %(\n) - str << %(
\n\n) - end - end - - def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?, first_page : Bool, params : URI::Params) - return String.build do |str| - str << %(
\n) - str << %(\n) - str << %(
\n\n) - end - end -end diff --git a/src/invidious/frontend/search_filters.cr b/src/invidious/frontend/search_filters.cr deleted file mode 100644 index 8ac0af2e..00000000 --- a/src/invidious/frontend/search_filters.cr +++ /dev/null @@ -1,135 +0,0 @@ -module Invidious::Frontend::SearchFilters - extend self - - # Generate the search filters collapsable widget. - def generate(filters : Search::Filters, query : String, page : Int, locale : String) : String - return String.build(8000) do |str| - str << "
\n" - str << "\t
" - str << "\t\t" << translate(locale, "search_filters_title") << "\n" - - str << "\t\t
\n" - - str << "\t\t\t\n" - str << "\t\t\t\n" - - str << "\t\t\t
" - - filter_wrapper(date) - filter_wrapper(type) - filter_wrapper(duration) - filter_wrapper(features) - filter_wrapper(sort) - - str << "\t\t\t
\n" - - str << "\t\t\t
" - str << "
\n" - - str << "\t\t
\n" - - str << "\t
\n" - str << "
\n" - end - end - - # Generate wrapper HTML (`
`, filter name, etc...) around the - # `` elements of a search filter - macro filter_wrapper(name) - str << "\t\t\t\t
\n" - - str << "\t\t\t\t\t
" - str << translate(locale, "search_filters_{{name}}_label") - str << "
\n" - - str << "\t\t\t\t\t
\n" - make_{{name}}_filter_options(str, filters.{{name}}, locale) - str << "\t\t\t\t\t
" - - str << "\t\t\t\t
\n" - end - - # Generates the HTML for the list of radio buttons of the "date" search filter - def make_date_filter_options(str : String::Builder, value : Search::Filters::Date, locale : String) - {% for value in Invidious::Search::Filters::Date.constants %} - {% date = value.underscore %} - - str << "\t\t\t\t\t\t
" - str << "' - - str << "
\n" - {% end %} - end - - # Generates the HTML for the list of radio buttons of the "type" search filter - def make_type_filter_options(str : String::Builder, value : Search::Filters::Type, locale : String) - {% for value in Invidious::Search::Filters::Type.constants %} - {% type = value.underscore %} - - str << "\t\t\t\t\t\t
" - str << "' - - str << "
\n" - {% end %} - end - - # Generates the HTML for the list of radio buttons of the "duration" search filter - def make_duration_filter_options(str : String::Builder, value : Search::Filters::Duration, locale : String) - {% for value in Invidious::Search::Filters::Duration.constants %} - {% duration = value.underscore %} - - str << "\t\t\t\t\t\t
" - str << "' - - str << "
\n" - {% end %} - end - - # Generates the HTML for the list of checkboxes of the "features" search filter - def make_features_filter_options(str : String::Builder, value : Search::Filters::Features, locale : String) - {% for value in Invidious::Search::Filters::Features.constants %} - {% if value.stringify != "All" && value.stringify != "None" %} - {% feature = value.underscore %} - - str << "\t\t\t\t\t\t
" - str << "' - - str << "
\n" - {% end %} - {% end %} - end - - # Generates the HTML for the list of radio buttons of the "sort" search filter - def make_sort_filter_options(str : String::Builder, value : Search::Filters::Sort, locale : String) - {% for value in Invidious::Search::Filters::Sort.constants %} - {% sort = value.underscore %} - - str << "\t\t\t\t\t\t
" - str << "' - - str << "
\n" - {% end %} - end -end diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index 15d925e3..80b67641 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -7,32 +7,26 @@ module Invidious::Frontend::WatchPage 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(Invidious::Videos::Captions::Metadata) + getter captions : Array(Caption) def initialize( @full_videos, @video_streams, @audio_streams, - @captions, + @captions ) end end def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String if CONFIG.disabled?("downloads") - return "

#{translate(locale, "Download is disabled")}

" - end - - url = "/download" - if (CONFIG.invidious_companion.present?) - invidious_companion = CONFIG.invidious_companion.sample - url = "#{invidious_companion.public_url}/download?check=#{invidious_companion_encrypt(video.id)}" + return "

#{translate(locale, "Download is disabled.")}

" end return String.build(4000) do |str| str << "" @@ -48,14 +42,15 @@ module Invidious::Frontend::WatchPage str << translate(locale, "Download as: ") str << "\n" - str << "\t\t\n" # Non-DASH videos (audio+video) video_assets.full_videos.each do |option| mimetype = option["mimeType"].as_s.split(";")[0] - height = Invidious::Videos::Formats.itag_to_metadata?(option["itag"]).try &.["height"]? + height = itag_to_metadata?(option["itag"]).try &.["height"]? value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr deleted file mode 100644 index d9d584c9..00000000 --- a/src/invidious/hashtag.cr +++ /dev/null @@ -1,42 +0,0 @@ -module Invidious::Hashtag - extend self - - def fetch(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem) - cursor = (page - 1) * 60 - ctoken = generate_continuation(hashtag, cursor) - - client_config = YoutubeAPI::ClientConfig.new(region: region) - response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config) - - items, _ = extract_items(response) - return items - end - - def generate_continuation(hashtag : String, cursor : Int) - object = { - "80226972:embedded" => { - "2:string" => "FEhashtag", - "3:base64" => { - "1:varint" => 60_i64, # result count - "15:base64" => { - "1:varint" => cursor.to_i64, - "2:varint" => 0_i64, - }, - "93:2:embedded" => { - "1:string" => hashtag, - "2:varint" => 0_i64, - "3:varint" => 1_i64, - }, - }, - "35:string" => "browse-feedFEhashtag", - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation - end -end diff --git a/src/invidious/helpers/crystal_class_overrides.cr b/src/invidious/helpers/crystal_class_overrides.cr index fec3f62c..bf56d826 100644 --- a/src/invidious/helpers/crystal_class_overrides.cr +++ b/src/invidious/helpers/crystal_class_overrides.cr @@ -3,9 +3,9 @@ # IPv6 addresses. # class TCPSocket - def initialize(host, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC) + def initialize(host : String, port, dns_timeout = nil, connect_timeout = nil, family = Socket::Family::UNSPEC) Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo| - super(addrinfo.family, addrinfo.type, addrinfo.protocol, blocking) + super(addrinfo.family, addrinfo.type, addrinfo.protocol) connect(addrinfo, timeout: connect_timeout) do |error| close error @@ -26,7 +26,7 @@ class HTTP::Client end hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host - io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, family: @family + io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, @family io.read_timeout = @read_timeout if @read_timeout io.write_timeout = @write_timeout if @write_timeout io.sync = false @@ -35,7 +35,7 @@ class HTTP::Client if tls = @tls tcp_socket = io begin - io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host.rchop('.')) + io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host) rescue exc # don't leak the TCP socket when the SSL connection failed tcp_socket.close diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index e2c4b650..6155e561 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -1,9 +1,17 @@ +# InfoExceptions are for displaying information to the user. +# +# An InfoException might or might not indicate that something went wrong. +# Historically Invidious didn't differentiate between these two options, so to +# maintain previous functionality InfoExceptions do not print backtraces. +class InfoException < Exception +end + # ------------------- # Issue template # ------------------- macro error_template(*args) - error_template_helper(env, {{args.splat}}) + error_template_helper(env, {{*args}}) end def github_details(summary : String, content : String) @@ -18,22 +26,6 @@ def github_details(summary : String, content : String) return HTML.escape(details) end -def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tuple(String, String) - issue_title = "#{exception.message} (#{exception.class})" - - issue_template = <<-TEXT - Title: `#{HTML.escape(issue_title)}` - Date: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}` - Route: `#{HTML.escape(env.request.resource)}` - Version: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}` - - TEXT - - issue_template += github_details("Backtrace", exception.inspect_with_backtrace) - - return issue_title, issue_template -end - def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception) if exception.is_a?(InfoException) return error_template_helper(env, status_code, exception.message || "") @@ -44,16 +36,21 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce env.response.content_type = "text/html" env.response.status_code = status_code - # Unpacking into issue_title, issue_template directly causes a compiler error - # I have no idea why. - issue_template_components = get_issue_template(env, exception) - issue_title, issue_template = issue_template_components + issue_title = "#{exception.message} (#{exception.class})" + + issue_template = <<-TEXT + Title: `#{HTML.escape(issue_title)}` + Date: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}` + Route: `#{HTML.escape(env.request.resource)}` + Version: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}` + + TEXT + + issue_template += github_details("Backtrace", HTML.escape(exception.inspect_with_backtrace)) # URLs for the error message below - url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md" + url_faq = "https://github.com/iv-org/documentation/blob/master/FAQ.md" url_search_issues = "https://github.com/iv-org/invidious/issues" - url_search_issues += "?q=is:issue+is:open+" - url_search_issues += URI.encode_www_form("[Bug] #{issue_title}") url_switch = "https://redirect.invidious.io" + env.request.resource @@ -78,7 +75,7 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce

#{translate(locale, "crash_page_report_issue", url_new_issue)}

-
#{issue_template}
+
#{issue_template}
END_HTML @@ -106,7 +103,7 @@ end # ------------------- macro error_atom(*args) - error_atom_helper(env, {{args.splat}}) + error_atom_helper(env, {{*args}}) end def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception) @@ -132,14 +129,14 @@ end # ------------------- macro error_json(*args) - error_json_helper(env, {{args.splat}}) + error_json_helper(env, {{*args}}) end def error_json_helper( env : HTTP::Server::Context, status_code : Int32, exception : Exception, - additional_fields : Hash(String, Object) | Nil = nil, + additional_fields : Hash(String, Object) | Nil = nil ) if exception.is_a?(InfoException) return error_json_helper(env, status_code, exception.message || "", additional_fields) @@ -161,7 +158,7 @@ def error_json_helper( env : HTTP::Server::Context, status_code : Int32, message : String, - additional_fields : Hash(String, Object) | Nil = nil, + additional_fields : Hash(String, Object) | Nil = nil ) env.response.content_type = "application/json" env.response.status_code = status_code @@ -201,7 +198,7 @@ def error_redirect_helper(env : HTTP::Server::Context) #{switch_instance}
  • - #{go_to_youtube} + #{go_to_youtube}
  • END_HTML diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 13ea9fe9..d140a858 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -27,7 +27,6 @@ class Kemal::RouteHandler # Processes the route if it's a match. Otherwise renders 404. private def process_request(context) raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found? - return if context.response.closed? content = context.route.handler.call(context) if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context) @@ -98,7 +97,7 @@ class AuthHandler < Kemal::Handler if token = env.request.headers["Authorization"]? token = JSON.parse(URI.decode_www_form(token.lchop("Bearer "))) session = URI.decode_www_form(token["session"].as_s) - scopes, _, _ = validate_request(token, session, env.request, HMAC_KEY, nil) + scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, nil) if email = Invidious::Database::SessionIDs.select_email(session) user = Invidious::Database::Users.select!(email: email) @@ -143,8 +142,63 @@ class APIHandler < Kemal::Handler exclude ["/api/v1/auth/notifications"], "POST" def call(env) - env.response.headers["Access-Control-Allow-Origin"] = "*" if only_match?(env) - call_next env + return call_next env unless only_match? env + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + # Since /api/v1/notifications is an event-stream, we don't want + # to wrap the response + return call_next env if exclude_match? env + + # Here we swap out the socket IO so we can modify the response as needed + output = env.response.output + env.response.output = IO::Memory.new + + begin + call_next env + + env.response.output.rewind + + if env.response.output.as(IO::Memory).size != 0 && + env.response.headers.includes_word?("Content-Type", "application/json") + response = JSON.parse(env.response.output) + + if fields_text = env.params.query["fields"]? + begin + JSONFilter.filter(response, fields_text) + rescue ex + env.response.status_code = 400 + response = {"error" => ex.message} + end + end + + if env.params.query["pretty"]?.try &.== "1" + response = response.to_pretty_json + else + response = response.to_json + end + else + response = env.response.output.gets_to_end + end + rescue ex + env.response.content_type = "application/json" if env.response.headers.includes_word?("Content-Type", "text/html") + env.response.status_code = 500 + + if env.response.headers.includes_word?("Content-Type", "application/json") + response = {"error" => ex.message || "Unspecified error"} + + if env.params.query["pretty"]?.try &.== "1" + response = response.to_pretty_json + else + response = response.to_json + end + end + ensure + env.response.output = output + env.response.print response + + env.response.flush + end end end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 6add0237..c3b53339 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -22,6 +22,31 @@ struct Annotation property annotations : String end +def login_req(f_req) + data = { + # Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard + # Generally this is much longer (>1250 characters), see also + # https://github.com/ytdl-org/youtube-dl/commit/baf67a604d912722b0fe03a40e9dc5349a2208cb . + # For now this can be empty. + "bgRequest" => %|["identifier",""]|, + "pstMsg" => "1", + "checkConnection" => "youtube", + "checkedDomains" => "youtube", + "hl" => "en", + "deviceinfo" => %|[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]|, + "f.req" => f_req, + "flowName" => "GlifWebSignIn", + "flowEntry" => "ServiceLogin", + # "cookiesDisabled" => "false", + # "gmscoreversion" => "undefined", + # "continue" => "https://accounts.google.com/ManageAccount", + # "azt" => "", + # "bgHash" => "", + } + + return HTTP::Params.encode(data) +end + def html_to_content(description_html : String) description = description_html.gsub(/(
    )|()/, { "
    ": "\n", @@ -78,6 +103,15 @@ def create_notification_stream(env, topics, connection_channel) video.published = published response = JSON.parse(video.to_json(locale, nil)) + if fields_text = env.params.query["fields"]? + begin + JSONFilter.filter(response, fields_text) + rescue ex + env.response.status_code = 400 + response = {"error" => ex.message} + end + end + env.response.puts "id: #{id}" env.response.puts "data: #{response.to_json}" env.response.puts @@ -104,6 +138,15 @@ def create_notification_stream(env, topics, connection_channel) Invidious::Database::ChannelVideos.select_notfications(topic, since_unix).each do |video| response = JSON.parse(video.to_json(locale)) + if fields_text = env.params.query["fields"]? + begin + JSONFilter.filter(response, fields_text) + rescue ex + env.response.status_code = 400 + response = {"error" => ex.message} + end + end + env.response.puts "id: #{id}" env.response.puts "data: #{response.to_json}" env.response.puts @@ -137,6 +180,15 @@ def create_notification_stream(env, topics, connection_channel) video.published = Time.unix(published) response = JSON.parse(video.to_json(locale, nil)) + if fields_text = env.params.query["fields"]? + begin + JSONFilter.filter(response, fields_text) + rescue ex + env.response.status_code = 400 + response = {"error" => ex.message} + end + end + env.response.puts "id: #{id}" env.response.puts "data: #{response.to_json}" env.response.puts @@ -181,20 +233,3 @@ def proxy_file(response, env) IO.copy response.body_io, env.response end end - -# Fetch the playback requests tracker from the statistics endpoint. -# -# Creates a new tracker when unavailable. -def get_playback_statistic - if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]) && tracker.as(Hash).empty? - tracker = { - "totalRequests" => 0_i64, - "successfulRequests" => 0_i64, - "ratio" => 0_f64, - } - - Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"] = tracker - end - - return tracker.as(Hash(String, Int64 | Float64)) -end diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index bca2edda..39e183f2 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,35 +1,19 @@ -# Languages requiring a better level of translation (at least 20%) -# to be added to the list below: -# -# "af" => "", # Afrikaans -# "az" => "", # Azerbaijani -# "be" => "", # Belarusian -# "bn_BD" => "", # Bengali (Bangladesh) -# "ia" => "", # Interlingua -# "or" => "", # Odia -# "tk" => "", # Turkmen -# "tok => "", # Toki Pona -# +# "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete] +# "eu" => load_locale("eu"), # Basque [Incomplete] +# "sk" => load_locale("sk"), # Slovak [Incomplete] LOCALES_LIST = { "ar" => "العربية", # Arabic - "bg" => "български", # Bulgarian - "bn" => "বাংলা", # Bengali - "ca" => "Català", # Catalan "cs" => "Čeština", # Czech - "cy" => "Cymraeg", # Welsh "da" => "Dansk", # Danish "de" => "Deutsch", # German "el" => "Ελληνικά", # Greek "en-US" => "English", # English "eo" => "Esperanto", # Esperanto "es" => "Español", # Spanish - "et" => "Eesti keel", # Estonian - "eu" => "Euskara", # Basque "fa" => "فارسی", # Persian "fi" => "Suomi", # Finnish "fr" => "Français", # French "he" => "עברית", # Hebrew - "hi" => "हिन्दी", # Hindi "hr" => "Hrvatski", # Croatian "hu-HU" => "Magyar Nyelv", # Hungarian "id" => "Bahasa Indonesia", # Indonesian @@ -37,7 +21,6 @@ LOCALES_LIST = { "it" => "Italiano", # Italian "ja" => "日本語", # Japanese "ko" => "한국어", # Korean - "lmo" => "Lombard", # Lombard "lt" => "Lietuvių", # Lithuanian "nb-NO" => "Norsk bokmål", # Norwegian Bokmål "nl" => "Nederlands", # Dutch @@ -46,15 +29,11 @@ LOCALES_LIST = { "pt-BR" => "Português Brasileiro", # Portuguese (Brazil) "pt-PT" => "Português de Portugal", # Portuguese (Portugal) "ro" => "Română", # Romanian - "ru" => "Русский", # Russian - "si" => "සිංහල", # Sinhala - "sk" => "Slovenčina", # Slovak - "sl" => "Slovenščina", # Slovenian + "ru" => "русский", # Russian "sq" => "Shqip", # Albanian - "sr" => "Srpski (latinica)", # Serbian (Latin) - "sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic) + "sr" => "srpski (latinica)", # Serbian (Latin) + "sr_Cyrl" => "српски (ћирилица)", # Serbian (Cyrillic) "sv-SE" => "Svenska", # Swedish - "ta" => "தமிழ்", # Tamil "tr" => "Türkçe", # Turkish "uk" => "Українська", # Ukrainian "vi" => "Tiếng Việt", # Vietnamese @@ -94,7 +73,7 @@ def load_all_locales return locales end -def translate(locale : String?, key : String, text : String | Hash(String, String) | Nil = nil) : String +def translate(locale : String?, key : String, text : String | Nil = nil) : String # Log a warning if "key" doesn't exist in en-US locale and return # that key as the text, so this is more or less transparent to the user. if !LOCALES["en-US"].has_key?(key) @@ -117,12 +96,10 @@ def translate(locale : String?, key : String, text : String | Hash(String, Strin match_length = 0 raw_data.as_h.each do |hash_key, value| - if text.is_a?(String) - if md = text.try &.match(/#{hash_key}/) - if md[0].size >= match_length - translation = value.as_s - match_length = md[0].size - end + if md = text.try &.match(/#{hash_key}/) + if md[0].size >= match_length + translation = value.as_s + match_length = md[0].size end end end @@ -132,13 +109,8 @@ def translate(locale : String?, key : String, text : String | Hash(String, Strin raise "Invalid translation \"#{raw_data}\"" end - if text.is_a?(String) + if text translation = translation.gsub("`x`", text) - elsif text.is_a?(Hash(String, String)) - # adds support for multi string interpolation. Based on i18next https://www.i18next.com/translation-function/interpolation#basic - text.each_key do |hash_key| - translation = translation.gsub("{{#{hash_key}}}", text[hash_key]) - end end return translation @@ -188,12 +160,3 @@ def translate_bool(locale : String?, translation : Bool) return translate(locale, "No") end end - -def locale_is_rtl?(locale : String?) - # Fallback to en-US - return false if locale.nil? - - # Arabic, Persian, Hebrew - # See https://en.wikipedia.org/wiki/Right-to-left_script#List_of_RTL_scripts - return {"ar", "fa", "he"}.includes? locale -end diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr index 684e6d14..e84f88fb 100644 --- a/src/invidious/helpers/i18next.cr +++ b/src/invidious/helpers/i18next.cr @@ -35,35 +35,27 @@ module I18next::Plurals Special_Slovenian = 21 Special_Hebrew = 22 Special_Odia = 23 - - # Mixed v3/v4 rules in Weblate - # `es`, `pt` and `pt-PT` doesn't seem to have been refreshed - # by weblate yet, but I suspect it will happen one day. - # See: https://github.com/translate/translate/issues/4873 - Special_French_Portuguese - Special_Hungarian_Serbian - Special_Spanish_Italian end private PLURAL_SETS = { PluralForms::Single_gt_one => [ - "ach", "ak", "am", "arn", "br", "fa", "fil", "gun", "ln", "mfe", "mg", - "mi", "oc", "pt-PT", "tg", "tl", "ti", "tr", "uz", "wa", + "ach", "ak", "am", "arn", "br", "fil", "fr", "gun", "ln", "mfe", "mg", + "mi", "oc", "pt", "pt-BR", "tg", "tl", "ti", "tr", "uz", "wa", ], PluralForms::Single_not_one => [ "af", "an", "ast", "az", "bg", "bn", "ca", "da", "de", "dev", "el", "en", - "eo", "et", "eu", "fi", "fo", "fur", "fy", "gl", "gu", "ha", "hi", - "hu", "hy", "ia", "kk", "kn", "ku", "lb", "mai", "ml", "mn", "mr", + "eo", "es", "et", "eu", "fi", "fo", "fur", "fy", "gl", "gu", "ha", "hi", + "hu", "hy", "ia", "it", "kk", "kn", "ku", "lb", "mai", "ml", "mn", "mr", "nah", "nap", "nb", "ne", "nl", "nn", "no", "nso", "pa", "pap", "pms", - "ps", "rm", "sco", "se", "si", "so", "son", "sq", "sv", "sw", + "ps", "pt-PT", "rm", "sco", "se", "si", "so", "son", "sq", "sv", "sw", "ta", "te", "tk", "ur", "yo", ], PluralForms::None => [ - "ay", "bo", "cgg", "ht", "id", "ja", "jbo", "ka", "km", "ko", "ky", + "ay", "bo", "cgg", "fa", "ht", "id", "ja", "jbo", "ka", "km", "ko", "ky", "lo", "ms", "sah", "su", "th", "tt", "ug", "vi", "wo", "zh", ], PluralForms::Dual_Slavic => [ - "be", "bs", "cnr", "dz", "ru", "uk", + "be", "bs", "cnr", "dz", "hr", "ru", "sr", "uk", ], } @@ -89,13 +81,6 @@ module I18next::Plurals "ro" => PluralForms::Special_Romanian, "sk" => PluralForms::Special_Czech_Slovak, "sl" => PluralForms::Special_Slovenian, - # Mixed v3/v4 rules - "es" => PluralForms::Special_Spanish_Italian, - "fr" => PluralForms::Special_French_Portuguese, - "hr" => PluralForms::Special_Hungarian_Serbian, - "it" => PluralForms::Special_Spanish_Italian, - "pt" => PluralForms::Special_French_Portuguese, - "sr" => PluralForms::Special_Hungarian_Serbian, } # These are the v1 and v2 compatible suffixes. @@ -165,8 +150,9 @@ module I18next::Plurals end def get_plural_form(locale : String) : PluralForms - # Extract the ISO 639-1 or 639-2 code from an RFC 5646 language code - if !locale.matches?(/^pt-PT$/) + # Extract the ISO 639-1 or 639-2 code from an RFC 5646 language code, + # except for pt-BR and pt-PT which needs to be kept as-is. + if !locale.matches?(/^pt-(BR|PT)$/) locale = locale.split('-')[0] end @@ -188,7 +174,7 @@ module I18next::Plurals # Emulate the `rule.numbers.size == 2 && rule.numbers[0] == 1` check # from original i18next code - private def simple_plural?(form : PluralForms) : Bool + private def is_simple_plural(form : PluralForms) : Bool case form when .single_gt_one? then return true when .single_not_one? then return true @@ -210,7 +196,7 @@ module I18next::Plurals idx = SuffixIndex.get_index(plural_form, count) # Simple plurals are handled differently in all versions (but v4) - if @simplify_plural_suffix && simple_plural?(plural_form) + if @simplify_plural_suffix && is_simple_plural(plural_form) return (idx == 1) ? "_plural" : "" end @@ -260,10 +246,6 @@ module I18next::Plurals when .special_slovenian? then return special_slovenian(count) when .special_hebrew? then return special_hebrew(count) when .special_odia? then return special_odia(count) - # Mixed v3/v4 forms - when .special_spanish_italian? then return special_cldr_spanish_italian(count) - when .special_french_portuguese? then return special_cldr_french_portuguese(count) - when .special_hungarian_serbian? then return special_cldr_hungarian_serbian(count) else # default, if nothing matched above return 0_u8 @@ -525,42 +507,5 @@ module I18next::Plurals def self.special_odia(count : Int) : UInt8 return (count == 1) ? 0_u8 : 1_u8 end - - # ------------------- - # "v3.5" rules - # ------------------- - - # Plural form for Spanish & Italian languages - # - # This rule is mostly compliant to CLDR v42 - # - def self.special_cldr_spanish_italian(count : Int) : UInt8 - return 0_u8 if (count == 1) # one - return 1_u8 if (count != 0 && count % 1_000_000 == 0) # many - return 2_u8 # other - end - - # Plural form for French and Portuguese - # - # This rule is mostly compliant to CLDR v42 - # - def self.special_cldr_french_portuguese(count : Int) : UInt8 - return 0_u8 if (count == 0 || count == 1) # one - return 1_u8 if (count % 1_000_000 == 0) # many - return 2_u8 # other - end - - # Plural form for Hungarian and Serbian - # - # This rule is mostly compliant to CLDR v42 - # - def self.special_cldr_hungarian_serbian(count : Int) : UInt8 - n_mod_10 = count % 10 - n_mod_100 = count % 100 - - return 0_u8 if (n_mod_10 == 1 && n_mod_100 != 11) # one - return 1_u8 if (2 <= n_mod_10 <= 4 && (n_mod_100 < 12 || 14 < n_mod_100)) # few - return 2_u8 # other - end end end diff --git a/src/invidious/helpers/json_filter.cr b/src/invidious/helpers/json_filter.cr new file mode 100644 index 00000000..b8e8f96d --- /dev/null +++ b/src/invidious/helpers/json_filter.cr @@ -0,0 +1,248 @@ +module JSONFilter + alias BracketIndex = Hash(Int64, Int64) + + alias GroupedFieldsValue = String | Array(GroupedFieldsValue) + alias GroupedFieldsList = Array(GroupedFieldsValue) + + class FieldsParser + class ParseError < Exception + end + + # Returns the `Regex` pattern used to match nest groups + def self.nest_group_pattern : Regex + # uses a '.' character to match json keys as they are allowed + # to contain any unicode codepoint + /(?:|,)(?[^,\n]*?)\(/ + end + + # Returns the `Regex` pattern used to check if there are any empty nest groups + def self.unnamed_nest_group_pattern : Regex + /^\(|\(\(|\/\(/ + end + + def self.parse_fields(fields_text : String) : Nil + if fields_text.empty? + raise FieldsParser::ParseError.new "Fields is empty" + end + + opening_bracket_count = fields_text.count('(') + closing_bracket_count = fields_text.count(')') + + if opening_bracket_count != closing_bracket_count + bracket_type = opening_bracket_count > closing_bracket_count ? "opening" : "closing" + raise FieldsParser::ParseError.new "There are too many #{bracket_type} brackets (#{opening_bracket_count}:#{closing_bracket_count})" + elsif match_result = unnamed_nest_group_pattern.match(fields_text) + raise FieldsParser::ParseError.new "Unnamed nest group at position #{match_result.begin}" + end + + # first, handle top-level single nested properties: items/id, playlistItems/snippet, etc + parse_single_nests(fields_text) { |nest_list| yield nest_list } + + # next, handle nest groups: items(id, etag, etc) + parse_nest_groups(fields_text) { |nest_list| yield nest_list } + end + + def self.parse_single_nests(fields_text : String) : Nil + single_nests = remove_nest_groups(fields_text) + + if !single_nests.empty? + property_nests = single_nests.split(',') + + property_nests.each do |nest| + nest_list = nest.split('/') + if nest_list.includes? "" + raise FieldsParser::ParseError.new "Empty key in nest list: #{nest_list}" + end + yield nest_list + end + # else + # raise FieldsParser::ParseError.new "Empty key in nest list 22: #{fields_text} | #{single_nests}" + end + end + + def self.parse_nest_groups(fields_text : String) : Nil + nest_stack = [] of NamedTuple(group_name: String, closing_bracket_index: Int64) + bracket_pairs = get_bracket_pairs(fields_text, true) + + text_index = 0 + regex_index = 0 + + while regex_result = self.nest_group_pattern.match(fields_text, regex_index) + raw_match = regex_result[0] + group_name = regex_result["groupname"] + + text_index = regex_result.begin + regex_index = regex_result.end + + if text_index.nil? || regex_index.nil? + raise FieldsParser::ParseError.new "Received invalid index while parsing nest groups: text_index: #{text_index} | regex_index: #{regex_index}" + end + + offset = raw_match.starts_with?(',') ? 1 : 0 + + opening_bracket_index = (text_index + group_name.size) + offset + closing_bracket_index = bracket_pairs[opening_bracket_index] + content_start = opening_bracket_index + 1 + + content = fields_text[content_start...closing_bracket_index] + + if content.empty? + raise FieldsParser::ParseError.new "Empty nest group at position #{content_start}" + else + content = remove_nest_groups(content) + end + + while nest_stack.size > 0 && closing_bracket_index > nest_stack[nest_stack.size - 1][:closing_bracket_index] + if nest_stack.size + nest_stack.pop + end + end + + group_name.split('/').each do |name| + nest_stack.push({ + group_name: name, + closing_bracket_index: closing_bracket_index, + }) + end + + if !content.empty? + properties = content.split(',') + + properties.each do |prop| + nest_list = nest_stack.map { |nest_prop| nest_prop[:group_name] } + + if !prop.empty? + if prop.includes?('/') + parse_single_nests(prop) { |list| nest_list += list } + else + nest_list.push prop + end + else + raise FieldsParser::ParseError.new "Empty key in nest list: #{nest_list << prop}" + end + + yield nest_list + end + end + end + end + + def self.remove_nest_groups(text : String) : String + content_bracket_pairs = get_bracket_pairs(text, false) + + content_bracket_pairs.each_key.to_a.reverse.each do |opening_bracket| + closing_bracket = content_bracket_pairs[opening_bracket] + last_comma = text.rindex(',', opening_bracket) || 0 + + text = text[0...last_comma] + text[closing_bracket + 1...text.size] + end + + return text.starts_with?(',') ? text[1...text.size] : text + end + + def self.get_bracket_pairs(text : String, recursive = true) : BracketIndex + istart = [] of Int64 + bracket_index = BracketIndex.new + + text.each_char_with_index do |char, index| + if char == '(' + istart.push(index.to_i64) + end + + if char == ')' + begin + opening = istart.pop + if recursive || (!recursive && istart.size == 0) + bracket_index[opening] = index.to_i64 + end + rescue + raise FieldsParser::ParseError.new "No matching opening parenthesis at: #{index}" + end + end + end + + if istart.size != 0 + idx = istart.pop + raise FieldsParser::ParseError.new "No matching closing parenthesis at: #{idx}" + end + + return bracket_index + end + end + + class FieldsGrouper + alias SkeletonValue = Hash(String, SkeletonValue) + + def self.create_json_skeleton(fields_text : String) : SkeletonValue + root_hash = {} of String => SkeletonValue + + FieldsParser.parse_fields(fields_text) do |nest_list| + current_item = root_hash + nest_list.each do |key| + if current_item[key]? + current_item = current_item[key] + else + current_item[key] = {} of String => SkeletonValue + current_item = current_item[key] + end + end + end + root_hash + end + + def self.create_grouped_fields_list(json_skeleton : SkeletonValue) : GroupedFieldsList + grouped_fields_list = GroupedFieldsList.new + json_skeleton.each do |key, value| + grouped_fields_list.push key + + nested_keys = create_grouped_fields_list(value) + grouped_fields_list.push nested_keys unless nested_keys.empty? + end + return grouped_fields_list + end + end + + class FilterError < Exception + end + + def self.filter(item : JSON::Any, fields_text : String, in_place : Bool = true) + skeleton = FieldsGrouper.create_json_skeleton(fields_text) + grouped_fields_list = FieldsGrouper.create_grouped_fields_list(skeleton) + filter(item, grouped_fields_list, in_place) + end + + def self.filter(item : JSON::Any, grouped_fields_list : GroupedFieldsList, in_place : Bool = true) : JSON::Any + item = item.clone unless in_place + + if !item.as_h? && !item.as_a? + raise FilterError.new "Can't filter '#{item}' by #{grouped_fields_list}" + end + + top_level_keys = Array(String).new + grouped_fields_list.each do |value| + if value.is_a? String + top_level_keys.push value + elsif value.is_a? Array + if !top_level_keys.empty? + key_to_filter = top_level_keys.last + + if item.as_h? + filter(item[key_to_filter], value, in_place: true) + elsif item.as_a? + item.as_a.each { |arr_item| filter(arr_item[key_to_filter], value, in_place: true) } + end + else + raise FilterError.new "Tried to filter while top level keys list is empty" + end + end + end + + if item.as_h? + item.as_h.select! top_level_keys + elsif item.as_a? + item.as_a.map { |value| filter(value, top_level_keys, in_place: true) } + end + + item + end +end diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr index 03349595..e2e50905 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -1,5 +1,3 @@ -require "colorize" - enum LogLevel All = 0 Trace = 1 @@ -12,9 +10,7 @@ enum LogLevel end class Invidious::LogHandler < Kemal::BaseLogHandler - def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true) - Colorize.enabled = use_color - Colorize.on_tty_only! + def initialize(@io : IO = STDOUT, @level = LogLevel::Debug) end def call(context : HTTP::Server::Context) @@ -38,27 +34,28 @@ class Invidious::LogHandler < Kemal::BaseLogHandler context end + def puts(message : String) + @io << message << '\n' + @io.flush + end + def write(message : String) @io << message @io.flush end - def color(level) - case level - when LogLevel::Trace then :cyan - when LogLevel::Debug then :green - when LogLevel::Info then :white - when LogLevel::Warn then :yellow - when LogLevel::Error then :red - when LogLevel::Fatal then :magenta - else :default - end + def set_log_level(level : String) + @level = LogLevel.parse(level) + end + + def set_log_level(level : LogLevel) + @level = level end {% for level in %w(trace debug info warn error fatal) %} def {{level.id}}(message : String) if LogLevel::{{level.id.capitalize}} >= @level - puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}}))) + puts("#{Time.utc} [{{level.id}}] #{message}") end end {% end %} diff --git a/src/invidious/helpers/macros.cr b/src/invidious/helpers/macros.cr index 84847321..75df1612 100644 --- a/src/invidious/helpers/macros.cr +++ b/src/invidious/helpers/macros.cr @@ -48,18 +48,13 @@ module JSON::Serializable end end -macro templated(_filename, template = "template", navbar_search = true) +macro templated(filename, template = "template", navbar_search = true) navbar_search = {{navbar_search}} - - {{ filename = "src/invidious/views/" + _filename + ".ecr" }} - {{ layout = "src/invidious/views/" + template + ".ecr" }} - - __content_filename__ = {{filename}} - render {{filename}}, {{layout}} + render "src/invidious/views/#{{{filename}}}.ecr", "src/invidious/views/#{{{template}}}.ecr" end macro rendered(filename) - render("src/invidious/views/#{{{filename}}}.ecr") + render "src/invidious/views/#{{{filename}}}.ecr" end # Similar to Kemals halt method but works in a diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 2796a8dc..bfbc237c 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -1,16 +1,3 @@ -@[Flags] -enum VideoBadges - LiveNow - Premium - ThreeD - FourK - New - EightK - VR180 - VR360 - ClosedCaptions -end - struct SearchVideo include DB::Serializable @@ -22,10 +9,9 @@ struct SearchVideo property views : Int64 property description_html : String property length_seconds : Int32 + property live_now : Bool + property premium : Bool property premiere_timestamp : Time? - property author_verified : Bool - property author_thumbnail : String? - property badges : VideoBadges def to_xml(auto_generated, query_params, xml : XML::Builder) query_params["v"] = self.id @@ -87,52 +73,25 @@ struct SearchVideo json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" - json.field "authorVerified", self.author_verified - - author_thumbnail = self.author_thumbnail - - if author_thumbnail - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - end json.field "videoThumbnails" do - Invidious::JSONify::APIv1.thumbnails(json, self.id) + generate_thumbnails(json, self.id) end json.field "description", html_to_content(self.description_html) json.field "descriptionHtml", self.description_html json.field "viewCount", self.views - json.field "viewCountText", translate_count(locale, "generic_views_count", self.views, NumberFormatting::Short) json.field "published", self.published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) json.field "lengthSeconds", self.length_seconds - json.field "liveNow", self.badges.live_now? - json.field "premium", self.badges.premium? - json.field "isUpcoming", self.upcoming? + json.field "liveNow", self.live_now + json.field "premium", self.premium + json.field "isUpcoming", self.is_upcoming if self.premiere_timestamp json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix end - json.field "isNew", self.badges.new? - json.field "is4k", self.badges.four_k? - json.field "is8k", self.badges.eight_k? - json.field "isVr180", self.badges.vr180? - json.field "isVr360", self.badges.vr360? - json.field "is3d", self.badges.three_d? - json.field "hasCaptions", self.badges.closed_captions? end end @@ -147,7 +106,7 @@ struct SearchVideo to_json(nil, json) end - def upcoming? + def is_upcoming premiere_timestamp ? true : false end end @@ -170,7 +129,6 @@ struct SearchPlaylist property video_count : Int32 property videos : Array(SearchPlaylistVideo) property thumbnail : String? - property author_verified : Bool def to_json(locale : String?, json : JSON::Builder) json.object do @@ -183,8 +141,6 @@ struct SearchPlaylist json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" - json.field "authorVerified", self.author_verified - json.field "videoCount", self.video_count json.field "videos" do json.array do @@ -195,7 +151,7 @@ struct SearchPlaylist json.field "lengthSeconds", video.length_seconds json.field "videoThumbnails" do - Invidious::JSONify::APIv1.thumbnails(json, video.id) + generate_thumbnails(json, video.id) end end end @@ -224,10 +180,8 @@ struct SearchChannel property author_thumbnail : String property subscriber_count : Int32 property video_count : Int32 - property channel_handle : String? property description_html : String property auto_generated : Bool - property author_verified : Bool def to_json(locale : String?, json : JSON::Builder) json.object do @@ -235,14 +189,14 @@ struct SearchChannel json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" - json.field "authorVerified", self.author_verified + json.field "authorThumbnails" do json.array do qualities = {32, 48, 76, 100, 176, 512} qualities.each do |quality| json.object do - json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}") json.field "width", quality json.field "height", quality end @@ -253,7 +207,6 @@ struct SearchChannel json.field "autoGenerated", self.auto_generated json.field "subCount", self.subscriber_count json.field "videoCount", self.video_count - json.field "channelHandle", self.channel_handle json.field "description", html_to_content(self.description_html) json.field "descriptionHtml", self.description_html @@ -272,74 +225,6 @@ struct SearchChannel end end -struct SearchHashtag - include DB::Serializable - - property title : String - property url : String - property video_count : Int64 - property channel_count : Int64 - - def to_json(locale : String?, json : JSON::Builder) - json.object do - json.field "type", "hashtag" - json.field "title", self.title - json.field "url", self.url - json.field "videoCount", self.video_count - json.field "channelCount", self.channel_count - end - end -end - -# A `ProblematicTimelineItem` is a `SearchItem` created by Invidious that -# represents an item that caused an exception during parsing. -# -# This is not a parsed object from YouTube but rather an Invidious-only type -# created to gracefully communicate parse errors without throwing away -# the rest of the (hopefully) successfully parsed item on a page. -struct ProblematicTimelineItem - property parse_exception : Exception - property id : String - - def initialize(@parse_exception) - @id = Random.new.hex(8) - end - - def to_json(locale : String?, json : JSON::Builder) - json.object do - json.field "type", "parse-error" - json.field "errorMessage", @parse_exception.message - json.field "errorBacktrace", @parse_exception.inspect_with_backtrace - end - end - - # Provides compatibility with PlaylistVideo - def to_json(json : JSON::Builder, *args, **kwargs) - return to_json("", json) - end - - def to_xml(env, locale, xml : XML::Builder) - xml.element("entry") do - xml.element("id") { xml.text "iv-err-#{@id}" } - xml.element("title") { xml.text "Parse Error: This item has failed to parse" } - xml.element("updated") { xml.text Time.utc.to_rfc3339 } - - xml.element("content", type: "xhtml") do - xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("div") do - xml.element("h4") { translate(locale, "timeline_parse_error_placeholder_heading") } - xml.element("p") { translate(locale, "timeline_parse_error_placeholder_message") } - end - - xml.element("pre") do - get_issue_template(env, @parse_exception) - end - end - end - end - end -end - class Category include DB::Serializable @@ -375,11 +260,4 @@ class Category end end -struct Continuation - getter token - - def initialize(@token : String) - end -end - -alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category | ProblematicTimelineItem +alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr deleted file mode 100644 index 6d198a42..00000000 --- a/src/invidious/helpers/sig_helper.cr +++ /dev/null @@ -1,349 +0,0 @@ -require "uri" -require "socket" -require "socket/tcp_socket" -require "socket/unix_socket" - -{% if flag?(:advanced_debug) %} - require "io/hexdump" -{% end %} - -private alias NetworkEndian = IO::ByteFormat::NetworkEndian - -module Invidious::SigHelper - enum UpdateStatus - Updated - UpdateNotRequired - Error - end - - # ------------------- - # Payload types - # ------------------- - - abstract struct Payload - end - - struct StringPayload < Payload - getter string : String - - def initialize(str : String) - raise Exception.new("SigHelper: String can't be empty") if str.empty? - @string = str - end - - def self.from_bytes(slice : Bytes) - size = IO::ByteFormat::NetworkEndian.decode(UInt16, slice) - if size == 0 # Error code - raise Exception.new("SigHelper: Server encountered an error") - end - - if (slice.bytesize - 2) != size - raise Exception.new("SigHelper: String size mismatch") - end - - if str = String.new(slice[2..]) - return self.new(str) - else - raise Exception.new("SigHelper: Can't read string from socket") - end - end - - def to_io(io) - # `.to_u16` raises if there is an overflow during the conversion - io.write_bytes(@string.bytesize.to_u16, NetworkEndian) - io.write(@string.to_slice) - end - end - - private enum Opcode - FORCE_UPDATE = 0 - DECRYPT_N_SIGNATURE = 1 - DECRYPT_SIGNATURE = 2 - GET_SIGNATURE_TIMESTAMP = 3 - GET_PLAYER_STATUS = 4 - PLAYER_UPDATE_TIMESTAMP = 5 - end - - private record Request, - opcode : Opcode, - payload : Payload? - - # ---------------------- - # High-level functions - # ---------------------- - - class Client - @mux : Multiplexor - - def initialize(uri_or_path) - @mux = Multiplexor.new(uri_or_path) - end - - # Forces the server to re-fetch the YouTube player, and extract the necessary - # components from it (nsig function code, sig function code, signature timestamp). - def force_update : UpdateStatus - request = Request.new(Opcode::FORCE_UPDATE, nil) - - value = send_request(request) do |bytes| - IO::ByteFormat::NetworkEndian.decode(UInt16, bytes) - end - - case value - when 0x0000 then return UpdateStatus::Error - when 0xFFFF then return UpdateStatus::UpdateNotRequired - when 0xF44F then return UpdateStatus::Updated - else - code = value.nil? ? "nil" : value.to_s(base: 16) - raise Exception.new("SigHelper: Invalid status code received #{code}") - end - end - - # Decrypt a provided n signature using the server's current nsig function - # code, and return the result (or an error). - def decrypt_n_param(n : String) : String? - request = Request.new(Opcode::DECRYPT_N_SIGNATURE, StringPayload.new(n)) - - n_dec = self.send_request(request) do |bytes| - StringPayload.from_bytes(bytes).string - end - - return n_dec - end - - # Decrypt a provided s signature using the server's current sig function - # code, and return the result (or an error). - def decrypt_sig(sig : String) : String? - request = Request.new(Opcode::DECRYPT_SIGNATURE, StringPayload.new(sig)) - - sig_dec = self.send_request(request) do |bytes| - StringPayload.from_bytes(bytes).string - end - - return sig_dec - end - - # Return the signature timestamp from the server's current player - def get_signature_timestamp : UInt64? - request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil) - - return self.send_request(request) do |bytes| - IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) - end - end - - # Return the current player's version - def get_player : UInt32? - request = Request.new(Opcode::GET_PLAYER_STATUS, nil) - - return self.send_request(request) do |bytes| - has_player = (bytes[0] == 0xFF) - player_version = IO::ByteFormat::NetworkEndian.decode(UInt32, bytes[1..4]) - has_player ? player_version : nil - end - end - - # Return when the player was last updated - def get_player_timestamp : UInt64? - request = Request.new(Opcode::PLAYER_UPDATE_TIMESTAMP, nil) - - return self.send_request(request) do |bytes| - IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) - end - end - - private def send_request(request : Request, &) - channel = @mux.send(request) - slice = channel.receive - return yield slice - rescue ex - LOGGER.debug("SigHelper: Error when sending a request") - LOGGER.trace(ex.inspect_with_backtrace) - return nil - end - end - - # --------------------- - # Low level functions - # --------------------- - - class Multiplexor - alias TransactionID = UInt32 - record Transaction, channel = ::Channel(Bytes).new - - @prng = Random.new - @mutex = Mutex.new - @queue = {} of TransactionID => Transaction - - @conn : Connection - @uri_or_path : String - - def initialize(@uri_or_path) - @conn = Connection.new(uri_or_path) - listen - end - - def listen : Nil - raise "Socket is closed" if @conn.closed? - - LOGGER.debug("SigHelper: Multiplexor listening") - - spawn do - loop do - begin - receive_data - rescue ex - LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...") - # We close the socket because for some reason is not closed. - @conn.close - loop do - begin - @conn = Connection.new(@uri_or_path) - LOGGER.info("SigHelper: Reconnected to SigHelper!") - rescue ex - LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}'. Retrying") - sleep 500.milliseconds - next - end - break if !@conn.closed? - end - end - Fiber.yield - end - end - end - - def send(request : Request) - transaction = Transaction.new - transaction_id = @prng.rand(TransactionID) - - # Add transaction to queue - @mutex.synchronize do - # On a 32-bits random integer, this should never happen. Though, just in case, ... - if @queue[transaction_id]? - raise Exception.new("SigHelper: Duplicate transaction ID! You got a shiny pokemon!") - end - - @queue[transaction_id] = transaction - end - - write_packet(transaction_id, request) - - return transaction.channel - end - - def receive_data - transaction_id, slice = read_packet - - @mutex.synchronize do - if transaction = @queue.delete(transaction_id) - # Remove transaction from queue and send data to the channel - transaction.channel.send(slice) - LOGGER.trace("SigHelper: Transaction unqueued and data sent to channel") - else - raise Exception.new("SigHelper: Received transaction was not in queue") - end - end - end - - # Read a single packet from the socket - private def read_packet : {TransactionID, Bytes} - # Header - transaction_id = @conn.read_bytes(UInt32, NetworkEndian) - length = @conn.read_bytes(UInt32, NetworkEndian) - - LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} / length #{length}") - - if length > 67_000 - raise Exception.new("SigHelper: Packet longer than expected (#{length})") - end - - # Payload - slice = Bytes.new(length) - @conn.read(slice) if length > 0 - - LOGGER.trace("SigHelper: payload = #{slice}") - LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} - Done") - - return transaction_id, slice - end - - # Write a single packet to the socket - private def write_packet(transaction_id : TransactionID, request : Request) - LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} / opcode #{request.opcode}") - - io = IO::Memory.new(1024) - io.write_bytes(request.opcode.to_u8, NetworkEndian) - io.write_bytes(transaction_id, NetworkEndian) - - if payload = request.payload - payload.to_io(io) - end - - @conn.send(io) - @conn.flush - - LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} - Done") - end - end - - class Connection - @socket : UNIXSocket | TCPSocket - - {% if flag?(:advanced_debug) %} - @io : IO::Hexdump - {% end %} - - def initialize(host_or_path : String) - case host_or_path - when .starts_with?('/') - # Make sure that the file exists - if File.exists?(host_or_path) - @socket = UNIXSocket.new(host_or_path) - else - raise Exception.new("SigHelper: '#{host_or_path}' no such file") - end - when .starts_with?("tcp://") - uri = URI.parse(host_or_path) - @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) - else - uri = URI.parse("tcp://#{host_or_path}") - @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) - end - LOGGER.info("SigHelper: Using helper at '#{host_or_path}'") - - {% if flag?(:advanced_debug) %} - @io = IO::Hexdump.new(@socket, output: STDERR, read: true, write: true) - {% end %} - - @socket.sync = false - @socket.blocking = false - end - - def closed? : Bool - return @socket.closed? - end - - def close : Nil - @socket.close if !@socket.closed? - end - - def flush(*args, **options) - @socket.flush(*args, **options) - end - - def send(*args, **options) - @socket.send(*args, **options) - end - - # Wrap IO functions, with added debug tooling if needed - {% for function in %w(read read_bytes write write_bytes) %} - def {{function.id}}(*args, **options) - {% if flag?(:advanced_debug) %} - @io.{{function.id}}(*args, **options) - {% else %} - @socket.{{function.id}}(*args, **options) - {% end %} - end - {% end %} - end -end diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index 82a28fc0..ee09415b 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -1,53 +1,73 @@ -require "http/params" -require "./sig_helper" +alias SigProc = Proc(Array(String), Int32, Array(String)) -class Invidious::DecryptFunction - @last_update : Time = Time.utc - 42.days +struct DecryptFunction + @decrypt_function = [] of {SigProc, Int32} + @decrypt_time = Time.monotonic - def initialize(uri_or_path) - @client = SigHelper::Client.new(uri_or_path) - self.check_update + def initialize(@use_polling = true) end - def check_update - # If we have updated in the last 5 minutes, do nothing - return if (Time.utc - @last_update) < 5.minutes + def update_decrypt_function + @decrypt_function = fetch_decrypt_function + end - # Get the amount of time elapsed since when the player was updated, in the - # event where multiple invidious processes are run in parallel. - update_time_elapsed = (@client.get_player_timestamp || 301).seconds + private def fetch_decrypt_function(id = "CvFH_6DNRCY") + document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body + url = document.match(/src="(?\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"] + player = YT_POOL.client &.get(url).body - if update_time_elapsed > 5.minutes - LOGGER.debug("Signature: Player might be outdated, updating") - @client.force_update - @last_update = Time.utc + function_name = player.match(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"] + function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?[^}]+)}/m).not_nil!["body"] + function_body = function_body.split(";")[1..-2] + + var_name = function_body[0][0, 2] + var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?(.*?))};/).not_nil!["body"] + + operations = {} of String => SigProc + var_body.split("},").each do |operation| + op_name = operation.match(/^[^:]+/).not_nil![0] + op_body = operation.match(/\{[^}]+/).not_nil![0] + + case op_body + when "{a.reverse()" + operations[op_name] = ->(a : Array(String), _b : Int32) { a.reverse } + when "{a.splice(0,b)" + operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a } + else + operations[op_name] = ->(a : Array(String), b : Int32) { c = a[0]; a[0] = a[b % a.size]; a[b % a.size] = c; a } + end end + + decrypt_function = [] of {SigProc, Int32} + function_body.each do |function| + function = function.lchop(var_name).delete("[].") + + op_name = function.match(/[^\(]+/).not_nil![0] + value = function.match(/\(\w,(?[\d]+)\)/).not_nil!["value"].to_i + + decrypt_function << {operations[op_name], value} + end + + return decrypt_function end - def decrypt_nsig(n : String) : String? - self.check_update - return @client.decrypt_n_param(n) - rescue ex - LOGGER.debug(ex.message || "Signature: Unknown error") - LOGGER.trace(ex.inspect_with_backtrace) - return nil - end + def decrypt_signature(fmt : Hash(String, JSON::Any)) + return "" if !fmt["s"]? || !fmt["sp"]? - def decrypt_signature(str : String) : String? - self.check_update - return @client.decrypt_sig(str) - rescue ex - LOGGER.debug(ex.message || "Signature: Unknown error") - LOGGER.trace(ex.inspect_with_backtrace) - return nil - end + sp = fmt["sp"].as_s + sig = fmt["s"].as_s.split("") + if !@use_polling + now = Time.monotonic + if now - @decrypt_time > 60.seconds || @decrypt_function.size == 0 + @decrypt_function = fetch_decrypt_function + @decrypt_time = Time.monotonic + end + end - def get_sts : UInt64? - self.check_update - return @client.get_signature_timestamp - rescue ex - LOGGER.debug(ex.message || "Signature: Unknown error") - LOGGER.trace(ex.inspect_with_backtrace) - return nil + @decrypt_function.each do |proc, value| + sig = proc.call(sig, value) + end + + return "&#{sp}=#{sig.join("")}" end end diff --git a/src/ext/kemal_static_file_handler.cr b/src/invidious/helpers/static_file_handler.cr similarity index 89% rename from src/ext/kemal_static_file_handler.cr rename to src/invidious/helpers/static_file_handler.cr index a5f42261..6ef2d74c 100644 --- a/src/ext/kemal_static_file_handler.cr +++ b/src/invidious/helpers/static_file_handler.cr @@ -71,7 +71,7 @@ def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt filesize = data.bytesize attachment(env, filename, disposition) - Kemal.config.static_headers.try(&.call(env, file_path, filestat)) + Kemal.config.static_headers.try(&.call(env.response, file_path, filestat)) file = IO::Memory.new(data) if env.request.method == "GET" && env.request.headers.has_key?("Range") @@ -111,7 +111,7 @@ module Kemal if @fallthrough call_next(context) else - context.response.status = HTTP::Status::METHOD_NOT_ALLOWED + context.response.status_code = 405 context.response.headers.add("Allow", "GET, HEAD") end return @@ -124,7 +124,7 @@ module Kemal # File path cannot contains '\0' (NUL) because all filesystem I know # don't accept '\0' character as file name. if request_path.includes? '\0' - context.response.status = HTTP::Status::BAD_REQUEST + context.response.status_code = 400 return end @@ -143,15 +143,13 @@ module Kemal add_cache_headers(context.response.headers, last_modified) if cache_request?(context, last_modified) - context.response.status = HTTP::Status::NOT_MODIFIED + context.response.status_code = 304 return end send_file(context, file_path, file[:data], file[:filestat]) else - file_info = File.info?(file_path) - is_dir = file_info.try &.directory? || false - is_file = file_info.try &.file? || false + is_dir = Dir.exists? file_path if request_path != expanded_path redirect_to context, expanded_path @@ -159,21 +157,19 @@ module Kemal redirect_to context, expanded_path + '/' end - return call_next(context) if file_info.nil? - - if is_dir + if Dir.exists?(file_path) if config.is_a?(Hash) && config["dir_listing"] == true context.response.content_type = "text/html" directory_listing(context.response, request_path, file_path) else call_next(context) end - elsif is_file - last_modified = file_info.modification_time + elsif File.exists?(file_path) + last_modified = modification_time(file_path) add_cache_headers(context.response.headers, last_modified) if cache_request?(context, last_modified) - context.response.status = HTTP::Status::NOT_MODIFIED + context.response.status_code = 304 return end @@ -181,12 +177,14 @@ module Kemal data = Bytes.new(size) File.open(file_path, &.read(data)) - @cached_files[file_path] = {data: data, filestat: file_info} - send_file(context, file_path, data, file_info) + filestat = File.info(file_path) + + @cached_files[file_path] = {data: data, filestat: filestat} + send_file(context, file_path, data, filestat) else send_file(context, file_path) end - else # Not a normal file (FIFO/device/socket) + else call_next(context) end end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 5637e533..c1dc17db 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -52,9 +52,9 @@ def recode_length_seconds(time) end def decode_interval(string : String) : Time::Span - raw_minutes = string.try &.to_i32? + rawMinutes = string.try &.to_i32? - if !raw_minutes + if !rawMinutes hours = /(?\d+)h/.match(string).try &.["hours"].try &.to_i32 hours ||= 0 @@ -63,7 +63,7 @@ def decode_interval(string : String) : Time::Span time = Time::Span.new(hours: hours, minutes: minutes) else - time = Time::Span.new(minutes: raw_minutes) + time = Time::Span.new(minutes: rawMinutes) end return time @@ -111,27 +111,24 @@ def decode_date(string : String) else nil # Continue end - # String matches format "20 hours ago", "4 months ago", "20s ago", "15min ago"... - match = string.match(/(?\d+) ?(?[smhdwy]\w*) ago/) + # String matches format "20 hours ago", "4 months ago"... + date = string.split(" ")[-3, 3] + delta = date[0].to_i - raise "Could not parse #{string}" if match.nil? - - delta = match["count"].to_i - - case match["span"] - when .starts_with? "s" # second(s) + case date[1] + when .includes? "second" delta = delta.seconds - when .starts_with? "mi" # minute(s) + when .includes? "minute" delta = delta.minutes - when .starts_with? "h" # hour(s) + when .includes? "hour" delta = delta.hours - when .starts_with? "d" # day(s) + when .includes? "day" delta = delta.days - when .starts_with? "w" # week(s) + when .includes? "week" delta = delta.weeks - when .starts_with? "mo" # month(s) + when .includes? "month" delta = delta.months - when .starts_with? "y" # year(s) + when .includes? "year" delta = delta.years else raise "Could not parse #{string}" @@ -164,19 +161,21 @@ def number_with_separator(number) number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse end -def short_text_to_number(short_text : String) : Int64 - matches = /(?\d+(\.\d+)?)\s?(?[mMkKbB]?)/.match(short_text) - number = matches.try &.["number"].to_f || 0.0 - - case matches.try &.["suffix"].downcase - when "k" then number *= 1_000 - when "m" then number *= 1_000_000 - when "b" then number *= 1_000_000_000 +def short_text_to_number(short_text : String) : Int32 + case short_text + when .ends_with? "M" + number = short_text.rstrip(" mM").to_f + number *= 1000000 + when .ends_with? "K" + number = short_text.rstrip(" kK").to_f + number *= 1000 + else + number = short_text.rstrip(" ") end - return number.to_i64 -rescue ex - return 0_i64 + number = number.to_i + + return number end def number_to_short_text(number) @@ -262,7 +261,7 @@ def get_referer(env, fallback = "/", unroll = true) end referer = referer.request_target - referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,*0-9a-zA-Z+]/, "").lstrip("/\\") + referer = "/" + referer.gsub(/[^\/?@&%=\-_.0-9a-zA-Z]/, "").lstrip("/\\") if referer == env.request.path referer = fallback @@ -323,82 +322,64 @@ def parse_range(range) return 0_i64, nil end -def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "…") : String - str = uri.to_s.sub(/^https?:\/\//, "") - if str.size > max_length - str = "#{str[0, max_length]}#{suffix}" +def fetch_random_instance + begin + instance_api_client = make_client(URI.parse("https://api.invidious.io")) + + # Timeouts + instance_api_client.connect_timeout = 10.seconds + instance_api_client.dns_timeout = 10.seconds + + instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a + instance_api_client.close + rescue Socket::ConnectError | IO::TimeoutError | JSON::ParseException + instance_list = [] of JSON::Any end - return str -end -# Get the html link from a NavigationEndpoint or an innertubeCommand -def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String) - if url = endpoint.dig?("urlEndpoint", "url").try &.as_s - url = URI.parse(url) - displayed_url = text + filtered_instance_list = [] of String - if url.host == "youtu.be" - url = "/watch?v=#{url.request_target.lstrip('/')}" - elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") - if url.path == "/redirect" - # Sometimes, links can be corrupted (why?) so make sure to fallback - # nicely. See https://github.com/iv-org/invidious/issues/2682 - url = url.query_params["q"]? || "" - displayed_url = url - else - url = url.request_target - displayed_url = "youtube.com#{url}" + instance_list.each do |data| + # TODO Check if current URL is onion instance and use .onion types if so. + if data[1]["type"] == "https" + # Instances can have statistics disabled, which is an requirement of version validation. + # as_nil? doesn't exist. Thus we'll have to handle the error raised if as_nil fails. + begin + data[1]["stats"].as_nil + next + rescue TypeCastError + end + + # stats endpoint could also lack the software dict. + next if data[1]["stats"]["software"]?.nil? + + # Makes sure the instance isn't too outdated. + if remote_version = data[1]["stats"]?.try &.["software"]?.try &.["version"] + remote_commit_date = remote_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/) + next if !remote_commit_date + + remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC) + local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC) + + next if (remote_commit_date - local_commit_date).abs.days > 30 + + begin + data[1]["monitor"].as_nil + health = data[1]["monitor"].as_h["dailyRatios"][0].as_h["ratio"] + filtered_instance_list << data[0].as_s if health.to_s.to_f > 90 + rescue TypeCastError + # We can't check the health if the monitoring is broken. Thus we'll just add it to the list + # and move on. Ideally we'll ignore any instance that has broken health monitoring but due to the fact that + # it's an error that often occurs with all the instances at the same time, we have to just skip the check. + filtered_instance_list << data[0].as_s + end end end - - text = %(#{reduce_uri(displayed_url)}) - elsif watch_endpoint = endpoint.dig?("watchEndpoint") - start_time = watch_endpoint["startTimeSeconds"]?.try &.as_i - link_video_id = watch_endpoint["videoId"].as_s - - url = "/watch?v=#{link_video_id}" - url += "&t=#{start_time}" if !start_time.nil? - - # If the current video ID (passed through from the caller function) - # is the same as the video ID in the link, add HTML attributes for - # the JS handler function that bypasses page reload. - # - # See: https://github.com/iv-org/invidious/issues/3063 - if link_video_id == video_id - start_time ||= 0 - text = %(#{reduce_uri(text)}) - else - text = %(#{text}) - end - elsif url = endpoint.dig?("commandMetadata", "webCommandMetadata", "url").try &.as_s - if text.starts_with?(/\s?[@#]/) - # Handle "pings" in comments and hasthags differently - # See: - # - https://github.com/iv-org/invidious/issues/3038 - # - https://github.com/iv-org/invidious/issues/3062 - text = %(#{text}) - else - text = %(#{reduce_uri(text)}) - end end - return text -end - -def encrypt_ecb_without_salt(data, key) - cipher = OpenSSL::Cipher.new("aes-128-ecb") - cipher.encrypt - cipher.key = key - - io = IO::Memory.new - io.write(cipher.update(data)) - io.write(cipher.final) - io.rewind - - return io -end - -def invidious_companion_encrypt(data) - timestamp = Time.utc.to_unix - encrypted_data = encrypt_ecb_without_salt("#{timestamp}|#{data}", CONFIG.invidious_companion_key) - return Base64.urlsafe_encode(encrypted_data) + + # If for some reason no instances managed to get fetched successfully then we'll just redirect to redirect.invidious.io + if filtered_instance_list.size == 0 + return "redirect.invidious.io" + end + + return filtered_instance_list.sample(1)[0] end diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr deleted file mode 100644 index 260d250f..00000000 --- a/src/invidious/helpers/webvtt.cr +++ /dev/null @@ -1,81 +0,0 @@ -# Namespace for logic relating to generating WebVTT files -# -# Probably not compliant to WebVTT's specs but it is enough for Invidious. -module WebVTT - # A WebVTT builder generates WebVTT files - private class Builder - # See https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API#cue_payload - private ESCAPE_SUBSTITUTIONS = { - '&' => "&", - '<' => "<", - '>' => ">", - '\u200E' => "‎", - '\u200F' => "‏", - '\u00A0' => " ", - } - - def initialize(@io : IO) - end - - # Writes an vtt cue with the specified time stamp and contents - def cue(start_time : Time::Span, end_time : Time::Span, text : String) - timestamp(start_time, end_time) - @io << self.escape(text) - @io << "\n\n" - end - - private def timestamp(start_time : Time::Span, end_time : Time::Span) - timestamp_component(start_time) - @io << " --> " - timestamp_component(end_time) - - @io << '\n' - end - - private def timestamp_component(timestamp : Time::Span) - @io << timestamp.hours.to_s.rjust(2, '0') - @io << ':' << timestamp.minutes.to_s.rjust(2, '0') - @io << ':' << timestamp.seconds.to_s.rjust(2, '0') - @io << '.' << timestamp.milliseconds.to_s.rjust(3, '0') - end - - private def escape(text : String) : String - return text.gsub(ESCAPE_SUBSTITUTIONS) - end - - def document(setting_fields : Hash(String, String)? = nil, &) - @io << "WEBVTT\n" - - if setting_fields - setting_fields.each do |name, value| - @io << name << ": " << value << '\n' - end - end - - @io << '\n' - - yield - end - end - - # Returns the resulting `String` of writing WebVTT to the yielded `WebVTT::Builder` - # - # ``` - # string = WebVTT.build do |vtt| - # vtt.cue(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1") - # vtt.cue(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2") - # end - # - # string # => "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nLine 1\n\n00:00:02.000 --> 00:00:03.000\nLine 2\n\n" - # ``` - # - # Accepts an optional settings fields hash to add settings attribute to the resulting vtt file. - def self.build(setting_fields : Hash(String, String)? = nil, &) - String.build do |str| - builder = Builder.new(str) - builder.document(setting_fields) do - yield builder - end - end - end -end diff --git a/src/invidious/http_server/utils.cr b/src/invidious/http_server/utils.cr deleted file mode 100644 index 623a9177..00000000 --- a/src/invidious/http_server/utils.cr +++ /dev/null @@ -1,41 +0,0 @@ -require "uri" - -module Invidious::HttpServer - module Utils - extend self - - def proxy_video_url(raw_url : String, *, region : String? = nil, absolute : Bool = false) - url = URI.parse(raw_url) - - # Add some URL parameters - params = url.query_params - params["host"] = url.host.not_nil! # Should never be nil, in theory - params["region"] = region if !region.nil? - url.query_params = params - - if absolute - return "#{HOST_URL}#{url.request_target}" - else - return url.request_target - end - end - - def add_params_to_url(url : String | URI, params : URI::Params) : URI - url = URI.parse(url) if url.is_a?(String) - - url_query = url.query || "" - - # Append the parameters - url.query = String.build do |str| - if !url_query.empty? - str << url_query - str << '&' - end - - str << params - end - - return url - end - end -end diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr index b6b673f7..ec0cad64 100644 --- a/src/invidious/jobs.cr +++ b/src/invidious/jobs.cr @@ -1,39 +1,12 @@ module Invidious::Jobs JOBS = [] of BaseJob - # Automatically generate a structure that wraps the various - # jobs' configs, so that the following YAML config can be used: - # - # jobs: - # job_name: - # enabled: true - # some_property: "value" - # - macro finished - struct JobsConfig - include YAML::Serializable - - {% for sc in BaseJob.subclasses %} - # Voodoo macro to transform `Some::Module::CustomJob` to `custom` - {% class_name = sc.id.split("::").last.id.gsub(/Job$/, "").underscore %} - - getter {{ class_name }} = {{ sc.name }}::Config.new - {% end %} - - def initialize - end - end - end - def self.register(job : BaseJob) JOBS << job end def self.start_all JOBS.each do |job| - # Don't run the main rountine if the job is disabled by config - next if job.disabled? - spawn { job.begin } end end diff --git a/src/invidious/jobs/base_job.cr b/src/invidious/jobs/base_job.cr index f90f0bfe..47e75864 100644 --- a/src/invidious/jobs/base_job.cr +++ b/src/invidious/jobs/base_job.cr @@ -1,33 +1,3 @@ abstract class Invidious::Jobs::BaseJob abstract def begin - - # When this base job class is inherited, make sure to define - # a basic "Config" structure, that contains the "enable" property, - # and to create the associated instance property. - # - macro inherited - macro finished - # This config structure can be expanded as required. - struct Config - include YAML::Serializable - - property enable = true - - def initialize - end - end - - property cfg = Config.new - - # Return true if job is enabled by config - protected def enabled? : Bool - return (@cfg.enable == true) - end - - # Return true if job is disabled by config - protected def disabled? : Bool - return (@cfg.enable == false) - end - end - end end diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr new file mode 100644 index 00000000..71f8a938 --- /dev/null +++ b/src/invidious/jobs/bypass_captcha_job.cr @@ -0,0 +1,135 @@ +class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob + def begin + loop do + begin + random_video = PG_DB.query_one?("select id, ucid from (select id, ucid from channel_videos limit 1000) as s ORDER BY RANDOM() LIMIT 1", as: {id: String, ucid: String}) + if !random_video + random_video = {id: "zj82_v2R6ts", ucid: "UCK87Lox575O_HCHBWaBSyGA"} + end + {"/watch?v=#{random_video["id"]}&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: random_video["ucid"])}.each do |path| + response = YT_POOL.client &.get(path) + if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") + html = XML.parse_html(response.body) + form = html.xpath_node(%(//form[@action="/das_captcha"])).not_nil! + site_key = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-sitekey"] + s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-s"] + + inputs = {} of String => String + form.xpath_nodes(%(.//input[@name])).map do |node| + inputs[node["name"]] = node["value"] + end + + headers = response.cookies.add_request_headers(HTTP::Headers.new) + + response = JSON.parse(HTTP::Client.post(CONFIG.captcha_api_url + "/createTask", + headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { + "clientKey" => CONFIG.captcha_key, + "task" => { + "type" => "NoCaptchaTaskProxyless", + "websiteURL" => "https://www.youtube.com#{path}", + "websiteKey" => site_key, + "recaptchaDataSValue" => s_value, + }, + }.to_json).body) + + raise response["error"].as_s if response["error"]? + task_id = response["taskId"].as_i + + loop do + sleep 10.seconds + + response = JSON.parse(HTTP::Client.post(CONFIG.captcha_api_url + "/getTaskResult", + headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { + "clientKey" => CONFIG.captcha_key, + "taskId" => task_id, + }.to_json).body) + + if response["status"]?.try &.== "ready" + break + elsif response["errorId"]?.try &.as_i != 0 + raise response["errorDescription"].as_s + end + end + + inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s + headers["Cookies"] = response["solution"]["cookies"].as_h?.try &.map { |k, v| "#{k}=#{v}" }.join("; ") || "" + response = YT_POOL.client &.post("/das_captcha", headers, form: inputs) + + response.cookies + .select { |cookie| cookie.name != "PREF" } + .each { |cookie| CONFIG.cookies << cookie } + + # Persist cookies between runs + File.write("config/config.yml", CONFIG.to_yaml) + elsif response.headers["Location"]?.try &.includes?("/sorry/index") + location = response.headers["Location"].try { |u| URI.parse(u) } + headers = HTTP::Headers{":authority" => location.host.not_nil!} + response = YT_POOL.client &.get(location.request_target, headers) + + html = XML.parse_html(response.body) + form = html.xpath_node(%(//form[@action="index"])).not_nil! + site_key = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-sitekey"] + s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-s"] + + inputs = {} of String => String + form.xpath_nodes(%(.//input[@name])).map do |node| + inputs[node["name"]] = node["value"] + end + + captcha_client = HTTPClient.new(URI.parse(CONFIG.captcha_api_url)) + captcha_client.family = CONFIG.force_resolve || Socket::Family::INET + response = JSON.parse(captcha_client.post("/createTask", + headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { + "clientKey" => CONFIG.captcha_key, + "task" => { + "type" => "NoCaptchaTaskProxyless", + "websiteURL" => location.to_s, + "websiteKey" => site_key, + "recaptchaDataSValue" => s_value, + }, + }.to_json).body) + + captcha_client.close + + raise response["error"].as_s if response["error"]? + task_id = response["taskId"].as_i + + loop do + sleep 10.seconds + + response = JSON.parse(captcha_client.post("/getTaskResult", + headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { + "clientKey" => CONFIG.captcha_key, + "taskId" => task_id, + }.to_json).body) + + if response["status"]?.try &.== "ready" + break + elsif response["errorId"]?.try &.as_i != 0 + raise response["errorDescription"].as_s + end + end + + inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s + headers["Cookies"] = response["solution"]["cookies"].as_h?.try &.map { |k, v| "#{k}=#{v}" }.join("; ") || "" + response = YT_POOL.client &.post("/sorry/index", headers: headers, form: inputs) + headers = HTTP::Headers{ + "Cookie" => URI.parse(response.headers["location"]).query_params["google_abuse"].split(";")[0], + } + cookies = HTTP::Cookies.from_client_headers(headers) + + cookies.each { |cookie| CONFIG.cookies << cookie } + + # Persist cookies between runs + File.write("config/config.yml", CONFIG.to_yaml) + end + end + rescue ex + LOGGER.error("BypassCaptchaJob: #{ex.message}") + ensure + sleep 1.minute + Fiber.yield + end + end + end +end diff --git a/src/invidious/jobs/clear_expired_items_job.cr b/src/invidious/jobs/clear_expired_items_job.cr deleted file mode 100644 index 17191aac..00000000 --- a/src/invidious/jobs/clear_expired_items_job.cr +++ /dev/null @@ -1,27 +0,0 @@ -class Invidious::Jobs::ClearExpiredItemsJob < Invidious::Jobs::BaseJob - # Remove items (videos, nonces, etc..) whose cache is outdated every hour. - # Removes the need for a cron job. - def begin - loop do - failed = false - - LOGGER.info("jobs: running ClearExpiredItems job") - - begin - Invidious::Database::Videos.delete_expired - Invidious::Database::Nonces.delete_expired - rescue DB::Error - failed = true - end - - # Retry earlier than scheduled on DB error - if failed - LOGGER.info("jobs: ClearExpiredItems failed. Retrying in 10 minutes.") - sleep 10.minutes - else - LOGGER.info("jobs: ClearExpiredItems done.") - sleep 1.hour - end - end - end -end diff --git a/src/invidious/jobs/instance_refresh_job.cr b/src/invidious/jobs/instance_refresh_job.cr deleted file mode 100644 index cb4280b9..00000000 --- a/src/invidious/jobs/instance_refresh_job.cr +++ /dev/null @@ -1,97 +0,0 @@ -class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob - # We update the internals of a constant as so it can be accessed from anywhere - # within the codebase - # - # "INSTANCES" => Array(Tuple(String, String)) # region, instance - - INSTANCES = {"INSTANCES" => [] of Tuple(String, String)} - - def initialize - end - - def begin - loop do - refresh_instances - LOGGER.info("InstanceListRefreshJob: Done, sleeping for 30 minutes") - sleep 30.minute - Fiber.yield - end - end - - # Refreshes the list of instances used for redirects. - # - # Does the following three checks for each instance - # - Is it a clear-net instance? - # - Is it an instance with a good uptime? - # - Is it an updated instance? - private def refresh_instances - raw_instance_list = self.fetch_instances - filtered_instance_list = [] of Tuple(String, String) - - raw_instance_list.each do |instance_data| - # TODO allow Tor hidden service instances when the current instance - # is also a hidden service. Same for i2p and any other non-clearnet instances. - begin - domain = instance_data[0] - info = instance_data[1] - stats = info["stats"] - - next unless info["type"] == "https" - next if bad_uptime?(info["monitor"]) - next if outdated?(stats["software"]["version"]) - - filtered_instance_list << {info["region"].as_s, domain.as_s} - rescue ex - if domain - LOGGER.info("InstanceListRefreshJob: failed to parse information from '#{domain}' because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ") - else - LOGGER.info("InstanceListRefreshJob: failed to parse information from an instance because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ") - end - end - end - - if !filtered_instance_list.empty? - INSTANCES["INSTANCES"] = filtered_instance_list - end - end - - # Fetches information regarding instances from api.invidious.io or an otherwise configured URL - private def fetch_instances : Array(JSON::Any) - begin - # We directly call the stdlib HTTP::Client here as it allows us to negate the effects - # of the force_resolve config option. This is needed as api.invidious.io does not support ipv6 - # and as such the following request raises if we were to use force_resolve with the ipv6 value. - instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io")) - - # Timeouts - instance_api_client.connect_timeout = 10.seconds - instance_api_client.dns_timeout = 10.seconds - - raw_instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a - instance_api_client.close - rescue ex : Socket::ConnectError | IO::TimeoutError | JSON::ParseException - raw_instance_list = [] of JSON::Any - end - - return raw_instance_list - end - - # Checks if the given target instance is outdated - private def outdated?(target_instance_version) : Bool - remote_commit_date = target_instance_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/) - return false if !remote_commit_date - - remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC) - local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC) - - return (remote_commit_date - local_commit_date).abs.days > 30 - end - - # Checks if the uptime of the target instance is greater than 90% over a 30 day period - private def bad_uptime?(target_instance_health_monitor) : Bool - return true if !target_instance_health_monitor["down"].as_bool == false - return true if target_instance_health_monitor["uptime"].as_f < 90 - - return false - end -end diff --git a/src/invidious/jobs/notification_job.cr b/src/invidious/jobs/notification_job.cr index 968ee47f..2f525e08 100644 --- a/src/invidious/jobs/notification_job.cr +++ b/src/invidious/jobs/notification_job.cr @@ -1,103 +1,15 @@ -struct VideoNotification - getter video_id : String - getter channel_id : String - getter published : Time - - def_hash @channel_id, @video_id - - def ==(other) - video_id == other.video_id - end - - def self.from_video(video : ChannelVideo) : self - VideoNotification.new(video.id, video.ucid, video.published) - end - - def initialize(@video_id, @channel_id, @published) - end - - def clone : VideoNotification - VideoNotification.new(video_id.clone, channel_id.clone, published.clone) - end -end - class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob - private getter notification_channel : ::Channel(VideoNotification) - private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)}) + private getter connection_channel : Channel({Bool, Channel(PQ::Notification)}) private getter pg_url : URI - def initialize(@notification_channel, @connection_channel, @pg_url) + def initialize(@connection_channel, @pg_url) end def begin - connections = [] of ::Channel(PQ::Notification) + connections = [] of Channel(PQ::Notification) PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) } - # hash of channels to their videos (id+published) that need notifying - to_notify = Hash(String, Set(VideoNotification)).new( - ->(hash : Hash(String, Set(VideoNotification)), key : String) { - hash[key] = Set(VideoNotification).new - } - ) - notify_mutex = Mutex.new - - # fiber to locally cache all incoming notifications (from pubsub webhooks and refresh channels job) - spawn do - begin - loop do - notification = notification_channel.receive - notify_mutex.synchronize do - to_notify[notification.channel_id] << notification - end - end - end - end - # fiber to regularly persist all cached notifications - spawn do - loop do - begin - LOGGER.debug("NotificationJob: waking up") - cloned = {} of String => Set(VideoNotification) - notify_mutex.synchronize do - cloned = to_notify.clone - to_notify.clear - end - - cloned.each do |channel_id, notifications| - if notifications.empty? - next - end - - LOGGER.info("NotificationJob: updating channel #{channel_id} with #{notifications.size} notifications") - if CONFIG.enable_user_notifications - video_ids = notifications.map(&.video_id) - Invidious::Database::Users.add_multiple_notifications(channel_id, video_ids) - PG_DB.using_connection do |conn| - notifications.each do |n| - # Deliver notifications to `/api/v1/auth/notifications` - payload = { - "topic" => n.channel_id, - "videoId" => n.video_id, - "published" => n.published.to_unix, - }.to_json - conn.exec("NOTIFY notifications, E'#{payload}'") - end - end - else - Invidious::Database::Users.feed_needs_update(channel_id) - end - end - - LOGGER.trace("NotificationJob: Done, sleeping") - rescue ex - LOGGER.error("NotificationJob: #{ex.message}") - end - sleep 1.minute - Fiber.yield - end - end - loop do action, connection = connection_channel.receive diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 80812a63..92681408 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -8,7 +8,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob max_fibers = CONFIG.channel_threads lim_fibers = max_fibers active_fibers = 0 - active_channel = ::Channel(Bool).new + active_channel = Channel(Bool).new backoff = 2.minutes loop do diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr index 4f8130df..4b52c959 100644 --- a/src/invidious/jobs/refresh_feeds_job.cr +++ b/src/invidious/jobs/refresh_feeds_job.cr @@ -7,7 +7,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob def begin max_fibers = CONFIG.feed_threads active_fibers = 0 - active_channel = ::Channel(Bool).new + active_channel = Channel(Bool).new loop do db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs| diff --git a/src/invidious/jobs/statistics_refresh_job.cr b/src/invidious/jobs/statistics_refresh_job.cr index 66c91ad5..a113bd77 100644 --- a/src/invidious/jobs/statistics_refresh_job.cr +++ b/src/invidious/jobs/statistics_refresh_job.cr @@ -18,13 +18,6 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob "updatedAt" => Time.utc.to_unix, "lastChannelRefreshedAt" => 0_i64, }, - - # - # "totalRequests" => 0_i64, - # "successfulRequests" => 0_i64 - # "ratio" => 0_i64 - # - "playback" => {} of String => Int64 | Float64, } private getter db : DB::Database @@ -37,7 +30,7 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob loop do refresh_stats - sleep 10.minute + sleep 1.minute Fiber.yield end end @@ -56,15 +49,12 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob users = STATISTICS.dig("usage", "users").as(Hash(String, Int64)) users["total"] = Invidious::Database::Statistics.count_users_total - users["activeHalfyear"] = Invidious::Database::Statistics.count_users_active_6m - users["activeMonth"] = Invidious::Database::Statistics.count_users_active_1m + users["activeHalfyear"] = Invidious::Database::Statistics.count_users_active_1m + users["activeMonth"] = Invidious::Database::Statistics.count_users_active_6m STATISTICS["metadata"] = { "updatedAt" => Time.utc.to_unix, "lastChannelRefreshedAt" => Invidious::Database::Statistics.channel_last_update.try &.to_unix || 0_i64, } - - # Reset playback requests tracker - STATISTICS["playback"] = {} of String => Int64 | Float64 end end diff --git a/src/invidious/jobs/subscribe_to_feeds_job.cr b/src/invidious/jobs/subscribe_to_feeds_job.cr index 8584fb9c..a431a48a 100644 --- a/src/invidious/jobs/subscribe_to_feeds_job.cr +++ b/src/invidious/jobs/subscribe_to_feeds_job.cr @@ -12,7 +12,7 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob end active_fibers = 0 - active_channel = ::Channel(Bool).new + active_channel = Channel(Bool).new loop do db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > interval '4 days' OR subscribed IS NULL") do |rs| diff --git a/src/invidious/jobs/update_decrypt_function_job.cr b/src/invidious/jobs/update_decrypt_function_job.cr new file mode 100644 index 00000000..6fa0ae1b --- /dev/null +++ b/src/invidious/jobs/update_decrypt_function_job.cr @@ -0,0 +1,14 @@ +class Invidious::Jobs::UpdateDecryptFunctionJob < Invidious::Jobs::BaseJob + def begin + loop do + begin + DECRYPT_FUNCTION.update_decrypt_function + rescue ex + LOGGER.error("UpdateDecryptFunctionJob : #{ex.message}") + ensure + sleep 1.minute + Fiber.yield + end + end + end +end diff --git a/src/invidious/jsonify/api_v1/common.cr b/src/invidious/jsonify/api_v1/common.cr deleted file mode 100644 index 64b06465..00000000 --- a/src/invidious/jsonify/api_v1/common.cr +++ /dev/null @@ -1,18 +0,0 @@ -require "json" - -module Invidious::JSONify::APIv1 - extend self - - def thumbnails(json : JSON::Builder, id : String) - json.array do - build_thumbnails(id).each do |thumbnail| - json.object do - json.field "quality", thumbnail[:name] - json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" - json.field "width", thumbnail[:width] - json.field "height", thumbnail[:height] - end - end - end - end -end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr deleted file mode 100644 index 58805af2..00000000 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ /dev/null @@ -1,301 +0,0 @@ -require "json" - -module Invidious::JSONify::APIv1 - extend self - - def video(video : Video, json : JSON::Builder, *, locale : String?, proxy : Bool = false) - json.object do - json.field "type", video.video_type - - json.field "title", video.title - json.field "videoId", video.id - - json.field "error", video.info["reason"] if video.info["reason"]? - - json.field "videoThumbnails" do - self.thumbnails(json, video.id) - end - json.field "storyboards" do - self.storyboards(json, video.id, video.storyboards) - end - - json.field "description", video.description - json.field "descriptionHtml", video.description_html - json.field "published", video.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale)) - json.field "keywords", video.keywords - - json.field "viewCount", video.views - json.field "likeCount", video.likes - json.field "dislikeCount", 0_i64 - - json.field "paid", video.paid - json.field "premium", video.premium - json.field "isFamilyFriendly", video.is_family_friendly - json.field "allowedRegions", video.allowed_regions - json.field "genre", video.genre - json.field "genreUrl", video.genre_url - - json.field "author", video.author - json.field "authorId", video.ucid - json.field "authorUrl", "/channel/#{video.ucid}" - json.field "authorVerified", video.author_verified - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", video.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "subCountText", video.sub_count_text - - json.field "lengthSeconds", video.length_seconds - json.field "allowRatings", video.allow_ratings - json.field "rating", 0_i64 - json.field "isListed", video.is_listed - json.field "liveNow", video.live_now - json.field "isPostLiveDvr", video.post_live_dvr - json.field "isUpcoming", video.upcoming? - - if video.premiere_timestamp - json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix - end - - if hlsvp = video.hls_manifest_url - hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL) - json.field "hlsUrl", hlsvp - end - - json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{video.id}" - - json.field "adaptiveFormats" do - json.array do - video.adaptive_fmts.each do |fmt| - json.object do - # Only available on regular videos, not livestreams/OTF streams - if init_range = fmt["initRange"]? - json.field "init", "#{init_range["start"]}-#{init_range["end"]}" - end - if index_range = fmt["indexRange"]? - json.field "index", "#{index_range["start"]}-#{index_range["end"]}" - end - - # Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only) - json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? - - if proxy - json.field "url", Invidious::HttpServer::Utils.proxy_video_url( - fmt["url"].to_s, absolute: true - ) - else - json.field "url", fmt["url"] - end - - json.field "itag", fmt["itag"].as_i.to_s - json.field "type", fmt["mimeType"] - json.field "clen", fmt["contentLength"]? || "-1" - - # Last modified is a unix timestamp with µS, with the dot omitted. - # E.g: 1638056732(.)141582 - # - # On livestreams, it's not present, so always fall back to the - # current unix timestamp (up to mS precision) for compatibility. - last_modified = fmt["lastModified"]? - last_modified ||= "#{Time.utc.to_unix_ms}000" - json.field "lmt", last_modified - - json.field "projectionType", fmt["projectionType"] - - height = fmt["height"]?.try &.as_i - width = fmt["width"]?.try &.as_i - - fps = fmt["fps"]?.try &.as_i - - if fps - json.field "fps", fps - end - - if height && width - json.field "size", "#{width}x#{height}" - json.field "resolution", "#{height}p" - - quality_label = "#{width > height ? height : width}p" - - if fps && fps > 30 - quality_label += fps.to_s - end - - json.field "qualityLabel", quality_label - end - - if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] - end - - # Livestream chunk infos - json.field "targetDurationSec", fmt["targetDurationSec"].as_i if fmt.has_key?("targetDurationSec") - json.field "maxDvrDurationSec", fmt["maxDvrDurationSec"].as_i if fmt.has_key?("maxDvrDurationSec") - - # Audio-related data - json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality") - json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate") - json.field "audioChannels", fmt["audioChannels"] if fmt.has_key?("audioChannels") - - # Extra misc stuff - json.field "colorInfo", fmt["colorInfo"] if fmt.has_key?("colorInfo") - json.field "captionTrack", fmt["captionTrack"] if fmt.has_key?("captionTrack") - end - end - end - end - - json.field "formatStreams" do - json.array do - video.fmt_stream.each do |fmt| - json.object do - if proxy - json.field "url", Invidious::HttpServer::Utils.proxy_video_url( - fmt["url"].to_s, absolute: true - ) - else - json.field "url", fmt["url"] - end - json.field "itag", fmt["itag"].as_i.to_s - json.field "type", fmt["mimeType"] - json.field "quality", fmt["quality"] - - json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? - - height = fmt["height"]?.try &.as_i - width = fmt["width"]?.try &.as_i - - fps = fmt["fps"]?.try &.as_i - - if fps - json.field "fps", fps - end - - if height && width - json.field "size", "#{width}x#{height}" - json.field "resolution", "#{height}p" - - quality_label = "#{width > height ? height : width}p" - - if fps && fps > 30 - quality_label += fps.to_s - end - - json.field "qualityLabel", quality_label - end - - if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] - end - end - end - end - end - - json.field "captions" do - json.array do - video.captions.each do |caption| - json.object do - json.field "label", caption.name - json.field "language_code", caption.language_code - json.field "url", "/api/v1/captions/#{video.id}?label=#{URI.encode_www_form(caption.name)}" - end - end - end - end - - if !video.music.empty? - json.field "musicTracks" do - json.array do - video.music.each do |music| - json.object do - json.field "song", music.song - json.field "artist", music.artist - json.field "album", music.album - json.field "license", music.license - end - end - end - end - end - - json.field "recommendedVideos" do - json.array do - video.related_videos.each do |rv| - if rv["id"]? - json.object do - json.field "videoId", rv["id"] - json.field "title", rv["title"] - json.field "videoThumbnails" do - self.thumbnails(json, rv["id"]) - end - - json.field "author", rv["author"] - json.field "authorUrl", "/channel/#{rv["ucid"]?}" - json.field "authorId", rv["ucid"]? - json.field "authorVerified", rv["author_verified"] == "true" - if rv["author_thumbnail"]? - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - - json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i - json.field "viewCountText", rv["short_view_count"]? - json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 - json.field "published", rv["published"]? - if rv["published"]?.try &.presence - json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale)) - else - json.field "publishedText", "" - end - end - end - end - end - end - end - end - - def storyboards(json, id, storyboards) - json.array do - storyboards.each do |sb| - json.object do - json.field "url", "/api/v1/storyboards/#{id}?width=#{sb.width}&height=#{sb.height}" - json.field "templateUrl", sb.url.to_s - json.field "width", sb.width - json.field "height", sb.height - json.field "count", sb.count - json.field "interval", sb.interval - json.field "storyboardWidth", sb.columns - json.field "storyboardHeight", sb.rows - json.field "storyboardCount", sb.images_count - end - end - end - end -end diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 28ff0ff6..3f342b92 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -81,7 +81,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil) }) end -def template_mix(mix, listen) +def template_mix(mix) html = <<-END_HTML

    @@ -95,9 +95,9 @@ def template_mix(mix, listen) mix["videos"].as_a.each do |video| html += <<-END_HTML
  • - +
    - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 7c584d15..aefa34cc 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -46,14 +46,8 @@ struct PlaylistVideo XML.build { |xml| to_xml(xml) } end - def to_json(locale : String?, json : JSON::Builder) - to_json(json) - end - def to_json(json : JSON::Builder, index : Int32? = nil) json.object do - json.field "type", "video" - json.field "title", self.title json.field "videoId", self.id @@ -62,7 +56,7 @@ struct PlaylistVideo json.field "authorUrl", "/channel/#{self.ucid}" json.field "videoThumbnails" do - Invidious::JSONify::APIv1.thumbnails(json, self.id) + generate_thumbnails(json, self.id) end if index @@ -73,7 +67,6 @@ struct PlaylistVideo end json.field "lengthSeconds", self.length_seconds - json.field "liveNow", self.live_now end end @@ -96,7 +89,6 @@ struct Playlist property views : Int64 property updated : Time property thumbnail : String? - property subtitle : String? def to_json(offset, json : JSON::Builder, video_id : String? = nil) json.object do @@ -108,7 +100,6 @@ struct Playlist json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" - json.field "subtitle", self.subtitle json.field "authorThumbnails" do json.array do @@ -270,7 +261,7 @@ end def subscribe_playlist(user, playlist) playlist = InvidiousPlaylist.new({ - title: playlist.title[..150], + title: playlist.title.byte_slice(0, 150), id: playlist.id, author: user.email, description: "", # Max 5000 characters @@ -326,7 +317,7 @@ def get_playlist(plid : String) if playlist = Invidious::Database::Playlists.select(id: plid) return playlist else - raise NotFoundException.new("Playlist does not exist.") + raise InfoException.new("Playlist does not exist.") end else return fetch_playlist(plid) @@ -365,16 +356,12 @@ def fetch_playlist(plid : String) updated = Time.utc video_count = 0 - subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle")) - playlist_info["stats"]?.try &.as_a.each do |stat| text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s next if !text if text.includes? "video" video_count = text.gsub(/\D/, "").to_i? || 0 - elsif text.includes? "episode" - video_count = text.gsub(/\D/, "").to_i? || 0 elsif text.includes? "view" views = text.gsub(/\D/, "").to_i64? || 0_i64 else @@ -410,7 +397,6 @@ def fetch_playlist(plid : String) views: views, updated: updated, thumbnail: thumbnail, - subtitle: subtitle, }) end @@ -432,7 +418,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset end - videos = [] of PlaylistVideo | ProblematicTimelineItem + videos = [] of PlaylistVideo until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count # 100 videos per request @@ -448,7 +434,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, end def extract_playlist_videos(initial_data : Hash(String, JSON::Any)) - videos = [] of PlaylistVideo | ProblematicTimelineItem + videos = [] of PlaylistVideo if initial_data["contents"]? tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"] @@ -500,14 +486,12 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any)) index: index, }) end - rescue ex - videos << ProblematicTimelineItem.new(parse_exception: ex) end return videos end -def template_playlist(playlist, listen) +def template_playlist(playlist) html = <<-END_HTML

    @@ -521,9 +505,9 @@ def template_playlist(playlist, listen) playlist["videos"].as_a.each do |video| html += <<-END_HTML
  • - +
    - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index c8db207c..9bb73136 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -42,6 +42,11 @@ module Invidious::Routes::Account sid = sid.as(String) token = env.params.body["csrf_token"]? + # We don't store passwords for Google accounts + if !user.password + return error_template(400, "Cannot change password for Google accounts") + end + begin validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex @@ -49,11 +54,11 @@ module Invidious::Routes::Account end password = env.params.body["password"]? - if password.nil? || password.empty? + if !password return error_template(401, "Password is a required field") end - new_passwords = env.params.body.select { |k, _| k.match(/^new_password\[\d+\]$/) }.map { |_, v| v } + new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v } if new_passwords.size <= 1 || new_passwords.uniq.size != 1 return error_template(400, "New passwords must match") @@ -198,7 +203,7 @@ module Invidious::Routes::Account referer = get_referer(env) if !user - return env.redirect "/login?referer=#{URI.encode_path_segment(env.request.resource)}" + return env.redirect referer end user = user.as(User) @@ -240,7 +245,7 @@ module Invidious::Routes::Account return error_template(400, ex) end - scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v } + scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } callback_url = env.params.body["callbackUrl"]? expire = env.params.body["expire"]?.try &.to_i? @@ -257,7 +262,6 @@ module Invidious::Routes::Account end query["token"] = access_token - query["username"] = URI.encode_path_segment(user.email) url.query = query.to_s env.redirect url.to_s @@ -328,9 +332,17 @@ module Invidious::Routes::Account end end - case action = env.params.query["action"]? - when "revoke_token" - session = env.params.query["session"] + if env.params.query["action_revoke_token"]? + action = "action_revoke_token" + else + return env.redirect referer + end + + session = env.params.query["session"]? + session ||= "" + + case action + when .starts_with? "action_revoke_token" Invidious::Database::SessionIDs.delete(sid: session, email: user.email) else return error_json(400, "Unsupported action #{action}") diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index c27caad7..ca429df5 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -8,49 +8,45 @@ module Invidious::Routes::API::Manifest id = env.params.url["id"] region = env.params.query["region"]? - if CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - return env.redirect "#{invidious_companion.public_url}/api/manifest/dash/id/#{id}?#{env.params.query}" - end - # Since some implementations create playlists based on resolution regardless of different codecs, # we can opt to only add a source to a representation if it has a unique height within that representation unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } begin video = get_video(id, region: region) - rescue ex : NotFoundException - haltf env, status_code: 404 + rescue ex : VideoRedirect + return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex haltf env, status_code: 403 end if dashmpd = video.dash_manifest_url - response = YT_POOL.client &.get(URI.parse(dashmpd).request_target) + manifest = YT_POOL.client &.get(URI.parse(dashmpd).request_target).body - if response.status_code != 200 - haltf env, status_code: response.status_code - end + manifest = manifest.gsub(/[^<]+<\/BaseURL>/) do |baseurl| + url = baseurl.lchop("") + url = url.rchop("") + + if local + uri = URI.parse(url) + url = "#{uri.request_target}host/#{uri.host}/" + end - # Proxy URLs for video playback on invidious. - # Other API clients can get the original URLs by omiting `local=true`. - manifest = response.body.gsub(/[^<]+<\/BaseURL>/) do |baseurl| - url = baseurl.lchop("").rchop("") - url = HttpServer::Utils.proxy_video_url(url, absolute: true) if local "#{url}" end return manifest end - # Ditto, only proxify URLs if `local=true` is used + adaptive_fmts = video.adaptive_fmts + if local - video.adaptive_fmts.each do |fmt| - fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s, absolute: true)) + adaptive_fmts.each do |fmt| + fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) end end - audio_streams = video.audio_streams.sort_by { |stream| {stream["bitrate"].as_i} }.reverse! + audio_streams = video.audio_streams video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse! manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml| @@ -60,32 +56,17 @@ module Invidious::Routes::API::Manifest xml.element("Period") do i = 0 - {"audio/mp4"}.each do |mime_type| + {"audio/mp4", "audio/webm"}.each do |mime_type| mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } next if mime_streams.empty? - mime_streams.each do |fmt| - # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) - next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) - - audio_track = fmt["audioTrack"]?.try &.as_h? || {} of String => JSON::Any - lang = audio_track["id"]?.try &.as_s.split('.')[0] || "und" - is_default = audio_track.has_key?("audioIsDefault") ? audio_track["audioIsDefault"].as_bool : i == 0 - displayname = audio_track["displayName"]?.try &.as_s || "Unknown" - bitrate = fmt["bitrate"] - - # Different representations of the same audio should be groupped into one AdaptationSet. - # However, most players don't support auto quality switching, so we have to trick them - # into providing a quality selector. - # See https://github.com/iv-org/invidious/issues/3074 for more details. - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: "#{displayname} [#{bitrate}k]", lang: lang) do + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do + mime_streams.each do |fmt| codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') bandwidth = fmt["bitrate"].as_i itag = fmt["itag"].as_i url = fmt["url"].as_s - xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: is_default ? "main" : "alternate") - xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", value: "2") @@ -95,22 +76,20 @@ module Invidious::Routes::API::Manifest end end end - i += 1 end + + i += 1 end potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} - {"video/mp4"}.each do |mime_type| + {"video/mp4", "video/webm"}.each do |mime_type| mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } next if mime_streams.empty? heights = [] of Int32 xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do mime_streams.each do |fmt| - # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) - next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) - codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') bandwidth = fmt["bitrate"].as_i itag = fmt["itag"].as_i @@ -181,9 +160,8 @@ module Invidious::Routes::API::Manifest manifest = response.body if local - manifest = manifest.gsub(/https:\/\/[^\n"]*/m) do |match| - uri = URI.parse(match) - path = uri.path + manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match| + path = URI.parse(match).path path = path.lchop("/videoplayback/") path = path.rchop("/") @@ -212,7 +190,7 @@ module Invidious::Routes::API::Manifest raw_params["fvip"] = fvip["fvip"] end - raw_params["host"] = uri.host.not_nil! + raw_params["local"] = "true" "#{HOST_URL}/videoplayback?#{raw_params}" end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index a35d2f2b..b559a01a 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -31,88 +31,6 @@ module Invidious::Routes::API::V1::Authenticated env.response.status_code = 204 end - def self.export_invidious(env) - env.response.content_type = "application/json" - user = env.get("user").as(User) - - return Invidious::User::Export.to_invidious(user) - end - - def self.import_invidious(env) - user = env.get("user").as(User) - - begin - if body = env.request.body - body = env.request.body.not_nil!.gets_to_end - else - body = "{}" - end - Invidious::User::Import.from_invidious(user, body) - rescue - end - - env.response.status_code = 204 - end - - def self.get_history(env) - env.response.content_type = "application/json" - user = env.get("user").as(User) - - page = env.params.query["page"]?.try &.to_i?.try &.clamp(0, Int32::MAX) - page ||= 1 - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - start_index = (page - 1) * max_results - if user.watched[start_index]? - watched = user.watched.reverse[start_index, max_results] - end - watched ||= [] of String - - return watched.to_json - end - - def self.mark_watched(env) - user = env.get("user").as(User) - - if !user.preferences.watch_history - return error_json(409, "Watch history is disabled in preferences.") - end - - id = env.params.url["id"] - if !id.match(/^[a-zA-Z0-9_-]{11}$/) - return error_json(400, "Invalid video id.") - end - - Invidious::Database::Users.mark_watched(user, id) - env.response.status_code = 204 - end - - def self.mark_unwatched(env) - user = env.get("user").as(User) - - if !user.preferences.watch_history - return error_json(409, "Watch history is disabled in preferences.") - end - - id = env.params.url["id"] - if !id.match(/^[a-zA-Z0-9_-]{11}$/) - return error_json(400, "Invalid video id.") - end - - Invidious::Database::Users.mark_unwatched(user, id) - env.response.status_code = 204 - end - - def self.clear_history(env) - user = env.get("user").as(User) - - Invidious::Database::Users.clear_watch_history(user) - env.response.status_code = 204 - end - def self.feed(env) env.response.content_type = "application/json" @@ -178,6 +96,10 @@ module Invidious::Routes::API::V1::Authenticated Invidious::Database::Users.subscribe_channel(user, ucid) end + # For Google accounts, access tokens don't have enough information to + # make a request on the user's behalf, which is why we don't sync with + # YouTube. + env.response.status_code = 204 end @@ -304,8 +226,8 @@ module Invidious::Routes::API::V1::Authenticated return error_json(403, "Invalid user") end - if playlist.index.size >= CONFIG.playlist_length_limit - return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") + if playlist.index.size >= 500 + return error_json(400, "Playlist cannot have more than 500 videos") end video_id = env.params.json["videoId"].try &.as(String) @@ -315,8 +237,6 @@ module Invidious::Routes::API::V1::Authenticated begin video = get_video(video_id) - rescue ex : NotFoundException - return error_json(404, ex) rescue ex return error_json(500, ex) end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index a940ee68..c4d6643a 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -1,44 +1,29 @@ module Invidious::Routes::API::V1::Channels - # Macro to avoid duplicating some code below - # This sets the `channel` variable, or handles Exceptions. - private macro get_channel + def self.home(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" + begin channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex : NotFoundException - return error_json(404, ex) rescue ex return error_json(500, ex) end - end - def self.home(env) - locale = env.get("preferences").as(Preferences).locale - ucid = env.params.url["ucid"] - - env.response.content_type = "application/json" - - # Use the private macro defined above. - channel = nil # Make the compiler happy - get_channel() - - # Retrieve "sort by" setting from URL parameters - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" - - if channel.is_age_gated - begin - playlist = get_playlist(channel.ucid.sub("UC", "UULF")) - videos = get_playlist_videos(playlist, offset: 0) - rescue ex : InfoException - # playlist doesnt exist. - videos = [] of PlaylistVideo - end - next_continuation = nil + page = 1 + if channel.auto_generated + videos = [] of SearchVideo + count = 0 else begin - videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by) + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) rescue ex return error_json(500, ex) end @@ -95,15 +80,11 @@ module Invidious::Routes::API::V1::Channels json.field "joined", channel.joined.to_unix json.field "autoGenerated", channel.auto_generated - json.field "ageGated", channel.is_age_gated json.field "isFamilyFriendly", channel.is_family_friendly json.field "description", html_to_content(channel.description_html) json.field "descriptionHtml", channel.description_html json.field "allowedRegions", channel.allowed_regions - json.field "tabs", channel.tabs - json.field "tags", channel.tags - json.field "authorVerified", channel.verified json.field "latestVideos" do json.array do @@ -117,13 +98,31 @@ module Invidious::Routes::API::V1::Channels json.array do # Fetch related channels begin - related_channels, _ = fetch_related_channels(channel) + related_channels = fetch_related_channels(channel) rescue ex - related_channels = [] of SearchChannel + related_channels = [] of AboutRelatedChannel end related_channels.each do |related_channel| - related_channel.to_json(locale, json) + json.object do + json.field "author", related_channel.author + json.field "authorId", related_channel.ucid + json.field "authorUrl", related_channel.author_url + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + end end end end # relatedChannels @@ -133,147 +132,59 @@ module Invidious::Routes::API::V1::Channels end def self.latest(env) - # Remove parameters that could affect this endpoint's behavior - env.params.query.delete("sort_by") if env.params.query.has_key?("sort_by") - env.params.query.delete("continuation") if env.params.query.has_key?("continuation") + locale = env.get("preferences").as(Preferences).locale - return self.videos(env) + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + begin + videos = get_latest_videos(ucid) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end end def self.videos(env) locale = env.get("preferences").as(Preferences).locale - ucid = env.params.url["ucid"] env.response.content_type = "application/json" - # Use the private macro defined above. - channel = nil # Make the compiler happy - get_channel() - - # Retrieve some URL parameters - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" - continuation = env.params.query["continuation"]? - - if channel.is_age_gated - begin - playlist = get_playlist(channel.ucid.sub("UC", "UULF")) - videos = get_playlist_videos(playlist, offset: 0) - rescue ex : InfoException - # playlist doesnt exist. - videos = [] of PlaylistVideo - end - next_continuation = nil - else - begin - videos, next_continuation = Channel::Tabs.get_60_videos( - channel, continuation: continuation, sort_by: sort_by - ) - rescue ex - return error_json(500, ex) - end - end - - return JSON.build do |json| - json.object do - json.field "videos" do - json.array do - videos.each &.to_json(locale, json) - end - end - - json.field "continuation", next_continuation if next_continuation - end - end - end - - def self.shorts(env) - locale = env.get("preferences").as(Preferences).locale ucid = env.params.url["ucid"] + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + sort_by = env.params.query["sort"]?.try &.downcase + sort_by ||= env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" - env.response.content_type = "application/json" - - # Use the private macro defined above. - channel = nil # Make the compiler happy - get_channel() - - # Retrieve continuation from URL parameters - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" - continuation = env.params.query["continuation"]? - - if channel.is_age_gated - begin - playlist = get_playlist(channel.ucid.sub("UC", "UUSH")) - videos = get_playlist_videos(playlist, offset: 0) - rescue ex : InfoException - # playlist doesnt exist. - videos = [] of PlaylistVideo - end - next_continuation = nil - else - begin - videos, next_continuation = Channel::Tabs.get_shorts( - channel, continuation: continuation, sort_by: sort_by - ) - rescue ex - return error_json(500, ex) - end + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) end - return JSON.build do |json| - json.object do - json.field "videos" do - json.array do - videos.each &.to_json(locale, json) - end + begin + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) end - - json.field "continuation", next_continuation if next_continuation - end - end - end - - def self.streams(env) - locale = env.get("preferences").as(Preferences).locale - ucid = env.params.url["ucid"] - - env.response.content_type = "application/json" - - # Use the private macro defined above. - channel = nil # Make the compiler happy - get_channel() - - # Retrieve continuation from URL parameters - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" - continuation = env.params.query["continuation"]? - - if channel.is_age_gated - begin - playlist = get_playlist(channel.ucid.sub("UC", "UULV")) - videos = get_playlist_videos(playlist, offset: 0) - rescue ex : InfoException - # playlist doesnt exist. - videos = [] of PlaylistVideo - end - next_continuation = nil - else - begin - videos, next_continuation = Channel::Tabs.get_60_livestreams( - channel, continuation: continuation, sort_by: sort_by - ) - rescue ex - return error_json(500, ex) - end - end - - return JSON.build do |json| - json.object do - json.field "videos" do - json.array do - videos.each &.to_json(locale, json) - end - end - - json.field "continuation", next_continuation if next_continuation end end end @@ -289,40 +200,16 @@ module Invidious::Routes::API::V1::Channels env.params.query["sort_by"]?.try &.downcase || "last" - # Use the macro defined above - channel = nil # Make the compiler happy - get_channel() - - items, next_continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - - JSON.build do |json| - json.object do - json.field "playlists" do - json.array do - items.each do |item| - item.to_json(locale, json) if item.is_a?(SearchPlaylist) - end - end - end - - json.field "continuation", next_continuation if next_continuation - end + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) end - end - def self.podcasts(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - continuation = env.params.query["continuation"]? - - # Use the macro defined above - channel = nil # Make the compiler happy - get_channel() - - items, next_continuation = fetch_channel_podcasts(channel.ucid, channel.author, continuation) + items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) JSON.build do |json| json.object do @@ -334,65 +221,7 @@ module Invidious::Routes::API::V1::Channels end end - json.field "continuation", next_continuation if next_continuation - end - end - end - - def self.releases(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - continuation = env.params.query["continuation"]? - - # Use the macro defined above - channel = nil # Make the compiler happy - get_channel() - - items, next_continuation = fetch_channel_releases(channel.ucid, channel.author, continuation) - - JSON.build do |json| - json.object do - json.field "playlists" do - json.array do - items.each do |item| - item.to_json(locale, json) if item.is_a?(SearchPlaylist) - end - end - end - - json.field "continuation", next_continuation if next_continuation - end - end - end - - def self.courses(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - continuation = env.params.query["continuation"]? - - # Use the macro defined above - channel = nil # Make the compiler happy - get_channel() - - items, next_continuation = fetch_channel_courses(channel.ucid, channel.author, continuation) - - JSON.build do |json| - json.object do - json.field "playlists" do - json.array do - items.each do |item| - item.to_json(locale, json) if item.is_a?(SearchPlaylist) - end - end - end - - json.field "continuation", next_continuation if next_continuation + json.field "continuation", continuation end end end @@ -420,108 +249,20 @@ module Invidious::Routes::API::V1::Channels end end - def self.post(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - id = env.params.url["id"].to_s - ucid = env.params.query["ucid"]? - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - if ucid.nil? - response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") - return error_json(400, "Invalid post ID") if response["error"]? - ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s - else - ucid = ucid.to_s - end - - begin - fetch_channel_community_post(ucid, id, locale, format, thin_mode) - rescue ex - return error_json(500, ex) - end - end - - def self.post_comments(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - - id = env.params.url["id"] - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - continuation = env.params.query["continuation"]? - - case continuation - when nil, "" - ucid = env.params.query["ucid"] - comments = Comments.fetch_community_post_comments(ucid, id) - else - comments = YoutubeAPI.browse(continuation: continuation) - end - return Comments.parse_youtube(id, comments, format, locale, thin_mode, is_post: true) - end - - def self.channels(env) - locale = env.get("preferences").as(Preferences).locale - ucid = env.params.url["ucid"] - - env.response.content_type = "application/json" - - # Use the macro defined above - channel = nil # Make the compiler happy - get_channel() - - continuation = env.params.query["continuation"]? - - begin - items, next_continuation = fetch_related_channels(channel, continuation) - rescue ex - return error_json(500, ex) - end - - JSON.build do |json| - json.object do - json.field "relatedChannels" do - json.array do - items.each &.to_json(locale, json) - end - end - - json.field "continuation", next_continuation if next_continuation - end - end - end - def self.search(env) locale = env.get("preferences").as(Preferences).locale - region = env.params.query["region"]? env.response.content_type = "application/json" - query = Invidious::Search::Query.new(env.params.query, :channel, region) + ucid = env.params.url["ucid"] - # Required because we can't (yet) pass multiple parameter to the - # `Search::Query` initializer (in this case, an URL segment) - query.channel = env.params.url["ucid"] + query = env.params.query["q"]? + query ||= "" - begin - search_results = query.process - rescue ex - return error_json(400, ex) - end + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + search_results = channel_search(query, page, ucid) JSON.build do |json| json.array do search_results.each do |item| diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr index fea2993c..41865f34 100644 --- a/src/invidious/routes/api/v1/feeds.cr +++ b/src/invidious/routes/api/v1/feeds.cr @@ -31,7 +31,7 @@ module Invidious::Routes::API::V1::Feeds if !CONFIG.popular_enabled error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - haltf env, 403, error_message + haltf env, 400, error_message end JSON.build do |json| diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 4f5b58da..844fedb8 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -6,22 +6,6 @@ module Invidious::Routes::API::V1::Misc if !CONFIG.statistics_enabled return {"software" => SOFTWARE}.to_json else - # Calculate playback success rate - if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]?) - tracker = tracker.as(Hash(String, Int64 | Float64)) - - if !tracker.empty? - total_requests = tracker["totalRequests"] - success_count = tracker["successfulRequests"] - - if total_requests.zero? - tracker["ratio"] = 1_i64 - else - tracker["ratio"] = (success_count / (total_requests)).round(2) - end - end - end - return Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json end end @@ -42,9 +26,6 @@ module Invidious::Routes::API::V1::Misc format = env.params.query["format"]? format ||= "json" - listen_param = env.params.query["listen"]? - listen = (listen_param == "true" || listen_param == "1") - if plid.starts_with? "RD" return env.redirect "/api/v1/mixes/#{plid}" end @@ -77,9 +58,7 @@ module Invidious::Routes::API::V1::Misc response = playlist.to_json(offset, video_id: video_id) json_response = JSON.parse(response) - if json_response["videos"].as_a.empty? - json_response = JSON.parse(response) - elsif json_response["videos"].as_a[0]["index"] != offset + if json_response["videos"].as_a[0]["index"] != offset offset = json_response["videos"].as_a[0]["index"].as_i lookback = offset < 50 ? offset : 50 response = playlist.to_json(offset - lookback) @@ -88,7 +67,7 @@ module Invidious::Routes::API::V1::Misc end if format == "html" - playlist_html = template_playlist(json_response, listen) + playlist_html = template_playlist(json_response) index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} response = { @@ -114,9 +93,6 @@ module Invidious::Routes::API::V1::Misc format = env.params.query["format"]? format ||= "json" - listen_param = env.params.query["listen"]? - listen = (listen_param == "true" || listen_param == "1") - begin mix = fetch_mix(rdid, continuation, locale: locale) @@ -147,7 +123,9 @@ module Invidious::Routes::API::V1::Misc json.field "authorUrl", "/channel/#{video.ucid}" json.field "videoThumbnails" do - Invidious::JSONify::APIv1.thumbnails(json, video.id) + json.array do + generate_thumbnails(json, video.id) + end end json.field "index", video.index @@ -161,7 +139,7 @@ module Invidious::Routes::API::V1::Misc if format == "html" response = JSON.parse(response) - playlist_html = template_mix(response, listen) + playlist_html = template_mix(response) next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] response = { @@ -172,36 +150,4 @@ module Invidious::Routes::API::V1::Misc response end - - # resolve channel and clip urls, return the UCID - def self.resolve_url(env) - env.response.content_type = "application/json" - url = env.params.query["url"]? - - return error_json(400, "Missing URL to resolve") if !url - - begin - resolved_url = YoutubeAPI.resolve_url(url.as(String)) - endpoint = resolved_url["endpoint"] - page_type = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" - if page_type == "WEB_PAGE_TYPE_UNKNOWN" - return error_json(400, "Unknown url") - end - - sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint - params = sub_endpoint.try &.dig?("params") - rescue ex - return error_json(500, ex) - end - JSON.build do |json| - json.object do - json.field "ucid", sub_endpoint["browseId"].as_s if sub_endpoint["browseId"]? - json.field "videoId", sub_endpoint["videoId"].as_s if sub_endpoint["videoId"]? - json.field "playlistId", sub_endpoint["playlistId"].as_s if sub_endpoint["playlistId"]? - json.field "startTimeSeconds", sub_endpoint["startTimeSeconds"].as_i if sub_endpoint["startTimeSeconds"]? - json.field "params", params.try &.as_s - json.field "pageType", page_type - end - end - end end diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 59a30745..0b0853b1 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -5,14 +5,34 @@ module Invidious::Routes::API::V1::Search env.response.content_type = "application/json" - query = Invidious::Search::Query.new(env.params.query, :regular, region) + query = env.params.query["q"]? + query ||= "" + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "relevance" + + date = env.params.query["date"]?.try &.downcase + date ||= "" + + duration = env.params.query["duration"]?.try &.downcase + duration ||= "" + + features = env.params.query["features"]?.try &.split(",").map(&.downcase) + features ||= [] of String + + content_type = env.params.query["type"]?.try &.downcase + content_type ||= "video" begin - search_results = query.process + search_params = produce_search_params(page, sort_by, date, content_type, duration, features) rescue ex return error_json(400, ex) end + search_results = search(query, search_params, region) JSON.build do |json| json.array do search_results.each do |item| @@ -23,21 +43,20 @@ module Invidious::Routes::API::V1::Search end def self.search_suggestions(env) - preferences = env.get("preferences").as(Preferences) - region = env.params.query["region"]? || preferences.region + locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? env.response.content_type = "application/json" - query = env.params.query["q"]? || "" + query = env.params.query["q"]? + query ||= "" begin - client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers: true) - url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&gs_ri=youtube&ds=yt" + headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} + response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body - response = client.get(url).body - client.close - - body = JSON.parse(response[19..-2]).as_a + body = response[35..-2] + body = JSON.parse(body).as_a suggestions = body[1].as_a[0..-2] JSON.build do |json| @@ -56,32 +75,4 @@ module Invidious::Routes::API::V1::Search return error_json(500, ex) end end - - def self.hashtag(env) - hashtag = env.params.url["hashtag"] - - page = env.params.query["page"]?.try &.to_i? || 1 - - locale = env.get("preferences").as(Preferences).locale - region = env.params.query["region"]? - env.response.content_type = "application/json" - - begin - results = Invidious::Hashtag.fetch(hashtag, page, region) - rescue ex - return error_json(400, ex) - end - - JSON.build do |json| - json.object do - json.field "results" do - json.array do - results.each do |item| - item.to_json(locale, json) - end - end - end - end - end - end end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 6a3eb8ae..a9f891f5 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -1,5 +1,3 @@ -require "html" - module Invidious::Routes::API::V1::Videos def self.videos(env) locale = env.get("preferences").as(Preferences).locale @@ -8,19 +6,17 @@ module Invidious::Routes::API::V1::Videos id = env.params.url["id"] region = env.params.query["region"]? - proxy = {"1", "true"}.any? &.== env.params.query["local"]? begin video = get_video(id, region: region) - rescue ex : NotFoundException - return error_json(404, ex) + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex return error_json(500, ex) end - return JSON.build do |json| - Invidious::JSONify::APIv1.video(video, json, locale: locale, proxy: proxy) - end + video.to_json(locale, nil) end def self.captions(env) @@ -43,8 +39,9 @@ module Invidious::Routes::API::V1::Videos begin video = get_video(id, region: region) - rescue ex : NotFoundException - haltf env, 404 + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex haltf env, 500 end @@ -89,77 +86,61 @@ module Invidious::Routes::API::V1::Videos caption = caption[0] end - if CONFIG.use_innertube_for_captions - params = Invidious::Videos::Transcript.generate_param(id, caption.language_code, caption.auto_generated) + url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target - transcript = Invidious::Videos::Transcript.from_raw( - YoutubeAPI.get_transcript(params), - caption.language_code, - caption.auto_generated - ) + # Auto-generated captions often have cues that aren't aligned properly with the video, + # as well as some other markup that makes it cumbersome, so we try to fix that here + if caption.name.includes? "auto-generated" + caption_xml = YT_POOL.client &.get(url).body + caption_xml = XML.parse(caption_xml) - webvtt = transcript.to_vtt - else - # Timedtext API handling - url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target + webvtt = String.build do |str| + str << <<-END_VTT + WEBVTT + Kind: captions + Language: #{tlang || caption.language_code} - # Auto-generated captions often have cues that aren't aligned properly with the video, - # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.includes? "auto-generated" - caption_xml = YT_POOL.client &.get(url).body - settings_field = { - "Kind" => "captions", - "Language" => "#{tlang || caption.language_code}", - } + END_VTT - if caption_xml.starts_with?(" i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration - end - - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" - end - - builder.cue(start_time, end_time, text) - end + if caption_nodes.size > i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration end - end - else - uri = URI.parse(url) - query_params = uri.query_params - query_params["fmt"] = "vtt" - uri.query_params = query_params - webvtt = YT_POOL.client &.get(uri.request_target).body - if webvtt.starts_with?(" [0-9:.]{12}).+/, "\\1") + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end + + str << <<-END_CUE + #{start_time} --> #{end_time} + #{text} + + + END_CUE end end + else + # Some captions have "align:[start/end]" and "position:[num]%" + # attributes. Those are causing issues with VideoJS, which is unable + # to properly align the captions on the video, so we remove them. + # + # See: https://github.com/iv-org/invidious/issues/2391 + webvtt = YT_POOL.client &.get("#{url}&format=vtt").body + .gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1") end if title = env.params.query["title"]? @@ -183,20 +164,22 @@ module Invidious::Routes::API::V1::Videos begin video = get_video(id, region: region) - rescue ex : NotFoundException - haltf env, 404 + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex haltf env, 500 end - width = env.params.query["width"]?.try &.to_i - height = env.params.query["height"]?.try &.to_i + storyboards = video.storyboards + width = env.params.query["width"]? + height = env.params.query["height"]? if !width && !height response = JSON.build do |json| json.object do json.field "storyboards" do - Invidious::JSONify::APIv1.storyboards(json, id, video.storyboards) + generate_storyboards(json, id, storyboards) end end end @@ -206,48 +189,43 @@ module Invidious::Routes::API::V1::Videos env.response.content_type = "text/vtt" - # Select a storyboard matching the user's provided width/height - storyboard = video.storyboards.select { |x| x.width == width || x.height == height } - haltf env, 404 if storyboard.empty? + storyboard = storyboards.select { |sb| width == "#{sb[:width]}" || height == "#{sb[:height]}" } - # Alias variable, to make the code below esaier to read - sb = storyboard[0] + if storyboard.empty? + haltf env, 404 + else + storyboard = storyboard[0] + end - # Some base URL segments that we'll use to craft the final URLs - work_url = sb.proxied_url.dup - template_path = sb.proxied_url.path + String.build do |str| + str << <<-END_VTT + WEBVTT + END_VTT - # Initialize cue timing variables - # NOTE: videojs-vtt-thumbnails gets lost when the cue times don't overlap - # (i.e: if cue[n] end time is 1:06:25.000, cue[n+1] start time should be 1:06:25.000) - time_delta = sb.interval.milliseconds - start_time = 0.milliseconds - end_time = time_delta + start_time = 0.milliseconds + end_time = storyboard[:interval].milliseconds - # Build a VTT file for VideoJS-vtt plugin - vtt_file = WebVTT.build do |vtt| - sb.images_count.times do |i| - # Replace the variable component part of the path - work_url.path = template_path.sub("$M", i) + storyboard[:storyboard_count].times do |i| + url = storyboard[:url] + authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? + url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") + url = "#{HOST_URL}/sb/#{authority}/#{url}" - sb.rows.times do |j| - sb.columns.times do |k| - # The URL fragment represents the offset of the thumbnail inside the storyboard image - work_url.fragment = "xywh=#{sb.width * k},#{sb.height * j},#{sb.width - 2},#{sb.height}" + storyboard[:storyboard_height].times do |j| + storyboard[:storyboard_width].times do |k| + str << <<-END_CUE + #{start_time}.000 --> #{end_time}.000 + #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} - vtt.cue(start_time, end_time, work_url.to_s) - start_time += time_delta - end_time += time_delta + END_CUE + + start_time += storyboard[:interval].milliseconds + end_time += storyboard[:interval].milliseconds end end end end - - # videojs-vtt-thumbnails is not compliant to the VTT specification, it - # doesn't unescape the HTML entities, so we have to do it here: - # TODO: remove this when we migrate to VideoJS 8 - return HTML.unescape(vtt_file) end def self.annotations(env) @@ -268,7 +246,7 @@ module Invidious::Routes::API::V1::Videos if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id)) annotations = cached_annotation.annotations else - index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0') + index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') # IA doesn't handle leading hyphens, # so we use https://archive.org/details/youtubeannotations_64 @@ -345,9 +323,7 @@ module Invidious::Routes::API::V1::Videos sort_by ||= "top" begin - comments = Comments.fetch_youtube(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) - rescue ex : NotFoundException - return error_json(404, ex) + comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) rescue ex return error_json(500, ex) end @@ -357,7 +333,7 @@ module Invidious::Routes::API::V1::Videos sort_by ||= "confidence" begin - comments, reddit_thread = Comments.fetch_reddit(id, sort_by: sort_by) + comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) rescue ex comments = nil reddit_thread = nil @@ -373,9 +349,9 @@ module Invidious::Routes::API::V1::Videos return reddit_thread.to_json else - content_html = Frontend::Comments.template_reddit(comments, locale) - content_html = Comments.fill_links(content_html, "https", "www.reddit.com") - content_html = Comments.replace_links(content_html) + content_html = template_reddit_comments(comments, locale) + content_html = fill_links(content_html, "https", "www.reddit.com") + content_html = replace_links(content_html) response = { "title" => reddit_thread.title, "permalink" => reddit_thread.permalink, @@ -386,133 +362,4 @@ module Invidious::Routes::API::V1::Videos end end end - - def self.clips(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - - clip_id = env.params.url["id"] - region = env.params.query["region"]? - proxy = {"1", "true"}.any? &.== env.params.query["local"]? - - response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}") - return error_json(400, "Invalid clip ID") if response["error"]? - - video_id = response.dig?("endpoint", "watchEndpoint", "videoId").try &.as_s - return error_json(400, "Invalid clip ID") if video_id.nil? - - start_time = nil - end_time = nil - clip_title = nil - - if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s - start_time, end_time, clip_title = parse_clip_parameters(params) - end - - begin - video = get_video(video_id, region: region) - rescue ex : NotFoundException - return error_json(404, ex) - rescue ex - return error_json(500, ex) - end - - return JSON.build do |json| - json.object do - json.field "startTime", start_time - json.field "endTime", end_time - json.field "clipTitle", clip_title - json.field "video" do - Invidious::JSONify::APIv1.video(video, json, locale: locale, proxy: proxy) - end - end - end - end - - # Fetches transcripts from YouTube - # - # Use the `lang` and `autogen` query parameter to select which transcript to fetch - # Request without any URL parameters to see all the available transcripts. - def self.transcripts(env) - env.response.content_type = "application/json" - - id = env.params.url["id"] - lang = env.params.query["lang"]? - label = env.params.query["label"]? - auto_generated = env.params.query["autogen"]? ? true : false - - # Return all available transcript options when none is given - if !label && !lang - begin - video = get_video(id) - rescue ex : NotFoundException - return error_json(404, ex) - rescue ex - return error_json(500, ex) - end - - response = JSON.build do |json| - # The amount of transcripts available to fetch is the - # same as the amount of captions available. - available_transcripts = video.captions - - json.object do - json.field "transcripts" do - json.array do - available_transcripts.each do |transcript| - json.object do - json.field "label", transcript.name - json.field "languageCode", transcript.language_code - json.field "autoGenerated", transcript.auto_generated - - if transcript.auto_generated - json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}&autogen" - else - json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}" - end - end - end - end - end - end - end - - return response - end - - # If lang is not given then we attempt to fetch - # the transcript through the given label - if lang.nil? - begin - video = get_video(id) - rescue ex : NotFoundException - return error_json(404, ex) - rescue ex - return error_json(500, ex) - end - - target_transcript = video.captions.select(&.name.== label) - if target_transcript.empty? - return error_json(404, NotFoundException.new("Requested transcript does not exist")) - else - target_transcript = target_transcript[0] - lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated - end - end - - params = Invidious::Videos::Transcript.generate_param(id, lang, auto_generated) - - begin - transcript = Invidious::Videos::Transcript.from_raw( - YoutubeAPI.get_transcript(params), lang, auto_generated - ) - rescue ex : NotFoundException - return error_json(404, ex) - rescue ex - return error_json(500, ex) - end - - return transcript.to_json - end end diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr deleted file mode 100644 index b5269668..00000000 --- a/src/invidious/routes/before_all.cr +++ /dev/null @@ -1,133 +0,0 @@ -module Invidious::Routes::BeforeAll - def self.handle(env) - preferences = Preferences.from_json("{}") - - begin - if prefs_cookie = env.request.cookies["PREFS"]? - preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value)) - else - if language_header = env.request.headers["Accept-Language"]? - if language = ANG.language_negotiator.best(language_header, LOCALES.keys) - preferences.locale = language.header - end - end - end - rescue - preferences = Preferences.from_json("{}") - end - - env.set "preferences", preferences - env.response.headers["X-XSS-Protection"] = "1; mode=block" - env.response.headers["X-Content-Type-Options"] = "nosniff" - - # Only allow the pages at /embed/* to be embedded - if env.request.resource.starts_with?("/embed") - frame_ancestors = "'self' file: http: https:" - else - frame_ancestors = "'none'" - end - - # TODO: Remove style-src's 'unsafe-inline', requires to remove all - # inline styles (, style=" [..] ") - env.response.headers["Content-Security-Policy"] = { - "default-src 'none'", - "script-src 'self'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data:", - "font-src 'self' data:", - "connect-src 'self'", - "manifest-src 'self'", - "media-src 'self' blob:", - "child-src 'self' blob:", - "frame-src 'self'", - "frame-ancestors " + frame_ancestors, - }.join("; ") - - env.response.headers["Referrer-Policy"] = "same-origin" - - # Ask the chrom*-based browsers to disable FLoC - # See: https://blog.runcloud.io/google-floc/ - env.response.headers["Permissions-Policy"] = "interest-cohort=()" - - if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts - env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" - end - - return if { - "/sb/", - "/vi/", - "/s_p/", - "/yts/", - "/ggpht/", - "/api/manifest/", - "/videoplayback", - "/latest_version", - "/download", - }.any? { |r| env.request.resource.starts_with? r } - - if env.request.cookies.has_key? "SID" - sid = env.request.cookies["SID"].value - - if sid.starts_with? "v1:" - raise "Cannot use token as SID" - end - - if email = Database::SessionIDs.select_email(sid) - user = Database::Users.select!(email: email) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - end - end - - dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s - thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s - thin_mode = thin_mode == "true" - locale = env.params.query["hl"]? || preferences.locale - - preferences.dark_mode = dark_mode - preferences.thin_mode = thin_mode - preferences.locale = locale - env.set "preferences", preferences - - # Allow media resources to be loaded from google servers - # TODO: check if *.youtube.com can be removed - # - # `!preferences.local` has to be checked after setting and - # reading `preferences` from the "PREFS" cookie and - # saved user preferences from the database, otherwise - # `https://*.googlevideo.com:443 https://*.youtube.com:443` - # will not be set in the CSP header if - # `default_user_preferences.local` is set to true on the - # configuration file, causing preference “Proxy Videos” - # not to work while having it disabled and using medium quality. - if CONFIG.disabled?("local") || !preferences.local - env.response.headers["Content-Security-Policy"] = env.response.headers["Content-Security-Policy"].gsub("media-src", "media-src https://*.googlevideo.com:443 https://*.youtube.com:443") - end - - current_page = env.request.path - if env.request.query - query = HTTP::Params.parse(env.request.query.not_nil!) - - if query["referer"]? - query["referer"] = get_referer(env, "/") - end - - current_page += "?#{query}" - end - - env.set "current_page", URI.encode_www_form(current_page) - end -end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 508aa3e4..cd2e3323 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -1,32 +1,27 @@ {% skip_file if flag?(:api_only) %} module Invidious::Routes::Channels - # Redirection for unsupported routes ("tabs") - def self.redirect_home(env) - ucid = env.params.url["ucid"] - return env.redirect "/channel/#{URI.encode_www_form(ucid)}" - end - def self.home(env) self.videos(env) end def self.videos(env) data = self.fetch_basic_information(env) - return data if !data.is_a?(Tuple) - + if !data.is_a?(Tuple) + return data + end locale, user, subscriptions, continuation, ucid, channel = data + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + sort_by = env.params.query["sort_by"]?.try &.downcase if channel.auto_generated - sort_by ||= "last" sort_options = {"last", "oldest", "newest"} + sort_by ||= "last" - items, next_continuation = fetch_channel_playlists( - channel.ucid, channel.author, continuation, sort_by - ) - + items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) items.uniq! do |item| if item.responds_to?(:title) item.title @@ -34,224 +29,62 @@ module Invidious::Routes::Channels item.author end end - items = items.select(SearchPlaylist) + items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) items.each(&.author = "") else - # Fetch items and continuation token - if channel.is_age_gated - sort_by = "" - sort_options = [] of String - begin - playlist = get_playlist(channel.ucid.sub("UC", "UULF")) - items = get_playlist_videos(playlist, offset: 0) - rescue ex : InfoException - # playlist doesnt exist. - items = [] of PlaylistVideo - end - next_continuation = nil - else - sort_by ||= "newest" - sort_options = {"newest", "oldest", "popular"} - - items, next_continuation = Channel::Tabs.get_60_videos( - channel, continuation: continuation, sort_by: sort_by - ) - end - end - - selected_tab = Frontend::ChannelPage::TabsAvailable::Videos - templated "channel" - end - - def self.shorts(env) - data = self.fetch_basic_information(env) - return data if !data.is_a?(Tuple) - - locale, user, subscriptions, continuation, ucid, channel = data - - if !channel.tabs.includes? "shorts" - return env.redirect "/channel/#{channel.ucid}" - end - - if channel.is_age_gated - sort_by = "" - sort_options = [] of String - begin - playlist = get_playlist(channel.ucid.sub("UC", "UUSH")) - items = get_playlist_videos(playlist, offset: 0) - rescue ex : InfoException - # playlist doesnt exist. - items = [] of PlaylistVideo - end - next_continuation = nil - else - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" sort_options = {"newest", "oldest", "popular"} + sort_by ||= "newest" - # Fetch items and continuation token - items, next_continuation = Channel::Tabs.get_shorts( - channel, continuation: continuation, sort_by: sort_by - ) + count, items = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) end - selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts - templated "channel" - end - - def self.streams(env) - data = self.fetch_basic_information(env) - return data if !data.is_a?(Tuple) - - locale, user, subscriptions, continuation, ucid, channel = data - - if !channel.tabs.includes? "streams" - return env.redirect "/channel/#{channel.ucid}" - end - - if channel.is_age_gated - sort_by = "" - sort_options = [] of String - begin - playlist = get_playlist(channel.ucid.sub("UC", "UULV")) - items = get_playlist_videos(playlist, offset: 0) - rescue ex : InfoException - # playlist doesnt exist. - items = [] of PlaylistVideo - end - next_continuation = nil - else - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" - sort_options = {"newest", "oldest", "popular"} - - # Fetch items and continuation token - items, next_continuation = Channel::Tabs.get_60_livestreams( - channel, continuation: continuation, sort_by: sort_by - ) - end - - selected_tab = Frontend::ChannelPage::TabsAvailable::Streams templated "channel" end def self.playlists(env) - data = self.fetch_basic_information(env) - return data if !data.is_a?(Tuple) - - locale, user, subscriptions, continuation, ucid, channel = data - - sort_options = {"last", "oldest", "newest"} - sort_by = env.params.query["sort_by"]?.try &.downcase - - if channel.auto_generated - return env.redirect "/channel/#{channel.ucid}" - end - - items, next_continuation = fetch_channel_playlists( - channel.ucid, channel.author, continuation, (sort_by || "last") - ) - - items = items.select(SearchPlaylist) - items.each(&.author = "") - - selected_tab = Frontend::ChannelPage::TabsAvailable::Playlists - templated "channel" - end - - def self.podcasts(env) - data = self.fetch_basic_information(env) - return data if !data.is_a?(Tuple) - - locale, user, subscriptions, continuation, ucid, channel = data - - sort_by = "" - sort_options = [] of String - - items, next_continuation = fetch_channel_podcasts( - channel.ucid, channel.author, continuation - ) - - items = items.select(SearchPlaylist) - items.each(&.author = "") - - selected_tab = Frontend::ChannelPage::TabsAvailable::Podcasts - templated "channel" - end - - def self.releases(env) - data = self.fetch_basic_information(env) - return data if !data.is_a?(Tuple) - - locale, user, subscriptions, continuation, ucid, channel = data - - sort_by = "" - sort_options = [] of String - - items, next_continuation = fetch_channel_releases( - channel.ucid, channel.author, continuation - ) - - items = items.select(SearchPlaylist) - items.each(&.author = "") - - selected_tab = Frontend::ChannelPage::TabsAvailable::Releases - templated "channel" - end - - def self.courses(env) - data = self.fetch_basic_information(env) - return data if !data.is_a?(Tuple) - - locale, user, subscriptions, continuation, ucid, channel = data - - sort_by = "" - sort_options = [] of String - - items, next_continuation = fetch_channel_courses( - channel.ucid, channel.author, continuation - ) - - items = items.select(SearchPlaylist) - items.each(&.author = "") - - selected_tab = Frontend::ChannelPage::TabsAvailable::Courses - templated "channel" - end - - def self.community(env) - return env.redirect env.request.path.sub("posts", "community") if env.request.path.split("/").last == "posts" - data = self.fetch_basic_information(env) if !data.is_a?(Tuple) return data end locale, user, subscriptions, continuation, ucid, channel = data - # redirect to post page - if lb = env.params.query["lb"]? - env.redirect "/post/#{URI.encode_www_form(lb)}?ucid=#{URI.encode_www_form(ucid)}" + sort_options = {"last", "oldest", "newest"} + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "last" + + if channel.auto_generated + return env.redirect "/channel/#{channel.ucid}" end + items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) + items.each(&.author = "") + + templated "playlists" + end + + def self.community(env) + data = self.fetch_basic_information(env) + if !data.is_a?(Tuple) + return data + end + locale, user, subscriptions, continuation, ucid, channel = data + thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode thin_mode = thin_mode == "true" continuation = env.params.query["continuation"]? + # sort_by = env.params.query["sort_by"]?.try &.downcase - if !channel.tabs.includes? "community" && "posts" + if !channel.tabs.includes? "community" return env.redirect "/channel/#{channel.ucid}" end - # TODO: support sort options for community posts - sort_by = "" - sort_options = [] of String - begin items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) rescue ex : InfoException env.response.status_code = 500 error_message = ex.message - rescue ex : NotFoundException - env.response.status_code = 404 - error_message = ex.message rescue ex return error_template(500, ex) end @@ -259,64 +92,6 @@ module Invidious::Routes::Channels templated "community" end - def self.post(env) - # /post/{postId} - id = env.params.url["id"] - ucid = env.params.query["ucid"]? - - prefs = env.get("preferences").as(Preferences) - - locale = prefs.locale - - thin_mode = env.params.query["thin_mode"]? || prefs.thin_mode - thin_mode = thin_mode == "true" - - nojs = env.params.query["nojs"]? - - nojs ||= "0" - nojs = nojs == "1" - - if !ucid.nil? - ucid = ucid.to_s - post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) - else - # resolve the url to get the author's UCID - response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") - return error_template(400, "Invalid post ID") if response["error"]? - - ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s - post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) - end - - post_response = JSON.parse(post_response) - - if nojs - comments = Comments.fetch_community_post_comments(ucid, id) - comment_html = JSON.parse(Comments.parse_youtube(id, comments, "html", locale, thin_mode, is_post: true))["contentHtml"] - end - templated "post" - end - - def self.channels(env) - data = self.fetch_basic_information(env) - return data if !data.is_a?(Tuple) - - locale, user, subscriptions, continuation, ucid, channel = data - - if channel.auto_generated - return env.redirect "/channel/#{channel.ucid}" - end - - items, next_continuation = fetch_related_channels(channel, continuation) - - # Featured/related channels can't be sorted - sort_options = [] of String - sort_by = nil - - selected_tab = Frontend::ChannelPage::TabsAvailable::Channels - templated "channel" - end - def self.about(env) data = self.fetch_basic_information(env) if !data.is_a?(Tuple) @@ -327,12 +102,6 @@ module Invidious::Routes::Channels env.redirect "/channel/#{ucid}" end - private KNOWN_TABS = { - "home", "videos", "shorts", "streams", "podcasts", - "releases", "courses", "playlists", "community", "channels", "about", - "posts", - } - # Redirects brand url channels to a normal /channel/:ucid route def self.brand_redirect(env) locale = env.get("preferences").as(Preferences).locale @@ -343,29 +112,23 @@ module Invidious::Routes::Channels yt_url_params = URI::Params.encode(env.params.query.to_h.select(["a", "u", "user"])) # Retrieves URL params that only Invidious uses - invidious_url_params = env.params.query.dup - invidious_url_params.delete_all("a") - invidious_url_params.delete_all("u") - invidious_url_params.delete_all("user") + invidious_url_params = URI::Params.encode(env.params.query.to_h.select!(["a", "u", "user"])) begin resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}") ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"] rescue ex : InfoException | KeyError - return error_template(404, translate(locale, "This channel does not exist.")) + raise InfoException.new(translate(locale, "This channel does not exist.")) end - selected_tab = env.params.url["tab"]? - - if KNOWN_TABS.includes? selected_tab + selected_tab = env.request.path.split("/")[-1] + if ["home", "videos", "playlists", "community", "channels", "about"].includes? selected_tab url = "/channel/#{ucid}/#{selected_tab}" else url = "/channel/#{ucid}" end - url += "?#{invidious_url_params}" if !invidious_url_params.empty? - - return env.redirect url + env.redirect url end # Handles redirects for the /profile endpoint @@ -378,7 +141,7 @@ module Invidious::Routes::Channels user = env.params.query["user"]? if !user - return error_template(404, "This channel does not exist.") + raise InfoException.new("This channel does not exist.") else env.redirect "/user/#{user}#{uri_params}" end @@ -434,13 +197,10 @@ module Invidious::Routes::Channels channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect return env.redirect env.request.resource.gsub(ucid, ex.channel_id) - rescue ex : NotFoundException - return error_template(404, ex) rescue ex return error_template(500, ex) end - env.set "search", "channel:#{ucid} " return {locale, user, subscriptions, continuation, ucid, channel} end end diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 930e4915..72617a63 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -2,25 +2,16 @@ module Invidious::Routes::Embed def self.redirect(env) - locale = env.get("preferences").as(Preferences).locale if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") begin playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 videos = get_playlist_videos(playlist, offset: offset) - if videos.empty? - url = "/playlist?list=#{plid}" - raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) - end - - first_playlist_video = videos[0].as(PlaylistVideo) - rescue ex : NotFoundException - return error_template(404, ex) rescue ex return error_template(500, ex) end - url = "/embed/#{first_playlist_video}?#{env.params.query}" + url = "/embed/#{videos[0].id}?#{env.params.query}" if env.params.query.size > 0 url += "?#{env.params.query}" @@ -33,7 +24,6 @@ module Invidious::Routes::Embed end def self.show(env) - locale = env.get("preferences").as(Preferences).locale id = env.params.url["id"] plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") @@ -70,19 +60,11 @@ module Invidious::Routes::Embed playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 videos = get_playlist_videos(playlist, offset: offset) - if videos.empty? - url = "/playlist?list=#{plid}" - raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) - end - - first_playlist_video = videos[0].as(PlaylistVideo) - rescue ex : NotFoundException - return error_template(404, ex) rescue ex return error_template(500, ex) end - url = "/embed/#{first_playlist_video.id}" + url = "/embed/#{videos[0].id}" elsif video_series url = "/embed/#{video_series.shift}" env.params.query["playlist"] = video_series.join(",") @@ -135,8 +117,8 @@ module Invidious::Routes::Embed begin video = get_video(id, region: params.region) - rescue ex : NotFoundException - return error_template(404, ex) + rescue ex : VideoRedirect + return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex return error_template(500, ex) end @@ -151,8 +133,10 @@ module Invidious::Routes::Embed # PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email) # end - if CONFIG.enable_user_notifications && notifications && notifications.includes? id - Invidious::Database::Users.remove_notification(user.as(User), id) + if notifications && notifications.includes? id + if preferences.notifications + Invidious::Database::Users.remove_notification(user.as(User), id) + end env.get("user").as(User).notifications.delete(id) notifications.delete(id) end @@ -161,12 +145,10 @@ module Invidious::Routes::Embed adaptive_fmts = video.adaptive_fmts if params.local - fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } + fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) } + adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) } end - # Always proxy DASH streams, otherwise youtube CORS headers will prevent playback - adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } - video_streams = video.video_streams audio_streams = video.audio_streams @@ -207,14 +189,6 @@ module Invidious::Routes::Embed return env.redirect url end - if CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - env.response.headers["Content-Security-Policy"] = - env.response.headers["Content-Security-Policy"] - .gsub("media-src", "media-src #{invidious_companion.public_url}") - .gsub("connect-src", "connect-src #{invidious_companion.public_url}") - end - rendered "embed" end end diff --git a/src/invidious/routes/errors.cr b/src/invidious/routes/errors.cr deleted file mode 100644 index 1e9ab44e..00000000 --- a/src/invidious/routes/errors.cr +++ /dev/null @@ -1,52 +0,0 @@ -module Invidious::Routes::ErrorRoutes - def self.error_404(env) - # Workaround for #3117 - if HOST_URL.empty? && env.request.path.starts_with?("/v1/storyboards/sb") - return env.redirect "#{env.request.path[15..]}?#{env.params.query}" - end - - if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) - item = md["id"] - - # Check if item is branding URL e.g. https://youtube.com/gaming - response = YT_POOL.client &.get("/#{item}") - - if response.status_code == 301 - response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) - end - - if response.body.empty? - env.response.headers["Location"] = "/" - haltf env, status_code: 302 - end - - html = XML.parse_html(response.body) - ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] - - if ucid - env.response.headers["Location"] = "/channel/#{ucid}" - haltf env, status_code: 302 - end - - params = [] of String - env.params.query.each do |k, v| - params << "#{k}=#{v}" - end - params = params.join("&") - - url = "/watch?v=#{item}" - if !params.empty? - url += "&#{params}" - end - - # Check if item is video ID - if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 - env.response.headers["Location"] = url - haltf env, status_code: 302 - end - end - - env.response.headers["Location"] = "/" - haltf env, status_code: 302 - end -end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 070c96eb..e0d69fcd 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -83,6 +83,10 @@ module Invidious::Routes::Feeds headers = HTTP::Headers.new headers["Cookie"] = env.request.headers["Cookie"] + if !user.password + user, sid = get_user(sid, headers) + end + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) max_results ||= user.preferences.max_results max_results ||= CONFIG.default_user_preferences.max_results @@ -92,20 +96,16 @@ module Invidious::Routes::Feeds videos, notifications = get_subscription_feed(user, max_results, page) - if CONFIG.enable_user_notifications - # "updated" here is used for delivering new notifications, so if - # we know a user has looked at their feed e.g. in the past 10 minutes, - # they've already seen a video posted 20 minutes ago, and don't need - # to be notified. + # "updated" here is used for delivering new notifications, so if + # we know a user has looked at their feed e.g. in the past 10 minutes, + # they've already seen a video posted 20 minutes ago, and don't need + # to be notified. + if preferences.notifications Invidious::Database::Users.clear_notifications(user) - user.notifications = [] of String end + user.notifications = [] of String env.set "user", user - # Used for pagination links - base_url = "/feed/subscriptions" - base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results") - templated "feeds/subscriptions" end @@ -133,91 +133,78 @@ module Invidious::Routes::Feeds end watched ||= [] of String - # Used for pagination links - base_url = "/feed/history" - base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results") - templated "feeds/history" end # RSS feeds def self.rss_channel(env) + locale = env.get("preferences").as(Preferences).locale + env.response.headers["Content-Type"] = "application/atom+xml" env.response.content_type = "application/atom+xml" - if env.params.url["ucid"].matches?(/^[\w-]+$/) - ucid = env.params.url["ucid"] - else - return error_atom(400, InfoException.new("Invalid channel ucid provided.")) - end + ucid = env.params.url["ucid"] params = HTTP::Params.parse(env.params.query["params"]? || "") - namespaces = { - "yt" => "http://www.youtube.com/xml/schemas/2015", - "media" => "http://search.yahoo.com/mrss/", - "default" => "http://www.w3.org/2005/Atom", - } + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + return env.redirect env.request.resource.gsub(ucid, ex.channel_id) + rescue ex + return error_atom(500, ex) + end - response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}") - return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404 - rss = XML.parse(response.body) + response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") + rss = XML.parse_html(response.body) - videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry| - video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content - title = entry.xpath_node("default:title", namespaces).not_nil!.content + videos = rss.xpath_nodes("//feed/entry").map do |entry| + video_id = entry.xpath_node("videoid").not_nil!.content + title = entry.xpath_node("title").not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) + published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content - video_ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content - description_html = entry.xpath_node("media:group/media:description", namespaces).not_nil!.to_s - views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64 + author = entry.xpath_node("author/name").not_nil!.content + ucid = entry.xpath_node("channelid").not_nil!.content + description_html = entry.xpath_node("group/description").not_nil!.to_s + views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 SearchVideo.new({ title: title, id: video_id, author: author, - ucid: video_ucid, + ucid: ucid, published: published, views: views, description_html: description_html, length_seconds: 0, + live_now: false, + paid: false, + premium: false, premiere_timestamp: nil, - author_verified: false, - author_thumbnail: nil, - badges: VideoBadges::None, }) end - author = "" - author = videos[0].author if videos.size > 0 - XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", "xml:lang": "en-US") do xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") - xml.element("id") { xml.text "yt:channel:#{ucid}" } - xml.element("yt:channelId") { xml.text ucid } - xml.element("title") { xml.text author } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{ucid}") + xml.element("id") { xml.text "yt:channel:#{channel.ucid}" } + xml.element("yt:channelId") { xml.text channel.ucid } + xml.element("icon") { xml.text channel.author_thumbnail } + xml.element("title") { xml.text channel.author } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") xml.element("author") do - xml.element("name") { xml.text author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } - end - - xml.element("image") do - xml.element("url") { xml.text "" } - xml.element("title") { xml.text author } - xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") + xml.element("name") { xml.text channel.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } end videos.each do |video| - video.to_xml(false, params, xml) + video.to_xml(channel.auto_generated, params, xml) end end end @@ -296,13 +283,7 @@ module Invidious::Routes::Feeds xml.element("name") { xml.text playlist.author } end - videos.each do |video| - if video.is_a? PlaylistVideo - video.to_xml(xml) - else - video.to_xml(env, locale, xml) - end - end + videos.each &.to_xml(xml) end end else @@ -311,9 +292,8 @@ module Invidious::Routes::Feeds end response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") - return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404 - document = XML.parse(response.body) + document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| node.attributes.each do |attribute| case attribute.name @@ -408,23 +388,22 @@ module Invidious::Routes::Feeds end spawn do - # TODO: unify this with the other almost identical looking parts in this and channels.cr somehow? - namespaces = { - "yt" => "http://www.youtube.com/xml/schemas/2015", - "default" => "http://www.w3.org/2005/Atom", - } - rss = XML.parse(body) - rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry| - id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content - author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) + rss = XML.parse_html(body) + rss.xpath_nodes("//feed/entry").each do |entry| + id = entry.xpath_node("videoid").not_nil!.content + author = entry.xpath_node("author/name").not_nil!.content + published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - begin - video = get_video(id, force_refresh: true) - rescue - next # skip this video since it raised an exception (e.g. it is a scheduled live event) - end + video = get_video(id, force_refresh: true) + + # Deliver notifications to `/api/v1/auth/notifications` + payload = { + "topic" => video.ucid, + "videoId" => video.id, + "published" => published.to_unix, + }.to_json + PG_DB.exec("NOTIFY notifications, E'#{payload}'") video = ChannelVideo.new({ id: id, @@ -440,8 +419,8 @@ module Invidious::Routes::Feeds }) was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) - if was_insert - NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video)) + if preferences.notifications && was_insert + Invidious::Database::Users.add_notification(video) end end end diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index 51d85dfe..594a7869 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -3,7 +3,17 @@ module Invidious::Routes::Images def self.ggpht(env) url = env.request.path.lchop("/ggpht") - headers = HTTP::Headers.new + headers = ( + {% unless flag?(:disable_quic) %} + if CONFIG.use_quic + HTTP::Headers{":authority" => "yt3.ggpht.com"} + else + HTTP::Headers.new + end + {% else %} + HTTP::Headers.new + {% end %} + ) REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? @@ -11,10 +21,43 @@ module Invidious::Routes::Images end end - begin - GGPHT_POOL.client &.get(url, headers) do |resp| - return self.proxy_image(env, resp) + # We're encapsulating this into a proc in order to easily reuse this + # portion of the code for each request block below. + request_proc = ->(response : HTTP::Client::Response) { + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 + env.response.headers.delete("Transfer-Encoding") + return + end + + proxy_file(response, env) + } + + begin + {% unless flag?(:disable_quic) %} + if CONFIG.use_quic + YT_POOL.client &.get(url, headers) do |resp| + return request_proc.call(resp) + end + else + HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| + return request_proc.call(resp) + end + end + {% else %} + # This can likely be optimized into a (small) pool sometime in the future. + HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| + return request_proc.call(resp) + end + {% end %} rescue ex end end @@ -35,17 +78,51 @@ module Invidious::Routes::Images headers = HTTP::Headers.new + {% unless flag?(:disable_quic) %} + headers[":authority"] = "#{authority}.ytimg.com" + {% end %} + REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? headers[header] = env.request.headers[header] end end - begin - get_ytimg_pool(authority).client &.get(url, headers) do |resp| - env.response.headers["Connection"] = "close" - return self.proxy_image(env, resp) + request_proc = ->(response : HTTP::Client::Response) { + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end end + + env.response.headers["Connection"] = "close" + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 + return env.response.headers.delete("Transfer-Encoding") + end + + proxy_file(response, env) + } + + begin + {% unless flag?(:disable_quic) %} + if CONFIG.use_quic + YT_POOL.client &.get(url, headers) do |resp| + return request_proc.call(resp) + end + else + HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end + end + {% else %} + # This can likely be optimized into a (small) pool sometime in the future. + HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end + {% end %} rescue ex end end @@ -56,7 +133,17 @@ module Invidious::Routes::Images name = env.params.url["name"] url = env.request.resource - headers = HTTP::Headers.new + headers = ( + {% unless flag?(:disable_quic) %} + if CONFIG.use_quic + HTTP::Headers{":authority" => "i9.ytimg.com"} + else + HTTP::Headers.new + end + {% else %} + HTTP::Headers.new + {% end %} + ) REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? @@ -64,10 +151,40 @@ module Invidious::Routes::Images end end - begin - get_ytimg_pool("i9").client &.get(url, headers) do |resp| - return self.proxy_image(env, resp) + request_proc = ->(response : HTTP::Client::Response) { + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + return env.response.headers.delete("Transfer-Encoding") + end + + proxy_file(response, env) + } + + begin + {% unless flag?(:disable_quic) %} + if CONFIG.use_quic + YT_POOL.client &.get(url, headers) do |resp| + return request_proc.call(resp) + end + else + HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end + end + {% else %} + # This can likely be optimized into a (small) pool sometime in the future. + HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end + {% end %} rescue ex end end @@ -106,15 +223,41 @@ module Invidious::Routes::Images id = env.params.url["id"] name = env.params.url["name"] - headers = HTTP::Headers.new + headers = ( + {% unless flag?(:disable_quic) %} + if CONFIG.use_quic + HTTP::Headers{":authority" => "i.ytimg.com"} + else + HTTP::Headers.new + end + {% else %} + HTTP::Headers.new + {% end %} + ) if name == "maxres.jpg" build_thumbnails(id).each do |thumb| thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" - if get_ytimg_pool("i").client &.head(thumbnail_resource_path, headers).status_code == 200 - name = thumb[:url] + ".jpg" - break - end + # Logic here is short enough that manually typing them out should be fine. + {% unless flag?(:disable_quic) %} + if CONFIG.use_quic + if YT_POOL.client &.head(thumbnail_resource_path, headers).status_code == 200 + name = thumb[:url] + ".jpg" + break + end + else + if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 + name = thumb[:url] + ".jpg" + break + end + end + {% else %} + # This can likely be optimized into a (small) pool sometime in the future. + if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 + name = thumb[:url] + ".jpg" + break + end + {% end %} end end @@ -126,28 +269,41 @@ module Invidious::Routes::Images end end - begin - get_ytimg_pool("i").client &.get(url, headers) do |resp| - return self.proxy_image(env, resp) + request_proc = ->(response : HTTP::Client::Response) { + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + return env.response.headers.delete("Transfer-Encoding") + end + + proxy_file(response, env) + } + + begin + {% unless flag?(:disable_quic) %} + if CONFIG.use_quic + YT_POOL.client &.get(url, headers) do |resp| + return request_proc.call(resp) + end + else + HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end + end + {% else %} + # This can likely be optimized into a (small) pool sometime in the future. + HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end + {% end %} rescue ex end end - - private def self.proxy_image(env, response) - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - return env.response.headers.delete("Transfer-Encoding") - end - - return proxy_file(response, env) - end end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index e7de5018..99fc13a2 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -6,14 +6,14 @@ module Invidious::Routes::Login user = env.get? "user" - referer = get_referer(env, "/feed/subscriptions") - - return env.redirect referer if user + return env.redirect "/feed/subscriptions" if user if !CONFIG.login_enabled return error_template(400, "Login has been disabled by administrator.") end + referer = get_referer(env, "/feed/subscriptions") + email = nil password = nil captcha = nil @@ -21,6 +21,12 @@ module Invidious::Routes::Login account_type = env.params.query["type"]? account_type ||= "invidious" + captcha_type = env.params.query["captcha"]? + captcha_type ||= "image" + + tfa = env.params.query["tfa"]? + prompt = nil + templated "user/login" end @@ -41,18 +47,283 @@ module Invidious::Routes::Login account_type ||= "invidious" case account_type + when "google" + tfa_code = env.params.body["tfa"]?.try &.lchop("G-") + traceback = IO::Memory.new + + # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82 + begin + client = nil # Declare variable + {% unless flag?(:disable_quic) %} + client = CONFIG.use_quic ? QUIC::Client.new(LOGIN_URL) : HTTP::Client.new(LOGIN_URL) + {% else %} + client = HTTP::Client.new(LOGIN_URL) + {% end %} + + headers = HTTP::Headers.new + + login_page = client.get("/ServiceLogin") + headers = login_page.cookies.add_request_headers(headers) + + lookup_req = { + email, nil, [] of String, nil, "US", nil, nil, 2, false, true, + {nil, nil, + {2, 1, nil, 1, + "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", + nil, [] of String, 4}, + 1, + {nil, nil, [] of String}, + nil, nil, nil, true, + }, + email, + }.to_json + + traceback << "Getting lookup..." + + headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" + headers["Google-Accounts-XSRF"] = "1" + + response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req)) + lookup_results = JSON.parse(response.body[5..-1]) + + traceback << "done, returned #{response.status_code}.
    " + + user_hash = lookup_results[0][2] + + if token = env.params.body["token"]? + answer = env.params.body["answer"]? + captcha = {token, answer} + else + captcha = nil + end + + challenge_req = { + user_hash, nil, 1, nil, + {1, nil, nil, nil, + {password, captcha, true}, + }, + {nil, nil, + {2, 1, nil, 1, + "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", + nil, [] of String, 4}, + 1, + {nil, nil, [] of String}, + nil, nil, nil, true, + }, + }.to_json + + traceback << "Getting challenge..." + + response = client.post("/_/signin/sl/challenge", headers, login_req(challenge_req)) + headers = response.cookies.add_request_headers(headers) + challenge_results = JSON.parse(response.body[5..-1]) + + traceback << "done, returned #{response.status_code}.
    " + + headers["Cookie"] = URI.decode_www_form(headers["Cookie"]) + + if challenge_results[0][3]?.try &.== 7 + return error_template(423, "Account has temporarily been disabled") + end + + if token = challenge_results[0][-1]?.try &.[-1]?.try &.as_h?.try &.["5001"]?.try &.[-1].as_a?.try &.[-1].as_s + account_type = "google" + captcha_type = "image" + prompt = nil + tfa = tfa_code + captcha = {tokens: [token], question: ""} + + return templated "user/login" + end + + if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" + return error_template(401, "Incorrect password") + end + + prompt_type = challenge_results[0][-1]?.try &.[0].as_a?.try &.[0][2]? + if {"TWO_STEP_VERIFICATION", "LOGIN_CHALLENGE"}.includes? prompt_type + traceback << "Handling prompt #{prompt_type}.
    " + case prompt_type + when "TWO_STEP_VERIFICATION" + prompt_type = 2 + else # "LOGIN_CHALLENGE" + prompt_type = 4 + end + + # Prefer Authenticator app and SMS over unsupported protocols + if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8].as_i) && prompt_type == 2 + tfa = challenge_results[0][-1][0].as_a.select { |auth_type| {6, 9, 12, 15}.includes? auth_type[8] }[0] + + traceback << "Selecting challenge #{tfa[8]}..." + select_challenge = {prompt_type, nil, nil, nil, {tfa[8]}}.to_json + + tl = challenge_results[1][2] + + tfa = client.post("/_/signin/selectchallenge?TL=#{tl}", headers, login_req(select_challenge)).body + tfa = tfa[5..-1] + tfa = JSON.parse(tfa)[0][-1] + + traceback << "done.
    " + else + traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.
    " + tfa = challenge_results[0][-1][0][0] + end + + if tfa[5] == "QUOTA_EXCEEDED" + return error_template(423, "Quota exceeded, try again in a few hours") + end + + if !tfa_code + account_type = "google" + captcha_type = "image" + + case tfa[8] + when 6, 9 + prompt = "Google verification code" + when 12 + prompt = "Login verification, recovery email: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" + when 15 + prompt = "Login verification, security question: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" + else + prompt = "Google verification code" + end + + tfa = nil + captcha = nil + return templated "user/login" + end + + tl = challenge_results[1][2] + + request_type = tfa[8] + case request_type + when 6 # Authenticator app + tfa_req = { + user_hash, nil, 2, nil, + {6, nil, nil, nil, nil, + {tfa_code, false}, + }, + }.to_json + when 9 # Voice or text message + tfa_req = { + user_hash, nil, 2, nil, + {9, nil, nil, nil, nil, nil, nil, nil, + {nil, tfa_code, false, 2}, + }, + }.to_json + when 12 # Recovery email + tfa_req = { + user_hash, nil, 4, nil, + {12, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + {tfa_code}, + }, + }.to_json + when 15 # Security question + tfa_req = { + user_hash, nil, 5, nil, + {15, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + {tfa_code}, + }, + }.to_json + else + return error_template(500, "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.") + end + + traceback << "Submitting challenge..." + + response = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(tfa_req)) + headers = response.cookies.add_request_headers(headers) + challenge_results = JSON.parse(response.body[5..-1]) + + if (challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED") || + (challenge_results[0][-1]?.try &.[5] == "INVALID_INPUT") + return error_template(401, "Invalid TFA code") + end + + traceback << "done.
    " + end + + traceback << "Logging in..." + + location = URI.parse(challenge_results[0][-1][2].to_s) + cookies = HTTP::Cookies.from_client_headers(headers) + + headers.delete("Content-Type") + headers.delete("Google-Accounts-XSRF") + + loop do + if !location || location.path == "/ManageAccount" + break + end + + # Occasionally there will be a second page after login confirming + # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle. + + if location.path.starts_with? "/b/0/SmsAuthInterstitial" + traceback << "Unhandled dialog /b/0/SmsAuthInterstitial." + end + + login = client.get(location.request_target, headers) + + headers = login.cookies.add_request_headers(headers) + location = login.headers["Location"]?.try { |u| URI.parse(u) } + end + + cookies = HTTP::Cookies.from_client_headers(headers) + sid = cookies["SID"]?.try &.value + if !sid + raise "Couldn't get SID." + end + + user, sid = get_user(sid, headers) + + # We are now logged in + traceback << "done.
    " + + host = URI.parse(env.request.headers["Host"]).host + + cookies.each do |cookie| + cookie.secure = Invidious::User::Cookies::SECURE + + if cookie.extension + cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host) + cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "") + end + env.response.cookies << cookie + end + + if env.request.cookies["PREFS"]? + user.preferences = env.get("preferences").as(Preferences) + Invidious::Database::Users.update_preferences(user) + + cookie = env.request.cookies["PREFS"] + cookie.expires = Time.utc(1990, 1, 1) + env.response.cookies << cookie + end + + env.redirect referer + rescue ex + traceback.rewind + # error_message = translate(locale, "Login failed. This may be because two-factor authentication is not turned on for your account.") + error_message = %(#{ex.message}
    Traceback:
    #{traceback.gets_to_end}
    ) + return error_template(500, error_message) + end when "invidious" - if email.nil? || email.empty? + if !email return error_template(401, "User ID is a required field") end - if password.nil? || password.empty? + if !password return error_template(401, "Password is a required field") end user = Invidious::Database::Users.select(email: email) if user + if !user.password + return error_template(400, "Please sign in using 'Log in with Google'") + end + if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) Invidious::Database::SessionIDs.insert(sid, email) @@ -85,14 +356,36 @@ module Invidious::Routes::Login password = password.byte_slice(0, 55) if CONFIG.captcha_enabled + captcha_type = env.params.body["captcha_type"]? answer = env.params.body["answer"]? + change_type = env.params.body["change_type"]? - account_type = "invidious" - captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) + if !captcha_type || change_type + if change_type + captcha_type = change_type + end + captcha_type ||= "image" + + account_type = "invidious" + tfa = false + prompt = "" + + if captcha_type == "image" + captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) + else + captcha = Invidious::User::Captcha.generate_text(HMAC_KEY) + end + + return templated "user/login" + end tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v } - if answer + answer ||= "" + captcha_type ||= "image" + + case captcha_type + when "image" answer = answer.lstrip('0') answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) @@ -101,8 +394,27 @@ module Invidious::Routes::Login rescue ex return error_template(400, ex) end - else - return templated "user/login" + else # "text" + answer = Digest::MD5.hexdigest(answer.downcase.strip) + + if tokens.empty? + return error_template(500, "Erroneous CAPTCHA") + end + + found_valid_captcha = false + error_exception = Exception.new + tokens.each do |tok| + begin + validate_request(tok, answer, env.request, HMAC_KEY, locale) + found_valid_captcha = true + rescue ex + error_exception = ex + end + end + + if !found_valid_captcha + return error_template(500, error_exception) + end end end @@ -169,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 diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index 0b868755..d6bd9571 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -40,21 +40,7 @@ module Invidious::Routes::Misc def self.cross_instance_redirect(env) referer = get_referer(env) - - instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"] - # Filter out the current instance - other_available_instances = instance_list.reject { |_, domain| domain == CONFIG.domain } - - if other_available_instances.empty? - # If the current instance is the only one, use the redirect URL as fallback - instance_url = "redirect.invidious.io" - else - # Select other random instance - # Sample returns an array - # Instances are packaged as {region, domain} in the instance list - instance_url = other_available_instances.sample(1)[0][1] - end - + instance_url = fetch_random_instance env.redirect "https://#{instance_url}#{referer}" end end diff --git a/src/invidious/routes/notifications.cr b/src/invidious/routes/notifications.cr index 8922b740..272a3dc7 100644 --- a/src/invidious/routes/notifications.cr +++ b/src/invidious/routes/notifications.cr @@ -24,6 +24,50 @@ module Invidious::Routes::Notifications user = user.as(User) + if !user.password + channel_req = {} of String => String + + channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true" + channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || "" + channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true" + + channel_req.reject! { |k, v| v != "true" && v != "false" } + + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) + + cookies = HTTP::Cookies.from_client_headers(headers) + html.cookies.each do |cookie| + if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name + if cookies[cookie.name]? + cookies[cookie.name] = cookie + else + cookies << cookie + end + end + end + headers = cookies.add_request_headers(headers) + + if match = html.body.match(/'XSRF_TOKEN': "(?[^"]+)"/) + session_token = match["session_token"] + else + return env.redirect referer + end + + headers["content-type"] = "application/x-www-form-urlencoded" + channel_req["session_token"] = session_token + + subs = XML.parse_html(html.body) + subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel| + channel_id = channel.content.lstrip("/channel/").not_nil! + channel_req["channel_id"] = channel_id + + YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req) + end + end + if redirect env.redirect referer else diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index f2213da4..dbeb4f97 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -66,13 +66,7 @@ module Invidious::Routes::Playlists user = user.as(User) playlist_id = env.params.query["list"] - begin - playlist = get_playlist(playlist_id) - rescue ex : NotFoundException - return error_template(404, ex) - rescue ex - return error_template(500, ex) - end + playlist = get_playlist(playlist_id) subscribe_playlist(user, playlist) env.redirect "/playlist?list=#{playlist.id}" @@ -163,20 +157,13 @@ module Invidious::Routes::Playlists end begin - items = get_playlist_videos(playlist, offset: (page - 1) * 100) + videos = get_playlist_videos(playlist, offset: (page - 1) * 100) rescue ex - items = [] of PlaylistVideo + videos = [] of PlaylistVideo end csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY) - # Pagination - page_nav_html = Frontend::Pagination.nav_numeric(locale, - base_url: "/playlist?list=#{playlist.id}", - current_page: page, - show_next: (items.size == 100) - ) - templated "edit_playlist" end @@ -225,10 +212,7 @@ module Invidious::Routes::Playlists end def self.add_playlist_items_page(env) - prefs = env.get("preferences").as(Preferences) - locale = prefs.locale - - region = env.params.query["region"]? || prefs.region + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -252,21 +236,18 @@ module Invidious::Routes::Playlists return env.redirect referer end - begin - query = Invidious::Search::Query.new(env.params.query, :playlist, region) - items = query.process.select(SearchVideo).map(&.as(SearchVideo)) - rescue ex - items = [] of SearchVideo + query = env.params.query["q"]? + if query + begin + search_query, items, operators = process_search_query(query, page, user, region: nil) + videos = items.select(SearchVideo).map(&.as(SearchVideo)) + rescue ex + videos = [] of SearchVideo + end + else + videos = [] of SearchVideo end - # Pagination - query_encoded = URI.encode_www_form(query.try &.text || "", space_to_plus: true) - page_nav_html = Frontend::Pagination.nav_numeric(locale, - base_url: "/add_playlist_items?list=#{playlist.id}&q=#{query_encoded}", - current_page: page, - show_next: (items.size >= 20) - ) - env.set "add_playlist_items", plid templated "add_playlist_items" end @@ -304,12 +285,27 @@ module Invidious::Routes::Playlists end end + if env.params.query["action_create_playlist"]? + action = "action_create_playlist" + elsif env.params.query["action_delete_playlist"]? + action = "action_delete_playlist" + elsif env.params.query["action_edit_playlist"]? + action = "action_edit_playlist" + elsif env.params.query["action_add_video"]? + action = "action_add_video" + video_id = env.params.query["video_id"] + elsif env.params.query["action_remove_video"]? + action = "action_remove_video" + elsif env.params.query["action_move_video_before"]? + action = "action_move_video_before" + else + return env.redirect referer + end + begin playlist_id = env.params.query["playlist_id"] playlist = get_playlist(playlist_id).as(InvidiousPlaylist) raise "Invalid user" if playlist.author != user.email - rescue ex : NotFoundException - return error_json(404, ex) rescue ex if redirect return error_template(400, ex) @@ -318,13 +314,21 @@ module Invidious::Routes::Playlists end end - case action = env.params.query["action"]? - when "add_video" - if playlist.index.size >= CONFIG.playlist_length_limit + if !user.password + # TODO: Playlist stub, sync with YouTube for Google accounts + # playlist_ajax(playlist_id, action, env.request.headers) + end + email = user.email + + case action + when "action_edit_playlist" + # TODO: Playlist stub + when "action_add_video" + if playlist.index.size >= 500 if redirect - return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") + return error_template(400, "Playlist cannot have more than 500 videos") else - return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") + return error_json(400, "Playlist cannot have more than 500 videos") end end @@ -332,8 +336,6 @@ module Invidious::Routes::Playlists begin video = get_video(video_id) - rescue ex : NotFoundException - return error_json(404, ex) rescue ex if redirect return error_template(500, ex) @@ -356,14 +358,12 @@ module Invidious::Routes::Playlists Invidious::Database::PlaylistVideos.insert(playlist_video) Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index) - when "remove_video" + when "action_remove_video" index = env.params.query["set_video_id"] Invidious::Database::PlaylistVideos.delete(index) Invidious::Database::Playlists.update_video_removed(playlist_id, index) - when "move_video_before" + when "action_move_video_before" # TODO: Playlist stub - when nil - return error_json(400, "Missing action") else return error_json(400, "Unsupported action #{action}") end @@ -396,19 +396,12 @@ module Invidious::Routes::Playlists begin playlist = get_playlist(plid) - rescue ex : NotFoundException - return error_template(404, ex) rescue ex return error_template(500, ex) end - if playlist.is_a? InvidiousPlaylist - page_count = (playlist.video_count / 100).to_i - page_count += 1 if (playlist.video_count % 100) > 0 - else - page_count = (playlist.video_count / 200).to_i - page_count += 1 if (playlist.video_count % 200) > 0 - end + page_count = (playlist.video_count / 100).to_i + page_count += 1 if (playlist.video_count % 100) > 0 if page > page_count return env.redirect "/playlist?list=#{plid}&page=#{page_count}" @@ -419,11 +412,7 @@ module Invidious::Routes::Playlists end begin - if playlist.is_a? InvidiousPlaylist - items = get_playlist_videos(playlist, offset: (page - 1) * 100) - else - items = get_playlist_videos(playlist, offset: (page - 1) * 200) - end + videos = get_playlist_videos(playlist, offset: (page - 1) * 100) rescue ex return error_template(500, "Error encountered while retrieving playlist videos.
    #{ex.message}") end @@ -432,13 +421,6 @@ module Invidious::Routes::Playlists env.set "remove_playlist_items", plid end - # Pagination - page_nav_html = Frontend::Pagination.nav_numeric(locale, - base_url: "/playlist?list=#{playlist.id}", - current_page: page, - show_next: (page_count != 1 && page < page_count) - ) - templated "playlist" end diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 39ca77c0..238c1bfa 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -27,10 +27,6 @@ module Invidious::Routes::PreferencesRoute annotations_subscribed ||= "off" annotations_subscribed = annotations_subscribed == "on" - preload = env.params.body["preload"]?.try &.as(String) - preload ||= "off" - preload = preload == "on" - autoplay = env.params.body["autoplay"]?.try &.as(String) autoplay ||= "off" autoplay = autoplay == "on" @@ -55,6 +51,10 @@ module Invidious::Routes::PreferencesRoute watch_history ||= "off" watch_history = watch_history == "on" + notifications = env.params.body["notifications"]?.try &.as(String) + notifications ||= "off" + notifications = notifications == "on" + speed = env.params.body["speed"]?.try &.as(String).to_f32? speed ||= CONFIG.default_user_preferences.speed @@ -148,7 +148,6 @@ module Invidious::Routes::PreferencesRoute preferences = Preferences.from_json({ annotations: annotations, annotations_subscribed: annotations_subscribed, - preload: preload, autoplay: autoplay, captions: captions, comments: comments, @@ -159,6 +158,7 @@ module Invidious::Routes::PreferencesRoute listen: listen, local: local, watch_history: watch_history, + notifications: notifications, locale: locale, max_results: max_results, notifications_only: notifications_only, @@ -219,7 +219,7 @@ module Invidious::Routes::PreferencesRoute statistics_enabled ||= "off" CONFIG.statistics_enabled = statistics_enabled == "on" - CONFIG.modified_source_code_url = env.params.body["modified_source_code_url"]?.presence + CONFIG.modified_source_code_url = env.params.body["modified_source_code_url"]?.try &.as(String) File.write("config/config.yml", CONFIG.to_yaml) end @@ -315,24 +315,6 @@ module Invidious::Routes::PreferencesRoute response: error_template(415, "Invalid subscription file uploaded") ) end - when "import_youtube_pl" - filename = part.filename || "" - success = Invidious::User::Import.from_youtube_pl(user, body, filename, type) - - if !success - haltf(env, status_code: 415, - response: error_template(415, "Invalid playlist file uploaded") - ) - end - when "import_youtube_wh" - filename = part.filename || "" - success = Invidious::User::Import.from_youtube_wh(user, body, filename, type) - - if !success - haltf(env, status_code: 415, - response: error_template(415, "Invalid watch history file uploaded") - ) - end when "import_freetube" Invidious::User::Import.from_freetube(user, body) when "import_newpipe_subscriptions" diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index b195c7b3..3f4c7e5e 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -37,87 +37,38 @@ module Invidious::Routes::Search end def self.search(env) - prefs = env.get("preferences").as(Preferences) - locale = prefs.locale + locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? - region = env.params.query["region"]? || prefs.region + query = env.params.query["search_query"]? + query ||= env.params.query["q"]? - query = Invidious::Search::Query.new(env.params.query, :regular, region) - - if query.empty? + if !query || query.empty? # Display the full page search box implemented in #1977 env.set "search", "" templated "search_homepage", navbar_search: false else + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + user = env.get? "user" - # An URL was copy/pasted in the search box. - # Redirect the user to the appropriate page. - if query.url? - return env.redirect UrlSanitizer.process(query.text).to_s - end - begin - if user - items = query.process(user.as(User)) - else - items = query.process - end + search_query, videos, operators = process_search_query(query, page, user, region: region) rescue ex : ChannelSearchException return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex return error_template(500, ex) end - redirect_url = Invidious::Frontend::Misc.redirect_url(env) - - # Pagination - page_nav_html = Frontend::Pagination.nav_numeric(locale, - base_url: "/search?#{query.to_http_params}", - current_page: query.page, - show_next: (items.size >= 20) - ) - - if query.type == Invidious::Search::Query::Type::Channel - env.set "search", "channel:#{query.channel} #{query.text}" - else - env.set "search", query.text + operator_hash = {} of String => String + operators.each do |operator| + key, value = operator.downcase.split(":") + operator_hash[key] = value end + env.set "search", query templated "search" end end - - def self.hashtag(env : HTTP::Server::Context) - locale = env.get("preferences").as(Preferences).locale - - hashtag = env.params.url["hashtag"]? - if hashtag.nil? || hashtag.empty? - return error_template(400, "Invalid request") - end - - page = env.params.query["page"]? - if page.nil? - page = 1 - else - page = Math.max(1, page.to_i) - env.params.query.delete_all("page") - end - - begin - items = Invidious::Hashtag.fetch(hashtag, page) - rescue ex - return error_template(500, ex) - end - - # Pagination - hashtag_encoded = URI.encode_www_form(hashtag, space_to_plus: false) - page_nav_html = Frontend::Pagination.nav_numeric(locale, - base_url: "/hashtag/#{hashtag_encoded}", - current_page: page, - show_next: (items.size >= 60) - ) - - templated "hashtag" - end end diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index 1de655d2..7b1fa876 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -32,16 +32,29 @@ module Invidious::Routes::Subscriptions end end + if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1 + action = "action_create_subscription_to_channel" + elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1 + action = "action_remove_subscriptions" + else + return env.redirect referer + end + channel_id = env.params.query["c"]? channel_id ||= "" - case action = env.params.query["action"]? - when "create_subscription_to_channel" + if !user.password + # Sync subscriptions with YouTube + subscribe_ajax(channel_id, action, env.request.headers) + end + + case action + when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id get_channel(channel_id) Invidious::Database::Users.subscribe_channel(user, channel_id) end - when "remove_subscriptions" + when "action_remove_subscriptions" Invidious::Database::Users.unsubscribe_channel(user, channel_id) else return error_json(400, "Unsupported action #{action}") @@ -69,6 +82,14 @@ module Invidious::Routes::Subscriptions user = user.as(User) sid = sid.as(String) + if !user.password + # Refresh account + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + user, sid = get_user(sid, headers) + end + action_takeout = env.params.query["action_takeout"]?.try &.to_i? action_takeout ||= 0 action_takeout = action_takeout == 1 @@ -83,8 +104,33 @@ module Invidious::Routes::Subscriptions if format == "json" env.response.content_type = "application/json" env.response.headers["content-disposition"] = "attachment" + playlists = Invidious::Database::Playlists.select_like_iv(user.email) - return Invidious::User::Export.to_invidious(user) + return JSON.build do |json| + json.object do + json.field "subscriptions", user.subscriptions + json.field "watch_history", user.watched + json.field "preferences", user.preferences + json.field "playlists" do + json.array do + playlists.each do |playlist| + json.object do + json.field "title", playlist.title + json.field "description", html_to_content(playlist.description_html) + json.field "privacy", playlist.privacy.to_s + json.field "videos" do + json.array do + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| + json.string video_id + end + end + end + end + end + end + end + end + end else env.response.content_type = "application/xml" env.response.headers["content-disposition"] = "attachment" diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 083087a9..3a92ef96 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -21,7 +21,7 @@ module Invidious::Routes::VideoPlayback end # Sanity check, to avoid being used as an open proxy - if !host.matches?(/[\w-]+\.(?:googlevideo|c\.youtube)\.com/) + if !host.matches?(/[\w-]+.googlevideo.com/) return error_template(400, "Invalid \"host\" parameter.") end @@ -35,15 +35,7 @@ module Invidious::Routes::VideoPlayback end end - # See: https://github.com/iv-org/invidious/issues/3302 - range_header = env.request.headers["Range"]? - sq = query_params["sq"]? - if range_header.nil? && sq.nil? - range_for_head = query_params["range"]? || "0-640" - headers["Range"] = "bytes=#{range_for_head}" - end - - client = make_client(URI.parse(host), region, force_resolve: true) + client = make_client(URI.parse(host), region) response = HTTP::Client::Response.new(500) error = "" 5.times do @@ -58,7 +50,7 @@ module Invidious::Routes::VideoPlayback if new_host != host host = new_host client.close - client = make_client(URI.parse(new_host), region, force_resolve: true) + client = make_client(URI.parse(new_host), region) end url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" @@ -72,23 +64,15 @@ module Invidious::Routes::VideoPlayback fvip = "3" host = "https://r#{fvip}---#{mn}.googlevideo.com" - client = make_client(URI.parse(host), region, force_resolve: true) + client = make_client(URI.parse(host), region) rescue ex error = ex.message end end - # Remove the Range header added previously. - headers.delete("Range") if range_header.nil? - - playback_statistics = get_playback_statistic() - playback_statistics["totalRequests"] += 1 - if response.status_code >= 400 env.response.content_type = "text/plain" haltf env, response.status_code - else - playback_statistics["successfulRequests"] += 1 end if url.includes? "&file=seg.ts" @@ -107,8 +91,14 @@ module Invidious::Routes::VideoPlayback env.response.headers["Access-Control-Allow-Origin"] = "*" if location = resp.headers["Location"]? - url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region) - return env.redirect url + location = URI.parse(location) + location = "#{location.request_target}&host=#{location.host}" + + if region + location += "®ion=#{region}" + end + + return env.redirect location end IO.copy(resp.body_io, env.response) @@ -132,7 +122,7 @@ module Invidious::Routes::VideoPlayback end # TODO: Record bytes written so we can restart after a chunk fails - loop do + while true if !range_end && content_length range_end = content_length end @@ -165,13 +155,10 @@ module Invidious::Routes::VideoPlayback env.response.headers["Access-Control-Allow-Origin"] = "*" if location = resp.headers["Location"]? - url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region) + location = URI.parse(location) + location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" - if title = query_params["title"]? - url = "#{url}&title=#{URI.encode_www_form(title)}" - end - - env.redirect url + env.redirect location break end @@ -200,7 +187,7 @@ module Invidious::Routes::VideoPlayback break else client.close - client = make_client(URI.parse(host), region, force_resolve: true) + client = make_client(URI.parse(host), region) end end @@ -257,11 +244,6 @@ 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 CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}" - end - id = env.params.query["id"]? itag = env.params.query["itag"]?.try &.to_i? @@ -270,7 +252,7 @@ module Invidious::Routes::VideoPlayback return error_template(400, "Invalid video ID") end - if !itag.nil? && (itag <= 0 || itag >= 1000) + if itag.nil? || itag <= 0 || itag >= 1000 return error_template(400, "Invalid itag") end @@ -283,19 +265,9 @@ module Invidious::Routes::VideoPlayback return error_template(403, "Administrator has disabled this endpoint.") end - begin - video = get_video(id, region: region) - rescue ex : NotFoundException - return error_template(404, ex) - rescue ex - return error_template(500, ex) - end + video = get_video(id, region: region) - if itag.nil? - fmt = video.fmt_stream[-1]? - else - fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } - end + fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } url = fmt.try &.["url"]?.try &.as_s if !url diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index e777b3f1..99703297 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -30,6 +30,14 @@ module Invidious::Routes::Watch return env.redirect "/" end + embed_link = "/embed/#{id}" + if env.params.query.size > 1 + embed_params = HTTP::Params.parse(env.params.query.to_s) + embed_params.delete_all("v") + embed_link += "?" + embed_link += embed_params.to_s + end + plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") continuation = process_continuation(env.params.query, plid, id) @@ -53,9 +61,8 @@ module Invidious::Routes::Watch begin video = get_video(id, region: params.region) - rescue ex : NotFoundException - LOGGER.error("get_video not found: #{id} : #{ex.message}") - return error_template(404, ex) + rescue ex : VideoRedirect + return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex LOGGER.error("get_video: #{id} : #{ex.message}") return error_template(500, ex) @@ -68,12 +75,14 @@ module Invidious::Routes::Watch end env.params.query.delete_all("iv_load_policy") - if watched && preferences.watch_history + if watched && preferences.watch_history && !watched.includes? id Invidious::Database::Users.mark_watched(user.as(User), id) end - if CONFIG.enable_user_notifications && notifications && notifications.includes? id - Invidious::Database::Users.remove_notification(user.as(User), id) + if notifications && notifications.includes? id + if preferences.notifications + Invidious::Database::Users.remove_notification(user.as(User), id) + end env.get("user").as(User).notifications.delete(id) notifications.delete(id) end @@ -87,31 +96,31 @@ module Invidious::Routes::Watch if source == "youtube" begin - comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] rescue ex if preferences.comments[1] == "reddit" - comments, reddit_thread = Comments.fetch_reddit(id) - comment_html = Frontend::Comments.template_reddit(comments, locale) + comments, reddit_thread = fetch_reddit_comments(id) + comment_html = template_reddit_comments(comments, locale) - comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com") - comment_html = Comments.replace_links(comment_html) + comment_html = fill_links(comment_html, "https", "www.reddit.com") + comment_html = replace_links(comment_html) end end elsif source == "reddit" begin - comments, reddit_thread = Comments.fetch_reddit(id) - comment_html = Frontend::Comments.template_reddit(comments, locale) + comments, reddit_thread = fetch_reddit_comments(id) + comment_html = template_reddit_comments(comments, locale) - comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com") - comment_html = Comments.replace_links(comment_html) + comment_html = fill_links(comment_html, "https", "www.reddit.com") + comment_html = replace_links(comment_html) rescue ex if preferences.comments[1] == "youtube" - comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end end end else - comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end comment_html ||= "" @@ -121,12 +130,10 @@ module Invidious::Routes::Watch adaptive_fmts = video.adaptive_fmts if params.local - fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } + fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) } + adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) } end - # Always proxy DASH streams, otherwise youtube CORS headers will prevent playback - adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } - video_streams = video.video_streams audio_streams = video.audio_streams @@ -192,14 +199,6 @@ module Invidious::Routes::Watch captions: video.captions ) - if CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - env.response.headers["Content-Security-Policy"] = - env.response.headers["Content-Security-Policy"] - .gsub("media-src", "media-src #{invidious_companion.public_url}") - .gsub("connect-src", "connect-src #{invidious_companion.public_url}") - end - templated "watch" end @@ -251,10 +250,20 @@ module Invidious::Routes::Watch end end - case action = env.params.query["action"]? - when "mark_watched" - Invidious::Database::Users.mark_watched(user, id) - when "mark_unwatched" + if env.params.query["action_mark_watched"]? + action = "action_mark_watched" + elsif env.params.query["action_mark_unwatched"]? + action = "action_mark_unwatched" + else + return env.redirect referer + end + + case action + when "action_mark_watched" + if !user.watched.includes? id + Invidious::Database::Users.mark_watched(user, id) + end + when "action_mark_unwatched" Invidious::Database::Users.mark_unwatched(user, id) else return error_json(400, "Unsupported action #{action}") @@ -277,12 +286,6 @@ module Invidious::Routes::Watch return error_template(400, "Invalid clip ID") if response["error"]? if video_id = response.dig?("endpoint", "watchEndpoint", "videoId") - if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s - start_time, end_time, _ = parse_clip_parameters(params) - env.params.query["start"] = start_time.to_s if start_time != nil - env.params.query["end"] = end_time.to_s if end_time != nil - end - return env.redirect "/watch?v=#{video_id}&#{env.params.query}" else return error_template(404, "The requested clip doesn't exist") @@ -293,9 +296,6 @@ module Invidious::Routes::Watch if CONFIG.disabled?("downloads") return error_template(403, "Administrator has disabled this endpoint.") end - if CONFIG.invidious_companion.present? - return error_template(403, "Downloads should be routed through Companion when present") - end title = env.params.body["title"]? || "" video_id = env.params.body["id"]? || "" @@ -308,27 +308,27 @@ module Invidious::Routes::Watch download_widget = JSON.parse(selection) extension = download_widget["ext"].as_s - filename = "#{title}-#{video_id}.#{extension}" - - # Delete the now useless URL parameters - env.params.body.delete("id") - env.params.body.delete("title") - env.params.body.delete("download_widget") + 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.url["id"] = video_id - env.params.query["title"] = filename - env.params.query["label"] = URI.decode_www_form(label.as_s) + 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.to_s + elsif itag = download_widget["itag"]?.try &.as_i # URL params specific to /latest_version - env.params.query["id"] = video_id - env.params.query["title"] = filename + env.params.query["itag"] = itag.to_s env.params.query["local"] = "true" return Invidious::Routes::VideoPlayback.latest_version(env) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 46b71f1f..bd72c577 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -1,319 +1,130 @@ module Invidious::Routing - extend self - - {% for http_method in {"get", "post", "delete", "options", "patch", "put"} %} + {% for http_method in {"get", "post", "delete", "options", "patch", "put", "head"} %} macro {{http_method.id}}(path, controller, method = :handle) - unless Kemal::Utils.path_starts_with_slash?(\{{path}}) - raise Kemal::Exceptions::InvalidPathStartException.new({{http_method}}, \{{path}}) - end - - Kemal::RouteHandler::INSTANCE.add_route({{http_method.upcase}}, \{{path}}) do |env| + {{http_method.id}} \{{ path }} do |env| \{{ controller }}.\{{ method.id }}(env) end end {% end %} - - def register_all - {% unless flag?(:api_only) %} - get "/", Routes::Misc, :home - get "/privacy", Routes::Misc, :privacy - get "/licenses", Routes::Misc, :licenses - get "/redirect", Routes::Misc, :cross_instance_redirect - - self.register_channel_routes - self.register_watch_routes - - self.register_iv_playlist_routes - self.register_yt_playlist_routes - - self.register_search_routes - - self.register_user_routes - self.register_feed_routes - - # Support push notifications via PubSubHubbub - get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get - post "/feed/webhook/:token", Routes::Feeds, :push_notifications_post - - if CONFIG.enable_user_notifications - get "/modify_notifications", Routes::Notifications, :modify - end - {% end %} - - self.register_image_routes - self.register_api_v1_routes - self.register_api_manifest_routes - self.register_video_playback_routes - end - - # ------------------- - # Invidious routes - # ------------------- - - def register_user_routes - # User login/out - get "/login", Routes::Login, :login_page - post "/login", Routes::Login, :login - post "/signout", Routes::Login, :signout - - # User preferences - get "/preferences", Routes::PreferencesRoute, :show - post "/preferences", Routes::PreferencesRoute, :update - get "/toggle_theme", Routes::PreferencesRoute, :toggle_theme - get "/data_control", Routes::PreferencesRoute, :data_control - post "/data_control", Routes::PreferencesRoute, :update_data_control - - # User account management - get "/change_password", Routes::Account, :get_change_password - post "/change_password", Routes::Account, :post_change_password - get "/delete_account", Routes::Account, :get_delete - post "/delete_account", Routes::Account, :post_delete - get "/clear_watch_history", Routes::Account, :get_clear_history - post "/clear_watch_history", Routes::Account, :post_clear_history - get "/authorize_token", Routes::Account, :get_authorize_token - post "/authorize_token", Routes::Account, :post_authorize_token - get "/token_manager", Routes::Account, :token_manager - post "/token_ajax", Routes::Account, :token_ajax - post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription - get "/subscription_manager", Routes::Subscriptions, :subscription_manager - end - - def register_iv_playlist_routes - get "/create_playlist", Routes::Playlists, :new - post "/create_playlist", Routes::Playlists, :create - get "/subscribe_playlist", Routes::Playlists, :subscribe - get "/delete_playlist", Routes::Playlists, :delete_page - post "/delete_playlist", Routes::Playlists, :delete - get "/edit_playlist", Routes::Playlists, :edit - post "/edit_playlist", Routes::Playlists, :update - get "/add_playlist_items", Routes::Playlists, :add_playlist_items_page - post "/playlist_ajax", Routes::Playlists, :playlist_ajax - end - - def register_feed_routes - # Feeds - get "/view_all_playlists", Routes::Feeds, :view_all_playlists_redirect - get "/feed/playlists", Routes::Feeds, :playlists - get "/feed/popular", Routes::Feeds, :popular - get "/feed/trending", Routes::Feeds, :trending - get "/feed/subscriptions", Routes::Feeds, :subscriptions - get "/feed/history", Routes::Feeds, :history - - # RSS Feeds - get "/feed/channel/:ucid", Routes::Feeds, :rss_channel - get "/feed/private", Routes::Feeds, :rss_private - get "/feed/playlist/:plid", Routes::Feeds, :rss_playlist - get "/feeds/videos.xml", Routes::Feeds, :rss_videos - end - - # ------------------- - # Youtube routes - # ------------------- - - def register_channel_routes - get "/channel/:ucid", Routes::Channels, :home - get "/channel/:ucid/home", Routes::Channels, :home - get "/channel/:ucid/videos", Routes::Channels, :videos - get "/channel/:ucid/shorts", Routes::Channels, :shorts - get "/channel/:ucid/streams", Routes::Channels, :streams - get "/channel/:ucid/podcasts", Routes::Channels, :podcasts - get "/channel/:ucid/releases", Routes::Channels, :releases - get "/channel/:ucid/courses", Routes::Channels, :courses - get "/channel/:ucid/playlists", Routes::Channels, :playlists - get "/channel/:ucid/community", Routes::Channels, :community - get "/channel/:ucid/posts", Routes::Channels, :community - get "/channel/:ucid/channels", Routes::Channels, :channels - get "/channel/:ucid/about", Routes::Channels, :about - - get "/channel/:ucid/live", Routes::Channels, :live - get "/user/:user/live", Routes::Channels, :live - get "/c/:user/live", Routes::Channels, :live - get "/post/:id", Routes::Channels, :post - - # Channel catch-all, to redirect future routes to the channel's home - # NOTE: defined last in order to be processed after the other routes - get "/channel/:ucid/*", Routes::Channels, :redirect_home - - # /c/LinusTechTips - get "/c/:user", Routes::Channels, :brand_redirect - get "/c/:user/:tab", Routes::Channels, :brand_redirect - - # /user/linustechtips (Not always the same as /c/) - get "/user/:user", Routes::Channels, :brand_redirect - get "/user/:user/:tab", Routes::Channels, :brand_redirect - - # /@LinusTechTips (Handle) - get "/@:user", Routes::Channels, :brand_redirect - get "/@:user/:tab", Routes::Channels, :brand_redirect - - # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow - get "/attribution_link", Routes::Channels, :brand_redirect - get "/attribution_link/:tab", Routes::Channels, :brand_redirect - - # /profile?user=linustechtips - get "/profile", Routes::Channels, :profile - get "/profile/*", Routes::Channels, :profile - end - - def register_watch_routes - get "/watch", Routes::Watch, :handle - post "/watch_ajax", Routes::Watch, :mark_watched - get "/watch/:id", Routes::Watch, :redirect - get "/live/:id", Routes::Watch, :redirect - get "/shorts/:id", Routes::Watch, :redirect - get "/clip/:clip", Routes::Watch, :clip - get "/w/:id", Routes::Watch, :redirect - get "/v/:id", Routes::Watch, :redirect - get "/e/:id", Routes::Watch, :redirect - - post "/download", Routes::Watch, :download - - get "/embed/", Routes::Embed, :redirect - get "/embed/:id", Routes::Embed, :show - end - - def register_yt_playlist_routes - get "/playlist", Routes::Playlists, :show - get "/mix", Routes::Playlists, :mix - get "/watch_videos", Routes::Playlists, :watch_videos - end - - def register_search_routes - get "/opensearch.xml", Routes::Search, :opensearch - get "/results", Routes::Search, :results - get "/search", Routes::Search, :search - get "/hashtag/:hashtag", Routes::Search, :hashtag - end - - # ------------------- - # Media proxy routes - # ------------------- - - def register_api_manifest_routes - get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id - - get "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :get_dash_video_playback - get "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :get_dash_video_playback_greedy - - options "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :options_dash_video_playback - options "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :options_dash_video_playback - - get "/api/manifest/hls_playlist/*", Routes::API::Manifest, :get_hls_playlist - get "/api/manifest/hls_variant/*", Routes::API::Manifest, :get_hls_variant - end - - def register_video_playback_routes - get "/videoplayback", Routes::VideoPlayback, :get_video_playback - get "/videoplayback/*", Routes::VideoPlayback, :get_video_playback_greedy - - options "/videoplayback", Routes::VideoPlayback, :options_video_playback - options "/videoplayback/*", Routes::VideoPlayback, :options_video_playback - - get "/latest_version", Routes::VideoPlayback, :latest_version - end - - def register_image_routes - get "/ggpht/*", Routes::Images, :ggpht - options "/sb/:authority/:id/:storyboard/:index", Routes::Images, :options_storyboard - get "/sb/:authority/:id/:storyboard/:index", Routes::Images, :get_storyboard - get "/s_p/:id/:name", Routes::Images, :s_p_image - get "/yts/img/:name", Routes::Images, :yts_image - get "/vi/:id/:name", Routes::Images, :thumbnails - end - - # ------------------- - # API routes - # ------------------- - - def register_api_v1_routes - {% begin %} - {{namespace = Routes::API::V1}} - - # Videos - get "/api/v1/videos/:id", {{namespace}}::Videos, :videos - get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards - get "/api/v1/captions/:id", {{namespace}}::Videos, :captions - get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations - get "/api/v1/comments/:id", {{namespace}}::Videos, :comments - get "/api/v1/clips/:id", {{namespace}}::Videos, :clips - get "/api/v1/transcripts/:id", {{namespace}}::Videos, :transcripts - - # Feeds - get "/api/v1/trending", {{namespace}}::Feeds, :trending - get "/api/v1/popular", {{namespace}}::Feeds, :popular - - # Channels - get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home - get "/api/v1/channels/:ucid/latest", {{namespace}}::Channels, :latest - get "/api/v1/channels/:ucid/videos", {{namespace}}::Channels, :videos - get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts - get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams - get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts - get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases - get "/api/v1/channels/:ucid/courses", {{namespace}}::Channels, :courses - get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists - get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community - get "/api/v1/channels/:ucid/posts", {{namespace}}::Channels, :community - get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels - get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search - - # Posts - get "/api/v1/post/:id", {{namespace}}::Channels, :post - get "/api/v1/post/:id/comments", {{namespace}}::Channels, :post_comments - - # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community - get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect - get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect - - # Search - get "/api/v1/search", {{namespace}}::Search, :search - get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions - get "/api/v1/hashtag/:hashtag", {{namespace}}::Search, :hashtag - - - # Authenticated - - get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences - post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences - - get "/api/v1/auth/export/invidious", {{namespace}}::Authenticated, :export_invidious - post "/api/v1/auth/import/invidious", {{namespace}}::Authenticated, :import_invidious - - get "/api/v1/auth/history", {{namespace}}::Authenticated, :get_history - post "/api/v1/auth/history/:id", {{namespace}}::Authenticated, :mark_watched - delete "/api/v1/auth/history/:id", {{namespace}}::Authenticated, :mark_unwatched - delete "/api/v1/auth/history", {{namespace}}::Authenticated, :clear_history - - get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed - - get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions - post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel - delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel - - get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists - post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist - patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute - delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist - post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist - delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist - - get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens - post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token - post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token - - if CONFIG.enable_user_notifications - get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - end - - # Misc - get "/api/v1/stats", {{namespace}}::Misc, :stats - get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist - get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist - get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes - get "/api/v1/resolveurl", {{namespace}}::Misc, :resolve_url - {% end %} - end +end + +macro define_user_routes + # User login/out + 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 + Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update + Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme + Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control + Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control + + # User account management + Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password + Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password + Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete + Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete + Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history + Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history + Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token + Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token + Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager + Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax +end + +macro define_v1_api_routes + {{namespace = Invidious::Routes::API::V1}} + # Videos + Invidious::Routing.get "/api/v1/videos/:id", {{namespace}}::Videos, :videos + Invidious::Routing.get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards + Invidious::Routing.get "/api/v1/captions/:id", {{namespace}}::Videos, :captions + Invidious::Routing.get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations + Invidious::Routing.get "/api/v1/comments/:id", {{namespace}}::Videos, :comments + + # Feeds + Invidious::Routing.get "/api/v1/trending", {{namespace}}::Feeds, :trending + Invidious::Routing.get "/api/v1/popular", {{namespace}}::Feeds, :popular + + # Channels + Invidious::Routing.get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home + {% for route in {"videos", "latest", "playlists", "community", "search"} %} + Invidious::Routing.get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} + Invidious::Routing.get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} + {% end %} + + # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community + Invidious::Routing.get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect + Invidious::Routing.get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect + + + # Search + Invidious::Routing.get "/api/v1/search", {{namespace}}::Search, :search + Invidious::Routing.get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions + + # Authenticated + + # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr + # + # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + + Invidious::Routing.get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences + Invidious::Routing.post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences + + Invidious::Routing.get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed + + Invidious::Routing.get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions + Invidious::Routing.post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel + Invidious::Routing.delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel + + + Invidious::Routing.get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists + Invidious::Routing.post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist + Invidious::Routing.patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute + Invidious::Routing.delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist + + + Invidious::Routing.post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist + Invidious::Routing.delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist + + Invidious::Routing.get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens + 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 + Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist + Invidious::Routing.get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes +end + +macro define_api_manifest_routes + Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::API::Manifest, :get_dash_video_id + + Invidious::Routing.get "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :get_dash_video_playback + Invidious::Routing.get "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :get_dash_video_playback_greedy + + Invidious::Routing.options "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :options_dash_video_playback + Invidious::Routing.options "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :options_dash_video_playback + + Invidious::Routing.get "/api/manifest/hls_playlist/*", Invidious::Routes::API::Manifest, :get_hls_playlist + Invidious::Routing.get "/api/manifest/hls_variant/*", Invidious::Routes::API::Manifest, :get_hls_variant +end + +macro define_video_playback_routes + Invidious::Routing.get "/videoplayback", Invidious::Routes::VideoPlayback, :get_video_playback + Invidious::Routing.get "/videoplayback/*", Invidious::Routes::VideoPlayback, :get_video_playback_greedy + + Invidious::Routing.options "/videoplayback", Invidious::Routes::VideoPlayback, :options_video_playback + Invidious::Routing.options "/videoplayback/*", Invidious::Routes::VideoPlayback, :options_video_playback + + Invidious::Routing.get "/latest_version", Invidious::Routes::VideoPlayback, :latest_version end diff --git a/src/invidious/search.cr b/src/invidious/search.cr new file mode 100644 index 00000000..ae106bf6 --- /dev/null +++ b/src/invidious/search.cr @@ -0,0 +1,254 @@ +class ChannelSearchException < InfoException + getter channel : String + + def initialize(@channel) + end +end + +def channel_search(query, page, channel) : Array(SearchItem) + response = YT_POOL.client &.get("/channel/#{channel}") + + if response.status_code == 404 + response = YT_POOL.client &.get("/user/#{channel}") + response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404 + initial_data = extract_initial_data(response.body) + ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) + raise ChannelSearchException.new(channel) if !ucid + else + ucid = channel + end + + continuation = produce_channel_search_continuation(ucid, query, page) + response_json = YoutubeAPI.browse(continuation) + + continuation_items = response_json["onResponseReceivedActions"]? + .try &.[0]["appendContinuationItemsAction"]["continuationItems"] + + return [] of SearchItem if !continuation_items + + items = [] of SearchItem + continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each do |item| + extract_item(item["itemSectionRenderer"]["contents"].as_a[0]).try { |t| items << t } + end + + return items +end + +def search(query, search_params = produce_search_params(content_type: "all"), region = nil) : Array(SearchItem) + return [] of SearchItem if query.empty? + + client_config = YoutubeAPI::ClientConfig.new(region: region) + initial_data = YoutubeAPI.search(query, search_params, client_config: client_config) + + return extract_items(initial_data) +end + +def produce_search_params(page = 1, sort : String = "relevance", date : String = "", content_type : String = "", + duration : String = "", features : Array(String) = [] of String) + object = { + "1:varint" => 0_i64, + "2:embedded" => {} of String => Int64, + "9:varint" => ((page - 1) * 20).to_i64, + } + + case sort + when "relevance" + object["1:varint"] = 0_i64 + when "rating" + object["1:varint"] = 1_i64 + when "upload_date", "date" + object["1:varint"] = 2_i64 + when "view_count", "views" + object["1:varint"] = 3_i64 + else + raise "No sort #{sort}" + end + + case date + when "hour" + object["2:embedded"].as(Hash)["1:varint"] = 1_i64 + when "today" + object["2:embedded"].as(Hash)["1:varint"] = 2_i64 + when "week" + object["2:embedded"].as(Hash)["1:varint"] = 3_i64 + when "month" + object["2:embedded"].as(Hash)["1:varint"] = 4_i64 + when "year" + object["2:embedded"].as(Hash)["1:varint"] = 5_i64 + else nil # Ignore + end + + case content_type + when "video" + object["2:embedded"].as(Hash)["2:varint"] = 1_i64 + when "channel" + object["2:embedded"].as(Hash)["2:varint"] = 2_i64 + when "playlist" + object["2:embedded"].as(Hash)["2:varint"] = 3_i64 + when "movie" + object["2:embedded"].as(Hash)["2:varint"] = 4_i64 + when "show" + object["2:embedded"].as(Hash)["2:varint"] = 5_i64 + when "all" + # + else + object["2:embedded"].as(Hash)["2:varint"] = 1_i64 + end + + case duration + when "short" + object["2:embedded"].as(Hash)["3:varint"] = 1_i64 + when "long" + object["2:embedded"].as(Hash)["3:varint"] = 2_i64 + else nil # Ignore + end + + features.each do |feature| + case feature + when "hd" + object["2:embedded"].as(Hash)["4:varint"] = 1_i64 + when "subtitles" + object["2:embedded"].as(Hash)["5:varint"] = 1_i64 + when "creative_commons", "cc" + object["2:embedded"].as(Hash)["6:varint"] = 1_i64 + when "3d" + object["2:embedded"].as(Hash)["7:varint"] = 1_i64 + when "live", "livestream" + object["2:embedded"].as(Hash)["8:varint"] = 1_i64 + when "purchased" + object["2:embedded"].as(Hash)["9:varint"] = 1_i64 + when "4k" + object["2:embedded"].as(Hash)["14:varint"] = 1_i64 + when "360" + object["2:embedded"].as(Hash)["15:varint"] = 1_i64 + when "location" + object["2:embedded"].as(Hash)["23:varint"] = 1_i64 + when "hdr" + object["2:embedded"].as(Hash)["25:varint"] = 1_i64 + else nil # Ignore + end + end + + if object["2:embedded"].as(Hash).empty? + object.delete("2:embedded") + end + + params = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return params +end + +def produce_channel_search_continuation(ucid, query, page) + if page <= 1 + idx = 0_i64 + else + idx = 30_i64 * (page - 1) + end + + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:base64" => { + "2:string" => "search", + "6:varint" => 1_i64, + "7:varint" => 1_i64, + "12:varint" => 1_i64, + "15:base64" => { + "3:varint" => idx, + }, + "23:varint" => 0_i64, + }, + "11:string" => query, + "35:string" => "browse-feed#{ucid}search", + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end + +def process_search_query(query, page, user, region) + if user + user = user.as(Invidious::User) + view_name = "subscriptions_#{sha256(user.email)}" + end + + channel = nil + content_type = "all" + date = "" + duration = "" + features = [] of String + sort = "relevance" + subscriptions = nil + + operators = query.split(" ").select(&.match(/\w+:[\w,]+/)) + operators.each do |operator| + key, value = operator.downcase.split(":") + + case key + when "channel", "user" + channel = operator.split(":")[-1] + when "content_type", "type" + content_type = value + when "date" + date = value + when "duration" + duration = value + when "feature", "features" + features = value.split(",") + when "sort" + sort = value + when "subscriptions" + subscriptions = value == "true" + else + operators.delete(operator) + end + end + + search_query = (query.split(" ") - operators).join(" ") + + if channel + items = channel_search(search_query, page, channel) + elsif subscriptions + if view_name + items = PG_DB.query_all("SELECT id,title,published,updated,ucid,author,length_seconds FROM ( + SELECT *, + to_tsvector(#{view_name}.title) || + to_tsvector(#{view_name}.author) + as document + FROM #{view_name} + ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", search_query, (page - 1) * 20, as: ChannelVideo) + else + items = [] of ChannelVideo + end + else + search_params = produce_search_params(page: page, sort: sort, date: date, content_type: content_type, + duration: duration, features: features) + + items = search(search_query, search_params, region) + end + + # Light processing to flatten search results out of Categories. + # They should ideally be supported in the future. + items_without_category = [] of SearchItem | ChannelVideo + items.each do |i| + if i.is_a? Category + i.contents.each do |nest_i| + if !nest_i.is_a? Video + items_without_category << nest_i + end + end + else + items_without_category << i + end + end + + {search_query, items_without_category, operators} +end diff --git a/src/invidious/search/ctoken.cr b/src/invidious/search/ctoken.cr deleted file mode 100644 index 161065e0..00000000 --- a/src/invidious/search/ctoken.cr +++ /dev/null @@ -1,32 +0,0 @@ -def produce_channel_search_continuation(ucid, query, page) - if page <= 1 - idx = 0_i64 - else - idx = 30_i64 * (page - 1) - end - - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "search", - "6:varint" => 1_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "15:base64" => { - "3:varint" => idx, - }, - "23:varint" => 0_i64, - }, - "11:string" => query, - "35:string" => "browse-feed#{ucid}search", - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr deleted file mode 100644 index bc2715cf..00000000 --- a/src/invidious/search/filters.cr +++ /dev/null @@ -1,376 +0,0 @@ -require "protodec/utils" -require "http/params" - -module Invidious::Search - struct Filters - # Values correspond to { "2:embedded": { "1:varint": }} - # except for "None" which is only used by us (= nothing selected) - enum Date - None = 0 - Hour = 1 - Today = 2 - Week = 3 - Month = 4 - Year = 5 - end - - # Values correspond to { "2:embedded": { "2:varint": }} - # except for "All" which is only used by us (= nothing selected) - enum Type - All = 0 - Video = 1 - Channel = 2 - Playlist = 3 - Movie = 4 - - # Has it been removed? - # (Not available on youtube's UI) - Show = 5 - end - - # Values correspond to { "2:embedded": { "3:varint": }} - # except for "None" which is only used by us (= nothing selected) - enum Duration - None = 0 - Short = 1 # "Under 4 minutes" - Long = 2 # "Over 20 minutes" - Medium = 3 # "4 - 20 minutes" - end - - # Note: flag enums automatically generate - # "none" and "all" members - @[Flags] - enum Features - Live - FourK # "4K" - HD - Subtitles # "Subtitles/CC" - CCommons # "Creative Commons" - ThreeSixty # "360°" - VR180 - ThreeD # "3D" - HDR - Location - Purchased - end - - # Values correspond to { "1:varint": } - enum Sort - Relevance = 0 - Rating = 1 - Date = 2 - Views = 3 - end - - # Parameters are sorted as on Youtube - property date : Date - property type : Type - property duration : Duration - property features : Features - property sort : Sort - - def initialize( - *, # All parameters must be named - @date : Date = Date::None, - @type : Type = Type::All, - @duration : Duration = Duration::None, - @features : Features = Features::None, - @sort : Sort = Sort::Relevance, - ) - end - - def default? : Bool - return @date.none? && @type.all? && @duration.none? && \ - @features.none? && @sort.relevance? - end - - # ------------------- - # Invidious params - # ------------------- - - def self.parse_features(raw : Array(String)) : Features - # Initialize return variable - features = Features.new(0) - - raw.each do |ft| - case ft.downcase - when "live", "livestream" - features = features | Features::Live - when "4k" then features = features | Features::FourK - when "hd" then features = features | Features::HD - when "subtitles" then features = features | Features::Subtitles - when "creative_commons", "commons", "cc" - features = features | Features::CCommons - when "360" then features = features | Features::ThreeSixty - when "vr180" then features = features | Features::VR180 - when "3d" then features = features | Features::ThreeD - when "hdr" then features = features | Features::HDR - when "location" then features = features | Features::Location - when "purchased" then features = features | Features::Purchased - end - end - - return features - end - - def self.format_features(features : Features) : String - # Directly return an empty string if there are no features - return "" if features.none? - - # Initialize return variable - str = [] of String - - str << "live" if features.live? - str << "4k" if features.four_k? - str << "hd" if features.hd? - str << "subtitles" if features.subtitles? - str << "commons" if features.c_commons? - str << "360" if features.three_sixty? - str << "vr180" if features.vr180? - str << "3d" if features.three_d? - str << "hdr" if features.hdr? - str << "location" if features.location? - str << "purchased" if features.purchased? - - return str.join(',') - end - - def self.from_legacy_filters(str : String) : {Filters, String, String, Bool} - # Split search query on spaces - members = str.split(' ') - - # Output variables - channel = "" - filters = Filters.new - subscriptions = false - - # Array to hold the non-filter members - query = [] of String - - # Parse! - members.each do |substr| - # Separator operators - operators = substr.split(':') - - case operators[0] - when "user", "channel" - next if operators.size != 2 - channel = operators[1] - # - when "type", "content_type" - next if operators.size != 2 - type = Type.parse?(operators[1]) - filters.type = type if !type.nil? - # - when "date" - next if operators.size != 2 - date = Date.parse?(operators[1]) - filters.date = date if !date.nil? - # - when "duration" - next if operators.size != 2 - duration = Duration.parse?(operators[1]) - filters.duration = duration if !duration.nil? - # - when "feature", "features" - next if operators.size != 2 - features = parse_features(operators[1].split(',')) - filters.features = features if !features.nil? - # - when "sort" - next if operators.size != 2 - sort = Sort.parse?(operators[1]) - filters.sort = sort if !sort.nil? - # - when "subscriptions" - next if operators.size != 2 - subscriptions = {"true", "on", "yes", "1"}.any?(&.== operators[1]) - # - else - query << substr - end - end - - # Re-assemble query (without filters) - cleaned_query = query.join(' ') - - return {filters, channel, cleaned_query, subscriptions} - end - - def self.from_iv_params(params : HTTP::Params) : Filters - # Temporary variables - filters = Filters.new - - if type = params["type"]? - filters.type = Type.parse?(type) || Type::All - params.delete("type") - end - - if date = params["date"]? - filters.date = Date.parse?(date) || Date::None - params.delete("date") - end - - if duration = params["duration"]? - filters.duration = Duration.parse?(duration) || Duration::None - params.delete("duration") - end - - features = params.fetch_all("features") - if !features.empty? - # Un-array input so it can be treated as a comma-separated list - features = features[0].split(',') if features.size == 1 - - filters.features = parse_features(features) || Features::None - params.delete_all("features") - end - - if sort = params["sort"]? - filters.sort = Sort.parse?(sort) || Sort::Relevance - params.delete("sort") - end - - return filters - end - - def to_iv_params : HTTP::Params - # Temporary variables - raw_params = {} of String => Array(String) - - raw_params["date"] = [@date.to_s.underscore] if !@date.none? - raw_params["type"] = [@type.to_s.underscore] if !@type.all? - raw_params["sort"] = [@sort.to_s.underscore] if !@sort.relevance? - - if !@duration.none? - raw_params["duration"] = [@duration.to_s.underscore] - end - - if !@features.none? - raw_params["features"] = [Filters.format_features(@features)] - end - - return HTTP::Params.new(raw_params) - end - - # ------------------- - # Youtube params - # ------------------- - - # Produce the youtube search parameters for the - # innertube API (base64-encoded protobuf object). - def to_yt_params(page : Int = 1) : String - # Initialize the embedded protobuf object - embedded = {} of String => Int64 - - # Add these field only if associated parameter is selected - embedded["1:varint"] = @date.to_i64 if !@date.none? - embedded["2:varint"] = @type.to_i64 if !@type.all? - embedded["3:varint"] = @duration.to_i64 if !@duration.none? - - if !@features.none? - # All features have a value of "1" when enabled, and - # the field is omitted when the feature is no selected. - embedded["4:varint"] = 1_i64 if @features.includes?(Features::HD) - embedded["5:varint"] = 1_i64 if @features.includes?(Features::Subtitles) - embedded["6:varint"] = 1_i64 if @features.includes?(Features::CCommons) - embedded["7:varint"] = 1_i64 if @features.includes?(Features::ThreeD) - embedded["8:varint"] = 1_i64 if @features.includes?(Features::Live) - embedded["9:varint"] = 1_i64 if @features.includes?(Features::Purchased) - embedded["14:varint"] = 1_i64 if @features.includes?(Features::FourK) - embedded["15:varint"] = 1_i64 if @features.includes?(Features::ThreeSixty) - embedded["23:varint"] = 1_i64 if @features.includes?(Features::Location) - embedded["25:varint"] = 1_i64 if @features.includes?(Features::HDR) - embedded["26:varint"] = 1_i64 if @features.includes?(Features::VR180) - end - - # Initialize an empty protobuf object - object = {} of String => (Int64 | String | Hash(String, Int64)) - - # As usual, everything can be omitted if it has no value - object["2:embedded"] = embedded if !embedded.empty? - - # Default sort is "relevance", so when this option is selected, - # the associated field can be omitted. - if !@sort.relevance? - object["1:varint"] = @sort.to_i64 - end - - # Add page number (if provided) - if page > 1 - object["9:varint"] = ((page - 1) * 20).to_i64 - end - - # Prevent censoring of self harm topics - # See https://github.com/iv-org/invidious/issues/4398 - object["30:varint"] = 1.to_i64 - - return object - .try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - end - - # Function to parse the `sp` URL parameter from Youtube - # search page. It's a base64-encoded protobuf object. - def self.from_yt_params(params : HTTP::Params) : Filters - # Initialize output variable - filters = Filters.new - - # Get parameter, and check emptyness - search_params = params["sp"]? - - if search_params.nil? || search_params.empty? - return filters - end - - # Decode protobuf object - object = search_params - .try { |i| URI.decode_www_form(i) } - .try { |i| Base64.decode(i) } - .try { |i| IO::Memory.new(i) } - .try { |i| Protodec::Any.parse(i) } - - # Parse items from embedded object - if embedded = object["2:0:embedded"]? - # All the following fields (date, type, duration) are optional. - if date = embedded["1:0:varint"]? - filters.date = Date.from_value?(date.as_i) || Date::None - end - - if type = embedded["2:0:varint"]? - filters.type = Type.from_value?(type.as_i) || Type::All - end - - if duration = embedded["3:0:varint"]? - filters.duration = Duration.from_value?(duration.as_i) || Duration::None - end - - # All features should have a value of "1" when enabled, and - # the field should be omitted when the feature is no selected. - features = 0 - features += (embedded["4:0:varint"]?.try &.as_i == 1_i64) ? Features::HD.value : 0 - features += (embedded["5:0:varint"]?.try &.as_i == 1_i64) ? Features::Subtitles.value : 0 - features += (embedded["6:0:varint"]?.try &.as_i == 1_i64) ? Features::CCommons.value : 0 - features += (embedded["7:0:varint"]?.try &.as_i == 1_i64) ? Features::ThreeD.value : 0 - features += (embedded["8:0:varint"]?.try &.as_i == 1_i64) ? Features::Live.value : 0 - features += (embedded["9:0:varint"]?.try &.as_i == 1_i64) ? Features::Purchased.value : 0 - features += (embedded["14:0:varint"]?.try &.as_i == 1_i64) ? Features::FourK.value : 0 - features += (embedded["15:0:varint"]?.try &.as_i == 1_i64) ? Features::ThreeSixty.value : 0 - features += (embedded["23:0:varint"]?.try &.as_i == 1_i64) ? Features::Location.value : 0 - features += (embedded["25:0:varint"]?.try &.as_i == 1_i64) ? Features::HDR.value : 0 - features += (embedded["26:0:varint"]?.try &.as_i == 1_i64) ? Features::VR180.value : 0 - - filters.features = Features.from_value?(features) || Features::None - end - - if sort = object["1:0:varint"]? - filters.sort = Sort.from_value?(sort.as_i) || Sort::Relevance - end - - # Remove URL parameter and return result - params.delete("sp") - return filters - end - end -end diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr deleted file mode 100644 index 25edb936..00000000 --- a/src/invidious/search/processors.cr +++ /dev/null @@ -1,56 +0,0 @@ -module Invidious::Search - module Processors - extend self - - # Regular search (`/search` endpoint) - def regular(query : Query) : Array(SearchItem) - search_params = query.filters.to_yt_params(page: query.page) - - client_config = YoutubeAPI::ClientConfig.new(region: query.region) - initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config) - - items, _ = extract_items(initial_data) - return items.reject!(Category) - end - - # Search a youtube channel - # TODO: clean code, and rely more on YoutubeAPI - def channel(query : Query) : Array(SearchItem) - response = YT_POOL.client &.get("/channel/#{query.channel}") - - if response.status_code == 404 - response = YT_POOL.client &.get("/user/#{query.channel}") - response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404 - initial_data = extract_initial_data(response.body) - ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) - raise ChannelSearchException.new(query.channel) if !ucid - else - ucid = query.channel - end - - continuation = produce_channel_search_continuation(ucid, query.text, query.page) - response_json = YoutubeAPI.browse(continuation) - - items, _ = extract_items(response_json, "", ucid) - return items.reject!(Category) - end - - # Search inside of user subscriptions - def subscriptions(query : Query, user : Invidious::User) : Array(ChannelVideo) - view_name = "subscriptions_#{sha256(user.email)}" - - return PG_DB.query_all(" - SELECT id,title,published,updated,ucid,author,length_seconds - FROM ( - SELECT *, - to_tsvector(#{view_name}.title) || - to_tsvector(#{view_name}.author) - as document - FROM #{view_name} - ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", - query.text, (query.page - 1) * 20, - as: ChannelVideo - ) - end - end -end diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr deleted file mode 100644 index 94a92e23..00000000 --- a/src/invidious/search/query.cr +++ /dev/null @@ -1,168 +0,0 @@ -module Invidious::Search - class Query - enum Type - # Types related to YouTube - Regular # Youtube search page - Channel # Youtube channel search box - - # Types specific to Invidious - Subscriptions # Search user subscriptions - Playlist # "Add playlist item" search - end - - getter type : Type = Type::Regular - - @raw_query : String - @query : String = "" - - property filters : Filters = Filters.new - property page : Int32 - property region : String? - property channel : String = "" - - # Flag that indicates if the smart search features have been disabled. - @inhibit_ssf : Bool = false - - # Return true if @raw_query is either `nil` or empty - private def empty_raw_query? - return @raw_query.empty? - end - - # Same as `empty_raw_query?`, but named for external use - def empty? - return self.empty_raw_query? - end - - # Getter for the query string. - # It is named `text` to reduce confusion (`search_query.text` makes more - # sense than `search_query.query`) - def text - return @query - end - - # Initialize a new search query. - # Parameters are used to get the query string, the page number - # and the search filters (if any). Type tells this function - # where it is being called from (See `Type` above). - def initialize( - params : HTTP::Params, - @type : Type = Type::Regular, - @region : String? = nil, - ) - # Get the raw search query string (common to all search types). In - # Regular search mode, also look for the `search_query` URL parameter - _raw_query = params["q"]? - _raw_query ||= params["search_query"]? if @type.regular? - _raw_query ||= "" - - # Remove surrounding whitespaces. Mostly useful for copy/pasted URLs. - @raw_query = _raw_query.strip - - # Check for smart features (ex: URL search) inhibitor (backslash). - # If inhibitor is present, remove it. - if @raw_query.starts_with?('\\') - @inhibit_ssf = true - @raw_query = @raw_query[1..] - end - - # Get the page number (also common to all search types) - @page = params["page"]?.try &.to_i? || 1 - - # Stop here if raw query is empty - # NOTE: maybe raise in the future? - return if self.empty_raw_query? - - # Specific handling - case @type - when .channel? - # In "channel search" mode, filters are ignored, but we still parse - # the query prevent transmission of legacy filters to youtube. - # - _, _, @query, _ = Filters.from_legacy_filters(@raw_query) - # - when .playlist? - # In "add playlist item" mode, filters are parsed from the query - # string itself (legacy), and the channel is ignored. - # - @filters, _, @query, _ = Filters.from_legacy_filters(@raw_query) - # - when .subscriptions?, .regular? - if params["sp"]? - # Parse the `sp` URL parameter (youtube compatibility) - @filters = Filters.from_yt_params(params) - @query = @raw_query || "" - else - # Parse invidious URL parameters (sort, date, etc...) - @filters = Filters.from_iv_params(params) - @channel = params["channel"]? || "" - - if @filters.default? && @raw_query.index(/\w:\w/) - # Parse legacy filters from query - @filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query) - else - @query = @raw_query || "" - end - - if !@channel.empty? - # Switch to channel search mode (filters will be ignored) - @type = Type::Channel - elsif subs - # Switch to subscriptions search mode - @type = Type::Subscriptions - end - end - end - end - - # Run the search query using the corresponding search processor. - # Returns either the results or an empty array of `SearchItem`. - def process(user : Invidious::User? = nil) : Array(SearchItem) | Array(ChannelVideo) - items = [] of SearchItem - - # Don't bother going further if search query is empty - return items if self.empty_raw_query? - - case @type - when .regular?, .playlist? - items = Processors.regular(self) - # - when .channel? - items = Processors.channel(self) - # - when .subscriptions? - if user - items = Processors.subscriptions(self, user.as(Invidious::User)) - end - end - - return items - end - - # Return the HTTP::Params corresponding to this Query (invidious format) - def to_http_params : HTTP::Params - params = @filters.to_iv_params - - params["q"] = @query - params["channel"] = @channel if !@channel.empty? - - return params - end - - # Checks if the query is a standalone URL - def url? : Bool - # If the smart features have been inhibited, don't go further. - return false if @inhibit_ssf - - # Only supported in regular search mode - return false if !@type.regular? - - # If filters are present, that's a regular search - return false if !@filters.default? - - # Simple heuristics: domain name - return @raw_query.starts_with?( - /(https?:\/\/)?(www\.)?(m\.)?youtu(\.be|be\.com)\// - ) - end - end -end diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index d14cde5d..1f957081 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -4,12 +4,11 @@ def fetch_trending(trending_type, region, locale) plid = nil - case trending_type.try &.downcase - when "music" + if trending_type == "Music" params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D" - when "gaming" + elsif trending_type == "Gaming" params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D" - when "movies" + elsif trending_type == "Movies" params = "4gIKGgh0cmFpbGVycw%3D%3D" else # Default params = "" @@ -17,26 +16,7 @@ def fetch_trending(trending_type, region, locale) client_config = YoutubeAPI::ClientConfig.new(region: region) initial_data = YoutubeAPI.browse("FEtrending", params: params, client_config: client_config) + trending = extract_videos(initial_data) - items, _ = extract_items(initial_data) - - extracted = [] of SearchItem - - deduplicate = items.size > 1 - - items.each do |itm| - if itm.is_a?(Category) - # Ignore the smaller categories, as they generally contain a sponsored - # channel, which brings a lot of noise on the trending page. - # See: https://github.com/iv-org/invidious/issues/2989 - next if (itm.contents.size < 24 && deduplicate) - - extracted.concat itm.contents.select(SearchItem) - else - extracted << itm - end - end - - # Deduplicate items before returning results - return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid + return {trending, plid} end diff --git a/src/invidious/user/captcha.cr b/src/invidious/user/captcha.cr index b175c3b9..8a0f67e5 100644 --- a/src/invidious/user/captcha.cr +++ b/src/invidious/user/captcha.cr @@ -4,6 +4,8 @@ struct Invidious::User module Captcha extend self + private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com") + def generate_image(key) second = Random::Secure.rand(12) second_angle = second * 30 @@ -58,5 +60,19 @@ struct Invidious::User tokens: {generate_response(answer, {":login"}, key, use_nonce: true)}, } end + + def generate_text(key) + response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body) + response = JSON.parse(response) + + tokens = response["a"].as_a.map do |answer| + generate_response(answer.as_s, {":login"}, key, use_nonce: true) + end + + return { + question: response["q"].as_s, + tokens: tokens, + } + end end end diff --git a/src/invidious/user/cookies.cr b/src/invidious/user/cookies.cr index 654efc15..99df1b07 100644 --- a/src/invidious/user/cookies.cr +++ b/src/invidious/user/cookies.cr @@ -17,8 +17,7 @@ struct Invidious::User value: sid, expires: Time.utc + 2.years, secure: SECURE, - http_only: true, - samesite: HTTP::Cookie::SameSite::Lax + http_only: true ) end @@ -31,8 +30,7 @@ struct Invidious::User value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, secure: SECURE, - http_only: false, - samesite: HTTP::Cookie::SameSite::Lax + http_only: true ) end end diff --git a/src/invidious/user/exports.cr b/src/invidious/user/exports.cr deleted file mode 100644 index b52503c9..00000000 --- a/src/invidious/user/exports.cr +++ /dev/null @@ -1,35 +0,0 @@ -struct Invidious::User - module Export - extend self - - def to_invidious(user : User) - playlists = Invidious::Database::Playlists.select_like_iv(user.email) - - return JSON.build do |json| - json.object do - json.field "subscriptions", user.subscriptions - json.field "watch_history", user.watched - json.field "preferences", user.preferences - json.field "playlists" do - json.array do - playlists.each do |playlist| - json.object do - json.field "title", playlist.title - json.field "description", html_to_content(playlist.description_html) - json.field "privacy", playlist.privacy.to_s - json.field "videos" do - json.array do - Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: CONFIG.playlist_length_limit).each do |video_id| - json.string video_id - end - end - end - end - end - end - end - end - end - end - end # module -end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 007eb666..f8b9e4e4 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -6,7 +6,7 @@ struct Invidious::User # Parse a youtube CSV subscription file def parse_subscription_export_csv(csv_content : String) - rows = CSV.new(csv_content.strip('\n'), headers: true) + rows = CSV.new(csv_content, headers: true) subscriptions = Array(String).new # Counter to limit the amount of imports. @@ -30,60 +30,6 @@ struct Invidious::User return subscriptions end - def parse_playlist_export_csv(user : User, raw_input : String) - # Split the input into head and body content - raw_head, raw_body = raw_input.strip('\n').split("\n\n", limit: 2, remove_empty: true) - - # Create the playlist from the head content - csv_head = CSV.new(raw_head.strip('\n'), headers: true) - csv_head.next - title = csv_head[4] - description = csv_head[5] - visibility = csv_head[6] - - if visibility.compare("Public", case_insensitive: true) == 0 - privacy = PlaylistPrivacy::Public - else - privacy = PlaylistPrivacy::Private - end - - playlist = create_playlist(title, privacy, user) - Invidious::Database::Playlists.update_description(playlist.id, description) - - # Add each video to the playlist from the body content - csv_body = CSV.new(raw_body.strip('\n'), headers: true) - csv_body.each do |row| - video_id = row[0] - if playlist - next if !video_id - next if video_id == "Video Id" - - begin - video = get_video(video_id) - rescue ex - next - end - - playlist_video = PlaylistVideo.new({ - title: video.title, - id: video.id, - author: video.author, - ucid: video.ucid, - length_seconds: video.length_seconds, - published: video.published, - plid: playlist.id, - live_now: video.live_now, - index: Random::Secure.rand(0_i64..Int64::MAX), - }) - - Invidious::Database::PlaylistVideos.insert(playlist_video) - Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) - end - end - - return playlist - end - # ------------------- # Invidious # ------------------- @@ -102,7 +48,7 @@ struct Invidious::User if data["watch_history"]? user.watched += data["watch_history"].as_a.map(&.as_s) - user.watched.reverse!.uniq!.reverse! + user.watched.uniq! Invidious::Database::Users.update_watch_history(user) end @@ -115,7 +61,7 @@ struct Invidious::User playlists.each do |item| title = item["title"]?.try &.as_s?.try &.delete("<>") description = item["description"]?.try &.as_s?.try &.delete("\r") - privacy = item["privacy"]?.try &.as_s?.try { |raw_pl_privacy_state| PlaylistPrivacy.parse? raw_pl_privacy_state } + privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy } next if !title next if !description @@ -124,16 +70,14 @@ struct Invidious::User playlist = create_playlist(title, privacy, user) Invidious::Database::Playlists.update_description(playlist.id, description) - item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| - if idx > CONFIG.playlist_length_limit - raise InfoException.new("Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") - end + videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| + raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 video_id = video_id.try &.as_s? next if !video_id begin - video = get_video(video_id, false) + video = get_video(video_id) rescue ex next end @@ -161,7 +105,7 @@ struct Invidious::User # Youtube # ------------------- - private def opml?(mimetype : String, extension : String) + private def is_opml?(mimetype : String, extension : String) opml_mimetypes = [ "application/xml", "text/xml", @@ -179,10 +123,10 @@ struct Invidious::User def from_youtube(user : User, body : String, filename : String, type : String) : Bool extension = filename.split(".").last - if opml?(type, extension) + if is_opml?(type, extension) subscriptions = XML.parse(body) user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| - channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0] + channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] end elsif extension == "json" || type == "application/json" subscriptions = JSON.parse(body) @@ -203,41 +147,6 @@ struct Invidious::User return true end - def from_youtube_pl(user : User, body : String, filename : String, type : String) : Bool - extension = filename.split(".").last - - if extension == "csv" || type == "text/csv" - playlist = parse_playlist_export_csv(user, body) - if playlist - return true - else - return false - end - else - return false - end - end - - def from_youtube_wh(user : User, body : String, filename : String, type : String) : Bool - extension = filename.split(".").last - - if extension == "json" || type == "application/json" - data = JSON.parse(body) - watched = data.as_a.compact_map do |item| - next unless url = item["titleUrl"]? - next unless match = url.as_s.match(/\?v=(?[a-zA-Z0-9_-]+)$/) - match["video_id"] - end - watched.reverse! # YouTube have newest first - user.watched += watched - user.watched.uniq! - Invidious::Database::Users.update_watch_history(user) - return true - else - return false - end - end - # ------------------- # Freetube # ------------------- @@ -248,12 +157,8 @@ struct Invidious::User subs = matches.map(&.["channel_id"]) if subs.empty? - profiles = body.split('\n', remove_empty: true) - profiles.each do |profile| - if data = JSON.parse(profile)["subscriptions"]? - subs += data.as_a.map(&.["id"].as_s) - end - end + data = JSON.parse(body)["subscriptions"] + subs = data.as_a.map(&.["id"].as_s) end user.subscriptions += subs @@ -290,39 +195,42 @@ struct Invidious::User end def from_newpipe(user : User, body : String) : Bool - Compress::Zip::File.open(IO::Memory.new(body), true) do |file| - entry = file.entries.find { |file_entry| file_entry.filename == "newpipe.db" } - return false if entry.nil? - entry.open do |file_io| - # Ensure max size of 4MB - io_sized = IO::Sized.new(file_io, 0x400000) + io = IO::Memory.new(body) - begin - temp = File.tempfile(".db") do |tempfile| - begin - File.write(tempfile.path, io_sized.gets_to_end) - rescue - return false - end + Compress::Zip::File.open(io) do |file| + file.entries.each do |entry| + entry.open do |file_io| + # Ensure max size of 4MB + io_sized = IO::Sized.new(file_io, 0x400000) - DB.open("sqlite3://" + tempfile.path) do |db| - user.watched += db.query_all("SELECT url FROM streams", as: String) - .map(&.lchop("https://www.youtube.com/watch?v=")) + next if entry.filename != "newpipe.db" - user.watched.uniq! - Invidious::Database::Users.update_watch_history(user) + tempfile = File.tempfile(".db") - user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String) - .map(&.lchop("https://www.youtube.com/channel/")) - - user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions) - - Invidious::Database::Users.update_subscriptions(user) - end + begin + File.write(tempfile.path, io_sized.gets_to_end) + rescue + return false end - ensure - temp.delete if !temp.nil? + + db = DB.open("sqlite3://" + tempfile.path) + + user.watched += db.query_all("SELECT url FROM streams", as: String) + .map(&.lchop("https://www.youtube.com/watch?v=")) + + user.watched.uniq! + Invidious::Database::Users.update_watch_history(user) + + user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String) + .map(&.lchop("https://www.youtube.com/channel/")) + + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + + db.close + tempfile.delete end end end diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index 0a8525f3..6490aa7b 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -4,7 +4,6 @@ struct Preferences property annotations : Bool = CONFIG.default_user_preferences.annotations property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed - property preload : Bool = CONFIG.default_user_preferences.preload property autoplay : Bool = CONFIG.default_user_preferences.autoplay property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect @@ -25,6 +24,7 @@ struct Preferences 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 notifications : Bool = CONFIG.default_user_preferences.notifications property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode property show_nick : Bool = CONFIG.default_user_preferences.show_nick diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 65566d20..b763596b 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -3,6 +3,75 @@ require "crypto/bcrypt/password" # Materialized views may not be defined using bound parameters (`$1` as used elsewhere) MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" } +def get_user(sid, headers, refresh = true) + if email = Invidious::Database::SessionIDs.select_email(sid) + user = Invidious::Database::Users.select!(email: email) + + if refresh && Time.utc - user.updated > 1.minute + user, sid = fetch_user(sid, headers) + + Invidious::Database::Users.insert(user, update_on_conflict: true) + Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) + + begin + view_name = "subscriptions_#{sha256(user.email)}" + PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") + rescue ex + end + end + else + user, sid = fetch_user(sid, headers) + + Invidious::Database::Users.insert(user, update_on_conflict: true) + Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) + + begin + view_name = "subscriptions_#{sha256(user.email)}" + PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") + rescue ex + end + end + + return user, sid +end + +def fetch_user(sid, headers) + feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) + feed = XML.parse_html(feed.body) + + channels = feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).compact_map do |channel| + if {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"] + nil + else + channel["href"].lstrip("/channel/") + end + end + + channels = get_batch_channels(channels) + + email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) + if email + email = email.content.strip + else + email = "" + end + + token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) + + user = Invidious::User.new({ + updated: Time.utc, + notifications: [] of String, + subscriptions: channels, + email: email, + preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple), + password: nil, + token: token, + watched: [] of String, + feed_needs_update: true, + }) + return user, sid +end + def create_user(sid, email, password) password = Crypto::Bcrypt::Password.create(password, cost: 10) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) @@ -22,6 +91,38 @@ def create_user(sid, email, password) return user, sid end +def subscribe_ajax(channel_id, action, env_headers) + headers = HTTP::Headers.new + headers["Cookie"] = env_headers["Cookie"] + + html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) + + cookies = HTTP::Cookies.from_client_headers(headers) + html.cookies.each do |cookie| + if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name + if cookies[cookie.name]? + cookies[cookie.name] = cookie + else + cookies << cookie + end + end + end + headers = cookies.add_request_headers(headers) + + if match = html.body.match(/'XSRF_TOKEN': "(?[^"]+)"/) + session_token = match["session_token"] + + headers["content-type"] = "application/x-www-form-urlencoded" + + post_req = { + session_token: session_token, + } + post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}" + + YT_POOL.client &.post(post_url, headers, form: post_req) + end +end + def get_subscription_feed(user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 348a0a66..81fce5b8 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1,22 +1,280 @@ -enum VideoType - Video - Livestream - Scheduled +CAPTION_LANGUAGES = { + "", + "English", + "English (auto-generated)", + "English (United Kingdom)", + "English (United States)", + "Afrikaans", + "Albanian", + "Amharic", + "Arabic", + "Armenian", + "Azerbaijani", + "Bangla", + "Basque", + "Belarusian", + "Bosnian", + "Bulgarian", + "Burmese", + "Cantonese (Hong Kong)", + "Catalan", + "Cebuano", + "Chinese", + "Chinese (China)", + "Chinese (Hong Kong)", + "Chinese (Simplified)", + "Chinese (Taiwan)", + "Chinese (Traditional)", + "Corsican", + "Croatian", + "Czech", + "Danish", + "Dutch", + "Dutch (auto-generated)", + "Esperanto", + "Estonian", + "Filipino", + "Finnish", + "French", + "French (auto-generated)", + "Galician", + "Georgian", + "German", + "German (auto-generated)", + "Greek", + "Gujarati", + "Haitian Creole", + "Hausa", + "Hawaiian", + "Hebrew", + "Hindi", + "Hmong", + "Hungarian", + "Icelandic", + "Igbo", + "Indonesian", + "Indonesian (auto-generated)", + "Interlingue", + "Irish", + "Italian", + "Italian (auto-generated)", + "Japanese", + "Japanese (auto-generated)", + "Javanese", + "Kannada", + "Kazakh", + "Khmer", + "Korean", + "Korean (auto-generated)", + "Kurdish", + "Kyrgyz", + "Lao", + "Latin", + "Latvian", + "Lithuanian", + "Luxembourgish", + "Macedonian", + "Malagasy", + "Malay", + "Malayalam", + "Maltese", + "Maori", + "Marathi", + "Mongolian", + "Nepali", + "Norwegian Bokmål", + "Nyanja", + "Pashto", + "Persian", + "Polish", + "Portuguese", + "Portuguese (auto-generated)", + "Portuguese (Brazil)", + "Punjabi", + "Romanian", + "Russian", + "Russian (auto-generated)", + "Samoan", + "Scottish Gaelic", + "Serbian", + "Shona", + "Sindhi", + "Sinhala", + "Slovak", + "Slovenian", + "Somali", + "Southern Sotho", + "Spanish", + "Spanish (auto-generated)", + "Spanish (Latin America)", + "Spanish (Mexico)", + "Spanish (Spain)", + "Sundanese", + "Swahili", + "Swedish", + "Tajik", + "Tamil", + "Telugu", + "Thai", + "Turkish", + "Turkish (auto-generated)", + "Ukrainian", + "Urdu", + "Uzbek", + "Vietnamese", + "Vietnamese (auto-generated)", + "Welsh", + "Western Frisian", + "Xhosa", + "Yiddish", + "Yoruba", + "Zulu", +} + +REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"} + +# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 +VIDEO_FORMATS = { + "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, + "6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, + "13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"}, + "17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"}, + "18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"}, + "22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + + "36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"}, + "37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + + # 3D videos + "82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + + # Apple HTTP Live Streaming + "91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, + "96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, + "132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"}, + + # DASH mp4 video + "133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"}, + "134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"}, + "135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, + "136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"}, + "137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"}, + "138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559) + "160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"}, + "212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, + "264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"}, + "298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, + "299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, + "266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"}, + + # Dash mp4 audio + "139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"}, + "140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"}, + "141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"}, + "256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, + "258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, + "325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"}, + "328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"}, + + # Dash webm + "167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"}, + "242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"}, + "243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"}, + "244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"}, + "248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"}, + "271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"}, + # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug) + "272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, + "302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, + "315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + + # Dash webm audio + "171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128}, + "172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256}, + + # Dash webm audio with opus inside + "249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50}, + "250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70}, + "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, + + # av01 video only formats sometimes served with "unknown" codecs + "394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"}, + "395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"}, + "396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"}, + "397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"}, +} + +struct VideoPreferences + include JSON::Serializable + + property annotations : Bool + property autoplay : Bool + property comments : Array(String) + property continue : Bool + property continue_autoplay : Bool + property controls : Bool + property listen : Bool + property local : Bool + property preferred_captions : Array(String) + property player_style : String + property quality : String + property quality_dash : String + property raw : Bool + property region : String? + property related_videos : Bool + property speed : Float32 | Float64 + property video_end : Float64 | Int32 + property video_loop : Bool + property extend_desc : Bool + property video_start : Float64 | Int32 + property volume : Int32 + property vr_mode : Bool + property save_player_pos : Bool end struct Video include DB::Serializable - # Version of the JSON structure - # It prevents us from loading an incompatible version from cache - # (either newer or older, if instances with different versions run - # concurrently, e.g during a version upgrade rollout). - # - # NOTE: don't forget to bump this number if any change is made to - # the `params` structure in videos/parser.cr!!! - # - SCHEMA_VERSION = 3 - property id : String @[DB::Field(converter: Video::JSONConverter)] @@ -24,7 +282,13 @@ struct Video property updated : Time @[DB::Field(ignore: true)] - @captions = [] of Invidious::Videos::Captions::Metadata + property captions : Array(Caption)? + + @[DB::Field(ignore: true)] + property adaptive_fmts : Array(Hash(String, JSON::Any))? + + @[DB::Field(ignore: true)] + property fmt_stream : Array(Hash(String, JSON::Any))? @[DB::Field(ignore: true)] property description : String? @@ -35,49 +299,274 @@ struct Video end end - # Methods for API v1 JSON - def to_json(locale : String?, json : JSON::Builder) - Invidious::JSONify::APIv1.video(self, json, locale: locale) + json.object do + json.field "type", "video" + + json.field "title", self.title + json.field "videoId", self.id + + json.field "error", info["reason"] if info["reason"]? + + json.field "videoThumbnails" do + generate_thumbnails(json, self.id) + end + json.field "storyboards" do + generate_storyboards(json, self.id, self.storyboards) + end + + json.field "description", self.description + json.field "descriptionHtml", self.description_html + json.field "published", self.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) + json.field "keywords", self.keywords + + json.field "viewCount", self.views + json.field "likeCount", self.likes + json.field "dislikeCount", self.dislikes + + json.field "paid", self.paid + json.field "premium", self.premium + json.field "isFamilyFriendly", self.is_family_friendly + json.field "allowedRegions", self.allowed_regions + json.field "genre", self.genre + json.field "genreUrl", self.genre_url + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "subCountText", self.sub_count_text + + json.field "lengthSeconds", self.length_seconds + json.field "allowRatings", self.allow_ratings + json.field "rating", self.average_rating + json.field "isListed", self.is_listed + json.field "liveNow", self.live_now + json.field "isUpcoming", self.is_upcoming + + if self.premiere_timestamp + json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix + end + + if hlsvp = self.hls_manifest_url + hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL) + json.field "hlsUrl", hlsvp + end + + json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{id}" + + json.field "adaptiveFormats" do + json.array do + self.adaptive_fmts.each do |fmt| + json.object do + json.field "index", "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}" + json.field "bitrate", fmt["bitrate"].as_i.to_s + json.field "init", "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}" + json.field "url", fmt["url"] + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] + json.field "clen", fmt["contentLength"] + json.field "lmt", fmt["lastModified"] + json.field "projectionType", fmt["projectionType"] + + fmt_info = itag_to_metadata?(fmt["itag"]) + if fmt_info + fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 + json.field "fps", fps + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + + if fmt_info["height"]? + json.field "resolution", "#{fmt_info["height"]}p" + + quality_label = "#{fmt_info["height"]}p" + if fps > 30 + quality_label += "60" + end + json.field "qualityLabel", quality_label + + if fmt_info["width"]? + json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" + end + end + end + end + end + end + end + + json.field "formatStreams" do + json.array do + self.fmt_stream.each do |fmt| + json.object do + json.field "url", fmt["url"] + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] + json.field "quality", fmt["quality"] + + fmt_info = itag_to_metadata?(fmt["itag"]) + if fmt_info + fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 + json.field "fps", fps + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + + if fmt_info["height"]? + json.field "resolution", "#{fmt_info["height"]}p" + + quality_label = "#{fmt_info["height"]}p" + if fps > 30 + quality_label += "60" + end + json.field "qualityLabel", quality_label + + if fmt_info["width"]? + json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" + end + end + end + end + end + end + end + + json.field "captions" do + json.array do + self.captions.each do |caption| + json.object do + json.field "label", caption.name + json.field "language_code", caption.language_code + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" + end + end + end + end + + json.field "recommendedVideos" do + json.array do + self.related_videos.each do |rv| + if rv["id"]? + json.object do + json.field "videoId", rv["id"] + json.field "title", rv["title"] + json.field "videoThumbnails" do + generate_thumbnails(json, rv["id"]) + end + + json.field "author", rv["author"] + json.field "authorUrl", "/channel/#{rv["ucid"]?}" + json.field "authorId", rv["ucid"]? + if rv["author_thumbnail"]? + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") + json.field "width", quality + json.field "height", quality + end + end + end + end + end + + json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i + json.field "viewCountText", rv["short_view_count"]? + json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 + end + end + end + end + end + end end # TODO: remove the locale and follow the crystal convention def to_json(locale : String?, _json : Nil) - JSON.build do |json| - Invidious::JSONify::APIv1.video(self, json, locale: locale) - end + JSON.build { |json| to_json(locale, json) } end def to_json(json : JSON::Builder | Nil = nil) to_json(nil, json) end - # Misc methods - - def video_type : VideoType - video_type = info["videoType"]?.try &.as_s || "video" - return VideoType.parse?(video_type) || VideoType::Video + def title + info["videoDetails"]["title"]?.try &.as_s || "" end - def schema_version : Int - return info["version"]?.try &.as_i || 1 + def ucid + info["videoDetails"]["channelId"]?.try &.as_s || "" + end + + def author + info["videoDetails"]["author"]?.try &.as_s || "" + end + + def length_seconds : Int32 + info.dig?("microformat", "playerMicroformatRenderer", "lengthSeconds").try &.as_s.to_i || + info["videoDetails"]["lengthSeconds"]?.try &.as_s.to_i || 0 + end + + def views : Int64 + info["videoDetails"]["viewCount"]?.try &.as_s.to_i64 || 0_i64 + end + + def likes : Int64 + info["likes"]?.try &.as_i64 || 0_i64 + end + + def dislikes : Int64 + info["dislikes"]?.try &.as_i64 || 0_i64 + end + + def average_rating : Float64 + # (likes / (likes + dislikes) * 4 + 1) + info["videoDetails"]["averageRating"]?.try { |t| t.as_f? || t.as_i64?.try &.to_f64 }.try &.round(4) || 0.0 end def published : Time - return info["published"]? + info + .dig?("microformat", "playerMicroformatRenderer", "publishDate") .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc end def published=(other : Time) - info["published"] = JSON::Any.new(other.to_s("%Y-%m-%d")) + info["microformat"].as_h["playerMicroformatRenderer"].as_h["publishDate"] = JSON::Any.new(other.to_s("%Y-%m-%d")) + end + + def allow_ratings + r = info["videoDetails"]["allowRatings"]?.try &.as_bool + r.nil? ? false : r end def live_now - return (self.video_type == VideoType::Livestream) + info["microformat"]?.try &.["playerMicroformatRenderer"]? + .try &.["liveBroadcastDetails"]?.try &.["isLiveNow"]?.try &.as_bool || false end - def post_live_dvr - return info["isPostLiveDvr"].as_bool + def is_listed + info["videoDetails"]["isCrawlable"]?.try &.as_bool || false + end + + def is_upcoming + info["videoDetails"]["isUpcoming"]?.try &.as_bool || false end def premiere_timestamp : Time? @@ -86,30 +575,68 @@ struct Video .try { |t| Time.parse_rfc3339(t.as_s) } end + def keywords + info["videoDetails"]["keywords"]?.try &.as_a.map &.as_s || [] of String + end + def related_videos info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String) end - # Methods for parsing streaming data - - def fmt_stream : Array(Hash(String, JSON::Any)) - if formats = info.dig?("streamingData", "formats") - return formats - .as_a.map(&.as_h) - .sort_by! { |f| f["width"]?.try &.as_i || 0 } - else - return [] of Hash(String, JSON::Any) - end + def allowed_regions + info + .dig("microformat", "playerMicroformatRenderer", "availableCountries") + .try &.as_a.map &.as_s || [] of String end - def adaptive_fmts : Array(Hash(String, JSON::Any)) - if formats = info.dig?("streamingData", "adaptiveFormats") - return formats - .as_a.map(&.as_h) - .sort_by! { |f| f["width"]?.try &.as_i || f["audioTrack"]?.try { |a| a["audioIsDefault"]?.try { |v| v.as_bool ? -1 : 0 } } || 0 } - else - return [] of Hash(String, JSON::Any) + def author_thumbnail : String + info["authorThumbnail"]?.try &.as_s || "" + end + + def sub_count_text : String + info["subCountText"]?.try &.as_s || "-" + end + + def fmt_stream + return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream + + fmt_stream = info["streamingData"]?.try &.["formats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) + fmt_stream.each do |fmt| + if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) } + s.each do |k, v| + fmt[k] = JSON::Any.new(v) + end + fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") + end + + fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") + fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? end + fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } + @fmt_stream = fmt_stream + return @fmt_stream.as(Array(Hash(String, JSON::Any))) + end + + def adaptive_fmts + return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts + fmt_stream = info["streamingData"]?.try &.["adaptiveFormats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) + fmt_stream.each do |fmt| + if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) } + s.each do |k, v| + fmt[k] = JSON::Any.new(v) + end + fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") + end + + fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") + fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? + end + # See https://github.com/TeamNewPipe/NewPipe/issues/2415 + # Some streams are segmented by URL `sq/` rather than index, for now we just filter them out + fmt_stream.reject! { |f| !f["indexRange"]? } + fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } + @adaptive_fmts = fmt_stream + return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) end def video_streams @@ -120,178 +647,429 @@ struct Video adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("audio") end - # Misc. methods - def storyboards - container = info.dig?("storyboards") || JSON::Any.new("{}") - return IV::Videos::Storyboard.from_yt_json(container, self.length_seconds) + storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec") + .try &.as_s.split("|") + + if !storyboards + if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s + return [{ + url: storyboard.split("#")[0], + width: 106, + height: 60, + count: -1, + interval: 5000, + storyboard_width: 3, + storyboard_height: 3, + storyboard_count: -1, + }] + end + end + + items = [] of NamedTuple( + url: String, + width: Int32, + height: Int32, + count: Int32, + interval: Int32, + storyboard_width: Int32, + storyboard_height: Int32, + storyboard_count: Int32) + + return items if !storyboards + + url = URI.parse(storyboards.shift) + params = HTTP::Params.parse(url.query || "") + + storyboards.each_with_index do |sb, i| + width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#") + params["sigh"] = sigh + url.query = params.to_s + + width = width.to_i + height = height.to_i + count = count.to_i + interval = interval.to_i + storyboard_width = storyboard_width.to_i + storyboard_height = storyboard_height.to_i + storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i + + items << { + url: url.to_s.sub("$L", i).sub("$N", "M$M"), + width: width, + height: height, + count: count, + interval: interval, + storyboard_width: storyboard_width, + storyboard_height: storyboard_height, + storyboard_count: storyboard_count, + } + end + + items end def paid - return (self.reason || "").includes? "requires payment" + reason = info.dig?("playabilityStatus", "reason").try &.as_s || "" + return reason.includes? "requires payment" end def premium keywords.includes? "YouTube Red" end - def captions : Array(Invidious::Videos::Captions::Metadata) - if @captions.empty? && @info.has_key?("captions") - @captions = Invidious::Videos::Captions::Metadata.from_yt_json(info["captions"]) - end + def captions : Array(Caption) + return @captions.as(Array(Caption)) if @captions + captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption| + name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] + language_code = caption["languageCode"].to_s + base_url = caption["baseUrl"].to_s - return @captions + caption = Caption.new(name.to_s, language_code, base_url) + caption.name = caption.name.split(" - ")[0] + caption + end + captions ||= [] of Caption + @captions = captions + return @captions.as(Array(Caption)) + end + + def description + description = info + .dig?("microformat", "playerMicroformatRenderer", "description", "simpleText") + .try &.as_s || "" + end + + # TODO + def description=(value : String) + @description = value + end + + def description_html + info["descriptionHtml"]?.try &.as_s || "

    " + end + + def description_html=(value : String) + info["descriptionHtml"] = JSON::Any.new(value) + end + + def short_description + info["shortDescription"]?.try &.as_s? || "" end def hls_manifest_url : String? info.dig?("streamingData", "hlsManifestUrl").try &.as_s end - def dash_manifest_url : String? - raw_dash_url = info.dig?("streamingData", "dashManifestUrl").try &.as_s - return nil if raw_dash_url.nil? + def dash_manifest_url + info.dig?("streamingData", "dashManifestUrl").try &.as_s + end - # Use manifest v5 parameter to reduce file size - # See https://github.com/iv-org/invidious/issues/4186 - dash_url = URI.parse(raw_dash_url) - dash_query = dash_url.query || "" - - if dash_query.empty? - dash_url.path = "#{dash_url.path}/mpd_version/5" - else - dash_url.query = "#{dash_query}&mpd_version=5" - end - - return dash_url.to_s + def genre : String + info["genre"]?.try &.as_s || "" end def genre_url : String? - info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil + info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil end - def vr? : Bool? - return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type + def license : String? + info["license"]?.try &.as_s + end + + def is_family_friendly : Bool + info.dig?("microformat", "playerMicroformatRenderer", "isFamilySafe").try &.as_bool || false + end + + def is_vr : Bool? + projection_type = info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s + return {"EQUIRECTANGULAR", "MESH"}.includes? projection_type end def projection_type : String? return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s end + def wilson_score : Float64 + ci_lower_bound(likes, likes + dislikes).round(4) + end + + def engagement : Float64 + (((likes + dislikes) / views) * 100).round(4) + end + def reason : String? info["reason"]?.try &.as_s end +end - def music : Array(VideoMusic) - info["music"].as_a.map { |music_json| - VideoMusic.new( - music_json["song"].as_s, - music_json["album"].as_s, - music_json["artist"].as_s, - music_json["license"].as_s - ) - } +struct Caption + property name + property language_code + property base_url + + getter name : String + getter language_code : String + getter base_url : String + + setter name + + def initialize(@name, @language_code, @base_url) + end +end + +class VideoRedirect < Exception + property video_id : String + + def initialize(@video_id) + end +end + +# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer". +# The former is preferred as it has more videos in it. The second has +# the same 11 first entries as the compact rendered. +# +# TODO: "compactRadioRenderer" (Mix) and +def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? + return nil if !related["videoId"]? + + # The compact renderer has video length in seconds, where the end + # screen rendered has a full text version ("42:40") + length = related["lengthInSeconds"]?.try &.as_i.to_s + length ||= related.dig?("lengthText", "simpleText").try do |box| + decode_length_seconds(box.as_s).to_s end - # Macros defining getters/setters for various types of data + # Both have "short", so the "long" option shouldn't be required + channel_info = (related["shortBylineText"]? || related["longBylineText"]?) + .try &.dig?("runs", 0) - private macro getset_string(name) - # Return {{name.stringify}} from `info` - def {{name.id.underscore}} : String - return info[{{name.stringify}}]?.try &.as_s || "" - end + author = channel_info.try &.dig?("text") + ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } - # Update {{name.stringify}} into `info` - def {{name.id.underscore}}=(value : String) - info[{{name.stringify}}] = JSON::Any.new(value) - end + # "4,088,033 views", only available on compact renderer + # and when video is not a livestream + view_count = related.dig?("viewCountText", "simpleText") + .try &.as_s.gsub(/\D/, "") - {% if flag?(:debug_macros) %} {{debug}} {% end %} + short_view_count = related.try do |r| + HelperExtractors.get_short_view_count(r).to_s end - private macro getset_string_array(name) - # Return {{name.stringify}} from `info` - def {{name.id.underscore}} : Array(String) - return info[{{name.stringify}}]?.try &.as_a.map &.as_s || [] of String - end + LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") - # Update {{name.stringify}} into `info` - def {{name.id.underscore}}=(value : Array(String)) - info[{{name.stringify}}] = JSON::Any.new(value) - end + # TODO: when refactoring video types, make a struct for related videos + # or reuse an existing type, if that fits. + return { + "id" => related["videoId"], + "title" => related["title"]["simpleText"], + "author" => author || JSON::Any.new(""), + "ucid" => JSON::Any.new(ucid || ""), + "length_seconds" => JSON::Any.new(length || "0"), + "view_count" => JSON::Any.new(view_count || "0"), + "short_view_count" => JSON::Any.new(short_view_count || "0"), + } +end - {% if flag?(:debug_macros) %} {{debug}} {% end %} +def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) + params = {} of String => JSON::Any + + client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) + if context_screen == "embed" + client_config.client_type = YoutubeAPI::ClientType::WebScreenEmbed end - {% for op, type in {i32: Int32, i64: Int64} %} - private macro getset_{{op}}(name) - def \{{name.id.underscore}} : {{type}} - return info[\{{name.stringify}}]?.try &.as_i64.to_{{op}} || 0_{{op}} + player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + + if player_response.dig?("playabilityStatus", "status").try &.as_s != "OK" + subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") + reason = subreason.try &.[]?("simpleText").try &.as_s + reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") + reason ||= player_response.dig("playabilityStatus", "reason").as_s + params["reason"] = JSON::Any.new(reason) + return params + end + + params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil) + + # Don't fetch the next endpoint if the video is unavailable. + if !params["reason"]? + next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) + player_response = player_response.merge(next_response) + end + + # Fetch the video streams using an Android client in order to get the decrypted URLs and + # maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs: + # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 + if !params["reason"]? + if context_screen == "embed" + client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed + else + client_config.client_type = YoutubeAPI::ClientType::Android + end + android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + + # Sometime, the video is available from the web client, but not on Android, so check + # that here, and fallback to the streaming data from the web client if needed. + # See: https://github.com/iv-org/invidious/issues/2549 + if android_player["playabilityStatus"]["status"] == "OK" + params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("") + else + params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("") + end + end + + {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| + params[f] = player_response[f] if player_response[f]? + end + + # Top level elements + + main_results = player_response.dig?("contents", "twoColumnWatchNextResults") + + raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results + + primary_results = main_results.dig?("results", "results", "contents") + + raise BrokenTubeException.new("results") if !primary_results + + video_primary_renderer = primary_results + .as_a.find(&.["videoPrimaryInfoRenderer"]?) + .try &.["videoPrimaryInfoRenderer"] + + video_secondary_renderer = primary_results + .as_a.find(&.["videoSecondaryInfoRenderer"]?) + .try &.["videoSecondaryInfoRenderer"] + + raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer + raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer + + # Related videos + + LOGGER.debug("extract_video_info: parsing related videos...") + + related = [] of JSON::Any + + # Parse "compactVideoRenderer" items (under secondary results) + secondary_results = main_results + .dig?("secondaryResults", "secondaryResults", "results") + secondary_results.try &.as_a.each do |element| + if item = element["compactVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + + # If nothing was found previously, fall back to end screen renderer + if related.empty? + # Container for "endScreenVideoRenderer" items + player_overlays = player_response.dig?( + "playerOverlays", "playerOverlayRenderer", + "endScreen", "watchNextEndScreenRenderer", "results" + ) + + player_overlays.try &.as_a.each do |element| + if item = element["endScreenVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video end - - def \{{name.id.underscore}}=(value : Int) - info[\{{name.stringify}}] = JSON::Any.new(value.to_i64) - end - - \{% if flag?(:debug_macros) %} \{{debug}} \{% end %} end - {% end %} - - private macro getset_bool(name) - # Return {{name.stringify}} from `info` - def {{name.id.underscore}} : Bool - return info[{{name.stringify}}]?.try &.as_bool || false - end - - # Update {{name.stringify}} into `info` - def {{name.id.underscore}}=(value : Bool) - info[{{name.stringify}}] = JSON::Any.new(value) - end - - {% if flag?(:debug_macros) %} {{debug}} {% end %} end - # Macro to generate ? and = accessor methods for attributes in `info` - private macro predicate_bool(method_name, name) - # Return {{name.stringify}} from `info` - def {{method_name.id.underscore}}? : Bool - return info[{{name.stringify}}]?.try &.as_bool || false + params["relatedVideos"] = JSON::Any.new(related) + + # Likes/dislikes + + toplevel_buttons = video_primary_renderer + .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") + + if toplevel_buttons + likes_button = toplevel_buttons.as_a + .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "LIKE") + .try &.["toggleButtonRenderer"] + + if likes_button + likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?) + .try &.dig?("accessibility", "accessibilityData", "label") + likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt + + LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") + LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes end - # Update {{name.stringify}} into `info` - def {{method_name.id.underscore}}=(value : Bool) - info[{{name.stringify}}] = JSON::Any.new(value) - end + dislikes_button = toplevel_buttons.as_a + .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "DISLIKE") + .try &.["toggleButtonRenderer"] - {% if flag?(:debug_macros) %} {{debug}} {% end %} + if dislikes_button + dislikes_txt = (dislikes_button["defaultText"]? || dislikes_button["toggledText"]?) + .try &.dig?("accessibility", "accessibilityData", "label") + dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64? if dislikes_txt + + LOGGER.trace("extract_video_info: Found \"dislikes\" button. Button text is \"#{dislikes_txt}\"") + LOGGER.debug("extract_video_info: Dislikes count is #{dislikes}") if dislikes + end end - # Method definitions, using the macros above + if likes && likes != 0_i64 && (!dislikes || dislikes == 0_i64) + if rating = player_response.dig?("videoDetails", "averageRating").try { |x| x.as_i64? || x.as_f? } + dislikes = (likes * ((5 - rating)/(rating - 1))).round.to_i64 + LOGGER.debug("extract_video_info: Dislikes count (using fallback method) is #{dislikes}") + end + end - getset_string author - getset_string authorThumbnail - getset_string description - getset_string descriptionHtml - getset_string genre - getset_string genreUcid - getset_string license - getset_string shortDescription - getset_string subCountText - getset_string title - getset_string ucid + params["likes"] = JSON::Any.new(likes || 0_i64) + params["dislikes"] = JSON::Any.new(dislikes || 0_i64) - getset_string_array allowedRegions - getset_string_array keywords + # Description - getset_i32 lengthSeconds - getset_i64 likes - getset_i64 views + description_html = video_secondary_renderer.try &.dig?("description", "runs") + .try &.as_a.try { |t| content_to_comment_html(t) } - # TODO: Make predicate_bool the default as to adhere to Crystal conventions - getset_bool allowRatings - getset_bool authorVerified - getset_bool isFamilyFriendly - getset_bool isListed - predicate_bool upcoming, isUpcoming + params["descriptionHtml"] = JSON::Any.new(description_html || "

    ") + + # Video metadata + + metadata = video_secondary_renderer + .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") + .try &.as_a + + params["genre"] = params["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["category"]? || JSON::Any.new("") + params["genreUrl"] = JSON::Any.new(nil) + + metadata.try &.each do |row| + title = row["metadataRowRenderer"]?.try &.["title"]?.try &.["simpleText"]?.try &.as_s + contents = row.dig?("metadataRowRenderer", "contents", 0) + + if title.try &.== "Category" + contents = contents.try &.dig?("runs", 0) + + params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") + params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]? + .try &.["browseId"]?.try &.as_s || "") + elsif title.try &.== "License" + contents = contents.try &.["runs"]? + .try &.as_a[0]? + + params["license"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") + elsif title.try &.== "Licensed to YouTube by" + params["license"] = JSON::Any.new(contents.try &.["simpleText"]?.try &.as_s || "") + end + end + + # Author infos + + author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") + author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url") + + params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") + + params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? + .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }.try &.as_s.split(" ", 2)[0] || "-") + + # Return data + + return params end def get_video(id, refresh = true, region = nil, force_refresh = false) @@ -301,8 +1079,7 @@ def get_video(id, refresh = true, region = nil, force_refresh = false) if (refresh && (Time.utc - video.updated > 10.minutes) || (video.premiere_timestamp.try &.< Time.utc)) || - force_refresh || - video.schema_version != Video::SCHEMA_VERSION # cache control + force_refresh begin video = fetch_video(id, region) Invidious::Database::Videos.update(video) @@ -317,25 +1094,36 @@ def get_video(id, refresh = true, region = nil, force_refresh = false) end return video -rescue DB::Error - # Avoid common `DB::PoolRetryAttemptsExceeded` error and friends - # Note: All DB errors inherit from `DB::Error` - return fetch_video(id, region) end def fetch_video(id, region) info = extract_video_info(video_id: id) - if reason = info["reason"]? - if reason == "Video unavailable" - raise NotFoundException.new(reason.as_s || "") - elsif !reason.as_s.starts_with? "Premieres" - # dont error when it's a premiere. - # we already parsed most of the data and display the premiere date - raise InfoException.new(reason.as_s || "") + allowed_regions = info + .dig?("microformat", "playerMicroformatRenderer", "availableCountries") + .try &.as_a.map &.as_s || [] of String + + # Check for region-blocks + if info["reason"]?.try &.as_s.includes?("your country") + bypass_regions = PROXY_LIST.keys & allowed_regions + if !bypass_regions.empty? + region = bypass_regions[rand(bypass_regions.size)] + region_info = extract_video_info(video_id: id, proxy_region: region) + region_info["region"] = JSON::Any.new(region) if region + info = region_info if !region_info["reason"]? end end + # Try to fetch video info using an embedded client + if info["reason"]? + embed_info = extract_video_info(video_id: id, context_screen: "embed") + info = embed_info if !embed_info["reason"]? + end + + if reason = info["reason"]? + raise InfoException.new(reason.as_s || "") + end + video = Video.new({ id: id, info: info, @@ -345,6 +1133,10 @@ def fetch_video(id, region) return video end +def itag_to_metadata?(itag : JSON::Any) + return VIDEO_FORMATS[itag.to_s]? +end + def process_continuation(query, plid, id) continuation = nil if plid @@ -359,6 +1151,135 @@ def process_continuation(query, plid, id) continuation end +def process_video_params(query, preferences) + annotations = query["iv_load_policy"]?.try &.to_i? + autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } + comments = query["comments"]?.try &.split(",").map(&.downcase) + continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe } + continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } + listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe } + local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe } + player_style = query["player_style"]? + preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase) + quality = query["quality"]? + quality_dash = query["quality_dash"]? + region = query["region"]? + related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe } + speed = query["speed"]?.try &.rchop("x").to_f? + video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe } + extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } + volume = query["volume"]?.try &.to_i? + vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } + save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe } + + if preferences + # region ||= preferences.region + annotations ||= preferences.annotations.to_unsafe + autoplay ||= preferences.autoplay.to_unsafe + comments ||= preferences.comments + continue ||= preferences.continue.to_unsafe + 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 + quality_dash ||= preferences.quality_dash + related_videos ||= preferences.related_videos.to_unsafe + speed ||= preferences.speed + video_loop ||= preferences.video_loop.to_unsafe + extend_desc ||= preferences.extend_desc.to_unsafe + volume ||= preferences.volume + vr_mode ||= preferences.vr_mode.to_unsafe + save_player_pos ||= preferences.save_player_pos.to_unsafe + end + + annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe + autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe + comments ||= CONFIG.default_user_preferences.comments + continue ||= CONFIG.default_user_preferences.continue.to_unsafe + 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 + quality_dash ||= CONFIG.default_user_preferences.quality_dash + related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe + speed ||= CONFIG.default_user_preferences.speed + video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe + extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe + volume ||= CONFIG.default_user_preferences.volume + vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe + save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe + + annotations = annotations == 1 + autoplay = autoplay == 1 + continue = continue == 1 + continue_autoplay = continue_autoplay == 1 + listen = listen == 1 + local = local == 1 + related_videos = related_videos == 1 + video_loop = video_loop == 1 + extend_desc = extend_desc == 1 + vr_mode = vr_mode == 1 + save_player_pos = save_player_pos == 1 + + if CONFIG.disabled?("dash") && quality == "dash" + quality = "high" + end + + if CONFIG.disabled?("local") && local + local = false + end + + if start = query["t"]? || query["time_continue"]? || query["start"]? + video_start = decode_time(start) + end + video_start ||= 0 + + if query["end"]? + video_end = decode_time(query["end"]) + end + video_end ||= -1 + + raw = query["raw"]?.try &.to_i? + raw ||= 0 + raw = raw == 1 + + controls = query["controls"]?.try &.to_i? + controls ||= 1 + controls = controls >= 1 + + params = VideoPreferences.new({ + annotations: annotations, + autoplay: autoplay, + comments: comments, + continue: continue, + continue_autoplay: continue_autoplay, + controls: controls, + listen: listen, + local: local, + player_style: player_style, + preferred_captions: preferred_captions, + quality: quality, + quality_dash: quality_dash, + raw: raw, + region: region, + related_videos: related_videos, + speed: speed, + video_end: video_end, + video_loop: video_loop, + extend_desc: extend_desc, + video_start: video_start, + volume: volume, + vr_mode: vr_mode, + save_player_pos: save_player_pos, + }) + + return params +end + def build_thumbnails(id) return { {host: HOST_URL, height: 720, width: 1280, name: "maxres", url: "maxres"}, @@ -372,3 +1293,34 @@ def build_thumbnails(id) {host: HOST_URL, height: 90, width: 120, name: "end", url: "3"}, } end + +def generate_thumbnails(json, id) + json.array do + build_thumbnails(id).each do |thumbnail| + json.object do + json.field "quality", thumbnail[:name] + json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" + json.field "width", thumbnail[:width] + json.field "height", thumbnail[:height] + end + end + end +end + +def generate_storyboards(json, id, storyboards) + json.array do + storyboards.each do |storyboard| + json.object do + json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}" + json.field "templateUrl", storyboard[:url] + json.field "width", storyboard[:width] + json.field "height", storyboard[:height] + json.field "count", storyboard[:count] + json.field "interval", storyboard[:interval] + json.field "storyboardWidth", storyboard[:storyboard_width] + json.field "storyboardHeight", storyboard[:storyboard_height] + json.field "storyboardCount", storyboard[:storyboard_count] + end + end + end +end diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr deleted file mode 100644 index c811cfe1..00000000 --- a/src/invidious/videos/caption.cr +++ /dev/null @@ -1,224 +0,0 @@ -require "json" - -module Invidious::Videos - module Captions - struct Metadata - property name : String - property language_code : String - property base_url : String - - property auto_generated : Bool - - def initialize(@name, @language_code, @base_url, @auto_generated) - end - - # Parse the JSON structure from Youtube - def self.from_yt_json(container : JSON::Any) : Array(Captions::Metadata) - caption_tracks = container - .dig?("playerCaptionsTracklistRenderer", "captionTracks") - .try &.as_a - - captions_list = [] of Captions::Metadata - return captions_list if caption_tracks.nil? - - caption_tracks.each do |caption| - name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] - name = name.to_s.split(" - ")[0] - - language_code = caption["languageCode"].to_s - base_url = caption["baseUrl"].to_s - - auto_generated = (caption["kind"]? == "asr") - - captions_list << Captions::Metadata.new(name, language_code, base_url, auto_generated) - end - - return captions_list - end - - def timedtext_to_vtt(timedtext : String, tlang = nil) : String - # In the future, we could just directly work with the url. This is more of a POC - cues = [] of XML::Node - tree = XML.parse(timedtext) - tree = tree.children.first - - tree.children.each do |item| - if item.name == "body" - item.children.each do |cue| - if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") - cues << cue - end - end - break - end - end - - settings_field = { - "Kind" => "captions", - "Language" => "#{tlang || @language_code}", - } - - result = WebVTT.build(settings_field) do |vtt| - cues.each_with_index do |node, i| - start_time = node["t"].to_f.milliseconds - - duration = node["d"]?.try &.to_f.milliseconds - - duration ||= start_time - - if cues.size > i + 1 - end_time = cues[i + 1]["t"].to_f.milliseconds - else - end_time = start_time + duration - end - - text = String.build do |io| - node.children.each do |s| - io << s.content - end - end - - vtt.cue(start_time, end_time, text) - end - end - - return result - end - end - - # List of all caption languages available on Youtube. - LANGUAGES = { - "", - "English", - "English (auto-generated)", - "English (United Kingdom)", - "English (United States)", - "Afrikaans", - "Albanian", - "Amharic", - "Arabic", - "Armenian", - "Azerbaijani", - "Bangla", - "Basque", - "Belarusian", - "Bosnian", - "Bulgarian", - "Burmese", - "Cantonese (Hong Kong)", - "Catalan", - "Cebuano", - "Chinese", - "Chinese (China)", - "Chinese (Hong Kong)", - "Chinese (Simplified)", - "Chinese (Taiwan)", - "Chinese (Traditional)", - "Corsican", - "Croatian", - "Czech", - "Danish", - "Dutch", - "Dutch (auto-generated)", - "Esperanto", - "Estonian", - "Filipino", - "Filipino (auto-generated)", - "Finnish", - "French", - "French (auto-generated)", - "Galician", - "Georgian", - "German", - "German (auto-generated)", - "Greek", - "Gujarati", - "Haitian Creole", - "Hausa", - "Hawaiian", - "Hebrew", - "Hindi", - "Hmong", - "Hungarian", - "Icelandic", - "Igbo", - "Indonesian", - "Indonesian (auto-generated)", - "Interlingue", - "Irish", - "Italian", - "Italian (auto-generated)", - "Japanese", - "Japanese (auto-generated)", - "Javanese", - "Kannada", - "Kazakh", - "Khmer", - "Korean", - "Korean (auto-generated)", - "Kurdish", - "Kyrgyz", - "Lao", - "Latin", - "Latvian", - "Lithuanian", - "Luxembourgish", - "Macedonian", - "Malagasy", - "Malay", - "Malayalam", - "Maltese", - "Maori", - "Marathi", - "Mongolian", - "Nepali", - "Norwegian Bokmål", - "Nyanja", - "Pashto", - "Persian", - "Polish", - "Portuguese", - "Portuguese (auto-generated)", - "Portuguese (Brazil)", - "Punjabi", - "Romanian", - "Russian", - "Russian (auto-generated)", - "Samoan", - "Scottish Gaelic", - "Serbian", - "Shona", - "Sindhi", - "Sinhala", - "Slovak", - "Slovenian", - "Somali", - "Southern Sotho", - "Spanish", - "Spanish (auto-generated)", - "Spanish (Latin America)", - "Spanish (Mexico)", - "Spanish (Spain)", - "Sundanese", - "Swahili", - "Swedish", - "Tajik", - "Tamil", - "Telugu", - "Thai", - "Turkish", - "Turkish (auto-generated)", - "Ukrainian", - "Urdu", - "Uzbek", - "Vietnamese", - "Vietnamese (auto-generated)", - "Welsh", - "Western Frisian", - "Xhosa", - "Yiddish", - "Yoruba", - "Zulu", - } - end -end diff --git a/src/invidious/videos/clip.cr b/src/invidious/videos/clip.cr deleted file mode 100644 index 29c57182..00000000 --- a/src/invidious/videos/clip.cr +++ /dev/null @@ -1,22 +0,0 @@ -require "json" - -# returns start_time, end_time and clip_title -def parse_clip_parameters(params) : {Float64?, Float64?, String?} - decoded_protobuf = params.try { |i| URI.decode_www_form(i) } - .try { |i| Base64.decode(i) } - .try { |i| IO::Memory.new(i) } - .try { |i| Protodec::Any.parse(i) } - - start_time = decoded_protobuf - .try(&.["50:0:embedded"]["2:1:varint"].as_i64) - .try { |i| i/1000 } - - end_time = decoded_protobuf - .try(&.["50:0:embedded"]["3:2:varint"].as_i64) - .try { |i| i/1000 } - - clip_title = decoded_protobuf - .try(&.["50:0:embedded"]["4:3:string"].as_s) - - return start_time, end_time, clip_title -end diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr deleted file mode 100644 index 1371bebb..00000000 --- a/src/invidious/videos/description.cr +++ /dev/null @@ -1,82 +0,0 @@ -require "json" -require "uri" - -private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int - copied = 0 - while copied < count - cp = iter.next - break if cp.is_a?(Iterator::Stop) - - if cp == 0x26 # Ampersand (&) - str << "&" - elsif cp == 0x27 # Single quote (') - str << "'" - elsif cp == 0x22 # Double quote (") - str << """ - elsif cp == 0x3C # Less-than (<) - str << "<" - elsif cp == 0x3E # Greater than (>) - str << ">" - else - str << cp.chr - end - - # A codepoint from the SMP counts twice - copied += 1 if cp > 0xFFFF - copied += 1 - end - - return copied -end - -def parse_description(desc, video_id : String) : String? - return "" if desc.nil? - - content = desc["content"].as_s - return "" if content.empty? - - commands = desc["commandRuns"]?.try &.as_a - if commands.nil? - # Slightly faster than HTML.escape, as we're only doing one pass on - # the string instead of five for the standard library - return String.build do |str| - copy_string(str, content.each_codepoint, content.size) - end - end - - # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints - # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are - # automatically decoded by the JSON parser. It means that we need to count - # copied byte in a special manner, preventing the use of regular string copy. - iter = content.each_codepoint - - index = 0 - - return String.build do |str| - commands.each do |command| - cmd_start = command["startIndex"].as_i - cmd_length = command["length"].as_i - - # Copy the text chunk between this command and the previous if needed. - length = cmd_start - index - index += copy_string(str, iter, length) - - # We need to copy the command's text using the iterator - # and the special function defined above. - cmd_content = String.build(cmd_length) do |str2| - copy_string(str2, iter, cmd_length) - end - - link = cmd_content - if on_tap = command.dig?("onTap", "innertubeCommand") - link = parse_link_endpoint(on_tap, cmd_content, video_id) - end - str << link - index += cmd_length - end - - # Copy the end of the string (past the last command). - remaining_length = content.size - index - copy_string(str, iter, remaining_length) if remaining_length > 0 - end -end diff --git a/src/invidious/videos/formats.cr b/src/invidious/videos/formats.cr deleted file mode 100644 index e98e7257..00000000 --- a/src/invidious/videos/formats.cr +++ /dev/null @@ -1,116 +0,0 @@ -module Invidious::Videos::Formats - def self.itag_to_metadata?(itag : JSON::Any) - return FORMATS[itag.to_s]? - end - - # See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 - private FORMATS = { - "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, - "6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, - "13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"}, - "17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"}, - "18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"}, - "22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - - "36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"}, - "37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - - # 3D videos - "82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - - # Apple HTTP Live Streaming - "91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, - "96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, - "132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"}, - - # DASH mp4 video - "133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"}, - "134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"}, - "135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, - "136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"}, - "137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"}, - "138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559) - "160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"}, - "212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, - "264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"}, - "298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, - "299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, - "266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"}, - - # Dash mp4 audio - "139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"}, - "140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"}, - "141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"}, - "256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, - "258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, - "325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"}, - "328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"}, - - # Dash webm - "167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"}, - "242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"}, - "243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"}, - "244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"}, - "248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"}, - "271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"}, - # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug) - "272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, - "302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, - "315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - - # Dash webm audio - "171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128}, - "172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256}, - - # Dash webm audio with opus inside - "249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50}, - "250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70}, - "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, - - # av01 video only formats sometimes served with "unknown" codecs - "394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"}, - "395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"}, - "396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"}, - "397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"}, - } -end diff --git a/src/invidious/videos/music.cr b/src/invidious/videos/music.cr deleted file mode 100644 index 08d88a3e..00000000 --- a/src/invidious/videos/music.cr +++ /dev/null @@ -1,13 +0,0 @@ -require "json" - -struct VideoMusic - include JSON::Serializable - - property song : String - property album : String - property artist : String - property license : String - - def initialize(@song : String, @album : String, @artist : String, @license : String) - end -end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr deleted file mode 100644 index feb58440..00000000 --- a/src/invidious/videos/parser.cr +++ /dev/null @@ -1,504 +0,0 @@ -require "json" - -# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer". -# The former is preferred as it has more videos in it. The second has -# the same 11 first entries as the compact rendered. -# -# TODO: "compactRadioRenderer" (Mix) and -# TODO: Use a proper struct/class instead of a hacky JSON object -def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? - return nil if !related["videoId"]? - - # The compact renderer has video length in seconds, where the end - # screen rendered has a full text version ("42:40") - length = related["lengthInSeconds"]?.try &.as_i.to_s - length ||= related.dig?("lengthText", "simpleText").try do |box| - decode_length_seconds(box.as_s).to_s - end - - # Both have "short", so the "long" option shouldn't be required - channel_info = (related["shortBylineText"]? || related["longBylineText"]?) - .try &.dig?("runs", 0) - - author = channel_info.try &.dig?("text") - author_verified = has_verified_badge?(related["ownerBadges"]?).to_s - - ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } - - # "4,088,033 views", only available on compact renderer - # and when video is not a livestream - view_count = related.dig?("viewCountText", "simpleText") - .try &.as_s.gsub(/\D/, "") - - short_view_count = related.try do |r| - HelperExtractors.get_short_view_count(r).to_s - end - - LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") - - if published_time_text = related["publishedTimeText"]? - decoded_time = decode_date(published_time_text["simpleText"].to_s) - published = decoded_time.to_rfc3339.to_s - else - published = nil - end - - # TODO: when refactoring video types, make a struct for related videos - # or reuse an existing type, if that fits. - return { - "id" => related["videoId"], - "title" => related["title"]["simpleText"], - "author" => author || JSON::Any.new(""), - "ucid" => JSON::Any.new(ucid || ""), - "length_seconds" => JSON::Any.new(length || "0"), - "view_count" => JSON::Any.new(view_count || "0"), - "short_view_count" => JSON::Any.new(short_view_count || "0"), - "author_verified" => JSON::Any.new(author_verified), - "published" => JSON::Any.new(published || ""), - } -end - -def extract_video_info(video_id : String) - # Init client config for the API - client_config = YoutubeAPI::ClientConfig.new - - # Fetch data from the player endpoint - player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config) - - playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s - - if playability_status != "OK" - subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") - reason = subreason.try &.[]?("simpleText").try &.as_s - reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") - reason ||= player_response.dig("playabilityStatus", "reason").as_s - - # Stop here if video is not a scheduled livestream or - # for LOGIN_REQUIRED when videoDetails element is not found because retrying won't help - if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) || - playability_status == "LOGIN_REQUIRED" && !player_response.dig?("videoDetails") - return { - "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), - "reason" => JSON::Any.new(reason), - } - end - elsif video_id != player_response.dig?("videoDetails", "videoId") - # YouTube may return a different video player response than expected. - # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 - # Line to be reverted if one day we solve the video not available issue. - - # Although technically not a call to /videoplayback the fact that YouTube is returning the - # wrong video means that we should count it as a failure. - get_playback_statistic()["totalRequests"] += 1 - - return { - "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), - "reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances.
    Click here for more info about the issue."), - } - else - reason = nil - end - - # Don't fetch the next endpoint if the video is unavailable. - if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) - next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) - player_response = player_response.merge(next_response) - end - - params = parse_video_info(video_id, player_response) - params["reason"] = JSON::Any.new(reason) if reason - - if !CONFIG.invidious_companion.present? - if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil? - LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.") - players_fallback = {YoutubeAPI::ClientType::TvHtml5, YoutubeAPI::ClientType::WebMobile} - - players_fallback.each do |player_fallback| - client_config.client_type = player_fallback - - next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config)) - - if player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url") - streaming_data = player_response["streamingData"].as_h - streaming_data["adaptiveFormats"] = player_fallback_response["streamingData"]["adaptiveFormats"] - player_response["streamingData"] = JSON::Any.new(streaming_data) - break - end - rescue InfoException - next LOGGER.warn("Failed to fetch streams with #{player_fallback}") - end - end - - # Seems like video page can still render even without playable streams. - # its better than nothing. - # - # # Were we able to find playable video streams? - # if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil? - # # No :( - # end - end - - {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| - params[f] = player_response[f] if player_response[f]? - end - - # Convert URLs, if those are present - if streaming_data = player_response["streamingData"]? - %w[formats adaptiveFormats].each do |key| - streaming_data.as_h[key]?.try &.as_a.each do |format| - format.as_h["url"] = JSON::Any.new(convert_url(format)) - end - end - - params["streamingData"] = streaming_data - end - - # Data structure version, for cache control - params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) - - return params -end - -def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? - LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") - response = YoutubeAPI.player(video_id: id, params: "2AMB", client_config: client_config) - - playability_status = response["playabilityStatus"]["status"] - LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") - - if id != response.dig?("videoDetails", "videoId") - # YouTube may return a different video player response than expected. - # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 - raise InfoException.new( - "The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)" - ) - elsif playability_status == "OK" - return response - else - return nil - end -end - -def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any) - # Top level elements - - main_results = player_response.dig?("contents", "twoColumnWatchNextResults") - - raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results - - # Primary results are not available on Music videos - # See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725 - if primary_results = main_results.dig?("results", "results", "contents") - video_primary_renderer = primary_results - .as_a.find(&.["videoPrimaryInfoRenderer"]?) - .try &.["videoPrimaryInfoRenderer"] - - video_secondary_renderer = primary_results - .as_a.find(&.["videoSecondaryInfoRenderer"]?) - .try &.["videoSecondaryInfoRenderer"] - - raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer - raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer - end - - video_details = player_response.dig?("videoDetails") - if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer")) - microformat = {} of String => JSON::Any - end - - raise BrokenTubeException.new("videoDetails") if !video_details - - # Basic video infos - - title = video_details["title"]?.try &.as_s - - # We have to try to extract viewCount from videoPrimaryInfoRenderer first, - # then from videoDetails, as the latter is "0" for livestreams (we want - # to get the amount of viewers watching). - views_txt = extract_text( - video_primary_renderer - .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount") - ) - views_txt ||= video_details["viewCount"]?.try &.as_s || "" - views = views_txt.gsub(/\D/, "").to_i64? - - length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) - .try &.as_s.to_i64 - - published = microformat["publishDate"]? - .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc - - premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") - .try { |t| Time.parse_rfc3339(t.as_s) } - - premiere_timestamp ||= player_response.dig?( - "playabilityStatus", "liveStreamability", - "liveStreamabilityRenderer", "offlineSlate", - "liveStreamOfflineSlateRenderer", "scheduledStartTime" - ) - .try &.as_s.to_i64 - .try { |t| Time.unix(t) } - - live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") - .try &.as_bool - live_now ||= video_details.dig?("isLive").try &.as_bool || false - - post_live_dvr = video_details.dig?("isPostLiveDvr") - .try &.as_bool || false - - # Extra video infos - - allowed_regions = microformat["availableCountries"]? - .try &.as_a.map &.as_s || [] of String - - allow_ratings = video_details["allowRatings"]?.try &.as_bool - family_friendly = microformat["isFamilySafe"]?.try &.as_bool - is_listed = video_details["isCrawlable"]?.try &.as_bool - is_upcoming = video_details["isUpcoming"]?.try &.as_bool - - keywords = video_details["keywords"]? - .try &.as_a.map &.as_s || [] of String - - # Related videos - - LOGGER.debug("extract_video_info: parsing related videos...") - - related = [] of JSON::Any - - # Parse "compactVideoRenderer" items (under secondary results) - secondary_results = main_results - .dig?("secondaryResults", "secondaryResults", "results") - secondary_results.try &.as_a.each do |element| - if item = element["compactVideoRenderer"]? - related_video = parse_related_video(item) - related << JSON::Any.new(related_video) if related_video - end - end - - # If nothing was found previously, fall back to end screen renderer - if related.empty? - # Container for "endScreenVideoRenderer" items - player_overlays = player_response.dig?( - "playerOverlays", "playerOverlayRenderer", - "endScreen", "watchNextEndScreenRenderer", "results" - ) - - player_overlays.try &.as_a.each do |element| - if item = element["endScreenVideoRenderer"]? - related_video = parse_related_video(item) - related << JSON::Any.new(related_video) if related_video - end - end - end - - # Likes - - toplevel_buttons = video_primary_renderer - .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") - - if toplevel_buttons - # New Format as of december 2023 - likes_button = toplevel_buttons.dig?(0, - "segmentedLikeDislikeButtonViewModel", - "likeButtonViewModel", - "likeButtonViewModel", - "toggleButtonViewModel", - "toggleButtonViewModel", - "defaultButtonViewModel", - "buttonViewModel" - ) - - likes_button ||= toplevel_buttons.try &.as_a - .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE") - .try &.["toggleButtonRenderer"] - - # New format as of september 2022 - likes_button ||= toplevel_buttons.try &.as_a - .find(&.["segmentedLikeDislikeButtonRenderer"]?) - .try &.dig?( - "segmentedLikeDislikeButtonRenderer", - "likeButton", "toggleButtonRenderer" - ) - - if likes_button - likes_txt = likes_button.dig?("accessibilityText") - # Note: The like count from `toggledText` is off by one, as it would - # represent the new like count in the event where the user clicks on "like". - likes_txt ||= (likes_button["defaultText"]? || likes_button["toggledText"]?) - .try &.dig?("accessibility", "accessibilityData", "label") - likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt - - LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") - LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes - end - end - - # Description - - description = microformat.dig?("description", "simpleText").try &.as_s || "" - short_description = player_response.dig?("videoDetails", "shortDescription") - - # description_html = video_secondary_renderer.try &.dig?("description", "runs") - # .try &.as_a.try { |t| content_to_comment_html(t, video_id) } - - description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), video_id) - - # Video metadata - - metadata = video_secondary_renderer - .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") - .try &.as_a - - genre = microformat["category"]? - genre_ucid = nil - license = nil - - metadata.try &.each do |row| - metadata_title = extract_text(row.dig?("metadataRowRenderer", "title")) - contents = row.dig?("metadataRowRenderer", "contents", 0) - - if metadata_title == "Category" - contents = contents.try &.dig?("runs", 0) - - genre = contents.try &.["text"]? - genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId") - elsif metadata_title == "License" - license = contents.try &.dig?("runs", 0, "text") - elsif metadata_title == "Licensed to YouTube by" - license = contents.try &.["simpleText"]? - end - end - - # Music section - - music_list = [] of VideoMusic - music_desclist = player_response.dig?( - "engagementPanels", 1, "engagementPanelSectionListRenderer", - "content", "structuredDescriptionContentRenderer", "items", 2, - "videoDescriptionMusicSectionRenderer", "carouselLockups" - ) - - music_desclist.try &.as_a.each do |music_desc| - artist = nil - album = nil - music_license = nil - - # Used when the video has multiple songs - if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title") - # "simpleText" for plain text / "runs" when song has a link - song = song_title["simpleText"]? || song_title.dig?("runs", 0, "text") - - # some videos can have empty tracks. See: https://www.youtube.com/watch?v=eBGIQ7ZuuiU - next if !song - end - - music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| - desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) - if desc_title == "ARTIST" - artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) - elsif desc_title == "SONG" - song = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) - elsif desc_title == "ALBUM" - album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) - elsif desc_title == "LICENSES" - music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) - end - end - music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s) - end - - # Author infos - - author = video_details["author"]?.try &.as_s - ucid = video_details["channelId"]?.try &.as_s - - if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") - author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") - author_verified = has_verified_badge?(author_info["badges"]?) - - subs_text = author_info["subscriberCountText"]? - .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } - .try &.as_s.split(" ", 2)[0] - end - - # Return data - - if live_now - video_type = VideoType::Livestream - elsif !premiere_timestamp.nil? - video_type = VideoType::Scheduled - published = premiere_timestamp || Time.utc - else - video_type = VideoType::Video - end - - params = { - "videoType" => JSON::Any.new(video_type.to_s), - # Basic video infos - "title" => JSON::Any.new(title || ""), - "views" => JSON::Any.new(views || 0_i64), - "likes" => JSON::Any.new(likes || 0_i64), - "lengthSeconds" => JSON::Any.new(length_txt || 0_i64), - "published" => JSON::Any.new(published.to_rfc3339), - # Extra video infos - "allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }), - "allowRatings" => JSON::Any.new(allow_ratings || false), - "isFamilyFriendly" => JSON::Any.new(family_friendly || false), - "isListed" => JSON::Any.new(is_listed || false), - "isUpcoming" => JSON::Any.new(is_upcoming || false), - "keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }), - "isPostLiveDvr" => JSON::Any.new(post_live_dvr), - # Related videos - "relatedVideos" => JSON::Any.new(related), - # Description - "description" => JSON::Any.new(description || ""), - "descriptionHtml" => JSON::Any.new(description_html || "

    "), - "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), - # Video metadata - "genre" => JSON::Any.new(genre.try &.as_s || ""), - "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?), - "license" => JSON::Any.new(license.try &.as_s || ""), - # Music section - "music" => JSON.parse(music_list.to_json), - # Author infos - "author" => JSON::Any.new(author || ""), - "ucid" => JSON::Any.new(ucid || ""), - "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), - "authorVerified" => JSON::Any.new(author_verified || false), - "subCountText" => JSON::Any.new(subs_text || "-"), - } - - return params -end - -private def convert_url(fmt) - if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } - sp = cfr["sp"] - url = URI.parse(cfr["url"]) - params = url.query_params - - LOGGER.debug("convert_url: Decoding '#{cfr}'") - - unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"]) - params[sp] = unsig if unsig - else - url = URI.parse(fmt["url"].as_s) - params = url.query_params - end - - n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) - params["n"] = n if n - - if token = CONFIG.po_token - params["pot"] = token - end - - url.query_params = params - LOGGER.trace("convert_url: new url is '#{url}'") - - return url.to_s -rescue ex - LOGGER.debug("convert_url: Error when parsing video URL") - LOGGER.trace(ex.inspect_with_backtrace) - return "" -end diff --git a/src/invidious/videos/regions.cr b/src/invidious/videos/regions.cr deleted file mode 100644 index 575f8c25..00000000 --- a/src/invidious/videos/regions.cr +++ /dev/null @@ -1,27 +0,0 @@ -# List of geographical regions that Youtube recognizes. -# This is used to determine if a video is either restricted to a list -# of allowed regions (= whitelisted) or if it can't be watched in -# a set of regions (= blacklisted). -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", -} diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr deleted file mode 100644 index bd0eef59..00000000 --- a/src/invidious/videos/storyboard.cr +++ /dev/null @@ -1,122 +0,0 @@ -require "uri" -require "http/params" - -module Invidious::Videos - struct Storyboard - # Template URL - getter url : URI - getter proxied_url : URI - - # Thumbnail parameters - getter width : Int32 - getter height : Int32 - getter count : Int32 - getter interval : Int32 - - # Image (storyboard) parameters - getter rows : Int32 - getter columns : Int32 - getter images_count : Int32 - - def initialize( - *, @url, @width, @height, @count, @interval, - @rows, @columns, @images_count, - ) - authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]? - - @proxied_url = URI.parse(HOST_URL) - @proxied_url.path = "/sb/#{authority}/#{@url.path.lchop("/sb/")}" - @proxied_url.query = @url.query - end - - # Parse the JSON structure from Youtube - def self.from_yt_json(container : JSON::Any, length_seconds : Int32) : Array(Storyboard) - # Livestream storyboards are a bit different - # TODO: document exactly how - if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s - return [Storyboard.new( - url: URI.parse(storyboard.split("#")[0]), - width: 106, - height: 60, - count: -1, - interval: 5000, - rows: 3, - columns: 3, - images_count: -1 - )] - end - - # Split the storyboard string into chunks - # - # General format (whitespaces added for legibility): - # https://i.ytimg.com/sb//storyboard3_L$L/$N.jpg?sqp= - # | 48 # 27 # 100 # 10 # 10 # 0 # default # rs$ - # | 80 # 45 # 95 # 10 # 10 # 10000 # M$M # rs$ - # | 160 # 90 # 95 # 5 # 5 # 10000 # M$M # rs$ - # - storyboards = container.dig?("playerStoryboardSpecRenderer", "spec") - .try &.as_s.split("|") - - return [] of Storyboard if !storyboards - - # The base URL is the first chunk - base_url = URI.parse(storyboards.shift) - - return storyboards.map_with_index do |sb, i| - # Separate the different storyboard parameters: - # width/height: respective dimensions, in pixels, of a single thumbnail - # count: how many thumbnails are displayed across the full video - # columns/rows: maximum amount of thumbnails that can be stuffed in a - # single image, horizontally and vertically. - # interval: interval between two thumbnails, in milliseconds - # name: storyboard filename. Usually "M$M" or "default" - # sigh: URL cryptographic signature - width, height, count, columns, rows, interval, name, sigh = sb.split("#") - - width = width.to_i - height = height.to_i - count = count.to_i - interval = interval.to_i - columns = columns.to_i - rows = rows.to_i - - # Copy base URL object, so that we can modify it - url = base_url.dup - - # Add the signature to the URL - params = url.query_params - params["sigh"] = sigh - url.query_params = params - - # Replace the template parts with what we have - url.path = url.path.sub("$L", i).sub("$N", name) - - # This value represents the maximum amount of thumbnails that can fit - # in a single image. The last image (or the only one for short videos) - # will contain less thumbnails than that. - thumbnails_per_image = columns * rows - - # This value represents the total amount of storyboards required to - # hold all of the thumbnails. It can't be less than 1. - images_count = (count / thumbnails_per_image).ceil.to_i - - # Compute the interval when needed (in general, that's only required - # for the first "default" storyboard). - if interval == 0 - interval = ((length_seconds / count) * 1_000).to_i - end - - Storyboard.new( - url: url, - width: width, - height: height, - count: count, - interval: interval, - rows: rows, - columns: columns, - images_count: images_count, - ) - end - end - end -end diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr deleted file mode 100644 index ee1272d1..00000000 --- a/src/invidious/videos/transcript.cr +++ /dev/null @@ -1,161 +0,0 @@ -module Invidious::Videos - # A `Transcripts` struct encapsulates a sequence of lines that together forms the whole transcript for a given YouTube video. - # These lines can be categorized into two types: section headings and regular lines representing content from the video. - struct Transcript - # Types - record HeadingLine, start_ms : Time::Span, end_ms : Time::Span, line : String - record RegularLine, start_ms : Time::Span, end_ms : Time::Span, line : String - alias TranscriptLine = HeadingLine | RegularLine - - property lines : Array(TranscriptLine) - - property language_code : String - property auto_generated : Bool - - # User friendly label for the current transcript. - # Example: "English (auto-generated)" - property label : String - - # Initializes a new Transcript struct with the contents and associated metadata describing it - def initialize(@lines : Array(TranscriptLine), @language_code : String, @auto_generated : Bool, @label : String) - end - - # Generates a protobuf string to fetch the requested transcript from YouTube - def self.generate_param(video_id : String, language_code : String, auto_generated : Bool) : String - kind = auto_generated ? "asr" : "" - - object = { - "1:0:string" => video_id, - - "2:base64" => { - "1:string" => kind, - "2:string" => language_code, - "3:string" => "", - }, - - "3:varint" => 1_i64, - "5:string" => "engagement-panel-searchable-transcript-search-panel", - "6:varint" => 1_i64, - "7:varint" => 1_i64, - "8:varint" => 1_i64, - } - - params = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return params - end - - # Constructs a Transcripts struct from the initial YouTube response - def self.from_raw(initial_data : Hash(String, JSON::Any), language_code : String, auto_generated : Bool) - transcript_panel = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", - "content", "transcriptSearchPanelRenderer") - - segment_list = transcript_panel.dig("body", "transcriptSegmentListRenderer") - - if !segment_list["initialSegments"]? - raise NotFoundException.new("Requested transcript does not exist") - end - - # Extract user-friendly label for the current transcript - - footer_language_menu = transcript_panel.dig?( - "footer", "transcriptFooterRenderer", "languageMenu", "sortFilterSubMenuRenderer", "subMenuItems" - ) - - if footer_language_menu - label = footer_language_menu.as_a.select(&.["selected"].as_bool)[0]["title"].as_s - else - label = language_code - end - - # Extract transcript lines - - initial_segments = segment_list["initialSegments"].as_a - - lines = [] of TranscriptLine - - initial_segments.each do |line| - if unpacked_line = line["transcriptSectionHeaderRenderer"]? - line_type = HeadingLine - else - unpacked_line = line["transcriptSegmentRenderer"] - line_type = RegularLine - end - - start_ms = unpacked_line["startMs"].as_s.to_i.millisecond - end_ms = unpacked_line["endMs"].as_s.to_i.millisecond - text = extract_text(unpacked_line["snippet"]) || "" - - lines << line_type.new(start_ms, end_ms, text) - end - - return Transcript.new( - lines: lines, - language_code: language_code, - auto_generated: auto_generated, - label: label - ) - end - - # Converts transcript lines to a WebVTT file - # - # This is used within Invidious to replace subtitles - # as to workaround YouTube's rate-limited timedtext endpoint. - def to_vtt - settings_field = { - "Kind" => "captions", - "Language" => @language_code, - } - - vtt = WebVTT.build(settings_field) do |builder| - @lines.each do |line| - # Section headers are excluded from the VTT conversion as to - # match the regular captions returned from YouTube as much as possible - next if line.is_a? HeadingLine - - builder.cue(line.start_ms, line.end_ms, line.line) - end - end - - return vtt - end - - def to_json(json : JSON::Builder) - json.field "languageCode", @language_code - json.field "autoGenerated", @auto_generated - json.field "label", @label - json.field "body" do - json.array do - @lines.each do |line| - json.object do - if line.is_a? HeadingLine - json.field "type", "heading" - else - json.field "type", "regular" - end - - json.field "startMs", line.start_ms.total_milliseconds - json.field "endMs", line.end_ms.total_milliseconds - json.field "line", line.line - end - end - end - end - end - - def to_json - JSON.build do |json| - json.object do - json.field "transcript" do - json.object do - to_json(json) - end - end - end - end - end - end -end diff --git a/src/invidious/videos/video_preferences.cr b/src/invidious/videos/video_preferences.cr deleted file mode 100644 index 48177bd8..00000000 --- a/src/invidious/videos/video_preferences.cr +++ /dev/null @@ -1,162 +0,0 @@ -struct VideoPreferences - include JSON::Serializable - - property annotations : Bool - property preload : Bool - property autoplay : Bool - property comments : Array(String) - property continue : Bool - property continue_autoplay : Bool - property controls : Bool - property listen : Bool - property local : Bool - property preferred_captions : Array(String) - property player_style : String - property quality : String - property quality_dash : String - property raw : Bool - property region : String? - property related_videos : Bool - property speed : Float32 | Float64 - property video_end : Float64 | Int32 - property video_loop : Bool - property extend_desc : Bool - property video_start : Float64 | Int32 - property volume : Int32 - property vr_mode : Bool - property save_player_pos : Bool -end - -def process_video_params(query, preferences) - annotations = query["iv_load_policy"]?.try &.to_i? - preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe } - autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - comments = query["comments"]?.try &.split(",").map(&.downcase) - continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe } - continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe } - local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe } - player_style = query["player_style"]? - preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase) - quality = query["quality"]? - quality_dash = query["quality_dash"]? - region = query["region"]? - related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe } - speed = query["speed"]?.try &.rchop("x").to_f? - video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe } - extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } - volume = query["volume"]?.try &.to_i? - vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } - save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe } - - if preferences - # region ||= preferences.region - annotations ||= preferences.annotations.to_unsafe - preload ||= preferences.preload.to_unsafe - autoplay ||= preferences.autoplay.to_unsafe - comments ||= preferences.comments - continue ||= preferences.continue.to_unsafe - 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 - quality_dash ||= preferences.quality_dash - related_videos ||= preferences.related_videos.to_unsafe - speed ||= preferences.speed - video_loop ||= preferences.video_loop.to_unsafe - extend_desc ||= preferences.extend_desc.to_unsafe - volume ||= preferences.volume - vr_mode ||= preferences.vr_mode.to_unsafe - save_player_pos ||= preferences.save_player_pos.to_unsafe - end - - annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe - preload ||= CONFIG.default_user_preferences.preload.to_unsafe - autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe - comments ||= CONFIG.default_user_preferences.comments - continue ||= CONFIG.default_user_preferences.continue.to_unsafe - 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 - quality_dash ||= CONFIG.default_user_preferences.quality_dash - related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe - speed ||= CONFIG.default_user_preferences.speed - video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe - extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe - volume ||= CONFIG.default_user_preferences.volume - vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe - save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe - - annotations = annotations == 1 - preload = preload == 1 - autoplay = autoplay == 1 - continue = continue == 1 - continue_autoplay = continue_autoplay == 1 - listen = listen == 1 - local = local == 1 - related_videos = related_videos == 1 - video_loop = video_loop == 1 - extend_desc = extend_desc == 1 - vr_mode = vr_mode == 1 - save_player_pos = save_player_pos == 1 - - if CONFIG.disabled?("dash") && quality == "dash" - quality = "high" - end - - if CONFIG.disabled?("local") && local - local = false - end - - if start = query["t"]? || query["time_continue"]? || query["start"]? - video_start = decode_time(start) - end - video_start ||= 0 - - if query["end"]? - video_end = decode_time(query["end"]) - end - video_end ||= -1 - - raw = query["raw"]?.try &.to_i? - raw ||= 0 - raw = raw == 1 - - controls = query["controls"]?.try &.to_i? - controls ||= 1 - controls = controls >= 1 - - params = VideoPreferences.new({ - annotations: annotations, - preload: preload, - autoplay: autoplay, - comments: comments, - continue: continue, - continue_autoplay: continue_autoplay, - controls: controls, - listen: listen, - local: local, - player_style: player_style, - preferred_captions: preferred_captions, - quality: quality, - quality_dash: quality_dash, - raw: raw, - region: region, - related_videos: related_videos, - speed: speed, - video_end: video_end, - video_loop: video_loop, - extend_desc: extend_desc, - video_start: video_start, - volume: volume, - vr_mode: vr_mode, - save_player_pos: save_player_pos, - }) - - return params -end diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index 6aea82ae..ad50909a 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -11,9 +11,7 @@ <%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %>
    - value="<%= HTML.escape(query.text) %>"<% end %> - placeholder="<%= translate(locale, "Search for videos") %>"> + value="<%= HTML.escape(query) %>"<% else %>placeholder="<%= translate(locale, "Search for videos") %>"<% end %>>
    @@ -31,5 +29,30 @@ +
    + <% videos.each_slice(4) do |slice| %> + <% slice.each do |item| %> + <%= rendered "components/item" %> + <% end %> + <% end %> +
    -<%= rendered "components/items_paginated" %> +<% if query %> +
    +
    + <% if page > 1 %> + + <%= translate(locale, "Previous page") %> + + <% end %> +
    +
    +
    + <% if videos.size >= 20 %> + + <%= translate(locale, "Next page") %> + + <% end %> +
    +
    +<% end %> diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 686de6bd..40b553a9 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -1,59 +1,116 @@ -<%- - ucid = channel.ucid - author = HTML.escape(channel.author) - channel_profile_pic = URI.parse(channel.author_thumbnail).request_target - - relative_url = - case selected_tab - when .shorts? then "/channel/#{ucid}/shorts" - when .streams? then "/channel/#{ucid}/streams" - when .playlists? then "/channel/#{ucid}/playlists" - when .channels? then "/channel/#{ucid}/channels" - when .podcasts? then "/channel/#{ucid}/podcasts" - when .releases? then "/channel/#{ucid}/releases" - when .courses? then "/channel/#{ucid}/courses" - else - "/channel/#{ucid}" - end - - youtube_url = "https://www.youtube.com#{relative_url}" - redirect_url = Invidious::Frontend::Misc.redirect_url(env) - - page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale, - base_url: relative_url, - ctoken: next_continuation, - first_page: continuation.nil?, - params: env.params.query, - ) -%> +<% ucid = channel.ucid %> +<% author = HTML.escape(channel.author) %> <% content_for "header" do %> -<%- if selected_tab.videos? -%> - - - - - - - - - - - - -<%- end -%> - - - - <%= author %> - Invidious + <% end %> -<%= rendered "components/channel_info" %> +<% if channel.banner %> +
    + "> +
    + +
    +
    +
    +<% end %> + +
    +
    +
    + + <%= author %> +
    +
    +
    +

    + +

    +
    +
    + +
    +
    +

    <%= channel.description_html %>

    +
    +
    + +
    + <% sub_count_text = number_to_short_text(channel.sub_count) %> + <%= rendered "components/subscribe_widget" %> +
    + +
    +
    + <%= translate(locale, "View channel on YouTube") %> +
    + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> + "><%= translate(locale, "Switch Invidious Instance") %> + <% else %> + <%= translate(locale, "Switch Invidious Instance") %> + <% end %> +
    + <% if !channel.auto_generated %> +
    + <%= translate(locale, "Videos") %> +
    + <% end %> +
    + <% if channel.auto_generated %> + <%= translate(locale, "Playlists") %> + <% else %> + <%= translate(locale, "Playlists") %> + <% end %> +
    +
    + <% if channel.tabs.includes? "community" %> + <%= translate(locale, "Community") %> + <% end %> +
    +
    +
    +
    +
    + <% sort_options.each do |sort| %> +
    + <% if sort_by == sort %> + <%= translate(locale, sort) %> + <% else %> + + <%= translate(locale, sort) %> + + <% end %> +
    + <% end %> +
    +
    +

    +
    +<% items.each do |item| %> + <%= rendered "components/item" %> +<% end %> +
    -<%= rendered "components/items_paginated" %> + diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 132e636c..f0add06b 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -1,21 +1,71 @@ -<%- - ucid = channel.ucid - author = HTML.escape(channel.author) - channel_profile_pic = URI.parse(channel.author_thumbnail).request_target - - relative_url = "/channel/#{ucid}/community" - youtube_url = "https://www.youtube.com#{relative_url}" - redirect_url = Invidious::Frontend::Misc.redirect_url(env) - - selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Posts --%> +<% ucid = channel.ucid %> +<% author = HTML.escape(channel.author) %> <% content_for "header" do %> - <%= author %> - Invidious <% end %> -<%= rendered "components/channel_info" %> +<% if channel.banner %> +
    + "> +
    + +
    +
    +
    +<% end %> + +
    +
    +
    + + <%= author %> +
    +
    +
    +

    + +

    +
    +
    + +
    +
    +

    <%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %>

    +
    +
    + +
    + <% sub_count_text = number_to_short_text(channel.sub_count) %> + <%= rendered "components/subscribe_widget" %> +
    + +
    +
    + <%= translate(locale, "View channel on YouTube") %> +
    + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> + "><%= translate(locale, "Switch Invidious Instance") %> + <% else %> + <%= translate(locale, "Switch Invidious Instance") %> + <% end %> +
    + <% if !channel.auto_generated %> + + <% end %> + +
    + <% if channel.tabs.includes? "community" %> + <%= translate(locale, "Community") %> + <% end %> +
    +
    +
    +

    @@ -26,8 +76,8 @@

    <%= error_message %>

    <% else %> -
    - <%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %> +
    + <%= template_youtube_comments(items.not_nil!, locale, thin_mode) %>
    <% end %> diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr deleted file mode 100644 index f4164f31..00000000 --- a/src/invidious/views/components/channel_info.ecr +++ /dev/null @@ -1,61 +0,0 @@ -<% if channel.banner %> -
    - " alt="" /> -
    - -
    -
    -
    -<% end %> - -
    -
    -
    - - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> -
    -
    - -
    -
    - <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -
    - - -
    -
    - -
    -

    <%= channel.description_html %>

    -
    - -
    -
    - - - - <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %> -
    -
    -
    - <% sort_options.each do |sort| %> -
    - <% if sort_by == sort %> - <%= translate(locale, sort) %> - <% else %> - <%= translate(locale, sort) %> - <% end %> -
    - <% end %> -
    -
    -
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index a24423df..5f8bde13 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -1,199 +1,151 @@ -<%- - thin_mode = env.get("preferences").as(Preferences).thin_mode - item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category | ProblematicTimelineItem) && env.get?("user").try &.as(User).watched.index(item.id) != nil - author_verified = item.responds_to?(:author_verified) && item.author_verified --%> -
    <% case item when %> <% when SearchChannel %> - <% if !thin_mode %> - + + <% if !env.get("preferences").as(Preferences).thin_mode %>
    - " alt="" /> + "/>
    -
    - <%- else -%> -
    + <% end %> +

    <%= HTML.escape(item.author) %>

    + +

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    + <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> +
    <%= item.description_html %>
    + <% when SearchPlaylist, InvidiousPlaylist %> + <% if item.id.starts_with? "RD" %> + <% url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}" %> + <% else %> + <% url = "/playlist?list=#{item.id}" %> <% end %> + + <% if !env.get("preferences").as(Preferences).thin_mode %> +
    + "/> +

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    +
    + <% end %> +

    <%= HTML.escape(item.title) %>

    +
    + +

    <%= HTML.escape(item.author) %>

    +
    + <% when MixVideo %> + + <% if !env.get("preferences").as(Preferences).thin_mode %> +
    + + <% if item.length_seconds != 0 %> +

    <%= recode_length_seconds(item.length_seconds) %>

    + <% end %> +
    + <% end %> +

    <%= HTML.escape(item.title) %>

    +
    + +

    <%= HTML.escape(item.author) %>

    +
    + <% when PlaylistVideo %> + + <% if !env.get("preferences").as(Preferences).thin_mode %> +
    + + <% if plid = env.get?("remove_playlist_items") %> +
    " method="post"> + "> +

    + + + +

    +
    + <% end %> + + <% if item.responds_to?(:live_now) && item.live_now %> +

    <%= translate(locale, "LIVE") %>

    + <% elsif item.length_seconds != 0 %> +

    <%= recode_length_seconds(item.length_seconds) %>

    + <% end %> +
    + <% end %> +

    <%= HTML.escape(item.title) %>

    + + - - <% if !item.channel_handle.nil? %>

    <%= item.channel_handle %>

    <% end %> -

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    - <% if !item.auto_generated && item.channel_handle.nil? %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> -
    <%= item.description_html %>
    - <% when SearchHashtag %> - <% if !thin_mode %> - -
    -
    - <%- else -%> -
    - <% end %> - - - -
    - <%- if item.video_count != 0 -%> -

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    - <%- end -%> -
    - -
    - <%- if item.channel_count != 0 -%> -

    <%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %>

    - <%- end -%> -
    - <% when SearchPlaylist, InvidiousPlaylist %> - <%- - if item.id.starts_with? "RD" - link_url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}" - else - link_url = "/playlist?list=#{item.id}" - end - -%> - -
    - <%- if !thin_mode %> - - " alt="" /> - - <%- else -%> -
    - <%- end -%> - -
    -

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    -
    -
    - -
    -

    <%= HTML.escape(item.title) %>

    + <% endpoint_params = "?v=#{item.id}&list=#{item.plid}" %> + <%= rendered "components/video-context-buttons" %>
    - <% if !item.ucid.to_s.empty? %> - -

    <%= HTML.escape(item.author) %> - <%- if author_verified %> <% end -%> -

    -
    - <% else %> -

    <%= HTML.escape(item.author) %> - <%- if author_verified %> <% end -%> -

    + <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> +

    <%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>

    + <% elsif Time.utc - item.published > 1.minute %> +

    <%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>

    <% end %>
    + + <% if item.responds_to?(:views) && item.views %> +
    +

    <%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %>

    +
    + <% end %>
    <% when Category %> - <% when ProblematicTimelineItem %> -
    -
    - -

    <%=translate(locale, "timeline_parse_error_placeholder_heading")%>

    -

    <%=translate(locale, "timeline_parse_error_placeholder_message")%>

    -
    -
    - <%=translate(locale, "timeline_parse_error_show_technical_details")%> -
    <%=get_issue_template(env, item.parse_exception)[1]%>
    -
    -
    <% else %> - <%- - # `endpoint_params` is used for the "video-context-buttons" component - if item.is_a?(PlaylistVideo) - link_url = "/watch?v=#{item.id}&list=#{item.plid}&index=#{item.index}" - endpoint_params = "?v=#{item.id}&list=#{item.plid}" - elsif item.is_a?(MixVideo) - link_url = "/watch?v=#{item.id}&list=#{item.rdid}" - endpoint_params = "?v=#{item.id}&list=#{item.rdid}" - else - link_url = "/watch?v=#{item.id}" - endpoint_params = "?v=#{item.id}" - end - -%> - -
    - <%- if !thin_mode -%> - - - - <% if item_watched %> -
    -
    +
    + <% if !env.get("preferences").as(Preferences).thin_mode %> +
    + + <% if env.get? "show_watched" %> +
    " method="post"> + "> +

    + + + +

    +
    + <% elsif plid = env.get? "add_playlist_items" %> +
    " method="post"> + "> +

    + + + +

    +
    <% end %> - - <%- else -%> -
    - <%- end -%> -
    - <%- if env.get? "show_watched" -%> -
    " method="post"> - "> - -
    - <%- end -%> - - <%- if plid_form = env.get?("add_playlist_items") -%> - <%- form_parameters = "action=add_video&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> -
    - "> - -
    - <%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%> - <%- form_parameters = "action=remove_video&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> -
    - "> - -
    - <%- end -%> -
    - -
    - <%- if item.responds_to?(:live_now) && item.live_now -%> -

     <%= translate(locale, "LIVE") %>

    - <%- elsif item.length_seconds != 0 -%> -

    <%= recode_length_seconds(item.length_seconds) %>

    - <%- end -%> -
    -
    - - + <% if item.responds_to?(:live_now) && item.live_now %> +

    <%= translate(locale, "LIVE") %>

    + <% elsif item.length_seconds != 0 %> +

    <%= recode_length_seconds(item.length_seconds) %>

    + <% end %> +
    + <% end %> +

    <%= HTML.escape(item.title) %>

    +
    -
    - <% if !item.ucid.to_s.empty? %> - -

    <%= HTML.escape(item.author) %> - <%- if author_verified %> <% end -%> -

    -
    - <% else %> -

    <%= HTML.escape(item.author) %> - <%- if author_verified %> <% end -%> -

    - <% end %> -
    + + <% endpoint_params = "?v=#{item.id}" %> <%= rendered "components/video-context-buttons" %>
    @@ -201,7 +153,7 @@
    <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>

    <%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>

    - <% elsif item.responds_to?(:published) && (Time.utc - item.published) > 1.minute %> + <% elsif Time.utc - item.published > 1.minute %>

    <%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>

    <% end %>
    diff --git a/src/invidious/views/components/items_paginated.ecr b/src/invidious/views/components/items_paginated.ecr deleted file mode 100644 index f69df3fe..00000000 --- a/src/invidious/views/components/items_paginated.ecr +++ /dev/null @@ -1,21 +0,0 @@ -<%= page_nav_html %> - -
    - <%- items.each do |item| -%> - <%= rendered "components/item" %> - <%- end -%> -
    - -<%= page_nav_html %> - - - - diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index af352102..206ba380 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -1,47 +1,18 @@
  • a list of channel UCIDs the user is subscribed to
  • a user ID (for persistent storage of subscriptions and preferences)
  • a json object containing user preferences
  • -
  • a hashed password
  • +
  • a hashed password if applicable (not present on google accounts)
  • a randomly generated token for providing an RSS feed of a user's subscriptions
  • a list of video IDs identifying watched videos
  • Users can clear their watch history using the clear watch history page.

    +

    If a user is logged in with a Google account, no password will ever be stored. This website uses the session token provided by Google to identify a user, but does not store the information required to make requests on a user's behalf without their knowledge or consent.

    Data you passively provide

    When you request any resource from this website (for example: a page, a font, an image, or an API endpoint) information about the request may be logged.

    diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index b1300214..45bbdefc 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -1,21 +1,147 @@ <% content_for "header" do %> -<%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : HTML.escape(query.text) %> - Invidious - +<%= search_query.not_nil!.size > 30 ? HTML.escape(query.not_nil![0,30].rstrip(".") + "...") : HTML.escape(query.not_nil!) %> - Invidious <% end %> +<% search_query_encoded = env.get?("search").try { |x| URI.encode_www_form(x.as(String), space_to_plus: true) } %> + -<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %> -
    +<% if videos.size == 0 %> +

    + "><%= translate(locale, "Broken? Try another Invidious Instance!") %> +

    +<% else %> +
    + +

    <%= translate(locale, "filter") %>

    +
    +
    +
    + <%= translate(locale, "date") %> +
    + <% ["hour", "today", "week", "month", "year"].each do |date| %> +
    + <% if operator_hash.fetch("date", "all") == date %> + <%= translate(locale, date) %> + <% else %> + &page=<%= page %>"> + <%= translate(locale, date) %> + + <% end %> +
    + <% end %> +
    +
    + <%= translate(locale, "content_type") %> +
    + <% ["video", "channel", "playlist", "movie", "show"].each do |content_type| %> +
    + <% if operator_hash.fetch("content_type", "all") == content_type %> + <%= translate(locale, content_type) %> + <% else %> + &page=<%= page %>"> + <%= translate(locale, content_type) %> + + <% end %> +
    + <% end %> +
    +
    + <%= translate(locale, "duration") %> +
    + <% ["short", "long"].each do |duration| %> +
    + <% if operator_hash.fetch("duration", "all") == duration %> + <%= translate(locale, duration) %> + <% else %> + &page=<%= page %>"> + <%= translate(locale, duration) %> + + <% end %> +
    + <% end %> +
    +
    + <%= translate(locale, "features") %> +
    + <% ["hd", "subtitles", "creative_commons", "3d", "live", "purchased", "4k", "360", "location", "hdr"].each do |feature| %> +
    + <% if operator_hash.fetch("features", "all").includes?(feature) %> + <%= translate(locale, feature) %> + <% elsif operator_hash.has_key?("features") %> + &page=<%= page %>"> + <%= translate(locale, feature) %> + + <% else %> + &page=<%= page %>"> + <%= translate(locale, feature) %> + + <% end %> +
    + <% end %> +
    +
    + <%= translate(locale, "sort") %> +
    + <% ["relevance", "rating", "date", "views"].each do |sort| %> +
    + <% if operator_hash.fetch("sort", "relevance") == sort %> + <%= translate(locale, sort) %> + <% else %> + &page=<%= page %>"> + <%= translate(locale, sort) %> + + <% end %> +
    + <% end %> +
    +
    +
    +<% end %> +<% if videos.size == 0 %> +
    +<% else %> +
    +<% end %> -<%- if items.empty? -%> -
    -
    - <%= translate(locale, "search_message_no_results") %>

    - <%= translate(locale, "search_message_change_filters_or_query") %>

    - <%= translate(locale, "search_message_use_another_instance", redirect_url) %> +
    +
    + <% if page > 1 %> + + <%= translate(locale, "Previous page") %> + + <% end %> +
    +
    +
    + <% if videos.size >= 20 %> + + <%= translate(locale, "Next page") %> + + <% end %> +
    +
    + +
    + <% videos.each do |item| %> + <%= rendered "components/item" %> + <% end %> +
    + +
    +
    + <% if page > 1 %> + + <%= translate(locale, "Previous page") %> + + <% end %> +
    +
    +
    + <% if videos.size >= 20 %> + + <%= translate(locale, "Next page") %> + + <% end %>
    -<%- else -%> - <%= rendered "components/items_paginated" %> -<%- end -%> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 9904b4fc..bd908dd6 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -1,9 +1,5 @@ -<% - locale = env.get("preferences").as(Preferences).locale - dark_mode = env.get("preferences").as(Preferences).dark_mode -%> - +"> @@ -21,14 +17,18 @@ - - +<% + locale = env.get("preferences").as(Preferences).locale + dark_mode = env.get("preferences").as(Preferences).dark_mode +%> + -theme"> - +
    -
    +
    +

    + <% end %> <% end %>
  • @@ -370,5 +302,4 @@ we're going to need to do it here in order to allow for translations.
    <% end %>
    - diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 0daed46c..3feb9233 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -1,153 +1,113 @@ -# Mapping of subdomain => YoutubeConnectionPool -# This is needed as we may need to access arbitrary subdomains of ytimg -private YTIMG_POOLS = {} of String => YoutubeConnectionPool +{% unless flag?(:disable_quic) %} + require "lsquic" + + alias HTTPClientType = QUIC::Client | HTTP::Client +{% else %} + alias HTTPClientType = HTTP::Client +{% end %} + +def add_yt_headers(request) + request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" + request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" + request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + request.headers["accept-language"] ||= "en-us,en;q=0.5" + return if request.resource.starts_with? "/sorry/index" + request.headers["x-youtube-client-name"] ||= "1" + request.headers["x-youtube-client-version"] ||= "2.20200609" + # Preserve original cookies and add new YT consent cookie for EU servers + request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" + if !CONFIG.cookies.empty? + request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" + end +end struct YoutubeConnectionPool property! url : URI property! capacity : Int32 property! timeout : Float64 - property pool : DB::Pool(HTTP::Client) + property pool : DB::Pool(HTTPClientType) - def initialize(url : URI, @capacity = 5, @timeout = 5.0) + def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true) @url = url - @pool = build_pool() + @pool = build_pool(use_quic) end - def client(&) - conn = pool.checkout - # Proxy needs to be reinstated every time we get a client from the pool - conn.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy - - begin + def client(region = nil, &block) + if region + conn = make_client(url, region) response = yield conn - rescue ex - conn.close - conn = make_client(url, force_resolve: true) + else + conn = pool.checkout + begin + response = yield conn + rescue ex + conn.close + {% unless flag?(:disable_quic) %} + conn = CONFIG.use_quic ? QUIC::Client.new(url) : HTTP::Client.new(url) + {% else %} + conn = HTTP::Client.new(url) + {% end %} - response = yield conn - ensure - pool.release(conn) + conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET + conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC + conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + response = yield conn + ensure + pool.release(conn) + end end response end - private def build_pool - options = DB::Pool::Options.new( - initial_pool_size: 0, - max_pool_size: capacity, - max_idle_pool_size: capacity, - checkout_timeout: timeout - ) + private def build_pool(use_quic) + DB::Pool(HTTPClientType).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do + conn = nil # Declare + {% unless flag?(:disable_quic) %} + if use_quic + conn = QUIC::Client.new(url) + else + conn = HTTP::Client.new(url) + end + {% else %} + conn = HTTP::Client.new(url) + {% end %} - DB::Pool(HTTP::Client).new(options) do - next make_client(url, force_resolve: true) + conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET + conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC + conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + conn end end end -struct CompanionConnectionPool - property pool : DB::Pool(HTTP::Client) - - def initialize(capacity = 5, timeout = 5.0) - options = DB::Pool::Options.new( - initial_pool_size: 0, - max_pool_size: capacity, - max_idle_pool_size: capacity, - checkout_timeout: timeout - ) - - @pool = DB::Pool(HTTP::Client).new(options) do - companion = CONFIG.invidious_companion.sample - next make_client(companion.private_url, use_http_proxy: false) - end - end - - def client(&) - conn = pool.checkout - - begin - response = yield conn - rescue ex - conn.close - - companion = CONFIG.invidious_companion.sample - conn = make_client(companion.private_url, use_http_proxy: false) - - response = yield conn - ensure - pool.release(conn) - end - - response - end -end - -def add_yt_headers(request) - request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal" - request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" - - request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" - request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - request.headers["Accept-Language"] ||= "en-us,en;q=0.5" - - # Preserve original cookies and add new YT consent cookie for EU servers - request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}" - if !CONFIG.cookies.empty? - request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" - end -end - -def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true) - client = HTTP::Client.new(url) - client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy - - # Force the usage of a specific configured IP Family - if force_resolve - client.family = CONFIG.force_resolve - client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC - end - - client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers +def make_client(url : URI, region = nil) + # TODO: Migrate any applicable endpoints to QUIC + client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure) + client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC + client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" client.read_timeout = 10.seconds client.connect_timeout = 10.seconds + if region + PROXY_LIST[region]?.try &.sample(40).each do |proxy| + begin + proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) + client.set_proxy(proxy) + break + rescue ex + end + end + end + return client end -def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &) - client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy) +def make_client(url : URI, region = nil, &block) + client = make_client(url, region) begin yield client ensure client.close end end - -def make_configured_http_proxy_client - # This method is only called when configuration for an HTTP proxy are set - config_proxy = CONFIG.http_proxy.not_nil! - - return HTTP::Proxy::Client.new( - config_proxy.host, - config_proxy.port, - - username: config_proxy.user, - password: config_proxy.password, - ) -end - -# Fetches a HTTP pool for the specified subdomain of ytimg.com -# -# Creates a new one when the specified pool for the subdomain does not exist -def get_ytimg_pool(subdomain) - if pool = YTIMG_POOLS[subdomain]? - return pool - else - LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"") - pool = YoutubeConnectionPool.new(URI.parse("https://#{subdomain}.ytimg.com"), capacity: CONFIG.pool_size) - YTIMG_POOLS[subdomain] = pool - - return pool - end -end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 85f6caa5..ce39bc28 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -1,5 +1,3 @@ -require "../helpers/serialized_yt_data" - # This file contains helper methods to parse the Youtube API json data into # neat little packages we can use @@ -7,25 +5,17 @@ require "../helpers/serialized_yt_data" private ITEM_CONTAINER_EXTRACTOR = { Extractors::YouTubeTabs, Extractors::SearchResults, - Extractors::ContinuationContent, + Extractors::Continuation, } private ITEM_PARSERS = { - Parsers::RichItemRendererParser, Parsers::VideoRendererParser, Parsers::ChannelRendererParser, Parsers::GridPlaylistRendererParser, Parsers::PlaylistRendererParser, Parsers::CategoryRendererParser, - Parsers::ReelItemRendererParser, - Parsers::ItemSectionRendererParser, - Parsers::ContinuationItemRendererParser, - Parsers::HashtagRendererParser, - Parsers::LockupViewModelParser, } -private alias InitialData = Hash(String, JSON::Any) - record AuthorFallback, name : String, id : String # Namespace for logic relating to parsing InnerTube data into various datastructs. @@ -35,20 +25,6 @@ record AuthorFallback, name : String, id : String # data is passed to the private `#parse()` method which returns a datastruct of the given # type. Otherwise, nil is returned. private module Parsers - module BaseParser - def parse(*args) - begin - return parse_internal(*args) - rescue ex - LOGGER.debug("#{{{@type.name}}}: Failed to render item.") - LOGGER.debug("#{{{@type.name}}}: Got exception: #{ex.message}") - ProblematicTimelineItem.new( - parse_exception: ex - ) - end - end - end - # Parses a InnerTube videoRenderer into a SearchVideo. Returns nil when the given object isn't a videoRenderer # # A videoRenderer renders a video to click on within the YouTube and Invidious UI. It is **not** @@ -59,16 +35,13 @@ private module Parsers # `videoRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. # module VideoRendererParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) + def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?) return self.parse(item_contents, author_fallback) end end - private def parse_internal(item_contents, author_fallback) + private def self.parse(item_contents, author_fallback) video_id = item_contents["videoId"].as_s title = extract_text(item_contents["title"]?) || "" @@ -84,10 +57,6 @@ private module Parsers author_id = author_fallback.id end - author_thumbnail = item_contents.dig?("channelThumbnailSupportedRenderers", "channelThumbnailWithLinkRenderer", "thumbnail", "thumbnails", 0, "url").try &.as_s - - author_verified = has_verified_badge?(item_contents["ownerBadges"]?) - # For live videos (and possibly recently premiered videos) there is no published information. # Instead, in its place is the amount of people currently watching. This behavior should be replicated # on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current @@ -100,7 +69,7 @@ private module Parsers # TODO change default value to nil and typical encoding type to tuple storing type (watchers, views, etc) # and count view_count = item_contents.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 - description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t, video_id) } || "" + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" # The length information generally exist in "lengthText". However, the info can sometimes # be retrieved from "thumbnailOverlays" (e.g when the video is a "shorts" one). @@ -128,30 +97,22 @@ private module Parsers length_seconds = 0 end + live_now = false + paid = false + premium = false + premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } - badges = VideoBadges::None + item_contents["badges"]?.try &.as_a.each do |badge| b = badge["metadataBadgeRenderer"] - case b["label"]?.try &.as_s - when "LIVE" - badges |= VideoBadges::LiveNow - when "New" - badges |= VideoBadges::New - when "4K" - badges |= VideoBadges::FourK - when "8K" - badges |= VideoBadges::EightK - when "VR180" - badges |= VideoBadges::VR180 - when "360°" - badges |= VideoBadges::VR360 - when "3D" - badges |= VideoBadges::ThreeD - when "CC" - badges |= VideoBadges::ClosedCaptions + case b["label"].as_s + when "LIVE NOW" + live_now = true + when "New", "4K", "CC" + # TODO when "Premium" # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] - badges |= VideoBadges::Premium + premium = true else nil # Ignore end end @@ -165,10 +126,9 @@ private module Parsers views: view_count, description_html: description_html, length_seconds: length_seconds, + live_now: live_now, + premium: premium, premiere_timestamp: premiere_timestamp, - author_verified: author_verified, - author_thumbnail: author_thumbnail, - badges: badges, }) end @@ -187,37 +147,22 @@ private module Parsers # `channelRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. # module ChannelRendererParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) + def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) return self.parse(item_contents, author_fallback) end end - private def parse_internal(item_contents, author_fallback) + private def self.parse(item_contents, author_fallback) author = extract_text(item_contents["title"]) || author_fallback.name author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id - author_verified = has_verified_badge?(item_contents["ownerBadges"]?) - author_thumbnail = HelperExtractors.get_thumbnails(item_contents) + author_thumbnail = HelperExtractors.get_thumbnails(item_contents) # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. # Always simpleText # TODO change default value to nil - - subscriber_count = item_contents.dig?("subscriberCountText", "simpleText").try &.as_s - channel_handle = subscriber_count if (subscriber_count.try &.starts_with? "@") - - # Since youtube added channel handles, `VideoCountText` holds the number of - # subscribers and `subscriberCountText` holds the handle, except when the - # channel doesn't have a handle (e.g: some topic music channels). - # See https://github.com/iv-org/invidious/issues/3394#issuecomment-1321261688 - if !subscriber_count || !subscriber_count.includes? " subscriber" - subscriber_count = item_contents.dig?("videoCountText", "simpleText").try &.as_s - end - subscriber_count = subscriber_count - .try { |s| short_text_to_number(s.split(" ")[0]).to_i32 } || 0 + subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") + .try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 # Auto-generated channels doesn't have videoCountText # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922 @@ -232,59 +177,8 @@ private module Parsers author_thumbnail: author_thumbnail, subscriber_count: subscriber_count, video_count: video_count, - channel_handle: channel_handle, description_html: description_html, auto_generated: auto_generated, - author_verified: author_verified, - }) - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses an Innertube `hashtagTileRenderer` into a `SearchHashtag`. - # Returns `nil` when the given object is not a `hashtagTileRenderer`. - # - # A `hashtagTileRenderer` is a kind of search result. - # It can be found when searching for any hashtag (e.g "#hi" or "#shorts") - module HashtagRendererParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item["hashtagTileRenderer"]? - return self.parse(item_contents) - end - end - - private def parse_internal(item_contents) - title = extract_text(item_contents["hashtag"]).not_nil! # E.g "#hi" - - # E.g "/hashtag/hi" - url = item_contents.dig?("onTapCommand", "commandMetadata", "webCommandMetadata", "url").try &.as_s - url ||= URI.encode_path("/hashtag/#{title.lchop('#')}") - - video_count_txt = extract_text(item_contents["hashtagVideoCount"]?) # E.g "203K videos" - channel_count_txt = extract_text(item_contents["hashtagChannelCount"]?) # E.g "81K channels" - - # Fallback for video/channel counts - if channel_count_txt.nil? || video_count_txt.nil? - # E.g: "203K videos • 81K channels" - info_text = extract_text(item_contents["hashtagInfoText"]?).try &.split(" • ") - - if info_text && info_text.size == 2 - video_count_txt ||= info_text[0] - channel_count_txt ||= info_text[1] - end - end - - return SearchHashtag.new({ - title: title, - url: url, - video_count: short_text_to_number(video_count_txt || ""), - channel_count: short_text_to_number(channel_count_txt || ""), }) end @@ -303,33 +197,27 @@ private module Parsers # `gridPlaylistRenderer`s can be found on the playlist-tabs of channels and expanded categories. # module GridPlaylistRendererParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) + def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = item["gridPlaylistRenderer"]? return self.parse(item_contents, author_fallback) end end - private def parse_internal(item_contents, author_fallback) + private def self.parse(item_contents, author_fallback) title = extract_text(item_contents["title"]) || "" plid = item_contents["playlistId"]?.try &.as_s || "" - author_verified = has_verified_badge?(item_contents["ownerBadges"]?) - video_count = HelperExtractors.get_video_count(item_contents) playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents) SearchPlaylist.new({ - title: title, - id: plid, - author: author_fallback.name, - ucid: author_fallback.id, - video_count: video_count, - videos: [] of SearchPlaylistVideo, - thumbnail: playlist_thumbnail, - author_verified: author_verified, + title: title, + id: plid, + author: author_fallback.name, + ucid: author_fallback.id, + video_count: video_count, + videos: [] of SearchPlaylistVideo, + thumbnail: playlist_thumbnail, }) end @@ -347,17 +235,14 @@ private module Parsers # `playlistRenderer`s can be found almost everywhere on YouTube. In categories, search results, recommended, etc. # module PlaylistRendererParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) + def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = item["playlistRenderer"]? return self.parse(item_contents, author_fallback) end end - private def parse_internal(item_contents, author_fallback) - title = extract_text(item_contents["title"]) || "" + private def self.parse(item_contents, author_fallback) + title = item_contents["title"]["simpleText"]?.try &.as_s || "" plid = item_contents["playlistId"]?.try &.as_s || "" video_count = HelperExtractors.get_video_count(item_contents) @@ -366,7 +251,6 @@ private module Parsers author_info = item_contents.dig?("shortBylineText", "runs", 0) author = author_info.try &.["text"].as_s || author_fallback.name author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id - author_verified = has_verified_badge?(item_contents["ownerBadges"]?) videos = item_contents["videos"]?.try &.as_a.map do |v| v = v["childVideoRenderer"] @@ -383,14 +267,13 @@ private module Parsers # TODO: item_contents["publishedTimeText"]? SearchPlaylist.new({ - title: title, - id: plid, - author: author, - ucid: author_id, - video_count: video_count, - videos: videos, - thumbnail: playlist_thumbnail, - author_verified: author_verified, + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail, }) end @@ -410,16 +293,13 @@ private module Parsers # `shelfRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. # module CategoryRendererParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) + def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = item["shelfRenderer"]? return self.parse(item_contents, author_fallback) end end - private def parse_internal(item_contents, author_fallback) + private def self.parse(item_contents, author_fallback) title = extract_text(item_contents["title"]?) || "" url = item_contents.dig?("endpoint", "commandMetadata", "webCommandMetadata", "url") .try &.as_s @@ -451,9 +331,14 @@ private module Parsers content_container = item_contents["contents"] end - content_container["items"]?.try &.as_a.each do |item| - result = parse_item(item, author_fallback.name, author_fallback.id) - contents << result if result.is_a?(SearchItem) + raw_contents = content_container["items"]?.try &.as_a + if !raw_contents.nil? + raw_contents.each do |item| + result = extract_item(item) + if !result.nil? + contents << result + end + end end Category.new({ @@ -469,330 +354,6 @@ private module Parsers return {{@type.name}} end end - - # Parses an InnerTube itemSectionRenderer into a SearchVideo. - # Returns nil when the given object isn't a ItemSectionRenderer - # - # A itemSectionRenderer seems to be a simple wrapper for a videoRenderer or a playlistRenderer, used - # by the result page for channel searches. It is located inside a continuationItems - # container.It is very similar to RichItemRendererParser - # - module ItemSectionRendererParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item.dig?("itemSectionRenderer", "contents", 0) - return self.parse(item_contents, author_fallback) - end - end - - private def parse_internal(item_contents, author_fallback) - child = VideoRendererParser.process(item_contents, author_fallback) - child ||= PlaylistRendererParser.process(item_contents, author_fallback) - - return child - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses an InnerTube richItemRenderer into a SearchVideo. - # Returns nil when the given object isn't a RichItemRenderer - # - # A richItemRenderer seems to be a simple wrapper for a various other types, - # used on the hashtags result page and the channel podcast tab. It is located - # itself inside a richGridRenderer container. - # - module RichItemRendererParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item.dig?("richItemRenderer", "content") - return self.parse(item_contents, author_fallback) - end - end - - private def parse_internal(item_contents, author_fallback) - child = VideoRendererParser.process(item_contents, author_fallback) - child ||= ReelItemRendererParser.process(item_contents, author_fallback) - child ||= PlaylistRendererParser.process(item_contents, author_fallback) - child ||= LockupViewModelParser.process(item_contents, author_fallback) - child ||= ShortsLockupViewModelParser.process(item_contents, author_fallback) - return child - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses an InnerTube reelItemRenderer into a SearchVideo. - # Returns nil when the given object isn't a reelItemRenderer - # - # reelItemRenderer items are used in the new (2022) channel layout, - # in the "shorts" tab. - # - # NOTE: As of 10/2024, it might have been fully replaced by shortsLockupViewModel - # TODO: Confirm that hypothesis - # - module ReelItemRendererParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item["reelItemRenderer"]? - return self.parse(item_contents, author_fallback) - end - end - - private def parse_internal(item_contents, author_fallback) - video_id = item_contents["videoId"].as_s - - reel_player_overlay = item_contents.dig( - "navigationEndpoint", "reelWatchEndpoint", - "overlay", "reelPlayerOverlayRenderer" - ) - - if video_details_container = reel_player_overlay.dig?( - "reelPlayerHeaderSupportedRenderers", - "reelPlayerHeaderRenderer" - ) - # Author infos - - author = video_details_container - .dig?("channelTitleText", "runs", 0, "text") - .try &.as_s || author_fallback.name - - ucid = video_details_container - .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId") - .try &.as_s || author_fallback.id - - # Title & publication date - - title = video_details_container.dig?("reelTitleText") - .try { |t| extract_text(t) } || "" - - published = video_details_container - .dig?("timestampText", "simpleText") - .try { |t| decode_date(t.as_s) } || Time.utc - - # View count - view_count_text = video_details_container.dig?("viewCountText", "simpleText") - else - author = author_fallback.name - ucid = author_fallback.id - published = Time.utc - title = item_contents.dig?("headline", "simpleText").try &.as_s || "" - end - # View count - - # View count used to be in the reelWatchEndpoint, but that changed? - view_count_text ||= item_contents.dig?("viewCountText", "simpleText") - - view_count = short_text_to_number(view_count_text.try &.as_s || "0") - - # Duration - - a11y_data = item_contents - .dig?("accessibility", "accessibilityData", "label") - .try &.as_s || "" - - regex_match = /- (?\d+ minutes? )?(?\d+ seconds?)+ -/.match(a11y_data) - - minutes = regex_match.try &.["min"]?.try &.to_i(strict: false) || 0 - seconds = regex_match.try &.["sec"]?.try &.to_i(strict: false) || 0 - - duration = (minutes*60 + seconds) - - SearchVideo.new({ - title: title, - id: video_id, - author: author, - ucid: ucid, - published: published, - views: view_count, - description_html: "", - length_seconds: duration, - premiere_timestamp: Time.unix(0), - author_verified: false, - author_thumbnail: nil, - badges: VideoBadges::None, - }) - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses an InnerTube lockupViewModel into a SearchPlaylist. - # Returns nil when the given object is not a lockupViewModel. - # - # This structure is present since November 2024 on the "podcasts" and - # "playlists" tabs of the channel page. It is usually encapsulated in either - # a richItemRenderer or a richGridRenderer. - # - module LockupViewModelParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item["lockupViewModel"]? - return self.parse(item_contents, author_fallback) - end - end - - private def parse_internal(item_contents, author_fallback) - playlist_id = item_contents["contentId"].as_s - - thumbnail_view_model = item_contents.dig( - "contentImage", "collectionThumbnailViewModel", - "primaryThumbnail", "thumbnailViewModel" - ) - - thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s - - # This complicated sequences tries to extract the following data structure: - # "overlays": [{ - # "thumbnailOverlayBadgeViewModel": { - # "thumbnailBadges": [{ - # "thumbnailBadgeViewModel": { - # "text": "430 episodes", - # "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT" - # } - # }] - # } - # }] - # - # NOTE: this simplistic `.to_i` conversion might not work on larger - # playlists and hasn't been tested. - video_count = thumbnail_view_model.dig("overlays").as_a - .compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a) - .flatten - .find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node| - {"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) } - }) - .try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false) - - metadata = item_contents.dig("metadata", "lockupMetadataViewModel") - title = metadata.dig("title", "content").as_s - - # TODO: Retrieve "updated" info from metadata parts - # rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a - # parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s) - # One of these parts should contain a string like: "Updated 2 days ago" - - # TODO: Maybe add a button to access the first video of the playlist? - # item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint") - # Available fields: "videoId", "playlistId", "params" - - return SearchPlaylist.new({ - title: title, - id: playlist_id, - author: author_fallback.name, - ucid: author_fallback.id, - video_count: video_count || -1, - videos: [] of SearchPlaylistVideo, - thumbnail: thumbnail, - author_verified: false, - }) - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses an InnerTube shortsLockupViewModel into a SearchVideo. - # Returns nil when the given object is not a shortsLockupViewModel. - # - # This structure is present since around October 2024 on the "shorts" tab of - # the channel page and likely replaces the reelItemRenderer structure. It is - # usually (always?) encapsulated in a richItemRenderer. - # - module ShortsLockupViewModelParser - extend self - include BaseParser - - def process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item["shortsLockupViewModel"]? - return self.parse(item_contents, author_fallback) - end - end - - private def parse_internal(item_contents, author_fallback) - # TODO: Maybe add support for "oardefault.jpg" thumbnails? - # thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s - # Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?... - - video_id = item_contents.dig( - "onTap", "innertubeCommand", "reelWatchEndpoint", "videoId" - ).as_s - - title = item_contents.dig("overlayMetadata", "primaryText", "content").as_s - - view_count = short_text_to_number( - item_contents.dig("overlayMetadata", "secondaryText", "content").as_s - ) - - # Approximate to one minute, as "shorts" generally don't exceed that. - # NOTE: The actual duration is not provided by Youtube anymore. - # TODO: Maybe use -1 as an error value and handle that on the frontend? - duration = 60_i32 - - SearchVideo.new({ - title: title, - id: video_id, - author: author_fallback.name, - ucid: author_fallback.id, - published: Time.unix(0), - views: view_count, - description_html: "", - length_seconds: duration, - premiere_timestamp: Time.unix(0), - author_verified: false, - author_thumbnail: nil, - badges: VideoBadges::None, - }) - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses an InnerTube continuationItemRenderer into a Continuation. - # Returns nil when the given object isn't a continuationItemRenderer. - # - # continuationItemRenderer contains various metadata ued to load more - # content (i.e when the user scrolls down). The interesting bit is the - # protobuf object known as the "continutation token". Previously, those - # were generated from sratch, but recent (as of 11/2022) Youtube changes - # are forcing us to extract them from replies. - # - module ContinuationItemRendererParser - def self.process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item["continuationItemRenderer"]? - return self.parse(item_contents) - end - end - - private def self.parse(item_contents) - token = item_contents - .dig?("continuationEndpoint", "continuationCommand", "token") - .try &.as_s - - return Continuation.new(token) if token - end - - def self.parser_name - return {{@type.name}} - end - end end # The following are the extractors for extracting an array of items from @@ -820,7 +381,7 @@ private module Extractors # {"tabRenderer": { # "endpoint": {...} # "title": "Playlists", - # "selected": true, # Is nil unless tab is selected + # "selected": true, # "content": {...}, # ... # }} @@ -828,7 +389,7 @@ private module Extractors # }] # module YouTubeTabs - def self.process(initial_data : InitialData) + def self.process(initial_data : Hash(String, JSON::Any)) if target = initial_data["twoColumnBrowseResultsRenderer"]? self.extract(target) end @@ -838,37 +399,19 @@ private module Extractors raw_items = [] of JSON::Any content = extract_selected_tab(target["tabs"])["content"] - if section_list_contents = content.dig?("sectionListRenderer", "contents") - raw_items = unpack_section_list(section_list_contents) - elsif rich_grid_contents = content.dig?("richGridRenderer", "contents") - raw_items = rich_grid_contents.as_a - end + content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| + renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] - return raw_items - end - - private def self.unpack_section_list(contents) - raw_items = [] of JSON::Any - - contents.as_a.each do |item| - if item_section_content = item.dig?("itemSectionRenderer", "contents") - raw_items += self.unpack_item_section(item_section_content) - else - raw_items << item - end - end - - return raw_items - end - - private def self.unpack_item_section(contents) - raw_items = [] of JSON::Any - - contents.as_a.each do |item| # Category extraction - if container = item.dig?("gridRenderer", "items") || item.dig?("items") - raw_items += container.as_a + if items_container = renderer_container_contents["shelfRenderer"]? + raw_items << renderer_container_contents + next + elsif items_container = renderer_container_contents["gridRenderer"]? else + items_container = renderer_container_contents + end + + items_container["items"]?.try &.as_a.each do |item| raw_items << item end end @@ -899,7 +442,7 @@ private module Extractors # } # module SearchResults - def self.process(initial_data : InitialData) + def self.process(initial_data : Hash(String, JSON::Any)) if target = initial_data["twoColumnSearchResultsRenderer"]? self.extract(target) end @@ -932,23 +475,24 @@ private module Extractors # The way they are structured is too varied to be accurately written down here. # However, they all eventually lead to an array of parsable items after traversing # through the JSON structure. - module ContinuationContent - def self.process(initial_data : InitialData) + module Continuation + def self.process(initial_data : Hash(String, JSON::Any)) if target = initial_data["continuationContents"]? self.extract(target) elsif target = initial_data["appendContinuationItemsAction"]? self.extract(target) - elsif target = initial_data["reloadContinuationItemsCommand"]? - self.extract(target) end end private def self.extract(target) - content = target["continuationItems"]? - content ||= target.dig?("gridContinuation", "items") - content ||= target.dig?("richGridContinuation", "contents") + raw_items = [] of JSON::Any + if content = target["gridContinuation"]? + raw_items = content["items"].as_a + elsif content = target["continuationItems"]? + raw_items = content.as_a + end - return content.nil? ? [] of JSON::Any : content.as_a + return raw_items end def self.extractor_name @@ -967,11 +511,7 @@ module HelperExtractors # Returns a 0 when it's unable to do so def self.get_video_count(container : JSON::Any) : Int32 if box = container["videoCountText"]? - if (extracted_text = extract_text(box)) && !extracted_text.includes? " subscriber" - return extracted_text.gsub(/\D/, "").to_i - else - return 0 - end + return extract_text(box).try &.gsub(/\D/, "").to_i || 0 elsif box = container["videoCount"]? return box.as_s.to_i else @@ -1011,15 +551,16 @@ module HelperExtractors end # Retrieves the ID required for querying the InnerTube browse endpoint. - # Returns an empty string when it's unable to do so + # Raises when it's unable to do so def self.get_browse_id(container) - return container.dig?("navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || "" + return container.dig("navigationEndpoint", "browseEndpoint", "browseId").as_s end end # Parses an item from Youtube's JSON response into a more usable structure. # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. -def parse_item(item : JSON::Any, author_fallback : String? = "", author_id_fallback : String? = "") +def extract_item(item : JSON::Any, author_fallback : String? = "", + author_id_fallback : String? = "") # We "allow" nil values but secretly use empty strings instead. This is to save us the # hassle of modifying every author_fallback and author_id_fallback arg usage # which is more often than not nil. @@ -1029,62 +570,49 @@ def parse_item(item : JSON::Any, author_fallback : String? = "", author_id_fallb # Each parser automatically validates the data given to see if the data is # applicable to itself. If not nil is returned and the next parser is attempted. ITEM_PARSERS.each do |parser| - LOGGER.trace("parse_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") + LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") if result = parser.process(item, author_fallback) - LOGGER.debug("parse_item: Successfully parsed via #{parser.parser_name}") + LOGGER.debug("extract_item: Successfully parsed via #{parser.parser_name}") + return result else - LOGGER.trace("parse_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") + LOGGER.trace("extract_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") end end end # Parses multiple items from YouTube's initial JSON response into a more usable structure. # The end result is an array of SearchItem. -# -# This function yields the container so that items can be parsed separately. -# -def extract_items(initial_data : InitialData, &) +def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, + author_id_fallback : String? = nil) : Array(SearchItem) + items = [] of SearchItem + if unpackaged_data = initial_data["contents"]?.try &.as_h elsif unpackaged_data = initial_data["response"]?.try &.as_h - elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 1).try &.as_h elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h else unpackaged_data = initial_data end - # This is identical to the parser cycling of parse_item(). + # This is identical to the parser cycling of extract_item(). ITEM_CONTAINER_EXTRACTOR.each do |extractor| LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)") if container = extractor.process(unpackaged_data) LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"") # Extract items in container - container.each { |item| yield item } + container.each do |item| + if parsed_result = extract_item(item, author_fallback, author_id_fallback) + items << parsed_result + end + end + + break else LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...") end end -end - -# Wrapper using the block function above -def extract_items( - initial_data : InitialData, - author_fallback : String? = nil, - author_id_fallback : String? = nil, -) : {Array(SearchItem), String?} - items = [] of SearchItem - continuation = nil - - extract_items(initial_data) do |item| - parsed = parse_item(item, author_fallback, author_id_fallback) - - case parsed - when .is_a?(Continuation) then continuation = parsed.token - when .is_a?(SearchItem) then items << parsed - end - end - - return items, continuation + + return items end diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index c83a2de5..add5f488 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -29,59 +29,39 @@ def extract_text(item : JSON::Any?) : String? end end -# Check if an "ownerBadges" or a "badges" element contains a verified badge. -# There is currently two known types of verified badges: -# -# "ownerBadges": [{ -# "metadataBadgeRenderer": { -# "icon": { "iconType": "CHECK_CIRCLE_THICK" }, -# "style": "BADGE_STYLE_TYPE_VERIFIED", -# "tooltip": "Verified", -# "accessibilityData": { "label": "Verified" } -# } -# }], -# -# "ownerBadges": [{ -# "metadataBadgeRenderer": { -# "icon": { "iconType": "OFFICIAL_ARTIST_BADGE" }, -# "style": "BADGE_STYLE_TYPE_VERIFIED_ARTIST", -# "tooltip": "Official Artist Channel", -# "accessibilityData": { "label": "Official Artist Channel" } -# } -# }], -# -def has_verified_badge?(badges : JSON::Any?) - return false if badges.nil? +def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) + extracted = extract_items(initial_data, author_fallback, author_id_fallback) - badges.as_a.each do |badge| - style = badge.dig("metadataBadgeRenderer", "style").as_s - - return true if style == "BADGE_STYLE_TYPE_VERIFIED" - return true if style == "BADGE_STYLE_TYPE_VERIFIED_ARTIST" - end - - return false -rescue ex - LOGGER.debug("Unable to parse owner badges. Got exception: #{ex.message}") - LOGGER.trace("Owner badges data: #{badges.to_json}") - - return false -end - -# This function extracts SearchVideo items from a Category. -# Categories are commonly returned in search results and trending pages. -def extract_category(category : Category) : Array(SearchVideo) - return category.contents.select(SearchVideo) -end - -# :ditto: -def extract_category(category : Category, &) - category.contents.select(SearchVideo).each do |item| - yield item + target = [] of SearchItem + extracted.each do |i| + if i.is_a?(Category) + i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } + else + target << i + end end + return target.select(SearchVideo).map(&.as(SearchVideo)) end def extract_selected_tab(tabs) # Extract the selected tab from the array of tabs Youtube returns - return tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"] + return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"] +end + +def fetch_continuation_token(items : Array(JSON::Any)) + # Fetches the continuation token from an array of items + return items.last["continuationItemRenderer"]? + .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s +end + +def fetch_continuation_token(initial_data : Hash(String, JSON::Any)) + # Fetches the continuation token from initial data + if initial_data["onResponseReceivedActions"]? + continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"] + else + tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]) + continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"] + end + + return fetch_continuation_token(continuation_items.as_a) end diff --git a/src/invidious/yt_backend/proxy.cr b/src/invidious/yt_backend/proxy.cr new file mode 100644 index 00000000..2d0fd4ba --- /dev/null +++ b/src/invidious/yt_backend/proxy.cr @@ -0,0 +1,316 @@ +# See https://github.com/crystal-lang/crystal/issues/2963 +class HTTPProxy + getter proxy_host : String + getter proxy_port : Int32 + getter options : Hash(Symbol, String) + getter tls : OpenSSL::SSL::Context::Client? + + def initialize(@proxy_host, @proxy_port = 80, @options = {} of Symbol => String) + end + + def open(host, port, tls = nil, connection_options = {} of Symbol => Float64 | Nil) + dns_timeout = connection_options.fetch(:dns_timeout, nil) + connect_timeout = connection_options.fetch(:connect_timeout, nil) + read_timeout = connection_options.fetch(:read_timeout, nil) + + socket = TCPSocket.new @proxy_host, @proxy_port, dns_timeout, connect_timeout + socket.read_timeout = read_timeout if read_timeout + socket.sync = true + + socket << "CONNECT #{host}:#{port} HTTP/1.1\r\n" + + if options[:user]? + credentials = Base64.strict_encode("#{options[:user]}:#{options[:password]}") + credentials = "#{credentials}\n".gsub(/\s/, "") + socket << "Proxy-Authorization: Basic #{credentials}\r\n" + end + + socket << "\r\n" + + resp = parse_response(socket) + + if resp[:code]? == 200 + {% if !flag?(:without_openssl) %} + if tls + tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host) + socket = tls_socket + end + {% end %} + + return socket + else + socket.close + raise IO::Error.new(resp.inspect) + end + end + + private def parse_response(socket) + resp = {} of Symbol => Int32 | String | Hash(String, String) + + begin + version, code, reason = socket.gets.as(String).chomp.split(/ /, 3) + + headers = {} of String => String + + while (line = socket.gets.as(String)) && (line.chomp != "") + name, value = line.split(/:/, 2) + headers[name.strip] = value.strip + end + + resp[:version] = version + resp[:code] = code.to_i + resp[:reason] = reason + resp[:headers] = headers + rescue + end + + return resp + end +end + +class HTTPClient < HTTP::Client + def set_proxy(proxy : HTTPProxy) + begin + @io = proxy.open(host: @host, port: @port, tls: @tls, connection_options: proxy_connection_options) + rescue IO::Error + @io = nil + end + end + + def unset_proxy + @io = nil + end + + def proxy_connection_options + opts = {} of Symbol => Float64 | Nil + + opts[:dns_timeout] = @dns_timeout + opts[:connect_timeout] = @connect_timeout + opts[:read_timeout] = @read_timeout + + return opts + end +end + +def get_proxies(country_code = "US") + # return get_spys_proxies(country_code) + return get_nova_proxies(country_code) +end + +def filter_proxies(proxies) + proxies.select! do |proxy| + begin + client = HTTPClient.new(YT_URL) + client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + client.read_timeout = 10.seconds + client.connect_timeout = 10.seconds + + proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) + client.set_proxy(proxy) + + status_ok = client.head("/").status_code == 200 + client.close + status_ok + rescue ex + false + end + end + + return proxies +end + +def get_nova_proxies(country_code = "US") + country_code = country_code.downcase + client = HTTP::Client.new(URI.parse("https://www.proxynova.com")) + client.read_timeout = 10.seconds + client.connect_timeout = 10.seconds + + headers = HTTP::Headers.new + headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" + headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" + headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" + headers["Host"] = "www.proxynova.com" + headers["Origin"] = "https://www.proxynova.com" + headers["Referer"] = "https://www.proxynova.com/proxy-server-list/country-#{country_code}/" + + response = client.get("/proxy-server-list/country-#{country_code}/", headers) + client.close + document = XML.parse_html(response.body) + + proxies = [] of {ip: String, port: Int32, score: Float64} + document.xpath_nodes(%q(//tr[@data-proxy-id])).each do |node| + ip = node.xpath_node(%q(.//td/abbr/script)).not_nil!.content + ip = ip.match(/document\.write\('(?[^']+)'.substr\(8\) \+ '(?[^']+)'/).not_nil! + ip = "#{ip["sub1"][8..-1]}#{ip["sub2"]}" + port = node.xpath_node(%q(.//td[2])).not_nil!.content.strip.to_i + + anchor = node.xpath_node(%q(.//td[4]/div)).not_nil! + speed = anchor["data-value"].to_f + latency = anchor["title"].to_f + uptime = node.xpath_node(%q(.//td[5]/span)).not_nil!.content.rchop("%").to_f + + # TODO: Tweak me + score = (uptime*4 + speed*2 + latency)/7 + proxies << {ip: ip, port: port, score: score} + end + + # proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse + return proxies +end + +def get_spys_proxies(country_code = "US") + client = HTTP::Client.new(URI.parse("http://spys.one")) + client.read_timeout = 10.seconds + client.connect_timeout = 10.seconds + + headers = HTTP::Headers.new + headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" + headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" + headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" + headers["Host"] = "spys.one" + headers["Origin"] = "http://spys.one" + headers["Referer"] = "http://spys.one/free-proxy-list/#{country_code}/" + headers["Content-Type"] = "application/x-www-form-urlencoded" + body = { + "xpp" => "5", + "xf1" => "0", + "xf2" => "0", + "xf4" => "0", + "xf5" => "1", + } + + response = client.post("/free-proxy-list/#{country_code}/", headers, form: body) + client.close + 20.times do + if response.status_code == 200 + break + end + response = client.post("/free-proxy-list/#{country_code}/", headers, form: body) + end + + response = XML.parse_html(response.body) + + mapping = response.xpath_node(%q(.//body/script)).not_nil!.content + mapping = mapping.match(/\}\('(?

    [^']+)',\d+,\d+,'(?[^']+)'/).not_nil! + p = mapping["p"].not_nil! + x = mapping["x"].not_nil! + mapping = decrypt_port(p, x) + + proxies = [] of {ip: String, port: Int32, score: Float64} + response = response.xpath_node(%q(//tr/td/table)).not_nil! + response.xpath_nodes(%q(.//tr)).each do |node| + if !node["onmouseover"]? + next + end + + ip = node.xpath_node(%q(.//td[1]/font[2])).to_s.match(/(?

    [^<]+)"\+(?[\d\D]+)\)$/).not_nil!["encrypted_port"] + + port = "" + encrypted_port.split("+").each do |number| + number = number.delete("()") + left_side, right_side = number.split("^") + result = mapping[left_side] ^ mapping[right_side] + port = "#{port}#{result}" + end + port = port.to_i + + latency = node.xpath_node(%q(.//td[6])).not_nil!.content.to_f + speed = node.xpath_node(%q(.//td[7]/font/table)).not_nil!["width"].to_f + uptime = node.xpath_node(%q(.//td[8]/font/acronym)).not_nil! + + # Skip proxies that are down + if uptime["title"].ends_with? "?" + next + end + + if md = uptime.content.match(/^\d+/) + uptime = md[0].to_f + else + next + end + + score = (uptime*4 + speed*2 + latency)/7 + + proxies << {ip: ip, port: port, score: score} + end + + proxies = proxies.sort_by!(&.[:score]).reverse! + return proxies +end + +def decrypt_port(p, x) + x = x.split("^") + s = {} of String => String + + 60.times do |i| + if x[i]?.try &.empty? + s[y_func(i)] = y_func(i) + else + s[y_func(i)] = x[i] + end + end + + x = s + p = p.gsub(/\b\w+\b/, x) + + p = p.split(";") + p = p.map(&.split("=")) + + mapping = {} of String => Int32 + p.each do |item| + if item == [""] + next + end + + key = item[0] + value = item[1] + value = value.split("^") + + if value.size == 1 + value = value[0].to_i + else + left_side = value[0].to_i? + left_side ||= mapping[value[0]] + right_side = value[1].to_i? + right_side ||= mapping[value[1]] + + value = left_side ^ right_side + end + + mapping[key] = value + end + + return mapping +end + +def y_func(c) + return (c < 60 ? "" : y_func((c/60).to_i)) + ((c = c % 60) > 35 ? ((c.to_u8 + 29).unsafe_chr) : c.to_s(36)) +end + +PROXY_LIST = { + "GB" => [{ip: "147.135.206.233", port: 3128}, {ip: "167.114.180.102", port: 8080}, {ip: "176.35.250.108", port: 8080}, {ip: "5.148.128.44", port: 80}, {ip: "62.7.85.234", port: 8080}, {ip: "88.150.135.10", port: 36624}], + "DE" => [{ip: "138.201.223.250", port: 31288}, {ip: "138.68.73.59", port: 32574}, {ip: "159.69.211.173", port: 3128}, {ip: "173.249.43.105", port: 3128}, {ip: "212.202.244.90", port: 8080}, {ip: "5.56.18.35", port: 38827}], + "FR" => [{ip: "137.74.254.242", port: 3128}, {ip: "151.80.143.155", port: 53281}, {ip: "178.33.150.97", port: 3128}, {ip: "37.187.2.31", port: 3128}, {ip: "5.135.164.72", port: 3128}, {ip: "5.39.91.73", port: 3128}, {ip: "51.38.162.2", port: 32231}, {ip: "51.38.217.121", port: 808}, {ip: "51.75.109.81", port: 3128}, {ip: "51.75.109.82", port: 3128}, {ip: "51.75.109.83", port: 3128}, {ip: "51.75.109.84", port: 3128}, {ip: "51.75.109.86", port: 3128}, {ip: "51.75.109.88", port: 3128}, {ip: "51.75.109.90", port: 3128}, {ip: "62.210.167.3", port: 3128}, {ip: "90.63.218.232", port: 8080}, {ip: "91.134.165.198", port: 9999}], + "IN" => [{ip: "1.186.151.206", port: 36253}, {ip: "1.186.63.130", port: 39142}, {ip: "103.105.40.1", port: 16538}, {ip: "103.105.40.153", port: 16538}, {ip: "103.106.148.203", port: 60227}, {ip: "103.106.148.207", port: 51451}, {ip: "103.12.246.12", port: 8080}, {ip: "103.14.235.109", port: 8080}, {ip: "103.14.235.26", port: 8080}, {ip: "103.198.172.4", port: 50820}, {ip: "103.205.112.1", port: 23500}, {ip: "103.209.64.19", port: 6666}, {ip: "103.211.76.5", port: 8080}, {ip: "103.216.82.19", port: 6666}, {ip: "103.216.82.190", port: 6666}, {ip: "103.216.82.209", port: 54806}, {ip: "103.216.82.214", port: 6666}, {ip: "103.216.82.37", port: 6666}, {ip: "103.216.82.44", port: 8080}, {ip: "103.216.82.50", port: 53281}, {ip: "103.22.173.230", port: 8080}, {ip: "103.224.38.2", port: 83}, {ip: "103.226.142.90", port: 41386}, {ip: "103.236.114.38", port: 49638}, {ip: "103.240.161.107", port: 6666}, {ip: "103.240.161.108", port: 6666}, {ip: "103.240.161.109", port: 6666}, {ip: "103.240.161.59", port: 48809}, {ip: "103.245.198.101", port: 8080}, {ip: "103.250.148.82", port: 6666}, {ip: "103.251.58.51", port: 61489}, {ip: "103.253.169.115", port: 32731}, {ip: "103.253.211.182", port: 8080}, {ip: "103.253.211.182", port: 80}, {ip: "103.255.234.169", port: 39847}, {ip: "103.42.161.118", port: 8080}, {ip: "103.42.162.30", port: 8080}, {ip: "103.42.162.50", port: 8080}, {ip: "103.42.162.58", port: 8080}, {ip: "103.46.233.12", port: 83}, {ip: "103.46.233.13", port: 83}, {ip: "103.46.233.16", port: 83}, {ip: "103.46.233.17", port: 83}, {ip: "103.46.233.21", port: 83}, {ip: "103.46.233.23", port: 83}, {ip: "103.46.233.29", port: 81}, {ip: "103.46.233.29", port: 83}, {ip: "103.46.233.50", port: 83}, {ip: "103.47.153.87", port: 8080}, {ip: "103.47.66.2", port: 39804}, {ip: "103.49.53.1", port: 81}, {ip: "103.52.220.1", port: 49068}, {ip: "103.56.228.166", port: 53281}, {ip: "103.56.30.128", port: 8080}, {ip: "103.65.193.17", port: 50862}, {ip: "103.65.195.1", port: 33960}, {ip: "103.69.220.14", port: 3128}, {ip: "103.70.128.84", port: 8080}, {ip: "103.70.128.86", port: 8080}, {ip: "103.70.131.74", port: 8080}, {ip: "103.70.146.250", port: 59563}, {ip: "103.72.216.194", port: 38345}, {ip: "103.75.161.38", port: 21776}, {ip: "103.76.253.155", port: 3128}, {ip: "103.87.104.137", port: 8080}, {ip: "110.235.198.3", port: 57660}, {ip: "114.69.229.161", port: 8080}, {ip: "117.196.231.201", port: 37769}, {ip: "117.211.166.214", port: 3128}, {ip: "117.240.175.51", port: 3128}, {ip: "117.240.210.155", port: 53281}, {ip: "117.240.59.115", port: 36127}, {ip: "117.242.154.73", port: 33889}, {ip: "117.244.15.243", port: 3128}, {ip: "119.235.54.3", port: 8080}, {ip: "120.138.117.102", port: 59308}, {ip: "123.108.200.185", port: 83}, {ip: "123.108.200.217", port: 82}, {ip: "123.176.43.218", port: 40524}, {ip: "125.21.43.82", port: 8080}, {ip: "125.62.192.225", port: 82}, {ip: "125.62.192.33", port: 84}, {ip: "125.62.194.1", port: 83}, {ip: "125.62.213.134", port: 82}, {ip: "125.62.213.18", port: 83}, {ip: "125.62.213.201", port: 84}, {ip: "125.62.213.242", port: 83}, {ip: "125.62.214.185", port: 84}, {ip: "139.5.26.27", port: 53281}, {ip: "14.102.67.101", port: 30337}, {ip: "14.142.122.134", port: 8080}, {ip: "150.129.114.194", port: 6666}, {ip: "150.129.151.62", port: 6666}, {ip: "150.129.171.115", port: 6666}, {ip: "150.129.201.30", port: 6666}, {ip: "157.119.207.38", port: 53281}, {ip: "175.100.185.151", port: 53281}, {ip: "182.18.177.114", port: 56173}, {ip: "182.73.194.170", port: 8080}, {ip: "182.74.85.230", port: 51214}, {ip: "183.82.116.56", port: 8080}, {ip: "183.82.32.56", port: 49551}, {ip: "183.87.14.229", port: 53281}, {ip: "183.87.14.250", port: 44915}, {ip: "202.134.160.168", port: 8080}, {ip: "202.134.166.1", port: 8080}, {ip: "202.134.180.50", port: 8080}, {ip: "202.62.84.210", port: 53281}, {ip: "203.192.193.225", port: 8080}, {ip: "203.192.195.14", port: 31062}, {ip: "203.192.217.11", port: 8080}, {ip: "223.196.83.182", port: 53281}, {ip: "27.116.20.169", port: 36630}, {ip: "27.116.20.209", port: 36630}, {ip: "27.116.51.21", port: 36033}, {ip: "43.224.8.114", port: 50333}, {ip: "43.224.8.116", port: 6666}, {ip: "43.224.8.124", port: 6666}, {ip: "43.224.8.86", port: 6666}, {ip: "43.225.20.73", port: 8080}, {ip: "43.225.23.26", port: 8080}, {ip: "43.230.196.98", port: 36569}, {ip: "43.240.5.225", port: 31777}, {ip: "43.241.28.248", port: 8080}, {ip: "43.242.209.201", port: 8080}, {ip: "43.246.139.82", port: 8080}, {ip: "43.248.73.86", port: 53281}, {ip: "43.251.170.145", port: 54059}, {ip: "45.112.57.230", port: 61222}, {ip: "45.115.171.30", port: 47949}, {ip: "45.121.29.254", port: 54858}, {ip: "45.123.26.146", port: 53281}, {ip: "45.125.61.193", port: 32804}, {ip: "45.125.61.209", port: 32804}, {ip: "45.127.121.194", port: 53281}, {ip: "45.250.226.14", port: 3128}, {ip: "45.250.226.38", port: 8080}, {ip: "45.250.226.47", port: 8080}, {ip: "45.250.226.55", port: 8080}, {ip: "49.249.251.86", port: 53281}], + "CN" => [{ip: "182.61.170.45", port: 3128}], + "RU" => [{ip: "109.106.139.225", port: 45689}, {ip: "109.161.48.228", port: 53281}, {ip: "109.167.224.198", port: 51919}, {ip: "109.172.57.250", port: 23500}, {ip: "109.194.2.126", port: 61822}, {ip: "109.195.150.128", port: 37564}, {ip: "109.201.96.171", port: 31773}, {ip: "109.201.97.204", port: 41258}, {ip: "109.201.97.235", port: 39125}, {ip: "109.206.140.74", port: 45991}, {ip: "109.206.148.31", port: 30797}, {ip: "109.69.75.5", port: 46347}, {ip: "109.71.181.170", port: 53983}, {ip: "109.74.132.190", port: 42663}, {ip: "109.74.143.45", port: 36529}, {ip: "109.75.140.158", port: 59916}, {ip: "109.95.84.114", port: 52125}, {ip: "130.255.12.24", port: 31004}, {ip: "134.19.147.72", port: 44812}, {ip: "134.90.181.7", port: 54353}, {ip: "145.255.6.171", port: 31252}, {ip: "146.120.227.3", port: 8080}, {ip: "149.255.112.194", port: 48968}, {ip: "158.46.127.222", port: 52574}, {ip: "158.46.43.144", port: 39120}, {ip: "158.58.130.185", port: 50016}, {ip: "158.58.132.12", port: 56962}, {ip: "158.58.133.106", port: 41258}, {ip: "158.58.133.13", port: 21213}, {ip: "176.101.0.47", port: 34471}, {ip: "176.101.89.226", port: 33470}, {ip: "176.106.12.65", port: 30120}, {ip: "176.107.80.110", port: 58901}, {ip: "176.110.121.9", port: 46322}, {ip: "176.110.121.90", port: 21776}, {ip: "176.111.97.18", port: 8080}, {ip: "176.112.106.230", port: 33996}, {ip: "176.112.110.40", port: 61142}, {ip: "176.113.116.70", port: 55589}, {ip: "176.113.27.192", port: 47337}, {ip: "176.115.197.118", port: 8080}, {ip: "176.117.255.182", port: 53100}, {ip: "176.120.200.69", port: 44331}, {ip: "176.124.123.93", port: 41258}, {ip: "176.192.124.98", port: 60787}, {ip: "176.192.5.238", port: 61227}, {ip: "176.192.8.206", port: 39422}, {ip: "176.193.15.94", port: 8080}, {ip: "176.196.195.170", port: 48129}, {ip: "176.196.198.154", port: 35252}, {ip: "176.196.238.234", port: 44648}, {ip: "176.196.239.46", port: 35656}, {ip: "176.196.246.6", port: 53281}, {ip: "176.196.84.138", port: 51336}, {ip: "176.197.145.246", port: 32649}, {ip: "176.197.99.142", port: 47278}, {ip: "176.215.1.108", port: 60339}, {ip: "176.215.170.147", port: 35604}, {ip: "176.56.23.14", port: 35340}, {ip: "176.62.185.54", port: 53883}, {ip: "176.74.13.110", port: 8080}, {ip: "178.130.29.226", port: 53295}, {ip: "178.170.254.178", port: 46788}, {ip: "178.213.13.136", port: 53281}, {ip: "178.218.104.8", port: 49707}, {ip: "178.219.183.163", port: 8080}, {ip: "178.237.180.34", port: 57307}, {ip: "178.57.101.212", port: 38020}, {ip: "178.57.101.235", port: 31309}, {ip: "178.64.190.133", port: 46688}, {ip: "178.75.1.111", port: 50411}, {ip: "178.75.27.131", port: 41879}, {ip: "185.13.35.178", port: 40654}, {ip: "185.15.189.67", port: 30215}, {ip: "185.175.119.137", port: 41258}, {ip: "185.18.111.194", port: 41258}, {ip: "185.19.176.237", port: 53281}, {ip: "185.190.40.115", port: 31747}, {ip: "185.216.195.134", port: 61287}, {ip: "185.22.172.94", port: 10010}, {ip: "185.22.172.94", port: 1448}, {ip: "185.22.174.65", port: 10010}, {ip: "185.22.174.65", port: 1448}, {ip: "185.23.64.100", port: 3130}, {ip: "185.23.82.39", port: 59248}, {ip: "185.233.94.105", port: 59288}, {ip: "185.233.94.146", port: 57736}, {ip: "185.3.68.54", port: 53500}, {ip: "185.32.120.177", port: 60724}, {ip: "185.34.20.164", port: 53700}, {ip: "185.34.23.43", port: 63238}, {ip: "185.51.60.141", port: 39935}, {ip: "185.61.92.228", port: 33060}, {ip: "185.61.93.67", port: 49107}, {ip: "185.7.233.66", port: 53504}, {ip: "185.72.225.10", port: 56285}, {ip: "185.75.5.158", port: 60819}, {ip: "185.9.86.186", port: 39345}, {ip: "188.133.136.10", port: 47113}, {ip: "188.168.75.254", port: 56899}, {ip: "188.170.41.6", port: 60332}, {ip: "188.187.189.142", port: 38264}, {ip: "188.234.151.103", port: 8080}, {ip: "188.235.11.88", port: 57143}, {ip: "188.235.137.196", port: 23500}, {ip: "188.244.175.2", port: 8080}, {ip: "188.255.82.136", port: 53281}, {ip: "188.43.4.117", port: 60577}, {ip: "188.68.95.166", port: 41258}, {ip: "188.92.242.180", port: 52048}, {ip: "188.93.242.213", port: 49774}, {ip: "192.162.193.243", port: 36910}, {ip: "192.162.214.11", port: 41258}, {ip: "193.106.170.133", port: 38591}, {ip: "193.232.113.244", port: 40412}, {ip: "193.232.234.130", port: 61932}, {ip: "193.242.177.105", port: 53281}, {ip: "193.242.178.50", port: 52376}, {ip: "193.242.178.90", port: 8080}, {ip: "193.33.101.152", port: 34611}, {ip: "194.114.128.149", port: 61213}, {ip: "194.135.15.146", port: 59328}, {ip: "194.135.216.178", port: 56805}, {ip: "194.135.75.74", port: 41258}, {ip: "194.146.201.67", port: 53281}, {ip: "194.186.18.46", port: 56408}, {ip: "194.186.20.62", port: 21231}, {ip: "194.190.171.214", port: 43960}, {ip: "194.9.27.82", port: 42720}, {ip: "195.133.232.58", port: 41733}, {ip: "195.14.114.116", port: 59530}, {ip: "195.14.114.24", port: 56897}, {ip: "195.158.250.97", port: 41582}, {ip: "195.16.48.142", port: 36083}, {ip: "195.191.183.169", port: 47238}, {ip: "195.206.45.112", port: 53281}, {ip: "195.208.172.70", port: 8080}, {ip: "195.209.141.67", port: 31927}, {ip: "195.209.176.2", port: 8080}, {ip: "195.210.144.166", port: 30088}, {ip: "195.211.160.88", port: 44464}, {ip: "195.218.144.182", port: 31705}, {ip: "195.46.168.147", port: 8080}, {ip: "195.9.188.78", port: 53281}, {ip: "195.9.209.10", port: 35242}, {ip: "195.9.223.246", port: 52098}, {ip: "195.9.237.66", port: 8080}, {ip: "195.9.91.66", port: 33199}, {ip: "195.91.132.20", port: 19600}, {ip: "195.98.183.82", port: 30953}, {ip: "212.104.82.246", port: 36495}, {ip: "212.119.229.18", port: 33852}, {ip: "212.13.97.122", port: 30466}, {ip: "212.19.21.19", port: 53264}, {ip: "212.19.5.157", port: 58442}, {ip: "212.19.8.223", port: 30281}, {ip: "212.19.8.239", port: 55602}, {ip: "212.192.202.207", port: 4550}, {ip: "212.22.80.224", port: 34822}, {ip: "212.26.247.178", port: 38418}, {ip: "212.33.228.161", port: 37971}, {ip: "212.33.243.83", port: 38605}, {ip: "212.34.53.126", port: 44369}, {ip: "212.5.107.81", port: 56481}, {ip: "212.7.230.7", port: 51405}, {ip: "212.77.138.161", port: 41258}, {ip: "213.108.221.201", port: 32800}, {ip: "213.109.7.135", port: 59918}, {ip: "213.128.9.204", port: 35549}, {ip: "213.134.196.12", port: 38723}, {ip: "213.168.37.86", port: 8080}, {ip: "213.187.118.184", port: 53281}, {ip: "213.21.23.98", port: 53281}, {ip: "213.210.67.166", port: 53281}, {ip: "213.234.0.242", port: 56503}, {ip: "213.247.192.131", port: 41258}, {ip: "213.251.226.208", port: 56900}, {ip: "213.33.155.80", port: 44387}, {ip: "213.33.199.194", port: 36411}, {ip: "213.33.224.82", port: 8080}, {ip: "213.59.153.19", port: 53281}, {ip: "217.10.45.103", port: 8080}, {ip: "217.107.197.39", port: 33628}, {ip: "217.116.60.66", port: 21231}, {ip: "217.195.87.58", port: 41258}, {ip: "217.197.239.54", port: 34463}, {ip: "217.74.161.42", port: 34175}, {ip: "217.8.84.76", port: 46378}, {ip: "31.131.67.14", port: 8080}, {ip: "31.132.127.142", port: 35432}, {ip: "31.132.218.252", port: 32423}, {ip: "31.173.17.118", port: 51317}, {ip: "31.193.124.70", port: 53281}, {ip: "31.210.211.147", port: 8080}, {ip: "31.220.183.217", port: 53281}, {ip: "31.29.212.82", port: 35066}, {ip: "31.42.254.24", port: 30912}, {ip: "31.47.189.14", port: 38473}, {ip: "37.113.129.98", port: 41665}, {ip: "37.192.103.164", port: 34835}, {ip: "37.192.194.50", port: 50165}, {ip: "37.192.99.151", port: 51417}, {ip: "37.205.83.91", port: 35888}, {ip: "37.233.85.155", port: 53281}, {ip: "37.235.167.66", port: 53281}, {ip: "37.235.65.2", port: 47816}, {ip: "37.235.67.178", port: 34450}, {ip: "37.9.134.133", port: 41262}, {ip: "46.150.174.90", port: 53281}, {ip: "46.151.156.198", port: 56013}, {ip: "46.16.226.10", port: 8080}, {ip: "46.163.131.55", port: 48306}, {ip: "46.173.191.51", port: 53281}, {ip: "46.174.222.61", port: 34977}, {ip: "46.180.96.79", port: 42319}, {ip: "46.181.151.79", port: 39386}, {ip: "46.21.74.130", port: 8080}, {ip: "46.227.162.98", port: 51558}, {ip: "46.229.187.169", port: 53281}, {ip: "46.229.67.198", port: 47437}, {ip: "46.243.179.221", port: 41598}, {ip: "46.254.217.54", port: 53281}, {ip: "46.32.68.188", port: 39707}, {ip: "46.39.224.112", port: 36765}, {ip: "46.63.162.171", port: 8080}, {ip: "46.73.33.253", port: 8080}, {ip: "5.128.32.12", port: 51959}, {ip: "5.129.155.3", port: 51390}, {ip: "5.129.16.27", port: 48935}, {ip: "5.141.81.65", port: 61853}, {ip: "5.16.15.234", port: 8080}, {ip: "5.167.51.235", port: 8080}, {ip: "5.167.96.238", port: 3128}, {ip: "5.19.165.235", port: 30793}, {ip: "5.35.93.157", port: 31773}, {ip: "5.59.137.90", port: 8888}, {ip: "5.8.207.160", port: 57192}, {ip: "62.122.97.66", port: 59143}, {ip: "62.148.151.253", port: 53570}, {ip: "62.152.85.158", port: 31156}, {ip: "62.165.54.153", port: 55522}, {ip: "62.173.140.14", port: 8080}, {ip: "62.173.155.206", port: 41258}, {ip: "62.182.206.19", port: 37715}, {ip: "62.213.14.166", port: 8080}, {ip: "62.76.123.224", port: 8080}, {ip: "77.221.220.133", port: 44331}, {ip: "77.232.153.248", port: 60950}, {ip: "77.233.10.37", port: 54210}, {ip: "77.244.27.109", port: 47554}, {ip: "77.37.142.203", port: 53281}, {ip: "77.39.29.29", port: 49243}, {ip: "77.75.6.34", port: 8080}, {ip: "77.87.102.7", port: 42601}, {ip: "77.94.121.212", port: 36896}, {ip: "77.94.121.51", port: 45293}, {ip: "78.110.154.177", port: 59888}, {ip: "78.140.201.226", port: 8090}, {ip: "78.153.4.122", port: 9001}, {ip: "78.156.225.170", port: 41258}, {ip: "78.156.243.146", port: 59730}, {ip: "78.29.14.201", port: 39001}, {ip: "78.81.24.112", port: 8080}, {ip: "78.85.36.203", port: 8080}, {ip: "79.104.219.125", port: 3128}, {ip: "79.104.55.134", port: 8080}, {ip: "79.137.181.170", port: 8080}, {ip: "79.173.124.194", port: 47832}, {ip: "79.173.124.207", port: 53281}, {ip: "79.174.186.168", port: 45710}, {ip: "79.175.51.13", port: 54853}, {ip: "79.175.57.77", port: 55477}, {ip: "80.234.107.118", port: 56952}, {ip: "80.237.6.1", port: 34880}, {ip: "80.243.14.182", port: 49320}, {ip: "80.251.48.215", port: 45157}, {ip: "80.254.121.66", port: 41055}, {ip: "80.254.125.236", port: 80}, {ip: "80.72.121.185", port: 52379}, {ip: "80.89.133.210", port: 3128}, {ip: "80.91.17.113", port: 41258}, {ip: "81.162.61.166", port: 40392}, {ip: "81.163.57.121", port: 41258}, {ip: "81.163.57.46", port: 41258}, {ip: "81.163.62.136", port: 41258}, {ip: "81.23.112.98", port: 55269}, {ip: "81.23.118.106", port: 60427}, {ip: "81.23.177.245", port: 8080}, {ip: "81.24.126.166", port: 8080}, {ip: "81.30.216.147", port: 41258}, {ip: "81.95.131.10", port: 44292}, {ip: "82.114.125.22", port: 8080}, {ip: "82.151.208.20", port: 8080}, {ip: "83.221.216.110", port: 47326}, {ip: "83.246.139.24", port: 8080}, {ip: "83.97.108.8", port: 41258}, {ip: "84.22.154.76", port: 8080}, {ip: "84.52.110.36", port: 38674}, {ip: "84.52.74.194", port: 8080}, {ip: "84.52.77.227", port: 41806}, {ip: "84.52.79.166", port: 43548}, {ip: "84.52.84.157", port: 44331}, {ip: "84.52.88.125", port: 32666}, {ip: "85.113.48.148", port: 8080}, {ip: "85.113.49.220", port: 8080}, {ip: "85.12.193.210", port: 58470}, {ip: "85.15.179.5", port: 8080}, {ip: "85.173.244.102", port: 53281}, {ip: "85.174.227.52", port: 59280}, {ip: "85.192.184.133", port: 8080}, {ip: "85.192.184.133", port: 80}, {ip: "85.21.240.193", port: 55820}, {ip: "85.21.63.219", port: 53281}, {ip: "85.235.190.18", port: 42494}, {ip: "85.237.56.193", port: 8080}, {ip: "85.91.119.6", port: 8080}, {ip: "86.102.116.30", port: 8080}, {ip: "86.110.30.146", port: 38109}, {ip: "87.117.3.129", port: 3128}, {ip: "87.225.108.195", port: 8080}, {ip: "87.228.103.111", port: 8080}, {ip: "87.228.103.43", port: 8080}, {ip: "87.229.143.10", port: 48872}, {ip: "87.249.205.103", port: 8080}, {ip: "87.249.21.193", port: 43079}, {ip: "87.255.13.217", port: 8080}, {ip: "88.147.159.167", port: 53281}, {ip: "88.200.225.32", port: 38583}, {ip: "88.204.59.177", port: 32666}, {ip: "88.84.209.69", port: 30819}, {ip: "88.87.72.72", port: 8080}, {ip: "88.87.79.20", port: 8080}, {ip: "88.87.91.163", port: 48513}, {ip: "88.87.93.20", port: 33277}, {ip: "89.109.12.82", port: 47972}, {ip: "89.109.21.43", port: 9090}, {ip: "89.109.239.183", port: 41041}, {ip: "89.109.54.137", port: 36469}, {ip: "89.17.37.218", port: 52957}, {ip: "89.189.130.103", port: 32626}, {ip: "89.189.159.214", port: 42530}, {ip: "89.189.174.121", port: 52636}, {ip: "89.23.18.29", port: 53281}, {ip: "89.249.251.21", port: 3128}, {ip: "89.250.149.114", port: 60981}, {ip: "89.250.17.209", port: 8080}, {ip: "89.250.19.173", port: 8080}, {ip: "90.150.87.172", port: 81}, {ip: "90.154.125.173", port: 33078}, {ip: "90.188.38.81", port: 60585}, {ip: "90.189.151.183", port: 32601}, {ip: "91.103.208.114", port: 57063}, {ip: "91.122.100.222", port: 44331}, {ip: "91.122.207.229", port: 8080}, {ip: "91.144.139.93", port: 3128}, {ip: "91.144.142.19", port: 44617}, {ip: "91.146.16.54", port: 57902}, {ip: "91.190.116.194", port: 38783}, {ip: "91.190.80.100", port: 31659}, {ip: "91.190.85.97", port: 34286}, {ip: "91.203.36.188", port: 8080}, {ip: "91.205.131.102", port: 8080}, {ip: "91.205.146.25", port: 37501}, {ip: "91.210.94.212", port: 52635}, {ip: "91.213.23.110", port: 8080}, {ip: "91.215.22.51", port: 53305}, {ip: "91.217.42.3", port: 8080}, {ip: "91.217.42.4", port: 8080}, {ip: "91.220.135.146", port: 41258}, {ip: "91.222.167.213", port: 38057}, {ip: "91.226.140.71", port: 33199}, {ip: "91.235.7.216", port: 59067}, {ip: "92.124.195.22", port: 3128}, {ip: "92.126.193.180", port: 8080}, {ip: "92.241.110.223", port: 53281}, {ip: "92.252.240.1", port: 53281}, {ip: "92.255.164.187", port: 3128}, {ip: "92.255.195.57", port: 53281}, {ip: "92.255.229.146", port: 55785}, {ip: "92.255.5.2", port: 41012}, {ip: "92.38.32.36", port: 56113}, {ip: "92.39.138.98", port: 31150}, {ip: "92.51.16.155", port: 46202}, {ip: "92.55.59.63", port: 33030}, {ip: "93.170.112.200", port: 47995}, {ip: "93.183.86.185", port: 53281}, {ip: "93.188.45.157", port: 8080}, {ip: "93.81.246.5", port: 53281}, {ip: "93.91.112.247", port: 41258}, {ip: "94.127.217.66", port: 40115}, {ip: "94.154.85.214", port: 8080}, {ip: "94.180.106.94", port: 32767}, {ip: "94.180.249.187", port: 38051}, {ip: "94.230.243.6", port: 8080}, {ip: "94.232.57.231", port: 51064}, {ip: "94.24.244.170", port: 48936}, {ip: "94.242.55.108", port: 10010}, {ip: "94.242.55.108", port: 1448}, {ip: "94.242.57.136", port: 10010}, {ip: "94.242.57.136", port: 1448}, {ip: "94.242.58.108", port: 10010}, {ip: "94.242.58.108", port: 1448}, {ip: "94.242.58.14", port: 10010}, {ip: "94.242.58.14", port: 1448}, {ip: "94.242.58.142", port: 10010}, {ip: "94.242.58.142", port: 1448}, {ip: "94.242.59.245", port: 10010}, {ip: "94.242.59.245", port: 1448}, {ip: "94.247.241.70", port: 53640}, {ip: "94.247.62.165", port: 33176}, {ip: "94.253.13.228", port: 54935}, {ip: "94.253.14.187", port: 55045}, {ip: "94.28.94.154", port: 46966}, {ip: "94.73.217.125", port: 40858}, {ip: "95.140.19.9", port: 8080}, {ip: "95.140.20.94", port: 33994}, {ip: "95.154.137.66", port: 41258}, {ip: "95.154.159.119", port: 44242}, {ip: "95.154.82.254", port: 52484}, {ip: "95.161.157.227", port: 43170}, {ip: "95.161.182.146", port: 33877}, {ip: "95.161.189.26", port: 61522}, {ip: "95.165.163.146", port: 8888}, {ip: "95.165.172.90", port: 60496}, {ip: "95.165.182.18", port: 38950}, {ip: "95.165.203.222", port: 33805}, {ip: "95.165.244.122", port: 58162}, {ip: "95.167.123.54", port: 58664}, {ip: "95.167.241.242", port: 49636}, {ip: "95.171.1.92", port: 35956}, {ip: "95.172.52.230", port: 35989}, {ip: "95.181.35.30", port: 40804}, {ip: "95.181.56.178", port: 39144}, {ip: "95.181.75.228", port: 53281}, {ip: "95.188.74.194", port: 57122}, {ip: "95.189.112.214", port: 35508}, {ip: "95.31.10.247", port: 30711}, {ip: "95.31.197.77", port: 41651}, {ip: "95.31.2.199", port: 33632}, {ip: "95.71.125.50", port: 49882}, {ip: "95.73.62.13", port: 32185}, {ip: "95.79.36.55", port: 44861}, {ip: "95.79.55.196", port: 53281}, {ip: "95.79.99.148", port: 3128}, {ip: "95.80.65.39", port: 43555}, {ip: "95.80.93.44", port: 41258}, {ip: "95.80.98.41", port: 8080}, {ip: "95.83.156.250", port: 58438}, {ip: "95.84.128.25", port: 33765}, {ip: "95.84.154.73", port: 57423}], + "CA" => [{ip: "144.217.161.149", port: 8080}, {ip: "24.37.9.6", port: 54154}, {ip: "54.39.138.144", port: 3128}, {ip: "54.39.138.145", port: 3128}, {ip: "54.39.138.151", port: 3128}, {ip: "54.39.138.152", port: 3128}, {ip: "54.39.138.153", port: 3128}, {ip: "54.39.138.154", port: 3128}, {ip: "54.39.138.155", port: 3128}, {ip: "54.39.138.156", port: 3128}, {ip: "54.39.138.157", port: 3128}, {ip: "54.39.53.104", port: 3128}, {ip: "66.70.167.113", port: 3128}, {ip: "66.70.167.116", port: 3128}, {ip: "66.70.167.117", port: 3128}, {ip: "66.70.167.119", port: 3128}, {ip: "66.70.167.120", port: 3128}, {ip: "66.70.167.125", port: 3128}, {ip: "66.70.188.148", port: 3128}, {ip: "70.35.213.229", port: 36127}, {ip: "70.65.233.174", port: 8080}, {ip: "72.139.24.66", port: 38861}, {ip: "74.15.191.160", port: 41564}], + "JP" => [{ip: "47.91.20.67", port: 8080}, {ip: "61.118.35.94", port: 55725}], + "IT" => [{ip: "109.70.201.97", port: 53517}, {ip: "176.31.82.212", port: 8080}, {ip: "185.132.228.118", port: 55583}, {ip: "185.49.58.88", port: 56006}, {ip: "185.94.89.179", port: 41258}, {ip: "213.203.134.10", port: 41258}, {ip: "217.61.172.12", port: 41369}, {ip: "46.232.143.126", port: 41258}, {ip: "46.232.143.253", port: 41258}, {ip: "93.67.154.125", port: 8080}, {ip: "93.67.154.125", port: 80}, {ip: "95.169.95.242", port: 53803}], + "TH" => [{ip: "1.10.184.166", port: 57330}, {ip: "1.10.186.100", port: 55011}, {ip: "1.10.186.209", port: 32431}, {ip: "1.10.186.245", port: 34360}, {ip: "1.10.186.93", port: 53711}, {ip: "1.10.187.118", port: 62000}, {ip: "1.10.187.34", port: 51635}, {ip: "1.10.187.43", port: 38715}, {ip: "1.10.188.181", port: 51093}, {ip: "1.10.188.83", port: 31940}, {ip: "1.10.188.95", port: 30593}, {ip: "1.10.189.58", port: 48564}, {ip: "1.179.157.237", port: 46178}, {ip: "1.179.164.213", port: 8080}, {ip: "1.179.198.37", port: 8080}, {ip: "1.20.100.99", port: 53794}, {ip: "1.20.101.221", port: 55707}, {ip: "1.20.101.254", port: 35394}, {ip: "1.20.101.80", port: 36234}, {ip: "1.20.102.133", port: 40296}, {ip: "1.20.103.13", port: 40544}, {ip: "1.20.103.56", port: 55422}, {ip: "1.20.96.234", port: 53142}, {ip: "1.20.97.54", port: 60122}, {ip: "1.20.99.63", port: 32123}, {ip: "101.108.92.20", port: 8080}, {ip: "101.109.143.71", port: 36127}, {ip: "101.51.141.110", port: 42860}, {ip: "101.51.141.60", port: 60417}, {ip: "103.246.17.237", port: 3128}, {ip: "110.164.73.131", port: 8080}, {ip: "110.164.87.80", port: 35844}, {ip: "110.77.134.106", port: 8080}, {ip: "113.53.29.92", port: 47297}, {ip: "113.53.83.192", port: 32780}, {ip: "113.53.83.195", port: 35686}, {ip: "113.53.91.214", port: 8080}, {ip: "115.87.27.0", port: 53276}, {ip: "118.172.211.3", port: 58535}, {ip: "118.172.211.40", port: 30430}, {ip: "118.174.196.174", port: 23500}, {ip: "118.174.196.203", port: 23500}, {ip: "118.174.220.107", port: 41222}, {ip: "118.174.220.110", port: 39025}, {ip: "118.174.220.115", port: 41011}, {ip: "118.174.220.118", port: 59556}, {ip: "118.174.220.136", port: 55041}, {ip: "118.174.220.163", port: 31561}, {ip: "118.174.220.168", port: 47455}, {ip: "118.174.220.231", port: 40924}, {ip: "118.174.220.238", port: 46326}, {ip: "118.174.234.13", port: 53084}, {ip: "118.174.234.26", port: 41926}, {ip: "118.174.234.32", port: 57403}, {ip: "118.174.234.59", port: 59149}, {ip: "118.174.234.68", port: 42626}, {ip: "118.174.234.83", port: 38006}, {ip: "118.175.207.104", port: 38959}, {ip: "118.175.244.111", port: 8080}, {ip: "118.175.93.207", port: 50738}, {ip: "122.154.38.53", port: 8080}, {ip: "122.154.59.6", port: 8080}, {ip: "122.154.72.102", port: 8080}, {ip: "122.155.222.98", port: 3128}, {ip: "124.121.22.121", port: 61699}, {ip: "125.24.156.16", port: 44321}, {ip: "125.25.165.105", port: 33850}, {ip: "125.25.165.111", port: 40808}, {ip: "125.25.165.42", port: 47221}, {ip: "125.25.201.14", port: 30100}, {ip: "125.26.99.135", port: 55637}, {ip: "125.26.99.141", port: 38537}, {ip: "125.26.99.148", port: 31818}, {ip: "134.236.247.137", port: 8080}, {ip: "159.192.98.224", port: 3128}, {ip: "171.100.2.154", port: 8080}, {ip: "171.100.9.126", port: 49163}, {ip: "180.180.156.116", port: 48431}, {ip: "180.180.156.46", port: 48507}, {ip: "180.180.156.87", port: 36628}, {ip: "180.180.218.204", port: 51565}, {ip: "180.180.8.34", port: 8080}, {ip: "182.52.238.125", port: 58861}, {ip: "182.52.74.73", port: 36286}, {ip: "182.52.74.76", port: 34084}, {ip: "182.52.74.77", port: 34825}, {ip: "182.52.74.78", port: 48708}, {ip: "182.52.90.45", port: 53799}, {ip: "182.53.206.155", port: 34307}, {ip: "182.53.206.43", port: 45330}, {ip: "182.53.206.49", port: 54228}, {ip: "183.88.212.141", port: 8080}, {ip: "183.88.212.184", port: 8080}, {ip: "183.88.213.85", port: 8080}, {ip: "183.88.214.47", port: 8080}, {ip: "184.82.128.211", port: 8080}, {ip: "202.183.201.13", port: 8081}, {ip: "202.29.20.151", port: 43083}, {ip: "203.150.172.151", port: 8080}, {ip: "27.131.157.94", port: 8080}, {ip: "27.145.100.22", port: 8080}, {ip: "27.145.100.243", port: 8080}, {ip: "49.231.196.114", port: 53281}, {ip: "58.97.72.83", port: 8080}, {ip: "61.19.145.66", port: 8080}], + "ES" => [{ip: "185.198.184.14", port: 48122}, {ip: "185.26.226.241", port: 36012}, {ip: "194.224.188.82", port: 3128}, {ip: "195.235.68.61", port: 3128}, {ip: "195.53.237.122", port: 3128}, {ip: "195.53.86.82", port: 3128}, {ip: "213.96.245.47", port: 8080}, {ip: "217.125.71.214", port: 33950}, {ip: "62.14.178.72", port: 53281}, {ip: "80.35.254.42", port: 53281}, {ip: "81.33.4.214", port: 61711}, {ip: "83.175.238.170", port: 53281}, {ip: "85.217.137.77", port: 3128}, {ip: "90.170.205.178", port: 33680}, {ip: "93.156.177.91", port: 53281}, {ip: "95.60.152.139", port: 37995}], + "AE" => [{ip: "178.32.5.90", port: 36159}], + "KR" => [{ip: "112.217.219.179", port: 3128}, {ip: "114.141.229.2", port: 58115}, {ip: "121.139.218.165", port: 31409}, {ip: "122.49.112.2", port: 38592}, {ip: "61.42.18.132", port: 53281}], + "BR" => [{ip: "128.201.97.157", port: 53281}, {ip: "128.201.97.158", port: 53281}, {ip: "131.0.246.157", port: 35252}, {ip: "131.161.26.90", port: 8080}, {ip: "131.72.143.100", port: 41396}, {ip: "138.0.24.66", port: 53281}, {ip: "138.121.130.50", port: 50600}, {ip: "138.121.155.127", port: 61932}, {ip: "138.121.32.133", port: 23492}, {ip: "138.185.176.63", port: 53281}, {ip: "138.204.233.190", port: 53281}, {ip: "138.204.233.242", port: 53281}, {ip: "138.219.71.74", port: 52688}, {ip: "138.36.107.24", port: 41184}, {ip: "138.94.115.166", port: 8080}, {ip: "143.0.188.161", port: 53281}, {ip: "143.202.218.135", port: 8080}, {ip: "143.208.2.42", port: 53281}, {ip: "143.208.79.223", port: 8080}, {ip: "143.255.52.102", port: 40687}, {ip: "143.255.52.116", port: 57856}, {ip: "143.255.52.117", port: 37279}, {ip: "144.217.22.128", port: 8080}, {ip: "168.0.8.225", port: 8080}, {ip: "168.0.8.55", port: 8080}, {ip: "168.121.139.54", port: 40056}, {ip: "168.181.168.23", port: 53281}, {ip: "168.181.170.198", port: 31935}, {ip: "168.232.198.25", port: 32009}, {ip: "168.232.198.35", port: 42267}, {ip: "168.232.207.145", port: 46342}, {ip: "170.0.104.107", port: 60337}, {ip: "170.0.112.2", port: 50359}, {ip: "170.0.112.229", port: 50359}, {ip: "170.238.118.107", port: 34314}, {ip: "170.239.144.9", port: 3128}, {ip: "170.247.29.138", port: 8080}, {ip: "170.81.237.36", port: 37124}, {ip: "170.84.51.74", port: 53281}, {ip: "170.84.60.222", port: 42981}, {ip: "177.10.202.67", port: 8080}, {ip: "177.101.60.86", port: 80}, {ip: "177.103.231.211", port: 55091}, {ip: "177.12.80.50", port: 50556}, {ip: "177.131.13.9", port: 20183}, {ip: "177.135.178.115", port: 42510}, {ip: "177.135.248.75", port: 20183}, {ip: "177.184.206.238", port: 39508}, {ip: "177.185.148.46", port: 58623}, {ip: "177.200.83.238", port: 8080}, {ip: "177.21.24.146", port: 666}, {ip: "177.220.188.120", port: 47556}, {ip: "177.220.188.213", port: 8080}, {ip: "177.222.229.243", port: 23500}, {ip: "177.234.161.42", port: 8080}, {ip: "177.36.11.241", port: 3128}, {ip: "177.36.12.193", port: 23500}, {ip: "177.37.199.175", port: 49608}, {ip: "177.39.187.70", port: 37315}, {ip: "177.44.175.199", port: 8080}, {ip: "177.46.148.126", port: 3128}, {ip: "177.46.148.142", port: 3128}, {ip: "177.47.194.98", port: 21231}, {ip: "177.5.98.58", port: 20183}, {ip: "177.52.55.19", port: 60901}, {ip: "177.54.200.66", port: 57526}, {ip: "177.55.255.74", port: 37147}, {ip: "177.67.217.94", port: 53281}, {ip: "177.73.248.6", port: 54381}, {ip: "177.73.4.234", port: 23500}, {ip: "177.75.143.211", port: 35955}, {ip: "177.75.161.206", port: 3128}, {ip: "177.75.86.49", port: 20183}, {ip: "177.8.216.106", port: 8080}, {ip: "177.8.216.114", port: 8080}, {ip: "177.8.37.247", port: 56052}, {ip: "177.84.216.17", port: 50569}, {ip: "177.85.200.254", port: 53095}, {ip: "177.87.169.1", port: 53281}, {ip: "179.107.97.178", port: 3128}, {ip: "179.109.144.25", port: 8080}, {ip: "179.109.193.137", port: 53281}, {ip: "179.189.125.206", port: 8080}, {ip: "179.97.30.46", port: 53100}, {ip: "186.192.195.220", port: 38983}, {ip: "186.193.11.226", port: 48999}, {ip: "186.193.26.106", port: 3128}, {ip: "186.208.220.248", port: 3128}, {ip: "186.209.243.142", port: 3128}, {ip: "186.209.243.233", port: 3128}, {ip: "186.211.106.227", port: 34334}, {ip: "186.211.160.178", port: 36756}, {ip: "186.215.133.170", port: 20183}, {ip: "186.216.81.21", port: 31773}, {ip: "186.219.214.13", port: 32708}, {ip: "186.224.94.6", port: 48957}, {ip: "186.225.97.246", port: 43082}, {ip: "186.226.171.163", port: 48698}, {ip: "186.226.179.2", port: 56089}, {ip: "186.226.234.67", port: 33834}, {ip: "186.228.147.58", port: 20183}, {ip: "186.233.97.163", port: 8888}, {ip: "186.248.170.82", port: 53281}, {ip: "186.249.213.101", port: 53482}, {ip: "186.249.213.65", port: 52018}, {ip: "186.250.213.225", port: 60774}, {ip: "186.250.96.70", port: 8080}, {ip: "186.250.96.77", port: 8080}, {ip: "187.1.43.246", port: 53396}, {ip: "187.108.36.250", port: 20183}, {ip: "187.108.38.10", port: 20183}, {ip: "187.109.36.251", port: 20183}, {ip: "187.109.40.9", port: 20183}, {ip: "187.109.56.101", port: 20183}, {ip: "187.111.90.89", port: 53281}, {ip: "187.115.10.50", port: 20183}, {ip: "187.19.62.7", port: 59010}, {ip: "187.33.79.61", port: 33469}, {ip: "187.35.158.150", port: 38872}, {ip: "187.44.1.167", port: 8080}, {ip: "187.45.127.87", port: 20183}, {ip: "187.45.156.109", port: 8080}, {ip: "187.5.218.215", port: 20183}, {ip: "187.58.65.225", port: 3128}, {ip: "187.63.111.37", port: 3128}, {ip: "187.72.166.10", port: 8080}, {ip: "187.73.68.14", port: 53281}, {ip: "187.84.177.6", port: 45903}, {ip: "187.84.191.170", port: 43936}, {ip: "187.87.204.210", port: 45597}, {ip: "187.87.39.247", port: 31793}, {ip: "189.1.16.162", port: 23500}, {ip: "189.113.124.162", port: 8080}, {ip: "189.124.195.185", port: 37318}, {ip: "189.3.196.18", port: 61595}, {ip: "189.37.33.59", port: 35532}, {ip: "189.7.49.66", port: 42700}, {ip: "189.90.194.35", port: 30843}, {ip: "189.90.248.75", port: 8080}, {ip: "189.91.231.43", port: 3128}, {ip: "191.239.243.156", port: 3128}, {ip: "191.240.154.246", port: 23500}, {ip: "191.240.156.154", port: 36127}, {ip: "191.240.99.142", port: 9090}, {ip: "191.241.226.230", port: 53281}, {ip: "191.241.228.74", port: 20183}, {ip: "191.241.228.78", port: 20183}, {ip: "191.241.33.238", port: 39188}, {ip: "191.241.36.170", port: 8080}, {ip: "191.241.36.218", port: 3128}, {ip: "191.242.182.132", port: 8081}, {ip: "191.243.221.130", port: 3128}, {ip: "191.255.207.231", port: 20183}, {ip: "191.36.192.196", port: 3128}, {ip: "191.36.244.230", port: 51377}, {ip: "191.5.0.79", port: 53281}, {ip: "191.6.228.6", port: 53281}, {ip: "191.7.193.18", port: 38133}, {ip: "191.7.20.134", port: 3128}, {ip: "192.140.91.173", port: 20183}, {ip: "200.150.86.138", port: 44677}, {ip: "200.155.36.185", port: 3128}, {ip: "200.155.36.188", port: 3128}, {ip: "200.155.39.41", port: 3128}, {ip: "200.174.158.26", port: 34112}, {ip: "200.187.177.105", port: 20183}, {ip: "200.187.87.138", port: 20183}, {ip: "200.192.252.201", port: 8080}, {ip: "200.192.255.102", port: 8080}, {ip: "200.203.144.2", port: 50262}, {ip: "200.229.238.42", port: 20183}, {ip: "200.233.134.85", port: 43172}, {ip: "200.233.136.177", port: 20183}, {ip: "200.241.44.3", port: 20183}, {ip: "200.255.122.170", port: 8080}, {ip: "200.255.122.174", port: 8080}, {ip: "201.12.21.57", port: 8080}, {ip: "201.131.224.21", port: 56200}, {ip: "201.182.223.16", port: 37492}, {ip: "201.20.89.126", port: 8080}, {ip: "201.22.95.10", port: 8080}, {ip: "201.57.167.34", port: 8080}, {ip: "201.59.200.246", port: 80}, {ip: "201.6.167.178", port: 3128}, {ip: "201.90.36.194", port: 3128}, {ip: "45.226.20.6", port: 8080}, {ip: "45.234.139.129", port: 20183}, {ip: "45.234.200.18", port: 53281}, {ip: "45.235.87.4", port: 51996}, {ip: "45.6.136.38", port: 53281}, {ip: "45.6.80.131", port: 52080}, {ip: "45.6.93.10", port: 8080}, {ip: "45.71.108.162", port: 53281}], + "PK" => [{ip: "103.18.243.154", port: 8080}, {ip: "110.36.218.126", port: 36651}, {ip: "110.36.234.210", port: 8080}, {ip: "110.39.162.74", port: 53281}, {ip: "110.39.174.58", port: 8080}, {ip: "111.68.108.34", port: 8080}, {ip: "125.209.116.182", port: 31653}, {ip: "125.209.78.21", port: 8080}, {ip: "125.209.82.78", port: 35087}, {ip: "180.92.156.150", port: 8080}, {ip: "202.142.158.114", port: 8080}, {ip: "202.147.173.10", port: 8080}, {ip: "202.147.173.10", port: 80}, {ip: "202.69.38.82", port: 8080}, {ip: "203.128.16.126", port: 59538}, {ip: "203.128.16.154", port: 33002}, {ip: "27.255.4.170", port: 8080}], + "ID" => [{ip: "101.128.68.113", port: 8080}, {ip: "101.255.116.113", port: 53281}, {ip: "101.255.120.170", port: 6969}, {ip: "101.255.121.74", port: 8080}, {ip: "101.255.124.242", port: 8080}, {ip: "101.255.124.242", port: 80}, {ip: "101.255.56.138", port: 53560}, {ip: "103.10.171.132", port: 41043}, {ip: "103.10.81.172", port: 80}, {ip: "103.108.158.3", port: 48196}, {ip: "103.111.219.159", port: 53281}, {ip: "103.111.54.26", port: 49781}, {ip: "103.111.54.74", port: 8080}, {ip: "103.19.110.177", port: 8080}, {ip: "103.2.146.66", port: 49089}, {ip: "103.206.168.177", port: 53281}, {ip: "103.206.253.58", port: 49573}, {ip: "103.21.92.254", port: 33929}, {ip: "103.226.49.83", port: 23500}, {ip: "103.227.147.142", port: 37581}, {ip: "103.23.101.58", port: 8080}, {ip: "103.24.107.2", port: 8181}, {ip: "103.245.19.222", port: 53281}, {ip: "103.247.122.38", port: 8080}, {ip: "103.247.218.166", port: 3128}, {ip: "103.248.219.26", port: 53634}, {ip: "103.253.2.165", port: 33543}, {ip: "103.253.2.168", port: 51229}, {ip: "103.253.2.174", port: 30827}, {ip: "103.28.114.134", port: 8080}, {ip: "103.28.220.73", port: 53281}, {ip: "103.30.246.47", port: 3128}, {ip: "103.31.45.169", port: 57655}, {ip: "103.41.122.14", port: 53281}, {ip: "103.75.101.97", port: 8080}, {ip: "103.76.17.151", port: 23500}, {ip: "103.76.50.181", port: 8080}, {ip: "103.76.50.181", port: 80}, {ip: "103.76.50.182", port: 8080}, {ip: "103.78.74.170", port: 3128}, {ip: "103.78.80.194", port: 33442}, {ip: "103.8.122.5", port: 53297}, {ip: "103.80.236.107", port: 53281}, {ip: "103.80.238.203", port: 53281}, {ip: "103.86.140.74", port: 59538}, {ip: "103.94.122.254", port: 8080}, {ip: "103.94.125.244", port: 41508}, {ip: "103.94.169.19", port: 8080}, {ip: "103.94.7.254", port: 53281}, {ip: "106.0.51.50", port: 17385}, {ip: "110.93.13.202", port: 34881}, {ip: "112.78.37.6", port: 54791}, {ip: "114.199.110.58", port: 55898}, {ip: "114.199.112.170", port: 23500}, {ip: "114.199.123.194", port: 8080}, {ip: "114.57.33.162", port: 46935}, {ip: "114.57.33.214", port: 8080}, {ip: "114.6.197.254", port: 8080}, {ip: "114.7.15.146", port: 8080}, {ip: "114.7.162.254", port: 53281}, {ip: "115.124.75.226", port: 53990}, {ip: "115.124.75.228", port: 3128}, {ip: "117.102.78.42", port: 8080}, {ip: "117.102.93.251", port: 8080}, {ip: "117.102.94.186", port: 8080}, {ip: "117.102.94.186", port: 80}, {ip: "117.103.2.249", port: 58276}, {ip: "117.54.13.174", port: 34190}, {ip: "117.74.124.129", port: 8088}, {ip: "118.97.100.83", port: 35220}, {ip: "118.97.191.162", port: 80}, {ip: "118.97.191.203", port: 8080}, {ip: "118.97.36.18", port: 8080}, {ip: "118.97.73.85", port: 53281}, {ip: "118.99.105.226", port: 8080}, {ip: "119.252.168.53", port: 53281}, {ip: "122.248.45.35", port: 53281}, {ip: "122.50.6.186", port: 8080}, {ip: "122.50.6.186", port: 80}, {ip: "123.231.226.114", port: 47562}, {ip: "123.255.202.83", port: 32523}, {ip: "124.158.164.195", port: 8080}, {ip: "124.81.99.30", port: 3128}, {ip: "137.59.162.10", port: 3128}, {ip: "139.0.29.20", port: 59532}, {ip: "139.255.123.194", port: 4550}, {ip: "139.255.16.171", port: 31773}, {ip: "139.255.17.2", port: 47421}, {ip: "139.255.19.162", port: 42371}, {ip: "139.255.7.81", port: 53281}, {ip: "139.255.91.115", port: 8080}, {ip: "139.255.92.26", port: 53281}, {ip: "158.140.181.140", port: 54041}, {ip: "160.202.40.20", port: 55655}, {ip: "175.103.42.147", port: 8080}, {ip: "180.178.98.198", port: 8080}, {ip: "180.250.101.146", port: 8080}, {ip: "182.23.107.212", port: 3128}, {ip: "182.23.2.101", port: 49833}, {ip: "182.23.7.226", port: 8080}, {ip: "182.253.209.203", port: 3128}, {ip: "183.91.66.210", port: 80}, {ip: "202.137.10.179", port: 57338}, {ip: "202.137.25.53", port: 3128}, {ip: "202.137.25.8", port: 8080}, {ip: "202.138.242.76", port: 4550}, {ip: "202.138.249.202", port: 43108}, {ip: "202.148.2.254", port: 8000}, {ip: "202.162.201.94", port: 53281}, {ip: "202.165.47.26", port: 8080}, {ip: "202.43.167.130", port: 8080}, {ip: "202.51.126.10", port: 53281}, {ip: "202.59.171.164", port: 58567}, {ip: "202.93.128.98", port: 3128}, {ip: "203.142.72.114", port: 808}, {ip: "203.153.117.65", port: 54144}, {ip: "203.189.89.1", port: 53281}, {ip: "203.77.239.18", port: 37002}, {ip: "203.99.123.25", port: 61502}, {ip: "220.247.168.163", port: 53281}, {ip: "220.247.173.154", port: 53281}, {ip: "220.247.174.206", port: 53445}, {ip: "222.124.131.211", port: 47343}, {ip: "222.124.173.146", port: 53281}, {ip: "222.124.2.131", port: 8080}, {ip: "222.124.2.186", port: 8080}, {ip: "222.124.215.187", port: 38913}, {ip: "222.124.221.179", port: 53281}, {ip: "223.25.101.242", port: 59504}, {ip: "223.25.97.62", port: 8080}, {ip: "223.25.99.38", port: 80}, {ip: "27.111.44.202", port: 80}, {ip: "27.111.47.3", port: 51144}, {ip: "36.37.124.234", port: 36179}, {ip: "36.37.124.235", port: 36179}, {ip: "36.37.81.135", port: 8080}, {ip: "36.37.89.98", port: 32323}, {ip: "36.66.217.179", port: 8080}, {ip: "36.66.98.6", port: 53281}, {ip: "36.67.143.183", port: 48746}, {ip: "36.67.206.187", port: 8080}, {ip: "36.67.32.87", port: 8080}, {ip: "36.67.93.220", port: 3128}, {ip: "36.67.93.220", port: 80}, {ip: "36.89.10.51", port: 34115}, {ip: "36.89.119.149", port: 8080}, {ip: "36.89.157.23", port: 37728}, {ip: "36.89.181.155", port: 60165}, {ip: "36.89.188.11", port: 39507}, {ip: "36.89.194.113", port: 37811}, {ip: "36.89.226.254", port: 8081}, {ip: "36.89.232.138", port: 23500}, {ip: "36.89.39.10", port: 3128}, {ip: "36.89.65.253", port: 60997}, {ip: "43.243.141.114", port: 8080}, {ip: "43.245.184.202", port: 41102}, {ip: "43.245.184.238", port: 80}, {ip: "66.96.233.225", port: 35053}, {ip: "66.96.237.253", port: 8080}], + "BD" => [{ip: "103.103.88.91", port: 8080}, {ip: "103.106.119.154", port: 8080}, {ip: "103.106.236.1", port: 8080}, {ip: "103.106.236.41", port: 8080}, {ip: "103.108.144.139", port: 53281}, {ip: "103.109.57.218", port: 8080}, {ip: "103.109.58.242", port: 8080}, {ip: "103.112.129.106", port: 31094}, {ip: "103.112.129.82", port: 53281}, {ip: "103.114.10.177", port: 8080}, {ip: "103.114.10.250", port: 8080}, {ip: "103.15.245.26", port: 8080}, {ip: "103.195.204.73", port: 21776}, {ip: "103.197.49.106", port: 49688}, {ip: "103.198.168.29", port: 21776}, {ip: "103.214.200.6", port: 59008}, {ip: "103.218.25.161", port: 8080}, {ip: "103.218.25.41", port: 8080}, {ip: "103.218.26.204", port: 8080}, {ip: "103.218.27.221", port: 8080}, {ip: "103.231.229.90", port: 53281}, {ip: "103.239.252.233", port: 8080}, {ip: "103.239.252.50", port: 8080}, {ip: "103.239.253.193", port: 8080}, {ip: "103.250.68.193", port: 51370}, {ip: "103.5.232.146", port: 8080}, {ip: "103.73.224.53", port: 23500}, {ip: "103.9.134.73", port: 65301}, {ip: "113.11.47.242", port: 40071}, {ip: "113.11.5.67", port: 40071}, {ip: "114.31.5.34", port: 52606}, {ip: "115.127.51.226", port: 42764}, {ip: "115.127.64.62", port: 39611}, {ip: "115.127.91.106", port: 8080}, {ip: "119.40.85.198", port: 36899}, {ip: "123.200.29.110", port: 23500}, {ip: "123.49.51.42", port: 55124}, {ip: "163.47.36.90", port: 3128}, {ip: "180.211.134.158", port: 23500}, {ip: "180.211.193.74", port: 40536}, {ip: "180.92.238.226", port: 53451}, {ip: "182.160.104.213", port: 8080}, {ip: "202.191.126.58", port: 23500}, {ip: "202.4.126.170", port: 8080}, {ip: "202.5.37.241", port: 33623}, {ip: "202.5.57.5", port: 61729}, {ip: "202.79.17.65", port: 60122}, {ip: "203.188.248.52", port: 23500}, {ip: "27.147.146.78", port: 52220}, {ip: "27.147.164.10", port: 52344}, {ip: "27.147.212.38", port: 53281}, {ip: "27.147.217.154", port: 43252}, {ip: "27.147.219.102", port: 49464}, {ip: "43.239.74.137", port: 8080}, {ip: "43.240.103.252", port: 8080}, {ip: "45.125.223.57", port: 8080}, {ip: "45.125.223.81", port: 8080}, {ip: "45.251.228.122", port: 41418}, {ip: "45.64.132.137", port: 8080}, {ip: "45.64.132.137", port: 80}, {ip: "61.247.186.137", port: 8080}], + "MX" => [{ip: "148.217.94.54", port: 3128}, {ip: "177.244.28.77", port: 53281}, {ip: "187.141.73.147", port: 53281}, {ip: "187.185.15.35", port: 53281}, {ip: "187.188.46.172", port: 53455}, {ip: "187.216.83.185", port: 8080}, {ip: "187.216.90.46", port: 53281}, {ip: "187.243.253.182", port: 33796}, {ip: "189.195.132.86", port: 43286}, {ip: "189.204.158.161", port: 8080}, {ip: "200.79.180.115", port: 8080}, {ip: "201.140.113.90", port: 37193}, {ip: "201.144.14.229", port: 53281}, {ip: "201.163.73.93", port: 53281}], + "PH" => [{ip: "103.86.187.242", port: 23500}, {ip: "122.54.101.69", port: 8080}, {ip: "122.54.65.150", port: 8080}, {ip: "125.5.20.134", port: 53281}, {ip: "146.88.77.51", port: 8080}, {ip: "182.18.200.92", port: 8080}, {ip: "219.90.87.91", port: 53281}, {ip: "58.69.12.210", port: 8080}], + "EG" => [{ip: "41.65.0.167", port: 8080}], + "VN" => [{ip: "1.55.240.156", port: 53281}, {ip: "101.99.23.136", port: 3128}, {ip: "103.15.51.160", port: 8080}, {ip: "113.161.128.169", port: 60427}, {ip: "113.161.161.143", port: 57967}, {ip: "113.161.173.10", port: 3128}, {ip: "113.161.35.108", port: 30028}, {ip: "113.164.79.177", port: 46281}, {ip: "113.190.235.50", port: 34619}, {ip: "115.78.160.247", port: 8080}, {ip: "117.2.155.29", port: 47228}, {ip: "117.2.17.26", port: 53281}, {ip: "117.2.22.41", port: 41973}, {ip: "117.4.145.16", port: 51487}, {ip: "118.69.219.185", port: 55184}, {ip: "118.69.61.212", port: 53281}, {ip: "118.70.116.227", port: 61651}, {ip: "118.70.219.124", port: 53281}, {ip: "221.121.12.238", port: 36077}, {ip: "27.2.7.59", port: 52148}], + "CD" => [{ip: "41.79.233.45", port: 8080}], + "TR" => [{ip: "151.80.65.175", port: 3128}, {ip: "176.235.186.242", port: 37043}, {ip: "178.250.92.18", port: 8080}, {ip: "185.203.170.92", port: 8080}, {ip: "185.203.170.94", port: 8080}, {ip: "185.203.170.95", port: 8080}, {ip: "185.51.36.152", port: 41258}, {ip: "195.137.223.50", port: 41336}, {ip: "195.155.98.70", port: 52598}, {ip: "212.156.146.22", port: 40080}, {ip: "213.14.31.122", port: 44621}, {ip: "31.145.137.139", port: 31871}, {ip: "31.145.138.129", port: 31871}, {ip: "31.145.138.146", port: 34159}, {ip: "31.145.187.172", port: 30636}, {ip: "78.188.4.124", port: 34514}, {ip: "88.248.23.216", port: 36426}, {ip: "93.182.72.36", port: 8080}, {ip: "95.0.194.241", port: 9090}], +} diff --git a/src/invidious/yt_backend/url_sanitizer.cr b/src/invidious/yt_backend/url_sanitizer.cr deleted file mode 100644 index d539dadb..00000000 --- a/src/invidious/yt_backend/url_sanitizer.cr +++ /dev/null @@ -1,121 +0,0 @@ -require "uri" - -module UrlSanitizer - extend self - - ALLOWED_QUERY_PARAMS = { - channel: ["u", "user", "lb"], - playlist: ["list"], - search: ["q", "search_query", "sp"], - watch: [ - "v", # Video ID - "list", "index", # Playlist-related - "playlist", # Unnamed playlist (id,id,id,...) (embed-only?) - "t", "time_continue", "start", "end", # Timestamp - "lc", # Highlighted comment (watch page only) - ], - } - - # Returns whether the given string is an ASCII word. This is the same as - # running the following regex in US-ASCII locale: /^[\w-]+$/ - private def ascii_word?(str : String) : Bool - return false if str.bytesize != str.size - - str.each_byte do |byte| - next if 'a'.ord <= byte <= 'z'.ord - next if 'A'.ord <= byte <= 'Z'.ord - next if '0'.ord <= byte <= '9'.ord - next if byte == '-'.ord || byte == '_'.ord - - return false - end - - return true - end - - # Return which kind of parameters are allowed based on the - # first path component (breadcrumb 0). - private def determine_allowed(path_root : String) - case path_root - when "watch", "w", "v", "embed", "e", "shorts", "clip" - return :watch - when .starts_with?("@"), "c", "channel", "user", "profile", "attribution_link" - return :channel - when "playlist", "mix" - return :playlist - when "results", "search" - return :search - else # hashtag, post, trending, brand URLs, etc.. - return nil - end - end - - # Create a new URI::Param containing only the allowed parameters - private def copy_params(unsafe_params : URI::Params, allowed_type) : URI::Params - new_params = URI::Params.new - - ALLOWED_QUERY_PARAMS[allowed_type].each do |name| - if unsafe_params[name]? - # Only copy the last parameter, in case there is more than one - new_params[name] = unsafe_params.fetch_all(name)[-1] - end - end - - return new_params - end - - # Transform any user-supplied youtube URL into something we can trust - # and use across the code. - def process(str : String) : URI - # Because URI follows RFC3986 specifications, URL without a scheme - # will be parsed as a relative path. So we have to add a scheme ourselves. - str = "https://#{str}" if !str.starts_with?(/https?:\/\//) - - unsafe_uri = URI.parse(str) - unsafe_host = unsafe_uri.host - unsafe_path = unsafe_uri.path - - new_uri = URI.new(path: "/") - - # Redirect to homepage for bogus URLs - return new_uri if (unsafe_host.nil? || unsafe_path.nil?) - - breadcrumbs = unsafe_path - .split('/', remove_empty: true) - .compact_map do |bc| - # Exclude attempts at path trasversal - next if bc == "." || bc == ".." - - # Non-alnum characters are unlikely in a genuine URL - next if !ascii_word?(bc) - - bc - end - - # If nothing remains, it's either a legit URL to the homepage - # (who does that!?) or because we filtered some junk earlier. - return new_uri if breadcrumbs.empty? - - # Replace the original query parameters with the sanitized ones - case unsafe_host - when .ends_with?("youtube.com") - # Use our sanitized path (not forgetting the leading '/') - new_uri.path = "/#{breadcrumbs.join('/')}" - - # Then determine which params are allowed, and copy them over - if allowed = determine_allowed(breadcrumbs[0]) - new_uri.query_params = copy_params(unsafe_uri.query_params, allowed) - end - when "youtu.be" - # Always redirect to the watch page - new_uri.path = "/watch" - - new_params = copy_params(unsafe_uri.query_params, :watch) - new_params["v"] = breadcrumbs[0] - - new_uri.query_params = new_params - end - - return new_uri - end -end diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index b40092a1..5bbd9213 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -5,178 +5,60 @@ module YoutubeAPI extend self - # For Android versions, see https://en.wikipedia.org/wiki/Android_version_history - private ANDROID_APP_VERSION = "19.32.34" - private ANDROID_VERSION = "12" - private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; US) gzip" - private ANDROID_SDK_VERSION = 31_i64 - - private ANDROID_TS_APP_VERSION = "1.9" - private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip" - - # For Apple device names, see https://gist.github.com/adamawolf/3048717 - # For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases, - # then go to the dedicated article of the major version you want. - private IOS_APP_VERSION = "19.32.8" - private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)" - private IOS_VERSION = "17.6.1.21G93" # Major.Minor.Patch.Build - - private WINDOWS_VERSION = "10.0" - # Enumerate used to select one of the clients supported by the API enum ClientType Web WebEmbeddedPlayer WebMobile WebScreenEmbed - WebCreator - Android AndroidEmbeddedPlayer AndroidScreenEmbed - AndroidTestSuite - - IOS - IOSEmbedded - IOSMusic - - TvHtml5 - TvHtml5ScreenEmbed end # List of hard-coded values used by the different clients HARDCODED_CLIENTS = { ClientType::Web => { - name: "WEB", - name_proto: "1", - version: "2.20240814.00.00", - screen: "WATCH_FULL_SCREEN", - os_name: "Windows", - os_version: WINDOWS_VERSION, - platform: "DESKTOP", + name: "WEB", + version: "2.20210721.00.00", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "WATCH_FULL_SCREEN", }, ClientType::WebEmbeddedPlayer => { - name: "WEB_EMBEDDED_PLAYER", - name_proto: "56", - version: "1.20240812.01.00", - screen: "EMBED", - os_name: "Windows", - os_version: WINDOWS_VERSION, - platform: "DESKTOP", + name: "WEB_EMBEDDED_PLAYER", # 56 + version: "1.20210721.1.0", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "EMBED", }, ClientType::WebMobile => { - name: "MWEB", - name_proto: "2", - version: "2.20240813.02.00", - os_name: "Android", - os_version: ANDROID_VERSION, - platform: "MOBILE", + name: "MWEB", + version: "2.20210726.08.00", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "", # None }, ClientType::WebScreenEmbed => { - name: "WEB", - name_proto: "1", - version: "2.20240814.00.00", - screen: "EMBED", - os_name: "Windows", - os_version: WINDOWS_VERSION, - platform: "DESKTOP", + name: "WEB", + version: "2.20210721.00.00", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "EMBED", }, - ClientType::WebCreator => { - name: "WEB_CREATOR", - name_proto: "62", - version: "1.20240918.03.00", - os_name: "Windows", - os_version: WINDOWS_VERSION, - platform: "DESKTOP", - }, - - # Android - ClientType::Android => { - name: "ANDROID", - name_proto: "3", - version: ANDROID_APP_VERSION, - android_sdk_version: ANDROID_SDK_VERSION, - user_agent: ANDROID_USER_AGENT, - os_name: "Android", - os_version: ANDROID_VERSION, - platform: "MOBILE", + name: "ANDROID", + version: "16.20", + api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", + screen: "", # ?? }, ClientType::AndroidEmbeddedPlayer => { - name: "ANDROID_EMBEDDED_PLAYER", - name_proto: "55", - version: ANDROID_APP_VERSION, + name: "ANDROID_EMBEDDED_PLAYER", # 55 + version: "16.20", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "", # None? }, ClientType::AndroidScreenEmbed => { - name: "ANDROID", - name_proto: "3", - version: ANDROID_APP_VERSION, - screen: "EMBED", - android_sdk_version: ANDROID_SDK_VERSION, - user_agent: ANDROID_USER_AGENT, - os_name: "Android", - os_version: ANDROID_VERSION, - platform: "MOBILE", - }, - ClientType::AndroidTestSuite => { - name: "ANDROID_TESTSUITE", - name_proto: "30", - version: ANDROID_TS_APP_VERSION, - android_sdk_version: ANDROID_SDK_VERSION, - user_agent: ANDROID_TS_USER_AGENT, - os_name: "Android", - os_version: ANDROID_VERSION, - platform: "MOBILE", - }, - - # IOS - - ClientType::IOS => { - name: "IOS", - name_proto: "5", - version: IOS_APP_VERSION, - user_agent: IOS_USER_AGENT, - device_make: "Apple", - device_model: "iPhone14,5", - os_name: "iPhone", - os_version: IOS_VERSION, - platform: "MOBILE", - }, - ClientType::IOSEmbedded => { - name: "IOS_MESSAGES_EXTENSION", - name_proto: "66", - version: IOS_APP_VERSION, - user_agent: IOS_USER_AGENT, - device_make: "Apple", - device_model: "iPhone14,5", - os_name: "iPhone", - os_version: IOS_VERSION, - platform: "MOBILE", - }, - ClientType::IOSMusic => { - name: "IOS_MUSIC", - name_proto: "26", - version: "7.14", - user_agent: "com.google.ios.youtubemusic/7.14 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)", - device_make: "Apple", - device_model: "iPhone14,5", - os_name: "iPhone", - os_version: IOS_VERSION, - platform: "MOBILE", - }, - - # TV app - - ClientType::TvHtml5 => { - name: "TVHTML5", - name_proto: "7", - version: "7.20240813.07.00", - }, - ClientType::TvHtml5ScreenEmbed => { - name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", - name_proto: "85", - version: "2.0", - screen: "EMBED", + name: "ANDROID", # 3 + version: "16.20", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "EMBED", }, } @@ -197,6 +79,10 @@ module YoutubeAPI # conf_2 = ClientConfig.new(client_type: ClientType::Android) # YoutubeAPI::player(video_id: "dQw4w9WgXcQ", client_config: conf_2) # + # # Proxy request through russian proxies + # conf_3 = ClientConfig.new(proxy_region: "RU") + # YoutubeAPI::next({video_id: "dQw4w9WgXcQ"}, client_config: conf_3) + # ``` # struct ClientConfig # Type of client to emulate. @@ -207,11 +93,16 @@ module YoutubeAPI # (this is passed as the `gl` parameter). property region : String | Nil + # ISO code of country where the proxy is located. + # Used in case of geo-restricted videos. + property proxy_region : String | Nil + # Initialization function def initialize( *, @client_type = ClientType::Web, @region = "US", + @proxy_region = nil ) end @@ -221,53 +112,27 @@ module YoutubeAPI HARDCODED_CLIENTS[@client_type][:name] end - def name_proto : String - HARDCODED_CLIENTS[@client_type][:name_proto] - end - # :ditto: def version : String HARDCODED_CLIENTS[@client_type][:version] end + # :ditto: + def api_key : String + HARDCODED_CLIENTS[@client_type][:api_key] + end + # :ditto: def screen : String - HARDCODED_CLIENTS[@client_type][:screen]? || "" - end - - def android_sdk_version : Int64? - HARDCODED_CLIENTS[@client_type][:android_sdk_version]? - end - - def user_agent : String? - HARDCODED_CLIENTS[@client_type][:user_agent]? - end - - def os_name : String? - HARDCODED_CLIENTS[@client_type][:os_name]? - end - - def device_make : String? - HARDCODED_CLIENTS[@client_type][:device_make]? - end - - def device_model : String? - HARDCODED_CLIENTS[@client_type][:device_model]? - end - - def os_version : String? - HARDCODED_CLIENTS[@client_type][:os_version]? - end - - def platform : String? - HARDCODED_CLIENTS[@client_type][:platform]? + HARDCODED_CLIENTS[@client_type][:screen] end # Convert to string, for logging purposes def to_s return { - client_type: self.name, - region: @region, + client_type: self.name, + region: @region, + proxy_region: @proxy_region, }.to_s end end @@ -281,7 +146,7 @@ module YoutubeAPI # Return, as a Hash, the "context" data required to request the # youtube API endpoints. # - private def make_context(client_config : ClientConfig | Nil, video_id = "dQw4w9WgXcQ") : Hash + private def make_context(client_config : ClientConfig | Nil) : Hash # Use the default client config if nil is passed client_config ||= DEFAULT_CLIENT_CONFIG @@ -291,7 +156,7 @@ module YoutubeAPI "gl" => client_config.region || "US", # Can't be empty! "clientName" => client_config.name, "clientVersion" => client_config.version, - } of String => String | Int64, + }, } # Add some more context if it exists in the client definitions @@ -301,36 +166,8 @@ module YoutubeAPI if client_config.screen == "EMBED" client_context["thirdParty"] = { - "embedUrl" => "https://www.youtube.com/embed/#{video_id}", - } of String => String | Int64 - end - - if android_sdk_version = client_config.android_sdk_version - client_context["client"]["androidSdkVersion"] = android_sdk_version - end - - if device_make = client_config.device_make - client_context["client"]["deviceMake"] = device_make - end - - if device_model = client_config.device_model - client_context["client"]["deviceModel"] = device_model - end - - if os_name = client_config.os_name - client_context["client"]["osName"] = os_name - end - - if os_version = client_config.os_version - client_context["client"]["osVersion"] = os_version - end - - if platform = client_config.platform - client_context["client"]["platform"] = platform - end - - if CONFIG.visitor_data.is_a?(String) - client_context["client"]["visitorData"] = CONFIG.visitor_data.as(String) + "embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ", + } end return client_context @@ -370,7 +207,7 @@ module YoutubeAPI browse_id : String, *, # Force the following parameters to be passed by name params : String, - client_config : ClientConfig | Nil = nil, + client_config : ClientConfig | Nil = nil ) # JSON Request data, required by the API data = { @@ -464,35 +301,12 @@ module YoutubeAPI video_id : String, *, # Force the following parameters to be passed by name params : String, - client_config : ClientConfig | Nil = nil, + client_config : ClientConfig | Nil = nil ) - # Playback context, separate because it can be different between clients - playback_ctx = { - "html5Preference" => "HTML5_PREF_WANTS", - "referer" => "https://www.youtube.com/watch?v=#{video_id}", - } of String => String | Int64 - - if {"WEB", "TVHTML5"}.any? { |s| client_config.name.starts_with? s } - if sts = DECRYPT_FUNCTION.try &.get_sts - playback_ctx["signatureTimestamp"] = sts.to_i64 - end - end - # JSON Request data, required by the API data = { - "contentCheckOk" => true, - "videoId" => video_id, - "context" => self.make_context(client_config, video_id), - "racyCheckOk" => true, - "user" => { - "lockedSafetyMode" => false, - }, - "playbackContext" => { - "contentPlaybackContext" => playback_ctx, - }, - "serviceIntegrityDimensions" => { - "poToken" => CONFIG.po_token, - }, + "videoId" => video_id, + "context" => self.make_context(client_config), } # Append the additional parameters if those were provided @@ -500,11 +314,7 @@ module YoutubeAPI data["params"] = params end - if CONFIG.invidious_companion.present? - return self._post_invidious_companion("/youtubei/v1/player", data) - else - return self._post_json("/youtubei/v1/player", data, client_config) - end + return self._post_json("/youtubei/v1/player", data, client_config) end #################################################################### @@ -561,7 +371,7 @@ module YoutubeAPI def search( search_query : String, params : String, - client_config : ClientConfig | Nil = nil, + client_config : ClientConfig | Nil = nil ) # JSON Request data, required by the API data = { @@ -573,30 +383,6 @@ module YoutubeAPI return self._post_json("/youtubei/v1/search", data, client_config) end - #################################################################### - # get_transcript(params, client_config?) - # - # Requests the youtubei/v1/get_transcript endpoint with the required headers - # and POST data in order to get a JSON reply. - # - # The requested data is a specially encoded protobuf string that denotes the specific language requested. - # - # An optional ClientConfig parameter can be passed, too (see - # `struct ClientConfig` above for more details). - # - - def get_transcript( - params : String, - client_config : ClientConfig | Nil = nil, - ) : Hash(String, JSON::Any) - data = { - "context" => self.make_context(client_config), - "params" => params, - } - - return self._post_json("/youtubei/v1/get_transcript", data, client_config) - end - #################################################################### # _post_json(endpoint, data, client_config?) # @@ -609,44 +395,36 @@ module YoutubeAPI def _post_json( endpoint : String, data : Hash, - client_config : ClientConfig | Nil, + client_config : ClientConfig | Nil ) : Hash(String, JSON::Any) # Use the default client config if nil is passed client_config ||= DEFAULT_CLIENT_CONFIG # Query parameters - url = "#{endpoint}?prettyPrint=false" + url = "#{endpoint}?key=#{client_config.api_key}" headers = HTTP::Headers{ - "Content-Type" => "application/json; charset=UTF-8", - "Accept-Encoding" => "gzip, deflate", - "x-goog-api-format-version" => "2", - "x-youtube-client-name" => client_config.name_proto, - "x-youtube-client-version" => client_config.version, + "Content-Type" => "application/json; charset=UTF-8", + "Accept-Encoding" => "gzip, deflate", } - if user_agent = client_config.user_agent - headers["User-Agent"] = user_agent - end - - if CONFIG.visitor_data.is_a?(String) - headers["X-Goog-Visitor-Id"] = CONFIG.visitor_data.as(String) - end - # Logging LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") LOGGER.trace("YoutubeAPI: POST data: #{data}") # Send the POST request - body = YT_POOL.client() do |client| - client.post(url, headers: headers, body: data.to_json) do |response| - if response.status_code != 200 - raise InfoException.new("Error: non 200 status code. Youtube API returned \ - status code #{response.status_code}. See \ - https://docs.invidious.io/youtube-errors-explained/ for troubleshooting.") + if {{ !flag?(:disable_quic) }} && CONFIG.use_quic + # Using QUIC client + body = YT_POOL.client(client_config.proxy_region, + &.post(url, headers: headers, body: data.to_json) + ).body + else + # Using HTTP client + body = YT_POOL.client(client_config.proxy_region) do |client| + client.post(url, headers: headers, body: data.to_json) do |response| + self._decompress(response.body_io, response.headers["Content-Encoding"]?) end - self._decompress(response.body_io, response.headers["Content-Encoding"]?) end end @@ -670,49 +448,6 @@ module YoutubeAPI return initial_data end - #################################################################### - # _post_invidious_companion(endpoint, data) - # - # Internal function that does the actual request to Invidious companion - # and handles errors. - # - # The requested data is an endpoint (URL without the domain part) - # and the data as a Hash object. - # - def _post_invidious_companion( - endpoint : String, - data : Hash, - ) : Hash(String, JSON::Any) - headers = HTTP::Headers{ - "Content-Type" => "application/json; charset=UTF-8", - "Authorization" => "Bearer #{CONFIG.invidious_companion_key}", - } - - # Logging - LOGGER.debug("Invidious companion: Using endpoint: \"#{endpoint}\"") - LOGGER.trace("Invidious companion: POST data: #{data}") - - # Send the POST request - - begin - response = COMPANION_POOL.client &.post(endpoint, headers: headers, body: data.to_json) - body = response.body - if (response.status_code != 200) - raise Exception.new( - "Error while communicating with Invidious companion: \ - status code: #{response.status_code} and body: #{body.dump}" - ) - end - rescue ex - raise InfoException.new("Error while communicating with Invidious companion: " + (ex.message || "no extra info found")) - end - - # Convert result to Hash - initial_data = JSON.parse(body).as_h - - return initial_data - end - #################################################################### # _decompress(body_io, headers) # diff --git a/videojs-dependencies.yml b/videojs-dependencies.yml index e9ccc9dd..6de23d25 100644 --- a/videojs-dependencies.yml +++ b/videojs-dependencies.yml @@ -1,7 +1,9 @@ -# Due to a 'video append of' error (see #3011), we're stuck on 7.12.1. +# Due to an firefox issue, we're stuck on 7.11.0. If you're hosting a private instance +# and you're using a chromium based browser, feel free to bump this to the latest version +# in order to get support for higher resolutions on more videos. video.js: - version: 7.12.1 - shasum: 1d12eeb1f52e3679e8e4c987d9b9eb37e2247fa2 + version: 7.11.0 + shasum: e20747d890716085e7255a90d73c00f32324a224 videojs-contrib-quality-levels: version: 2.1.0