Add administrator preferences

pull/387/head
Omar Roth 6 years ago
parent 2fe545e19a
commit a39b1583da

@ -82,6 +82,13 @@
"Manage subscriptions": "إدارة المشتركين", "Manage subscriptions": "إدارة المشتركين",
"Watch history": "سجل المشاهدة", "Watch history": "سجل المشاهدة",
"Delete account": "حذف الحساب", "Delete account": "حذف الحساب",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Save preferences": "حفظ التفضيلات", "Save preferences": "حفظ التفضيلات",
"Subscription manager": "مدير الإشتراكات", "Subscription manager": "مدير الإشتراكات",
"`x` subscriptions": "`x` مشتركين", "`x` subscriptions": "`x` مشتركين",

@ -82,6 +82,13 @@
"Manage subscriptions": "Abonnements verwalten", "Manage subscriptions": "Abonnements verwalten",
"Watch history": "Verlauf", "Watch history": "Verlauf",
"Delete account": "Account löschen", "Delete account": "Account löschen",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Save preferences": "Einstellungen speichern", "Save preferences": "Einstellungen speichern",
"Subscription manager": "Abonnementverwaltung", "Subscription manager": "Abonnementverwaltung",
"`x` subscriptions": "`x` Abonnements", "`x` subscriptions": "`x` Abonnements",

@ -80,6 +80,13 @@
"Manage subscriptions": "Manage subscriptions", "Manage subscriptions": "Manage subscriptions",
"Watch history": "Watch history", "Watch history": "Watch history",
"Delete account": "Delete account", "Delete account": "Delete account",
"Administrator preferences": "Administrator preferences",
"Default homepage: ": "Default homepage: ",
"Feed menu: ": "Feed menu: ",
"Top enabled? ": "Top enabled? ",
"CAPTCHA enabled? ": "CAPTCHA enabled? ",
"Login enabled? ": "Login enabled? ",
"Registration enabled? ": "Registration enabled? ",
"Save preferences": "Save preferences", "Save preferences": "Save preferences",
"Subscription manager": "Subscription manager", "Subscription manager": "Subscription manager",
"`x` subscriptions": "`x` subscriptions", "`x` subscriptions": "`x` subscriptions",

@ -80,6 +80,13 @@
"Manage subscriptions": "", "Manage subscriptions": "",
"Watch history": "", "Watch history": "",
"Delete account": "", "Delete account": "",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Save preferences": "", "Save preferences": "",
"Subscription manager": "", "Subscription manager": "",
"`x` subscriptions": "", "`x` subscriptions": "",

@ -79,6 +79,13 @@
"Manage subscriptions": "Gérer les abonnements", "Manage subscriptions": "Gérer les abonnements",
"Watch history": "Historique de visionnage", "Watch history": "Historique de visionnage",
"Delete account": "Supprimer votre compte", "Delete account": "Supprimer votre compte",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Save preferences": "Enregistrer les préférences", "Save preferences": "Enregistrer les préférences",
"Subscription manager": "Gestionnaire d'abonnement", "Subscription manager": "Gestionnaire d'abonnement",
"`x` subscriptions": "`x` abonnements", "`x` subscriptions": "`x` abonnements",

@ -79,6 +79,13 @@
"Manage subscriptions": "Gestisci le iscrizioni", "Manage subscriptions": "Gestisci le iscrizioni",
"Watch history": "Cronologia dei video", "Watch history": "Cronologia dei video",
"Delete account": "Elimina l'account", "Delete account": "Elimina l'account",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Save preferences": "Salva le preferenze", "Save preferences": "Salva le preferenze",
"Subscription manager": "Gestisci le iscrizioni", "Subscription manager": "Gestisci le iscrizioni",
"`x` subscriptions": "`x` iscrizioni", "`x` subscriptions": "`x` iscrizioni",

@ -1,280 +1,287 @@
{ {
"`x` subscribers": "`x` abonnenter", "`x` subscribers": "`x` abonnenter",
"`x` videos": "`x` videoer", "`x` videos": "`x` videoer",
"LIVE": "SANNTIDSVISNING", "LIVE": "SANNTIDSVISNING",
"Shared `x` ago": "Delt for `x` siden", "Shared `x` ago": "Delt for `x` siden",
"Unsubscribe": "Opphev abonnement", "Unsubscribe": "Opphev abonnement",
"Subscribe": "Abonner", "Subscribe": "Abonner",
"Login to subscribe to `x`": "Logg inn for å abonnere på `x`", "Login to subscribe to `x`": "Logg inn for å abonnere på `x`",
"View channel on YouTube": "Vis kanal på YouTube", "View channel on YouTube": "Vis kanal på YouTube",
"newest": "nyeste", "newest": "nyeste",
"oldest": "eldste", "oldest": "eldste",
"popular": "populært", "popular": "populært",
"Preview page": "Forhåndsvis side", "Preview page": "Forhåndsvis side",
"Next page": "Neste side", "Next page": "Neste side",
"Clear watch history?": "Tøm visningshistorikk?", "Clear watch history?": "Tøm visningshistorikk?",
"Yes": "Ja", "Yes": "Ja",
"No": "Nei", "No": "Nei",
"Import and Export Data": "Importer- og eksporter data", "Import and Export Data": "Importer- og eksporter data",
"Import": "Importer", "Import": "Importer",
"Import Invidious data": "Importer Invidious-data", "Import Invidious data": "Importer Invidious-data",
"Import YouTube subscriptions": "Importer YouTube-abonnenter", "Import YouTube subscriptions": "Importer YouTube-abonnenter",
"Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnenter (.db)", "Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnenter (.db)",
"Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnenter (.json)", "Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnenter (.json)",
"Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)", "Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)",
"Export": "Eksporter", "Export": "Eksporter",
"Export subscriptions as OPML": "Eksporter abonnenter som OPML", "Export subscriptions as OPML": "Eksporter abonnenter som OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksporter abonnenter som OPML (for NewPipe og FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksporter abonnenter som OPML (for NewPipe og FreeTube)",
"Export data as JSON": "Eksporter data som JSON", "Export data as JSON": "Eksporter data som JSON",
"Delete account?": "Slett konto?", "Delete account?": "Slett konto?",
"History": "Historikk", "History": "Historikk",
"Previous page": "Forrige side", "Previous page": "Forrige side",
"An alternative front-end to YouTube": "En alternativ grenseflate for YouTube", "An alternative front-end to YouTube": "En alternativ grenseflate for YouTube",
"JavaScript license information": "JavaScript-lisensinformasjon", "JavaScript license information": "JavaScript-lisensinformasjon",
"source": "kilde", "source": "kilde",
"Login": "Logg inn", "Login": "Logg inn",
"Login/Register": "Logg inn/registrer", "Login/Register": "Logg inn/registrer",
"Login to Google": "Logg inn med Google", "Login to Google": "Logg inn med Google",
"User ID:": "Bruker-ID:", "User ID:": "Bruker-ID:",
"Password:": "Passord:", "Password:": "Passord:",
"Time (h:mm:ss):": "Tid (h:mm:ss):", "Time (h:mm:ss):": "Tid (h:mm:ss):",
"Text CAPTCHA": "Tekst-CAPTCHA", "Text CAPTCHA": "Tekst-CAPTCHA",
"Image CAPTCHA": "Bilde-CAPTCHA", "Image CAPTCHA": "Bilde-CAPTCHA",
"Sign In": "Innlogging", "Sign In": "Innlogging",
"Register": "Registrer", "Register": "Registrer",
"Email:": "E-post:", "Email:": "E-post:",
"Google verification code:": "Google-bekreftelseskode:", "Google verification code:": "Google-bekreftelseskode:",
"Preferences": "Innstillinger", "Preferences": "Innstillinger",
"Player preferences": "Avspillerinnstillinger", "Player preferences": "Avspillerinnstillinger",
"Always loop: ": "Alltid gjenta: ", "Always loop: ": "Alltid gjenta: ",
"Autoplay: ": "Autoavspilling: ", "Autoplay: ": "Autoavspilling: ",
"Autoplay next video: ": "Autospill neste video: ", "Autoplay next video: ": "Autospill neste video: ",
"Listen by default: ": "Lytt som forvalg: ", "Listen by default: ": "Lytt som forvalg: ",
"Default speed: ": "Forvalgt hastighet: ", "Default speed: ": "Forvalgt hastighet: ",
"Preferred video quality: ": "Foretrukket videokvalitet: ", "Preferred video quality: ": "Foretrukket videokvalitet: ",
"Player volume: ": "Avspillerlydstyrke: ", "Player volume: ": "Avspillerlydstyrke: ",
"Default comments: ": "Forvalgte kommentarer: ", "Default comments: ": "Forvalgte kommentarer: ",
"Default captions: ": "Forvalgte undertitler: ", "Default captions: ": "Forvalgte undertitler: ",
"Fallback captions: ": "Tilbakefallsundertitler: ", "Fallback captions: ": "Tilbakefallsundertitler: ",
"Show related videos? ": "Vis relaterte videoer? ", "Show related videos? ": "Vis relaterte videoer? ",
"Visual preferences": "Visuelle innstillinger", "Visual preferences": "Visuelle innstillinger",
"Dark mode: ": "Mørk drakt: ", "Dark mode: ": "Mørk drakt: ",
"Thin mode: ": "Tynt modus: ", "Thin mode: ": "Tynt modus: ",
"Subscription preferences": "Abonnementsinnstillinger", "Subscription preferences": "Abonnementsinnstillinger",
"Redirect homepage to feed: ": "Videresend hjemmeside til flyt: ", "Redirect homepage to feed: ": "Videresend hjemmeside til flyt: ",
"Number of videos shown in feed: ": "Antall videoer å vise i flyt: ", "Number of videos shown in feed: ": "Antall videoer å vise i flyt: ",
"Sort videos by: ": "Sorter videoer etter: ", "Sort videos by: ": "Sorter videoer etter: ",
"published": "publisert", "published": "publisert",
"published - reverse": "publisert - motsatt", "published - reverse": "publisert - motsatt",
"alphabetically": "alfabetisk", "alphabetically": "alfabetisk",
"alphabetically - reverse": "alfabetisk - motsatt", "alphabetically - reverse": "alfabetisk - motsatt",
"channel name": "kanalnavn", "channel name": "kanalnavn",
"channel name - reverse": "kanalnavn - motsatt", "channel name - reverse": "kanalnavn - motsatt",
"Only show latest video from channel: ": "Kun vis siste video fra kanal: ", "Only show latest video from channel: ": "Kun vis siste video fra kanal: ",
"Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ", "Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ",
"Only show unwatched: ": "Kun vis usette: ", "Only show unwatched: ": "Kun vis usette: ",
"Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ", "Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ",
"Data preferences": "Datainnstillinger", "Data preferences": "Datainnstillinger",
"Clear watch history": "Tøm visningshistorikk", "Clear watch history": "Tøm visningshistorikk",
"Import/Export data": "Importer/eksporter data", "Import/Export data": "Importer/eksporter data",
"Manage subscriptions": "Behandle abonnementer", "Manage subscriptions": "Behandle abonnementer",
"Watch history": "Visningshistorikk", "Watch history": "Visningshistorikk",
"Delete account": "Slett konto", "Delete account": "Slett konto",
"Save preferences": "Lagre innstillinger", "Administrator preferences": "",
"Subscription manager": "Abonnementsbehandler", "Default homepage: ": "",
"`x` subscriptions": "`x` abonnementer", "Feed menu: ": "",
"Import/Export": "Importer/eksporter", "Top enabled? ": "",
"unsubscribe": "opphev abonnement", "CAPTCHA enabled? ": "",
"Subscriptions": "Abonnement", "Login enabled? ": "",
"`x` unseen notifications": "`x` usette merknader", "Registration enabled? ": "",
"search": "søk", "Save preferences": "Lagre innstillinger",
"Sign out": "Logg ut", "Subscription manager": "Abonnementsbehandler",
"Released under the AGPLv3 by Omar Roth.": "Utgitt med AGPLv3+lisens av Omar Roth.", "`x` subscriptions": "`x` abonnementer",
"Source available here.": "Kildekode tilgjengelig her.", "Import/Export": "Importer/eksporter",
"View JavaScript license information.": "Vis JavaScript-lisensinfo.", "unsubscribe": "opphev abonnement",
"Trending": "Trendsettende", "Subscriptions": "Abonnement",
"Watch video on Youtube": "Vis video på YouTube", "`x` unseen notifications": "`x` usette merknader",
"Genre: ": "Sjanger: ", "search": "søk",
"License: ": "Lisens: ", "Sign out": "Logg ut",
"Family friendly? ": "Familievennlig? ", "Released under the AGPLv3 by Omar Roth.": "Utgitt med AGPLv3+lisens av Omar Roth.",
"Wilson score: ": "Wilson-poengsum: ", "Source available here.": "Kildekode tilgjengelig her.",
"Engagement: ": "Engasjement: ", "View JavaScript license information.": "Vis JavaScript-lisensinfo.",
"Whitelisted regions: ": "Hvitlistede regioner: ", "Trending": "Trendsettende",
"Blacklisted regions: ": "Svartelistede regioner: ", "Watch video on Youtube": "Vis video på YouTube",
"Shared `x`": "Delt `x`", "Genre: ": "Sjanger: ",
"Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Hei. Det ser ut til at du har JavaScript avslått. Klikk her for å vise kommentarer, ha i minnet at innlasting tar lengre tid.", "License: ": "Lisens: ",
"View YouTube comments": "Vis YouTube-kommentarer", "Family friendly? ": "Familievennlig? ",
"View more comments on Reddit": "Vis flere kommenterer på Reddit", "Wilson score: ": "Wilson-poengsum: ",
"View `x` comments": "Vis `x` kommentarer", "Engagement: ": "Engasjement: ",
"View Reddit comments": "Vis Reddit-kommentarer", "Whitelisted regions: ": "Hvitlistede regioner: ",
"Hide replies": "Skjul svar", "Blacklisted regions: ": "Svartelistede regioner: ",
"Show replies": "Vis svar", "Shared `x`": "Delt `x`",
"Incorrect password": "Feil passord", "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Hei. Det ser ut til at du har JavaScript avslått. Klikk her for å vise kommentarer, ha i minnet at innlasting tar lengre tid.",
"Quota exceeded, try again in a few hours": "Kvote overskredet, prøv igjen om et par timer", "View YouTube comments": "Vis YouTube-kommentarer",
"Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Kunne ikke logge inn, forsikre deg om at tofaktor-identitetsbekreftelse (Authenticator eller SMS) er skrudd på.", "View more comments on Reddit": "Vis flere kommenterer på Reddit",
"Invalid TFA code": "Ugyldig tofaktorkode", "View `x` comments": "Vis `x` kommentarer",
"Login failed. This may be because two-factor authentication is not enabled on your account.": "Innlogging mislyktes. Dette kan være fordi tofaktor-identitetsbekreftelse er skrudd av på kontoen din.", "View Reddit comments": "Vis Reddit-kommentarer",
"Invalid answer": "Ugyldig svar", "Hide replies": "Skjul svar",
"Invalid CAPTCHA": "Ugyldig CAPTCHA", "Show replies": "Vis svar",
"CAPTCHA is a required field": "CAPTCHA er et påkrevd felt", "Incorrect password": "Feil passord",
"User ID is a required field": "Bruker-ID er et påkrevd felt", "Quota exceeded, try again in a few hours": "Kvote overskredet, prøv igjen om et par timer",
"Password is a required field": "Passord er et påkrevd felt", "Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Kunne ikke logge inn, forsikre deg om at tofaktor-identitetsbekreftelse (Authenticator eller SMS) er skrudd på.",
"Invalid username or password": "Ugyldig brukernavn eller passord", "Invalid TFA code": "Ugyldig tofaktorkode",
"Please sign in using 'Sign in with Google'": "Logg inn ved bruk av \"Google-innlogging\"", "Login failed. This may be because two-factor authentication is not enabled on your account.": "Innlogging mislyktes. Dette kan være fordi tofaktor-identitetsbekreftelse er skrudd av på kontoen din.",
"Password cannot be empty": "Passordet kan ikke være tomt", "Invalid answer": "Ugyldig svar",
"Password cannot be longer than 55 characters": "Passordet kan ikke være lengre enn 55 tegn", "Invalid CAPTCHA": "Ugyldig CAPTCHA",
"Please sign in": "Logg inn", "CAPTCHA is a required field": "CAPTCHA er et påkrevd felt",
"Invidious Private Feed for `x`": "Ugyldig privat flyt for `x`", "User ID is a required field": "Bruker-ID er et påkrevd felt",
"channel:`x`": "kanal `x`", "Password is a required field": "Passord er et påkrevd felt",
"Deleted or invalid channel": "Slettet eller ugyldig kanal", "Invalid username or password": "Ugyldig brukernavn eller passord",
"This channel does not exist.": "Denne kanalen finnes ikke.", "Please sign in using 'Sign in with Google'": "Logg inn ved bruk av \"Google-innlogging\"",
"Could not get channel info.": "Kunne ikke innhente kanalinfo.", "Password cannot be empty": "Passordet kan ikke være tomt",
"Could not fetch comments": "Kunne ikke hente kommentarer", "Password cannot be longer than 55 characters": "Passordet kan ikke være lengre enn 55 tegn",
"View `x` replies": "Vis `x` svar", "Please sign in": "Logg inn",
"`x` ago": "`x` siden", "Invidious Private Feed for `x`": "Ugyldig privat flyt for `x`",
"Load more": "Last inn flere", "channel:`x`": "kanal `x`",
"`x` points": "`x` poeng", "Deleted or invalid channel": "Slettet eller ugyldig kanal",
"Could not create mix.": "Kunne ikke opprette miks.", "This channel does not exist.": "Denne kanalen finnes ikke.",
"Playlist is empty": "Spillelisten er tom", "Could not get channel info.": "Kunne ikke innhente kanalinfo.",
"Invalid playlist.": "Ugyldig spilleliste.", "Could not fetch comments": "Kunne ikke hente kommentarer",
"Playlist does not exist.": "Spillelisten finnes ikke.", "View `x` replies": "Vis `x` svar",
"Could not pull trending pages.": "Kunne ikke hente trendsettende sider.", "`x` ago": "`x` siden",
"Hidden field \"challenge\" is a required field": "Skjult felt \"utfordring\" er et påkrevd felt", "Load more": "Last inn flere",
"Hidden field \"token\" is a required field": "Skjult felt \"symbol\" er et påkrevd felt", "`x` points": "`x` poeng",
"Invalid challenge": "Ugyldig utfordring", "Could not create mix.": "Kunne ikke opprette miks.",
"Invalid token": "Ugyldig symbol", "Playlist is empty": "Spillelisten er tom",
"Invalid user": "Ugyldig bruker", "Invalid playlist.": "Ugyldig spilleliste.",
"Token is expired, please try again": "Symbol utløpt, prøv igjen", "Playlist does not exist.": "Spillelisten finnes ikke.",
"English": "Engelsk", "Could not pull trending pages.": "Kunne ikke hente trendsettende sider.",
"English (auto-generated)": "Engelsk (auto-generert)", "Hidden field \"challenge\" is a required field": "Skjult felt \"utfordring\" er et påkrevd felt",
"Afrikaans": "", "Hidden field \"token\" is a required field": "Skjult felt \"symbol\" er et påkrevd felt",
"Albanian": "Albansk", "Invalid challenge": "Ugyldig utfordring",
"Amharic": "", "Invalid token": "Ugyldig symbol",
"Arabic": "Arabisk", "Invalid user": "Ugyldig bruker",
"Armenian": "Armensk", "Token is expired, please try again": "Symbol utløpt, prøv igjen",
"Azerbaijani": "", "English": "Engelsk",
"Bangla": "", "English (auto-generated)": "Engelsk (auto-generert)",
"Basque": "", "Afrikaans": "",
"Belarusian": "Hviterussisk", "Albanian": "Albansk",
"Bosnian": "Bosnisk", "Amharic": "",
"Bulgarian": "Bulgarsk", "Arabic": "Arabisk",
"Burmese": "Burmesisk", "Armenian": "Armensk",
"Catalan": "Katalansk", "Azerbaijani": "",
"Cebuano": "", "Bangla": "",
"Chinese (Simplified)": "", "Basque": "",
"Chinese (Traditional)": "", "Belarusian": "Hviterussisk",
"Corsican": "", "Bosnian": "Bosnisk",
"Croatian": "", "Bulgarian": "Bulgarsk",
"Czech": "Tsjekkisk", "Burmese": "Burmesisk",
"Danish": "Dansk", "Catalan": "Katalansk",
"Dutch": "", "Cebuano": "",
"Esperanto": "Esperanto", "Chinese (Simplified)": "",
"Estonian": "", "Chinese (Traditional)": "",
"Filipino": "", "Corsican": "",
"Finnish": "Finsk", "Croatian": "",
"French": "Fransk", "Czech": "Tsjekkisk",
"Galician": "", "Danish": "Dansk",
"Georgian": "", "Dutch": "",
"German": "", "Esperanto": "Esperanto",
"Greek": "", "Estonian": "",
"Gujarati": "", "Filipino": "",
"Haitian Creole": "", "Finnish": "Finsk",
"Hausa": "", "French": "Fransk",
"Hawaiian": "", "Galician": "",
"Hebrew": "", "Georgian": "",
"Hindi": "", "German": "",
"Hmong": "", "Greek": "",
"Hungarian": "Ungarsk", "Gujarati": "",
"Icelandic": "Islandsk", "Haitian Creole": "",
"Igbo": "", "Hausa": "",
"Indonesian": "Indonesisk", "Hawaiian": "",
"Irish": "Irsk", "Hebrew": "",
"Italian": "Italiensk", "Hindi": "",
"Japanese": "Japansk", "Hmong": "",
"Javanese": "", "Hungarian": "Ungarsk",
"Kannada": "", "Icelandic": "Islandsk",
"Kazakh": "", "Igbo": "",
"Khmer": "", "Indonesian": "Indonesisk",
"Korean": "", "Irish": "Irsk",
"Kurdish": "", "Italian": "Italiensk",
"Kyrgyz": "", "Japanese": "Japansk",
"Lao": "", "Javanese": "",
"Latin": "", "Kannada": "",
"Latvian": "", "Kazakh": "",
"Lithuanian": "", "Khmer": "",
"Luxembourgish": "", "Korean": "",
"Macedonian": "", "Kurdish": "",
"Malagasy": "", "Kyrgyz": "",
"Malay": "", "Lao": "",
"Malayalam": "", "Latin": "",
"Maltese": "", "Latvian": "",
"Maori": "", "Lithuanian": "",
"Marathi": "", "Luxembourgish": "",
"Mongolian": "", "Macedonian": "",
"Nepali": "", "Malagasy": "",
"Norwegian": "Norsk bokmål", "Malay": "",
"Nyanja": "", "Malayalam": "",
"Pashto": "", "Maltese": "",
"Persian": "", "Maori": "",
"Polish": "", "Marathi": "",
"Portuguese": "", "Mongolian": "",
"Punjabi": "", "Nepali": "",
"Romanian": "", "Norwegian": "Norsk bokmål",
"Russian": "Russisk", "Nyanja": "",
"Samoan": "", "Pashto": "",
"Scottish Gaelic": "", "Persian": "",
"Serbian": "Serbisk", "Polish": "",
"Shona": "", "Portuguese": "",
"Sindhi": "", "Punjabi": "",
"Sinhala": "", "Romanian": "",
"Slovak": "Slovakisk", "Russian": "Russisk",
"Slovenian": "Slovensk", "Samoan": "",
"Somali": "Somali", "Scottish Gaelic": "",
"Southern Sotho": "", "Serbian": "Serbisk",
"Spanish": "Spansk", "Shona": "",
"Spanish (Latin America)": "", "Sindhi": "",
"Sundanese": "", "Sinhala": "",
"Swahili": "", "Slovak": "Slovakisk",
"Swedish": "Svensk", "Slovenian": "Slovensk",
"Tajik": "", "Somali": "Somali",
"Tamil": "", "Southern Sotho": "",
"Telugu": "", "Spanish": "Spansk",
"Thai": "", "Spanish (Latin America)": "",
"Turkish": "Tyrkisk", "Sundanese": "",
"Ukrainian": "Ukrainsk", "Swahili": "",
"Urdu": "", "Swedish": "Svensk",
"Uzbek": "", "Tajik": "",
"Vietnamese": "Vietnamesisk", "Tamil": "",
"Welsh": "", "Telugu": "",
"Western Frisian": "", "Thai": "",
"Xhosa": "", "Turkish": "Tyrkisk",
"Yiddish": "", "Ukrainian": "Ukrainsk",
"Yoruba": "", "Urdu": "",
"Zulu": "", "Uzbek": "",
"`x` years": "`x` år", "Vietnamese": "Vietnamesisk",
"`x` months": "`x` måneder", "Welsh": "",
"`x` weeks": "`x` uker", "Western Frisian": "",
"`x` days": "`x` dager", "Xhosa": "",
"`x` hours": "`x` timer", "Yiddish": "",
"`x` minutes": "`x` minutter", "Yoruba": "",
"`x` seconds": "`x` sekunder", "Zulu": "",
"Fallback comments: ": "Tilbakefallskommentarer: ", "`x` years": "`x` år",
"Popular": "Pupulært", "`x` months": "`x` måneder",
"Top": "Topp", "`x` weeks": "`x` uker",
"About": "Om", "`x` days": "`x` dager",
"Rating: ": "Vurdering: ", "`x` hours": "`x` timer",
"Language: ": "Språk: ", "`x` minutes": "`x` minutter",
"Default": "Forvalg", "`x` seconds": "`x` sekunder",
"Music": "Musikk", "Fallback comments: ": "Tilbakefallskommentarer: ",
"Gaming": "Spill", "Popular": "Pupulært",
"News": "Nyheter", "Top": "Topp",
"Movies": "Filmer", "About": "Om",
"Download": "Last ned", "Rating: ": "Vurdering: ",
"Download as: ": "Last ned som: ", "Language: ": "Språk: ",
"%A %B %-d, %Y": "", "Default": "Forvalg",
"(edited)": "(redigert)", "Music": "Musikk",
"Youtube permalink of the comment": "Permanent YouTube-lenke til innholdet", "Gaming": "Spill",
"`x` marked it with a ❤": "`x` levnet et ❤", "News": "Nyheter",
"Audio mode": "Lydmodus", "Movies": "Filmer",
"Video mode": "Video-modus" "Download": "Last ned",
"Download as: ": "Last ned som: ",
"%A %B %-d, %Y": "",
"(edited)": "(redigert)",
"Youtube permalink of the comment": "Permanent YouTube-lenke til innholdet",
"`x` marked it with a ❤": "`x` levnet et ❤",
"Audio mode": "Lydmodus",
"Video mode": "Video-modus"
} }

@ -80,6 +80,13 @@
"Manage subscriptions": "Abonnees beheren", "Manage subscriptions": "Abonnees beheren",
"Watch history": "Kijkgeschiedenis", "Watch history": "Kijkgeschiedenis",
"Delete account": "Account verwijderen", "Delete account": "Account verwijderen",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Save preferences": "Opslaan voorkeuren", "Save preferences": "Opslaan voorkeuren",
"Subscription manager": "Abonnees beheerder", "Subscription manager": "Abonnees beheerder",
"`x` subscriptions": "`x` abonnees", "`x` subscriptions": "`x` abonnees",

@ -80,6 +80,13 @@
"Manage subscriptions": "Organizuj subskrybcje", "Manage subscriptions": "Organizuj subskrybcje",
"Watch history": "Historia", "Watch history": "Historia",
"Delete account": "Usuń konto", "Delete account": "Usuń konto",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Save preferences": "Zapisz preferencje", "Save preferences": "Zapisz preferencje",
"Subscription manager": "Manager subskrybcji", "Subscription manager": "Manager subskrybcji",
"`x` subscriptions": "`x` subskrybcji", "`x` subscriptions": "`x` subskrybcji",

@ -82,6 +82,13 @@
"Manage subscriptions": "Управление подписками", "Manage subscriptions": "Управление подписками",
"Watch history": "История просмотров", "Watch history": "История просмотров",
"Delete account": "Удалить аккаунт", "Delete account": "Удалить аккаунт",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Save preferences": "Сохранить настройки", "Save preferences": "Сохранить настройки",
"Subscription manager": "Менеджер подписок", "Subscription manager": "Менеджер подписок",
"`x` subscriptions": "`x` подписок", "`x` subscriptions": "`x` подписок",

@ -31,42 +31,38 @@ require "./invidious/*"
CONFIG = Config.from_yaml(File.read("config/config.yml")) CONFIG = Config.from_yaml(File.read("config/config.yml"))
HMAC_KEY = CONFIG.hmac_key || Random::Secure.random_bytes(32) HMAC_KEY = CONFIG.hmac_key || Random::Secure.random_bytes(32)
crawl_threads = CONFIG.crawl_threads config = CONFIG
channel_threads = CONFIG.channel_threads
feed_threads = CONFIG.feed_threads
video_threads = CONFIG.video_threads
logger = Invidious::LogHandler.new logger = Invidious::LogHandler.new
Kemal.config.extra_options do |parser| Kemal.config.extra_options do |parser|
parser.banner = "Usage: invidious [arguments]" parser.banner = "Usage: invidious [arguments]"
parser.on("-t THREADS", "--crawl-threads=THREADS", "Number of threads for crawling YouTube (default: #{crawl_threads})") do |number| parser.on("-t THREADS", "--crawl-threads=THREADS", "Number of threads for crawling YouTube (default: #{config.crawl_threads})") do |number|
begin begin
crawl_threads = number.to_i config.crawl_threads = number.to_i
rescue ex rescue ex
puts "THREADS must be integer" puts "THREADS must be integer"
exit exit
end end
end end
parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{channel_threads})") do |number| parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{config.channel_threads})") do |number|
begin begin
channel_threads = number.to_i config.channel_threads = number.to_i
rescue ex rescue ex
puts "THREADS must be integer" puts "THREADS must be integer"
exit exit
end end
end end
parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{feed_threads})") do |number| parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{config.feed_threads})") do |number|
begin begin
feed_threads = number.to_i config.feed_threads = number.to_i
rescue ex rescue ex
puts "THREADS must be integer" puts "THREADS must be integer"
exit exit
end end
end end
parser.on("-v THREADS", "--video-threads=THREADS", "Number of threads for refreshing videos (default: #{video_threads})") do |number| parser.on("-v THREADS", "--video-threads=THREADS", "Number of threads for refreshing videos (default: #{config.video_threads})") do |number|
begin begin
video_threads = number.to_i config.video_threads = number.to_i
rescue ex rescue ex
puts "THREADS must be integer" puts "THREADS must be integer"
exit exit
@ -107,28 +103,30 @@ LOCALES = {
"ru" => load_locale("ru"), "ru" => load_locale("ru"),
} }
crawl_threads.times do config.crawl_threads.times do
spawn do spawn do
crawl_videos(PG_DB, logger) crawl_videos(PG_DB, logger)
end end
end end
refresh_channels(PG_DB, logger, channel_threads, CONFIG.full_refresh) refresh_channels(PG_DB, logger, config.channel_threads, config.full_refresh)
refresh_feeds(PG_DB, logger, feed_threads) refresh_feeds(PG_DB, logger, config.feed_threads)
video_threads.times do |i| config.video_threads.times do |i|
spawn do spawn do
refresh_videos(PG_DB, logger) refresh_videos(PG_DB, logger)
end end
end end
top_videos = [] of Video top_videos = [] of Video
spawn do if config.top_enabled
pull_top_videos(CONFIG, PG_DB) do |videos| spawn do
top_videos = videos pull_top_videos(config, PG_DB) do |videos|
sleep 1.minutes top_videos = videos
Fiber.yield sleep 1.minutes
Fiber.yield
end
end end
end end
@ -231,7 +229,20 @@ get "/" do |env|
end end
end end
templated "index" case config.default_home
when "Popular"
templated "popular"
when "Top"
templated "top"
when "Trending"
env.redirect "/feed/trending"
when "Subscriptions"
if user
env.redirect "/feed/subscriptions"
else
templated "popular"
end
end
end end
get "/licenses" do |env| get "/licenses" do |env|
@ -367,7 +378,7 @@ get "/watch" do |env|
video.description = replace_links(video.description) video.description = replace_links(video.description)
description = video.short_description description = video.short_description
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
host_params = env.request.query_params host_params = env.request.query_params
host_params.delete_all("v") host_params.delete_all("v")
@ -467,7 +478,7 @@ get "/embed/:id" do |env|
video.description = replace_links(video.description) video.description = replace_links(video.description)
description = video.short_description description = video.short_description
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
host_params = env.request.query_params host_params = env.request.query_params
host_params.delete_all("v") host_params.delete_all("v")
@ -553,7 +564,7 @@ get "/opensearch.xml" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("locale").as(String)]?
env.response.content_type = "application/opensearchdescription+xml" env.response.content_type = "application/opensearchdescription+xml"
host = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
XML.build(indent: " ", encoding: "UTF-8") do |xml| XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("OpenSearchDescription", xmlns: "http://a9.com/-/spec/opensearch/1.1/") do xml.element("OpenSearchDescription", xmlns: "http://a9.com/-/spec/opensearch/1.1/") do
@ -678,6 +689,11 @@ get "/login" do |env|
next env.redirect "/feed/subscriptions" next env.redirect "/feed/subscriptions"
end end
if !config.login_enabled
error_message = "Login has been disabled by administrator."
next templated "error"
end
referer = get_referer(env, "/feed/subscriptions") referer = get_referer(env, "/feed/subscriptions")
account_type = env.params.query["type"]? account_type = env.params.query["type"]?
@ -716,6 +732,11 @@ post "/login" do |env|
referer = get_referer(env, "/feed/subscriptions") referer = get_referer(env, "/feed/subscriptions")
if !config.login_enabled
error_message = "Login has been disabled by administrator."
next templated "error"
end
email = env.params.body["email"]? email = env.params.body["email"]?
password = env.params.body["password"]? password = env.params.body["password"]?
@ -876,14 +897,14 @@ post "/login" do |env|
host = URI.parse(env.request.headers["Host"]).host host = URI.parse(env.request.headers["Host"]).host
if Kemal.config.ssl || CONFIG.https_only if Kemal.config.ssl || config.https_only
secure = true secure = true
else else
secure = false secure = false
end end
login.cookies.each do |cookie| login.cookies.each do |cookie|
if Kemal.config.ssl || CONFIG.https_only if Kemal.config.ssl || config.https_only
cookie.secure = secure cookie.secure = secure
else else
cookie.secure = secure cookie.secure = secure
@ -912,54 +933,56 @@ post "/login" do |env|
answer = env.params.body["answer"]? answer = env.params.body["answer"]?
text_answer = env.params.body["text_answer"]? text_answer = env.params.body["text_answer"]?
if answer if config.captcha_enabled
answer = answer.lstrip('0') if answer
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) answer = answer.lstrip('0')
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
challenge = env.params.body["challenge"]?
token = env.params.body["token"]?
begin
validate_response(challenge, token, answer, "sign_in", HMAC_KEY, PG_DB, locale)
rescue ex
if ex.message == translate(locale, "Invalid user")
error_message = translate(locale, "Invalid answer")
else
error_message = ex.message
end
next templated "error"
end
elsif text_answer
text_answer = Digest::MD5.hexdigest(text_answer.downcase.strip)
challenges = env.params.body.select { |k, v| k.match(/text_challenge\d+/) } challenge = env.params.body["challenge"]?
tokens = env.params.body.select { |k, v| k.match(/text_token\d+/) } token = env.params.body["token"]?
found_valid_captcha = false
error_message = translate(locale, "Invalid CAPTCHA")
challenges.each_with_index do |challenge, i|
begin begin
challenge = challenge[1] validate_response(challenge, token, answer, "sign_in", HMAC_KEY, PG_DB, locale)
token = tokens[i][1]
validate_response(challenge, token, text_answer, "sign_in", HMAC_KEY, PG_DB, locale)
found_valid_captcha = true
rescue ex rescue ex
if ex.message == translate(locale, "Invalid user") if ex.message == translate(locale, "Invalid user")
error_message = translate(locale, "Invalid answer") error_message = translate(locale, "Invalid answer")
else else
error_message = ex.message error_message = ex.message
end end
next templated "error"
end
elsif text_answer
text_answer = Digest::MD5.hexdigest(text_answer.downcase.strip)
challenges = env.params.body.select { |k, v| k.match(/text_challenge\d+/) }
tokens = env.params.body.select { |k, v| k.match(/text_token\d+/) }
found_valid_captcha = false
error_message = translate(locale, "Invalid CAPTCHA")
challenges.each_with_index do |challenge, i|
begin
challenge = challenge[1]
token = tokens[i][1]
validate_response(challenge, token, text_answer, "sign_in", HMAC_KEY, PG_DB, locale)
found_valid_captcha = true
rescue ex
if ex.message == translate(locale, "Invalid user")
error_message = translate(locale, "Invalid answer")
else
error_message = ex.message
end
end
end end
end
if !found_valid_captcha if !found_valid_captcha
next templated "error"
end
else
error_message = translate(locale, "CAPTCHA is a required field")
next templated "error" next templated "error"
end end
else
error_message = translate(locale, "CAPTCHA is a required field")
next templated "error"
end end
action = env.params.body["action"]? action = env.params.body["action"]?
@ -992,14 +1015,14 @@ post "/login" do |env|
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.now) PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.now)
if Kemal.config.ssl || CONFIG.https_only if Kemal.config.ssl || config.https_only
secure = true secure = true
else else
secure = false secure = false
end end
if CONFIG.domain if config.domain
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: ".#{CONFIG.domain}", value: sid, expires: Time.now + 2.years, env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: ".#{config.domain}", value: sid, expires: Time.now + 2.years,
secure: secure, http_only: true) secure: secure, http_only: true)
else else
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.now + 2.years, env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.now + 2.years,
@ -1016,6 +1039,11 @@ post "/login" do |env|
secure: secure, http_only: true) secure: secure, http_only: true)
end end
elsif action == "register" elsif action == "register"
if !config.registration_enabled
error_message = "Registration has been disabled by administrator."
next templated "error"
end
if password.empty? if password.empty?
error_message = translate(locale, "Password cannot be empty") error_message = translate(locale, "Password cannot be empty")
next templated "error" next templated "error"
@ -1049,14 +1077,14 @@ post "/login" do |env|
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \ ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;") ORDER BY published DESC;")
if Kemal.config.ssl || CONFIG.https_only if Kemal.config.ssl || config.https_only
secure = true secure = true
else else
secure = false secure = false
end end
if CONFIG.domain if config.domain
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: ".#{CONFIG.domain}", value: sid, expires: Time.now + 2.years, env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: ".#{config.domain}", value: sid, expires: Time.now + 2.years,
secure: secure, http_only: true) secure: secure, http_only: true)
else else
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.now + 2.years, env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.now + 2.years,
@ -1153,14 +1181,15 @@ post "/preferences" do |env|
volume = env.params.body["volume"]?.try &.as(String).to_i? volume = env.params.body["volume"]?.try &.as(String).to_i?
volume ||= DEFAULT_USER_PREFERENCES.volume volume ||= DEFAULT_USER_PREFERENCES.volume
comments_0 = env.params.body["comments_0"]?.try &.as(String) || DEFAULT_USER_PREFERENCES.comments[0] comments = [] of String
comments_1 = env.params.body["comments_1"]?.try &.as(String) || DEFAULT_USER_PREFERENCES.comments[1] 2.times do |i|
comments = [comments_0, comments_1] comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || DEFAULT_USER_PREFERENCES.comments[i])
end
captions_0 = env.params.body["captions_0"]?.try &.as(String) || DEFAULT_USER_PREFERENCES.captions[0] captions = [] of String
captions_1 = env.params.body["captions_1"]?.try &.as(String) || DEFAULT_USER_PREFERENCES.captions[1] 3.times do |i|
captions_2 = env.params.body["captions_2"]?.try &.as(String) || DEFAULT_USER_PREFERENCES.captions[2] captions << (env.params.body["captions[#{i}]"]?.try &.as(String) || DEFAULT_USER_PREFERENCES.captions[i])
captions = [captions_0, captions_1, captions_2] end
related_videos = env.params.body["related_videos"]?.try &.as(String) related_videos = env.params.body["related_videos"]?.try &.as(String)
related_videos ||= "off" related_videos ||= "off"
@ -1224,6 +1253,37 @@ post "/preferences" do |env|
if user = env.get? "user" if user = env.get? "user"
user = user.as(User) user = user.as(User)
PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email)
if config.admins.includes? user.email
config.default_home = env.params.body["default_home"]?.try &.as(String) || config.default_home
feed_menu = [] of String
4.times do |index|
option = env.params.body["feed_menu[#{index}]"]?.try &.as(String) || ""
if !option.empty?
feed_menu << option
end
end
config.feed_menu = feed_menu
top_enabled = env.params.body["top_enabled"]?.try &.as(String)
top_enabled ||= "off"
config.top_enabled = top_enabled == "on"
captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String)
captcha_enabled ||= "off"
config.captcha_enabled = captcha_enabled == "on"
login_enabled = env.params.body["login_enabled"]?.try &.as(String)
login_enabled ||= "off"
config.login_enabled = login_enabled == "on"
registration_enabled = env.params.body["registration_enabled"]?.try &.as(String)
registration_enabled ||= "off"
config.registration_enabled = registration_enabled == "on"
File.write("config/config.yml", config.to_yaml)
end
else else
env.response.cookies["PREFS"] = preferences env.response.cookies["PREFS"] = preferences
end end
@ -1397,7 +1457,7 @@ get "/subscription_manager" do |env|
subscriptions.sort_by! { |channel| channel.author.downcase } subscriptions.sort_by! { |channel| channel.author.downcase }
if action_takeout if action_takeout
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
if format == "json" if format == "json"
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -1741,7 +1801,11 @@ end
get "/feed/top" do |env| get "/feed/top" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("locale").as(String)]?
templated "top" if config.top_enabled
templated "top"
else
env.redirect "/"
end
end end
get "/feed/popular" do |env| get "/feed/popular" do |env|
@ -1984,7 +2048,7 @@ get "/feed/channel/:ucid" do |env|
) )
end end
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
path = env.request.path path = env.request.path
feed = XML.build(indent: " ", encoding: "UTF-8") do |xml| feed = XML.build(indent: " ", encoding: "UTF-8") do |xml|
@ -2118,7 +2182,7 @@ get "/feed/private" do |env|
videos = videos[0..max_results] videos = videos[0..max_results]
end end
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
path = env.request.path path = env.request.path
query = env.request.query.not_nil! query = env.request.query.not_nil!
@ -2173,7 +2237,7 @@ get "/feed/playlist/:plid" do |env|
plid = env.params.url["plid"] plid = env.params.url["plid"]
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
path = env.request.path path = env.request.path
client = make_client(YT_URL) client = make_client(YT_URL)
@ -2487,7 +2551,7 @@ get "/api/v1/insights/:id" do |env|
env.response.content_type = "application/json" env.response.content_type = "application/json"
error_message = {"error" => "YouTube has removed publicly-available analytics."}.to_json error_message = {"error" => "YouTube has removed publicly-available analytics."}.to_json
halt env, status_code: 503, response: error_message halt env, status_code: 410, response: error_message
client = make_client(YT_URL) client = make_client(YT_URL)
headers = HTTP::Headers.new headers = HTTP::Headers.new
@ -2653,7 +2717,7 @@ get "/api/v1/videos/:id" do |env|
end end
if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]? if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
host_params = env.request.query_params host_params = env.request.query_params
host_params.delete_all("v") host_params.delete_all("v")
@ -2871,6 +2935,11 @@ get "/api/v1/top" do |env|
env.response.content_type = "application/json" env.response.content_type = "application/json"
if !config.top_enabled
error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
halt env, status_code: 400, response: error_message
end
videos = JSON.build do |json| videos = JSON.build do |json|
json.array do json.array do
top_videos.each do |video| top_videos.each do |video|
@ -3842,7 +3911,7 @@ get "/api/manifest/hls_variant/*" do |env|
env.response.content_type = "application/x-mpegURL" env.response.content_type = "application/x-mpegURL"
env.response.headers.add("Access-Control-Allow-Origin", "*") env.response.headers.add("Access-Control-Allow-Origin", "*")
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
manifest = manifest.body manifest = manifest.body
manifest.gsub("https://www.youtube.com", host_url) manifest.gsub("https://www.youtube.com", host_url)
@ -3856,7 +3925,7 @@ get "/api/manifest/hls_playlist/*" do |env|
halt env, status_code: manifest.status_code halt env, status_code: manifest.status_code
end end
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
manifest = manifest.body.gsub("https://www.youtube.com", host_url) manifest = manifest.body.gsub("https://www.youtube.com", host_url)
manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, host_url) manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, host_url)

@ -1,9 +1,9 @@
class Config class Config
YAML.mapping({ YAML.mapping({
video_threads: Int32, # Number of threads to use for updating videos in cache (mostly non-functional)
crawl_threads: Int32, # Number of threads to use for finding new videos from YouTube (used to populate "top" page) crawl_threads: Int32, # Number of threads to use for finding new videos from YouTube (used to populate "top" page)
channel_threads: Int32, # Number of threads to use for crawling videos from channels (for updating subscriptions) channel_threads: Int32, # Number of threads to use for crawling videos from channels (for updating subscriptions)
feed_threads: Int32, # Number of threads to use for updating feeds feed_threads: Int32, # Number of threads to use for updating feeds
video_threads: Int32, # Number of threads to use for updating videos in cache (mostly non-functional)
db: NamedTuple( # Database configuration db: NamedTuple( # Database configuration
user: String, user: String,
password: String, password: String,
@ -11,11 +11,18 @@ user: String,
port: Int32, port: Int32,
dbname: String, dbname: String,
), ),
dl_api_key: String?, # DetectLanguage API Key (used to filter non-English results from "top" page), mostly non-functional full_refresh: Bool, # Used for crawling channels: threads should check all videos uploaded by a channel
https_only: Bool?, # Used to tell Invidious it is behind a proxy, so links to resources should be https:// https_only: Bool?, # Used to tell Invidious it is behind a proxy, so links to resources should be https://
hmac_key: String?, # HMAC signing key for CSRF tokens hmac_key: String?, # HMAC signing key for CSRF tokens
full_refresh: Bool, # Used for crawling channels: threads should check all videos uploaded by a channel domain: String, # Domain to be used for links to resources on the site where an absolute URL is required
domain: String, # Domain to be used for links to resources on the site where an absolute URL is required dl_api_key: String?, # DetectLanguage API Key (used to filter non-English results from "top" page), mostly non-functional
default_home: {type: String, default: "Top"},
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending"]},
top_enabled: {type: Bool, default: true},
captcha_enabled: {type: Bool, default: true},
login_enabled: {type: Bool, default: true},
registration_enabled: {type: Bool, default: true},
admins: {type: Array(String), default: [] of String},
}) })
end end

@ -133,7 +133,7 @@ def refresh_feeds(db, logger, max_threads = 1)
rescue ex rescue ex
# Create view if it doesn't exist # Create view if it doesn't exist
if ex.message.try &.ends_with? "does not exist" if ex.message.try &.ends_with? "does not exist"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
SELECT * FROM channel_videos WHERE \ SELECT * FROM channel_videos WHERE \
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \ ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;") ORDER BY published DESC;")
@ -193,11 +193,11 @@ end
def pull_popular_videos(db) def pull_popular_videos(db)
loop do loop do
subscriptions = PG_DB.query_all("SELECT channel FROM \ subscriptions = db.query_all("SELECT channel FROM \
(SELECT UNNEST(subscriptions) AS channel FROM users) AS d \ (SELECT UNNEST(subscriptions) AS channel FROM users) AS d \
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40", as: String) GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40", as: String)
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM \ videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM \
channel_videos WHERE ucid IN (#{arg_array(subscriptions)}) \ channel_videos WHERE ucid IN (#{arg_array(subscriptions)}) \
ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse

@ -143,7 +143,7 @@ def get_user(sid, headers, db, refresh = true)
begin begin
view_name = "subscriptions_#{sha256(user.email)[0..7]}" view_name = "subscriptions_#{sha256(user.email)[0..7]}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
SELECT * FROM channel_videos WHERE \ SELECT * FROM channel_videos WHERE \
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \ ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;") ORDER BY published DESC;")
@ -165,7 +165,7 @@ def get_user(sid, headers, db, refresh = true)
begin begin
view_name = "subscriptions_#{sha256(user.email)[0..7]}" view_name = "subscriptions_#{sha256(user.email)[0..7]}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
SELECT * FROM channel_videos WHERE \ SELECT * FROM channel_videos WHERE \
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \ ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;") ORDER BY published DESC;")
@ -247,7 +247,7 @@ def validate_response(challenge, token, user_id, operation, key, db, locale)
raise translate(locale, "Invalid challenge") raise translate(locale, "Invalid challenge")
end end
challenge = OpenSSL::HMAC.digest(:sha256, HMAC_KEY, challenge) challenge = OpenSSL::HMAC.digest(:sha256, key, challenge)
challenge = Base64.urlsafe_encode(challenge) challenge = Base64.urlsafe_encode(challenge)
if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool) if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool)

@ -2,12 +2,12 @@
<div class="pure-u-1 pure-u-md-1-4"></div> <div class="pure-u-1 pure-u-md-1-4"></div>
<div class="pure-u-1 pure-u-md-1-2"> <div class="pure-u-1 pure-u-md-1-2">
<div class="pure-g"> <div class="pure-g">
<% feeds = ["Popular", "Top", "Trending"] %> <% feed_menu = config.feed_menu.dup %>
<% if env.get? "user" %> <% if !env.get?("user") %>
<% feeds << "Subscriptions" %> <% feed_menu.reject! {|feed| feed == "Subscriptions"} %>
<% end %> <% end %>
<% feeds.each do |feed| %> <% feed_menu.each do |feed| %>
<div class="pure-u-1-2 pure-u-md-1-<%= feeds.size %>"> <div class="pure-u-1-2 pure-u-md-1-<%= feed_menu.size %>">
<a href="/feed/<%= feed.downcase %>" style="text-align:center;" class="pure-menu-heading"> <a href="/feed/<%= feed.downcase %>" style="text-align:center;" class="pure-menu-heading">
<%= translate(locale, feed) %> <%= translate(locale, feed) %>
</a> </a>

@ -1,14 +0,0 @@
<% content_for "header" do %>
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title>Invidious</title>
<% end %>
<%= rendered "components/feed_menu" %>
<div class="pure-g">
<% top_videos.each_slice(4) do |slice| %>
<% slice.each do |item| %>
<%= rendered "components/item" %>
<% end %>
<% end %>
</div>

@ -27,36 +27,40 @@
<label for="password"><%= translate(locale, "Password:") %></label> <label for="password"><%= translate(locale, "Password:") %></label>
<input required class="pure-input-1" name="password" type="password" placeholder="Password"> <input required class="pure-input-1" name="password" type="password" placeholder="Password">
<% if captcha_type == "image" %>
<img style="width:100%" src='<%= captcha.not_nil![:image] %>'/>
<input type="hidden" name="token" value="<%= captcha.not_nil![:token] %>">
<input type="hidden" name="challenge" value="<%= captcha.not_nil![:challenge] %>">
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
<input required type="text" name="answer" type="text" placeholder="h:mm:ss">
<label>
<a href="/login?referer=<%= URI.escape(referer) %>&type=invidious&captcha=text">
<%= translate(locale, "Text CAPTCHA") %>
</a>
</label>
<% else %>
<% text_captcha.not_nil![:tokens].each_with_index do |token, i| %>
<input type="hidden" name="text_challenge<%= i %>" value="<%= token[0] %>">
<input type="hidden" name="text_token<%= i %>" value="<%= token[1] %>">
<% end %>
<label for="text_answer"><%= text_captcha.not_nil![:question] %></label>
<input required type="text" name="text_answer" type="text" placeholder="Answer">
<label> <% if config.captcha_enabled %>
<a href="/login?referer=<%= URI.escape(referer) %>&type=invidious"> <% if captcha_type == "image" %>
<%= translate(locale, "Image CAPTCHA") %> <img style="width:100%" src='<%= captcha.not_nil![:image] %>'/>
</a> <input type="hidden" name="token" value="<%= captcha.not_nil![:token] %>">
</label> <input type="hidden" name="challenge" value="<%= captcha.not_nil![:challenge] %>">
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
<input required type="text" name="answer" type="text" placeholder="h:mm:ss">
<label>
<a href="/login?referer=<%= URI.escape(referer) %>&type=invidious&captcha=text">
<%= translate(locale, "Text CAPTCHA") %>
</a>
</label>
<% else %>
<% text_captcha.not_nil![:tokens].each_with_index do |token, i| %>
<input type="hidden" name="text_challenge<%= i %>" value="<%= token[0] %>">
<input type="hidden" name="text_token<%= i %>" value="<%= token[1] %>">
<% end %>
<label for="text_answer"><%= text_captcha.not_nil![:question] %></label>
<input required type="text" name="text_answer" type="text" placeholder="Answer">
<label>
<a href="/login?referer=<%= URI.escape(referer) %>&type=invidious">
<%= translate(locale, "Image CAPTCHA") %>
</a>
</label>
<% end %>
<% end %> <% end %>
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary"><%= translate(locale, "Sign In") %></button> <button type="submit" name="action" value="signin" class="pure-button pure-button-primary"><%= translate(locale, "Sign In") %></button>
<% if config.registration_enabled %>
<button type="submit" name="action" value="register" class="pure-button pure-button-primary"><%= translate(locale, "Register") %></button> <button type="submit" name="action" value="register" class="pure-button pure-button-primary"><%= translate(locale, "Register") %></button>
<% end %>
</fieldset> </fieldset>
</form> </form>
<% elsif account_type == "google" %> <% elsif account_type == "google" %>
@ -67,7 +71,7 @@
<label for="password"><%= translate(locale, "Password:") %></label> <label for="password"><%= translate(locale, "Password:") %></label>
<input required class="pure-input-1" name="password" type="password" placeholder="Password"> <input required class="pure-input-1" name="password" type="password" placeholder="Password">
<% if tfa %> <% if tfa %>
<label for="tfa"><%= translate(locale, "Google verification code:") %></label> <label for="tfa"><%= translate(locale, "Google verification code:") %></label>
<input required class="pure-input-1" name="tfa" type="text" placeholder="Google verification code"> <input required class="pure-input-1" name="tfa" type="text" placeholder="Google verification code">

@ -1,6 +1,6 @@
<% content_for "header" do %> <% content_for "header" do %>
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>"> <meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title><%= translate(locale, "Popular") %> - Invidious</title> <title><% if config.default_home != "Popular" %><%= translate(locale, "Popular") %> - <% end %>Invidious</title>
<% end %> <% end %>
<%= rendered "components/feed_menu" %> <%= rendered "components/feed_menu" %>

@ -58,45 +58,25 @@ function update_value(element) {
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="comments_0"><%= translate(locale, "Default comments: ") %></label> <label for="comments[0]"><%= translate(locale, "Default comments: ") %></label>
<select name="comments_0" id="comments_0"> <% preferences.comments.each_with_index do |comments, index| %>
<select name="comments[<%= index %>]" id="comments[<%= index %>]">
<% {"", "youtube", "reddit"}.each do |option| %> <% {"", "youtube", "reddit"}.each do |option| %>
<option value="<%= option %>" <% if preferences.comments[0] == option %> selected <% end %>><%= translate(locale, option) %></option> <option value="<%= option %>" <% if preferences.comments[index] == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %> <% end %>
</select> </select>
</div>
<div class="pure-control-group">
<label for="comments_1"><%= translate(locale, "Fallback comments: ") %></label>
<select name="comments_1" id="comments_1">
<% {"", "youtube", "reddit"}.each do |option| %>
<option value="<%= option %>" <% if preferences.comments[1] == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="captions_0"><%= translate(locale, "Default captions: ") %></label>
<select class="pure-u-1-5" name="captions_0" id="captions_0">
<% CAPTION_LANGUAGES.each do |option| %>
<option value="<%= option %>" <% if preferences.captions[0] == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %> <% end %>
</select>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="captions_fallback"><%= translate(locale, "Fallback captions: ") %></label> <label for="captions[0]"><%= translate(locale, "Default captions: ") %></label>
<select class="pure-u-1-5" name="captions_1" id="captions_1"> <% preferences.captions.each_with_index do |caption, index| %>
<select class="pure-u-1-6" name="captions[<%= index %>]" id="captions[<%= index %>]">
<% CAPTION_LANGUAGES.each do |option| %> <% CAPTION_LANGUAGES.each do |option| %>
<option value="<%= option %>" <% if preferences.captions[1] == option %> selected <% end %>><%= translate(locale, option) %></option> <option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %> <% end %>
</select> </select>
<select class="pure-u-1-5" name="captions_2" id="captions_2">
<% CAPTION_LANGUAGES.each do |option| %>
<option value="<%= option %>" <% if preferences.captions[2] == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %> <% end %>
</select>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
@ -167,13 +147,57 @@ function update_value(element) {
</div> </div>
<% end %> <% end %>
<% if env.get?("user") && config.admins.includes? env.get?("user").as(User).email %>
<legend><%= translate(locale, "Administrator preferences") %></legend>
<div class="pure-control-group">
<label for="default_home"><%= translate(locale, "Default homepage: ") %></label>
<select name="default_home" id="default_home">
<% {"Popular", "Top", "Trending", "Subscriptions"}.each do |option| %>
<option value="<%= option %>" <% if config.default_home == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="feed_menu"><%= translate(locale, "Feed menu: ") %></label>
<% 4.times do |index| %>
<select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]">
<% {"", "Popular", "Top", "Trending", "Subscriptions"}.each do |option| %>
<option value="<%= option %>" <% if config.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
<% end %>
</div>
<div class="pure-control-group">
<label for="top_enabled"><%= translate(locale, "Top enabled? ") %></label>
<input name="top_enabled" id="top_enabled" type="checkbox" <% if config.top_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="captcha_enabled"><%= translate(locale, "CAPTCHA enabled? ") %></label>
<input name="captcha_enabled" id="captcha_enabled" type="checkbox" <% if config.captcha_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="login_enabled"><%= translate(locale, "Login enabled? ") %></label>
<input name="login_enabled" id="login_enabled" type="checkbox" <% if config.login_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="registration_enabled"><%= translate(locale, "Registration enabled? ") %></label>
<input name="registration_enabled" id="registration_enabled" type="checkbox" <% if config.registration_enabled %>checked<% end %>>
</div>
<% end %>
<% if env.get? "user" %> <% if env.get? "user" %>
<legend><%= translate(locale, "Data preferences") %></legend> <legend><%= translate(locale, "Data preferences") %></legend>
<div class="pure-control-group"> <div class="pure-control-group">
<a href="/clear_watch_history?referer=<%= URI.escape(referer) %>"><%= translate(locale, "Clear watch history") %></a> <a href="/clear_watch_history?referer=<%= URI.escape(referer) %>"><%= translate(locale, "Clear watch history") %></a>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<a href="/data_control?referer=<%= URI.escape(referer) %>"><%= translate(locale, "Import/Export data") %></a> <a href="/data_control?referer=<%= URI.escape(referer) %>"><%= translate(locale, "Import/Export data") %></a>
</div> </div>

@ -89,12 +89,14 @@
<i class="icon ion-ios-cog"></i> <i class="icon ion-ios-cog"></i>
</a> </a>
</div> </div>
<% if config.login_enabled %>
<div class="pure-u-1-3"> <div class="pure-u-1-3">
<a href="/login?referer=<%= env.get?("current_page") %>" class="pure-menu-heading"> <a href="/login?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<%= translate(locale, "Login") %> <%= translate(locale, "Login") %>
</a> </a>
</div> </div>
<% end %> <% end %>
<% end %>
</div> </div>
</div> </div>
<%= content %> <%= content %>

@ -1,6 +1,6 @@
<% content_for "header" do %> <% content_for "header" do %>
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>"> <meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title><%= translate(locale, "Top") %> - Invidious</title> <title><% if config.default_home != "Top" %><%= translate(locale, "Top") %> - <% end %>Invidious</title>
<% end %> <% end %>
<%= rendered "components/feed_menu" %> <%= rendered "components/feed_menu" %>

@ -1,6 +1,6 @@
<% content_for "header" do %> <% content_for "header" do %>
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>"> <meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title><%= translate(locale, "Trending") %> - Invidious</title> <title><% if config.default_home != "Trending" %><%= translate(locale, "Trending") %> - <% end %>Invidious</title>
<% end %> <% end %>
<%= rendered "components/feed_menu" %> <%= rendered "components/feed_menu" %>

Loading…
Cancel
Save