diff options
52 files changed, 1810 insertions, 710 deletions
diff --git a/assets/js/player.js b/assets/js/player.js index 0cc4bab9..5ff55eb3 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -59,6 +59,16 @@ videojs.Hls.xhr.beforeRequest = function(options) { var player = videojs('player', options); +const storage = (() => { + try { + if (localStorage.length !== -1) { + return localStorage; + } + } catch (e) { + console.info('No storage available: ' + e); + } + return undefined; +})(); if (location.pathname.startsWith('/embed/')) { player.overlay({ @@ -215,7 +225,7 @@ if (video_data.params.save_player_pos) { const raw = player.currentTime(); const time = Math.floor(raw); - if(lastUpdated !== time) { + if(lastUpdated !== time && raw <= video_data.length_seconds - 15) { save_video_time(time); lastUpdated = time; } @@ -386,25 +396,35 @@ function get_video_time() { } function set_all_video_times(times) { - const json = JSON.stringify(times); - - localStorage.setItem(save_player_pos_key, json); + if (storage) { + if (times) { + try { + storage.setItem(save_player_pos_key, JSON.stringify(times)); + } catch (e) { + console.debug('set_all_video_times: ' + e); + } + } else { + storage.removeItem(save_player_pos_key); + } + } } function get_all_video_times() { - try { - const raw = localStorage.getItem(save_player_pos_key); - const times = JSON.parse(raw); - - return times || {}; - } - catch { - return {}; + if (storage) { + const raw = storage.getItem(save_player_pos_key); + if (raw !== null) { + try { + return JSON.parse(raw); + } catch (e) { + console.debug('get_all_video_times: ' + e); + } + } } + return {}; } function remove_all_video_times() { - localStorage.removeItem(save_player_pos_key); + set_all_video_times(null); } function set_time_percent(percent) { diff --git a/assets/js/themes.js b/assets/js/themes.js index 543b849e..470f10bf 100644 --- a/assets/js/themes.js +++ b/assets/js/themes.js @@ -11,7 +11,9 @@ toggle_theme.addEventListener('click', function () { xhr.open('GET', url, true); set_mode(dark_mode); - window.localStorage.setItem('dark_mode', dark_mode ? 'dark' : 'light'); + try { + window.localStorage.setItem('dark_mode', dark_mode ? 'dark' : 'light'); + } catch {} xhr.send(); }); @@ -23,9 +25,12 @@ window.addEventListener('storage', function (e) { }); window.addEventListener('DOMContentLoaded', function () { - window.localStorage.setItem('dark_mode', document.getElementById('dark_mode_pref').textContent); - // Update localStorage if dark mode preference changed on preferences page - update_mode(window.localStorage.dark_mode); + const dark_mode = document.getElementById('dark_mode_pref').textContent; + try { + // Update localStorage if dark mode preference changed on preferences page + window.localStorage.setItem('dark_mode', dark_mode); + } catch {} + update_mode(dark_mode); }); @@ -37,9 +42,11 @@ lightScheme.addListener(scheme_switch); function scheme_switch (e) { // ignore this method if we have a preference set - if (localStorage.getItem('dark_mode')) { - return; - } + try { + if (localStorage.getItem('dark_mode')) { + return; + } + } catch {} if (e.matches) { if (e.media.includes("dark")) { set_mode(true); diff --git a/docker/Dockerfile b/docker/Dockerfile index 5f1c0a11..c73821da 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -18,7 +18,7 @@ COPY ./.git/ ./.git/ RUN crystal spec --warnings all \ --link-flags "-lxml2 -llzma" -RUN if [ ${release} == 1 ] ; then \ +RUN if [ "${release}" == 1 ] ; then \ crystal build ./src/invidious.cr \ --release \ --static --warnings all \ diff --git a/locales/ar.json b/locales/ar.json index 6e8792ca..3e9d3ac6 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -461,5 +461,6 @@ "Video unavailable": "الفيديو غير متوفر", "360": "360°", "download_subtitles": "ترجمات - 'x' (.vtt)", - "invidious": "الخيالي" + "invidious": "الخيالي", + "preferences_save_player_pos_label": "احفظ وقت الفيديو الحالي: " } diff --git a/locales/da.json b/locales/da.json index d8954f45..c08984d9 100644 --- a/locales/da.json +++ b/locales/da.json @@ -433,5 +433,34 @@ "channel": "Kanal", "3d": "3D", "4k": "4K", - "Hmong": "Hmong" + "Hmong": "Hmong", + "preferences_quality_option_medium": "Medium", + "preferences_quality_option_small": "Lille", + "preferences_quality_dash_option_best": "Bedste", + "preferences_quality_dash_option_worst": "Værste", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "purchased": "Købt", + "360": "360°", + "none": "ingen", + "videoinfo_started_streaming_x_ago": "Streamen blev startet for `x`siden", + "videoinfo_watch_on_youTube": "Se på YouTube", + "videoinfo_youTube_embed_link": "Integrer", + "videoinfo_invidious_embed_link": "Integrer Link", + "download_subtitles": "Undertekster - `x`(.vtt)", + "user_created_playlists": "`x`opretede spillelister", + "user_saved_playlists": "´x`gemte spillelister", + "Video unavailable": "Video ikke tilgængelig", + "preferences_save_player_pos_label": "Gem den nuværende videotid: ", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_option_dash": "DASH (adaptiv kvalitet)", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_240p": "240p" } diff --git a/locales/de.json b/locales/de.json index f0663f12..9ab5b211 100644 --- a/locales/de.json +++ b/locales/de.json @@ -84,7 +84,7 @@ "Dark mode: ": "Nachtmodus: ", "preferences_dark_mode_label": "Modus: ", "dark": "Nachtmodus", - "light": "Hellermodus", + "light": "hell", "preferences_thin_mode_label": "Schlanker Modus: ", "preferences_category_misc": "Sonstige Einstellungen", "preferences_automatic_instance_redirect_label": "Automatische Instanzweiterleitung (über redirect.invidious.io): ", @@ -149,7 +149,7 @@ "Source available here.": "Quellcode verfügbar hier.", "View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.", "View privacy policy.": "Datenschutzerklärung einsehen.", - "Trending": "Trending", + "Trending": "Angesagt", "Public": "Öffentlich", "Unlisted": "Nicht aufgeführt", "Private": "Privat", @@ -422,7 +422,7 @@ "filter": "Filtern", "Current version: ": "Aktuelle Version: ", "next_steps_error_message": "Danach folgendes versuchen: ", - "next_steps_error_message_refresh": "Neuladen", + "next_steps_error_message_refresh": "Aktualisieren", "next_steps_error_message_go_to_youtube": "Zu YouTube gehen", "footer_donate_page": "Spende", "long": "Lang (> 20 Minuten)", @@ -432,5 +432,35 @@ "footer_source_code": "Quellcode", "adminprefs_modified_source_code_url_label": "URL zum Repositorie des modifizierten Quellcodes", "short": "Kurz (< 4 Minuten)", - "preferences_region_label": "Land der Inhalte: " + "preferences_region_label": "Land der Inhalte: ", + "preferences_quality_option_dash": "DASH (automatische Qualität)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Mittel", + "preferences_quality_option_small": "Niedrig", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "videoinfo_invidious_embed_link": "Link zum Einbetten", + "download_subtitles": "Untertitel - `x` (.vtt)", + "Video unavailable": "Video nicht verfügbar", + "user_created_playlists": "`x` Wiedergabelisten erstellt", + "user_saved_playlists": "`x` Wiedergabelisten gespeichert", + "preferences_save_player_pos_label": "Aktuelle Position speichern: ", + "360": "360°", + "preferences_quality_dash_option_best": "Höchste", + "preferences_quality_dash_option_worst": "Niedrigste", + "preferences_quality_dash_option_1440p": "1440p", + "videoinfo_youTube_embed_link": "Eingebettet", + "purchased": "Gekauft", + "none": "keine", + "videoinfo_started_streaming_x_ago": "Stream begann vor `x`", + "videoinfo_watch_on_youTube": "Auf YouTube ansehen", + "preferences_quality_dash_label": "Bevorzugte DASH-Videoqualität: " } diff --git a/locales/es.json b/locales/es.json index 059747be..9f876ccb 100644 --- a/locales/es.json +++ b/locales/es.json @@ -461,5 +461,6 @@ "preferences_quality_dash_option_1080p": "1080p", "purchased": "Comprado", "360": "360°", - "videoinfo_watch_on_youTube": "Ver en YouTube" + "videoinfo_watch_on_youTube": "Ver en YouTube", + "preferences_save_player_pos_label": "Guardar el tiempo del vídeo actual: " } diff --git a/locales/fa.json b/locales/fa.json index efee1cdb..1f723a63 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -1,51 +1,51 @@ { "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشترکان", - "": "`x` مشترکان" + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` دنبال کننده", + "": "`x` دنبال کننده" }, "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ویدیو ها", - "": "`x` ویدیو ها" + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ویدئو", + "": "`x` ویدئو" }, "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` سیاههٔ پخش", - "": "`x` سیاهههای پخش" + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` فهرست پخش", + "": "`x` فهرست پخش" }, "LIVE": "زنده", - "Shared `x` ago": "به اشتراک گذاشته شده `x` پیش", + "Shared `x` ago": "`x` پیش به اشتراک گذاشته شده", "Unsubscribe": "لغو اشتراک", "Subscribe": "مشترک شدن", - "View channel on YouTube": "نمایش کانال در یوتیوب", - "View playlist on YouTube": "نمایش سیاههٔ پخش در یوتیوب", - "newest": "جدید تر", - "oldest": "قدیمی تر", + "View channel on YouTube": "دیدن کانال در یوتیوب", + "View playlist on YouTube": "دیدن فهرست پخش در یوتیوب", + "newest": "تازهترین", + "oldest": "کهنهترین", "popular": "محبوب", "last": "آخرین", "Next page": "صفحه بعد", "Previous page": "صفحه قبل", "Clear watch history?": "پاک کردن تاریخچه نمایش؟", - "New password": "گذرواژه جدید", - "New passwords must match": "گذارواژه های جدید باید باهم همخوانی داشته باشند", + "New password": "گذرواژه تازه", + "New passwords must match": "گذارواژه های تازه باید باهم همخوانی داشته باشند", "Cannot change password for Google accounts": "نمیتوان گذرواژه را برای حساب های کاربری گوگل تغییر داد", "Authorize token?": "توکن دسترسی؟", "Authorize token for `x`?": "توکن دسترسی برای `x`؟", "Yes": "بله", "No": "خیر", - "Import and Export Data": "وارد کردن و خارج کردن داده ها", - "Import": "وارد کردن", - "Import Invidious data": "وارد کردن داده Invidious", - "Import YouTube subscriptions": "وارد کردن اشتراک های یوتیوب", - "Import FreeTube subscriptions (.db)": "وارد کردن اشتراک های فری توب (.db)", - "Import NewPipe subscriptions (.json)": "وارد کردن اشتراک های نیو پایپ (.json)", - "Import NewPipe data (.zip)": "وارد کردن داده نیو پایپ (.zip)", - "Export": "خارج کردن", - "Export subscriptions as OPML": "خارج کردن اشتراک ها به عنوان OPML", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "خارج کردن اشتراک ها به عنوان OPML (برای فری توب و نیو پایپ)", - "Export data as JSON": "خارج کردن داده ها به عنوان JSON", + "Import and Export Data": "درونبرد و برونبرد داده", + "Import": "درونبرد", + "Import Invidious data": "درونبرد داده اینویدیوس", + "Import YouTube subscriptions": "درونبرد اشتراکهای یوتیوب", + "Import FreeTube subscriptions (.db)": "درونبرد اشتراکهای فریتیوب (.db)", + "Import NewPipe subscriptions (.json)": "درونبرد اشتراکهای نیوپایپ (.json)", + "Import NewPipe data (.zip)": "درونبرد داده نیوپایپ (.zip)", + "Export": "برونبرد", + "Export subscriptions as OPML": "برونبرد اشتراکها در قالب OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "برونبرد اشتراکها در قالب OPML (برای نیوپایپ و فریتیوب)", + "Export data as JSON": "برونبرد داده در قالب JSON", "Delete account?": "حذف حساب کاربری؟", "History": "تاریخچه", - "An alternative front-end to YouTube": "یک فرانت-اند جایگذین برای یوتیوب", - "JavaScript license information": "اطلاعات مجوز جاوا اسکریپت", + "An alternative front-end to YouTube": "یک پیشانه جایگزین برای یوتیوب", + "JavaScript license information": "اطلاعات پروانه جاوااسکریپت", "source": "منبع", "Log in": "ورود", "Log in/register": "ورود/ثبت نام", @@ -53,15 +53,15 @@ "User ID": "شناسه کاربری", "Password": "گذرواژه", "Time (h:mm:ss):": "زمان (h:mm:ss):", - "Text CAPTCHA": "متن CAPTCHA", - "Image CAPTCHA": "تصویر CAPTCHA", + "Text CAPTCHA": "کپچای متنی", + "Image CAPTCHA": "کپچای تصویری", "Sign In": "ورود", "Register": "ثبت نام", "E-mail": "ایمیل", "Google verification code": "کد تایید گوگل", "Preferences": "ترجیحات", "preferences_category_player": "ترجیحات نمایشدهنده", - "preferences_video_loop_label": "همیشه تکرار شنوده: ", + "preferences_video_loop_label": "همواره ویدئو را بازپخش کن ", "preferences_autoplay_label": "نمایش خودکار: ", "preferences_continue_label": "پخش بعدی به طور پیشفرض: ", "preferences_continue_autoplay_label": "پخش خودکار ویدیو بعدی: ", @@ -423,5 +423,42 @@ "Current version: ": "نسخه فعلی: ", "next_steps_error_message": "اکنون بایستی یکی از این موارد را امتحان کنید: ", "next_steps_error_message_refresh": "تازهسازی", - "next_steps_error_message_go_to_youtube": "رفتن به یوتیوب" + "next_steps_error_message_go_to_youtube": "رفتن به یوتیوب", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_dash": "DASH (کیفیت قابل تطبیق)", + "preferences_quality_option_medium": "میانه", + "preferences_quality_option_small": "پایین", + "preferences_quality_dash_option_auto": "خودکار", + "preferences_quality_dash_option_best": "بهترین", + "preferences_quality_dash_option_worst": "بدترین", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "اینویدیوس", + "360": "360°", + "footer_donate_page": "کمک مالی", + "footer_source_code": "کد منبع", + "footer_modfied_source_code": "کد منبع ویرایش شده", + "none": "هیچکدام", + "videoinfo_started_streaming_x_ago": "پخش جریانی `x` پیش آغاز شد", + "videoinfo_watch_on_youTube": "تماشا در یوتیوب", + "videoinfo_youTube_embed_link": "توکار", + "videoinfo_invidious_embed_link": "پیوند توکار", + "download_subtitles": "زیرنویسها - `x` (.vtt)", + "Video unavailable": "ویدئو دردسترس نیست", + "preferences_save_player_pos_label": "ذخیره زمان کنونی ویدئو: ", + "purchased": "خریداری شده", + "preferences_quality_dash_label": "کیفیت ترجیحی ویدئو DASH: ", + "preferences_region_label": "کشور محتوا: ", + "footer_documentation": "مستندات", + "footer_original_source_code": "کد منبع اصلی", + "long": "بلند (> 20 دقیقه)", + "adminprefs_modified_source_code_url_label": "URL مخزن کد منبع ویریش شده", + "short": "کوتاه (< 4 دقیقه)" } diff --git a/locales/fr.json b/locales/fr.json index aa439f89..5ebd6f70 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -461,5 +461,6 @@ "preferences_quality_option_dash": "DASH (qualité adaptative)", "preferences_quality_dash_option_1440p": "1440p", "preferences_quality_dash_option_1080p": "1080p", - "user_created_playlists": "`x` listes de lecture créées" + "user_created_playlists": "`x` listes de lecture créées", + "preferences_save_player_pos_label": "Sauvegarder la durée actuelle de la vidéo : " } diff --git a/locales/hr.json b/locales/hr.json index d005e0dc..02c5d784 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -8,15 +8,15 @@ "": "`x` videa" }, "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playliste", - "": "`x` playliste" + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` zbirka", + "": "`x` zbirke" }, "LIVE": "UŽIVO", "Shared `x` ago": "Dijeljeno prije `x`", "Unsubscribe": "Odjavi pretplatu", "Subscribe": "Pretplati se", "View channel on YouTube": "Prikaži kanal na YouTubeu", - "View playlist on YouTube": "Prikaži playlistu na YouTubeu", + "View playlist on YouTube": "Prikaži zbirku na YouTubeu", "newest": "najnovije", "oldest": "najstarije", "popular": "popularni", @@ -153,14 +153,14 @@ "Public": "Javno", "Unlisted": "Nenavedeno", "Private": "Privatno", - "View all playlists": "Prikaži sve playliste", + "View all playlists": "Prikaži sve zbirke", "Updated `x` ago": "Aktualizirano prije `x`", - "Delete playlist `x`?": "Izbrisati playlistu `x`?", - "Delete playlist": "Izbriši playlistu", - "Create playlist": "Stvori playlistu", + "Delete playlist `x`?": "Izbrisati zbirku `x`?", + "Delete playlist": "Izbriši zbirku", + "Create playlist": "Stvori zbirku", "Title": "Naslov", - "Playlist privacy": "Privatnost playliste", - "Editing playlist `x`": "Uređivanje playliste `x`", + "Playlist privacy": "Privatnost zbirke", + "Editing playlist `x`": "Uređivanje zbirke `x`", "Show more": "Pokaži više", "Show less": "Pokaži manje", "Watch on YouTube": "Gledaj na YouTubeu", @@ -224,9 +224,9 @@ "": "`x` bodova" }, "Could not create mix.": "Neuspjelo stvaranje miksa.", - "Empty playlist": "Prazna playlista", - "Not a playlist.": "Nije playlista.", - "Playlist does not exist.": "Playlista ne postoji.", + "Empty playlist": "Prazna zbirka", + "Not a playlist.": "Nije zbirka.", + "Playlist does not exist.": "Zbirka ne postoji.", "Could not pull trending pages.": "Neuspjelo preuzimanje stranica u trendu.", "Hidden field \"challenge\" is a required field": "Skriveno polje „izazov” je obavezno polje", "Hidden field \"token\" is a required field": "Skriveno polje „token” je obavezno polje", @@ -375,7 +375,7 @@ "About": "Informacije", "Rating: ": "Ocjena: ", "preferences_locale_label": "Jezik: ", - "View as playlist": "Prikaži kao playlistu", + "View as playlist": "Prikaži kao zbirku", "Default": "Standardno", "Music": "Glazba", "Gaming": "Videoigre", @@ -391,7 +391,7 @@ "Audio mode": "Audio modus", "Video mode": "Videomodus", "Videos": "Videa", - "Playlists": "Playliste", + "Playlists": "Zbirke", "Community": "Zajednica", "relevance": "značaj", "rating": "ocjena", @@ -408,7 +408,7 @@ "year": "godina", "video": "video", "channel": "kanal", - "playlist": "playlista", + "playlist": "Zbirka", "movie": "film", "show": "emisija", "hd": "hd", @@ -433,5 +433,34 @@ "footer_documentation": "Dokumentacija", "footer_original_source_code": "Izvoran izvorni kod", "preferences_region_label": "Zemlja sadržaja: ", - "preferences_quality_dash_label": "Preferirana DASH videokvaliteta: " + "preferences_quality_dash_label": "Preferirana DASH videokvaliteta: ", + "preferences_quality_option_dash": "DASH (adaptativna kvaliteta)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Srednja", + "preferences_quality_dash_option_worst": "Najgora", + "preferences_quality_dash_option_4320p": "4320 p", + "preferences_quality_dash_option_2160p": "2160 p", + "preferences_quality_dash_option_1440p": "1440 p", + "preferences_quality_dash_option_1080p": "1080 p", + "preferences_quality_dash_option_360p": "360 p", + "preferences_quality_dash_option_240p": "240 p", + "preferences_quality_dash_option_144p": "144 p", + "invidious": "Invidious", + "purchased": "Kupljeno", + "360": "360 °", + "none": "bez", + "videoinfo_youTube_embed_link": "Ugradi", + "user_created_playlists": "`x` stvorene zbirke", + "user_saved_playlists": "`x` spremljene zbirke", + "Video unavailable": "Video nedostupan", + "preferences_save_player_pos_label": "Spremi trenutačno vrijeme videa: ", + "videoinfo_watch_on_youTube": "Gledaj na YouTubeu", + "download_subtitles": "Podnaslovi - `x` (.vtt)", + "preferences_quality_dash_option_auto": "Automatska", + "preferences_quality_option_small": "Niska", + "preferences_quality_dash_option_best": "Najbolja", + "preferences_quality_dash_option_720p": "720 p", + "preferences_quality_dash_option_480p": "480 p", + "videoinfo_started_streaming_x_ago": "Započet prijenos prije `x`", + "videoinfo_invidious_embed_link": "Ugradi poveznicu" } diff --git a/locales/hu-HU.json b/locales/hu-HU.json index 2e721a0d..14248e87 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -1,224 +1,234 @@ { "`x` subscribers": { - "": "`x` feliratkozó" + "": "`x` feliratkozó", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` feliratkozó" }, "`x` videos": { - "": "`x` videó" + "": "`x` videó", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videó" }, "`x` playlists": { - "": "`x` playlist" + "": "`x` lejátszási lista", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` lejátszási lista" }, "LIVE": "ÉLŐ", - "Shared `x` ago": "`x` óta megosztva", + "Shared `x` ago": "`x` ezelőtt lett megosztva", "Unsubscribe": "Leiratkozás", "Subscribe": "Feliratkozás", - "View channel on YouTube": "csatorna megtekintése a YouTube-on", - "View playlist on YouTube": "lejátszási lista megtekintése a YouTube-on", + "View channel on YouTube": "Csatorna megnézése YouTube-on", + "View playlist on YouTube": "Lejátszási lista megnézése YouTube-on", "newest": "legújabb", "oldest": "legrégibb", "popular": "népszerű", "last": "utolsó", "Next page": "Következő oldal", "Previous page": "Előző oldal", - "Clear watch history?": "Megtekintési napló törlése?", + "Clear watch history?": "Törölve legyen a megnézett videók listája?", "New password": "Új jelszó", - "New passwords must match": "Az új jelszavaknak egyezniük kell", - "Cannot change password for Google accounts": "Google fiók jelszavát nem lehet megváltoztatni", - "Authorize token?": "Token felhatalmazása?", - "Authorize token for `x`?": "Token felhatalmazása `x`-ra?", + "New passwords must match": "Az új jelszavaknak egyezniük kell!", + "Cannot change password for Google accounts": "A Google-fiók jelszavát nem lehet megváltoztatni.", + "Authorize token?": "Engedélyezve legyen a token?", + "Authorize token for `x`?": "Engedélyezve legyen a token erre? „`x`”", "Yes": "Igen", "No": "Nem", "Import and Export Data": "Adatok importálása és exportálása", "Import": "Importálás", - "Import Invidious data": "Invidious adatainak importálása", - "Import YouTube subscriptions": "YouTube feliratkozások importálása", - "Import FreeTube subscriptions (.db)": "FreeTube feliratkozások importálása (.db)", - "Import NewPipe subscriptions (.json)": "NewPipe feliratkozások importálása (.json)", + "Import Invidious data": "Az Invidious adatainak importálása", + "Import YouTube subscriptions": "YouTube-feliratkozások importálása", + "Import FreeTube subscriptions (.db)": "FreeTube-feliratkozások importálása (.db)", + "Import NewPipe subscriptions (.json)": "NewPipe-feliratkozások importálása (.json)", "Import NewPipe data (.zip)": "NewPipe adatainak importálása (.zip)", "Export": "Exportálás", "Export subscriptions as OPML": "Feliratkozások exportálása OPML-ként", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Feliratkozások exportálása OPML-ként (NewPipe és FreeTube számára)", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Feliratkozások exportálása OPML-ként (NewPipe-hoz és FreeTube-hoz)", "Export data as JSON": "Adat exportálása JSON-ként", - "Delete account?": "Fiók törlése?", - "History": "Megtekintési napló", + "Delete account?": "Törlésre kerüljön a fiók?", + "History": "Megnézések naplója", "An alternative front-end to YouTube": "Alternatív YouTube front-end", - "JavaScript license information": "JavaScript licensz információ", + "JavaScript license information": "A JavaScript licencinformációja", "source": "forrás", "Log in": "Bejelentkezés", "Log in/register": "Bejelentkezés/Regisztráció", - "Log in with Google": "Bejelentkezés Google fiókkal", - "User ID": "Felhasználó-ID", + "Log in with Google": "Bejelentkezés Google-fiókkal", + "User ID": "Felhasználói azonosító", "Password": "Jelszó", - "Time (h:mm:ss):": "Idő (h:mm:ss):", - "Text CAPTCHA": "Szöveg-CAPTCHA", - "Image CAPTCHA": "Kép-CAPTCHA", + "Time (h:mm:ss):": "A pontos idő (ó:pp:mm):", + "Text CAPTCHA": "Szöveges CAPTCHA kérése", + "Image CAPTCHA": "Kép CAPTCHA kérése", "Sign In": "Bejelentkezés", "Register": "Regisztráció", - "E-mail": "E-mail", - "Google verification code": "Google verifikációs kód", + "E-mail": "E-mail-cím", + "Google verification code": "A Google ellenőrző kódja", "Preferences": "Beállítások", - "preferences_category_player": "Lejátszó beállítások", - "preferences_video_loop_label": "Mindig loop-ol: ", + "preferences_category_player": "Lejátszó beállításai", + "preferences_video_loop_label": "Videó állandó ismétlése: ", "preferences_autoplay_label": "Automatikus lejátszás: ", - "preferences_continue_label": "Következő lejátszása alapértelmezésben: ", - "preferences_continue_autoplay_label": "Következő automatikus lejátszása: ", - "preferences_listen_label": "Hallgatás alapértelmezésben: ", - "preferences_local_label": "Videók proxyzása: ", + "preferences_continue_label": "A következő videót mindig automatikusan játssza le: ", + "preferences_continue_autoplay_label": "A következő videó automatikus lejátszása: ", + "preferences_listen_label": "Mindig csak a hangsáv lejátszása: ", + "preferences_local_label": "Videók proxyn keresztüli lejátszása: ", "preferences_speed_label": "Alapértelmezett sebesség: ", - "preferences_quality_label": "Kívánt video minőség: ", + "preferences_quality_label": "Videó minősége: ", "preferences_volume_label": "Hangerő: ", - "preferences_comments_label": "Alapértelmezett kommentek: ", + "preferences_comments_label": "Mindig innen legyenek betöltve a hozzászólások: ", "youtube": "YouTube", - "reddit": "reddit", - "preferences_captions_label": "Alapértelmezett feliratok: ", + "reddit": "Reddit", + "preferences_captions_label": "Felirat nyelvének sorrendje: ", "Fallback captions: ": "Másodlagos feliratok: ", - "preferences_related_videos_label": "Hasonló videók mutatása: ", - "preferences_annotations_label": "Szövegmagyarázatok mutatása alapértelmezésben: ", - "preferences_extend_desc_label": "Automatikusan növelje meg a videó leírását", - "preferences_vr_mode_label": "Interaktív 360° videók", - "preferences_category_visual": "Kinézeti beállítások", - "preferences_player_style_label": "Lejátszó stílusa: ", - "Dark mode: ": "Sötét mód: ", + "preferences_related_videos_label": "Hasonló videók ajánlása: ", + "preferences_annotations_label": "Szövegmagyarázatok alapértelmezett mutatása: ", + "preferences_extend_desc_label": "A videó leírása automatikusan látható: ", + "preferences_vr_mode_label": "Interaktív, 360°-os videók ", + "preferences_category_visual": "Kinézet, elrendezés és régió beállításai", + "preferences_player_style_label": "Lejátszó kinézete: ", + "Dark mode: ": "Elsötétített mód: ", "preferences_dark_mode_label": "Téma: ", "dark": "sötét", "light": "világos", "preferences_thin_mode_label": "Vékony mód: ", - "preferences_category_subscription": "Feliratkozási beállítások", - "preferences_annotations_subscribed_label": "Szövegmagyarázatok mutatása alapértelmezésben feliratkozott csatornák esetében: ", - "Redirect homepage to feed: ": "Kezdő oldal átirányitása a feed-re: ", - "preferences_max_results_label": "Feed-ben mutatott videók száma: ", - "preferences_sort_label": "Videók sorrendje: ", - "published": "közzétéve", - "published - reverse": "közzétéve - fordítva", - "alphabetically": "ABC sorrend", - "alphabetically - reverse": "ABC sorrend - fordítva", - "channel name": "csatorna neve", - "channel name - reverse": "csatorna neve - fordítva", - "Only show latest video from channel: ": "Csak a legutolsó videó mutatása a csatornából: ", - "Only show latest unwatched video from channel: ": "Csak a legutolsó nem megtekintett videó mutatása a csatornából: ", - "preferences_unseen_only_label": "Csak a nem megtekintettek mutatása: ", - "preferences_notifications_only_label": "Csak értesítések mutatása (ha van): ", - "Enable web notifications": "Web értesítések bekapcsolása", + "preferences_category_subscription": "Feliratkozott tartalmak beállításai", + "preferences_annotations_subscribed_label": "A feliratkozott csatornák szövegmagyarázatainak alapértelmezett mutatása: ", + "Redirect homepage to feed: ": "Kezdőoldal átirányitása a feedre: ", + "preferences_max_results_label": "Feedben mutatott videók száma: ", + "preferences_sort_label": "Videók rendezése: ", + "published": "közzététel szerint", + "published - reverse": "közzététel szerint – fordított sorrendben", + "alphabetically": "ABC-sorrend szerint", + "alphabetically - reverse": "Fordított ABC-sorrend szerint", + "channel name": "csatorna neve szerint", + "channel name - reverse": "csatorna neve szerint – fordított sorrendben", + "Only show latest video from channel: ": "Csak a csatorna legújabb videójának mutatása: ", + "Only show latest unwatched video from channel: ": "Csak a csatorna legújabb, de még nem megnézett videójának mutatása: ", + "preferences_unseen_only_label": "A még nem megnézett videók mutatása: ", + "preferences_notifications_only_label": "Csak az értesítések mutatása (ha van): ", + "Enable web notifications": "Böngészőn belüli értesítések bekapcsolása", "`x` uploaded a video": "`x` feltöltött egy videót", - "`x` is live": "`x` élő", - "preferences_category_data": "Adat beállítások", - "Clear watch history": "Megtekintési napló törlése", - "Import/export data": "Adat Import/Export", - "Change password": "Jelszócsere", + "`x` is live": "`x` élőben közvetít", + "preferences_category_data": "Fiók beállításai és egyéb lehetőségek", + "Clear watch history": "Megnézett videók listájának törlése", + "Import/export data": "Adatok importálása vagy exportálása", + "Change password": "Jelszó megváltoztatása", "Manage subscriptions": "Feliratkozások kezelése", "Manage tokens": "Tokenek kezelése", - "Watch history": "Megtekintési napló", + "Watch history": "Megnézett videók", "Delete account": "Fiók törlése", - "preferences_category_admin": "Adminisztrátor beállítások", - "preferences_default_home_label": "Alapértelmezett oldal: ", - "preferences_feed_menu_label": "Feed menü: ", - "Top enabled: ": "Top lista engedélyezve: ", + "preferences_category_admin": "Adminisztrátorok beállításai", + "preferences_default_home_label": "Kezdőoldal: ", + "preferences_feed_menu_label": "Feed menü sorrendje: ", + "Top enabled: ": "Toplista engedélyezve: ", "CAPTCHA enabled: ": "CAPTCHA engedélyezve: ", "Login enabled: ": "Bejelentkezés engedélyezve: ", - "Registration enabled: ": "Registztráció engedélyezve: ", - "Report statistics: ": "Statisztikák gyűjtése: ", + "Registration enabled: ": "Regisztráció engedélyezve: ", + "Report statistics: ": "Statisztika jelentése: ", "Save preferences": "Beállítások mentése", - "Subscription manager": "Feliratkozás kezelő", - "Token manager": "Token kezelő", + "Subscription manager": "Feliratkozások kezelője", + "Token manager": "Tokenek kezelője", "Token": "Token", "`x` subscriptions": { - "": "`x` feliratkozás" + "": "`x` csatornára van feliratkozás", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` csatornára van feliratkozás" }, "`x` tokens": { - "": "`x` token" + "": "`x` token", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token" }, - "Import/export": "Import/export", + "Import/export": "Importálás/exportálás", "unsubscribe": "leiratkozás", "revoke": "visszavonás", "Subscriptions": "Feliratkozások", "`x` unseen notifications": { - "": "`x` kimaradt érdesítés" + "": "`x` kimaradt értesítés", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` kimaradt értesítés" }, - "search": "keresés", + "search": "Videó keresése", "Log out": "Kijelentkezés", - "Source available here.": "A forráskód itt érhető el.", - "View JavaScript license information.": "JavaScript licensz inforkációk megtekintése.", - "View privacy policy.": "Adatvédelmi irányelvek megtekintése.", + "Source available here.": "A forráskód itt érhető el", + "View JavaScript license information.": "JavaScript licencinformáció megnyitása", + "View privacy policy.": "Adatvédelmi szabályzat megnyitása", "Trending": "Felkapott", - "Public": "Nyilvános", - "Unlisted": "Nem nyilvános", - "Private": "Privát", - "View all playlists": "Minden lejátszási lista megtekintése", - "Updated `x` ago": "Frissitve: `x`", - "Delete playlist `x`?": "`x` playlist törlése?", + "Public": "nyilvános", + "Unlisted": "nem nyilvános", + "Private": "magán", + "View all playlists": "Összes lejátszási lista megnézése", + "Updated `x` ago": "`x` ezelőtt lett frissítve", + "Delete playlist `x`?": "Törlésre kerüljön ez a lejátszási lista? „`x`”", "Delete playlist": "Lejátszási lista törlése", "Create playlist": "Lejátszási lista létrehozása", - "Title": "Cím", + "Title": "Lejátszási lista címe", "Playlist privacy": "Lejátszási lista láthatósága", - "Editing playlist `x`": "`x` lista szerkesztése", - "Show more": "Mutass többet", - "Show less": "Mutass kevesebbet", - "Watch on YouTube": "Megtekintés a YouTube-on", - "Hide annotations": "Szövegmagyarázat elrejtése", - "Show annotations": "Szövegmagyarázat mutatása", + "Editing playlist `x`": "„`x`” lejátszási lista szerkesztése", + "Show more": "Többi szöveg mutatása", + "Show less": "Kevesebb szöveg mutatása", + "Watch on YouTube": "Megnézés a YouTube-on", + "Hide annotations": "Megjegyzések elrejtése", + "Show annotations": "Megjegyzések mutatása", "Genre: ": "Műfaj: ", - "License: ": "Licensz: ", + "License: ": "Licenc: ", "Family friendly? ": "Családbarát? ", "Wilson score: ": "Wilson-pontszám: ", - "Engagement: ": "elkötelezettség: ", + "Engagement: ": "Visszajelzési mutató: ", "Whitelisted regions: ": "Engedélyezett régiók: ", "Blacklisted regions: ": "Tiltott régiók: ", - "Shared `x`": "Megosztva `x`", + "Shared `x`": "`x` osztották meg", "`x` views": { - "": "`x` megtekintés" + "": "`x` alkalommal nézték meg", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` alkalommal nézték meg" }, - "Premieres in `x`": "premierel `x` múlva", - "Premieres `x`": "`x`-t premierel", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Úgy látszik, hogy a JavaScript ki van kapcsolva a böngésződben. Kattints ide hogy megtekintsd a kommenteket, de tudd, hogy így kicsit tovább tarthat a betöltés.", - "View YouTube comments": "YouTube kommentek megtekintése", - "View more comments on Reddit": "További kommentek megtekintése Redditen", + "Premieres in `x`": "`x` később lesz a premierje", + "Premieres `x`": "`x` lesz a premierje", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Helló! Úgy tűnik a JavaScript ki van kapcsolva a böngészőben. Ide kattintva lehet olvasni a hozzászólásokat, de a betöltésük így kicsit több időbe fog telni.", + "View YouTube comments": "YouTube-on lévő hozzászólások olvasása", + "View more comments on Reddit": "A többi hozzászólás olvasása Redditen", "View `x` comments": { - "": "`x` komment megtekintése" + "": "`x` hozzászólás olvasása", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hozzászólás olvasása" }, - "View Reddit comments": "Reddit kommentek megtekintése", + "View Reddit comments": "Redditen lévő hozzászólások olvasása", "Hide replies": "Válaszok elrejtése", "Show replies": "Válaszok mutatása", - "Incorrect password": "Helytelen jelszó", - "Quota exceeded, try again in a few hours": "Kvóta túllépve, próbálkozz pár órával később", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sikertelen bejelentkezés. Győződj meg róla, hogy a kétfaktoros hitelesítés (hitelesítő vagy SMS) engedélyezve van.", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Sikertelen bejelentkezés. Győződj meg róla, hogy a kétfaktoros hitelesítés engedélyezve van.", - "Wrong answer": "Rossz válasz", - "Erroneous CAPTCHA": "Hibás CAPTCHA", - "CAPTCHA is a required field": "A CAPTCHA kötelező", - "User ID is a required field": "A felhasználó-ID kötelező", - "Password is a required field": "A jelszó kötelező", - "Wrong username or password": "Rossz felhasználónév vagy jelszó", - "Please sign in using 'Log in with Google'": "Kérem, jelentkezzen be a \"Bejelentkezés Google-el\"", - "Password cannot be empty": "A jelszó nem lehet üres", - "Password cannot be longer than 55 characters": "A jelszó nem lehet hosszabb 55 karakternél", - "Please log in": "Kérem lépjen be", - "Invidious Private Feed for `x`": "`x` Invidious privát feed-je", - "channel:`x`": "`x` csatorna", - "Deleted or invalid channel": "Törölt vagy nemlétező csatorna", - "This channel does not exist.": "Ez a csatorna nem létezik.", - "Could not get channel info.": "Nem sikerült lekérni a csatorna adatokat.", - "Could not fetch comments": "Nem sikerült lekérni a kommenteket", + "Incorrect password": "A jelszó nem megfelelő", + "Quota exceeded, try again in a few hours": "A kvótát meghaladták. Néhány órával később próbáld meg újból betölteni.", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Nem sikerült bejelentkezni. A kétlépcsős (hitelesítő vagy szöveges üzenet általi) hitelesítésnek bekapcsolva kell lennie.", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "Nem sikerült bejelentkezni. Ennek oka lehet, hogy a kétlépcsős hitelesítés nincs bekapcsolva a fiók beállításaiban.", + "Wrong answer": "Nem jól válaszoltál.", + "Erroneous CAPTCHA": "A CAPTCHA hibás.", + "CAPTCHA is a required field": "A CAPTCHA-mezőt ki kell tölteni.", + "User ID is a required field": "A felhasználói azonosítót meg kell adni!", + "Password is a required field": "Meg kell adni egy jelszót.", + "Wrong username or password": "Vagy a felhasználói név, vagy pedig a jelszó nem megfelelő.", + "Please sign in using 'Log in with Google'": "A „Bejelentkezés Google-el” gombbal jelentkezz be!", + "Password cannot be empty": "A jelszót nem lehet kihagyni.", + "Password cannot be longer than 55 characters": "A jelszó nem lehet hosszabb 55 karakternél.", + "Please log in": "Kérjük, jelentkezz be!", + "Invidious Private Feed for `x`": "„`x`” Invidious magán feedje", + "channel:`x`": "`x` csatornája", + "Deleted or invalid channel": "A csatorna érvénytelen, vagy pedig törölve lett.", + "This channel does not exist.": "Nincs ilyen csatorna.", + "Could not get channel info.": "Nem lehetett betölteni a csatorna adatait.", + "Could not fetch comments": "Nem lehetett betölteni a hozzászólásokat.", "View `x` replies": { - "": "`x` válasz megtekintése" + "": "`x` válasz olvasása", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` válasz olvasása" }, - "`x` ago": "`x` óta", - "Load more": "További betöltése", + "`x` ago": "`x` ezelőtt", + "Load more": "Többi hozzászólás betöltése", "`x` points": { - "": "`x` pont" + "": "`x` pont", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pont" }, - "Could not create mix.": "Nem tudok mix-et készíteni.", + "Could not create mix.": "A válogatást nem lehetett elkészíteni.", "Empty playlist": "Üres lejátszási lista", - "Not a playlist.": "Nem lejátszási lista.", + "Not a playlist.": "Ez nem egy lejátszási lista.", "Playlist does not exist.": "Nincs ilyen lejátszási lista.", - "Could not pull trending pages.": "Nem sikerült lekérni a felkapott oldalt.", - "Hidden field \"challenge\" is a required field": "A rejtett \"challenge\" mező kötelező", - "Hidden field \"token\" is a required field": "A rejtett \"token\" mező kötelező", + "Could not pull trending pages.": "Nem lehetett betölteni a felkapott videók oldalát.", + "Hidden field \"challenge\" is a required field": "A rejtett „challenge” mezőt ki kell tölteni.", + "Hidden field \"token\" is a required field": "A rejtett „token” mezőt ki kell tölteni.", "Erroneous challenge": "Hibás challenge", "Erroneous token": "Hibás token", "No such user": "Nincs ilyen felhasználó", - "Token is expired, please try again": "Lejárt token, kérem próbáld újra", + "Token is expired, please try again": "A token lejárt. Kérjük, próbáld meg újból!", "English": "angol", - "English (auto-generated)": "angol (automatikusan generált)", + "English (auto-generated)": "angol (automatikusan létrehozott)", "Afrikaans": "afrikaans", "Albanian": "albán", "Amharic": "amhara", @@ -245,10 +255,10 @@ "Filipino": "filippínó", "Finnish": "finn", "French": "francia", - "Galician": "galíciai", + "Galician": "galiciai", "Georgian": "grúz", "German": "német", - "Greek": "görök", + "Greek": "görög", "Gujarati": "gudzsaráti", "Haitian Creole": "haiti kreol", "Hausa": "hausza", @@ -259,13 +269,13 @@ "Hungarian": "magyar", "Icelandic": "izlandi", "Igbo": "igbo", - "Indonesian": "indonéziai", + "Indonesian": "indonéz", "Irish": "ír", "Italian": "olasz", "Japanese": "japán", "Javanese": "jávai", "Kannada": "kannada", - "Kazakh": "kazah", + "Kazakh": "kazak", "Khmer": "khmer", "Korean": "koreai", "Kurdish": "kurd", @@ -275,17 +285,17 @@ "Latvian": "lett", "Lithuanian": "litván", "Luxembourgish": "luxemburgi", - "Macedonian": "macedóniai", + "Macedonian": "macedón", "Malagasy": "madagaszkári", "Malay": "maláj", "Malayalam": "malajálam", "Maltese": "máltai", "Maori": "maori", - "Marathi": "Maráthi", + "Marathi": "maráthi", "Mongolian": "mongol", "Nepali": "nepáli", - "Norwegian Bokmål": "bokmål", - "Nyanja": "nyánja", + "Norwegian Bokmål": "norvég (bokmål)", + "Nyanja": "njándzsa (csicseva)", "Pashto": "pastu", "Persian": "perzsa", "Polish": "lengyel", @@ -296,19 +306,19 @@ "Samoan": "szamoai", "Scottish Gaelic": "skót gael", "Serbian": "szerb", - "Shona": "shona", - "Sindhi": "szindhi", + "Shona": "sona", + "Sindhi": "szindi", "Sinhala": "szingaléz", "Slovak": "szlovák", "Slovenian": "szlovén", "Somali": "szomáliai", - "Southern Sotho": "déli szothó", + "Southern Sotho": "déli szútú", "Spanish": "spanyol", - "Spanish (Latin America)": "spanyol (Latin-Amerika)", + "Spanish (Latin America)": "spanyol (latinamerikai)", "Sundanese": "szunda", "Swahili": "szuahéli", - "Swedish": "svld", - "Tajik": "tadzsik", + "Swedish": "svéd", + "Tajik": "tádzsik", "Tamil": "tamil", "Telugu": "telugu", "Thai": "thai", @@ -322,49 +332,135 @@ "Yoruba": "joruba", "Zulu": "zulu", "`x` years": { - "": "`x` év" + "": "`x` évvel", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` évvel" }, "`x` months": { - "": "`x` hónap" + "": "x` hónappal", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hónappal" }, "`x` weeks": { - "": "`x` hét" + "": "`x` héttel", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` héttel" }, "`x` days": { - "": "`x` nap" + "": "`x` nappal", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` nappal" }, "`x` hours": { - "": "`x` óra" + "": "`x` órával", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` órával" }, "`x` minutes": { - "": "`x` perc" + "": "`x` perccel", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` perccel" }, "`x` seconds": { - "": "`x` másodperc" + "": "`x` másodperccel", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` másodperccel" }, "Fallback comments: ": "Másodlagos kommentek: ", "Popular": "Népszerű", - "Search": "Keresés", + "Search": "Keresési oldal", "Top": "Top", "About": "Leírás", "Rating: ": "Besorolás: ", "preferences_locale_label": "Nyelv: ", - "View as playlist": "Megtekintés lejátszási listaként", + "View as playlist": "Megnézés lejátszási listában", "Default": "Alapértelmezett", - "Music": "Zene", + "Music": "Zenék", "Gaming": "Játékok", "News": "Hírek", "Movies": "Filmek", "Download": "Letöltés", - "Download as: ": "Letöltés mint: ", + "Download as: ": "Letöltés másként: ", "(edited)": "(szerkesztve)", - "YouTube comment permalink": "YouTube komment permalink", + "YouTube comment permalink": "YouTube-hozzászólás permalinkje", "permalink": "permalink", - "`x` marked it with a ❤": "`x` jelölte ❤-vel", - "Audio mode": "Audió mód", - "Video mode": "Hang mód", + "`x` marked it with a ❤": "`x` egy ❤ jellel jelölte meg", + "Audio mode": "Csak hanggal", + "Video mode": "Hanggal és képpel", "Videos": "Videók", "Playlists": "Lejátszási listák", "Community": "Közösség", - "Current version: ": "Jelenlegi verzió: " + "Current version: ": "Jelenlegi verzió: ", + "preferences_quality_option_medium": "Közepes", + "preferences_quality_dash_option_auto": "Automatikus", + "preferences_quality_dash_option_best": "Legjobb", + "preferences_quality_dash_option_worst": "Legrosszabb", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "videoinfo_started_streaming_x_ago": "`x` ezelőtt kezdte streamelni", + "views": "Megnézések száma szerint", + "purchased": "Megvásárolva", + "360": "360°-os", + "footer_original_source_code": "Eredeti forráskód", + "none": "egyik sem", + "videoinfo_watch_on_youTube": "Megnézés a YouTube-on", + "videoinfo_youTube_embed_link": "Beágyazás", + "videoinfo_invidious_embed_link": "Beágyazás linkje", + "download_subtitles": "Felirat – `x` (.vtt)", + "user_created_playlists": "`x` létrehozott lejátszási lista", + "user_saved_playlists": "`x` mentett lejátszási lista", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_dash": "DASH (adaptív minőség)", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_label": "DASH-videó minősége: ", + "preferences_quality_option_small": "Rossz", + "date": "Feltöltés dátuma szerint", + "Video unavailable": "A videó nem érhető el", + "preferences_save_player_pos_label": "A videó folytatása onnan, ahol félbe lett hagyva: ", + "preferences_show_nick_label": "Becenév mutatása felül: ", + "Released under the AGPLv3 on Github.": "Az AGPLv3 licenc alapján, a GitHubon elérhető", + "3d": "3D-ben", + "live": "Élőben", + "filter": "Szűrők", + "next_steps_error_message_refresh": "Újratöltés", + "footer_donate_page": "Adakozás", + "footer_source_code": "Forráskód", + "footer_modfied_source_code": "Módosított forráskód", + "adminprefs_modified_source_code_url_label": "A módosított forráskód repositoryjának URL-je:", + "preferences_automatic_instance_redirect_label": "Váltáskor másik Invidious oldal automatikus betöltése (redirect.invidious.io töltődik, ha nem működne): ", + "preferences_region_label": "Ország tartalmainak mutatása: ", + "relevance": "Relevancia szerint", + "rating": "Besorolás szerint", + "content_type": "Típus", + "today": "Mai napon", + "channel": "Csatorna", + "video": "Videó", + "playlist": "Lejátszási lista", + "creative_commons": "Creative Commons", + "features": "Jellemzők", + "sort": "Rendezés módja", + "preferences_category_misc": "További beállítások", + "%A %B %-d, %Y": "%Y. %B %-d %A", + "long": "Hosszú (több, mint 20 perces)", + "year": "Ebben az évben", + "hour": "Az elmúlt órában", + "movie": "Film", + "hdr": "HDR", + "Broken? Try another Invidious Instance": "Nem működik? Próbáld meg egy másik Invidious oldallal!", + "duration": "Játékidő", + "next_steps_error_message": "Az alábbi lehetőségek állnak rendelkezésre: ", + "Xhosa": "xhosza", + "Switch Invidious Instance": "Váltás másik Invidious-oldalra", + "Urdu": "urdu", + "week": "Ezen a héten", + "Invalid TFA code": "A kétlépéses hitelesítés kódja nem megfelelő", + "footer_documentation": "Dokumentáció", + "hd": "HD", + "next_steps_error_message_go_to_youtube": "Ugrás a YouTube-ra", + "show": "Műsor", + "4k": "4K", + "short": "Rövid (kevesebb, mint 4 perces)", + "month": "Ebben a hónapban", + "subtitles": "Felirattal", + "location": "Közelben" } diff --git a/locales/id.json b/locales/id.json index ffae1654..b3918955 100644 --- a/locales/id.json +++ b/locales/id.json @@ -461,5 +461,6 @@ "preferences_quality_dash_option_1440p": "1440p", "preferences_quality_dash_option_auto": "Otomatis", "preferences_quality_dash_option_480p": "480p", - "Video unavailable": "Video tidak tersedia" + "Video unavailable": "Video tidak tersedia", + "preferences_save_player_pos_label": "Simpan waktu video saat ini: " } diff --git a/locales/nl.json b/locales/nl.json index 9214602c..c51d6e18 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -433,5 +433,34 @@ "Broken? Try another Invidious Instance": "Kapot? Probeer een andere Invidious Instantie", "next_steps_error_message": "Waarna u moet proberen om: ", "footer_source_code": "Bron-code", - "long": "Lang (> 20 minuten)" + "long": "Lang (> 20 minuten)", + "preferences_quality_option_dash": "DASH (adaptieve kwaliteit)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Gemiddeld", + "preferences_quality_option_small": "Klein", + "preferences_quality_dash_option_auto": "Automatisch", + "preferences_quality_dash_option_best": "Beste", + "preferences_quality_dash_option_worst": "Slechtste", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "videoinfo_started_streaming_x_ago": "Stream `x` geleden begonnen", + "videoinfo_watch_on_youTube": "Bekijken op YouTube", + "videoinfo_youTube_embed_link": "Inbedden", + "videoinfo_invidious_embed_link": "Link ingebedde versie", + "download_subtitles": "Ondertiteling - `x` (.vtt)", + "user_created_playlists": "`x` afspeellijsten aangemaakt", + "user_saved_playlists": "`x` afspeellijsten opgeslagen", + "Video unavailable": "Video onbeschikbaar", + "preferences_save_player_pos_label": "Huidig afspeeltijdstip opslaan: ", + "none": "geen", + "purchased": "Gekocht", + "360": "360º" } diff --git a/locales/sq.json b/locales/sq.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/sq.json @@ -0,0 +1 @@ +{} diff --git a/locales/tr.json b/locales/tr.json index 2ef57d03..cf427666 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -461,5 +461,6 @@ "purchased": "Satın alınan", "360": "360°", "videoinfo_watch_on_youTube": "YouTube'da izle", - "download_subtitles": "Alt yazılar - `x` (.vtt)" + "download_subtitles": "Alt yazılar - `x` (.vtt)", + "preferences_save_player_pos_label": "Geçerli video zamanını kaydet: " } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 2fa19f4a..aad51069 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -461,5 +461,6 @@ "preferences_quality_option_dash": "DASH(主動調整品質)", "preferences_quality_option_medium": "中等", "preferences_quality_dash_option_auto": "自動", - "preferences_quality_dash_option_best": "最佳" + "preferences_quality_dash_option_best": "最佳", + "preferences_save_player_pos_label": "儲存目前影片時間: " } diff --git a/src/invidious.cr b/src/invidious.cr index ade13608..fb67af87 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -20,12 +20,13 @@ require "kemal" require "athena-negotiation" require "openssl/hmac" require "option_parser" -require "pg" require "sqlite3" require "xml" require "yaml" require "compress/zip" require "protodec/utils" + +require "./invidious/database/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" require "./invidious/*" @@ -112,19 +113,19 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) # Check table integrity if CONFIG.check_tables - check_enum(PG_DB, "privacy", PlaylistPrivacy) + Invidious::Database.check_enum(PG_DB, "privacy", PlaylistPrivacy) - check_table(PG_DB, "channels", InvidiousChannel) - check_table(PG_DB, "channel_videos", ChannelVideo) - check_table(PG_DB, "playlists", InvidiousPlaylist) - check_table(PG_DB, "playlist_videos", PlaylistVideo) - check_table(PG_DB, "nonces", Nonce) - check_table(PG_DB, "session_ids", SessionId) - check_table(PG_DB, "users", User) - check_table(PG_DB, "videos", Video) + Invidious::Database.check_table(PG_DB, "channels", InvidiousChannel) + Invidious::Database.check_table(PG_DB, "channel_videos", ChannelVideo) + Invidious::Database.check_table(PG_DB, "playlists", InvidiousPlaylist) + Invidious::Database.check_table(PG_DB, "playlist_videos", PlaylistVideo) + Invidious::Database.check_table(PG_DB, "nonces", Nonce) + Invidious::Database.check_table(PG_DB, "session_ids", SessionId) + Invidious::Database.check_table(PG_DB, "users", User) + Invidious::Database.check_table(PG_DB, "videos", Video) if CONFIG.cache_annotations - check_table(PG_DB, "annotations", Annotation) + Invidious::Database.check_table(PG_DB, "annotations", Annotation) end end @@ -246,8 +247,8 @@ before_all do |env| # Invidious users only have SID if !env.request.cookies.has_key? "SSID" - if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) - user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + if email = Invidious::Database::SessionIDs.select_email(sid) + user = Invidious::Database::Users.select!(email: email) csrf_token = generate_response(sid, { ":authorize_token", ":playlist_ajax", @@ -255,7 +256,7 @@ before_all do |env| ":subscription_ajax", ":token_ajax", ":watch_ajax", - }, HMAC_KEY, PG_DB, 1.week) + }, HMAC_KEY, 1.week) preferences = user.preferences env.set "preferences", preferences @@ -269,7 +270,7 @@ before_all do |env| headers["Cookie"] = env.request.headers["Cookie"] begin - user, sid = get_user(sid, headers, PG_DB, false) + user, sid = get_user(sid, headers, false) csrf_token = generate_response(sid, { ":authorize_token", ":playlist_ajax", @@ -277,7 +278,7 @@ before_all do |env| ":subscription_ajax", ":token_ajax", ":watch_ajax", - }, HMAC_KEY, PG_DB, 1.week) + }, HMAC_KEY, 1.week) preferences = user.preferences env.set "preferences", preferences @@ -437,7 +438,7 @@ post "/watch_ajax" do |env| end begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex if redirect next error_template(400, ex) @@ -457,10 +458,10 @@ post "/watch_ajax" do |env| case action when "action_mark_watched" if !user.watched.includes? id - PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.email) + Invidious::Database::Users.mark_watched(user, id) end when "action_mark_unwatched" - PG_DB.exec("UPDATE users SET watched = array_remove(watched, $1) WHERE email = $2", id, user.email) + Invidious::Database::Users.mark_unwatched(user, id) else next error_json(400, "Unsupported action #{action}") end @@ -574,7 +575,7 @@ post "/subscription_ajax" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex if redirect next error_template(400, ex) @@ -598,16 +599,15 @@ post "/subscription_ajax" do |env| # Sync subscriptions with YouTube subscribe_ajax(channel_id, action, env.request.headers) end - email = user.email case action when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id - get_channel(channel_id, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions, $1) WHERE email = $2", channel_id, email) + get_channel(channel_id, false, false) + Invidious::Database::Users.subscribe_channel(user, channel_id) end when "action_remove_subscriptions" - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", channel_id, email) + Invidious::Database::Users.unsubscribe_channel(user, channel_id) else next error_json(400, "Unsupported action #{action}") end @@ -632,13 +632,14 @@ get "/subscription_manager" do |env| end 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, PG_DB) + user, sid = get_user(sid, headers) end action_takeout = env.params.query["action_takeout"]?.try &.to_i? @@ -648,20 +649,14 @@ get "/subscription_manager" do |env| format = env.params.query["format"]? format ||= "rss" - if user.subscriptions.empty? - values = "'{}'" - else - values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" - end - - subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) + subscriptions = Invidious::Database::Channels.select(user.subscriptions) subscriptions.sort_by!(&.author.downcase) if action_takeout if format == "json" env.response.content_type = "application/json" env.response.headers["content-disposition"] = "attachment" - playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + playlists = Invidious::Database::Playlists.select_like_iv(user.email) next JSON.build do |json| json.object do @@ -677,7 +672,7 @@ get "/subscription_manager" do |env| json.field "privacy", playlist.privacy.to_s json.field "videos" do json.array do - PG_DB.query_all("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 500", playlist.id, playlist.index, as: String).each do |video_id| + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| json.string video_id end end @@ -762,20 +757,20 @@ post "/data_control" do |env| user.subscriptions += body["subscriptions"].as_a.map(&.as_s) user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) end if body["watch_history"]? user.watched += body["watch_history"].as_a.map(&.as_s) user.watched.uniq! - PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email) + Invidious::Database::Users.update_watch_history(user) end if body["preferences"]? user.preferences = Preferences.from_json(body["preferences"].to_json) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", user.preferences.to_json, user.email) + Invidious::Database::Users.update_preferences(user) end if playlists = body["playlists"]?.try &.as_a? @@ -788,8 +783,8 @@ post "/data_control" do |env| next if !description next if !privacy - playlist = create_playlist(PG_DB, title, privacy, user) - PG_DB.exec("UPDATE playlists SET description = $1 WHERE id = $2", description, playlist.id) + playlist = create_playlist(title, privacy, user) + Invidious::Database::Playlists.update_description(playlist.id, description) 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 @@ -798,7 +793,7 @@ post "/data_control" do |env| next if !video_id begin - video = get_video(video_id, PG_DB) + video = get_video(video_id) rescue ex next end @@ -815,11 +810,8 @@ post "/data_control" do |env| index: Random::Secure.rand(0_i64..Int64::MAX), }) - video_array = playlist_video.to_a - args = arg_array(video_array) - - PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) - PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist.id) + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) end end end @@ -837,18 +829,18 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) when "import_freetube" user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md| md["channel_id"] end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) when "import_newpipe_subscriptions" body = JSON.parse(body) user.subscriptions += body["subscriptions"].as_a.compact_map do |channel| @@ -865,9 +857,9 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) when "import_newpipe" Compress::Zip::Reader.open(IO::Memory.new(body)) do |file| file.each_entry do |entry| @@ -879,14 +871,14 @@ post "/data_control" do |env| user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v=")) user.watched.uniq! - PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email) + 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, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) db.close tempfile.delete @@ -914,7 +906,7 @@ get "/change_password" do |env| user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY) templated "change_password" end @@ -940,7 +932,7 @@ post "/change_password" do |env| end begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex next error_template(400, ex) end @@ -970,7 +962,7 @@ post "/change_password" do |env| end new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10) - PG_DB.exec("UPDATE users SET password = $1 WHERE email = $2", new_password.to_s, user.email) + Invidious::Database::Users.update_password(user, new_password.to_s) env.redirect referer end @@ -988,7 +980,7 @@ get "/delete_account" do |env| user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY) templated "delete_account" end @@ -1009,14 +1001,14 @@ post "/delete_account" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex next error_template(400, ex) end view_name = "subscriptions_#{sha256(user.email)}" - PG_DB.exec("DELETE FROM users * WHERE email = $1", user.email) - PG_DB.exec("DELETE FROM session_ids * WHERE email = $1", user.email) + Invidious::Database::Users.delete(user) + Invidious::Database::SessionIDs.delete(email: user.email) PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}") env.request.cookies.each do |cookie| @@ -1040,7 +1032,7 @@ get "/clear_watch_history" do |env| user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY) templated "clear_watch_history" end @@ -1061,12 +1053,12 @@ post "/clear_watch_history" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex next error_template(400, ex) end - PG_DB.exec("UPDATE users SET watched = '{}' WHERE email = $1", user.email) + Invidious::Database::Users.clear_watch_history(user) env.redirect referer end @@ -1083,7 +1075,7 @@ get "/authorize_token" do |env| user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY) scopes = env.params.query["scopes"]?.try &.split(",") scopes ||= [] of String @@ -1114,7 +1106,7 @@ post "/authorize_token" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex next error_template(400, ex) end @@ -1123,7 +1115,7 @@ post "/authorize_token" do |env| callback_url = env.params.body["callbackUrl"]? expire = env.params.body["expire"]?.try &.to_i? - access_token = generate_token(user.email, scopes, expire, HMAC_KEY, PG_DB) + access_token = generate_token(user.email, scopes, expire, HMAC_KEY) if callback_url access_token = URI.encode_www_form(access_token) @@ -1158,8 +1150,7 @@ get "/token_manager" do |env| end user = user.as(User) - - tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1 ORDER BY issued DESC", user.email, as: {session: String, issued: Time}) + tokens = Invidious::Database::SessionIDs.select_all(user.email) templated "token_manager" end @@ -1188,7 +1179,7 @@ post "/token_ajax" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex if redirect next error_template(400, ex) @@ -1208,7 +1199,7 @@ post "/token_ajax" do |env| case action when .starts_with? "action_revoke_token" - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email) + Invidious::Database::SessionIDs.delete(sid: session, email: user.email) else next error_json(400, "Unsupported action #{action}") end diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 827b6534..155ec559 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -114,7 +114,7 @@ class ChannelRedirect < Exception end end -def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) +def get_batch_channels(channels, refresh = false, pull_all_videos = true, max_threads = 10) finished_channel = Channel(String | Nil).new spawn do @@ -130,7 +130,7 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma active_threads += 1 spawn do begin - get_channel(ucid, db, refresh, pull_all_videos) + get_channel(ucid, refresh, pull_all_videos) finished_channel.send(ucid) rescue ex finished_channel.send(nil) @@ -151,28 +151,21 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma return final end -def get_channel(id, db, refresh = true, pull_all_videos = true) - if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) +def get_channel(id, refresh = true, pull_all_videos = true) + if channel = Invidious::Database::Channels.select(id) if refresh && Time.utc - channel.updated > 10.minutes - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args}) \ - ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) + channel = fetch_channel(id, pull_all_videos: pull_all_videos) + Invidious::Database::Channels.insert(channel, update_on_conflict: true) end else - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array) + channel = fetch_channel(id, pull_all_videos: pull_all_videos) + Invidious::Database::Channels.insert(channel) end return channel end -def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) +def fetch_channel(ucid, pull_all_videos = true, locale = nil) LOGGER.debug("fetch_channel: #{ucid}") LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") @@ -241,15 +234,11 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) # We don't include the 'premiere_timestamp' here because channel pages don't include them, # meaning the above timestamp is always null - was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + was_insert = Invidious::Database::ChannelVideos.insert(video) if was_insert LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) + Invidious::Database::Users.add_notification(video) else LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") end @@ -284,13 +273,8 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) # 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 = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert + was_insert = Invidious::Database::ChannelVideos.insert(video) + Invidious::Database::Users.add_notification(video) if was_insert end end diff --git a/src/invidious/database/annotations.cr b/src/invidious/database/annotations.cr new file mode 100644 index 00000000..03749473 --- /dev/null +++ b/src/invidious/database/annotations.cr @@ -0,0 +1,24 @@ +require "./base.cr" + +module Invidious::Database::Annotations + extend self + + def insert(id : String, annotations : String) + request = <<-SQL + INSERT INTO annotations + VALUES ($1, $2) + ON CONFLICT DO NOTHING + SQL + + PG_DB.exec(request, id, annotations) + end + + def select(id : String) : Annotation? + request = <<-SQL + SELECT * FROM annotations + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: Annotation) + end +end diff --git a/src/invidious/database/base.cr b/src/invidious/database/base.cr new file mode 100644 index 00000000..6e49ea1a --- /dev/null +++ b/src/invidious/database/base.cr @@ -0,0 +1,110 @@ +require "pg" + +module Invidious::Database + extend self + + def check_enum(db, enum_name, struct_type = nil) + return # TODO + + if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) + LOGGER.info("check_enum: CREATE TYPE #{enum_name}") + + db.using_connection do |conn| + conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql")) + end + end + end + + def check_table(db, table_name, struct_type = nil) + # Create table if it doesn't exist + begin + db.exec("SELECT * FROM #{table_name} LIMIT 0") + rescue ex + LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}") + + db.using_connection do |conn| + conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql")) + end + end + + return if !struct_type + + struct_array = struct_type.type_array + column_array = get_column_array(db, table_name) + column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?<types>[\d\D]*?)\);/) + .try &.["types"].split(",").map(&.strip).reject &.starts_with?("CONSTRAINT") + + return if !column_types + + struct_array.each_with_index do |name, i| + if name != column_array[i]? + if !column_array[i]? + new_column = column_types.select(&.starts_with?(name))[0] + LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + next + end + + # Column doesn't exist + if !column_array.includes? name + new_column = column_types.select(&.starts_with?(name))[0] + db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + end + + # Column exists but in the wrong position, rotate + if struct_array.includes? column_array[i] + until name == column_array[i] + new_column = column_types.select(&.starts_with?(column_array[i]))[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new") + + # There's a column we didn't expect + if !new_column + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}") + db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + + column_array = get_column_array(db, table_name) + next + end + + LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + + LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") + db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") + + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + + LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") + db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") + + column_array = get_column_array(db, table_name) + end + else + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + end + end + end + + return if column_array.size <= struct_array.size + + column_array.each do |column| + if !struct_array.includes? column + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + end + end + end + + def get_column_array(db, table_name) + column_array = [] of String + db.query("SELECT * FROM #{table_name} LIMIT 0") do |rs| + rs.column_count.times do |i| + column = rs.as(PG::ResultSet).field(i) + column_array << column.name + end + end + + return column_array + end +end diff --git a/src/invidious/database/channels.cr b/src/invidious/database/channels.cr new file mode 100644 index 00000000..134cf59d --- /dev/null +++ b/src/invidious/database/channels.cr @@ -0,0 +1,149 @@ +require "./base.cr" + +# +# This module contains functions related to the "channels" table. +# +module Invidious::Database::Channels + extend self + + # ------------------- + # Insert / delete + # ------------------- + + def insert(channel : InvidiousChannel, update_on_conflict : Bool = false) + channel_array = channel.to_a + + request = <<-SQL + INSERT INTO channels + VALUES (#{arg_array(channel_array)}) + SQL + + if update_on_conflict + request += <<-SQL + ON CONFLICT (id) DO UPDATE + SET author = $2, updated = $3 + SQL + end + + PG_DB.exec(request, args: channel_array) + end + + # ------------------- + # Update + # ------------------- + + def update_author(id : String, author : String) + request = <<-SQL + UPDATE channels + SET updated = $1, author = $2, deleted = false + WHERE id = $3 + SQL + + PG_DB.exec(request, Time.utc, author, id) + end + + def update_mark_deleted(id : String) + request = <<-SQL + UPDATE channels + SET updated = $1, deleted = true + WHERE id = $2 + SQL + + PG_DB.exec(request, Time.utc, id) + end + + # ------------------- + # Select + # ------------------- + + def select(id : String) : InvidiousChannel? + request = <<-SQL + SELECT * FROM channels + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: InvidiousChannel) + end + + def select(ids : Array(String)) : Array(InvidiousChannel)? + return [] of InvidiousChannel if ids.empty? + values = ids.map { |id| %(('#{id}')) }.join(",") + + request = <<-SQL + SELECT * FROM channels + WHERE id = ANY(VALUES #{values}) + SQL + + return PG_DB.query_all(request, as: InvidiousChannel) + end +end + +# +# This module contains functions related to the "channel_videos" table. +# +module Invidious::Database::ChannelVideos + extend self + + # ------------------- + # Insert + # ------------------- + + # This function returns the status of the query (i.e: success?) + def insert(video : ChannelVideo, with_premiere_timestamp : Bool = false) : Bool + if with_premiere_timestamp + last_items = "premiere_timestamp = $9, views = $10" + else + last_items = "views = $10" + end + + request = <<-SQL + INSERT INTO channel_videos + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE + SET title = $2, published = $3, updated = $4, ucid = $5, + author = $6, length_seconds = $7, live_now = $8, #{last_items} + RETURNING (xmax=0) AS was_insert + SQL + + return PG_DB.query_one(request, *video.to_tuple, as: Bool) + end + + # ------------------- + # Select + # ------------------- + + def select(ids : Array(String)) : Array(ChannelVideo) + return [] of ChannelVideo if ids.empty? + + request = <<-SQL + SELECT * FROM channel_videos + WHERE id IN (#{arg_array(ids)}) + ORDER BY published DESC + SQL + + return PG_DB.query_all(request, args: ids, as: ChannelVideo) + end + + def select_notfications(ucid : String, since : Time) : Array(ChannelVideo) + request = <<-SQL + SELECT * FROM channel_videos + WHERE ucid = $1 AND published > $2 + ORDER BY published DESC + LIMIT 15 + SQL + + return PG_DB.query_all(request, ucid, since, as: ChannelVideo) + end + + def select_popular_videos : Array(ChannelVideo) + request = <<-SQL + SELECT DISTINCT ON (ucid) * + FROM channel_videos + WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d + GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) + ORDER BY ucid, published DESC + SQL + + PG_DB.query_all(request, as: ChannelVideo) + end +end diff --git a/src/invidious/database/nonces.cr b/src/invidious/database/nonces.cr new file mode 100644 index 00000000..469fcbd8 --- /dev/null +++ b/src/invidious/database/nonces.cr @@ -0,0 +1,46 @@ +require "./base.cr" + +module Invidious::Database::Nonces + extend self + + # ------------------- + # Insert + # ------------------- + + def insert(nonce : String, expire : Time) + request = <<-SQL + INSERT INTO nonces + VALUES ($1, $2) + ON CONFLICT DO NOTHING + SQL + + PG_DB.exec(request, nonce, expire) + end + + # ------------------- + # Update + # ------------------- + + def update_set_expired(nonce : String) + request = <<-SQL + UPDATE nonces + SET expire = $1 + WHERE nonce = $2 + SQL + + PG_DB.exec(request, Time.utc(1990, 1, 1), nonce) + end + + # ------------------- + # Select + # ------------------- + + def select(nonce : String) : Tuple(String, Time)? + request = <<-SQL + SELECT * FROM nonces + WHERE nonce = $1 + SQL + + return PG_DB.query_one?(request, nonce, as: {String, Time}) + end +end diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr new file mode 100644 index 00000000..950d5f4b --- /dev/null +++ b/src/invidious/database/playlists.cr @@ -0,0 +1,257 @@ +require "./base.cr" + +# +# This module contains functions related to the "playlists" table. +# +module Invidious::Database::Playlists + extend self + + # ------------------- + # Insert / delete + # ------------------- + + def insert(playlist : InvidiousPlaylist) + playlist_array = playlist.to_a + + request = <<-SQL + INSERT INTO playlists + VALUES (#{arg_array(playlist_array)}) + SQL + + PG_DB.exec(request, args: playlist_array) + end + + # this function is a bit special: it will also remove all videos + # related to the given playlist ID in the "playlist_videos" table, + # in addition to deleting said ID from "playlists". + def delete(id : String) + request = <<-SQL + DELETE FROM playlist_videos * WHERE plid = $1; + DELETE FROM playlists * WHERE id = $1 + SQL + + PG_DB.exec(request, id) + end + + # ------------------- + # Update + # ------------------- + + def update(id : String, title : String, privacy, description, updated) + request = <<-SQL + UPDATE playlists + SET title = $1, privacy = $2, description = $3, updated = $4 + WHERE id = $5 + SQL + + PG_DB.exec(request, title, privacy, description, updated, id) + end + + def update_description(id : String, description) + request = <<-SQL + UPDATE playlists + SET description = $1 + WHERE id = $2 + SQL + + PG_DB.exec(request, description, id) + end + + def update_subscription_time(id : String) + request = <<-SQL + UPDATE playlists + SET subscribed = $1 + WHERE id = $2 + SQL + + PG_DB.exec(request, Time.utc, id) + end + + def update_video_added(id : String, index : String | Int64) + request = <<-SQL + UPDATE playlists + SET index = array_append(index, $1), + video_count = cardinality(index) + 1, + updated = $2 + WHERE id = $3 + SQL + + PG_DB.exec(request, index, Time.utc, id) + end + + def update_video_removed(id : String, index : String | Int64) + request = <<-SQL + UPDATE playlists + SET index = array_remove(index, $1), + video_count = cardinality(index) - 1, + updated = $2 + WHERE id = $3 + SQL + + PG_DB.exec(request, index, Time.utc, id) + end + + # ------------------- + # Salect + # ------------------- + + def select(*, id : String, raise_on_fail : Bool = false) : InvidiousPlaylist? + request = <<-SQL + SELECT * FROM playlists + WHERE id = $1 + SQL + + if raise_on_fail + return PG_DB.query_one(request, id, as: InvidiousPlaylist) + else + return PG_DB.query_one?(request, id, as: InvidiousPlaylist) + end + end + + def select_all(*, author : String) : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE author = $1 + SQL + + return PG_DB.query_all(request, author, as: InvidiousPlaylist) + end + + # ------------------- + # Salect (filtered) + # ------------------- + + def select_like_iv(email : String) : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE author = $1 AND id LIKE 'IV%' + ORDER BY created + SQL + + PG_DB.query_all(request, email, as: InvidiousPlaylist) + end + + def select_not_like_iv(email : String) : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE author = $1 AND id NOT LIKE 'IV%' + ORDER BY created + SQL + + PG_DB.query_all(request, email, as: InvidiousPlaylist) + end + + def select_user_created_playlists(email : String) : Array({String, String}) + request = <<-SQL + SELECT id,title FROM playlists + WHERE author = $1 AND id LIKE 'IV%' + SQL + + PG_DB.query_all(request, email, as: {String, String}) + end + + # ------------------- + # Misc checks + # ------------------- + + # Check if given playlist ID exists + def exists?(id : String) : Bool + request = <<-SQL + SELECT id FROM playlists + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: String).nil? + end + + # Count how many playlist a user has created. + def count_owned_by(author : String) : Int64 + request = <<-SQL + SELECT count(*) FROM playlists + WHERE author = $1 + SQL + + return PG_DB.query_one?(request, author, as: Int64) || 0_i64 + end +end + +# +# This module contains functions related to the "playlist_videos" table. +# +module Invidious::Database::PlaylistVideos + extend self + + private alias VideoIndex = Int64 | Array(Int64) + + # ------------------- + # Insert / Delete + # ------------------- + + def insert(video : PlaylistVideo) + video_array = video.to_a + + request = <<-SQL + INSERT INTO playlist_videos + VALUES (#{arg_array(video_array)}) + SQL + + PG_DB.exec(request, args: video_array) + end + + def delete(index) + request = <<-SQL + DELETE FROM playlist_videos * + WHERE index = $1 + SQL + + PG_DB.exec(request, index) + end + + # ------------------- + # Salect + # ------------------- + + def select(plid : String, index : VideoIndex, offset, limit = 100) : Array(PlaylistVideo) + request = <<-SQL + SELECT * FROM playlist_videos + WHERE plid = $1 + ORDER BY array_position($2, index) + LIMIT $3 + OFFSET $4 + SQL + + return PG_DB.query_all(request, plid, index, limit, offset, as: PlaylistVideo) + end + + def select_index(plid : String, vid : String) : Int64? + request = <<-SQL + SELECT index FROM playlist_videos + WHERE plid = $1 AND id = $2 + LIMIT 1 + SQL + + return PG_DB.query_one?(request, plid, vid, as: Int64) + end + + def select_one_id(plid : String, index : VideoIndex) : String? + request = <<-SQL + SELECT id FROM playlist_videos + WHERE plid = $1 + ORDER BY array_position($2, index) + LIMIT 1 + SQL + + return PG_DB.query_one?(request, plid, index, as: String) + end + + def select_ids(plid : String, index : VideoIndex, limit = 500) : Array(String) + request = <<-SQL + SELECT id FROM playlist_videos + WHERE plid = $1 + ORDER BY array_position($2, index) + LIMIT $3 + SQL + + return PG_DB.query_all(request, plid, index, limit, as: String) + end +end diff --git a/src/invidious/database/sessions.cr b/src/invidious/database/sessions.cr new file mode 100644 index 00000000..d5f85dd6 --- /dev/null +++ b/src/invidious/database/sessions.cr @@ -0,0 +1,74 @@ +require "./base.cr" + +module Invidious::Database::SessionIDs + extend self + + # ------------------- + # Insert + # ------------------- + + def insert(sid : String, email : String, handle_conflicts : Bool = false) + request = <<-SQL + INSERT INTO session_ids + VALUES ($1, $2, $3) + SQL + + request += " ON CONFLICT (id) DO NOTHING" if handle_conflicts + + PG_DB.exec(request, sid, email, Time.utc) + end + + # ------------------- + # Delete + # ------------------- + + def delete(*, sid : String) + request = <<-SQL + DELETE FROM session_ids * + WHERE id = $1 + SQL + + PG_DB.exec(request, sid) + end + + def delete(*, email : String) + request = <<-SQL + DELETE FROM session_ids * + WHERE email = $1 + SQL + + PG_DB.exec(request, email) + end + + def delete(*, sid : String, email : String) + request = <<-SQL + DELETE FROM session_ids * + WHERE id = $1 AND email = $2 + SQL + + PG_DB.exec(request, sid, email) + end + + # ------------------- + # Select + # ------------------- + + def select_email(sid : String) : String? + request = <<-SQL + SELECT email FROM session_ids + WHERE id = $1 + SQL + + PG_DB.query_one?(request, sid, as: String) + end + + def select_all(email : String) : Array({session: String, issued: Time}) + request = <<-SQL + SELECT id, issued FROM session_ids + WHERE email = $1 + ORDER BY issued DESC + SQL + + PG_DB.query_all(request, email, as: {session: String, issued: Time}) + end +end diff --git a/src/invidious/database/statistics.cr b/src/invidious/database/statistics.cr new file mode 100644 index 00000000..1df549e2 --- /dev/null +++ b/src/invidious/database/statistics.cr @@ -0,0 +1,49 @@ +require "./base.cr" + +module Invidious::Database::Statistics + extend self + + # ------------------- + # User stats + # ------------------- + + def count_users_total : Int64 + request = <<-SQL + SELECT count(*) FROM users + SQL + + PG_DB.query_one(request, as: Int64) + end + + def count_users_active_1m : Int64 + request = <<-SQL + SELECT count(*) FROM users + WHERE CURRENT_TIMESTAMP - updated < '6 months' + SQL + + PG_DB.query_one(request, as: Int64) + end + + def count_users_active_6m : Int64 + request = <<-SQL + SELECT count(*) FROM users + WHERE CURRENT_TIMESTAMP - updated < '1 month' + SQL + + PG_DB.query_one(request, as: Int64) + end + + # ------------------- + # Channel stats + # ------------------- + + def channel_last_update : Time? + request = <<-SQL + SELECT updated FROM channels + ORDER BY updated DESC + LIMIT 1 + SQL + + PG_DB.query_one?(request, as: Time) + end +end diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr new file mode 100644 index 00000000..71650918 --- /dev/null +++ b/src/invidious/database/users.cr @@ -0,0 +1,218 @@ +require "./base.cr" + +module Invidious::Database::Users + extend self + + # ------------------- + # Insert / delete + # ------------------- + + def insert(user : User, update_on_conflict : Bool = false) + user_array = user.to_a + user_array[4] = user_array[4].to_json # User preferences + + request = <<-SQL + INSERT INTO users + VALUES (#{arg_array(user_array)}) + SQL + + if update_on_conflict + request += <<-SQL + ON CONFLICT (email) DO UPDATE + SET updated = $1, subscriptions = $3 + SQL + end + + PG_DB.exec(request, args: user_array) + end + + def delete(user : User) + request = <<-SQL + DELETE FROM users * + WHERE email = $1 + SQL + + PG_DB.exec(request, user.email) + end + + # ------------------- + # Update (history) + # ------------------- + + def update_watch_history(user : User) + request = <<-SQL + UPDATE users + SET watched = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.watched, user.email) + end + + def mark_watched(user : User, vid : String) + request = <<-SQL + UPDATE users + SET watched = array_append(watched, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, vid, user.email) + end + + def mark_unwatched(user : User, vid : String) + request = <<-SQL + UPDATE users + SET watched = array_remove(watched, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, vid, user.email) + end + + def clear_watch_history(user : User) + request = <<-SQL + UPDATE users + SET watched = '{}' + WHERE email = $1 + SQL + + PG_DB.exec(request, user.email) + end + + # ------------------- + # Update (channels) + # ------------------- + + def update_subscriptions(user : User) + request = <<-SQL + UPDATE users + SET feed_needs_update = true, subscriptions = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.subscriptions, user.email) + end + + def subscribe_channel(user : User, ucid : String) + request = <<-SQL + UPDATE users + SET feed_needs_update = true, + subscriptions = array_append(subscriptions,$1) + WHERE email = $2 + SQL + + PG_DB.exec(request, ucid, user.email) + end + + def unsubscribe_channel(user : User, ucid : String) + request = <<-SQL + UPDATE users + SET feed_needs_update = true, + subscriptions = array_remove(subscriptions, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, ucid, user.email) + end + + # ------------------- + # Update (notifs) + # ------------------- + + def add_notification(video : ChannelVideo) + request = <<-SQL + UPDATE users + SET notifications = array_append(notifications, $1), + feed_needs_update = true + WHERE $2 = ANY(subscriptions) + SQL + + PG_DB.exec(request, video.id, video.ucid) + end + + def remove_notification(user : User, vid : String) + request = <<-SQL + UPDATE users + SET notifications = array_remove(notifications, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, vid, user.email) + end + + def clear_notifications(user : User) + request = <<-SQL + UPDATE users + SET notifications = $1, updated = $2 + WHERE email = $3 + SQL + + PG_DB.exec(request, [] of String, Time.utc, user) + end + + # ------------------- + # Update (misc) + # ------------------- + + def update_preferences(user : User) + request = <<-SQL + UPDATE users + SET preferences = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.preferences.to_json, user.email) + end + + def update_password(user : User, pass : String) + request = <<-SQL + UPDATE users + SET password = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.email, pass) + end + + # ------------------- + # Select + # ------------------- + + def select(*, email : String) : User? + request = <<-SQL + SELECT * FROM users + WHERE email = $1 + SQL + + return PG_DB.query_one?(request, email, as: User) + end + + # Same as select, but can raise an exception + def select!(*, email : String) : User + request = <<-SQL + SELECT * FROM users + WHERE email = $1 + SQL + + return PG_DB.query_one(request, email, as: User) + end + + def select(*, token : String) : User? + request = <<-SQL + SELECT * FROM users + WHERE token = $1 + SQL + + return PG_DB.query_one?(request, token, as: User) + end + + def select_notifications(user : User) : Array(String) + request = <<-SQL + SELECT notifications + FROM users + WHERE email = $1 + SQL + + return PG_DB.query_one(request, user.email, as: Array(String)) + end +end diff --git a/src/invidious/database/videos.cr b/src/invidious/database/videos.cr new file mode 100644 index 00000000..e1fa01c3 --- /dev/null +++ b/src/invidious/database/videos.cr @@ -0,0 +1,43 @@ +require "./base.cr" + +module Invidious::Database::Videos + extend self + + def insert(video : Video) + request = <<-SQL + INSERT INTO videos + VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING + SQL + + PG_DB.exec(request, video.id, video.info.to_json, video.updated) + end + + def delete(id) + request = <<-SQL + DELETE FROM videos * + WHERE id = $1 + SQL + + PG_DB.exec(request, id) + end + + def update(video : Video) + request = <<-SQL + UPDATE videos + SET (id, info, updated) = ($1, $2, $3) + WHERE id = $1 + SQL + + PG_DB.exec(request, video.id, video.info.to_json, video.updated) + end + + def select(id : String) : Video? + request = <<-SQL + SELECT * FROM videos + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: Video) + end +end diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 045b6701..d140a858 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -97,18 +97,18 @@ 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, expire, signature = validate_request(token, session, env.request, HMAC_KEY, PG_DB, nil) + scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, nil) - if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", session, as: String) - user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + if email = Invidious::Database::SessionIDs.select_email(session) + user = Invidious::Database::Users.select!(email: email) end elsif sid = env.request.cookies["SID"]?.try &.value if sid.starts_with? "v1:" raise "Cannot use token as SID" end - if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) - user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + if email = Invidious::Database::SessionIDs.select_email(sid) + user = Invidious::Database::Users.select!(email: email) end scopes = [":*"] diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 96a78eb9..c3b53339 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -60,112 +60,7 @@ def html_to_content(description_html : String) return description end -def check_enum(db, enum_name, struct_type = nil) - return # TODO - - if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) - LOGGER.info("check_enum: CREATE TYPE #{enum_name}") - - db.using_connection do |conn| - conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql")) - end - end -end - -def check_table(db, table_name, struct_type = nil) - # Create table if it doesn't exist - begin - db.exec("SELECT * FROM #{table_name} LIMIT 0") - rescue ex - LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}") - - db.using_connection do |conn| - conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql")) - end - end - - return if !struct_type - - struct_array = struct_type.type_array - column_array = get_column_array(db, table_name) - column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?<types>[\d\D]*?)\);/) - .try &.["types"].split(",").map(&.strip).reject &.starts_with?("CONSTRAINT") - - return if !column_types - - struct_array.each_with_index do |name, i| - if name != column_array[i]? - if !column_array[i]? - new_column = column_types.select(&.starts_with?(name))[0] - LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - next - end - - # Column doesn't exist - if !column_array.includes? name - new_column = column_types.select(&.starts_with?(name))[0] - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - end - - # Column exists but in the wrong position, rotate - if struct_array.includes? column_array[i] - until name == column_array[i] - new_column = column_types.select(&.starts_with?(column_array[i]))[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new") - - # There's a column we didn't expect - if !new_column - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - - column_array = get_column_array(db, table_name) - next - end - - LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - - LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") - db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") - - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - - LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") - db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") - - column_array = get_column_array(db, table_name) - end - else - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - end - end - end - - return if column_array.size <= struct_array.size - - column_array.each do |column| - if !struct_array.includes? column - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") - end - end -end - -def get_column_array(db, table_name) - column_array = [] of String - db.query("SELECT * FROM #{table_name} LIMIT 0") do |rs| - rs.column_count.times do |i| - column = rs.as(PG::ResultSet).field(i) - column_array << column.name - end - end - - return column_array -end - -def cache_annotation(db, id, annotations) +def cache_annotation(id, annotations) if !CONFIG.cache_annotations return end @@ -183,7 +78,7 @@ def cache_annotation(db, id, annotations) end end - db.exec("INSERT INTO annotations VALUES ($1, $2) ON CONFLICT DO NOTHING", id, annotations) if has_legacy_annotations + Invidious::Database::Annotations.insert(id, annotations) if has_legacy_annotations end def create_notification_stream(env, topics, connection_channel) @@ -204,7 +99,7 @@ def create_notification_stream(env, topics, connection_channel) published = Time.utc - Time::Span.new(days: time_span[0], hours: time_span[1], minutes: time_span[2], seconds: time_span[3]) video_id = TEST_IDS[rand(TEST_IDS.size)] - video = get_video(video_id, PG_DB) + video = get_video(video_id) video.published = published response = JSON.parse(video.to_json(locale, nil)) @@ -235,11 +130,12 @@ def create_notification_stream(env, topics, connection_channel) spawn do begin if since + since_unix = Time.unix(since.not_nil!) + topics.try &.each do |topic| case topic when .match(/UC[A-Za-z0-9_-]{22}/) - PG_DB.query_all("SELECT * FROM channel_videos WHERE ucid = $1 AND published > $2 ORDER BY published DESC LIMIT 15", - topic, Time.unix(since.not_nil!), as: ChannelVideo).each do |video| + 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"]? @@ -280,7 +176,7 @@ def create_notification_stream(env, topics, connection_channel) next end - video = get_video(video_id, PG_DB) + video = get_video(video_id) video.published = Time.unix(published) response = JSON.parse(video.to_json(locale, nil)) diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index 3874799a..8b076e39 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -1,8 +1,8 @@ require "crypto/subtle" -def generate_token(email, scopes, expire, key, db) +def generate_token(email, scopes, expire, key) session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}" - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.utc) + Invidious::Database::SessionIDs.insert(session, email) token = { "session" => session, @@ -19,7 +19,7 @@ def generate_token(email, scopes, expire, key, db) return token.to_json end -def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false) +def generate_response(session, scopes, key, expire = 6.hours, use_nonce = false) expire = Time.utc + expire token = { @@ -30,7 +30,7 @@ def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = fa if use_nonce nonce = Random::Secure.hex(16) - db.exec("INSERT INTO nonces VALUES ($1, $2) ON CONFLICT DO NOTHING", nonce, expire) + Invidious::Database::Nonces.insert(nonce, expire) token["nonce"] = nonce end @@ -63,7 +63,7 @@ def sign_token(key, hash) return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip end -def validate_request(token, session, request, key, db, locale = nil) +def validate_request(token, session, request, key, locale = nil) case token when String token = JSON.parse(URI.decode_www_form(token)).as_h @@ -92,9 +92,9 @@ def validate_request(token, session, request, key, db, locale = nil) raise InfoException.new("Invalid signature") end - if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time})) + if token["nonce"]? && (nonce = Invidious::Database::Nonces.select(token["nonce"].as_s)) if nonce[1] > Time.utc - db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0]) + Invidious::Database::Nonces.update_set_expired(nonce[0]) else raise InfoException.new("Erroneous token") end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 7bbbcb92..8453d605 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -1,5 +1,3 @@ -require "db" - # See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html def ci_lower_bound(pos, n) if n == 0 diff --git a/src/invidious/jobs/pull_popular_videos_job.cr b/src/invidious/jobs/pull_popular_videos_job.cr index 38de816e..dc785bae 100644 --- a/src/invidious/jobs/pull_popular_videos_job.cr +++ b/src/invidious/jobs/pull_popular_videos_job.cr @@ -1,11 +1,4 @@ class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob - QUERY = <<-SQL - SELECT DISTINCT ON (ucid) * - FROM channel_videos - WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d - GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) - ORDER BY ucid, published DESC - SQL POPULAR_VIDEOS = Atomic.new([] of ChannelVideo) private getter db : DB::Database @@ -14,7 +7,7 @@ class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob def begin loop do - videos = db.query_all(QUERY, as: ChannelVideo) + videos = Invidious::Database::ChannelVideos.select_popular_videos .sort_by!(&.published) .reverse! diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 2321e964..c224c745 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -35,11 +35,11 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob lim_fibers = max_fibers LOGGER.trace("RefreshChannelsJob: #{id} fiber : Updating DB") - db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id) + Invidious::Database::Channels.update_author(id, channel.author) rescue ex LOGGER.error("RefreshChannelsJob: #{id} : #{ex.message}") if ex.message == "Deleted or invalid channel" - db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id) + Invidious::Database::Channels.update_mark_deleted(id) else lim_fibers = 1 LOGGER.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s") diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr index 926c27fa..4b52c959 100644 --- a/src/invidious/jobs/refresh_feeds_job.cr +++ b/src/invidious/jobs/refresh_feeds_job.cr @@ -25,7 +25,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob spawn do begin # Drop outdated views - column_array = get_column_array(db, view_name) + column_array = Invidious::Database.get_column_array(db, view_name) ChannelVideo.type_array.each_with_index do |name, i| if name != column_array[i]? LOGGER.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}") diff --git a/src/invidious/jobs/statistics_refresh_job.cr b/src/invidious/jobs/statistics_refresh_job.cr index 6569c0a1..a113bd77 100644 --- a/src/invidious/jobs/statistics_refresh_job.cr +++ b/src/invidious/jobs/statistics_refresh_job.cr @@ -47,12 +47,14 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob private def refresh_stats users = STATISTICS.dig("usage", "users").as(Hash(String, Int64)) - users["total"] = db.query_one("SELECT count(*) FROM users", as: Int64) - users["activeHalfyear"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '6 months'", as: Int64) - users["activeMonth"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '1 month'", as: Int64) + + users["total"] = Invidious::Database::Statistics.count_users_total + 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" => db.query_one?("SELECT updated FROM channels ORDER BY updated DESC LIMIT 1", as: Time).try &.to_unix || 0_i64, + "lastChannelRefreshedAt" => Invidious::Database::Statistics.channel_last_update.try &.to_unix || 0_i64, } end end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index e40be974..a09e6cdb 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -125,7 +125,7 @@ struct Playlist json.field "videos" do json.array do - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id) + videos = get_playlist_videos(self, offset: offset, locale: locale, video_id: video_id) videos.each do |video| video.to_json(json) end @@ -200,12 +200,12 @@ struct InvidiousPlaylist json.field "videos" do json.array do - if !offset || offset == 0 - index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, video_id, as: Int64) + if (!offset || offset == 0) && !video_id.nil? + index = Invidious::Database::PlaylistVideos.select_index(self.id, video_id) offset = self.index.index(index) || 0 end - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id) + videos = get_playlist_videos(self, offset: offset, locale: locale, video_id: video_id) videos.each_with_index do |video, index| video.to_json(json, offset + index) end @@ -225,7 +225,8 @@ struct InvidiousPlaylist end def thumbnail - @thumbnail_id ||= PG_DB.query_one?("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 1", self.id, self.index, as: String) || "-----------" + # TODO: Get playlist thumbnail from playlist data rather than first video + @thumbnail_id ||= Invidious::Database::PlaylistVideos.select_one_id(self.id, self.index) || "-----------" "/vi/#{@thumbnail_id}/mqdefault.jpg" end @@ -246,7 +247,7 @@ struct InvidiousPlaylist end end -def create_playlist(db, title, privacy, user) +def create_playlist(title, privacy, user) plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}" playlist = InvidiousPlaylist.new({ @@ -261,15 +262,12 @@ def create_playlist(db, title, privacy, user) index: [] of Int64, }) - playlist_array = playlist.to_a - args = arg_array(playlist_array) - - db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array) + Invidious::Database::Playlists.insert(playlist) return playlist end -def subscribe_playlist(db, user, playlist) +def subscribe_playlist(user, playlist) playlist = InvidiousPlaylist.new({ title: playlist.title.byte_slice(0, 150), id: playlist.id, @@ -282,10 +280,7 @@ def subscribe_playlist(db, user, playlist) index: [] of Int64, }) - playlist_array = playlist.to_a - args = arg_array(playlist_array) - - db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array) + Invidious::Database::Playlists.insert(playlist) return playlist end @@ -325,9 +320,9 @@ def produce_playlist_continuation(id, index) return continuation end -def get_playlist(db, plid, locale, refresh = true, force_refresh = false) +def get_playlist(plid, locale, refresh = true, force_refresh = false) if plid.starts_with? "IV" - if playlist = db.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if playlist = Invidious::Database::Playlists.select(id: plid) return playlist else raise InfoException.new("Playlist does not exist.") @@ -407,7 +402,7 @@ def fetch_playlist(plid, locale) }) end -def get_playlist_videos(db, playlist, offset, locale = nil, video_id = nil) +def get_playlist_videos(playlist, offset, locale = nil, video_id = nil) # Show empy playlist if requested page is out of range # (e.g, when a new playlist has been created, offset will be negative) if offset >= playlist.video_count || offset < 0 @@ -415,8 +410,7 @@ def get_playlist_videos(db, playlist, offset, locale = nil, video_id = nil) end if playlist.is_a? InvidiousPlaylist - db.query_all("SELECT * FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 100 OFFSET $3", - playlist.id, playlist.index, offset, as: PlaylistVideo) + Invidious::Database::PlaylistVideos.select(playlist.id, playlist.index, offset, limit: 100) else if video_id initial_data = YoutubeAPI.next({ diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 12687ec6..b6183001 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -13,7 +13,7 @@ module Invidious::Routes::API::Manifest unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } begin - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index aaf728ff..fda655ef 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -22,12 +22,11 @@ module Invidious::Routes::API::V1::Authenticated user = env.get("user").as(User) begin - preferences = Preferences.from_json(env.request.body || "{}") + user.preferences = Preferences.from_json(env.request.body || "{}") rescue - preferences = user.preferences end - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) + Invidious::Database::Users.update_preferences(user) env.response.status_code = 204 end @@ -45,7 +44,7 @@ module Invidious::Routes::API::V1::Authenticated page = env.params.query["page"]?.try &.to_i? page ||= 1 - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + videos, notifications = get_subscription_feed(user, max_results, page) JSON.build do |json| json.object do @@ -72,13 +71,7 @@ module Invidious::Routes::API::V1::Authenticated env.response.content_type = "application/json" user = env.get("user").as(User) - if user.subscriptions.empty? - values = "'{}'" - else - values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" - end - - subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) + subscriptions = Invidious::Database::Channels.select(user.subscriptions) JSON.build do |json| json.array do @@ -99,8 +92,8 @@ module Invidious::Routes::API::V1::Authenticated ucid = env.params.url["ucid"] if !user.subscriptions.includes? ucid - get_channel(ucid, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) + get_channel(ucid, false, false) + Invidious::Database::Users.subscribe_channel(user, ucid) end # For Google accounts, access tokens don't have enough information to @@ -116,7 +109,7 @@ module Invidious::Routes::API::V1::Authenticated ucid = env.params.url["ucid"] - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", ucid, user.email) + Invidious::Database::Users.unsubscribe_channel(user, ucid) env.response.status_code = 204 end @@ -127,7 +120,7 @@ module Invidious::Routes::API::V1::Authenticated env.response.content_type = "application/json" user = env.get("user").as(User) - playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1", user.email, as: InvidiousPlaylist) + playlists = Invidious::Database::Playlists.select_all(author: user.email) JSON.build do |json| json.array do @@ -153,11 +146,11 @@ module Invidious::Routes::API::V1::Authenticated return error_json(400, "Invalid privacy setting.") end - if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + if Invidious::Database::Playlists.count_owned_by(user.email) >= 100 return error_json(400, "User cannot have more than 100 playlists.") end - playlist = create_playlist(PG_DB, title, privacy, user) + playlist = create_playlist(title, privacy, user) env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}" env.response.status_code = 201 { @@ -172,9 +165,12 @@ module Invidious::Routes::API::V1::Authenticated env.response.content_type = "application/json" user = env.get("user").as(User) - plid = env.params.url["plid"] + plid = env.params.url["plid"]? + if !plid || plid.empty? + return error_json(400, "A playlist ID is required") + end - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email && playlist.privacy.private? return error_json(404, "Playlist does not exist.") end @@ -195,7 +191,8 @@ module Invidious::Routes::API::V1::Authenticated updated = playlist.updated end - PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) + Invidious::Database::Playlists.update(plid, title, privacy, description, updated) + env.response.status_code = 204 end @@ -207,7 +204,7 @@ module Invidious::Routes::API::V1::Authenticated plid = env.params.url["plid"] - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email && playlist.privacy.private? return error_json(404, "Playlist does not exist.") end @@ -216,8 +213,7 @@ module Invidious::Routes::API::V1::Authenticated return error_json(403, "Invalid user") end - PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) - PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) + Invidious::Database::Playlists.delete(plid) env.response.status_code = 204 end @@ -230,7 +226,7 @@ module Invidious::Routes::API::V1::Authenticated plid = env.params.url["plid"] - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email && playlist.privacy.private? return error_json(404, "Playlist does not exist.") end @@ -249,7 +245,7 @@ module Invidious::Routes::API::V1::Authenticated end begin - video = get_video(video_id, PG_DB) + video = get_video(video_id) rescue ex return error_json(500, ex) end @@ -266,11 +262,8 @@ module Invidious::Routes::API::V1::Authenticated index: Random::Secure.rand(0_i64..Int64::MAX), }) - video_array = playlist_video.to_a - args = arg_array(video_array) - - PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) - PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(plid, playlist_video.index) env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index.to_u64.to_s(16).upcase}" env.response.status_code = 201 @@ -289,7 +282,7 @@ module Invidious::Routes::API::V1::Authenticated plid = env.params.url["plid"] index = env.params.url["index"].to_i64(16) - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email && playlist.privacy.private? return error_json(404, "Playlist does not exist.") end @@ -302,8 +295,8 @@ module Invidious::Routes::API::V1::Authenticated return error_json(404, "Playlist does not contain index") end - PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) - PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, plid) + Invidious::Database::PlaylistVideos.delete(index) + Invidious::Database::Playlists.update_video_removed(plid, index) env.response.status_code = 204 end @@ -318,7 +311,7 @@ module Invidious::Routes::API::V1::Authenticated user = env.get("user").as(User) scopes = env.get("scopes").as(Array(String)) - tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time}) + tokens = Invidious::Database::SessionIDs.select_all(user.email) JSON.build do |json| json.array do @@ -360,7 +353,7 @@ module Invidious::Routes::API::V1::Authenticated if sid = env.get?("sid").try &.as(String) env.response.content_type = "text/html" - csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true) + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true) return templated "authorize_token" else env.response.content_type = "application/json" @@ -374,7 +367,7 @@ module Invidious::Routes::API::V1::Authenticated end end - access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB) + access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY) if callback_url access_token = URI.encode_www_form(access_token) @@ -406,9 +399,9 @@ module Invidious::Routes::API::V1::Authenticated # Allow tokens to revoke other tokens with correct scope if session == env.get("session").as(String) - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) + Invidious::Database::SessionIDs.delete(sid: session) elsif scopes_include_scope(scopes, "GET:tokens") - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) + Invidious::Database::SessionIDs.delete(sid: session) else return error_json(400, "Cannot revoke session #{session}") end diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 1621c9ef..ac0576a0 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -34,7 +34,7 @@ module Invidious::Routes::API::V1::Misc end begin - playlist = get_playlist(PG_DB, plid, locale) + playlist = get_playlist(plid, locale) rescue ex : InfoException return error_json(404, ex) rescue ex diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 4c7179ce..4d244e7f 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -8,7 +8,7 @@ module Invidious::Routes::API::V1::Videos region = env.params.query["region"]? begin - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) 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}) @@ -36,7 +36,7 @@ module Invidious::Routes::API::V1::Videos # getting video info. begin - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) 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}) @@ -157,7 +157,7 @@ module Invidious::Routes::API::V1::Videos region = env.params.query["region"]? begin - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) 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}) @@ -239,7 +239,7 @@ module Invidious::Routes::API::V1::Videos case source when "archive" - if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) + if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id)) annotations = cached_annotation.annotations else index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') @@ -271,7 +271,7 @@ module Invidious::Routes::API::V1::Videos annotations = response.body - cache_annotation(PG_DB, id, annotations) + cache_annotation(id, annotations) end else # "youtube" response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 049ee344..ab722ae2 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -6,9 +6,9 @@ module Invidious::Routes::Embed if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") begin - playlist = get_playlist(PG_DB, plid, locale: locale) + playlist = get_playlist(plid, locale: locale) offset = env.params.query["index"]?.try &.to_i? || 0 - videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) + videos = get_playlist_videos(playlist, offset: offset, locale: locale) rescue ex return error_template(500, ex) end @@ -30,7 +30,7 @@ module Invidious::Routes::Embed id = env.params.url["id"] plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") - continuation = process_continuation(PG_DB, env.params.query, plid, id) + continuation = process_continuation(env.params.query, plid, id) if md = env.params.query["playlist"]? .try &.match(/[a-zA-Z0-9_-]{11}(,[a-zA-Z0-9_-]{11})*/) @@ -60,9 +60,9 @@ module Invidious::Routes::Embed if plid begin - playlist = get_playlist(PG_DB, plid, locale: locale) + playlist = get_playlist(plid, locale: locale) offset = env.params.query["index"]?.try &.to_i? || 0 - videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) + videos = get_playlist_videos(playlist, offset: offset, locale: locale) rescue ex return error_template(500, ex) end @@ -119,7 +119,7 @@ module Invidious::Routes::Embed subscriptions ||= [] of String begin - video = get_video(id, PG_DB, region: params.region) + video = get_video(id, region: params.region) rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex @@ -137,7 +137,7 @@ module Invidious::Routes::Embed # end if notifications && notifications.includes? id - PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email) + Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 9650bcf4..fd8c25ce 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -15,13 +15,14 @@ module Invidious::Routes::Feeds user = user.as(User) - items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + # TODO: make a single DB call and separate the items here? + items_created = Invidious::Database::Playlists.select_like_iv(user.email) items_created.map! do |item| item.author = "" item end - items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + items_saved = Invidious::Database::Playlists.select_not_like_iv(user.email) items_saved.map! do |item| item.author = "" item @@ -83,7 +84,7 @@ module Invidious::Routes::Feeds headers["Cookie"] = env.request.headers["Cookie"] if !user.password - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers) end max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) @@ -93,14 +94,13 @@ module Invidious::Routes::Feeds page = env.params.query["page"]?.try &.to_i? page ||= 1 - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + videos, notifications = get_subscription_feed(user, max_results, page) # "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. - PG_DB.exec("UPDATE users SET notifications = $1, updated = $2 WHERE email = $3", [] of String, Time.utc, - user.email) + Invidious::Database::Users.clear_notifications(user) user.notifications = [] of String env.set "user", user @@ -220,7 +220,7 @@ module Invidious::Routes::Feeds haltf env, status_code: 403 end - user = PG_DB.query_one?("SELECT * FROM users WHERE token = $1", token.strip, as: User) + user = Invidious::Database::Users.select(token: token.strip) if !user haltf env, status_code: 403 end @@ -234,7 +234,7 @@ module Invidious::Routes::Feeds params = HTTP::Params.parse(env.params.query["params"]? || "") - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + videos, notifications = get_subscription_feed(user, max_results, page) XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", @@ -264,8 +264,8 @@ module Invidious::Routes::Feeds path = env.request.path if plid.starts_with? "IV" - if playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - videos = get_playlist_videos(PG_DB, playlist, offset: 0, locale: locale) + if playlist = Invidious::Database::Playlists.select(id: plid) + videos = get_playlist_videos(playlist, offset: 0, locale: locale) return XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", @@ -364,7 +364,7 @@ module Invidious::Routes::Feeds if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]? PG_DB.exec("UPDATE channels SET subscribed = $1 WHERE id = $2", Time.utc, ucid) elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]? - PG_DB.exec("UPDATE playlists SET subscribed = $1 WHERE id = $2", Time.utc, ucid) + Invidious::Database::Playlists.update_subscription_time(plid) else haltf env, status_code: 400 end @@ -393,7 +393,7 @@ module Invidious::Routes::Feeds published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - video = get_video(id, PG_DB, force_refresh: true) + video = get_video(id, force_refresh: true) # Deliver notifications to `/api/v1/auth/notifications` payload = { @@ -416,13 +416,8 @@ module Invidious::Routes::Feeds views: video.views, }) - was_insert = PG_DB.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, - updated = $4, ucid = $5, author = $6, length_seconds = $7, - live_now = $8, premiere_timestamp = $9, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - PG_DB.exec("UPDATE users SET notifications = array_append(notifications, $1), - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert + was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) + Invidious::Database::Users.add_notification(video) if was_insert end end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 2a50561d..64da3e4e 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -275,7 +275,7 @@ module Invidious::Routes::Login raise "Couldn't get SID." end - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers) # We are now logged in traceback << "done.<br/>" @@ -303,8 +303,8 @@ module Invidious::Routes::Login end if env.request.cookies["PREFS"]? - preferences = env.get("preferences").as(Preferences) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) + 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) @@ -327,7 +327,7 @@ module Invidious::Routes::Login return error_template(401, "Password is a required field") end - user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User) + user = Invidious::Database::Users.select(email: email) if user if !user.password @@ -336,7 +336,7 @@ module Invidious::Routes::Login if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) + Invidious::Database::SessionIDs.insert(sid, email) if Kemal.config.ssl || CONFIG.https_only secure = true @@ -393,9 +393,9 @@ module Invidious::Routes::Login prompt = "" if captcha_type == "image" - captcha = generate_captcha(HMAC_KEY, PG_DB) + captcha = generate_captcha(HMAC_KEY) else - captcha = generate_text_captcha(HMAC_KEY, PG_DB) + captcha = generate_text_captcha(HMAC_KEY) end return templated "login" @@ -412,7 +412,7 @@ module Invidious::Routes::Login answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) begin - validate_request(tokens[0], answer, env.request, HMAC_KEY, PG_DB, locale) + validate_request(tokens[0], answer, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end @@ -427,7 +427,7 @@ module Invidious::Routes::Login error_exception = Exception.new tokens.each do |token| begin - validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, answer, env.request, HMAC_KEY, locale) found_valid_captcha = true rescue ex error_exception = ex @@ -449,13 +449,8 @@ module Invidious::Routes::Login end end - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - - args = arg_array(user_array) - - PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array) - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) + Invidious::Database::Users.insert(user) + Invidious::Database::SessionIDs.insert(sid, email) view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") @@ -475,8 +470,8 @@ module Invidious::Routes::Login end if env.request.cookies["PREFS"]? - preferences = env.get("preferences").as(Preferences) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) + 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) @@ -506,12 +501,12 @@ module Invidious::Routes::Login token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", sid) + Invidious::Database::SessionIDs.delete(sid: sid) env.request.cookies.each do |cookie| cookie.expires = Time.utc(1990, 1, 1) diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 7b7bd03f..d437b79c 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -12,7 +12,7 @@ module Invidious::Routes::Playlists user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY) templated "create_playlist" end @@ -31,7 +31,7 @@ module Invidious::Routes::Playlists token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end @@ -46,11 +46,11 @@ module Invidious::Routes::Playlists return error_template(400, "Invalid privacy setting.") end - if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + if Invidious::Database::Playlists.count_owned_by(user.email) >= 100 return error_template(400, "User cannot have more than 100 playlists.") end - playlist = create_playlist(PG_DB, title, privacy, user) + playlist = create_playlist(title, privacy, user) env.redirect "/playlist?list=#{playlist.id}" end @@ -66,8 +66,8 @@ module Invidious::Routes::Playlists user = user.as(User) playlist_id = env.params.query["list"] - playlist = get_playlist(PG_DB, playlist_id, locale) - subscribe_playlist(PG_DB, user, playlist) + playlist = get_playlist(playlist_id, locale) + subscribe_playlist(user, playlist) env.redirect "/playlist?list=#{playlist.id}" end @@ -85,12 +85,16 @@ module Invidious::Routes::Playlists sid = sid.as(String) plid = env.params.query["list"]? - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !plid || plid.empty? + return error_template(400, "A playlist ID is required") + end + + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email return env.redirect referer end - csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY) templated "delete_playlist" end @@ -112,18 +116,17 @@ module Invidious::Routes::Playlists token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email return env.redirect referer end - PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) - PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) + Invidious::Database::Playlists.delete(plid) env.redirect "/feed/playlists" end @@ -149,7 +152,7 @@ module Invidious::Routes::Playlists page ||= 1 begin - playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid, raise_on_fail: true) if !playlist || playlist.author != user.email return env.redirect referer end @@ -158,12 +161,12 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) + videos = get_playlist_videos(playlist, offset: (page - 1) * 100, locale: locale) rescue ex videos = [] of PlaylistVideo end - csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY) templated "edit_playlist" end @@ -185,12 +188,12 @@ module Invidious::Routes::Playlists token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email return env.redirect referer end @@ -207,7 +210,7 @@ module Invidious::Routes::Playlists updated = playlist.updated end - PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) + Invidious::Database::Playlists.update(plid, title, privacy, description, updated) env.redirect "/playlist?list=#{plid}" end @@ -233,7 +236,7 @@ module Invidious::Routes::Playlists page ||= 1 begin - playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid, raise_on_fail: true) if !playlist || playlist.author != user.email return env.redirect referer end @@ -283,7 +286,7 @@ module Invidious::Routes::Playlists token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex if redirect return error_template(400, ex) @@ -311,7 +314,7 @@ module Invidious::Routes::Playlists begin playlist_id = env.params.query["playlist_id"] - playlist = get_playlist(PG_DB, playlist_id, locale).as(InvidiousPlaylist) + playlist = get_playlist(playlist_id, locale).as(InvidiousPlaylist) raise "Invalid user" if playlist.author != user.email rescue ex if redirect @@ -342,7 +345,7 @@ module Invidious::Routes::Playlists video_id = env.params.query["video_id"] begin - video = get_video(video_id, PG_DB) + video = get_video(video_id) rescue ex if redirect return error_template(500, ex) @@ -363,15 +366,12 @@ module Invidious::Routes::Playlists index: Random::Secure.rand(0_i64..Int64::MAX), }) - video_array = playlist_video.to_a - args = arg_array(video_array) - - PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) - PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id) + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index) when "action_remove_video" index = env.params.query["set_video_id"] - PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) - PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id) + Invidious::Database::PlaylistVideos.delete(index) + Invidious::Database::Playlists.update_video_removed(playlist_id, index) when "action_move_video_before" # TODO: Playlist stub else @@ -405,7 +405,7 @@ module Invidious::Routes::Playlists end begin - playlist = get_playlist(PG_DB, plid, locale) + playlist = get_playlist(plid, locale) rescue ex return error_template(500, ex) end @@ -422,7 +422,7 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) + videos = get_playlist_videos(playlist, offset: (page - 1) * 100, locale: locale) rescue ex return error_template(500, "Error encountered while retrieving playlist videos.<br>#{ex.message}") end diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 15c00700..a832076c 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -170,11 +170,12 @@ module Invidious::Routes::PreferencesRoute vr_mode: vr_mode, show_nick: show_nick, save_player_pos: save_player_pos, - }.to_json).to_json + }.to_json) if user = env.get? "user" user = user.as(User) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) + user.preferences = preferences + Invidious::Database::Users.update_preferences(user) if CONFIG.admins.includes? user.email CONFIG.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home @@ -220,10 +221,10 @@ module Invidious::Routes::PreferencesRoute end if CONFIG.domain - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years, + env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, secure: secure, http_only: true) else - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years, + env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, secure: secure, http_only: true) end end @@ -241,18 +242,15 @@ module Invidious::Routes::PreferencesRoute if user = env.get? "user" user = user.as(User) - preferences = user.preferences - case preferences.dark_mode + case user.preferences.dark_mode when "dark" - preferences.dark_mode = "light" + user.preferences.dark_mode = "light" else - preferences.dark_mode = "dark" + user.preferences.dark_mode = "dark" end - preferences = preferences.to_json - - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) + Invidious::Database::Users.update_preferences(user) else preferences = env.get("preferences").as(Preferences) diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 06ba6b8c..8a58b034 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -263,7 +263,7 @@ module Invidious::Routes::VideoPlayback haltf env, status_code: 400, response: "TESTING" end - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) 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 diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index b24222ff..1198f48f 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -39,7 +39,7 @@ module Invidious::Routes::Watch end plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") - continuation = process_continuation(PG_DB, env.params.query, plid, id) + continuation = process_continuation(env.params.query, plid, id) nojs = env.params.query["nojs"]? @@ -60,7 +60,7 @@ module Invidious::Routes::Watch env.params.query.delete_all("listen") begin - video = get_video(id, PG_DB, region: params.region) + video = get_video(id, region: params.region) rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex @@ -76,11 +76,11 @@ module Invidious::Routes::Watch env.params.query.delete_all("iv_load_policy") if watched && !watched.includes? id - PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email) + Invidious::Database::Users.mark_watched(user.as(User), id) end if notifications && notifications.includes? id - PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email) + Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 584082be..49074994 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -29,43 +29,31 @@ struct User end end -def get_user(sid, headers, db, refresh = true) - if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) - user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) +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, db) - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - args = arg_array(user_array) + user, sid = fetch_user(sid, headers) - db.exec("INSERT INTO users VALUES (#{args}) \ - ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) - - db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ - ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc) + 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)}" - db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(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, db) - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - args = arg_array(user.to_a) - - db.exec("INSERT INTO users VALUES (#{args}) \ - ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) + user, sid = fetch_user(sid, headers) - db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ - ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc) + 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)}" - db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") + PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") rescue ex end end @@ -73,7 +61,7 @@ def get_user(sid, headers, db, refresh = true) return user, sid end -def fetch_user(sid, headers, db) +def fetch_user(sid, headers) feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) feed = XML.parse_html(feed.body) @@ -86,7 +74,7 @@ def fetch_user(sid, headers, db) end end - channels = get_batch_channels(channels, db, false, false) + channels = get_batch_channels(channels, false, false) email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) if email @@ -130,7 +118,7 @@ def create_user(sid, email, password) return user, sid end -def generate_captcha(key, db) +def generate_captcha(key) second = Random::Secure.rand(12) second_angle = second * 30 second = second * 5 @@ -182,16 +170,16 @@ def generate_captcha(key, db) return { question: image, - tokens: {generate_response(answer, {":login"}, key, db, use_nonce: true)}, + tokens: {generate_response(answer, {":login"}, key, use_nonce: true)}, } end -def generate_text_captcha(key, db) +def generate_text_captcha(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, db, use_nonce: true) + generate_response(answer.as_s, {":login"}, key, use_nonce: true) end return { @@ -232,20 +220,16 @@ def subscribe_ajax(channel_id, action, env_headers) end end -def get_subscription_feed(db, user, max_results = 40, page = 1) +def get_subscription_feed(user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit - notifications = db.query_one("SELECT notifications FROM users WHERE email = $1", user.email, - as: Array(String)) + notifications = Invidious::Database::Users.select_notifications(user) view_name = "subscriptions_#{sha256(user.email)}" if user.preferences.notifications_only && !notifications.empty? # Only show notifications - - args = arg_array(notifications) - - notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args}) ORDER BY published DESC", args: notifications, as: ChannelVideo) + notifications = Invidious::Database::ChannelVideos.select(notifications) videos = [] of ChannelVideo notifications.sort_by!(&.published).reverse! @@ -311,8 +295,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) else nil # Ignore end - notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email, as: Array(String)) - + notifications = Invidious::Database::Users.select_notifications(user) notifications = videos.select { |v| notifications.includes? v.id } videos = videos - notifications end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index b1c60947..499ed94d 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -993,8 +993,8 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ return params end -def get_video(id, db, refresh = true, region = nil, force_refresh = false) - if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region +def get_video(id, refresh = true, region = nil, force_refresh = false) + if (video = Invidious::Database::Videos.select(id)) && !region # If record was last updated over 10 minutes ago, or video has since premiered, # refresh (expire param in response lasts for 6 hours) if (refresh && @@ -1003,17 +1003,15 @@ def get_video(id, db, refresh = true, region = nil, force_refresh = false) force_refresh begin video = fetch_video(id, region) - db.exec("UPDATE videos SET (id, info, updated) = ($1, $2, $3) WHERE id = $1", video.id, video.info.to_json, video.updated) + Invidious::Database::Videos.update(video) rescue ex - db.exec("DELETE FROM videos * WHERE id = $1", id) + Invidious::Database::Videos.delete(id) raise ex end end else video = fetch_video(id, region) - if !region - db.exec("INSERT INTO videos VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", video.id, video.info.to_json, video.updated) - end + Invidious::Database::Videos.insert(video) if !region end return video @@ -1058,7 +1056,7 @@ def itag_to_metadata?(itag : JSON::Any) return VIDEO_FORMATS[itag.to_s]? end -def process_continuation(db, query, plid, id) +def process_continuation(query, plid, id) continuation = nil if plid if index = query["index"]?.try &.to_i? diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index dd918404..b720bbc2 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -61,7 +61,7 @@ <div class="pure-u-1-3"><a href="/edit_playlist?list=<%= plid %>"><i class="icon ion-md-create"></i></a></div> <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div> <% else %> - <% if PG_DB.query_one?("SELECT id FROM playlists WHERE id = $1", playlist.id, as: String).nil? %> + <% if Invidious::Database::Playlists.exists?(playlist.id) %> <div class="pure-u-1-3"><a href="/subscribe_playlist?list=<%= plid %>"><i class="icon ion-md-add"></i></a></div> <% else %> <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 11e738ab..363f1262 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -138,7 +138,7 @@ we're going to need to do it here in order to allow for translations. </p> <% if user %> - <% playlists = PG_DB.query_all("SELECT id,title FROM playlists WHERE author = $1 AND id LIKE 'IV%'", user.email, as: {String, String}) %> + <% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %> <% if !playlists.empty? %> <form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post" target="_blank"> <div class="pure-control-group"> diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 27f25036..85239e72 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -404,38 +404,33 @@ module YoutubeAPI url = "#{endpoint}?key=#{client_config.api_key}" headers = HTTP::Headers{ - "Content-Type" => "application/json; charset=UTF-8", + "Content-Type" => "application/json; charset=UTF-8", + "Accept-Encoding" => "gzip, deflate", } - # The normal HTTP client automatically applies accept-encoding: gzip, - # and decompresses. However, explicitly applying it will remove this functionality. - # - # https://github.com/crystal-lang/crystal/issues/11252#issuecomment-929594741 - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - headers["Accept-Encoding"] = "gzip" - end - {% end %} - # Logging LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") LOGGER.trace("YoutubeAPI: POST data: #{data}") # Send the POST request - if client_config.proxy_region - response = YT_POOL.client( - client_config.proxy_region, + if {{ !flag?(:disable_quic) }} && CONFIG.use_quic + # Using QUIC client + response = YT_POOL.client(client_config.proxy_region, &.post(url, headers: headers, body: data.to_json) ) + body = response.body else - response = YT_POOL.client &.post( - url, headers: headers, body: data.to_json - ) + # 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 + end end # Convert result to Hash - initial_data = JSON.parse(response.body).as_h + initial_data = JSON.parse(body).as_h # Error handling if initial_data.has_key?("error") @@ -453,4 +448,35 @@ module YoutubeAPI return initial_data end + + #################################################################### + # _decompress(body_io, headers) + # + # Internal function that reads the Content-Encoding headers and + # decompresses the content accordingly. + # + # We decompress the body ourselves (when using HTTP::Client) because + # the auto-decompress feature is broken in the Crystal stdlib. + # + # Read more: + # - https://github.com/iv-org/invidious/issues/2612 + # - https://github.com/crystal-lang/crystal/issues/11354 + # + def _decompress(body_io : IO, encodings : String?) : String + if encodings + # Multiple encodings can be combined, and are listed in the order + # in which they were applied. E.g: "deflate, gzip" means that the + # content must be first "gunzipped", then "defated". + encodings.split(',').reverse.each do |enc| + case enc.strip(' ') + when "gzip" + body_io = Compress::Gzip::Reader.new(body_io, sync_close: true) + when "deflate" + body_io = Compress::Deflate::Reader.new(body_io, sync_close: true) + end + end + end + + return body_io.gets_to_end + end end # End of module |
