diff options
| -rw-r--r-- | config/config.example.yml | 2 | ||||
| -rw-r--r-- | docker/Dockerfile | 2 | ||||
| -rw-r--r-- | locales/cs.json | 414 | ||||
| -rw-r--r-- | locales/nb-NO.json | 8 | ||||
| -rw-r--r-- | locales/si.json | 414 | ||||
| -rw-r--r-- | spec/helpers_spec.cr | 10 | ||||
| -rw-r--r-- | src/invidious.cr | 40 | ||||
| -rw-r--r-- | src/invidious/channels.cr | 43 | ||||
| -rw-r--r-- | src/invidious/comments.cr | 16 | ||||
| -rw-r--r-- | src/invidious/helpers/helpers.cr | 37 | ||||
| -rw-r--r-- | src/invidious/playlists.cr | 15 | ||||
| -rw-r--r-- | src/invidious/search.cr | 11 | ||||
| -rw-r--r-- | src/invidious/users.cr | 14 | ||||
| -rw-r--r-- | src/invidious/videos.cr | 2 |
14 files changed, 958 insertions, 70 deletions
diff --git a/config/config.example.yml b/config/config.example.yml index e83a7515..e8330705 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -6,6 +6,8 @@ db: host: localhost port: 5432 dbname: invidious +# alternatively, the database URL can be provided directly - if both are set then the latter takes precedence +# database_url: postgres://kemal:kemal@localhost:5432/invidious full_refresh: false https_only: false domain: diff --git a/docker/Dockerfile b/docker/Dockerfile index b88a76c0..f7d990d7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ FROM crystallang/crystal:0.36.1-alpine AS builder -RUN apk add --no-cache curl sqlite-static +RUN apk add --no-cache curl sqlite-static yaml-static WORKDIR /invidious COPY ./shard.yml ./shard.yml COPY ./shard.lock ./shard.lock diff --git a/locales/cs.json b/locales/cs.json new file mode 100644 index 00000000..ba62298c --- /dev/null +++ b/locales/cs.json @@ -0,0 +1,414 @@ +{ + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` odběratelů.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` odběratelů." + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videí.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` videí." + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "LIVE": "ŽIVĚ", + "Shared `x` ago": "", + "Unsubscribe": "Odhlásit odběr", + "Subscribe": "Odebírat", + "View channel on YouTube": "Otevřít kanál na YouTube", + "View playlist on YouTube": "", + "newest": "nejnovější", + "oldest": "nejstarší", + "popular": "populární", + "last": "poslední", + "Next page": "Další strana", + "Previous page": "Předchozí strana", + "Clear watch history?": "Smazat historii?", + "New password": "Nové heslo", + "New passwords must match": "Hesla se musí schodovat", + "Cannot change password for Google accounts": "Nelze změnit heslo pro účty Google", + "Authorize token?": "Autorizovat token?", + "Authorize token for `x`?": "", + "Yes": "Ano", + "No": "Ne", + "Import and Export Data": "Import a Export údajů", + "Import": "Inport", + "Import Invidious data": "Importovat údaje Invidious", + "Import YouTube subscriptions": "Importovat odběry z YouTube", + "Import FreeTube subscriptions (.db)": "Importovat odběry z FreeTube (.db)", + "Import NewPipe subscriptions (.json)": "Importovat odběry z NewPipe (.json)", + "Import NewPipe data (.zip)": "Importovat údeje z NewPipe (.zip)", + "Export": "Exportovat", + "Export subscriptions as OPML": "Exportovat odběry jako OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportovat údaje jako OPML (na NewPipe a FreeTube)", + "Export data as JSON": "Exportovat data jako JSON", + "Delete account?": "Smazat účet?", + "History": "Historie", + "An alternative front-end to YouTube": "Alternativní front-end pro YouTube", + "JavaScript license information": "Informace o licenci JavaScript", + "source": "zdrojový kód", + "Log in": "Přihlásit se", + "Log in/register": "Přihlásit se/vytvořit účet", + "Log in with Google": "Přihlásit se s Googlem", + "User ID": "Uživatelské IČ", + "Password": "Heslo", + "Time (h:mm:ss):": "Čas (h:mm:ss):", + "Text CAPTCHA": "Textové CAPTCHA", + "Image CAPTCHA": "Obrázkové CAPTCHA", + "Sign In": "Přihlásit se", + "Register": "Vytvořit účet", + "E-mail": "E-mail", + "Google verification code": "Verifikační číslo Google", + "Preferences": "Nastavení", + "Player preferences": "Nastavení přehravače", + "Always loop: ": "Vždy opakovat: ", + "Autoplay: ": "Automatické přehrávání: ", + "Play next by default: ": "", + "Autoplay next video: ": "", + "Listen by default: ": "", + "Proxy videos: ": "", + "Default speed: ": "", + "Preferred video quality: ": "", + "Player volume: ": "Hlasitost přehrávače: ", + "Default comments: ": "", + "youtube": "youtube", + "reddit": "reddit", + "Default captions: ": "", + "Fallback captions: ": "", + "Show related videos: ": "Zobrazit podobné videa: ", + "Show annotations by default: ": "", + "Visual preferences": "", + "Player style: ": "Styl přehrávače ", + "Dark mode: ": "Tmavý režim ", + "Theme: ": "", + "dark": "", + "light": "", + "Thin mode: ": "", + "Subscription preferences": "", + "Show annotations by default for subscribed channels: ": "", + "Redirect homepage to feed: ": "", + "Number of videos shown in feed: ": "", + "Sort videos by: ": "Roztřídit videa podle: ", + "published": "publikováno", + "published - reverse": "", + "alphabetically": "podle abecedy", + "alphabetically - reverse": "", + "channel name": "název kanálu", + "channel name - reverse": "", + "Only show latest video from channel: ": "Jenom zobrazit nejnovjejší video z kanálu: ", + "Only show latest unwatched video from channel: ": "", + "Only show unwatched: ": "", + "Only show notifications (if there are any): ": "", + "Enable web notifications": "Povolit webové upozornění", + "`x` uploaded a video": "`x` nahrál(a) video", + "`x` is live": "`x` je živě", + "Data preferences": "", + "Clear watch history": "Smazat historii", + "Import/export data": "", + "Change password": "Změnit heslo", + "Manage subscriptions": "", + "Manage tokens": "", + "Watch history": "", + "Delete account": "", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled: ": "", + "CAPTCHA enabled: ": "", + "Login enabled: ": "", + "Registration enabled: ": "", + "Report statistics: ": "", + "Save preferences": "", + "Subscription manager": "", + "Token manager": "", + "Token": "", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Import/export": "", + "unsubscribe": "", + "revoke": "", + "Subscriptions": "", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "search": "", + "Log out": "", + "Released under the AGPLv3 by Omar Roth.": "", + "Source available here.": "", + "View JavaScript license information.": "", + "View privacy policy.": "", + "Trending": "", + "Public": "", + "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", + "Watch on YouTube": "", + "Hide annotations": "", + "Show annotations": "", + "Genre: ": "", + "License: ": "", + "Family friendly? ": "", + "Wilson score: ": "", + "Engagement: ": "", + "Whitelisted regions: ": "", + "Blacklisted regions: ": "", + "Shared `x`": "", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Premieres in `x`": "", + "Premieres `x`": "", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", + "View YouTube comments": "", + "View more comments on Reddit": "", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "View Reddit comments": "", + "Hide replies": "", + "Show replies": "", + "Incorrect password": "", + "Quota exceeded, try again in a few hours": "", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", + "Invalid TFA code": "", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "", + "Wrong answer": "", + "Erroneous CAPTCHA": "", + "CAPTCHA is a required field": "", + "User ID is a required field": "", + "Password is a required field": "", + "Wrong username or password": "", + "Please sign in using 'Log in with Google'": "", + "Password cannot be empty": "", + "Password cannot be longer than 55 characters": "", + "Please log in": "", + "Invidious Private Feed for `x`": "", + "channel:`x`": "", + "Deleted or invalid channel": "", + "This channel does not exist.": "", + "Could not get channel info.": "", + "Could not fetch comments": "", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` ago": "", + "Load more": "", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Could not create mix.": "", + "Empty playlist": "", + "Not a playlist.": "", + "Playlist does not exist.": "", + "Could not pull trending pages.": "", + "Hidden field \"challenge\" is a required field": "", + "Hidden field \"token\" is a required field": "", + "Erroneous challenge": "", + "Erroneous token": "", + "No such user": "", + "Token is expired, please try again": "", + "English": "", + "English (auto-generated)": "", + "Afrikaans": "", + "Albanian": "", + "Amharic": "", + "Arabic": "", + "Armenian": "", + "Azerbaijani": "", + "Bangla": "", + "Basque": "", + "Belarusian": "", + "Bosnian": "", + "Bulgarian": "", + "Burmese": "", + "Catalan": "", + "Cebuano": "", + "Chinese (Simplified)": "", + "Chinese (Traditional)": "", + "Corsican": "", + "Croatian": "", + "Czech": "", + "Danish": "", + "Dutch": "", + "Esperanto": "", + "Estonian": "", + "Filipino": "", + "Finnish": "", + "French": "", + "Galician": "", + "Georgian": "", + "German": "", + "Greek": "", + "Gujarati": "", + "Haitian Creole": "", + "Hausa": "", + "Hawaiian": "", + "Hebrew": "", + "Hindi": "", + "Hmong": "", + "Hungarian": "", + "Icelandic": "", + "Igbo": "", + "Indonesian": "", + "Irish": "", + "Italian": "", + "Japanese": "", + "Javanese": "", + "Kannada": "", + "Kazakh": "", + "Khmer": "", + "Korean": "", + "Kurdish": "", + "Kyrgyz": "", + "Lao": "", + "Latin": "", + "Latvian": "", + "Lithuanian": "", + "Luxembourgish": "", + "Macedonian": "", + "Malagasy": "", + "Malay": "", + "Malayalam": "", + "Maltese": "", + "Maori": "", + "Marathi": "", + "Mongolian": "", + "Nepali": "", + "Norwegian Bokmål": "", + "Nyanja": "", + "Pashto": "", + "Persian": "", + "Polish": "", + "Portuguese": "", + "Punjabi": "", + "Romanian": "", + "Russian": "", + "Samoan": "", + "Scottish Gaelic": "", + "Serbian": "", + "Shona": "", + "Sindhi": "", + "Sinhala": "", + "Slovak": "", + "Slovenian": "", + "Somali": "", + "Southern Sotho": "", + "Spanish": "", + "Spanish (Latin America)": "", + "Sundanese": "", + "Swahili": "", + "Swedish": "", + "Tajik": "", + "Tamil": "", + "Telugu": "", + "Thai": "", + "Turkish": "", + "Ukrainian": "", + "Urdu": "", + "Uzbek": "", + "Vietnamese": "", + "Welsh": "", + "Western Frisian": "", + "Xhosa": "", + "Yiddish": "", + "Yoruba": "", + "Zulu": "", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Fallback comments: ": "", + "Popular": "", + "Top": "", + "About": "Informace", + "Rating: ": "Hodnocení: ", + "Language: ": "Jazyk: ", + "View as playlist": "", + "Default": "", + "Music": "Hudba", + "Gaming": "", + "News": "Zprávy", + "Movies": "", + "Download": "Stáhnout", + "Download as: ": "Stáhnout jako: ", + "%A %B %-d, %Y": "", + "(edited)": "(upraveno)", + "YouTube comment permalink": "", + "permalink": "", + "`x` marked it with a ❤": "`x` to označil(a) se ❤", + "Audio mode": "Audiový režim", + "Video mode": "Videový režim", + "Videos": "Videa", + "Playlists": "", + "Community": "Komunita", + "relevance": "", + "rating": "hodnocení", + "date": "datum", + "views": "zhlédnutí", + "content_type": "", + "duration": "délka", + "features": "", + "sort": "", + "hour": "hodina", + "today": "dnes", + "week": "týden", + "month": "měsíc", + "year": "rok", + "video": "video", + "channel": "kanál", + "playlist": "", + "movie": "", + "show": "zobrazit", + "hd": "HD", + "subtitles": "titulky", + "creative_commons": "", + "3d": "3D", + "live": "živě", + "4k": "4k", + "location": "umístění", + "hdr": "HDR", + "filter": "filtr", + "Current version: ": "" +} diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 6bf5107b..355a1c33 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -77,8 +77,8 @@ "Thin mode: ": "Tynt modus: ", "Subscription preferences": "Abonnementsinnstillinger", "Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ", - "Redirect homepage to feed: ": "Videresend hjemmeside til flyt: ", - "Number of videos shown in feed: ": "Antall videoer å vise i flyt: ", + "Redirect homepage to feed: ": "Videresend hjemmeside til kilde: ", + "Number of videos shown in feed: ": "Antall videoer å vise i kilde: ", "Sort videos by: ": "Sorter videoer etter: ", "published": "publisert", "published - reverse": "publisert - motsatt", @@ -103,7 +103,7 @@ "Delete account": "Slett konto", "Administrator preferences": "Administratorinnstillinger", "Default homepage: ": "Forvalgt hjemmeside: ", - "Feed menu: ": "Flyt-meny: ", + "Feed menu: ": "Kilde-meny: ", "Top enabled: ": "Topp påskrudd? ", "CAPTCHA enabled: ": "CAPTCHA påskrudd? ", "Login enabled: ": "Innlogging påskrudd? ", @@ -174,7 +174,7 @@ "Password cannot be empty": "Passordet kan ikke være tomt", "Password cannot be longer than 55 characters": "Passordet kan ikke være lengre enn 55 tegn", "Please log in": "Logg inn", - "Invidious Private Feed for `x`": "Invidious personlige flyt for `x`", + "Invidious Private Feed for `x`": "Invidious personlig kilde for `x`", "channel:`x`": "kanal `x`", "Deleted or invalid channel": "Slettet eller ugyldig kanal", "This channel does not exist.": "Denne kanalen finnes ikke.", diff --git a/locales/si.json b/locales/si.json new file mode 100644 index 00000000..0dabe03a --- /dev/null +++ b/locales/si.json @@ -0,0 +1,414 @@ +{ + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "LIVE": "සජීව", + "Shared `x` ago": "", + "Unsubscribe": "", + "Subscribe": "", + "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": "", + "Cannot change password for Google accounts": "", + "Authorize token?": "", + "Authorize token for `x`?": "", + "Yes": "", + "No": "", + "Import and Export Data": "", + "Import": "", + "Import Invidious data": "", + "Import YouTube subscriptions": "", + "Import FreeTube subscriptions (.db)": "", + "Import NewPipe subscriptions (.json)": "", + "Import NewPipe data (.zip)": "", + "Export": "", + "Export subscriptions as OPML": "", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "", + "Export data as JSON": "", + "Delete account?": "", + "History": "", + "An alternative front-end to YouTube": "", + "JavaScript license information": "", + "source": "", + "Log in": "", + "Log in/register": "", + "Log in with Google": "", + "User ID": "", + "Password": "", + "Time (h:mm:ss):": "", + "Text CAPTCHA": "", + "Image CAPTCHA": "", + "Sign In": "", + "Register": "", + "E-mail": "", + "Google verification code": "", + "Preferences": "", + "Player preferences": "", + "Always loop: ": "", + "Autoplay: ": "", + "Play next by default: ": "", + "Autoplay next video: ": "", + "Listen by default: ": "", + "Proxy videos: ": "", + "Default speed: ": "", + "Preferred video quality: ": "", + "Player volume: ": "", + "Default comments: ": "", + "youtube": "", + "reddit": "", + "Default captions: ": "", + "Fallback captions: ": "", + "Show related videos: ": "", + "Show annotations by default: ": "", + "Visual preferences": "", + "Player style: ": "", + "Dark mode: ": "", + "Theme: ": "", + "dark": "", + "light": "", + "Thin mode: ": "", + "Subscription preferences": "", + "Show annotations by default for subscribed channels: ": "", + "Redirect homepage to feed: ": "", + "Number of videos shown in feed: ": "", + "Sort videos by: ": "", + "published": "", + "published - reverse": "", + "alphabetically": "", + "alphabetically - reverse": "", + "channel name": "", + "channel name - reverse": "", + "Only show latest video from channel: ": "", + "Only show latest unwatched video from channel: ": "", + "Only show unwatched: ": "", + "Only show notifications (if there are any): ": "", + "Enable web notifications": "", + "`x` uploaded a video": "", + "`x` is live": "", + "Data preferences": "", + "Clear watch history": "", + "Import/export data": "", + "Change password": "", + "Manage subscriptions": "", + "Manage tokens": "", + "Watch history": "", + "Delete account": "", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled: ": "", + "CAPTCHA enabled: ": "", + "Login enabled: ": "", + "Registration enabled: ": "", + "Report statistics: ": "", + "Save preferences": "", + "Subscription manager": "", + "Token manager": "", + "Token": "", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Import/export": "", + "unsubscribe": "", + "revoke": "", + "Subscriptions": "", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "search": "", + "Log out": "", + "Released under the AGPLv3 by Omar Roth.": "", + "Source available here.": "", + "View JavaScript license information.": "", + "View privacy policy.": "", + "Trending": "", + "Public": "", + "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", + "Watch on YouTube": "", + "Hide annotations": "", + "Show annotations": "", + "Genre: ": "", + "License: ": "", + "Family friendly? ": "", + "Wilson score: ": "", + "Engagement: ": "", + "Whitelisted regions: ": "", + "Blacklisted regions: ": "", + "Shared `x`": "", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Premieres in `x`": "", + "Premieres `x`": "", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", + "View YouTube comments": "", + "View more comments on Reddit": "", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "View Reddit comments": "", + "Hide replies": "", + "Show replies": "", + "Incorrect password": "", + "Quota exceeded, try again in a few hours": "", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", + "Invalid TFA code": "", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "", + "Wrong answer": "", + "Erroneous CAPTCHA": "", + "CAPTCHA is a required field": "", + "User ID is a required field": "", + "Password is a required field": "", + "Wrong username or password": "", + "Please sign in using 'Log in with Google'": "", + "Password cannot be empty": "", + "Password cannot be longer than 55 characters": "", + "Please log in": "", + "Invidious Private Feed for `x`": "", + "channel:`x`": "", + "Deleted or invalid channel": "", + "This channel does not exist.": "", + "Could not get channel info.": "", + "Could not fetch comments": "", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` ago": "", + "Load more": "", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Could not create mix.": "", + "Empty playlist": "", + "Not a playlist.": "", + "Playlist does not exist.": "", + "Could not pull trending pages.": "", + "Hidden field \"challenge\" is a required field": "", + "Hidden field \"token\" is a required field": "", + "Erroneous challenge": "", + "Erroneous token": "", + "No such user": "", + "Token is expired, please try again": "", + "English": "", + "English (auto-generated)": "", + "Afrikaans": "", + "Albanian": "", + "Amharic": "", + "Arabic": "", + "Armenian": "", + "Azerbaijani": "", + "Bangla": "", + "Basque": "", + "Belarusian": "", + "Bosnian": "", + "Bulgarian": "", + "Burmese": "", + "Catalan": "", + "Cebuano": "", + "Chinese (Simplified)": "", + "Chinese (Traditional)": "", + "Corsican": "", + "Croatian": "", + "Czech": "", + "Danish": "", + "Dutch": "", + "Esperanto": "", + "Estonian": "", + "Filipino": "", + "Finnish": "", + "French": "", + "Galician": "", + "Georgian": "", + "German": "", + "Greek": "", + "Gujarati": "", + "Haitian Creole": "", + "Hausa": "", + "Hawaiian": "", + "Hebrew": "", + "Hindi": "", + "Hmong": "", + "Hungarian": "", + "Icelandic": "", + "Igbo": "", + "Indonesian": "", + "Irish": "", + "Italian": "", + "Japanese": "", + "Javanese": "", + "Kannada": "", + "Kazakh": "", + "Khmer": "", + "Korean": "", + "Kurdish": "", + "Kyrgyz": "", + "Lao": "", + "Latin": "", + "Latvian": "", + "Lithuanian": "", + "Luxembourgish": "", + "Macedonian": "", + "Malagasy": "", + "Malay": "", + "Malayalam": "", + "Maltese": "", + "Maori": "", + "Marathi": "", + "Mongolian": "", + "Nepali": "", + "Norwegian Bokmål": "", + "Nyanja": "", + "Pashto": "", + "Persian": "", + "Polish": "", + "Portuguese": "", + "Punjabi": "", + "Romanian": "", + "Russian": "", + "Samoan": "", + "Scottish Gaelic": "", + "Serbian": "", + "Shona": "", + "Sindhi": "", + "Sinhala": "", + "Slovak": "", + "Slovenian": "", + "Somali": "", + "Southern Sotho": "", + "Spanish": "", + "Spanish (Latin America)": "", + "Sundanese": "", + "Swahili": "", + "Swedish": "", + "Tajik": "", + "Tamil": "", + "Telugu": "", + "Thai": "", + "Turkish": "", + "Ukrainian": "", + "Urdu": "", + "Uzbek": "", + "Vietnamese": "", + "Welsh": "", + "Western Frisian": "", + "Xhosa": "", + "Yiddish": "", + "Yoruba": "", + "Zulu": "", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Fallback comments: ": "", + "Popular": "", + "Top": "", + "About": "", + "Rating: ": "", + "Language: ": "", + "View as playlist": "", + "Default": "", + "Music": "", + "Gaming": "", + "News": "", + "Movies": "", + "Download": "", + "Download as: ": "", + "%A %B %-d, %Y": "", + "(edited)": "", + "YouTube comment permalink": "", + "permalink": "", + "`x` marked it with a ❤": "", + "Audio mode": "", + "Video mode": "", + "Videos": "", + "Playlists": "", + "Community": "", + "relevance": "", + "rating": "", + "date": "", + "views": "", + "content_type": "", + "duration": "", + "features": "", + "sort": "", + "hour": "", + "today": "", + "week": "", + "month": "", + "year": "", + "video": "", + "channel": "", + "playlist": "", + "movie": "", + "show": "", + "hd": "", + "subtitles": "", + "creative_commons": "", + "3d": "", + "live": "", + "4k": "", + "location": "", + "hdr": "", + "filter": "", + "Current version: ": "" +} diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index d297759e..073d2700 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -65,15 +65,15 @@ describe "Helper" do describe "#produce_search_params" do it "correctly produces token for searching with specified filters" do - produce_search_params.should eq("CAASAhAB") + produce_search_params.should eq("CAASAhABSAA%3D") - produce_search_params(sort: "upload_date", content_type: "video").should eq("CAISAhAB") + produce_search_params(sort: "upload_date", content_type: "video").should eq("CAISAhABSAA%3D") - produce_search_params(content_type: "playlist").should eq("CAASAhAD") + produce_search_params(content_type: "playlist").should eq("CAASAhADSAA%3D") - produce_search_params(sort: "date", content_type: "video", features: ["hd", "cc", "purchased", "hdr"]).should eq("CAISCxABIAEwAUgByAEB") + produce_search_params(sort: "date", content_type: "video", features: ["hd", "cc", "purchased", "hdr"]).should eq("CAISCxABIAEwAUgByAEBSAA%3D") - produce_search_params(content_type: "channel").should eq("CAASAhAC") + produce_search_params(content_type: "channel").should eq("CAASAhACSAA%3D") end end diff --git a/src/invidious.cr b/src/invidious.cr index 563a3768..89d99ecc 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -33,16 +33,7 @@ require "./invidious/jobs/**" CONFIG = Config.load HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) -PG_URL = URI.new( - scheme: "postgres", - user: CONFIG.db.user, - password: CONFIG.db.password, - host: CONFIG.db.host, - port: CONFIG.db.port, - path: CONFIG.db.dbname, -) - -PG_DB = DB.open PG_URL +PG_DB = DB.open CONFIG.database_url ARCHIVE_URL = URI.parse("https://archive.org") LOGIN_URL = URI.parse("https://accounts.google.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") @@ -195,7 +186,7 @@ if CONFIG.captcha_key end connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32) -Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, PG_URL) +Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url) Invidious::Jobs.start_all @@ -298,6 +289,7 @@ before_all do |env| preferences.dark_mode = dark_mode preferences.thin_mode = thin_mode preferences.locale = locale + env.set "preferences", preferences current_page = env.request.path if env.request.query @@ -760,10 +752,16 @@ post "/data_control" do |env| end end when "import_youtube" - subscriptions = JSON.parse(body) - - user.subscriptions += subscriptions.as_a.compact_map do |entry| - entry["snippet"]["resourceId"]["channelId"].as_s + if body[0..4] == "<opml" + subscriptions = XML.parse(body) + user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| + channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] + end + else + subscriptions = JSON.parse(body) + user.subscriptions += subscriptions.as_a.compact_map do |entry| + entry["snippet"]["resourceId"]["channelId"].as_s + end end user.subscriptions.uniq! @@ -1557,12 +1555,12 @@ post "/feed/webhook/:token" do |env| 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, \ + 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), \ + 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 end end @@ -2560,12 +2558,12 @@ get "/api/v1/search" do |env| content_type ||= "video" begin - search_params = produce_search_params(sort_by, date, content_type, duration, features) + search_params = produce_search_params(page, sort_by, date, content_type, duration, features) rescue ex next error_json(400, ex) end - count, search_results = search(query, page, search_params, region).as(Tuple) + count, search_results = search(query, search_params, region).as(Tuple) JSON.build do |json| json.array do search_results.each do |item| diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 9986fe1b..b9808d98 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -233,7 +233,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) videos = [] of SearchVideo begin - initial_data = JSON.parse(response.body).as_a.find &.["response"]? + initial_data = JSON.parse(response.body) raise InfoException.new("Could not extract channel JSON") if !initial_data LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel videos page initial_data") @@ -305,7 +305,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) loop do response = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - initial_data = JSON.parse(response.body).as_a.find &.["response"]? + initial_data = JSON.parse(response.body) raise InfoException.new("Could not extract channel JSON") if !initial_data videos = extract_videos(initial_data.as_h, author, ucid) @@ -388,7 +388,7 @@ def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by) return items, continuation end -def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) +def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) object = { "80226972:embedded" => { "2:string" => ucid, @@ -444,6 +444,11 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = " .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } + return continuation +end + +def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) + continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" end @@ -932,9 +937,27 @@ def get_about_info(ucid, locale) }) end -def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest") - url = produce_channel_videos_url(ucid, page, auto_generated: auto_generated, sort_by: sort_by, v2: true) - return YT_POOL.client &.get(url) +def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest", youtubei_browse = true) + if youtubei_browse + continuation = produce_channel_videos_continuation(ucid, page, auto_generated: auto_generated, sort_by: sort_by, v2: true) + data = { + "context": { + "client": { + "clientName": "WEB", + "clientVersion": "2.20201021.03.00", + }, + }, + "continuation": continuation, + }.to_json + return YT_POOL.client &.post( + "/youtubei/v1/browse?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + headers: HTTP::Headers{"content-type" => "application/json"}, + body: data + ) + else + url = produce_channel_videos_url(ucid, page, auto_generated: auto_generated, sort_by: sort_by, v2: true) + return YT_POOL.client &.get(url) + end end def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") @@ -942,7 +965,7 @@ def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") 2.times do |i| response = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) - initial_data = JSON.parse(response.body).as_a.find &.["response"]? + initial_data = JSON.parse(response.body) break if !initial_data videos.concat extract_videos(initial_data.as_h, author, ucid) end @@ -951,10 +974,10 @@ def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") end def get_latest_videos(ucid) - response = get_channel_videos_response(ucid, 1) - initial_data = JSON.parse(response.body).as_a.find &.["response"]? + response = get_channel_videos_response(ucid) + initial_data = JSON.parse(response.body) return [] of SearchVideo if !initial_data - author = initial_data["response"]?.try &.["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s + author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s items = extract_videos(initial_data.as_h, author, ucid) return items diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 13ebbd73..20e64a08 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -488,8 +488,12 @@ def replace_links(html) length_seconds = decode_time(anchor.content) end - anchor["href"] = "javascript:void(0)" - anchor["onclick"] = "player.currentTime(#{length_seconds})" + if length_seconds > 0 + anchor["href"] = "javascript:void(0)" + anchor["onclick"] = "player.currentTime(#{length_seconds})" + else + anchor["href"] = url.request_target + end end end @@ -528,11 +532,7 @@ end def content_to_comment_html(content) comment_html = content.map do |run| - text = HTML.escape(run["text"].as_s) - - if run["text"] == "\n" - text = "<br>" - end + text = HTML.escape(run["text"].as_s).gsub("\n", "<br>") if run["bold"]? text = "<b>#{text}</b>" @@ -559,7 +559,7 @@ def content_to_comment_html(content) length_seconds = watch_endpoint["startTimeSeconds"]? video_id = watch_endpoint["videoId"].as_s - if length_seconds + if length_seconds && length_seconds.as_i > 0 text = %(<a href="javascript:void(0)" data-onclick="jump_to_time" data-jump-time="#{length_seconds}">#{text}</a>) else text = %(<a href="/watch?v=#{video_id}">#{text}</a>) diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 944d869b..5d127e1a 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -64,11 +64,14 @@ end class Config include YAML::Serializable - property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) - property feed_threads : Int32 = 1 # Number of threads to use for updating feeds - property output : String = "STDOUT" # Log file path or STDOUT - property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr - property db : DBConfig # Database configuration + property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) + property feed_threads : Int32 = 1 # Number of threads to use for updating feeds + property output : String = "STDOUT" # Log file path or STDOUT + property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr + property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) + + @[YAML::Field(converter: Preferences::URIConverter)] + property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax property decrypt_polling : Bool = true # Use polling to keep decryption function up to date property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// @@ -170,6 +173,23 @@ class Config end {% end %} + # Build database_url from db.* if it's not set directly + if config.database_url.to_s.empty? + if db = config.db + config.database_url = URI.new( + scheme: "postgres", + user: db.user, + password: db.password, + host: db.host, + port: db.port, + path: db.dbname, + ) + else + puts "Config : Either database_url or db.* is required" + exit(1) + end + end + return config end end @@ -363,10 +383,9 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri items = [] of SearchItem channel_v2_response = initial_data - .try &.["response"]? - .try &.["continuationContents"]? - .try &.["gridContinuation"]? - .try &.["items"]? + .try &.["continuationContents"]? + .try &.["gridContinuation"]? + .try &.["items"]? if channel_v2_response channel_v2_response.try &.as_a.each { |item| diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 25797a36..0251a69c 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -101,6 +101,7 @@ struct Playlist property author_thumbnail : String property ucid : String property description : String + property description_html : String property video_count : Int32 property views : Int64 property updated : Time @@ -163,10 +164,6 @@ struct Playlist def privacy PlaylistPrivacy::Public end - - def description_html - HTML.escape(self.description).gsub("\n", "<br>") - end end enum PlaylistPrivacy @@ -375,7 +372,12 @@ def fetch_playlist(plid, locale) title = playlist_info["title"]?.try &.["runs"][0]?.try &.["text"]?.try &.as_s || "" desc_item = playlist_info["description"]? - description = desc_item.try &.["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || "" + + description_txt = desc_item.try &.["runs"]?.try &.as_a + .map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || "" + + description_html = desc_item.try &.["runs"]?.try &.as_a + .try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>" thumbnail = playlist_info["thumbnailRenderer"]?.try &.["playlistVideoThumbnailRenderer"]? .try &.["thumbnail"]["thumbnails"][0]["url"]?.try &.as_s @@ -415,7 +417,8 @@ def fetch_playlist(plid, locale) author: author, author_thumbnail: author_thumbnail, ucid: ucid, - description: description, + description: description_txt, + description_html: description_html, video_count: video_count, views: views, updated: updated, diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 1c4bc74e..cf8fd790 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -249,10 +249,10 @@ def channel_search(query, page, channel) return items.size, items end -def search(query, page = 1, search_params = produce_search_params(content_type: "all"), region = nil) +def search(query, search_params = produce_search_params(content_type: "all"), region = nil) return 0, [] of SearchItem if query.empty? - body = YT_POOL.client(region, &.get("/results?q=#{URI.encode_www_form(query)}&page=#{page}&sp=#{search_params}&hl=en").body) + body = YT_POOL.client(region, &.get("/results?search_query=#{URI.encode_www_form(query)}&sp=#{search_params}&hl=en").body) return 0, [] of SearchItem if body.empty? initial_data = extract_initial_data(body) @@ -263,11 +263,12 @@ def search(query, page = 1, search_params = produce_search_params(content_type: return items.size, items end -def produce_search_params(sort : String = "relevance", date : String = "", content_type : String = "", +def produce_search_params(page = 1, sort : String = "relevance", date : String = "", content_type : String = "", duration : String = "", features : Array(String) = [] of String) object = { "1:varint" => 0_i64, "2:embedded" => {} of String => Int64, + "9:varint" => ((page - 1) * 20).to_i64, } case sort @@ -439,10 +440,10 @@ def process_search_query(query, page, user, region) count = 0 end else - search_params = produce_search_params(sort: sort, date: date, content_type: content_type, + search_params = produce_search_params(page: page, sort: sort, date: date, content_type: content_type, duration: duration, features: features) - count, items = search(search_query, page, search_params, region).as(Tuple) + count, items = search(search_query, search_params, region).as(Tuple) end {search_query, count, items, operators} diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 153e3b6a..7a948b76 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -173,6 +173,20 @@ struct Preferences end end + module URIConverter + def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder) + yaml.scalar value.normalize! + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI + if node.is_a?(YAML::Nodes::Scalar) + URI.parse node.value + else + node.raise "Expected scalar, not #{node.class}" + end + end + end + module ProcessString def self.to_json(value : String, json : JSON::Builder) json.string value diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 95d9a80c..e6d4c764 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -764,7 +764,7 @@ struct Video end def engagement : Float64 - ((likes + dislikes) / views).round(4) + (((likes + dislikes) / views) * 100).round(4) end def reason : String? |
