Compare commits

...

178 Commits

Author SHA1 Message Date
Aleksandr Kadykov
6bcc2177b0
Merge branch 'iv-org:master' into newpipe-history-import 2024-09-07 11:20:49 +00:00
Samantaz Fox
4782a67038
Release v2.20240825.2 2024-08-26 22:52:50 +02:00
Samantaz Fox
5baaedfa39
CI: Fix docker container tags (#4883)
Closes issue 4880
2024-08-26 22:48:14 +02:00
Samantaz Fox
4f066e880c
CI: Fix docker container tags 2024-08-26 21:55:43 +02:00
Samantaz Fox
3e17d04875
Release v2.20240825.1 2024-08-25 22:30:46 +02:00
syeopite
cec905e95e
Allow manual trigger of release-container build (#4877) 2024-08-25 19:55:52 +00:00
Samantaz Fox
80958aa0d8
Release v2.20240825 2024-08-25 21:25:48 +02:00
Samantaz Fox
c5fdd9ea65
HTML: Sort playlists alphabetically in watch page drop down (#4853)
Closes issue 4708
2024-08-24 20:50:46 +02:00
Samantaz Fox
2876ee0f9f
HTML: Fix XSS vulnerability in description/comments (#4852)
Before this PR, the comment/description content was not HTML escaped when 'parse_description()'
was called with a JSON object lacking the "commandRuns" entry.

Closes issue 4727
2024-08-24 20:50:05 +02:00
Samantaz Fox
0699e5fc27
YtAPI: Bump client versions (#4849)
This might help reducing the amount of playback errors.

No related issue
2024-08-24 20:47:01 +02:00
Samantaz Fox
15669acccf
SigHelper: Fix inverted time comparison in 'check_update' (#4845)
Closes issue 4840
2024-08-24 20:44:52 +02:00
Samantaz Fox
cd2daf4adb
Storyboards: Various fixes and code cleaning (#4153)
Closes issue 3441
2024-08-24 20:43:05 +02:00
syeopite
ccecc6d318
Fix lint errors introduced in #4146 and #4295 (#4876)
* Ameba: Fix Naming/VariableNames

Introduced in #4295

* Ameba: Fix Naming/PredicateName

Introduced in #4146
2024-08-24 18:11:11 +00:00
Samantaz Fox
3c6a662aaf
Search: Add support for Youtube URLs (#4146)
Closes issue 3300
2024-08-24 19:44:59 +02:00
Samantaz Fox
9e55799269
Channel: Render age restricted channels (#4295)
This PR:
 * gets thumbnail and channel name from the initial request
 * gets videos, shorts and streams via autogenerated channel playlists

Test Url: /channel/UCbfnHqxXs_K3kvaH-WlNlig

Closes issue 3513
2024-08-24 19:43:59 +02:00
Samantaz Fox
da70c9b7b0
Ameba: Miscellaneous fixes (#4807)
End of a series of PRs meant to improve code quality.

Related to issue 2231
2024-08-24 19:42:10 +02:00
Samantaz Fox
828da3c6ce
API: Proxy formatStreams URLs too (#4859)
The /api/v1/videos endpoint does not proxy the formatStreams URLs when
'local=true' is passed, whereas the adaptiveFormats URLs are correctly proxied.

The Web UI does proxy when clicking "Download" with 'fmt=18' for example, so
this is probably an oversight. This PR aims to fix that.

No related issue
2024-08-24 19:39:36 +02:00
Samantaz Fox
febf18cbf7
UI: Add search button to search bar (#4706)
Closes issue 529
2024-08-24 19:38:48 +02:00
Samantaz Fox
21ab5dc668
Storyboard: Revert cue timing "fix" 2024-08-22 00:29:15 +02:00
Samantaz Fox
b200ebfb6b
CSS: Remove extra space in default.css 2024-08-21 20:23:45 +00:00
syeopite
ecbea0b67b
Ameba: Fix Lint/ShadowingOuterLocalVar 2024-08-21 02:43:26 -07:00
syeopite
d1cd790388
Ameba: Fix Lint/RedundantStringCoercion 2024-08-21 02:43:26 -07:00
syeopite
f66068976e
Ameba: Fix Naming/PredicateName 2024-08-21 02:43:08 -07:00
syeopite
22b35c453e
Ameba: Fix Style/WhileTrue 2024-08-21 02:43:08 -07:00
Colin Leroy-Mira
c606465708 Proxify formatStreams URLs too 2024-08-19 09:37:24 +02:00
Samantaz Fox
85deea5aca
Search: Change smart search inhibitor to a backslash 2024-08-17 19:22:40 +02:00
Samantaz Fox
78c5ba93c7
Misc: Clean some code in UrlSanitizer 2024-08-17 19:22:40 +02:00
Samantaz Fox
31a80420ec
Search: Add URL search inhibition logic 2024-08-17 19:22:40 +02:00
Samantaz Fox
4c0b5c314d
Search: Add support for youtu.be and youtube.com URLs 2024-08-17 19:22:40 +02:00
Samantaz Fox
eb0f651812
Add a youtube URL sanitizer 2024-08-17 19:22:40 +02:00
Samantaz Fox
764965c441
Storyboards: Fix lint error 2024-08-17 12:20:53 +02:00
Samantaz Fox
b795bdf2a4
HTML: Sort playlists alphabetically in watch page drop down 2024-08-16 12:10:22 +02:00
Samantaz Fox
5b05f3bd14
Storyboards: Workarounds for videojs-vtt-thumbnails
The workarounds are as follow:
  * Unescape HTML entities
  * Always use 0:00:00.000 for cue start/end
2024-08-16 11:36:01 +02:00
Samantaz Fox
a335bc0814
Storyboards: Fix some small logic mistakes 2024-08-16 10:05:49 +02:00
Samantaz Fox
7b50388eaf
Storyboards: Fix broken first storyboard 2024-08-16 10:05:48 +02:00
Samantaz Fox
da3d58f03c
Storyboards: Cleanup and document code 2024-08-16 10:05:47 +02:00
Samantaz Fox
8327862697
Storyboards: Use replace the NamedTuple by a struct 2024-08-16 10:04:40 +02:00
Samantaz Fox
6878822c4d
Storyboards: Move parser to its own file 2024-08-16 10:02:52 +02:00
Samantaz Fox
0b28054f8a
videos: Fix XSS vulnerability in description/comments
Patch provided by e-mail, thanks to an anonymous user whose cats are named
Yoshi and Yasuo.

Comment is mine
2024-08-15 18:26:17 +02:00
Samantaz Fox
cc33d3f074
YtAPI: Also update User-Agent string 2024-08-15 18:14:29 +02:00
Samantaz Fox
acbb625866
YtAPI: Update clients to latest version 2024-08-15 12:57:36 +02:00
Samantaz Fox
466bfbb306
SigHelper: Fix inverted time comparison in 'check_update' 2024-08-14 21:43:37 +02:00
ChunkyProgrammer
e31053e812 Use dig to get properties
Co-authored-by: Samantaz Fox <coding@samantaz.fr>
2024-08-13 15:09:16 -04:00
ChunkyProgrammer
96ade642fa Channel: Render age restricted channels 2024-08-13 15:09:16 -04:00
Samantaz Fox
e319c35f09
Videos: use intermediary variable when using CONFIG.po_token 2024-08-13 20:56:09 +02:00
Samantaz Fox
2d18ff1f80
Add ability to set po_token and visitordata ID (#4789)
This PR adds two new config option, to pass a PO token (config 'po_token') and
a visitor ID (config 'visitor_data') to Youtube. These two strings are required
to play videos using the WEB client.

Warning: These strings gives much more identifiable information to Google!

If the po_token setting is filled in, then the WEB client is used. If not, the
Android client is used. TvHtml5ScreenEmbed will still be used as a fallback.

Script for generating po_token and visitor_data:
https://github.com/iv-org/youtube-trusted-session-generator

Helps with issue 4734
2024-08-13 20:35:43 +02:00
Samantaz Fox
2d7869b48b
Add support for an external signature server (#4772)
This PR adds support for inv_sig_helper, which offloads the player fetching,
function extraction and signature parsing, which in turn allows to use the
web client to watch videos.

When the new config option "signature_server" is not set, the logic for the
external signature server is not enabled and invidious behaves like before.

This PR also updates the crystal overrides because the stdlib changed quite
a while ago (See issue 11049 at crystal-lang/crystal) and those were required
to properly use TCP/unix sockets.

Closes issue 4649
2024-08-13 20:26:59 +02:00
Samantaz Fox
88b9f17388
Ameba: Fix Naming/VariableNames (#4790)
Related to issue 2231
2024-08-13 20:26:15 +02:00
Samantaz Fox
7a7d1137d6
Translations update from Hosted Weblate (#4659) 2024-08-13 20:25:52 +02:00
Hosted Weblate
3add83c49e
Update Norwegian Bokmål translation
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Petter Reinholdtsen <pere-weblate@hungry.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
a8825a27d4
Update Serbian (cyrillic) translation
Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Bulgarian translation

Update German translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Least Significant Bite <leastsignificantbite@proton.me>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Unacceptium <unacceptium@proton.me>
Co-authored-by: hiatsu0 <hietsu@gmail.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
bedcf97fbf
Update Korean translation
Co-authored-by: Conflict3618 <won_ennui_0i@icloud.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
2024-08-13 19:51:36 +02:00
Hosted Weblate
89c17f2127
Update Serbian (cyrillic) translation
Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Bulgarian translation

Update German translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Least Significant Bite <leastsignificantbite@proton.me>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Unacceptium <unacceptium@proton.me>
Co-authored-by: hiatsu0 <hietsu@gmail.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
905fed66d1
Update Finnish translation
Update Finnish translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Bulgarian translation

Update German translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jiri Grönroos <jiri.gronroos@iki.fi>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Least Significant Bite <leastsignificantbite@proton.me>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Tuomas Hietala <tuomas.hietala@iki.fi>
Co-authored-by: Unacceptium <unacceptium@proton.me>
Co-authored-by: hiatsu0 <hietsu@gmail.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
f837d99eab
Update Persian translation
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Wireless Acquired <clcsdtxsl@proton.me>
2024-08-13 19:51:36 +02:00
Hosted Weblate
86ec5ad6e0
Update Swedish translation
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
2024-08-13 19:51:36 +02:00
Hosted Weblate
ae93146f47
Update French translation
Update French translation

Update French translation

Update French translation

Co-authored-by: ABCraft19 <lesenfantsbergaoui@gmail.com>
Co-authored-by: Duc-Thomas <duckiduc.dev@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Patricio Carrau <duckycb@proton.me>
Co-authored-by: Samantaz Fox <translator-weblate@samantaz.fr>
2024-08-13 19:51:36 +02:00
Hosted Weblate
e538410262
Update Dutch translation
Update Dutch translation

Co-authored-by: Dick Groskamp <dikgro@yahoo.co.uk>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Martijn Westerink <martijn.westerink.86@gmail.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
8ad19f06ee
Update Serbian (cyrillic) translation
Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Bulgarian translation

Update German translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Least Significant Bite <leastsignificantbite@proton.me>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Unacceptium <unacceptium@proton.me>
Co-authored-by: hiatsu0 <hietsu@gmail.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
366732b4fd
Update Serbian (cyrillic) translation
Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Bulgarian translation

Update German translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Least Significant Bite <leastsignificantbite@proton.me>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Unacceptium <unacceptium@proton.me>
Co-authored-by: hiatsu0 <hietsu@gmail.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
32ea9cfe16
Update Icelandic translation
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
2024-08-13 19:51:36 +02:00
Hosted Weblate
53a60bf7bd
Update Portuguese translation
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sergio Marques <so.boston.android@gmail.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
2d485b18a4
Update Welsh translation
Add Welsh translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: newidyn <grugallt@protonmail.ch>
2024-08-13 19:51:36 +02:00
Hosted Weblate
5cb1688c78
Update Catalan translation
Co-authored-by: Daniel <trikaphundo@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
2024-08-13 19:51:36 +02:00
Hosted Weblate
456b00a699
Update Ukrainian translation
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
84aded85c5
Update Serbian (cyrillic) translation
Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Bulgarian translation

Update German translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Least Significant Bite <leastsignificantbite@proton.me>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Unacceptium <unacceptium@proton.me>
Co-authored-by: hiatsu0 <hietsu@gmail.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
e99b591855
Update Russian translation
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Stepan <iam@amphetom.fun>
2024-08-13 19:51:36 +02:00
Hosted Weblate
7cf7cce0b2
Update Greek translation
Update Greek translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Open Contribution <open.alat4@slmail.me>
Co-authored-by: mpt.c <open.alat4@slmail.me>
2024-08-13 19:51:36 +02:00
Hosted Weblate
f842033eb5
Update Serbian (cyrillic) translation
Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Bulgarian translation

Update German translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Least Significant Bite <leastsignificantbite@proton.me>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Unacceptium <unacceptium@proton.me>
Co-authored-by: hiatsu0 <hietsu@gmail.com>
2024-08-13 19:51:36 +02:00
Hosted Weblate
c9fb19431d
Update Serbian (cyrillic) translation
Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Update Bulgarian translation

Update German translation

Update Serbian (cyrillic) translation

Update Serbian translation

Update Finnish translation

Update Italian translation

Update Hungarian translation

Update Portuguese (Brazil) translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Least Significant Bite <leastsignificantbite@proton.me>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Unacceptium <unacceptium@proton.me>
Co-authored-by: hiatsu0 <hietsu@gmail.com>
2024-08-13 19:51:36 +02:00
Emilien Devos
4b8bfe1201 use docker compose instead of docker-compose for CI 2024-08-13 15:02:02 +02:00
Emilien Devos
e6c39f9e3a add pot= parameter now required by youtube 2024-08-13 14:37:35 +02:00
Samantaz Fox
5e38ef59da
Ameba: Fix Lint/UselessAssign (#4795)
Related to issue 2231
2024-08-11 13:38:29 +02:00
Samantaz Fox
80ffc442f2
HTML: Add rel="noreferrer noopener" to external links (#4667)
Note: Does not add rel="noreferrer noopener" to:
 * links in channel description
 * links in video descriptions
 * links in video comments

Related to issue 4267
2024-08-11 13:35:57 +02:00
Samantaz Fox
9bf754ed4f
Remove unused methods in Invidious::LogHandler (#4812)
Closes issue 4791
2024-08-11 11:45:56 +02:00
Samantaz Fox
fa6c5158c5
Ameba: Fix Lint/NotNilAfterNoBang (#4796)
Related to issue 2231
2024-08-11 11:45:05 +02:00
Samantaz Fox
b45310c7d4
Ameba: Fix unused argument Lint warnings (#4805)
Related to issue 2231
2024-08-11 11:43:56 +02:00
Samantaz Fox
eb2dfe0ab1
Ameba: i18next.cr fixes (#4806)
Related to issue 2231
2024-08-11 11:41:36 +02:00
Samantaz Fox
cc36a82933
SigHelper: Fix some logic errors raised during code review 2024-08-07 23:26:10 +02:00
Samantaz Fox
7798faf234
SigHelper: Make signature server optional and configurable 2024-08-07 23:25:35 +02:00
Samantaz Fox
ec1bb5db87
SigHelper: Add support for PLAYER_UPDATE_TIMESTAMP opcode 2024-08-07 23:25:32 +02:00
Samantaz Fox
3b7e45b7bc
SigHelper: Small fixes + suggestions from code review 2024-08-07 23:12:38 +02:00
syeopite
e098c27a45
Remove unused methods in Invidious::LogHandler 2024-07-28 16:44:30 -07:00
syeopite
90e94d4e6c
Merge pull request #4792 from syeopite/disable-ameba-rules
Ameba: Disable rules
2024-07-27 02:31:31 +00:00
syeopite
6506b8dbfc
Ameba: Fix Naming/PredicateName 2024-07-25 20:08:26 -07:00
Samantaz Fox
61d75050e4
SigHelper: Use 'URI.parse' instead of 'URI.new'
Co-authored-by: Brahim Hadriche <brahim.hadriche@gmail.com>
2024-07-25 22:13:08 +02:00
Samantaz Fox
10e5788c21
Videos: Send player sts when required 2024-07-25 22:13:08 +02:00
Samantaz Fox
b509aa91d5
SigHelper: Fix many issues 2024-07-25 22:13:08 +02:00
Samantaz Fox
ec8b7916fa
Videos: Make use of the video decoding 2024-07-25 22:13:08 +02:00
Samantaz Fox
56a7488161
Helpers: Add inv_sig_helper client 2024-07-25 22:13:08 +02:00
Samantaz Fox
a845752fff
Jobs: Remove the signature function update job 2024-07-25 22:13:08 +02:00
Samantaz Fox
63a729998b
Misc: Sync crystal overrides with current stdlib 2024-07-25 22:13:07 +02:00
syeopite
205f988491
Ameba: Fix Naming/MethodNames 2024-07-24 20:04:44 -07:00
syeopite
0db3b830b7
Ameba: Fix Lint/HashDuplicatedKey 2024-07-24 20:03:41 -07:00
syeopite
c8fb75e6fd
Ameba: Fix Lint/UnusedBlockArgument 2024-07-24 19:59:20 -07:00
syeopite
636a6d0be2
Ameba: Fix Lint/UnusedArgument 2024-07-24 19:57:54 -07:00
syeopite
3415507e4a
Ameba: undo Lint/NotNilAfterNoBang in signatures.cr
File is set to be removed with #4772
2024-07-24 19:48:34 -07:00
Emilien Devos
53223f99b0 Add ability to set po_token and visitordata ID 2024-07-24 19:28:47 +02:00
Samantaz Fox
325561e755
Channel: parse subscriber count and channel banner (#4785)
This PR adds support for parsing the newer channel header format
(banner + subscription parsing)

Before this change:
* 0 subscribers
* No banner image

After this change:
* Example with Mr Breast channel: 299M
* Image banner is visible

Closes issue 4783
2024-07-21 17:24:09 +02:00
Samantaz Fox
09bf09befe
Player: Fix playback position of already watched videos (#4731)
Trying to watch an already watched video will make the video start 15 seconds
before the end. This is not very comfortable when listening to music or
watching/listening playlists over and over.

This can be easily tested on any instance with the "Save playback position"
enabled in the Preferences.

Closes issue 3976
2024-07-21 17:24:06 +02:00
Samantaz Fox
7fdbda612f
Videos: Fix genre url being unusable (#4717)
Closes issue 4700
2024-07-21 17:24:03 +02:00
Samantaz Fox
4f60feee17
API: Fix out of bound error on empty playlists (#4696)
Before this PR, Invidious assumed that every playlist had at least one video.
When a playlist had no videos, Invidious was throwing an "Index out of bounds"
exception.

The following API endpoints were impacted:
* api/v1/playlists/:plid
* api/v1/auth/playlists/:plid

Fixes issue 4679
2024-07-21 17:24:01 +02:00
Samantaz Fox
733bd27a5c
Handle playlists cataloged as Podcast (#4695)
Videos of a playlist cataloged as podcast are called "episodes" therefore
Invidious was not able to find video in the text value inside the stats array.

Test case: "/playlist?list=PLDu-Eh5lUs1a4irCbnxMIB6FrUMaTXgVF"

Fixes issue 4688
2024-07-21 17:23:58 +02:00
Samantaz Fox
1ff0775f4b
API: Fix duplicated query parameters in proxied video URLs (#4587)
This pull request fixes that bug that was causing the query parameters to get
doubled in the streaming URLs when '?local=true' is passed to the
'/api/v1/videos/{id}' API endpoint.

Before: host/path?parameters?parameters
After: host/path?parameters

No associated open issue
2024-07-21 17:23:53 +02:00
Samantaz Fox
e62d4db752
API: Return actual stream height, width and fps (#4586)
At the moment Invidious will return hardcoded data for the 'size',
'qualityLabel' and 'fps' fields for streams, when such hardcoded data is
available, otherwise it just omits those fields from the response (e.g. with
the AV1 formats). Those issues are especially noticable when Invidious claims
that 50fps streams have 60fps and when it claims that the dimensions for a
vertical video are landscape. The DASH manifests that Invidious generates
already use the correct information.

This pull request corrects that issue by returning the information that
YouTube provides instead of hardcoded values and also fixes the long
standing bug of Invidious claiming that audio streams have 30 fps.

Here are two test cases:
50/25/13fps: https://youtu.be/GbXYZwUigCM (/api/v1/videos/GbXYZwUigCM)
vertical video: https://youtu.be/hxQwWEOOyU8 (/api/v1/videos/hxQwWEOOyU8)

Originally these problems were going to be solved by the complete refactor
of stream handling in 3620, but as that pull request got closed by the stale
bot over a month ago and has such a massive scope that it would require a
massive amount of work to complete it, I decided to open this pull request
that takes a less radical approach of just fixing bugs instead of a full
on refactoring.

FreeTube generates it's own DASH manifests instead of using Invidious' one,
so that it can support multiple audio tracks and HDR. Unfortunately due to
the missing and inaccurate information in the API responses, FreeTube has
to request the DASH manifest from Invidious to extract the height, width and
fps. With this pull request FreeTube could rely just on the API response,
saving that extra request to the Invidious instance. It would also make it
possible for FreeTube to use the vp9 streams with Invidious, which would
reduce the load on the video proxies.

Closes issue 4131
2024-07-21 17:23:50 +02:00
Samantaz Fox
8b1da2001e
Preferences: Fix handling of modified source code URL(#4437)
Before this PR, setting the modified code repo URL through the preferences
page in Invidious was broken:

* the HTML input tag for this field had invalid type "input"
  (though browser falls back on text input)

* the URL was used to set the "checked" property and not as a plain value,
  which makes no sense for a text-based input (and resulted in a blank field)

* when the submitted field is empty, the retrieved value was an empty 'String'
  instead of 'nil', causing the "modified source code URL" to be an empty
  'href' link which just pointed to the current page

No associated open issue
2024-07-21 17:23:48 +02:00
Samantaz Fox
5a12005b48
API: Fix URL for vtt subtitles (#4221)
For 'fmt=vtt' to work, the 'fmt' parameter needs to be replaced
in the original caption api URL.

No associated open issue
2024-07-21 17:23:44 +02:00
syeopite
8575794bad
Exclude spec/parsers_helper from Lint/SpecFilename
False positive
2024-07-17 12:52:13 -07:00
syeopite
fad0a4f52d
Ameba: Fix Lint/UselessAssign 2024-07-17 12:39:40 -07:00
syeopite
fa50e0abf4
Simplify last_node retrieval
Co-authored-by: Samantaz Fox <coding@samantaz.fr>
2024-07-17 12:21:48 -07:00
syeopite
76ab51e219
Ameba: Disable Naming/BlockParameterName 2024-07-17 12:17:05 -07:00
syeopite
8258062ec5
Ameba: Fix Lint/NotNilAfterNoBang 2024-07-15 17:36:00 -07:00
syeopite
8d9723d43c
Disable Naming/AccessorMethodName rule
Most cases of Naming/AccessorMethodName are false positives
2024-07-11 21:15:45 -07:00
syeopite
8a90add310
Ameba: Fix Naming/VariableNames
Fix Naming/VariableNames in comment renderer

Fix Naming/VariableNames in helpers/utils

Fix Naming/VariableNames in api/v1/misc.cr
2024-07-11 20:56:28 -07:00
syeopite
c45e710845
Disable Documentation/DocumentationAdmonition rule 2024-07-11 20:47:24 -07:00
syeopite
593257a750
Fix typo 2024-07-11 20:45:27 -07:00
Samantaz Fox
bad92093bf
Channels: Add sort options to streams (#4224) 2024-07-10 22:28:22 +02:00
Samantaz Fox
436a61e3bb
API: Fix error code for disabled popular endpoint (#4296)
When visiting /api/v1/popular and popular endpoint is disabled
Before:

500 {"error":"Closed stream"}

After

403 {"error":"Administrator has disabled this endpoint."}
2024-07-10 22:25:31 +02:00
Samantaz Fox
5e0f55333a
Allow embedding videos in local HTML files (#4450)
The current Content Security Policy does not allow to embed videos
inside local HTML files which are viewed in the browser via the file
protocol. This commit adds the file protocol to the allowed frame
ancestors, so that the embedded videos load correctly in local HTML
files.

This behaviour is consistent which how the official YouTube website
allows to embed videos from itself.

Closes issue 4448
2024-07-10 22:24:18 +02:00
Samantaz Fox
de61b163a3
CI: Bump Crystal version matrix (#4654) 2024-07-10 22:21:17 +02:00
Samantaz Fox
99c7e9e800
YtAPI: Remove API keys like official clients (#4655)
This PR removes API keys from innertube requests, as the official clients
did it too.
2024-07-10 22:19:51 +02:00
Samantaz Fox
e9bab06e90
HTML: Use full URL in the og:image property (#4675)
Some opengraph implementations don't support a URL without the domain
therefore failing to fetch the video thumbnail and channel image.
This pull request basically fixes that.
2024-07-10 22:17:45 +02:00
Samantaz Fox
a56a724a55
Rewrite transcript logic to be more generic (#4747)
The transcript logic in Invidious was written specifically as a workaround for
captions, and not transcripts as a feature.

This PR genericises the logic as so it can be used to implement transcripts
within Invidious.

The most notable change is the added parsing of section headings when it was
previously skipped over in favor of regular lines.
2024-07-10 22:14:56 +02:00
Samantaz Fox
0a54e26536
CI: Run Ameba (#4753)
This PR simply adds Ameba to the CI but doesn't actually fix any of the
detected issues.
2024-07-10 22:13:45 +02:00
Samantaz Fox
d135e5b7f7
CI: Add release based containers (#4763)
This PR changes the current master based container to use "master" tag instead
of "latest" tag and adds a new workflow to build a container on each new
release which has the "latest" tag, and a tag based on the current released
version.
2024-07-10 22:11:01 +02:00
ChunkyProgrammer
911dad6935 Channel: parse subscriber count and channel banner 2024-07-09 14:43:14 -04:00
syeopite
220cc9bd2f
Typo
Co-authored-by: Samantaz Fox <coding@samantaz.fr>
2024-07-04 10:14:19 -07:00
syeopite
aace30b2b4
Bump nightly container build workflow crystal ver 2024-07-04 10:11:36 -07:00
syeopite
64d1f26ece
Fix trigger for stable container build 2024-07-01 21:39:14 -07:00
syeopite
8f5c6a602b
Rename container workflows 2024-07-01 21:35:08 -07:00
syeopite
dd38eef41a
Add workflow to build container on release 2024-06-24 11:45:00 -07:00
syeopite
848ab1e9c8
Specify which workflow builds from master 2024-06-24 11:36:11 -07:00
syeopite
933802b897
Use "master" label for master container build 2024-06-24 11:34:55 -07:00
meatball
3bac467a8c Call as? instead of as to not force string conversion 2024-06-19 12:52:53 +02:00
meatball
248df785d7 Update spec and rollback to last commits changes 2024-06-18 20:55:14 +02:00
syeopite
6b429575bf
Update ameba version 2024-06-16 16:22:01 -07:00
syeopite
e0ed094cc4
Cache ameba binary 2024-06-16 13:29:06 -07:00
syeopite
a644d76497
Update ameba config 2024-06-16 13:21:55 -07:00
syeopite
45fd4a1968
Add job to lint code through Ameba in CI 2024-06-16 13:21:55 -07:00
Fijxu
e82c965e89
Player: Fix video playback for videos that have already been watched.
Trying to watch an already watched video will make the video start 15
seconds before the end of the video. This is not very comfortable when
listening to music or watching/listening playlists over and over.
2024-06-15 18:15:51 -04:00
syeopite
f466116cd7
Extract label for transcript in YouTube response 2024-06-13 09:07:20 -07:00
syeopite
5b519123a7
Raise error when transcript does not exist 2024-06-11 18:46:34 -07:00
syeopite
0224162ad2
Rewrite transcript logic to be more generic
The transcript logic in Invidious was written specifically
as a workaround for captions, and not transcripts as a feature.

This commit genericises the logic a bit as so it can be used for
implementing transcripts within Invidious' API and UI as well.

The most notable change is the added parsing of section headings
when it was previously skipped over in favor of regular lines.
2024-06-11 18:23:01 -07:00
meatball
04ca64691b Make solution complaint with spec 2024-05-30 22:37:55 +02:00
meatball
5957523624 Improve code quallity 2024-05-30 22:13:30 +02:00
meatball
629599f940 Fix change in parser file 2024-05-30 21:57:15 +02:00
meatball
31ad708206 fix: Handle nil value for genreUcid in Video struct 2024-05-30 21:56:33 +02:00
thansk
1ce2d10c50
fix: use ion icon for search icon 2024-05-20 14:17:30 +00:00
thansk
5abafb8296
fix: use a search icon instead of text 2024-05-20 11:49:56 +00:00
thansk
9cd2e93a2e
feat: allow submitting search with mouse 2024-05-19 11:46:55 +00:00
absidue
3b773c4f77 Fix missing commas 2024-05-14 19:02:41 +02:00
absidue
57e606cb43 Add back missing resolution field 2024-05-14 19:02:41 +02:00
absidue
f57aac5815 Fix the missing p in the quality labels.
Co-authored-by: Samantaz Fox <coding@samantaz.fr>
2024-05-14 19:02:41 +02:00
absidue
71a821a7e6 Return actual height, width and fps for streams in /api/v1/videos 2024-05-14 19:02:32 +02:00
Fijxu
e0d0dbde3c
API: Check if playlist has any videos on it.
Invidious assumes that every playlist will have at least one video
because it needs to check for the `index` key. So if there is no videos
on a playlist, there is no `index` key and Invidious throws
`Index out of bounds`
2024-05-13 21:07:46 -04:00
Fijxu
90fcf80a8d
Handle playlists cataloged as Podcast
Videos of a playlist cataloged as podcast are called episodes therefore
Invidious was not able to find `video` in the `text` value inside the
stats array.
2024-05-13 19:39:46 -04:00
ulmemxpoc
c4fec89a9b
Apply suggestions from code review 2024-05-10 11:23:11 -07:00
Fijxu
9d66676f2d
Use full URL in the og:image property. 2024-05-01 22:21:18 -04:00
ulmemxpoc
f696f96824 Add rel="noreferrer noopener" to external links 2024-04-30 03:40:19 +00:00
Samantaz Fox
2fdb6dd644
CI: Bump Crystal version in docker too 2024-04-27 21:02:37 +02:00
Samantaz Fox
470245de54
YtAPI: Remove API keys like official clients 2024-04-27 20:48:42 +02:00
Samantaz Fox
b0ec359028
CI: Bump Crystal version matrix 2024-04-27 20:01:19 +02:00
absidue
b90cf286fc Fix duplicate query parameters in URLs when local=true for /api/v1/videos/{id} 2024-04-20 20:46:01 +02:00
Brahim Hadriche
a9e8aabe1f Merge commit '08390acd0c17875fddb84cabba54197a5b5740e4' into fix/popular-disabled-error 2024-04-01 10:03:37 -04:00
Brahim Hadriche
b0c6bdf44c use 403 code 2024-04-01 10:03:29 -04:00
Brahim Hadriche
c5eb10b21f Revert "Fix error code for disabled popular endpoint"
This reverts commit 1363fb8094.
2024-04-01 10:02:49 -04:00
src-tinkerer
72fe8af850
Merge branch 'master' into stream-sort 2024-03-26 12:19:45 +00:00
nooptek
499aed37dd Fix handling of modified source code URL setting 2024-03-10 17:51:29 +01:00
Tomasz Wilczyński
4adb4c00d2
routes: Allow embedding videos in local HTML files (fixes #4448)
The current Content Security Policy does not allow to embed videos
inside local HTML files which are viewed in the browser via the file
protocol. This commit adds the file protocol to the allowed frame
ancestors, so that the embedded videos load correctly in local HTML
files.

This behaviour is consistent which how the official YouTube website
allows to embed videos from itself.

Signed-off-by: Tomasz Wilczyński <twilczynski@naver.com>
2024-02-24 20:01:16 +01:00
src-tinkerer
cf61af67ab Update src/invidious/routes/channels.cr sort_by for consistency 2023-11-30 14:34:01 +03:30
Brahim Hadriche
1363fb8094 Fix error code for disabled popular endpoint 2023-11-28 21:34:17 -05:00
src-tinkerer
5f2b43d653 Remove unecessary if condition in videos.cr 2023-11-25 00:48:27 +03:30
src-tinkerer
6251d8d43f Rename a variable in videos.cr 2023-11-25 00:46:11 +03:30
src-tinkerer
162b89d942 Fix format in videos.cr 2023-11-23 14:44:37 +03:30
src-tinkerer
0d63ad5a7f Use a single function for fetching channel contents 2023-11-22 14:52:17 +03:30
src-tinkerer
63e5d72466 Remove unused function produce_channel_livestream_url 2023-11-20 15:50:59 +03:30
karelrooted
c251c66748 fix youtube api vtt format subtitle
for fmt=vtt to work the fmt parameter in the original caption api url need to be replaced
2023-11-14 13:16:08 +08:00
src-tinkerer
b0df3774db Add sort options to streams 2023-11-01 21:56:25 +03:30
86 changed files with 2559 additions and 804 deletions

View File

@ -20,6 +20,13 @@ Lint/ShadowingOuterLocalVar:
Excluded:
- src/invidious/helpers/tokens.cr
Lint/NotNil:
Enabled: false
Lint/SpecFilename:
Excluded:
- spec/parsers_helper.cr
#
# Style
@ -31,6 +38,26 @@ Style/RedundantBegin:
Style/RedundantReturn:
Enabled: false
Style/ParenthesesAroundCondition:
Enabled: false
# This requires a rewrite of most data structs (and their usage) in Invidious.
Naming/QueryBoolMethods:
Enabled: false
Naming/AccessorMethodName:
Enabled: false
Naming/BlockParameterName:
Enabled: false
# Hides TODO comment warnings.
#
# Call `bin/ameba --only Documentation/DocumentationAdmonition` to
# list them
Documentation/DocumentationAdmonition:
Enabled: false
#
# Metrics
@ -39,50 +66,4 @@ Style/RedundantReturn:
# Ignore function complexity (number of if/else & case/when branches)
# For some functions that can hardly be simplified for now
Metrics/CyclomaticComplexity:
Excluded:
# get_about_info(ucid, locale) => [17/10]
- src/invidious/channels/about.cr
# fetch_channel_community(ucid, continuation, ...) => [34/10]
- src/invidious/channels/community.cr
# create_notification_stream(env, topics, connection_channel) => [14/10]
- src/invidious/helpers/helpers.cr:84:5
# get_index(plural_form, count) => [25/10]
- src/invidious/helpers/i18next.cr
# call(context) => [18/10]
- src/invidious/helpers/static_file_handler.cr
# show(env) => [38/10]
- src/invidious/routes/embed.cr
# get_video_playback(env) => [45/10]
- src/invidious/routes/video_playback.cr
# handle(env) => [40/10]
- src/invidious/routes/watch.cr
# playlist_ajax(env) => [24/10]
- src/invidious/routes/playlists.cr
# fetch_youtube_comments(id, cursor, ....) => [40/10]
# template_youtube_comments(comments, locale, ...) => [16/10]
# content_to_comment_html(content) => [14/10]
- src/invidious/comments.cr
# to_json(locale, json) => [21/10]
# extract_video_info(video_id, ...) => [44/10]
# process_video_params(query, preferences) => [20/10]
- src/invidious/videos.cr
#src/invidious/playlists.cr:327:5
#[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [19/10]
# fetch_playlist(plid : String)
#src/invidious/playlists.cr:436:5
#[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [11/10]
# extract_playlist_videos(initial_data : Hash(String, JSON::Any))
Enabled: false

View File

@ -1,4 +1,4 @@
name: Build and release container
name: Build and release container directly from master
on:
push:
@ -24,9 +24,9 @@ jobs:
uses: actions/checkout@v4
- name: Install Crystal
uses: crystal-lang/install-crystal@v1.8.0
uses: crystal-lang/install-crystal@v1.8.2
with:
crystal: 1.9.2
crystal: 1.12.2
- name: Run lint
run: |
@ -58,7 +58,7 @@ jobs:
images: quay.io/invidious/invidious
tags: |
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
labels: |
quay.expires-after=12w
@ -83,7 +83,7 @@ jobs:
suffix=-arm64
tags: |
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
labels: |
quay.expires-after=12w

View File

@ -0,0 +1,94 @@
name: Build and release container
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Crystal
uses: crystal-lang/install-crystal@v1.8.2
with:
crystal: 1.12.2
- name: Run lint
run: |
if ! crystal tool format --check; then
crystal tool format
git diff
exit 1
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: quay.io/invidious/invidious
flavor: |
latest=false
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
labels: |
quay.expires-after=12w
- name: Build and push Docker AMD64 image for Push Event
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
"release=1"
- name: Docker meta
id: meta-arm64
uses: docker/metadata-action@v5
with:
images: quay.io/invidious/invidious
flavor: |
latest=false
suffix=-arm64
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
labels: |
quay.expires-after=12w
- name: Build and push Docker ARM64 image for Push Event
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.arm64
platforms: linux/arm64/v8
labels: ${{ steps.meta-arm64.outputs.labels }}
push: true
tags: ${{ steps.meta-arm64.outputs.tags }}
build-args: |
"release=1"

View File

@ -38,10 +38,10 @@ jobs:
matrix:
stable: [true]
crystal:
- 1.7.3
- 1.8.2
- 1.9.2
- 1.10.1
- 1.11.2
- 1.12.1
include:
- crystal: nightly
stable: false
@ -90,10 +90,10 @@ jobs:
- uses: actions/checkout@v4
- name: Build Docker
run: docker-compose build --build-arg release=0
run: docker compose build --build-arg release=0
- name: Run Docker
run: docker-compose up -d
run: docker compose up -d
- name: Test Docker
run: while curl -Isf http://localhost:3000; do sleep 1; done
@ -124,4 +124,28 @@ jobs:
- name: Test Docker
run: while curl -Isf http://localhost:3000; do sleep 1; done
ameba_lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install Crystal
uses: crystal-lang/install-crystal@v1.8.0
with:
crystal: latest
- name: Cache Shards
uses: actions/cache@v3
with:
path: |
./lib
./bin
key: shards-${{ hashFiles('shard.lock') }}
- name: Install Shards
run: shards install
- name: Run Ameba linter
run: bin/ameba

View File

@ -1,6 +1,189 @@
# CHANGELOG
## 2024-04-26
## v2.20240825.2 (2024-08-26)
This releases fixes the container tags pushed on quay.io.
Previously, the ARM64 build was released under the `latest` tag, instead of `latest-arm64`.
### Full list of pull requests merged since the last release (newest first)
CI: Fix docker container tags ([#4883], by @SamantazFox)
[#4877]: https://github.com/iv-org/invidious/pull/4877
## v2.20240825.1 (2024-08-25)
Add patch component to be [semver] compliant and make github actions happy.
[semver]: https://semver.org/
### Full list of pull requests merged since the last release (newest first)
Allow manual trigger of release-container build ([#4877], thanks @syeopite)
[#4877]: https://github.com/iv-org/invidious/pull/4877
## v2.20240825.0 (2024-08-25)
### New features & important changes
#### For users
* The search bar now has a button that you can click!
* Youtube URLs can be pasted directly in the search bar. Prepend search query with a
backslash (`\`) to disable that feature (useful if you need to search for a video whose
title contains some youtube URL).
* On the channel page the "streams" tab can be sorted by either: "newest", "oldest" or "popular"
* Lots of translations have been updated (thanks to our contributors on Weblate!)
* Videos embedded in local HTML files (e.g: a webpage saved from a blog) can now be played
#### For instance owners
* Invidious now has the ability to provide a `po_token` and `visitordata` to Youtube in order to
circumvent current Youtube restrictions.
* Invidious can use an (optional) external signature server like [inv_sig_helper]. Please note that
some videos can't be played without that signature server.
* The Helm charts were moved to a separate repo: https://github.com/iv-org/invidious-helm-chart
* We have changed how containers are released: the `latest` tag now tracks tagged releases, whereas
the `master` tag tracks the most recent commits of the `master` branch ("nightly" builds).
[inv_sig_helper]: https://github.com/iv-org/inv_sig_helper
#### For developpers
* The versions of Crystal that we test in CI/CD are now: `1.9.2`, `1.10.1`, `1.11.2`, `1.12.1`.
Please note that due to a bug in the `libxml` bindings (See [#4256]), versions prior to `1.10.0`
are not recommended to use.
* Thanks to @syeopite, the code is now [ameba] compliant.
* Ameba is part of our CI/CD pipeline, and its rules will be enforced in future PRs.
* The transcript code has been rewritten to permit transcripts as a feature rather than being
only a workaround for captions. Trancripts feature is coming soon!
* Various fixes regarding the logic interacting with Youtube
* The `sort_by` parameter can be used on the `/api/v1/channels/{id}/streams` endpoint. Accepted
values are: "newest", "oldest" and "popular"
[ameba]: https://github.com/crystal-ameba/ameba
[#4256]: https://github.com/iv-org/invidious/issues/4256
### Bugs fixed
#### User-side
* Channels: fixed broken "subscribers" and "views" counters
* Watch page: playback position is reset at the end of a video, so that the next time this video
is watched, it will start from the beginning rather than 15 seconds before the end
* Watch page: the items in the "add to playlist" drop down are now sorted alphabetically
* Videos: the "genre" URL is now always pointing to a valid webpage
* Playlists: Fixed `Could not parse N episodes` error on podcast playlists
* All external links should now have the [`rel`] attibute set to `noreferrer noopener` for
increased privacy.
* Preferences: Fixed the admin-only "modified source code" input being ignored
* Watch/channel pages: use the full image URL in `og:image` and `twitter:image` meta tags
[`rel`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel
#### API
* fixed the `local` parameter not applying to `formatStreams` on `/api/v1/videos/{id}`
* fixed an `Index out of bounds` error hapenning when a playlist had no videos
* fixed duplicated query parameters in proxied video URLs
* Return actual video height/width/fps rather than hard coded values
* Fixed the `/api/v1/popular` endpoint not returning a proper error code/message when the
popular page/endpoint are disabled.
### Full list of pull requests merged since the last release (newest first)
* HTML: Sort playlists alphabetically in watch page drop down ([#4853], by @SamantazFox)
* Videos: Fix XSS vulnerability in description/comments ([#4852], thanks _anonymous_)
* YtAPI: Bump client versions ([#4849], by @SamantazFox)
* SigHelper: Fix inverted time comparison in 'check_update' ([#4845], by @SamantazFox)
* Storyboards: Various fixes and code cleaning ([#4153], by SamantazFox)
* Fix lint errors introduced in #4146 and #4295 ([#4876], thanks @syeopite)
* Search: Add support for Youtube URLs ([#4146], by @SamantazFox)
* Channel: Render age restricted channels ([#4295], thanks @ChunkyProgrammer)
* Ameba: Miscellaneous fixes ([#4807], thanks @syeopite)
* API: Proxy formatStreams URLs too ([#4859], thanks @colinleroy)
* UI: Add search button to search bar ([#4706], thanks @thansk)
* Add ability to set po_token and visitordata ID ([#4789], thanks @unixfox)
* Add support for an external signature server ([#4772], by @SamantazFox)
* Ameba: Fix Naming/VariableNames ([#4790], thanks @syeopite)
* Translations update from Hosted Weblate ([#4659])
* Ameba: Fix Lint/UselessAssign ([#4795], thanks @syeopite)
* HTML: Add rel="noreferrer noopener" to external links ([#4667], thanks @ulmemxpoc)
* Remove unused methods in Invidious::LogHandler ([#4812], thanks @syeopite)
* Ameba: Fix Lint/NotNilAfterNoBang ([#4796], thanks @syeopite)
* Ameba: Fix unused argument Lint warnings ([#4805], thanks @syeopite)
* Ameba: i18next.cr fixes ([#4806], thanks @syeopite)
* Ameba: Disable rules ([#4792], thanks @syeopite)
* Channel: parse subscriber count and channel banner ([#4785], thanks @ChunkyProgrammer)
* Player: Fix playback position of already watched videos ([#4731], thanks @Fijxu)
* Videos: Fix genre url being unusable ([#4717], thanks @meatball133)
* API: Fix out of bound error on empty playlists ([#4696], thanks @Fijxu)
* Handle playlists cataloged as Podcast ([#4695], thanks @Fijxu)
* API: Fix duplicated query parameters in proxied video URLs ([#4587], thanks @absidue)
* API: Return actual stream height, width and fps ([#4586], thanks @absidue)
* Preferences: Fix handling of modified source code URL ([#4437], thanks @nooptek)
* API: Fix URL for vtt subtitles ([#4221], thanks @karelrooted)
* Channels: Add sort options to streams ([#4224], thanks @src-tinkerer)
* API: Fix error code for disabled popular endpoint ([#4296], thanks @iBicha)
* Allow embedding videos in local HTML files ([#4450], thanks @tomasz1986)
* CI: Bump Crystal version matrix ([#4654], by @SamantazFox)
* YtAPI: Remove API keys like official clients ([#4655], by @SamantazFox)
* HTML: Use full URL in the og:image property ([#4675], thanks @Fijxu)
* Rewrite transcript logic to be more generic ([#4747], thanks @syeopite)
* CI: Run Ameba ([#4753], thanks @syeopite)
* CI: Add release based containers ([#4763], thanks @syeopite)
* move helm chart to a dedicated github repository ([#4711], thanks @unixfox)
[#4146]: https://github.com/iv-org/invidious/pull/4146
[#4153]: https://github.com/iv-org/invidious/pull/4153
[#4221]: https://github.com/iv-org/invidious/pull/4221
[#4224]: https://github.com/iv-org/invidious/pull/4224
[#4295]: https://github.com/iv-org/invidious/pull/4295
[#4296]: https://github.com/iv-org/invidious/pull/4296
[#4437]: https://github.com/iv-org/invidious/pull/4437
[#4450]: https://github.com/iv-org/invidious/pull/4450
[#4586]: https://github.com/iv-org/invidious/pull/4586
[#4587]: https://github.com/iv-org/invidious/pull/4587
[#4654]: https://github.com/iv-org/invidious/pull/4654
[#4655]: https://github.com/iv-org/invidious/pull/4655
[#4659]: https://github.com/iv-org/invidious/pull/4659
[#4667]: https://github.com/iv-org/invidious/pull/4667
[#4675]: https://github.com/iv-org/invidious/pull/4675
[#4695]: https://github.com/iv-org/invidious/pull/4695
[#4696]: https://github.com/iv-org/invidious/pull/4696
[#4706]: https://github.com/iv-org/invidious/pull/4706
[#4711]: https://github.com/iv-org/invidious/pull/4711
[#4717]: https://github.com/iv-org/invidious/pull/4717
[#4731]: https://github.com/iv-org/invidious/pull/4731
[#4747]: https://github.com/iv-org/invidious/pull/4747
[#4753]: https://github.com/iv-org/invidious/pull/4753
[#4763]: https://github.com/iv-org/invidious/pull/4763
[#4772]: https://github.com/iv-org/invidious/pull/4772
[#4785]: https://github.com/iv-org/invidious/pull/4785
[#4789]: https://github.com/iv-org/invidious/pull/4789
[#4790]: https://github.com/iv-org/invidious/pull/4790
[#4792]: https://github.com/iv-org/invidious/pull/4792
[#4795]: https://github.com/iv-org/invidious/pull/4795
[#4796]: https://github.com/iv-org/invidious/pull/4796
[#4805]: https://github.com/iv-org/invidious/pull/4805
[#4806]: https://github.com/iv-org/invidious/pull/4806
[#4807]: https://github.com/iv-org/invidious/pull/4807
[#4812]: https://github.com/iv-org/invidious/pull/4812
[#4845]: https://github.com/iv-org/invidious/pull/4845
[#4849]: https://github.com/iv-org/invidious/pull/4849
[#4852]: https://github.com/iv-org/invidious/pull/4852
[#4853]: https://github.com/iv-org/invidious/pull/4853
[#4859]: https://github.com/iv-org/invidious/pull/4859
[#4876]: https://github.com/iv-org/invidious/pull/4876
## v2.20240427 (2024-04-27)
Major bug fixes:
* Videos: Use android test suite client (#4650, thanks @SamantazFox)

View File

@ -278,7 +278,14 @@ div.thumbnail > .bottom-right-overlay {
display: inline;
}
.searchbar .pure-form fieldset { padding: 0; }
.searchbar .pure-form {
display: flex;
}
.searchbar .pure-form fieldset {
padding: 0;
flex: 1;
}
.searchbar input[type="search"] {
width: 100%;
@ -310,6 +317,16 @@ input[type="search"]::-webkit-search-cancel-button {
background-size: 14px;
}
.searchbar #searchbutton {
border: none;
background: none;
margin-top: 0;
}
.searchbar #searchbutton:hover {
color: rgb(0, 182, 240);
}
.user-field {
display: flex;
flex-direction: row;

View File

@ -351,7 +351,12 @@ if (video_data.params.save_player_pos) {
const rememberedTime = get_video_time();
let lastUpdated = 0;
if(!hasTimeParam) set_seconds_after_start(rememberedTime);
if(!hasTimeParam) {
if (rememberedTime >= video_data.length_seconds - 20)
set_seconds_after_start(0);
else
set_seconds_after_start(rememberedTime);
}
player.on('timeupdate', function () {
const raw = player.currentTime();

View File

@ -1,6 +1,6 @@
#########################################
#
# Database configuration
# Database and other external servers
#
#########################################
@ -41,6 +41,19 @@ db:
#check_tables: false
##
## Path to an external signature resolver, used to emulate
## the Youtube client's Javascript. If no such server is
## available, some videos will not be playable.
##
## When this setting is commented out, no external
## resolver will be used.
##
## Accepted values: a path to a UNIX socket or "<IP>:<Port>"
## Default: <none>
##
#signature_server:
#########################################
#
@ -173,6 +186,18 @@ https_only: false
##
# use_innertube_for_captions: false
##
## Send Google session informations. This is useful when Invidious is blocked
## by the message "This helps protect our community."
## See https://github.com/iv-org/invidious/issues/4734.
##
## Warning: These strings gives much more identifiable information to Google!
##
## Accepted values: String
## Default: <none>
##
# po_token: ""
# visitor_data: ""
# -----------------------------
# Logging
@ -343,21 +368,6 @@ full_refresh: false
##
feed_threads: 1
##
## Enable/Disable the polling job that keeps the decryption
## function (for "secured" videos) up to date.
##
## Note: This part of the code generate a small amount of data every minute.
## This may not be desired if you have bandwidth limits set by your ISP.
##
## Note 2: This part of the code is currently broken, so changing
## this setting has no impact.
##
## Accepted values: true, false
## Default: false
##
#decrypt_polling: false
jobs:

View File

@ -1,4 +1,4 @@
FROM crystallang/crystal:1.8.2-alpine AS builder
FROM crystallang/crystal:1.12.1-alpine AS builder
RUN apk add --no-cache sqlite-static yaml-static

View File

@ -1,5 +1,5 @@
FROM alpine:3.18 AS builder
RUN apk add --no-cache 'crystal=1.8.2-r0' shards sqlite-static yaml-static yaml-dev libxml2-static zlib-static openssl-libs-static openssl-dev musl-dev xz-static
FROM alpine:3.19 AS builder
RUN apk add --no-cache 'crystal=1.10.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-static zlib-static openssl-libs-static openssl-dev musl-dev xz-static
ARG release

View File

@ -487,5 +487,11 @@
"generic_views_count": "{{count}} гледане",
"generic_views_count_plural": "{{count}} гледания",
"Next page": "Следваща страница",
"Import YouTube watch history (.json)": "Импортиране на историята на гледане от YouTube (.json)"
"Import YouTube watch history (.json)": "Импортиране на историята на гледане от YouTube (.json)",
"toggle_theme": "Смени темата",
"Add to playlist": "Добави към плейлист",
"Add to playlist: ": "Добави към плейлист: ",
"Answer": "Отговор",
"Search for videos": "Търсене на видеа",
"The Popular feed has been disabled by the administrator.": "Популярната страница е деактивирана от администратора."
}

View File

@ -487,5 +487,7 @@
"generic_button_edit": "Edita",
"generic_button_rss": "RSS",
"generic_button_delete": "Suprimeix",
"Import YouTube watch history (.json)": "Importa l'historial de visualitzacions de YouTube (.json)"
"Import YouTube watch history (.json)": "Importa l'historial de visualitzacions de YouTube (.json)",
"Answer": "Resposta",
"toggle_theme": "Commuta el tema"
}

385
locales/cy.json Normal file
View File

@ -0,0 +1,385 @@
{
"Time (h:mm:ss):": "Amser (h:mm:ss):",
"Password": "Cyfrinair",
"preferences_quality_dash_option_auto": "Awtomatig",
"preferences_quality_dash_option_best": "Gorau",
"preferences_quality_dash_option_worst": "Gwaethaf",
"preferences_quality_dash_option_360p": "360p",
"published": "dyddiad cyhoeddi",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_option_480p": "480p",
"preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p",
"preferences_comments_label": "Ffynhonnell sylwadau: ",
"preferences_captions_label": "Isdeitlau rhagosodedig: ",
"youtube": "YouTube",
"reddit": "Reddit",
"Fallback captions: ": "Isdeitlau amgen: ",
"preferences_related_videos_label": "Dangos fideos perthnasol: ",
"dark": "tywyll",
"preferences_dark_mode_label": "Thema: ",
"light": "golau",
"preferences_sort_label": "Trefnu fideo yn ôl: ",
"Import/export data": "Mewnforio/allforio data",
"Delete account": "Dileu eich cyfrif",
"preferences_category_admin": "Hoffterau gweinyddu",
"playlist_button_add_items": "Ychwanegu fideos",
"Delete playlist": "Dileu'r rhestr chwarae",
"Create playlist": "Creu rhestr chwarae",
"Show less": "Dangos llai",
"Show more": "Dangos rhagor",
"Watch on YouTube": "Gwylio ar YouTube",
"search_message_no_results": "Dim canlyniadau.",
"search_message_change_filters_or_query": "Ceisiwch ehangu eich chwiliad ac/neu newid yr hidlyddion.",
"License: ": "Trwydded: ",
"Standard YouTube license": "Trwydded safonol YouTube",
"Family friendly? ": "Addas i bawb? ",
"Wilson score: ": "Sgôr Wilson: ",
"Show replies": "Dangos ymatebion",
"Music in this video": "Cerddoriaeth yn y fideo hwn",
"Artist: ": "Artist: ",
"Erroneous CAPTCHA": "CAPTCHA anghywir",
"This channel does not exist.": "Dyw'r sianel hon ddim yn bodoli.",
"Not a playlist.": "Ddim yn rhestr chwarae.",
"Could not fetch comments": "Wedi methu llwytho sylwadau",
"Playlist does not exist.": "Dyw'r rhestr chwarae ddim yn bodoli.",
"Erroneous challenge": "Her annilys",
"channel_tab_podcasts_label": "Podlediadau",
"channel_tab_playlists_label": "Rhestrau chwarae",
"channel_tab_streams_label": "Fideos byw",
"crash_page_read_the_faq": "darllen y <a href=\"`x`\">cwestiynau cyffredin</a>",
"crash_page_switch_instance": "ceisio <a href=\"`x`\">defnyddio gweinydd arall</a>",
"crash_page_refresh": "ceisio <a href=\"`x`\">ail-lwytho'r dudalen</a>",
"search_filters_features_option_four_k": "4K",
"search_filters_features_label": "Nodweddion",
"search_filters_duration_option_medium": "Canolig (4 - 20 munud)",
"search_filters_features_option_live": "Yn fyw",
"search_filters_duration_option_long": "Hir (> 20 munud)",
"search_filters_date_option_year": "Eleni",
"search_filters_type_label": "Math",
"search_filters_date_option_month": "Y mis hwn",
"generic_views_count_0": "{{count}} o wyliadau",
"generic_views_count_1": "{{count}} gwyliad",
"generic_views_count_2": "{{count}} wyliad",
"generic_views_count_3": "{{count}} o wyliadau",
"generic_views_count_4": "{{count}} o wyliadau",
"generic_views_count_5": "{{count}} o wyliadau",
"Answer": "Ateb",
"Add to playlist: ": "Ychwanegu at y rhestr chwarae: ",
"Add to playlist": "Ychwanegu at y rhestr chwarae",
"generic_button_cancel": "Diddymu",
"generic_button_rss": "RSS",
"LIVE": "YN FYW",
"Import YouTube watch history (.json)": "Mewnforio hanes gwylio YouTube (.json)",
"generic_videos_count_0": "{{count}} fideo",
"generic_videos_count_1": "{{count}} fideo",
"generic_videos_count_2": "{{count}} fideo",
"generic_videos_count_3": "{{count}} fideo",
"generic_videos_count_4": "{{count}} fideo",
"generic_videos_count_5": "{{count}} fideo",
"generic_subscribers_count_0": "{{count}} tanysgrifiwr",
"generic_subscribers_count_1": "{{count}} tanysgrifiwr",
"generic_subscribers_count_2": "{{count}} danysgrifiwr",
"generic_subscribers_count_3": "{{count}} thanysgrifiwr",
"generic_subscribers_count_4": "{{count}} o danysgrifwyr",
"generic_subscribers_count_5": "{{count}} o danysgrifwyr",
"Authorize token?": "Awdurdodi'r tocyn?",
"Authorize token for `x`?": "Awdurdodi'r tocyn ar gyfer `x`?",
"English": "Saesneg",
"English (United Kingdom)": "Saesneg (Y Deyrnas Unedig)",
"English (United States)": "Saesneg (Yr Unol Daleithiau)",
"Afrikaans": "Affricaneg",
"English (auto-generated)": "Saesneg (awtomatig)",
"Amharic": "Amhareg",
"Albanian": "Albaneg",
"Arabic": "Arabeg",
"crash_page_report_issue": "Os nad yw'r awgrymiadau uchod wedi helpu, <a href=\"`x`\">codwch 'issue' newydd ar Github </a> (yn Saesneg, gorau oll) a chynnwys y testun canlynol yn eich neges (peidiwch â chyfieithu'r testun hwn):",
"Search for videos": "Chwilio am fideos",
"The Popular feed has been disabled by the administrator.": "Mae'r ffrwd fideos poblogaidd wedi ei hanalluogi gan y gweinyddwr.",
"generic_channels_count_0": "{{count}} sianel",
"generic_channels_count_1": "{{count}} sianel",
"generic_channels_count_2": "{{count}} sianel",
"generic_channels_count_3": "{{count}} sianel",
"generic_channels_count_4": "{{count}} sianel",
"generic_channels_count_5": "{{count}} sianel",
"generic_button_delete": "Dileu",
"generic_button_edit": "Golygu",
"generic_button_save": "Cadw",
"Shared `x` ago": "Rhannwyd `x` yn ôl",
"Unsubscribe": "Dad-danysgrifio",
"Subscribe": "Tanysgrifio",
"View channel on YouTube": "Gweld y sianel ar YouTube",
"View playlist on YouTube": "Gweld y rhestr chwarae ar YouTube",
"newest": "diweddaraf",
"oldest": "hynaf",
"popular": "poblogaidd",
"Next page": "Tudalen nesaf",
"Previous page": "Tudalen flaenorol",
"Clear watch history?": "Clirio'ch hanes gwylio?",
"New password": "Cyfrinair newydd",
"Import and Export Data": "Mewnforio ac allforio data",
"Import": "Mewnforio",
"Import Invidious data": "Mewnforio data JSON Invidious",
"Import YouTube subscriptions": "Mewnforio tanysgrifiadau YouTube ar fformat CSV neu OPML",
"Import YouTube playlist (.csv)": "Mewnforio rhestr chwarae YouTube (.csv)",
"Export": "Allforio",
"Export data as JSON": "Allforio data Invidious ar fformat JSON",
"Delete account?": "Ydych chi'n siŵr yr hoffech chi ddileu eich cyfrif?",
"History": "Hanes",
"JavaScript license information": "Gwybodaeth am y drwydded JavaScript",
"generic_subscriptions_count_0": "{{count}} tanysgrifiad",
"generic_subscriptions_count_1": "{{count}} tanysgrifiad",
"generic_subscriptions_count_2": "{{count}} danysgrifiad",
"generic_subscriptions_count_3": "{{count}} thanysgrifiad",
"generic_subscriptions_count_4": "{{count}} o danysgrifiadau",
"generic_subscriptions_count_5": "{{count}} o danysgrifiadau",
"Yes": "Iawn",
"No": "Na",
"Import FreeTube subscriptions (.db)": "Mewnforio tanysgrifiadau FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Mewnforio tanysgrifiadau NewPipe (.json)",
"Import NewPipe data (.zip)": "Mewnforio data NewPipe (.zip)",
"An alternative front-end to YouTube": "Pen blaen amgen i YouTube",
"source": "ffynhonnell",
"Log in": "Mewngofnodi",
"Log in/register": "Mewngofnodi/Cofrestru",
"User ID": "Enw defnyddiwr",
"preferences_quality_option_dash": "DASH (ansawdd addasol)",
"Sign In": "Mewngofnodi",
"Register": "Cofrestru",
"E-mail": "Ebost",
"Preferences": "Hoffterau",
"preferences_category_player": "Hoffterau'r chwaraeydd",
"preferences_autoplay_label": "Chwarae'n awtomatig: ",
"preferences_local_label": "Llwytho fideos drwy ddirprwy weinydd: ",
"preferences_watch_history_label": "Galluogi hanes gwylio: ",
"preferences_speed_label": "Cyflymder rhagosodedig: ",
"preferences_quality_label": "Ansawdd fideos: ",
"preferences_quality_option_hd720": "HD720",
"preferences_quality_option_medium": "Canolig",
"preferences_quality_option_small": "Bach",
"preferences_quality_dash_option_2160p": "2160p",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_720p": "720p",
"invidious": "Invidious",
"Text CAPTCHA": "CAPTCHA testun",
"Image CAPTCHA": "CAPTCHA delwedd",
"preferences_continue_label": "Chwarae'r fideo nesaf fel rhagosodiad: ",
"preferences_continue_autoplay_label": "Chwarae'r fideo nesaf yn awtomatig: ",
"preferences_listen_label": "Sain yn unig: ",
"preferences_quality_dash_label": "Ansawdd fideos DASH a ffefrir: ",
"preferences_volume_label": "Uchder sain y chwaraeydd: ",
"preferences_category_visual": "Hoffterau'r wefan",
"preferences_region_label": "Gwlad y cynnwys: ",
"preferences_player_style_label": "Arddull y chwaraeydd: ",
"Dark mode: ": "Modd tywyll: ",
"preferences_thin_mode_label": "Modd tenau: ",
"preferences_category_misc": "Hoffterau amrywiol",
"preferences_category_subscription": "Hoffterau tanysgrifio",
"preferences_max_results_label": "Nifer o fideos a ddangosir yn eich ffrwd: ",
"alphabetically": "yr wyddor",
"alphabetically - reverse": "yr wyddor - am yn ôl",
"published - reverse": "dyddiad cyhoeddi - am yn ôl",
"channel name": "enw'r sianel",
"channel name - reverse": "enw'r sianel - am yn ôl",
"Only show latest video from channel: ": "Dangos fideo diweddaraf y sianeli rydych chi'n tanysgrifio iddynt: ",
"Only show latest unwatched video from channel: ": "Dangos fideo heb ei wylio diweddaraf y sianeli rydych chi'n tanysgrifio iddynt: ",
"Enable web notifications": "Galluogi hysbysiadau gwe",
"`x` uploaded a video": "uwchlwythodd `x` fideo",
"`x` is live": "mae `x` yn darlledu'n fyw",
"preferences_category_data": "Hoffterau data",
"Clear watch history": "Clirio'ch hanes gwylio",
"Change password": "Newid eich cyfrinair",
"Manage subscriptions": "Rheoli tanysgrifiadau",
"Manage tokens": "Rheoli tocynnau",
"Watch history": "Hanes gwylio",
"preferences_default_home_label": "Hafan ragosodedig: ",
"preferences_show_nick_label": "Dangos eich enw defnyddiwr ar frig y dudalen: ",
"preferences_annotations_label": "Dangos nodiadau fel rhagosodiad: ",
"preferences_unseen_only_label": "Dangos fideos heb eu gwylio yn unig: ",
"preferences_notifications_only_label": "Dangos hysbysiadau yn unig (os oes unrhyw rai): ",
"Token manager": "Rheolydd tocynnau",
"Token": "Tocyn",
"unsubscribe": "dad-danysgrifio",
"Subscriptions": "Tanysgrifiadau",
"Import/export": "Mewngofnodi/allgofnodi",
"search": "chwilio",
"Log out": "Allgofnodi",
"View privacy policy.": "Polisi preifatrwydd",
"Trending": "Pynciau llosg",
"Public": "Cyhoeddus",
"Private": "Preifat",
"Updated `x` ago": "Diweddarwyd `x` yn ôl",
"Delete playlist `x`?": "Ydych chi'n siŵr yr hoffech chi ddileu'r rhestr chwarae `x`?",
"Title": "Teitl",
"Playlist privacy": "Preifatrwydd y rhestr chwarae",
"search_message_use_another_instance": " Gallwch hefyd <a href=\"`x`\">chwilio ar weinydd arall</a>.",
"Popular enabled: ": "Tudalen fideos poblogaidd wedi'i galluogi: ",
"CAPTCHA enabled: ": "CAPTCHA wedi'i alluogi: ",
"Registration enabled: ": "Cofrestru wedi'i alluogi: ",
"Save preferences": "Cadw'r hoffterau",
"Subscription manager": "Rheolydd tanysgrifio",
"revoke": "tynnu",
"subscriptions_unseen_notifs_count_0": "{{count}} hysbysiad heb ei weld",
"subscriptions_unseen_notifs_count_1": "{{count}} hysbysiad heb ei weld",
"subscriptions_unseen_notifs_count_2": "{{count}} hysbysiad heb eu gweld",
"subscriptions_unseen_notifs_count_3": "{{count}} hysbysiad heb eu gweld",
"subscriptions_unseen_notifs_count_4": "{{count}} hysbysiad heb eu gweld",
"subscriptions_unseen_notifs_count_5": "{{count}} hysbysiad heb eu gweld",
"Released under the AGPLv3 on Github.": "Cyhoeddwyd dan drwydded AGPLv3 ar GitHub",
"Unlisted": "Heb ei restru",
"Switch Invidious Instance": "Newid gweinydd Invidious",
"Report statistics: ": "Galluogi ystadegau'r gweinydd: ",
"View all playlists": "Gweld pob rhestr chwarae",
"Editing playlist `x`": "Yn golygu'r rhestr chwarae `x`",
"Whitelisted regions: ": "Rhanbarthau a ganiateir: ",
"Blacklisted regions: ": "Rhanbarthau a rwystrir: ",
"Song: ": "Cân: ",
"Album: ": "Albwm: ",
"Shared `x`": "Rhannwyd `x`",
"View YouTube comments": "Dangos sylwadau YouTube",
"View more comments on Reddit": "Dangos rhagor o sylwadau ar Reddit",
"View Reddit comments": "Dangos sylwadau Reddit",
"Hide replies": "Cuddio ymatebion",
"Incorrect password": "Cyfrinair anghywir",
"Wrong answer": "Ateb anghywir",
"CAPTCHA is a required field": "Rhaid rhoi'r CAPTCHA",
"User ID is a required field": "Rhaid rhoi enw defnyddiwr",
"Password is a required field": "Rhaid rhoi cyfrinair",
"Wrong username or password": "Enw defnyddiwr neu gyfrinair anghywir",
"Password cannot be empty": "All y cyfrinair ddim bod yn wag",
"Password cannot be longer than 55 characters": "All y cyfrinair ddim bod yn hirach na 55 nod",
"Please log in": "Mewngofnodwch",
"channel:`x`": "sianel: `x`",
"Deleted or invalid channel": "Sianel wedi'i dileu neu'n annilys",
"Could not get channel info.": "Wedi methu llwytho gwybodaeth y sianel.",
"`x` ago": "`x` yn ôl",
"Load more": "Llwytho rhagor",
"Empty playlist": "Rhestr chwarae wag",
"Hide annotations": "Cuddio nodiadau",
"Show annotations": "Dangos nodiadau",
"Premieres in `x`": "Yn dechrau mewn `x`",
"Premieres `x`": "Yn dechrau `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.": "Helo! Mae'n ymddangos eich bod wedi diffodd JavaScript. Cliciwch yma i weld sylwadau, ond cofiwch y gall gymryd mwy o amser i'w llwytho.",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Gweld `x` sylw",
"": "Gweld `x` sylw"
},
"Could not create mix.": "Wedi methu creu'r cymysgiad hwn.",
"Erroneous token": "Tocyn annilys",
"No such user": "Dyw'r defnyddiwr hwn ddim yn bodoli",
"Token is expired, please try again": "Mae'r tocyn hwn wedi dod i ben, ceisiwch eto",
"Bangla": "Bangleg",
"Basque": "Basgeg",
"Bulgarian": "Bwlgareg",
"Catalan": "Catalaneg",
"Chinese": "Tsieineeg",
"Chinese (China)": "Tsieineeg (Tsieina)",
"Chinese (Hong Kong)": "Tsieineeg (Hong Kong)",
"Chinese (Taiwan)": "Tsieineeg (Taiwan)",
"Danish": "Daneg",
"Dutch": "Iseldireg",
"Esperanto": "Esperanteg",
"Finnish": "Ffinneg",
"French": "Ffrangeg",
"German": "Almaeneg",
"Greek": "Groeg",
"Could not pull trending pages.": "Wedi methu llwytho tudalennau pynciau llosg.",
"Hidden field \"challenge\" is a required field": "Mae'r maes cudd \"her\" yn ofynnol",
"Hidden field \"token\" is a required field": "Mae'r maes cudd \"tocyn\" yn ofynnol",
"Hebrew": "Hebraeg",
"Hungarian": "Hwngareg",
"Irish": "Gwyddeleg",
"Italian": "Eidaleg",
"Welsh": "Cymraeg",
"generic_count_hours_0": "{{count}} awr",
"generic_count_hours_1": "{{count}} awr",
"generic_count_hours_2": "{{count}} awr",
"generic_count_hours_3": "{{count}} awr",
"generic_count_hours_4": "{{count}} awr",
"generic_count_hours_5": "{{count}} awr",
"generic_count_minutes_0": "{{count}} munud",
"generic_count_minutes_1": "{{count}} munud",
"generic_count_minutes_2": "{{count}} funud",
"generic_count_minutes_3": "{{count}} munud",
"generic_count_minutes_4": "{{count}} o funudau",
"generic_count_minutes_5": "{{count}} o funudau",
"generic_count_weeks_0": "{{count}} wythnos",
"generic_count_weeks_1": "{{count}} wythnos",
"generic_count_weeks_2": "{{count}} wythnos",
"generic_count_weeks_3": "{{count}} wythnos",
"generic_count_weeks_4": "{{count}} wythnos",
"generic_count_weeks_5": "{{count}} wythnos",
"generic_count_seconds_0": "{{count}} eiliad",
"generic_count_seconds_1": "{{count}} eiliad",
"generic_count_seconds_2": "{{count}} eiliad",
"generic_count_seconds_3": "{{count}} eiliad",
"generic_count_seconds_4": "{{count}} o eiliadau",
"generic_count_seconds_5": "{{count}} o eiliadau",
"Fallback comments: ": "Sylwadau amgen: ",
"Popular": "Poblogaidd",
"preferences_locale_label": "Iaith: ",
"About": "Ynghylch",
"Search": "Chwilio",
"search_filters_features_option_c_commons": "Comin Creu",
"search_filters_features_option_subtitles": "Isdeitlau (CC)",
"search_filters_features_option_hd": "HD",
"permalink": "dolen barhaol",
"search_filters_duration_option_short": "Byr (< 4 munud)",
"search_filters_duration_option_none": "Unrhyw hyd",
"search_filters_duration_label": "Hyd",
"search_filters_type_option_show": "Rhaglen",
"search_filters_type_option_movie": "Ffilm",
"search_filters_type_option_playlist": "Rhestr chwarae",
"search_filters_type_option_channel": "Sianel",
"search_filters_type_option_video": "Fideo",
"search_filters_type_option_all": "Unrhyw fath",
"search_filters_date_option_week": "Yr wythnos hon",
"search_filters_date_option_today": "Heddiw",
"search_filters_date_option_hour": "Yr awr ddiwethaf",
"search_filters_date_option_none": "Unrhyw ddyddiad",
"search_filters_date_label": "Dyddiad uwchlwytho",
"search_filters_title": "Hidlyddion",
"Playlists": "Rhestrau chwarae",
"Video mode": "Modd fideo",
"Audio mode": "Modd sain",
"Channel Sponsor": "Noddwr y sianel",
"(edited)": "(golygwyd)",
"Download": "Islwytho",
"Movies": "Ffilmiau",
"News": "Newyddion",
"Gaming": "Gemau",
"Music": "Cerddoriaeth",
"Download is disabled": "Mae islwytho wedi'i analluogi",
"Download as: ": "Islwytho fel: ",
"View as playlist": "Gweld fel rhestr chwarae",
"Default": "Rhagosodiad",
"YouTube comment permalink": "Dolen barhaol i'r sylw ar YouTube",
"crash_page_before_reporting": "Cyn adrodd nam, sicrhewch eich bod wedi:",
"crash_page_search_issue": "<a href=\"`x`\">chwilio am y nam ar GitHub</a>",
"videoinfo_watch_on_youTube": "Gwylio ar YouTube",
"videoinfo_started_streaming_x_ago": "Yn ffrydio'n fyw ers `x` o funudau",
"videoinfo_invidious_embed_link": "Dolen mewnblannu",
"footer_documentation": "Dogfennaeth",
"footer_donate_page": "Rhoddi",
"Current version: ": "Fersiwn gyfredol: ",
"search_filters_apply_button": "Rhoi'r hidlyddion ar waith",
"search_filters_sort_option_date": "Dyddiad uwchlwytho",
"search_filters_sort_option_relevance": "Perthnasedd",
"search_filters_sort_label": "Trefnu yn ôl",
"search_filters_features_option_location": "Lleoliad",
"search_filters_features_option_hdr": "HDR",
"search_filters_features_option_three_d": "3D",
"search_filters_features_option_vr180": "VR180",
"search_filters_features_option_three_sixty": "360°",
"videoinfo_youTube_embed_link": "Mewnblannu",
"download_subtitles": "Isdeitlau - `x` (.vtt)",
"user_created_playlists": "`x` rhestr chwarae wedi'u creu",
"user_saved_playlists": "`x` rhestr chwarae wedi'u cadw",
"Video unavailable": "Fideo ddim ar gael",
"crash_page_you_found_a_bug": "Mae'n debyg eich bod wedi dod o hyd i nam yn Invidious!",
"channel_tab_channels_label": "Sianeli",
"channel_tab_community_label": "Cymuned",
"channel_tab_shorts_label": "Fideos byrion",
"channel_tab_videos_label": "Fideos"
}

View File

@ -21,7 +21,7 @@
"Import and Export Data": "Daten importieren und exportieren",
"Import": "Importieren",
"Import Invidious data": "Invidious-JSON-Daten importieren",
"Import YouTube subscriptions": "YouTube-/OPML-Abonnements importieren",
"Import YouTube subscriptions": "YouTube-CSV/OPML-Abonnements importieren",
"Import FreeTube subscriptions (.db)": "FreeTube Abonnements importieren (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe Abonnements importieren (.json)",
"Import NewPipe data (.zip)": "NewPipe Daten importieren (.zip)",

View File

@ -486,5 +486,8 @@
"Switch Invidious Instance": "Αλλαγή Instance Invidious",
"Standard YouTube license": "Τυπική άδεια YouTube",
"search_filters_duration_option_medium": "Μεσαία (4 - 20 λεπτά)",
"search_filters_date_label": "Ημερομηνία αναφόρτωσης"
"search_filters_date_label": "Ημερομηνία αναφόρτωσης",
"Search for videos": "Αναζήτηση βίντεο",
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
"Answer": "Απάντηση"
}

View File

@ -17,7 +17,7 @@
"View playlist on YouTube": "دیدن فهرست پخش در یوتیوب",
"newest": "تازه‌ترین",
"oldest": "کهنه‌ترین",
"popular": "محبوب",
"popular": "پرطرفدار",
"last": "آخرین",
"Next page": "صفحه بعد",
"Previous page": "صفحه قبل",
@ -31,7 +31,7 @@
"Import and Export Data": "درون‌برد و برون‌برد داده",
"Import": "درون‌برد",
"Import Invidious data": "وارد کردن داده JSON اینویدیوس",
"Import YouTube subscriptions": "وارد کردن اشتراک OPML/ یوتیوب",
"Import YouTube subscriptions": "وارد کردن فایل CSV یا OPML سابسکرایب های یوتیوب",
"Import FreeTube subscriptions (.db)": "درون‌برد اشتراک‌های فری‌تیوب (.db)",
"Import NewPipe subscriptions (.json)": "درون‌برد اشتراک‌های نیوپایپ (.json)",
"Import NewPipe data (.zip)": "درون‌برد داده نیوپایپ (.zip)",
@ -328,7 +328,7 @@
"generic_count_seconds": "{{count}} ثانیه",
"generic_count_seconds_plural": "{{count}} ثانیه",
"Fallback comments: ": "نظرات عقب گرد: ",
"Popular": "محبوب",
"Popular": "پربیننده",
"Search": "جست و جو",
"Top": "بالا",
"About": "درباره",
@ -484,5 +484,17 @@
"channel_tab_shorts_label": "Shortها",
"channel_tab_playlists_label": "فهرست‌های پخش",
"channel_tab_channels_label": "کانال‌ها",
"error_video_not_in_playlist": "ویدیوی درخواستی معلق به این فهرست پخش نیست. <a href=\"`x`\">کلیک کنید تا به صفحهٔ اصلی فهرست پخش بروید.</a>"
"error_video_not_in_playlist": "ویدیوی درخواستی معلق به این فهرست پخش نیست. <a href=\"`x`\">کلیک کنید تا به صفحهٔ اصلی فهرست پخش بروید.</a>",
"Add to playlist": "به لیست پخش افزوده شود",
"Answer": "پاسخ",
"Search for videos": "جست و جو برای ویدیوها",
"Add to playlist: ": "افزودن به لیست پخش ",
"The Popular feed has been disabled by the administrator.": "بخش ویدیوهای پرطرفدار توسط مدیر غیرفعال شده است.",
"carousel_slide": "اسلاید {{current}} از {{total}}",
"carousel_skip": "رد شدن از گرداننده",
"carousel_go_to": "به اسلاید `x` برو",
"crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>",
"crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:",
"channel_tab_releases_label": "آثار",
"toggle_theme": "تغییر وضعیت تم"
}

View File

@ -28,7 +28,7 @@
"Export": "Vie",
"Export subscriptions as OPML": "Vie tilaukset OPML-muodossa",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Vie tilaukset OPML-muodossa (NewPipe & FreeTube)",
"Export data as JSON": "Vie Invidious-data JSON-muodossa",
"Export data as JSON": "Vie Invidiousin tiedot JSON-muodossa",
"Delete account?": "Poista tili?",
"History": "Historia",
"An alternative front-end to YouTube": "Vaihtoehtoinen front-end YouTubelle",
@ -46,12 +46,12 @@
"E-mail": "Sähköposti",
"Preferences": "Asetukset",
"preferences_category_player": "Soittimen asetukset",
"preferences_video_loop_label": "Toista jatkuvasti aina: ",
"preferences_autoplay_label": "Automaattinen toisto: ",
"preferences_video_loop_label": "Toista aina uudelleen: ",
"preferences_autoplay_label": "Automaattinen toiston aloitus: ",
"preferences_continue_label": "Toista seuraava oletuksena: ",
"preferences_continue_autoplay_label": "Toista seuraava video automaattisesti: ",
"preferences_continue_autoplay_label": "Aloita seuraava video automaattisesti: ",
"preferences_listen_label": "Kuuntele oletuksena: ",
"preferences_local_label": "Proxytä videot: ",
"preferences_local_label": "Videot välityspalvelimen kautta: ",
"preferences_speed_label": "Oletusnopeus: ",
"preferences_quality_label": "Ensisijainen videon laatu: ",
"preferences_volume_label": "Soittimen äänenvoimakkuus: ",
@ -63,7 +63,7 @@
"preferences_related_videos_label": "Näytä aiheeseen liittyviä videoita: ",
"preferences_annotations_label": "Näytä huomautukset oletuksena: ",
"preferences_extend_desc_label": "Laajenna automaattisesti videon kuvausta: ",
"preferences_vr_mode_label": "Interaktiiviset 360-asteiset videot (vaatii WebGL:n): ",
"preferences_vr_mode_label": "Interaktiiviset 360-videot (vaatii WebGL:n): ",
"preferences_category_visual": "Visuaaliset asetukset",
"preferences_player_style_label": "Soittimen tyyli: ",
"Dark mode: ": "Tumma tila: ",
@ -137,9 +137,9 @@
"Show less": "Näytä vähemmän",
"Watch on YouTube": "Katso YouTubessa",
"Switch Invidious Instance": "Vaihda Invidious-instanssia",
"Hide annotations": "Piilota merkkaukset",
"Show annotations": "Näytä merkkaukset",
"Genre: ": "Genre: ",
"Hide annotations": "Piilota huomautukset",
"Show annotations": "Näytä huomautukset",
"Genre: ": "Tyylilaji: ",
"License: ": "Lisenssi: ",
"Family friendly? ": "Kaiken ikäisille sopiva? ",
"Wilson score: ": "Wilson-pistemäärä: ",
@ -168,7 +168,7 @@
"Wrong username or password": "Väärä käyttäjänimi tai salasana",
"Password cannot be empty": "Salasana ei voi olla tyhjä",
"Password cannot be longer than 55 characters": "Salasana ei voi olla yli 55 merkkiä pitkä",
"Please log in": "Kirjaudu sisään, ole hyvä",
"Please log in": "Kirjaudu sisään",
"Invidious Private Feed for `x`": "Invidiousin yksityinen syöte `x`:lle",
"channel:`x`": "kanava:`x`",
"Deleted or invalid channel": "Poistettu tai virheellinen kanava",
@ -178,7 +178,7 @@
"`x` ago": "`x` sitten",
"Load more": "Lataa lisää",
"Could not create mix.": "Sekoituksen luominen epäonnistui.",
"Empty playlist": "Tyhjennä soittolista",
"Empty playlist": "Tyhjä soittolista",
"Not a playlist.": "Ei ole soittolista.",
"Playlist does not exist.": "Soittolistaa ei ole olemassa.",
"Could not pull trending pages.": "Nousussa olevien sivujen lataus epäonnistui.",
@ -216,11 +216,11 @@
"Filipino": "filipino",
"Finnish": "suomi",
"French": "ranska",
"Galician": "galego",
"Galician": "galicia",
"Georgian": "georgia",
"German": "saksa",
"Greek": "kreikka",
"Gujarati": "gujarati",
"Gujarati": "guarati",
"Haitian Creole": "haitinkreoli",
"Hausa": "hausa",
"Hawaiian": "havaiji",
@ -327,11 +327,11 @@
"search_filters_duration_label": "Kesto",
"search_filters_features_label": "Ominaisuudet",
"search_filters_sort_label": "Luokittele",
"search_filters_date_option_hour": "Viimeisin tunti",
"search_filters_date_option_hour": "Tunnin sisään",
"search_filters_date_option_today": "Tänään",
"search_filters_date_option_week": "Tämä viikko",
"search_filters_date_option_month": "Tämä kuukausi",
"search_filters_date_option_year": "Tämä vuosi",
"search_filters_date_option_week": "Tällä viikolla",
"search_filters_date_option_month": "Tässä kuussa",
"search_filters_date_option_year": "Tänä vuonna",
"search_filters_type_option_video": "Video",
"search_filters_type_option_channel": "Kanava",
"search_filters_type_option_playlist": "Soittolista",
@ -346,7 +346,7 @@
"search_filters_features_option_location": "Sijainti",
"search_filters_features_option_hdr": "HDR",
"Current version: ": "Tämänhetkinen versio: ",
"next_steps_error_message": "Sinun tulisi kokeilla seuraavia: ",
"next_steps_error_message": "Kokeile seuraavia: ",
"next_steps_error_message_refresh": "Päivitä",
"next_steps_error_message_go_to_youtube": "Siirry YouTubeen",
"generic_count_hours": "{{count}} tunti",
@ -391,7 +391,7 @@
"subscriptions_unseen_notifs_count": "{{count}} näkemätön ilmoitus",
"subscriptions_unseen_notifs_count_plural": "{{count}} näkemätöntä ilmoitusta",
"crash_page_switch_instance": "yrittänyt <a href=\"`x`\">käyttää toista instassia</a>",
"videoinfo_invidious_embed_link": "Upotuslinkki",
"videoinfo_invidious_embed_link": "Upotettava linkki",
"user_saved_playlists": "`x` tallennetua soittolistaa",
"crash_page_report_issue": "Jos mikään näistä ei auttanut, <a href=\"`x`\">avaathan uuden issuen GitHubissa</a> (mieluiten englanniksi) ja sisällytät seuraavan tekstin viestissäsi (ÄLÄ käännä tätä tekstiä):",
"preferences_quality_option_hd720": "HD720",
@ -410,7 +410,7 @@
"preferences_quality_dash_option_auto": "Auto",
"preferences_quality_dash_option_best": "Paras",
"preferences_quality_option_dash": "DASH (mukautuva laatu)",
"preferences_quality_dash_label": "Haluttava DASH-videolaatu: ",
"preferences_quality_dash_label": "Ensisijainen DASH-videolaatu: ",
"generic_count_years": "{{count}} vuosi",
"generic_count_years_plural": "{{count}} vuotta",
"search_filters_features_option_purchased": "Ostettu",
@ -421,39 +421,39 @@
"preferences_save_player_pos_label": "Tallenna toistokohta: ",
"footer_donate_page": "Lahjoita",
"footer_source_code": "Lähdekoodi",
"adminprefs_modified_source_code_url_label": "URL muokattuun lähdekoodirepositoryyn",
"Released under the AGPLv3 on Github.": "Julkaistu AGPLv3-lisenssin alla GitHubissa.",
"adminprefs_modified_source_code_url_label": "URL muokatun lähdekoodin repositorioon",
"Released under the AGPLv3 on Github.": "Julkaistu AGPLv3-lisenssillä GitHubissa.",
"search_filters_duration_option_short": "Lyhyt (< 4 minuuttia)",
"search_filters_duration_option_long": "Pitkä (> 20 minuuttia)",
"footer_documentation": "Dokumentaatio",
"footer_original_source_code": "Alkuperäinen lähdekoodi",
"footer_modfied_source_code": "Muokattu lähdekoodi",
"Japanese (auto-generated)": "Japani (automaattisesti luotu)",
"German (auto-generated)": "Saksa (automaattisesti luotu)",
"Japanese (auto-generated)": "japani (automaattisesti luotu)",
"German (auto-generated)": "saksa (automaattisesti luotu)",
"Portuguese (auto-generated)": "portugali (automaattisesti luotu)",
"Russian (auto-generated)": "Venäjä (automaattisesti luotu)",
"preferences_watch_history_label": "Ota katseluhistoria käyttöön: ",
"English (United Kingdom)": "Englanti (Iso-Britannia)",
"English (United States)": "Englanti (Yhdysvallat)",
"Cantonese (Hong Kong)": "Kantoninkiina (Hong Kong)",
"Chinese": "Kiina",
"Chinese (China)": "Kiina (Kiina)",
"Chinese (Hong Kong)": "Kiina (Hong Kong)",
"Chinese (Taiwan)": "Kiina (Taiwan)",
"Dutch (auto-generated)": "Hollanti (automaattisesti luotu)",
"French (auto-generated)": "Ranska (automaattisesti luotu)",
"Indonesian (auto-generated)": "Indonesia (automaattisesti luotu)",
"Interlingue": "Interlingue",
"English (United Kingdom)": "englanti (Iso-Britannia)",
"English (United States)": "englanti (Yhdysvallat)",
"Cantonese (Hong Kong)": "kantoninkiina (Hongkong)",
"Chinese": "kiina",
"Chinese (China)": "kiina (Kiina)",
"Chinese (Hong Kong)": "kiina (Hongkong)",
"Chinese (Taiwan)": "kiina (Taiwan)",
"Dutch (auto-generated)": "hollanti (automaattisesti luotu)",
"French (auto-generated)": "ranska (automaattisesti luotu)",
"Indonesian (auto-generated)": "indonesia (automaattisesti luotu)",
"Interlingue": "interlingue",
"Italian (auto-generated)": "Italia (automaattisesti luotu)",
"Korean (auto-generated)": "Korea (automaattisesti luotu)",
"Korean (auto-generated)": "korea (automaattisesti luotu)",
"Portuguese (Brazil)": "portugali (Brasilia)",
"Spanish (auto-generated)": "Espanja (automaattisesti luotu)",
"Spanish (Mexico)": "Espanja (Meksiko)",
"Spanish (Spain)": "Espanja (Espanja)",
"Turkish (auto-generated)": "Turkki (automaattisesti luotu)",
"Vietnamese (auto-generated)": "Vietnam (automaattisesti luotu)",
"search_filters_title": "Suodatin",
"search_message_no_results": "Ei tuloksia löydetty.",
"Spanish (auto-generated)": "espanja (automaattisesti luotu)",
"Spanish (Mexico)": "espanja (Meksiko)",
"Spanish (Spain)": "espanja (Espanja)",
"Turkish (auto-generated)": "turkki (automaattisesti luotu)",
"Vietnamese (auto-generated)": "vietnam (automaattisesti luotu)",
"search_filters_title": "Suodattimet",
"search_message_no_results": "Tuloksia ei löytynyt.",
"search_message_change_filters_or_query": "Yritä hakukyselysi laajentamista ja/tai suodattimien muuttamista.",
"search_filters_duration_option_none": "Mikä tahansa kesto",
"search_filters_features_option_vr180": "VR180",
@ -464,5 +464,37 @@
"search_filters_date_option_none": "Milloin tahansa",
"search_filters_type_option_all": "Mikä tahansa tyyppi",
"Popular enabled: ": "Suosittu käytössä: ",
"error_video_not_in_playlist": "Pyydettyä videota ei löydy tästä soittolistasta. <a href=\"`x`\">Klikkaa tähän päästäksesi soittolistan etusivulle.</a>"
"error_video_not_in_playlist": "Pyydettyä videota ei ole tässä soittolistassa. <a href=\"`x`\">Klikkaa tästä päästäksesi soittolistan kotisivulle.</a>",
"Import YouTube playlist (.csv)": "Tuo YouTube-soittolista (.csv)",
"Music in this video": "Musiikki tässä videossa",
"Add to playlist": "Lisää soittolistaan",
"Add to playlist: ": "Lisää soittolistaan: ",
"Search for videos": "Etsi videoita",
"generic_button_rss": "RSS",
"Answer": "Vastaus",
"Standard YouTube license": "Vakio YouTube-lisenssi",
"Song: ": "Kappale: ",
"Album: ": "Albumi: ",
"Download is disabled": "Lataus on poistettu käytöstä",
"Channel Sponsor": "Kanavan sponsori",
"channel_tab_podcasts_label": "Podcastit",
"channel_tab_releases_label": "Julkaisut",
"channel_tab_shorts_label": "Shorts-videot",
"carousel_slide": "Dia {{current}}/{{total}}",
"carousel_skip": "Ohita karuselli",
"carousel_go_to": "Siirry diaan `x`",
"channel_tab_playlists_label": "Soittolistat",
"channel_tab_channels_label": "Kanavat",
"generic_button_delete": "Poista",
"generic_button_edit": "Muokkaa",
"generic_button_save": "Tallenna",
"generic_button_cancel": "Peru",
"playlist_button_add_items": "Lisää videoita",
"Artist: ": "Esittäjä: ",
"channel_tab_streams_label": "Suoratoistot",
"generic_channels_count": "{{count}} kanava",
"generic_channels_count_plural": "{{count}} kanavaa",
"The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.",
"Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)",
"toggle_theme": "Vaihda teemaa"
}

View File

@ -18,7 +18,7 @@
"generic_subscriptions_count_1": "{{count}} d'abonnements",
"generic_subscriptions_count_2": "{{count}} abonnements",
"generic_button_delete": "Supprimer",
"generic_button_edit": "Editer",
"generic_button_edit": "Modifier",
"generic_button_save": "Enregistrer",
"generic_button_cancel": "Annuler",
"generic_button_rss": "RSS",
@ -44,7 +44,7 @@
"Import and Export Data": "Importer et exporter des données",
"Import": "Importer",
"Import Invidious data": "Importer des données Invidious au format JSON",
"Import YouTube subscriptions": "Importer des abonnements YouTube/OPML",
"Import YouTube subscriptions": "Importer des abonnements YouTube aux formats OPML/CSV",
"Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
"Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
@ -504,5 +504,14 @@
"Import YouTube playlist (.csv)": "Importer des listes de lecture de Youtube (.csv)",
"channel_tab_releases_label": "Parutions",
"channel_tab_podcasts_label": "Émissions audio",
"Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)"
"Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)",
"Add to playlist: ": "Ajouter à la playlist: ",
"Add to playlist": "Ajouter à la playlist",
"Answer": "Répondre",
"Search for videos": "Rechercher des vidéos",
"The Popular feed has been disabled by the administrator.": "Le flux populaire a été désactivé par l'administrateur.",
"carousel_skip": "Passez le carrousel",
"carousel_slide": "Diapositive {{current}} sur {{total}}",
"carousel_go_to": "Aller à la diapositive `x`",
"toggle_theme": "Changer le Thème"
}

View File

@ -464,5 +464,23 @@
"search_filters_features_option_vr180": "180°-os virtuális valóság",
"search_filters_apply_button": "Keresés a megadott szűrőkkel",
"Popular enabled: ": "Népszerű engedélyezve ",
"error_video_not_in_playlist": "A lejátszási listában keresett videó nem létezik. <a href=\"`x`\">Kattintson ide a lejátszási listához jutáshoz.</a>"
"error_video_not_in_playlist": "A lejátszási listában keresett videó nem létezik. <a href=\"`x`\">Kattintson ide a lejátszási listához jutáshoz.</a>",
"generic_button_delete": "Törlés",
"generic_button_rss": "RSS",
"Import YouTube playlist (.csv)": "Youtube lejátszási lista (.csv) importálása",
"Standard YouTube license": "Alap YouTube-licensz",
"Add to playlist": "Hozzáadás lejátszási listához",
"Add to playlist: ": "Hozzáadás a lejátszási listához: ",
"Answer": "Válasz",
"Search for videos": "Keresés videókhoz",
"generic_channels_count": "{{count}} csatorna",
"generic_channels_count_plural": "{{count}} csatornák",
"generic_button_edit": "Szerkesztés",
"generic_button_save": "Mentés",
"generic_button_cancel": "Mégsem",
"playlist_button_add_items": "Videók hozzáadása",
"Music in this video": "Zene ezen videóban",
"Song: ": "Dal: ",
"Album: ": "Album: ",
"Import YouTube watch history (.json)": "Youtube megtekintési előzmények (.json) importálása"
}

View File

@ -1,39 +1,39 @@
{
"LIVE": "BEINT",
"Shared `x` ago": "Deilt `x` síðan",
"Shared `x` ago": "Deilt fyrir `x` síðan",
"Unsubscribe": "Afskrá",
"Subscribe": "Áskrifa",
"View channel on YouTube": "Skoða rás á YouTube",
"View playlist on YouTube": "Skoða spilunarlisti á YouTube",
"View playlist on YouTube": "Skoða spilunarlista á YouTube",
"newest": "nýjasta",
"oldest": "elsta",
"popular": "vinsælt",
"last": "síðast",
"Next page": "Næsta síða",
"Previous page": "Fyrri síða",
"Clear watch history?": "Hreinsa áhorfssögu?",
"Clear watch history?": "Hreinsa áhorfsferil?",
"New password": "Nýtt lykilorð",
"New passwords must match": "Nýtt lykilorð verður að passa",
"Authorize token?": "Leyfa tákn?",
"Authorize token for `x`?": "Leyfa tákn fyrir `x`?",
"Authorize token?": "Leyfa teikn?",
"Authorize token for `x`?": "Leyfa teikn fyrir `x`?",
"Yes": "Já",
"No": "Nei",
"Import and Export Data": "Innflutningur og Útflutningur Gagna",
"Import and Export Data": "Inn- og útflutningur gagna",
"Import": "Flytja inn",
"Import Invidious data": "Flytja inn Invidious gögn",
"Import YouTube subscriptions": "Flytja inn YouTube áskriftir",
"Import Invidious data": "Flytja inn Invidious JSON-gögn",
"Import YouTube subscriptions": "Flytja inn YouTube CSV eða OPML-áskriftir",
"Import FreeTube subscriptions (.db)": "Flytja inn FreeTube áskriftir (.db)",
"Import NewPipe subscriptions (.json)": "Flytja inn NewPipe áskriftir (.json)",
"Import NewPipe data (.zip)": "Flytja inn NewPipe gögn (.zip)",
"Export": "Flytja út",
"Export subscriptions as OPML": "Flytja út áskriftir sem OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Flytja út áskriftir sem OPML (fyrir NewPipe & FreeTube)",
"Export data as JSON": "Flytja út gögn sem JSON",
"Export data as JSON": "Flytja út Invidious-gögn sem JSON",
"Delete account?": "Eyða reikningi?",
"History": "Saga",
"An alternative front-end to YouTube": "Önnur framhlið fyrir YouTube",
"JavaScript license information": "JavaScript leyfi upplýsingar",
"source": "uppspretta",
"History": "Ferill",
"An alternative front-end to YouTube": "Annað viðmót fyrir YouTube",
"JavaScript license information": "Upplýsingar um notkunarleyfi JavaScript",
"source": "uppruni",
"Log in": "Skrá inn",
"Log in/register": "Innskráning/nýskráning",
"User ID": "Notandakenni",
@ -47,33 +47,33 @@
"Preferences": "Kjörstillingar",
"preferences_category_player": "Kjörstillingar spilara",
"preferences_video_loop_label": "Alltaf lykkja: ",
"preferences_autoplay_label": "Spila sjálfkrafa: ",
"preferences_autoplay_label": "Sjálfvirk spilun: ",
"preferences_continue_label": "Spila næst sjálfgefið: ",
"preferences_continue_autoplay_label": "Spila næst sjálfkrafa: ",
"preferences_continue_autoplay_label": "Spila næsta myndskeið sjálfkrafa: ",
"preferences_listen_label": "Hlusta sjálfgefið: ",
"preferences_local_label": "Proxy myndbönd? ",
"preferences_local_label": "Milliþjónn fyrir myndskeið: ",
"preferences_speed_label": "Sjálfgefinn hraði: ",
"preferences_quality_label": "Æskilegt myndbands gæði: ",
"preferences_quality_label": "Æskileg gæði myndmerkis: ",
"preferences_volume_label": "Spilara hljóðstyrkur: ",
"preferences_comments_label": "Sjálfgefin ummæli: ",
"youtube": "YouTube",
"reddit": "reddit",
"reddit": "Reddit",
"preferences_captions_label": "Sjálfgefin texti: ",
"Fallback captions: ": "Varatextar: ",
"preferences_related_videos_label": "Sýna tengd myndbönd? ",
"preferences_related_videos_label": "Sýna tengd myndskeið? ",
"preferences_annotations_label": "Á að sýna glósur sjálfgefið? ",
"preferences_category_visual": "Sjónrænar stillingar",
"preferences_player_style_label": "Spilara stíl: ",
"Dark mode: ": "Myrkur ham: ",
"preferences_player_style_label": "Stíll spilara: ",
"Dark mode: ": "Dökkur hamur: ",
"preferences_dark_mode_label": "Þema: ",
"dark": "dimmt",
"dark": "dökkt",
"light": "ljóst",
"preferences_thin_mode_label": "Þunnt ham: ",
"preferences_thin_mode_label": "Grannur hamur: ",
"preferences_category_subscription": "Áskriftarstillingar",
"preferences_annotations_subscribed_label": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ",
"Redirect homepage to feed: ": "Endurbeina heimasíðu að straumi: ",
"preferences_max_results_label": "Fjöldi myndbanda sem sýndir eru í straumi: ",
"preferences_sort_label": "Raða myndbönd eftir: ",
"Redirect homepage to feed: ": "Endurbeina heimasíðu að streymi: ",
"preferences_max_results_label": "Fjöldi myndskeiða sem sýnd eru í streymi: ",
"preferences_sort_label": "Raða myndskeiðum eftir: ",
"published": "birt",
"published - reverse": "birt - afturábak",
"alphabetically": "í stafrófsröð",
@ -88,31 +88,31 @@
"`x` uploaded a video": "`x` hlóð upp myndband",
"`x` is live": "`x` er í beinni",
"preferences_category_data": "Gagnastillingar",
"Clear watch history": "Hreinsa áhorfssögu",
"Clear watch history": "Hreinsa áhorfsferil",
"Import/export data": "Flytja inn/út gögn",
"Change password": "Breyta lykilorði",
"Manage subscriptions": "Stjórna áskriftum",
"Manage tokens": "Stjórna tákn",
"Watch history": "Áhorfssögu",
"Manage subscriptions": "Sýsla með áskriftir",
"Manage tokens": "Sýsla með teikn",
"Watch history": "Áhorfsferill",
"Delete account": "Eyða reikningi",
"preferences_category_admin": "Kjörstillingar stjórnanda",
"preferences_default_home_label": "Sjálfgefin heimasíða: ",
"preferences_feed_menu_label": "Straum valmynd: ",
"Top enabled: ": "Toppur virkur? ",
"preferences_feed_menu_label": "Streymisvalmynd: ",
"Top enabled: ": "Vinsælast virkt? ",
"CAPTCHA enabled: ": "CAPTCHA virk? ",
"Login enabled: ": "Innskráning virk? ",
"Registration enabled: ": "Nýskráning virkjuð? ",
"Report statistics: ": "Skrá talnagögn? ",
"Report statistics: ": "Skrá tölfræði? ",
"Save preferences": "Vista stillingar",
"Subscription manager": "Áskriftarstjóri",
"Token manager": "Táknstjóri",
"Token": "Tákn",
"Token manager": "Teiknastjórnun",
"Token": "Teikn",
"Import/export": "Flytja inn/út",
"unsubscribe": "afskrá",
"revoke": "afturkalla",
"Subscriptions": "Áskriftir",
"search": "leita",
"Log out": "Útskrá",
"Log out": "Skrá út",
"Source available here.": "Frumkóði aðgengilegur hér.",
"View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.",
"View privacy policy.": "Skoða meðferð persónuupplýsinga.",
@ -122,13 +122,13 @@
"Private": "Einka",
"View all playlists": "Skoða alla spilunarlista",
"Updated `x` ago": "Uppfært `x` síðann",
"Delete playlist `x`?": "Eiða spilunarlista `x`?",
"Delete playlist": "Eiða spilunarlista",
"Delete playlist `x`?": "Eyða spilunarlista `x`?",
"Delete playlist": "Eyða spilunarlista",
"Create playlist": "Búa til spilunarlista",
"Title": "Titill",
"Playlist privacy": "Spilunarlista opinberri",
"Editing playlist `x`": "Að breyta spilunarlista `x`",
"Watch on YouTube": "Horfa á YouTube",
"Playlist privacy": "Friðhelgi spilunarlista",
"Editing playlist `x`": "Breyti spilunarlista `x`",
"Watch on YouTube": "Skoða á YouTube",
"Hide annotations": "Fela glósur",
"Show annotations": "Sýna glósur",
"Genre: ": "Tegund: ",
@ -160,26 +160,26 @@
"Wrong username or password": "Rangt notandanafn eða lykilorð",
"Password cannot be empty": "Lykilorð má ekki vera autt",
"Password cannot be longer than 55 characters": "Lykilorð má ekki vera lengra en 55 stafir",
"Please log in": "Vinsamlegast skráðu þig inn",
"Invidious Private Feed for `x`": "Invidious Persónulegur Straumur fyrir `x`",
"Please log in": "Skráðu þig inn",
"Invidious Private Feed for `x`": "Persónulegt Invidious-streymi fyrir `x`",
"channel:`x`": "rás:`x`",
"Deleted or invalid channel": "Eytt eða ógild rás",
"This channel does not exist.": "Þessi rás er ekki til.",
"Could not get channel info.": "Ekki tókst að fá rásarupplýsingar.",
"Could not get channel info.": "Ekki tókst að fá upplýsingar um rásina.",
"Could not fetch comments": "Ekki tókst að sækja ummæli",
"`x` ago": "`x` síðan",
"Load more": "Hlaða meira",
"Could not create mix.": "Ekki tókst að búa til blöndu.",
"Empty playlist": "Tómur spilunarlisti",
"Not a playlist.": "Ekki spilunarlisti.",
"Not a playlist.": "Er ekki spilunarlisti.",
"Playlist does not exist.": "Spilunarlisti er ekki til.",
"Could not pull trending pages.": "Ekki tókst að draga vinsælar síður.",
"Hidden field \"challenge\" is a required field": "Falinn reitur \"áskorun\" er nauðsynlegur reitur",
"Hidden field \"token\" is a required field": "Falinn reitur \"tákn\" er nauðsynlegur reitur",
"Hidden field \"token\" is a required field": "Falinn reitur \"teikn\" er nauðsynlegur reitur",
"Erroneous challenge": "Röng áskorun",
"Erroneous token": "Rangt tákn",
"Erroneous token": "Rangt teikn",
"No such user": "Enginn slíkur notandi",
"Token is expired, please try again": "Tákn er útrunnið, vinsamlegast reyndu aftur",
"Token is expired, please try again": "Teiknið er útrunnið, reyndu aftur",
"English": "Enska",
"English (auto-generated)": "Enska (sjálfkrafa)",
"Afrikaans": "Afríkanska",
@ -267,14 +267,14 @@
"Somali": "Sómalska",
"Southern Sotho": "Suður Sótó",
"Spanish": "Spænska",
"Spanish (Latin America)": "Spænska (Rómönsku Ameríka)",
"Spanish (Latin America)": "Spænska (Rómanska Ameríka)",
"Sundanese": "Sundaneska",
"Swahili": "Svahílí",
"Swedish": "Sænska",
"Tajik": "Tadsikíska",
"Tamil": "Tamílska",
"Telugu": "Telúgú",
"Thai": "Tlenska",
"Thai": "Tælenska",
"Turkish": "Tyrkneska",
"Ukrainian": "Úkraníska",
"Urdu": "Úrdú",
@ -286,9 +286,9 @@
"Yiddish": "Jiddíska",
"Yoruba": "Jórúba",
"Zulu": "Zúlú",
"Fallback comments: ": "Vara ummæli: ",
"Fallback comments: ": "Ummæli til vara: ",
"Popular": "Vinsælt",
"Top": "Topp",
"Top": "Vinsælast",
"About": "Um",
"Rating: ": "Einkunn: ",
"preferences_locale_label": "Tungumál: ",
@ -307,9 +307,194 @@
"`x` marked it with a ❤": "`x` merkti það með ❤",
"Audio mode": "Hljóð ham",
"Video mode": "Myndband ham",
"channel_tab_videos_label": "Myndbönd",
"channel_tab_videos_label": "Myndskeið",
"Playlists": "Spilunarlistar",
"channel_tab_community_label": "Samfélag",
"Current version: ": "Núverandi útgáfa: ",
"preferences_watch_history_label": "Virkja áhorfssögu: "
"preferences_watch_history_label": "Virkja áhorfsferil: ",
"Chinese (China)": "Kínverska (Kína)",
"Turkish (auto-generated)": "Tyrkneska (sjálfvirkt útbúið)",
"Search": "Leita",
"preferences_save_player_pos_label": "Vista staðsetningu í afspilun: ",
"Popular enabled: ": "Vinsælt virkjað: ",
"search_filters_features_option_purchased": "Keypt",
"Standard YouTube license": "Staðlað YouTube-notkunarleyfi",
"French (auto-generated)": "Franska (sjálfvirkt útbúið)",
"Spanish (Spain)": "Spænska (Spánn)",
"search_filters_title": "Síur",
"search_filters_date_label": "Dags. innsendingar",
"search_filters_features_option_four_k": "4K",
"search_filters_features_option_hd": "HD",
"crash_page_read_the_faq": "lesið <a href=\"`x`\">Algengar spurningar (FAQ)</a>",
"Add to playlist": "Bæta á spilunarlista",
"Add to playlist: ": "Bæta á spilunarlista: ",
"Answer": "Svar",
"Search for videos": "Leita að myndskeiðum",
"generic_channels_count": "{{count}} rás",
"generic_channels_count_plural": "{{count}} rásir",
"generic_videos_count": "{{count}} myndskeið",
"generic_videos_count_plural": "{{count}} myndskeið",
"The Popular feed has been disabled by the administrator.": "Kerfisstjórinn hefur gert Vinsælt-streymið óvirkt.",
"generic_playlists_count": "{{count}} spilunarlisti",
"generic_playlists_count_plural": "{{count}} spilunarlistar",
"generic_subscribers_count": "{{count}} áskrifandi",
"generic_subscribers_count_plural": "{{count}} áskrifendur",
"generic_subscriptions_count": "{{count}} áskrift",
"generic_subscriptions_count_plural": "{{count}} áskriftir",
"generic_button_delete": "Eyða",
"Import YouTube watch history (.json)": "Flytja inn YouTube áhorfsferil (.json)",
"preferences_vr_mode_label": "Gagnvirk 360 gráðu myndskeið (krefst WebGL): ",
"preferences_quality_dash_option_auto": "Sjálfvirkt",
"preferences_quality_dash_option_best": "Best",
"preferences_quality_dash_option_worst": "Verst",
"preferences_quality_dash_label": "Æskileg DASH-gæði myndmerkis: ",
"preferences_extend_desc_label": "Sjálfvirkt útvíkka lýsingu á myndskeiði: ",
"preferences_region_label": "Land efnis: ",
"preferences_show_nick_label": "Birta gælunafn efst: ",
"tokens_count": "{{count}} teikn",
"tokens_count_plural": "{{count}} teikn",
"subscriptions_unseen_notifs_count": "{{count}} óskoðuð tilkynning",
"subscriptions_unseen_notifs_count_plural": "{{count}} óskoðaðar tilkynningar",
"Released under the AGPLv3 on Github.": "Gefið út með AGPLv3-notkunarleyfi á GitHub.",
"Music in this video": "Tónlist í þessu myndskeiði",
"Artist: ": "Flytjandi: ",
"Album: ": "Hljómplata: ",
"comments_view_x_replies": "Skoða {{count}} svar",
"comments_view_x_replies_plural": "Skoða {{count}} svör",
"comments_points_count": "{{count}} punktur",
"comments_points_count_plural": "{{count}} punktar",
"Cantonese (Hong Kong)": "Kantónska (Hong Kong)",
"Chinese": "Kínverska",
"Chinese (Hong Kong)": "Kínverska (Hong Kong)",
"Chinese (Taiwan)": "Kínverska (Taívan)",
"Japanese (auto-generated)": "Japanska (sjálfvirkt útbúið)",
"generic_count_minutes": "{{count}} mínúta",
"generic_count_minutes_plural": "{{count}} mínútur",
"generic_count_seconds": "{{count}} sekúnda",
"generic_count_seconds_plural": "{{count}} sekúndur",
"search_filters_date_option_hour": "Síðustu klukkustund",
"search_filters_apply_button": "Virkja valdar síur",
"next_steps_error_message_go_to_youtube": "Fara á YouTube",
"footer_original_source_code": "Upprunalegur grunnkóði",
"videoinfo_started_streaming_x_ago": "Byrjaði streymi fyrir `x` síðan",
"next_steps_error_message": "Á eftir þessu ættirðu að prófa: ",
"videoinfo_invidious_embed_link": "Ívefja tengil",
"download_subtitles": "Skjátextar - `x` (.vtt)",
"user_created_playlists": "`x` útbjó spilunarlista",
"user_saved_playlists": "`x` vistaði spilunarlista",
"Video unavailable": "Myndskeið ekki tiltækt",
"videoinfo_watch_on_youTube": "Skoða á YouTube",
"crash_page_you_found_a_bug": "Það lítur út eins og þú hafir fundið galla í Invidious!",
"crash_page_before_reporting": "Áður en þú tilkynnir villu, gakktu úr skugga um að þú hafir:",
"crash_page_switch_instance": "reynt að <a href=\"`x`\">nota annað tilvik</a>",
"crash_page_report_issue": "Ef ekkert af ofantöldu hjálpaði, ættirðu að <a href=\"`x`\">opna nýja verkbeiðni (issue) á GitHub</a> (helst á ensku) og láta fylgja eftirfarandi texta í skilaboðunum þínum (alls EKKI þýða þennan texta):",
"channel_tab_shorts_label": "Stuttmyndir",
"carousel_slide": "Skyggna {{current}} af {{total}}",
"carousel_go_to": "Fara á skyggnu `x`",
"channel_tab_streams_label": "Bein streymi",
"channel_tab_playlists_label": "Spilunarlistar",
"toggle_theme": "Víxla þema",
"carousel_skip": "Sleppa hringekjunni",
"preferences_quality_option_medium": "Miðlungs",
"search_message_use_another_instance": " Þú getur líka <a href=\"`x`\">leitað á öðrum netþjóni</a>.",
"footer_source_code": "Grunnkóði",
"English (United Kingdom)": "Enska (Bretland)",
"English (United States)": "Enska (Bandarísk)",
"Vietnamese (auto-generated)": "Víetnamska (sjálfvirkt útbúið)",
"generic_count_months": "{{count}} mánuður",
"generic_count_months_plural": "{{count}} mánuðir",
"search_filters_sort_option_rating": "Einkunn",
"videoinfo_youTube_embed_link": "Ívefja",
"error_video_not_in_playlist": "Umbeðið myndskeið fyrirfinnst ekki í þessum spilunarlista. <a href=\"`x`\">Smelltu hér til að fara á heimasíðu spilunarlistans.</a>",
"generic_views_count": "{{count}} áhorf",
"generic_views_count_plural": "{{count}} áhorf",
"playlist_button_add_items": "Bæta við myndskeiðum",
"Show more": "Sýna meira",
"Show less": "Sýna minna",
"Song: ": "Lag: ",
"channel_tab_podcasts_label": "Hlaðvörp (podcasts)",
"channel_tab_releases_label": "Útgáfur",
"Download is disabled": "Niðurhal er óvirkt",
"search_filters_features_option_location": "Staðsetning",
"preferences_quality_dash_option_720p": "720p",
"Switch Invidious Instance": "Skipta um Invidious-tilvik",
"search_message_no_results": "Engar niðurstöður fundust.",
"search_message_change_filters_or_query": "Reyndu að víkka leitarsviðið og/eða breyta síunum.",
"Dutch (auto-generated)": "Hollenska (sjálfvirkt útbúið)",
"German (auto-generated)": "Þýska (sjálfvirkt útbúið)",
"Indonesian (auto-generated)": "Indónesíska (sjálfvirkt útbúið)",
"Interlingue": "Interlingue",
"Italian (auto-generated)": "Ítalska (sjálfvirkt útbúið)",
"Russian (auto-generated)": "Rússneska (sjálfvirkt útbúið)",
"Spanish (auto-generated)": "Spænska (sjálfvirkt útbúið)",
"Spanish (Mexico)": "Spænska (Mexíkó)",
"generic_count_hours": "{{count}} klukkustund",
"generic_count_hours_plural": "{{count}} klukkustundir",
"generic_count_years": "{{count}} ár",
"generic_count_years_plural": "{{count}} ár",
"generic_count_weeks": "{{count}} vika",
"generic_count_weeks_plural": "{{count}} vikur",
"search_filters_date_option_none": "Hvaða dagsetning sem er",
"Channel Sponsor": "Styrktaraðili rásar",
"search_filters_date_option_week": "Í þessari viku",
"search_filters_date_option_month": "Í þessum mánuði",
"search_filters_date_option_year": "Á þessu ári",
"search_filters_type_option_playlist": "Spilunarlisti",
"search_filters_type_option_show": "Þáttur",
"search_filters_duration_label": "Tímalengd",
"search_filters_duration_option_long": "Langt (> 20 mínútur)",
"search_filters_features_option_live": "Beint",
"search_filters_features_option_three_sixty": "360°",
"search_filters_features_option_vr180": "VR180",
"search_filters_features_option_three_d": "3D",
"search_filters_features_option_hdr": "HDR",
"search_filters_sort_label": "Raða eftir",
"search_filters_sort_option_relevance": "Samsvörun",
"footer_donate_page": "Styrkja",
"footer_modfied_source_code": "Breyttur grunnkóði",
"crash_page_refresh": "reynt að <a href=\"`x`\">endurlesa síðuna</a>",
"crash_page_search_issue": "leitað að <a href=\"`x`\">fyrirliggjandi villum á GitHub</a>",
"none": "ekkert",
"adminprefs_modified_source_code_url_label": "Slóð á gagnasafn með breyttum grunnkóða",
"preferences_quality_option_hd720": "HD720",
"preferences_quality_option_small": "Lítið",
"preferences_category_misc": "Ýmsar kjörstillingar",
"preferences_automatic_instance_redirect_label": "Sjálfvirk endurbeining tilvika (farið til vara á redirect.invidious.io): ",
"Portuguese (auto-generated)": "Portúgalska (sjálfvirkt útbúið)",
"Portuguese (Brazil)": "Portúgalska (Brasilía)",
"generic_button_edit": "Breyta",
"generic_button_save": "Vista",
"generic_button_cancel": "Hætta við",
"generic_button_rss": "RSS",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_option_2160p": "2160p",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_480p": "480p",
"preferences_quality_dash_option_360p": "360p",
"preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p",
"invidious": "Invidious",
"Korean (auto-generated)": "Kóreska (sjálfvirkt útbúið)",
"generic_count_days": "{{count}} dagur",
"generic_count_days_plural": "{{count}} dagar",
"search_filters_date_option_today": "Í dag",
"search_filters_type_label": "Tegund",
"search_filters_type_option_all": "Hvaða tegund sem er",
"search_filters_type_option_video": "Myndskeið",
"search_filters_type_option_channel": "Rás",
"search_filters_type_option_movie": "Kvikmynd",
"search_filters_duration_option_none": "Hvaða lengd sem er",
"search_filters_duration_option_short": "Stutt (< 4 mínútur)",
"search_filters_duration_option_medium": "Miðlungs (4 - 20 mínútur)",
"search_filters_features_label": "Eiginleikar",
"search_filters_features_option_subtitles": "Skjátextar/CC",
"search_filters_features_option_c_commons": "Creative Commons",
"search_filters_sort_option_date": "Dags. innsendingar",
"search_filters_sort_option_views": "Fjöldi áhorfa",
"next_steps_error_message_refresh": "Endurlesa",
"footer_documentation": "Leiðbeiningar",
"channel_tab_channels_label": "Rásir",
"Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
"preferences_quality_option_dash": "DASH (aðlaganleg gæði)"
}

View File

@ -30,7 +30,7 @@
"Import and Export Data": "Importazione ed esportazione dati",
"Import": "Importa",
"Import Invidious data": "Importa dati Invidious in formato JSON",
"Import YouTube subscriptions": "Importa le iscrizioni da YouTube/OPML",
"Import YouTube subscriptions": "Importa iscrizioni in CSV o OPML di YouTube",
"Import FreeTube subscriptions (.db)": "Importa le iscrizioni da FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importa le iscrizioni da NewPipe (.json)",
"Import NewPipe data (.zip)": "Importa i dati di NewPipe (.zip)",

View File

@ -12,14 +12,14 @@
"Dark mode: ": "다크 모드: ",
"preferences_player_style_label": "플레이어 스타일: ",
"preferences_category_visual": "환경 설정",
"preferences_vr_mode_label": "VR 영상 활성화(WebGL 필요): ",
"preferences_extend_desc_label": "자동으로 비디오 설명을 확장: ",
"preferences_vr_mode_label": "360도 영상 활성화 (WebGL 필요): ",
"preferences_extend_desc_label": "자동으로 비디오 설명 펼치기: ",
"preferences_annotations_label": "기본으로 주석 표시: ",
"preferences_related_videos_label": "관련 동영상 보기: ",
"Fallback captions: ": "대체 자막: ",
"preferences_captions_label": "기본 자막: ",
"reddit": "레딧",
"youtube": "유튜브",
"reddit": "Reddit",
"youtube": "YouTube",
"preferences_comments_label": "기본 댓글: ",
"preferences_volume_label": "플레이어 볼륨: ",
"preferences_quality_label": "선호하는 비디오 품질: ",
@ -65,23 +65,23 @@
"Authorize token?": "토큰을 승인하시겠습니까?",
"New passwords must match": "새 비밀번호는 일치해야 합니다",
"New password": "새 비밀번호",
"Clear watch history?": "재생 기록을 삭제 하시겠습니까?",
"Clear watch history?": "시청 기록을 지우시겠습니까?",
"Previous page": "이전 페이지",
"Next page": "다음 페이지",
"last": "마지막",
"Shared `x` ago": "`x` 전",
"popular": "인기",
"oldest": "오래된순",
"oldest": "과거순",
"newest": "최신순",
"View playlist on YouTube": "유튜브에서 재생목록 보기",
"View channel on YouTube": "유튜브에서 채널 보기",
"Subscribe": "구독",
"Unsubscribe": "구독 취소",
"LIVE": "실시간",
"generic_views_count_0": "{{count}}",
"generic_videos_count_0": "{{count}} 동영상",
"generic_playlists_count_0": "{{count}} 재생목록",
"generic_subscribers_count_0": "{{count}} 구독자",
"generic_views_count_0": "조회수 {{count}}회",
"generic_videos_count_0": "동영상 {{count}}개",
"generic_playlists_count_0": "재생목록 {{count}}개",
"generic_subscribers_count_0": "구독자 {{count}}명",
"generic_subscriptions_count_0": "{{count}} 구독",
"search_filters_type_option_playlist": "재생목록",
"Korean": "한국어",
@ -109,23 +109,23 @@
"This channel does not exist.": "이 채널은 존재하지 않습니다.",
"Deleted or invalid channel": "삭제되었거나 더 이상 존재하지 않는 채널",
"channel:`x`": "채널:`x`",
"Show replies": "댓글 보기",
"Show replies": "댓글 보기",
"Hide replies": "댓글 숨기기",
"Incorrect password": "잘못된 비밀번호",
"License: ": "라이선스: ",
"Genre: ": "장르: ",
"Editing playlist `x`": "재생목록 `x` 수정하기",
"Playlist privacy": "재생목록 공개 범위",
"Watch on YouTube": "유튜브에서 보기",
"Watch on YouTube": "YouTube에서 보기",
"Show less": "간략히",
"Show more": "더보기",
"Title": "제목",
"Create playlist": "재생목록 생성",
"Trending": "급상승",
"Delete playlist": "재생목록 삭제",
"Delete playlist `x`?": "재생목록 `x` 를 삭제 하시겠습니까?",
"Delete playlist `x`?": "재생목록 `x` 를 삭제하시겠습니까?",
"Updated `x` ago": "`x` 전에 업데이트됨",
"Released under the AGPLv3 on Github.": "깃허브에 AGPLv3 으로 배포됩니다.",
"Released under the AGPLv3 on Github.": "GitHub에 AGPLv3 으로 배포됩니다.",
"View all playlists": "모든 재생목록 보기",
"Private": "비공개",
"Unlisted": "목록에 없음",
@ -135,12 +135,12 @@
"Source available here.": "소스는 여기에서 사용할 수 있습니다.",
"Log out": "로그아웃",
"search": "검색",
"subscriptions_unseen_notifs_count_0": "{{count}} 읽지 않은 알림",
"subscriptions_unseen_notifs_count_0": "읽지 않은 알림 {{count}}개",
"Subscriptions": "구독",
"revoke": "철회",
"unsubscribe": "구독 취소",
"Import/export": "가져오기/내보내기",
"tokens_count_0": "{{count}} 토큰",
"tokens_count_0": "토큰 {{count}}개",
"Token": "토큰",
"Token manager": "토큰 관리자",
"Subscription manager": "구독 관리자",
@ -163,7 +163,7 @@
"Clear watch history": "시청 기록 지우기",
"preferences_category_data": "데이터 설정",
"`x` is live": "`x` 이(가) 라이브 중입니다",
"`x` uploaded a video": "`x` 동영상 게시됨",
"`x` uploaded a video": "`x` 이(가) 동영상을 게시했습니다",
"Enable web notifications": "웹 알림 활성화",
"preferences_notifications_only_label": "알림만 표시 (있는 경우): ",
"preferences_unseen_only_label": "시청하지 않은 것만 표시: ",
@ -241,7 +241,7 @@
"Could not create mix.": "믹스를 생성할 수 없습니다.",
"`x` ago": "`x` 전",
"comments_view_x_replies_0": "답글 {{count}}개 보기",
"View Reddit comments": "레딧 댓글 보기",
"View Reddit comments": "Reddit 댓글 보기",
"Engagement: ": "약속: ",
"Wilson score: ": "Wilson Score: ",
"Family friendly? ": "전연령 영상입니까? ",
@ -267,8 +267,8 @@
"Bulgarian": "불가리아어",
"Bosnian": "보스니아어",
"Belarusian": "벨라루스어",
"View more comments on Reddit": "레딧에서 더 많은 댓글 보기",
"View YouTube comments": "유튜브 댓글 보기",
"View more comments on Reddit": "Reddit에서 댓글 더 보기",
"View YouTube comments": "YouTube 댓글 보기",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "자바스크립트가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.",
"Shared `x`": "`x` 업로드",
"Whitelisted regions: ": "차단되지 않은 지역: ",
@ -289,7 +289,7 @@
"Empty playlist": "재생목록 비어 있음",
"Show annotations": "주석 보이기",
"Hide annotations": "주석 숨기기",
"Switch Invidious Instance": "인비디어스 인스턴스 변경",
"Switch Invidious Instance": "Invidious 인스턴스 변경",
"Spanish": "스페인어",
"Southern Sotho": "소토어",
"Somali": "소말리어",
@ -329,7 +329,7 @@
"Swedish": "스웨덴어",
"Spanish (Latin America)": "스페인어 (라틴 아메리카)",
"comments_points_count_0": "{{count}} 포인트",
"Invidious Private Feed for `x`": "`x` 에 대한 인비디어스 비공개 피드",
"Invidious Private Feed for `x`": "`x` 에 대한 Invidious 비공개 피드",
"Premieres `x`": "최초 공개 `x`",
"Premieres in `x`": "`x` 후 최초 공개",
"next_steps_error_message": "다음 방법을 시도해 보세요: ",
@ -408,7 +408,7 @@
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_worst": "최저",
"preferences_watch_history_label": "시청 기록 저장: ",
"invidious": "인비디어스",
"invidious": "Invidious",
"preferences_quality_option_small": "낮음",
"preferences_quality_dash_option_auto": "자동",
"preferences_quality_dash_option_480p": "480p",
@ -419,7 +419,7 @@
"Portuguese (Brazil)": "포르투갈어 (브라질)",
"search_message_no_results": "결과가 없습니다.",
"search_message_change_filters_or_query": "필터를 변경하시거나 검색어를 넓게 시도해보세요.",
"search_message_use_another_instance": " 당신은 <a href=\"`x`\">다른 인스턴스에서 검색</a>할 수도 있습니다.",
"search_message_use_another_instance": " <a href=\"`x`\">다른 인스턴스에서 검색</a>할 수도 있습니다.",
"English (United States)": "영어 (미국)",
"Chinese": "중국어",
"Chinese (China)": "중국어 (중국)",
@ -453,7 +453,7 @@
"channel_tab_streams_label": "실시간 스트리밍",
"channel_tab_channels_label": "채널",
"channel_tab_playlists_label": "재생목록",
"Standard YouTube license": "표준 유튜브 라이선스",
"Standard YouTube license": "표준 YouTube 라이선스",
"Song: ": "제목: ",
"Channel Sponsor": "채널 스폰서",
"Album: ": "앨범: ",

View File

@ -21,7 +21,7 @@
"Import and Export Data": "Importer- og eksporter data",
"Import": "Importer",
"Import Invidious data": "Importer Invidious-JSON-data",
"Import YouTube subscriptions": "Importer YouTube/OPML-abonnementer",
"Import YouTube subscriptions": "Importer YouTube CSV eller OPML-abonnementer",
"Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnementer (.db)",
"Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnementer (.json)",
"Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)",
@ -487,5 +487,12 @@
"playlist_button_add_items": "Legg til videoer",
"generic_channels_count": "{{count}} kanal",
"generic_channels_count_plural": "{{count}} kanaler",
"Import YouTube watch history (.json)": "Importere YouTube visningshistorikk (.json)"
"Import YouTube watch history (.json)": "Importere YouTube visningshistorikk (.json)",
"carousel_go_to": "Gå til lysark `x`",
"Search for videos": "Søk i videoer",
"Answer": "Svar",
"carousel_slide": "Lysark {{current}} av {{total}}",
"carousel_skip": "Hopp over karusellen",
"Add to playlist": "Legg til i spilleliste",
"Add to playlist: ": "Legg til i spilleliste: "
}

View File

@ -21,7 +21,7 @@
"Import and Export Data": "Gegevens im- en exporteren",
"Import": "Importeren",
"Import Invidious data": "JSON-gegevens Invidious importeren",
"Import YouTube subscriptions": "YouTube-/OPML-abonnementen importeren",
"Import YouTube subscriptions": "YouTube CVS of OPML-abonnementen importeren",
"Import FreeTube subscriptions (.db)": "FreeTube-abonnementen importeren (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe-abonnementen importeren (.json)",
"Import NewPipe data (.zip)": "NewPipe-gegevens importeren (.zip)",
@ -86,7 +86,7 @@
"Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ",
"preferences_unseen_only_label": "Alleen niet-bekeken videos tonen: ",
"preferences_notifications_only_label": "Alleen meldingen tonen (als die er zijn): ",
"Enable web notifications": "Systemmeldingen inschakelen",
"Enable web notifications": "Systeemmeldingen inschakelen",
"`x` uploaded a video": "`x` heeft een video geüpload",
"`x` is live": "`x` zendt nu live uit",
"preferences_category_data": "Gegevensinstellingen",
@ -192,15 +192,15 @@
"Arabic": "Arabisch",
"Armenian": "Armeens",
"Azerbaijani": "Azerbeidzjaans",
"Bangla": "Bangla",
"Bangla": "Bengaals",
"Basque": "Baskisch",
"Belarusian": "Wit-Rrussisch",
"Belarusian": "Wit-Russisch",
"Bosnian": "Bosnisch",
"Bulgarian": "Bulgaars",
"Burmese": "Birmaans",
"Catalan": "Catalaans",
"Cebuano": "Cebuano",
"Chinese (Simplified)": "Chinees (Veereenvoudigd)",
"Cebuano": "Cebuaans",
"Chinese (Simplified)": "Chinees (Vereenvoudigd)",
"Chinese (Traditional)": "Chinees (Traditioneel)",
"Corsican": "Corsicaans",
"Croatian": "Kroatisch",
@ -217,23 +217,23 @@
"German": "Duits",
"Greek": "Grieks",
"Gujarati": "Gujarati",
"Haitian Creole": "Creools",
"Haitian Creole": "Haïtiaans Creools",
"Hausa": "Hausa",
"Hawaiian": "Hawaïaans",
"Hebrew": "Heebreeuws",
"Hebrew": "Hebreeuws",
"Hindi": "Hindi",
"Hmong": "Hmong",
"Hungarian": "Hongaars",
"Icelandic": "IJslands",
"Igbo": "Igbo",
"Igbo": "Ikbo",
"Indonesian": "Indonesisch",
"Irish": "Iers",
"Italian": "Italiaans",
"Japanese": "Japans",
"Javanese": "Javaans",
"Kannada": "Kannada",
"Kannada": "Kannada-taal",
"Kazakh": "Kazachs",
"Khmer": "Khmer",
"Khmer": "Khmer-taal",
"Korean": "Koreaans",
"Kurdish": "Koerdisch",
"Kyrgyz": "Kirgizisch",
@ -245,10 +245,10 @@
"Macedonian": "Macedonisch",
"Malagasy": "Malagassisch",
"Malay": "Maleisisch",
"Malayalam": "Malayalam",
"Malayalam": "Malayalam-taal",
"Maltese": "Maltees",
"Maori": "Maorisch",
"Marathi": "Marathi",
"Marathi": "Marathi-taal",
"Mongolian": "Mongools",
"Nepali": "Nepalees",
"Norwegian Bokmål": "Noors (Bokmål)",
@ -309,7 +309,7 @@
"(edited)": "(bewerkt)",
"YouTube comment permalink": "Link naar YouTube-reactie",
"permalink": "permalink",
"`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤",
"`x` marked it with a ❤": "`x` heeft dit gemarkeerd met een ❤",
"Audio mode": "Audiomodus",
"Video mode": "Videomodus",
"channel_tab_videos_label": "Video's",
@ -396,7 +396,7 @@
"Dutch (auto-generated)": "Nederlands (automatisch gegenereerd)",
"tokens_count": "{{count}} token",
"tokens_count_plural": "{{count}} tokens",
"generic_count_seconds": "{{count}} second",
"generic_count_seconds": "{{count}} seconde",
"generic_count_seconds_plural": "{{count}} seconden",
"generic_count_weeks": "{{count}} week",
"generic_count_weeks_plural": "{{count}} weken",
@ -449,7 +449,7 @@
"generic_playlists_count_plural": "{{count}} afspeellijsten",
"Chinese (Hong Kong)": "Chinees (Hongkong)",
"Korean (auto-generated)": "Koreaans (automatisch gegenereerd)",
"search_filters_apply_button": "Geselecteerd filters toepassen",
"search_filters_apply_button": "Geselecteerde filters toepassen",
"search_message_use_another_instance": " Je kan ook <a href=\"`x`\">zoeken op een andere instantie</a>.",
"Cantonese (Hong Kong)": "Kantonees (Hongkong)",
"Chinese (China)": "Chinees (China)",

View File

@ -41,7 +41,7 @@
"Time (h:mm:ss):": "Hora (h:mm:ss):",
"Text CAPTCHA": "Mudar para um desafio de texto",
"Image CAPTCHA": "Mudar para um desafio visual",
"Sign In": "Entrar",
"Sign In": "Fazer login",
"Register": "Criar conta",
"E-mail": "E-mail",
"Preferences": "Preferências",

View File

@ -253,7 +253,7 @@
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
"Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
"Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
"Import YouTube subscriptions": "Importar subscrições via YouTube/OPML",
"Import YouTube subscriptions": "Importar via YouTube csv ou subscrição OPML",
"Import Invidious data": "Importar dados JSON do Invidious",
"Import": "Importar",
"No": "Não",

View File

@ -21,7 +21,7 @@
"Import and Export Data": "Импорт и экспорт данных",
"Import": "Импорт",
"Import Invidious data": "Импортировать JSON с данными Invidious",
"Import YouTube subscriptions": "Импортировать подписки из YouTube/OPML",
"Import YouTube subscriptions": "Импортировать подписки из CSV или OPML",
"Import FreeTube subscriptions (.db)": "Импортировать подписки из FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Импортировать подписки из NewPipe (.json)",
"Import NewPipe data (.zip)": "Импортировать данные из NewPipe (.zip)",
@ -504,5 +504,11 @@
"generic_channels_count_0": "{{count}} канал",
"generic_channels_count_1": "{{count}} канала",
"generic_channels_count_2": "{{count}} каналов",
"Import YouTube watch history (.json)": "Импортировать историю просмотра из YouTube (.json)"
"Import YouTube watch history (.json)": "Импортировать историю просмотра из YouTube (.json)",
"Add to playlist": "Добавить в плейлист",
"Add to playlist: ": "Добавить в плейлист: ",
"Answer": "Ответить",
"Search for videos": "Поиск видео",
"The Popular feed has been disabled by the administrator.": "Популярная лента была отключена администратором.",
"toggle_theme": "Переключатель тем"
}

View File

@ -174,7 +174,7 @@
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hej! Izgleda da ste isključili JavaScript. Kliknite ovde da biste videli komentare, imajte na umu da će možda potrajati malo duže da se učitaju.",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Pogledaj `x` komentar",
"": "Pogledaj`x` komentare"
"": "Pogledaj`x` komentara"
},
"View Reddit comments": "Pogledaj Reddit komentare",
"CAPTCHA is a required field": "CAPTCHA je obavezno polje",
@ -211,7 +211,7 @@
"About": "O sajtu",
"footer_source_code": "Izvorni kôd",
"footer_original_source_code": "Originalni izvorni kôd",
"preferences_related_videos_label": "Prikaži povezane video snimke: ",
"preferences_related_videos_label": "Prikaži srodne video snimke: ",
"preferences_annotations_label": "Podrazumevano prikaži napomene: ",
"preferences_extend_desc_label": "Automatski proširi opis video snimka: ",
"preferences_vr_mode_label": "Interaktivni video snimci od 360 stepeni (zahteva WebGl): ",

View File

@ -60,7 +60,7 @@
"reddit": "Reddit",
"preferences_captions_label": "Подразумевани титлови: ",
"Fallback captions: ": "Резервни титлови: ",
"preferences_related_videos_label": "Прикажи повезане видео снимке: ",
"preferences_related_videos_label": "Прикажи сродне видео снимке: ",
"preferences_annotations_label": "Подразумевано прикажи напомене: ",
"preferences_category_visual": "Визуелна подешавања",
"preferences_player_style_label": "Стил плејера: ",
@ -246,7 +246,7 @@
"preferences_locale_label": "Језик: ",
"Persian": "Персијски",
"View `x` comments": {
"": "Погледај `x` коментаре",
"": "Погледај `x` коментара",
"([^.,0-9]|^)1([^.,0-9]|$)": "Погледај `x` коментар"
},
"search_filters_type_option_channel": "Канал",

View File

@ -21,7 +21,7 @@
"Import and Export Data": "Importera och exportera data",
"Import": "Importera",
"Import Invidious data": "Importera Invidious JSON data",
"Import YouTube subscriptions": "Importera YouTube/OPML prenumerationer",
"Import YouTube subscriptions": "Importera YouTube CSV eller OPML prenumerationer",
"Import FreeTube subscriptions (.db)": "Importera FreeTube-prenumerationer (.db)",
"Import NewPipe subscriptions (.json)": "Importera NewPipe-prenumerationer (.json)",
"Import NewPipe data (.zip)": "Importera NewPipe-data (.zip)",

View File

@ -21,7 +21,7 @@
"Import and Export Data": "Імпорт і експорт даних",
"Import": "Імпорт",
"Import Invidious data": "Імпортувати JSON-дані Invidious",
"Import YouTube subscriptions": "Імпортувати підписки з YouTube чи OPML",
"Import YouTube subscriptions": "Імпортувати підписки YouTube з CSV чи OPML",
"Import FreeTube subscriptions (.db)": "Імпортувати підписки з FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Імпортувати підписки з NewPipe (.json)",
"Import NewPipe data (.zip)": "Імпортувати дані з NewPipe (.zip)",

View File

@ -2,7 +2,7 @@ version: 2.0
shards:
ameba:
git: https://github.com/crystal-ameba/ameba.git
version: 1.5.0
version: 1.6.1
athena-negotiation:
git: https://github.com/athena-framework/negotiation.git

View File

@ -35,7 +35,7 @@ development_dependencies:
version: ~> 0.10.4
ameba:
github: crystal-ameba/ameba
version: ~> 1.5.0
version: ~> 1.6.1
crystal: ">= 1.0.0, < 2.0.0"

View File

@ -301,7 +301,6 @@ Spectator.describe Invidious::Search::Filters do
it "Encodes features filter (single)" do
Invidious::Search::Filters::Features.each do |value|
string = described_class.format_features(value)
filters = described_class.new(features: value)
expect("#{filters.to_iv_params}")

View File

@ -67,7 +67,7 @@ Spectator.describe "parse_video_info" do
# Video metadata
expect(info["genre"].as_s).to eq("Entertainment")
expect(info["genreUcid"].as_s).to be_empty
expect(info["genreUcid"].as_s?).to be_nil
expect(info["license"].as_s).to be_empty
# Author infos
@ -151,7 +151,7 @@ Spectator.describe "parse_video_info" do
# Video metadata
expect(info["genre"].as_s).to eq("Music")
expect(info["genreUcid"].as_s).to be_empty
expect(info["genreUcid"].as_s?).to be_nil
expect(info["license"].as_s).to be_empty
# Author infos

View File

@ -94,7 +94,7 @@ Spectator.describe "parse_video_info" do
# Video metadata
expect(info["genre"].as_s).to eq("Entertainment")
expect(info["genreUcid"].as_s).to be_empty
expect(info["genreUcid"].as_s?).to be_nil
expect(info["license"].as_s).to be_empty
# Author infos

View File

@ -153,6 +153,15 @@ Invidious::Database.check_integrity(CONFIG)
{% puts "\nDone checking player dependencies, now compiling Invidious...\n" %}
{% end %}
# Misc
DECRYPT_FUNCTION =
if sig_helper_address = CONFIG.signature_server.presence
IV::DecryptFunction.new(sig_helper_address)
else
nil
end
# Start jobs
if CONFIG.channel_threads > 0
@ -163,11 +172,6 @@ if CONFIG.feed_threads > 0
Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB)
end
DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling)
if CONFIG.decrypt_polling
Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new
end
if CONFIG.statistics_enabled
Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE)
end

View File

@ -15,7 +15,8 @@ record AboutChannel,
allowed_regions : Array(String),
tabs : Array(String),
tags : Array(String),
verified : Bool
verified : Bool,
is_age_gated : Bool
def get_about_info(ucid, locale) : AboutChannel
begin
@ -45,45 +46,102 @@ def get_about_info(ucid, locale) : AboutChannel
end
tags = [] of String
tab_names = [] of String
total_views = 0_i64
joined = Time.unix(0)
if auto_generated
author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s
# Raises a KeyError on failure.
banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
banner = banners.try &.[-1]?.try &.["url"].as_s?
description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
# some channels have the description in a simpleText
# ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/
description_node = description_base_node.dig?("simpleText") || description_base_node
tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges")
.try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String
if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer")
description_node = nil
author = age_gate_renderer["channelTitle"].as_s
ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s
author_url = "https://www.youtube.com/channel/#{ucid}"
author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s
banner = nil
is_family_friendly = false
is_age_gated = true
tab_names = ["videos", "shorts", "streams"]
auto_generated = false
else
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
if auto_generated
author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s
ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
# Raises a KeyError on failure.
banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
banner = banners.try &.[-1]?.try &.["url"].as_s?
# Raises a KeyError on failure.
banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
banner = banners.try &.[-1]?.try &.["url"].as_s?
description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
# some channels have the description in a simpleText
# ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/
description_node = description_base_node.dig?("simpleText") || description_base_node
# if banner.includes? "channels/c4/default_banner"
# banner = nil
# end
tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges")
.try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String
else
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String
ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
# Raises a KeyError on failure.
banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources")
banner = banners.try &.[-1]?.try &.["url"].as_s?
# if banner.includes? "channels/c4/default_banner"
# banner = nil
# end
description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String
end
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
# Get the name of the tabs available on this channel
tab_names = tabs_json.as_a.compact_map do |entry|
name = entry.dig?("tabRenderer", "title").try &.as_s.downcase
# This is a small fix to not add extra code on the HTML side
# I.e, the URL for the "live" tab is .../streams, so use "streams"
# everywhere for the sake of simplicity
(name == "live") ? "streams" : name
end
# Get the currently active tab ("About")
about_tab = extract_selected_tab(tabs_json)
# Try to find the about metadata section
channel_about_meta = about_tab.dig?(
"content",
"sectionListRenderer", "contents", 0,
"itemSectionRenderer", "contents", 0,
"channelAboutFullMetadataRenderer"
)
if !channel_about_meta.nil?
total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64
# The joined text is split to several sub strings. The reduce joins those strings before parsing the date.
joined = extract_text(channel_about_meta["joinedDateText"]?)
.try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
# Normal Auto-generated channels
# https://support.google.com/youtube/answer/2579942
# For auto-generated channels, channel_about_meta only has
# ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"]
auto_generated = (
(channel_about_meta["primaryLinks"]?.try &.size) == 1 && \
extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" ||
channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube"
)
end
end
end
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
allowed_regions = initdata
.dig?("microformat", "microformatDataRenderer", "availableCountries")
.try &.as_a.map(&.as_s) || [] of String
@ -101,56 +159,18 @@ def get_about_info(ucid, locale) : AboutChannel
end
end
total_views = 0_i64
joined = Time.unix(0)
sub_count = 0
tab_names = [] of String
if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
# Get the name of the tabs available on this channel
tab_names = tabs_json.as_a.compact_map do |entry|
name = entry.dig?("tabRenderer", "title").try &.as_s.downcase
# This is a small fix to not add extra code on the HTML side
# I.e, the URL for the "live" tab is .../streams, so use "streams"
# everywhere for the sake of simplicity
(name == "live") ? "streams" : name
end
# Get the currently active tab ("About")
about_tab = extract_selected_tab(tabs_json)
# Try to find the about metadata section
channel_about_meta = about_tab.dig?(
"content",
"sectionListRenderer", "contents", 0,
"itemSectionRenderer", "contents", 0,
"channelAboutFullMetadataRenderer"
)
if !channel_about_meta.nil?
total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64
# The joined text is split to several sub strings. The reduce joins those strings before parsing the date.
joined = extract_text(channel_about_meta["joinedDateText"]?)
.try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
# Normal Auto-generated channels
# https://support.google.com/youtube/answer/2579942
# For auto-generated channels, channel_about_meta only has
# ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"]
auto_generated = (
(channel_about_meta["primaryLinks"]?.try &.size) == 1 && \
extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" ||
channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube"
)
if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a)
metadata_rows.each do |row|
metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") }
if !metadata_part.nil?
sub_count = short_text_to_number(metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32
end
break if sub_count != 0
end
end
sub_count = initdata
.dig?("header", "c4TabbedHeaderRenderer", "subscriberCountText", "simpleText").try &.as_s?
.try { |text| short_text_to_number(text.split(" ")[0]).to_i32 } || 0
AboutChannel.new(
ucid: ucid,
author: author,
@ -168,6 +188,7 @@ def get_about_info(ucid, locale) : AboutChannel
tabs: tab_names,
tags: tags,
verified: author_verified || false,
is_age_gated: is_age_gated || false,
)
end

View File

@ -232,7 +232,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
id: video_id,
title: title,
published: published,
updated: Time.utc,
updated: updated,
ucid: ucid,
author: author,
length_seconds: length_seconds,

View File

@ -1,4 +1,4 @@
def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
def produce_channel_content_continuation(ucid, content_type, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
object_inner_2 = {
"2:0:embedded" => {
"1:0:varint" => 0_i64,
@ -16,6 +16,13 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
content_type_numerical =
case content_type
when "videos" then 15
when "livestreams" then 14
else 15 # Fallback to "videos"
end
sort_by_numerical =
case sort_by
when "newest" then 1_i64
@ -27,7 +34,7 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so
object_inner_1 = {
"110:embedded" => {
"3:embedded" => {
"15:embedded" => {
"#{content_type_numerical}:embedded" => {
"1:embedded" => {
"1:string" => object_inner_2_encoded,
},
@ -62,6 +69,10 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so
return continuation
end
def make_initial_content_ctoken(ucid, content_type, sort_by) : String
return produce_channel_content_continuation(ucid, content_type, sort_by: sort_by)
end
module Invidious::Channel::Tabs
extend self
@ -69,10 +80,6 @@ module Invidious::Channel::Tabs
# Regular videos
# -------------------
def make_initial_video_ctoken(ucid, sort_by) : String
return produce_channel_videos_continuation(ucid, sort_by: sort_by)
end
# Wrapper for AboutChannel, as we still need to call get_videos with
# an author name and ucid directly (e.g in RSS feeds).
# TODO: figure out how to get rid of that
@ -94,7 +101,7 @@ module Invidious::Channel::Tabs
end
def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest")
continuation ||= make_initial_video_ctoken(ucid, sort_by)
continuation ||= make_initial_content_ctoken(ucid, "videos", sort_by)
initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, author, ucid)
@ -138,21 +145,18 @@ module Invidious::Channel::Tabs
# Livestreams
# -------------------
def get_livestreams(channel : AboutChannel, continuation : String? = nil)
if continuation.nil?
# EgdzdHJlYW1z8gYECgJ6AA%3D%3D is the protobuf object to load "streams"
initial_data = YoutubeAPI.browse(channel.ucid, params: "EgdzdHJlYW1z8gYECgJ6AA%3D%3D")
else
initial_data = YoutubeAPI.browse(continuation: continuation)
end
def get_livestreams(channel : AboutChannel, continuation : String? = nil, sort_by = "newest")
continuation ||= make_initial_content_ctoken(channel.ucid, "livestreams", sort_by)
initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, channel.author, channel.ucid)
end
def get_60_livestreams(channel : AboutChannel, continuation : String? = nil)
def get_60_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest")
if continuation.nil?
# Fetch the first "page" of streams
items, next_continuation = get_livestreams(channel)
# Fetch the first "page" of stream
items, next_continuation = get_livestreams(channel, sort_by: sort_by)
else
# Fetch a "page" of streams using the given continuation token
items, next_continuation = get_livestreams(channel, continuation: continuation)

View File

@ -5,35 +5,35 @@ def text_to_parsed_content(text : String) : JSON::Any
# In first case line is just a simple node before
# check patterns inside line
# { 'text': line }
currentNodes = [] of JSON::Any
initialNode = {"text" => line}
currentNodes << (JSON.parse(initialNode.to_json))
current_nodes = [] of JSON::Any
initial_node = {"text" => line}
current_nodes << (JSON.parse(initial_node.to_json))
# For each match with url pattern, get last node and preserve
# last node before create new node with url information
# { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } }
line.scan(/https?:\/\/[^ ]*/).each do |urlMatch|
line.scan(/https?:\/\/[^ ]*/).each do |url_match|
# Retrieve last node and update node without match
lastNode = currentNodes[currentNodes.size - 1].as_h
splittedLastNode = lastNode["text"].as_s.split(urlMatch[0])
lastNode["text"] = JSON.parse(splittedLastNode[0].to_json)
currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json)
last_node = current_nodes[-1].as_h
splitted_last_node = last_node["text"].as_s.split(url_match[0])
last_node["text"] = JSON.parse(splitted_last_node[0].to_json)
current_nodes[-1] = JSON.parse(last_node.to_json)
# Create new node with match and navigation infos
currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}}
currentNodes << (JSON.parse(currentNode.to_json))
current_node = {"text" => url_match[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => url_match[0]}}}
current_nodes << (JSON.parse(current_node.to_json))
# If text remain after match create new simple node with text after match
afterNode = {"text" => splittedLastNode.size > 1 ? splittedLastNode[1] : ""}
currentNodes << (JSON.parse(afterNode.to_json))
after_node = {"text" => splitted_last_node.size > 1 ? splitted_last_node[1] : ""}
current_nodes << (JSON.parse(after_node.to_json))
end
# After processing of matches inside line
# Add \n at end of last node for preserve carriage return
lastNode = currentNodes[currentNodes.size - 1].as_h
lastNode["text"] = JSON.parse("#{currentNodes[currentNodes.size - 1]["text"]}\n".to_json)
currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json)
last_node = current_nodes[-1].as_h
last_node["text"] = JSON.parse("#{last_node["text"]}\n".to_json)
current_nodes[-1] = JSON.parse(last_node.to_json)
# Finally add final nodes to nodes returned
currentNodes.each do |node|
current_nodes.each do |node|
nodes << (node)
end
end
@ -53,8 +53,8 @@ def content_to_comment_html(content, video_id : String? = "")
text = HTML.escape(run["text"].as_s)
if navigationEndpoint = run.dig?("navigationEndpoint")
text = parse_link_endpoint(navigationEndpoint, text, video_id)
if navigation_endpoint = run.dig?("navigationEndpoint")
text = parse_link_endpoint(navigation_endpoint, text, video_id)
end
text = "<b>#{text}</b>" if run["bold"]?

View File

@ -74,8 +74,6 @@ class Config
# Database configuration using 12-Factor "Database URL" syntax
@[YAML::Field(converter: Preferences::URIConverter)]
property database_url : URI = URI.parse("")
# Use polling to keep decryption function up to date
property decrypt_polling : Bool = false
# Used for crawling channels: threads should check all videos uploaded by a channel
property full_refresh : Bool = false
@ -120,6 +118,10 @@ class Config
# Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
@[YAML::Field(converter: Preferences::FamilyConverter)]
property force_resolve : Socket::Family = Socket::Family::UNSPEC
# External signature solver server socket (either a path to a UNIX domain socket or "<IP>:<Port>")
property signature_server : String? = nil
# Port to listen for connections (overridden by command line argument)
property port : Int32 = 3000
# Host to bind (overridden by command line argument)
@ -130,6 +132,11 @@ class Config
# Use Innertube's transcripts API instead of timedtext for closed captions
property use_innertube_for_captions : Bool = false
# visitor data ID for Google session
property visitor_data : String? = nil
# poToken for passing bot attestation
property po_token : String? = nil
# Saved cookies in "name1=value1; name2=value2..." format
@[YAML::Field(converter: Preferences::StringToCookies)]
property cookies : HTTP::Cookies = HTTP::Cookies.new

View File

@ -140,6 +140,7 @@ module Invidious::Database::Playlists
request = <<-SQL
SELECT id,title FROM playlists
WHERE author = $1 AND id LIKE 'IV%'
ORDER BY title
SQL
PG_DB.query_all(request, email, as: {String, String})

View File

@ -149,12 +149,12 @@ module Invidious::Frontend::Comments
if comments["videoId"]?
html << <<-END_HTML
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
<a rel="noreferrer noopener" href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
elsif comments["authorId"]?
html << <<-END_HTML
<a href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
<a rel="noreferrer noopener" href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
end

View File

@ -6,9 +6,9 @@ module Invidious::Frontend::Misc
if prefs.automatic_instance_redirect
current_page = env.get?("current_page").as(String)
redirect_url = "/redirect?referer=#{current_page}"
return "/redirect?referer=#{current_page}"
else
redirect_url = "https://redirect.invidious.io#{env.request.resource}"
return "https://redirect.invidious.io#{env.request.resource}"
end
end
end

View File

@ -3,9 +3,9 @@
# IPv6 addresses.
#
class TCPSocket
def initialize(host : String, port, dns_timeout = nil, connect_timeout = nil, family = Socket::Family::UNSPEC)
def initialize(host, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC)
Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo|
super(addrinfo.family, addrinfo.type, addrinfo.protocol)
super(addrinfo.family, addrinfo.type, addrinfo.protocol, blocking)
connect(addrinfo, timeout: connect_timeout) do |error|
close
error
@ -26,7 +26,7 @@ class HTTP::Client
end
hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host
io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, @family
io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, family: @family
io.read_timeout = @read_timeout if @read_timeout
io.write_timeout = @write_timeout if @write_timeout
io.sync = false
@ -35,7 +35,7 @@ class HTTP::Client
if tls = @tls
tcp_socket = io
begin
io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host)
io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host.rchop('.'))
rescue exc
# don't leak the TCP socket when the SSL connection failed
tcp_socket.close

View File

@ -190,7 +190,7 @@ def error_redirect_helper(env : HTTP::Server::Context)
<a href="/redirect?referer=#{env.get("current_page")}">#{switch_instance}</a>
</li>
<li>
<a href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
<a rel="noreferrer noopener" href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
</li>
</ul>
END_HTML

View File

@ -97,7 +97,7 @@ class AuthHandler < Kemal::Handler
if token = env.request.headers["Authorization"]?
token = JSON.parse(URI.decode_www_form(token.lchop("Bearer ")))
session = URI.decode_www_form(token["session"].as_s)
scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, nil)
scopes, _, _ = validate_request(token, session, env.request, HMAC_KEY, nil)
if email = Invidious::Database::SessionIDs.select_email(session)
user = Invidious::Database::Users.select!(email: email)

View File

@ -95,7 +95,6 @@ module I18next::Plurals
"hr" => PluralForms::Special_Hungarian_Serbian,
"it" => PluralForms::Special_Spanish_Italian,
"pt" => PluralForms::Special_French_Portuguese,
"pt" => PluralForms::Special_French_Portuguese,
"sr" => PluralForms::Special_Hungarian_Serbian,
}
@ -189,7 +188,7 @@ module I18next::Plurals
# Emulate the `rule.numbers.size == 2 && rule.numbers[0] == 1` check
# from original i18next code
private def is_simple_plural(form : PluralForms) : Bool
private def simple_plural?(form : PluralForms) : Bool
case form
when .single_gt_one? then return true
when .single_not_one? then return true
@ -211,7 +210,7 @@ module I18next::Plurals
idx = SuffixIndex.get_index(plural_form, count)
# Simple plurals are handled differently in all versions (but v4)
if @simplify_plural_suffix && is_simple_plural(plural_form)
if @simplify_plural_suffix && simple_plural?(plural_form)
return (idx == 1) ? "_plural" : ""
end
@ -262,9 +261,9 @@ module I18next::Plurals
when .special_hebrew? then return special_hebrew(count)
when .special_odia? then return special_odia(count)
# Mixed v3/v4 forms
when .special_spanish_italian? then return special_cldr_Spanish_Italian(count)
when .special_french_portuguese? then return special_cldr_French_Portuguese(count)
when .special_hungarian_serbian? then return special_cldr_Hungarian_Serbian(count)
when .special_spanish_italian? then return special_cldr_spanish_italian(count)
when .special_french_portuguese? then return special_cldr_french_portuguese(count)
when .special_hungarian_serbian? then return special_cldr_hungarian_serbian(count)
else
# default, if nothing matched above
return 0_u8
@ -535,7 +534,7 @@ module I18next::Plurals
#
# This rule is mostly compliant to CLDR v42
#
def self.special_cldr_Spanish_Italian(count : Int) : UInt8
def self.special_cldr_spanish_italian(count : Int) : UInt8
return 0_u8 if (count == 1) # one
return 1_u8 if (count != 0 && count % 1_000_000 == 0) # many
return 2_u8 # other
@ -545,7 +544,7 @@ module I18next::Plurals
#
# This rule is mostly compliant to CLDR v42
#
def self.special_cldr_French_Portuguese(count : Int) : UInt8
def self.special_cldr_french_portuguese(count : Int) : UInt8
return 0_u8 if (count == 0 || count == 1) # one
return 1_u8 if (count % 1_000_000 == 0) # many
return 2_u8 # other
@ -555,7 +554,7 @@ module I18next::Plurals
#
# This rule is mostly compliant to CLDR v42
#
def self.special_cldr_Hungarian_Serbian(count : Int) : UInt8
def self.special_cldr_hungarian_serbian(count : Int) : UInt8
n_mod_10 = count % 10
n_mod_100 = count % 100

View File

@ -34,24 +34,11 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
context
end
def puts(message : String)
@io << message << '\n'
@io.flush
end
def write(message : String)
@io << message
@io.flush
end
def set_log_level(level : String)
@level = LogLevel.parse(level)
end
def set_log_level(level : LogLevel)
@level = level
end
{% for level in %w(trace debug info warn error fatal) %}
def {{level.id}}(message : String)
if LogLevel::{{level.id.capitalize}} >= @level

View File

@ -90,7 +90,7 @@ struct SearchVideo
json.field "lengthSeconds", self.length_seconds
json.field "liveNow", self.live_now
json.field "premium", self.premium
json.field "isUpcoming", self.is_upcoming
json.field "isUpcoming", self.upcoming?
if self.premiere_timestamp
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
@ -109,7 +109,7 @@ struct SearchVideo
to_json(nil, json)
end
def is_upcoming
def upcoming?
premiere_timestamp ? true : false
end
end

View File

@ -0,0 +1,332 @@
require "uri"
require "socket"
require "socket/tcp_socket"
require "socket/unix_socket"
{% if flag?(:advanced_debug) %}
require "io/hexdump"
{% end %}
private alias NetworkEndian = IO::ByteFormat::NetworkEndian
module Invidious::SigHelper
enum UpdateStatus
Updated
UpdateNotRequired
Error
end
# -------------------
# Payload types
# -------------------
abstract struct Payload
end
struct StringPayload < Payload
getter string : String
def initialize(str : String)
raise Exception.new("SigHelper: String can't be empty") if str.empty?
@string = str
end
def self.from_bytes(slice : Bytes)
size = IO::ByteFormat::NetworkEndian.decode(UInt16, slice)
if size == 0 # Error code
raise Exception.new("SigHelper: Server encountered an error")
end
if (slice.bytesize - 2) != size
raise Exception.new("SigHelper: String size mismatch")
end
if str = String.new(slice[2..])
return self.new(str)
else
raise Exception.new("SigHelper: Can't read string from socket")
end
end
def to_io(io)
# `.to_u16` raises if there is an overflow during the conversion
io.write_bytes(@string.bytesize.to_u16, NetworkEndian)
io.write(@string.to_slice)
end
end
private enum Opcode
FORCE_UPDATE = 0
DECRYPT_N_SIGNATURE = 1
DECRYPT_SIGNATURE = 2
GET_SIGNATURE_TIMESTAMP = 3
GET_PLAYER_STATUS = 4
PLAYER_UPDATE_TIMESTAMP = 5
end
private record Request,
opcode : Opcode,
payload : Payload?
# ----------------------
# High-level functions
# ----------------------
class Client
@mux : Multiplexor
def initialize(uri_or_path)
@mux = Multiplexor.new(uri_or_path)
end
# Forces the server to re-fetch the YouTube player, and extract the necessary
# components from it (nsig function code, sig function code, signature timestamp).
def force_update : UpdateStatus
request = Request.new(Opcode::FORCE_UPDATE, nil)
value = send_request(request) do |bytes|
IO::ByteFormat::NetworkEndian.decode(UInt16, bytes)
end
case value
when 0x0000 then return UpdateStatus::Error
when 0xFFFF then return UpdateStatus::UpdateNotRequired
when 0xF44F then return UpdateStatus::Updated
else
code = value.nil? ? "nil" : value.to_s(base: 16)
raise Exception.new("SigHelper: Invalid status code received #{code}")
end
end
# Decrypt a provided n signature using the server's current nsig function
# code, and return the result (or an error).
def decrypt_n_param(n : String) : String?
request = Request.new(Opcode::DECRYPT_N_SIGNATURE, StringPayload.new(n))
n_dec = self.send_request(request) do |bytes|
StringPayload.from_bytes(bytes).string
end
return n_dec
end
# Decrypt a provided s signature using the server's current sig function
# code, and return the result (or an error).
def decrypt_sig(sig : String) : String?
request = Request.new(Opcode::DECRYPT_SIGNATURE, StringPayload.new(sig))
sig_dec = self.send_request(request) do |bytes|
StringPayload.from_bytes(bytes).string
end
return sig_dec
end
# Return the signature timestamp from the server's current player
def get_signature_timestamp : UInt64?
request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil)
return self.send_request(request) do |bytes|
IO::ByteFormat::NetworkEndian.decode(UInt64, bytes)
end
end
# Return the current player's version
def get_player : UInt32?
request = Request.new(Opcode::GET_PLAYER_STATUS, nil)
return self.send_request(request) do |bytes|
has_player = (bytes[0] == 0xFF)
player_version = IO::ByteFormat::NetworkEndian.decode(UInt32, bytes[1..4])
has_player ? player_version : nil
end
end
# Return when the player was last updated
def get_player_timestamp : UInt64?
request = Request.new(Opcode::PLAYER_UPDATE_TIMESTAMP, nil)
return self.send_request(request) do |bytes|
IO::ByteFormat::NetworkEndian.decode(UInt64, bytes)
end
end
private def send_request(request : Request, &)
channel = @mux.send(request)
slice = channel.receive
return yield slice
rescue ex
LOGGER.debug("SigHelper: Error when sending a request")
LOGGER.trace(ex.inspect_with_backtrace)
return nil
end
end
# ---------------------
# Low level functions
# ---------------------
class Multiplexor
alias TransactionID = UInt32
record Transaction, channel = ::Channel(Bytes).new
@prng = Random.new
@mutex = Mutex.new
@queue = {} of TransactionID => Transaction
@conn : Connection
def initialize(uri_or_path)
@conn = Connection.new(uri_or_path)
listen
end
def listen : Nil
raise "Socket is closed" if @conn.closed?
LOGGER.debug("SigHelper: Multiplexor listening")
# TODO: reopen socket if unexpectedly closed
spawn do
loop do
receive_data
Fiber.yield
end
end
end
def send(request : Request)
transaction = Transaction.new
transaction_id = @prng.rand(TransactionID)
# Add transaction to queue
@mutex.synchronize do
# On a 32-bits random integer, this should never happen. Though, just in case, ...
if @queue[transaction_id]?
raise Exception.new("SigHelper: Duplicate transaction ID! You got a shiny pokemon!")
end
@queue[transaction_id] = transaction
end
write_packet(transaction_id, request)
return transaction.channel
end
def receive_data
transaction_id, slice = read_packet
@mutex.synchronize do
if transaction = @queue.delete(transaction_id)
# Remove transaction from queue and send data to the channel
transaction.channel.send(slice)
LOGGER.trace("SigHelper: Transaction unqueued and data sent to channel")
else
raise Exception.new("SigHelper: Received transaction was not in queue")
end
end
end
# Read a single packet from the socket
private def read_packet : {TransactionID, Bytes}
# Header
transaction_id = @conn.read_bytes(UInt32, NetworkEndian)
length = @conn.read_bytes(UInt32, NetworkEndian)
LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} / length #{length}")
if length > 67_000
raise Exception.new("SigHelper: Packet longer than expected (#{length})")
end
# Payload
slice = Bytes.new(length)
@conn.read(slice) if length > 0
LOGGER.trace("SigHelper: payload = #{slice}")
LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} - Done")
return transaction_id, slice
end
# Write a single packet to the socket
private def write_packet(transaction_id : TransactionID, request : Request)
LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} / opcode #{request.opcode}")
io = IO::Memory.new(1024)
io.write_bytes(request.opcode.to_u8, NetworkEndian)
io.write_bytes(transaction_id, NetworkEndian)
if payload = request.payload
payload.to_io(io)
end
@conn.send(io)
@conn.flush
LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} - Done")
end
end
class Connection
@socket : UNIXSocket | TCPSocket
{% if flag?(:advanced_debug) %}
@io : IO::Hexdump
{% end %}
def initialize(host_or_path : String)
case host_or_path
when .starts_with?('/')
# Make sure that the file exists
if File.exists?(host_or_path)
@socket = UNIXSocket.new(host_or_path)
else
raise Exception.new("SigHelper: '#{host_or_path}' no such file")
end
when .starts_with?("tcp://")
uri = URI.parse(host_or_path)
@socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!)
else
uri = URI.parse("tcp://#{host_or_path}")
@socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!)
end
LOGGER.info("SigHelper: Using helper at '#{host_or_path}'")
{% if flag?(:advanced_debug) %}
@io = IO::Hexdump.new(@socket, output: STDERR, read: true, write: true)
{% end %}
@socket.sync = false
@socket.blocking = false
end
def closed? : Bool
return @socket.closed?
end
def close : Nil
@socket.close if !@socket.closed?
end
def flush(*args, **options)
@socket.flush(*args, **options)
end
def send(*args, **options)
@socket.send(*args, **options)
end
# Wrap IO functions, with added debug tooling if needed
{% for function in %w(read read_bytes write write_bytes) %}
def {{function.id}}(*args, **options)
{% if flag?(:advanced_debug) %}
@io.{{function.id}}(*args, **options)
{% else %}
@socket.{{function.id}}(*args, **options)
{% end %}
end
{% end %}
end
end

View File

@ -1,73 +1,53 @@
alias SigProc = Proc(Array(String), Int32, Array(String))
require "http/params"
require "./sig_helper"
struct DecryptFunction
@decrypt_function = [] of {SigProc, Int32}
@decrypt_time = Time.monotonic
class Invidious::DecryptFunction
@last_update : Time = Time.utc - 42.days
def initialize(@use_polling = true)
def initialize(uri_or_path)
@client = SigHelper::Client.new(uri_or_path)
self.check_update
end
def update_decrypt_function
@decrypt_function = fetch_decrypt_function
def check_update
# If we have updated in the last 5 minutes, do nothing
return if (Time.utc - @last_update) < 5.minutes
# Get the amount of time elapsed since when the player was updated, in the
# event where multiple invidious processes are run in parallel.
update_time_elapsed = (@client.get_player_timestamp || 301).seconds
if update_time_elapsed > 5.minutes
LOGGER.debug("Signature: Player might be outdated, updating")
@client.force_update
@last_update = Time.utc
end
end
private def fetch_decrypt_function(id = "CvFH_6DNRCY")
document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body
url = document.match(/src="(?<url>\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"]
player = YT_POOL.client &.get(url).body
function_name = player.match(/^(?<name>[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"]
function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?<body>[^}]+)}/m).not_nil!["body"]
function_body = function_body.split(";")[1..-2]
var_name = function_body[0][0, 2]
var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?<body>(.*?))};/).not_nil!["body"]
operations = {} of String => SigProc
var_body.split("},").each do |operation|
op_name = operation.match(/^[^:]+/).not_nil![0]
op_body = operation.match(/\{[^}]+/).not_nil![0]
case op_body
when "{a.reverse()"
operations[op_name] = ->(a : Array(String), _b : Int32) { a.reverse }
when "{a.splice(0,b)"
operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a }
else
operations[op_name] = ->(a : Array(String), b : Int32) { c = a[0]; a[0] = a[b % a.size]; a[b % a.size] = c; a }
end
end
decrypt_function = [] of {SigProc, Int32}
function_body.each do |function|
function = function.lchop(var_name).delete("[].")
op_name = function.match(/[^\(]+/).not_nil![0]
value = function.match(/\(\w,(?<value>[\d]+)\)/).not_nil!["value"].to_i
decrypt_function << {operations[op_name], value}
end
return decrypt_function
def decrypt_nsig(n : String) : String?
self.check_update
return @client.decrypt_n_param(n)
rescue ex
LOGGER.debug(ex.message || "Signature: Unknown error")
LOGGER.trace(ex.inspect_with_backtrace)
return nil
end
def decrypt_signature(fmt : Hash(String, JSON::Any))
return "" if !fmt["s"]? || !fmt["sp"]?
def decrypt_signature(str : String) : String?
self.check_update
return @client.decrypt_sig(str)
rescue ex
LOGGER.debug(ex.message || "Signature: Unknown error")
LOGGER.trace(ex.inspect_with_backtrace)
return nil
end
sp = fmt["sp"].as_s
sig = fmt["s"].as_s.split("")
if !@use_polling
now = Time.monotonic
if now - @decrypt_time > 60.seconds || @decrypt_function.size == 0
@decrypt_function = fetch_decrypt_function
@decrypt_time = Time.monotonic
end
end
@decrypt_function.each do |proc, value|
sig = proc.call(sig, value)
end
return "&#{sp}=#{sig.join("")}"
def get_sts : UInt64?
self.check_update
return @client.get_signature_timestamp
rescue ex
LOGGER.debug(ex.message || "Signature: Unknown error")
LOGGER.trace(ex.inspect_with_backtrace)
return nil
end
end

View File

@ -52,9 +52,9 @@ def recode_length_seconds(time)
end
def decode_interval(string : String) : Time::Span
rawMinutes = string.try &.to_i32?
raw_minutes = string.try &.to_i32?
if !rawMinutes
if !raw_minutes
hours = /(?<hours>\d+)h/.match(string).try &.["hours"].try &.to_i32
hours ||= 0
@ -63,7 +63,7 @@ def decode_interval(string : String) : Time::Span
time = Time::Span.new(hours: hours, minutes: minutes)
else
time = Time::Span.new(minutes: rawMinutes)
time = Time::Span.new(minutes: raw_minutes)
end
return time

View File

@ -11,11 +11,12 @@ module Invidious::HttpServer
params = url.query_params
params["host"] = url.host.not_nil! # Should never be nil, in theory
params["region"] = region if !region.nil?
url.query_params = params
if absolute
return "#{HOST_URL}#{url.request_target}?#{params}"
return "#{HOST_URL}#{url.request_target}"
else
return "#{url.request_target}?#{params}"
return url.request_target
end
end

View File

@ -1,14 +0,0 @@
class Invidious::Jobs::UpdateDecryptFunctionJob < Invidious::Jobs::BaseJob
def begin
loop do
begin
DECRYPT_FUNCTION.update_decrypt_function
rescue ex
LOGGER.error("UpdateDecryptFunctionJob : #{ex.message}")
ensure
sleep 1.minute
Fiber.yield
end
end
end
end

View File

@ -63,7 +63,7 @@ module Invidious::JSONify::APIv1
json.field "isListed", video.is_listed
json.field "liveNow", video.live_now
json.field "isPostLiveDvr", video.post_live_dvr
json.field "isUpcoming", video.is_upcoming
json.field "isUpcoming", video.upcoming?
if video.premiere_timestamp
json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix
@ -109,30 +109,36 @@ module Invidious::JSONify::APIv1
# On livestreams, it's not present, so always fall back to the
# current unix timestamp (up to mS precision) for compatibility.
last_modified = fmt["lastModified"]?
last_modified ||= "#{Time.utc.to_unix_ms.to_s}000"
last_modified ||= "#{Time.utc.to_unix_ms}000"
json.field "lmt", last_modified
json.field "projectionType", fmt["projectionType"]
if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
height = fmt["height"]?.try &.as_i
width = fmt["width"]?.try &.as_i
fps = fmt["fps"]?.try &.as_i
if fps
json.field "fps", fps
end
if height && width
json.field "size", "#{width}x#{height}"
json.field "resolution", "#{height}p"
quality_label = "#{width > height ? height : width}p"
if fps && fps > 30
quality_label += fps.to_s
end
json.field "qualityLabel", quality_label
end
if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
json.field "container", fmt_info["ext"]
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
if fmt_info["height"]?
json.field "resolution", "#{fmt_info["height"]}p"
quality_label = "#{fmt_info["height"]}p"
if fps > 30
quality_label += "60"
end
json.field "qualityLabel", quality_label
if fmt_info["width"]?
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
end
end
end
# Livestream chunk infos
@ -156,33 +162,44 @@ module Invidious::JSONify::APIv1
json.array do
video.fmt_stream.each do |fmt|
json.object do
json.field "url", fmt["url"]
if proxy
json.field "url", Invidious::HttpServer::Utils.proxy_video_url(
fmt["url"].to_s, absolute: true
)
else
json.field "url", fmt["url"]
end
json.field "itag", fmt["itag"].as_i.to_s
json.field "type", fmt["mimeType"]
json.field "quality", fmt["quality"]
json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]?
fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
if fmt_info
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
height = fmt["height"]?.try &.as_i
width = fmt["width"]?.try &.as_i
fps = fmt["fps"]?.try &.as_i
if fps
json.field "fps", fps
end
if height && width
json.field "size", "#{width}x#{height}"
json.field "resolution", "#{height}p"
quality_label = "#{width > height ? height : width}p"
if fps && fps > 30
quality_label += fps.to_s
end
json.field "qualityLabel", quality_label
end
if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
json.field "container", fmt_info["ext"]
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
if fmt_info["height"]?
json.field "resolution", "#{fmt_info["height"]}p"
quality_label = "#{fmt_info["height"]}p"
if fps > 30
quality_label += "60"
end
json.field "qualityLabel", quality_label
if fmt_info["width"]?
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
end
end
end
end
end
@ -260,17 +277,17 @@ module Invidious::JSONify::APIv1
def storyboards(json, id, storyboards)
json.array do
storyboards.each do |storyboard|
storyboards.each do |sb|
json.object do
json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}"
json.field "templateUrl", storyboard[:url]
json.field "width", storyboard[:width]
json.field "height", storyboard[:height]
json.field "count", storyboard[:count]
json.field "interval", storyboard[:interval]
json.field "storyboardWidth", storyboard[:storyboard_width]
json.field "storyboardHeight", storyboard[:storyboard_height]
json.field "storyboardCount", storyboard[:storyboard_count]
json.field "url", "/api/v1/storyboards/#{id}?width=#{sb.width}&height=#{sb.height}"
json.field "templateUrl", sb.url.to_s
json.field "width", sb.width
json.field "height", sb.height
json.field "count", sb.count
json.field "interval", sb.interval
json.field "storyboardWidth", sb.columns
json.field "storyboardHeight", sb.rows
json.field "storyboardCount", sb.images_count
end
end
end

View File

@ -46,8 +46,14 @@ struct PlaylistVideo
XML.build { |xml| to_xml(xml) }
end
def to_json(locale : String?, json : JSON::Builder)
to_json(json)
end
def to_json(json : JSON::Builder, index : Int32? = nil)
json.object do
json.field "type", "video"
json.field "title", self.title
json.field "videoId", self.id
@ -67,6 +73,7 @@ struct PlaylistVideo
end
json.field "lengthSeconds", self.length_seconds
json.field "liveNow", self.live_now
end
end
@ -366,6 +373,8 @@ def fetch_playlist(plid : String)
if text.includes? "video"
video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "episode"
video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "view"
views = text.gsub(/\D/, "").to_i64? || 0_i64
else

View File

@ -53,7 +53,7 @@ module Invidious::Routes::Account
return error_template(401, "Password is a required field")
end
new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v }
new_passwords = env.params.body.select { |k, _| k.match(/^new_password\[\d+\]$/) }.map { |_, v| v }
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
return error_template(400, "New passwords must match")
@ -240,7 +240,7 @@ module Invidious::Routes::Account
return error_template(400, ex)
end
scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v }
callback_url = env.params.body["callbackUrl"]?
expire = env.params.body["expire"]?.try &.to_i?

View File

@ -27,10 +27,21 @@ module Invidious::Routes::API::V1::Channels
# Retrieve "sort by" setting from URL parameters
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
begin
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
rescue ex
return error_json(500, ex)
if channel.is_age_gated
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
videos = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
end
next_continuation = nil
else
begin
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
rescue ex
return error_json(500, ex)
end
end
JSON.build do |json|
@ -84,6 +95,7 @@ module Invidious::Routes::API::V1::Channels
json.field "joined", channel.joined.to_unix
json.field "autoGenerated", channel.auto_generated
json.field "ageGated", channel.is_age_gated
json.field "isFamilyFriendly", channel.is_family_friendly
json.field "description", html_to_content(channel.description_html)
json.field "descriptionHtml", channel.description_html
@ -142,12 +154,23 @@ module Invidious::Routes::API::V1::Channels
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
continuation = env.params.query["continuation"]?
begin
videos, next_continuation = Channel::Tabs.get_60_videos(
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
return error_json(500, ex)
if channel.is_age_gated
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
videos = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
end
next_continuation = nil
else
begin
videos, next_continuation = Channel::Tabs.get_60_videos(
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
return error_json(500, ex)
end
end
return JSON.build do |json|
@ -176,12 +199,23 @@ module Invidious::Routes::API::V1::Channels
# Retrieve continuation from URL parameters
continuation = env.params.query["continuation"]?
begin
videos, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation
)
rescue ex
return error_json(500, ex)
if channel.is_age_gated
begin
playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
videos = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
end
next_continuation = nil
else
begin
videos, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation
)
rescue ex
return error_json(500, ex)
end
end
return JSON.build do |json|
@ -208,14 +242,26 @@ module Invidious::Routes::API::V1::Channels
get_channel()
# Retrieve continuation from URL parameters
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
continuation = env.params.query["continuation"]?
begin
videos, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation
)
rescue ex
return error_json(500, ex)
if channel.is_age_gated
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
videos = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
end
next_continuation = nil
else
begin
videos, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
return error_json(500, ex)
end
end
return JSON.build do |json|

View File

@ -31,7 +31,7 @@ module Invidious::Routes::API::V1::Feeds
if !CONFIG.popular_enabled
error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
haltf env, 400, error_message
haltf env, 403, error_message
end
JSON.build do |json|

View File

@ -74,7 +74,9 @@ module Invidious::Routes::API::V1::Misc
response = playlist.to_json(offset, video_id: video_id)
json_response = JSON.parse(response)
if json_response["videos"].as_a[0]["index"] != offset
if json_response["videos"].as_a.empty?
json_response = JSON.parse(response)
elsif json_response["videos"].as_a[0]["index"] != offset
offset = json_response["videos"].as_a[0]["index"].as_i
lookback = offset < 50 ? offset : 50
response = playlist.to_json(offset - lookback)
@ -177,8 +179,8 @@ module Invidious::Routes::API::V1::Misc
begin
resolved_url = YoutubeAPI.resolve_url(url.as(String))
endpoint = resolved_url["endpoint"]
pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || ""
if pageType == "WEB_PAGE_TYPE_UNKNOWN"
page_type = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || ""
if page_type == "WEB_PAGE_TYPE_UNKNOWN"
return error_json(400, "Unknown url")
end
@ -194,7 +196,7 @@ module Invidious::Routes::API::V1::Misc
json.field "playlistId", sub_endpoint["playlistId"].as_s if sub_endpoint["playlistId"]?
json.field "startTimeSeconds", sub_endpoint["startTimeSeconds"].as_i if sub_endpoint["startTimeSeconds"]?
json.field "params", params.try &.as_s
json.field "pageType", pageType
json.field "pageType", page_type
end
end
end

View File

@ -1,3 +1,5 @@
require "html"
module Invidious::Routes::API::V1::Videos
def self.videos(env)
locale = env.get("preferences").as(Preferences).locale
@ -89,9 +91,14 @@ module Invidious::Routes::API::V1::Videos
if CONFIG.use_innertube_for_captions
params = Invidious::Videos::Transcript.generate_param(id, caption.language_code, caption.auto_generated)
initial_data = YoutubeAPI.get_transcript(params)
webvtt = Invidious::Videos::Transcript.convert_transcripts_to_vtt(initial_data, caption.language_code)
transcript = Invidious::Videos::Transcript.from_raw(
YoutubeAPI.get_transcript(params),
caption.language_code,
caption.auto_generated
)
webvtt = transcript.to_vtt
else
# Timedtext API handling
url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target
@ -111,7 +118,7 @@ module Invidious::Routes::API::V1::Videos
else
caption_xml = XML.parse(caption_xml)
webvtt = WebVTT.build(settings_field) do |webvtt|
webvtt = WebVTT.build(settings_field) do |builder|
caption_nodes = caption_xml.xpath_nodes("//transcript/text")
caption_nodes.each_with_index do |node, i|
start_time = node["start"].to_f.seconds
@ -131,12 +138,16 @@ module Invidious::Routes::API::V1::Videos
text = "<v #{md["name"]}>#{md["text"]}</v>"
end
webvtt.cue(start_time, end_time, text)
builder.cue(start_time, end_time, text)
end
end
end
else
webvtt = YT_POOL.client &.get("#{url}&fmt=vtt").body
uri = URI.parse(url)
query_params = uri.query_params
query_params["fmt"] = "vtt"
uri.query_params = query_params
webvtt = YT_POOL.client &.get(uri.request_target).body
if webvtt.starts_with?("<?xml")
webvtt = caption.timedtext_to_vtt(webvtt)
@ -178,15 +189,14 @@ module Invidious::Routes::API::V1::Videos
haltf env, 500
end
storyboards = video.storyboards
width = env.params.query["width"]?
height = env.params.query["height"]?
width = env.params.query["width"]?.try &.to_i
height = env.params.query["height"]?.try &.to_i
if !width && !height
response = JSON.build do |json|
json.object do
json.field "storyboards" do
Invidious::JSONify::APIv1.storyboards(json, id, storyboards)
Invidious::JSONify::APIv1.storyboards(json, id, video.storyboards)
end
end
end
@ -196,35 +206,48 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "text/vtt"
storyboard = storyboards.select { |sb| width == "#{sb[:width]}" || height == "#{sb[:height]}" }
# Select a storyboard matching the user's provided width/height
storyboard = video.storyboards.select { |x| x.width == width || x.height == height }
haltf env, 404 if storyboard.empty?
if storyboard.empty?
haltf env, 404
else
storyboard = storyboard[0]
end
# Alias variable, to make the code below esaier to read
sb = storyboard[0]
WebVTT.build do |vtt|
start_time = 0.milliseconds
end_time = storyboard[:interval].milliseconds
# Some base URL segments that we'll use to craft the final URLs
work_url = sb.proxied_url.dup
template_path = sb.proxied_url.path
storyboard[:storyboard_count].times do |i|
url = storyboard[:url]
authority = /(i\d?).ytimg.com/.match(url).not_nil![1]?
url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
url = "#{HOST_URL}/sb/#{authority}/#{url}"
# Initialize cue timing variables
# NOTE: videojs-vtt-thumbnails gets lost when the cue times don't overlap
# (i.e: if cue[n] end time is 1:06:25.000, cue[n+1] start time should be 1:06:25.000)
time_delta = sb.interval.milliseconds
start_time = 0.milliseconds
end_time = time_delta
storyboard[:storyboard_height].times do |j|
storyboard[:storyboard_width].times do |k|
current_cue_url = "#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}"
vtt.cue(start_time, end_time, current_cue_url)
# Build a VTT file for VideoJS-vtt plugin
vtt_file = WebVTT.build do |vtt|
sb.images_count.times do |i|
# Replace the variable component part of the path
work_url.path = template_path.sub("$M", i)
start_time += storyboard[:interval].milliseconds
end_time += storyboard[:interval].milliseconds
sb.rows.times do |j|
sb.columns.times do |k|
# The URL fragment represents the offset of the thumbnail inside the storyboard image
work_url.fragment = "xywh=#{sb.width * k},#{sb.height * j},#{sb.width - 2},#{sb.height}"
vtt.cue(start_time, end_time, work_url.to_s)
start_time += time_delta
end_time += time_delta
end
end
end
end
# videojs-vtt-thumbnails is not compliant to the VTT specification, it
# doesn't unescape the HTML entities, so we have to do it here:
# TODO: remove this when we migrate to VideoJS 8
return HTML.unescape(vtt_file)
end
def self.annotations(env)
@ -245,7 +268,7 @@ module Invidious::Routes::API::V1::Videos
if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id))
annotations = cached_annotation.annotations
else
index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0')
index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0')
# IA doesn't handle leading hyphens,
# so we use https://archive.org/details/youtubeannotations_64

View File

@ -30,7 +30,7 @@ module Invidious::Routes::BeforeAll
# Only allow the pages at /embed/* to be embedded
if env.request.resource.starts_with?("/embed")
frame_ancestors = "'self' http: https:"
frame_ancestors = "'self' file: http: https:"
else
frame_ancestors = "'none'"
end

View File

@ -36,12 +36,24 @@ module Invidious::Routes::Channels
items = items.select(SearchPlaylist)
items.each(&.author = "")
else
sort_options = {"newest", "oldest", "popular"}
# Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_videos(
channel, continuation: continuation, sort_by: (sort_by || "newest")
)
if channel.is_age_gated
sort_by = ""
sort_options = [] of String
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
items = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
end
next_continuation = nil
else
sort_options = {"newest", "oldest", "popular"}
items, next_continuation = Channel::Tabs.get_videos(
channel, continuation: continuation, sort_by: (sort_by || "newest")
)
end
end
selected_tab = Frontend::ChannelPage::TabsAvailable::Videos
@ -58,14 +70,27 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}"
end
# TODO: support sort option for shorts
sort_by = ""
sort_options = [] of String
if channel.is_age_gated
sort_by = ""
sort_options = [] of String
begin
playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
items = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
end
next_continuation = nil
else
# TODO: support sort option for shorts
sort_by = ""
sort_options = [] of String
# Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation
)
# Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation
)
end
selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts
templated "channel"
@ -81,14 +106,26 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}"
end
# TODO: support sort option for livestreams
sort_by = ""
sort_options = [] of String
if channel.is_age_gated
sort_by = ""
sort_options = [] of String
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
items = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
end
next_continuation = nil
else
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
sort_options = {"newest", "oldest", "popular"}
# Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation
)
# Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation, sort_by: sort_by
)
end
selected_tab = Frontend::ChannelPage::TabsAvailable::Streams
templated "channel"

View File

@ -214,7 +214,7 @@ module Invidious::Routes::PreferencesRoute
statistics_enabled ||= "off"
CONFIG.statistics_enabled = statistics_enabled == "on"
CONFIG.modified_source_code_url = env.params.body["modified_source_code_url"]?.try &.as(String)
CONFIG.modified_source_code_url = env.params.body["modified_source_code_url"]?.presence
File.write("config/config.yml", CONFIG.to_yaml)
end

View File

@ -51,6 +51,12 @@ module Invidious::Routes::Search
else
user = env.get? "user"
# An URL was copy/pasted in the search box.
# Redirect the user to the appropriate page.
if query.url?
return env.redirect UrlSanitizer.process(query.text).to_s
end
begin
items = query.process
rescue ex : ChannelSearchException

View File

@ -131,7 +131,7 @@ module Invidious::Routes::VideoPlayback
end
# TODO: Record bytes written so we can restart after a chunk fails
while true
loop do
if !range_end && content_length
range_end = content_length
end

View File

@ -20,6 +20,9 @@ module Invidious::Search
property region : String?
property channel : String = ""
# Flag that indicates if the smart search features have been disabled.
@inhibit_ssf : Bool = false
# Return true if @raw_query is either `nil` or empty
private def empty_raw_query?
return @raw_query.empty?
@ -48,10 +51,18 @@ module Invidious::Search
)
# Get the raw search query string (common to all search types). In
# Regular search mode, also look for the `search_query` URL parameter
if @type.regular?
@raw_query = params["q"]? || params["search_query"]? || ""
else
@raw_query = params["q"]? || ""
_raw_query = params["q"]?
_raw_query ||= params["search_query"]? if @type.regular?
_raw_query ||= ""
# Remove surrounding whitespaces. Mostly useful for copy/pasted URLs.
@raw_query = _raw_query.strip
# Check for smart features (ex: URL search) inhibitor (backslash).
# If inhibitor is present, remove it.
if @raw_query.starts_with?('\\')
@inhibit_ssf = true
@raw_query = @raw_query[1..]
end
# Get the page number (also common to all search types)
@ -85,7 +96,7 @@ module Invidious::Search
@filters = Filters.from_iv_params(params)
@channel = params["channel"]? || ""
if @filters.default? && @raw_query.includes?(':')
if @filters.default? && @raw_query.index(/\w:\w/)
# Parse legacy filters from query
@filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query)
else
@ -136,5 +147,22 @@ module Invidious::Search
return params
end
# Checks if the query is a standalone URL
def url? : Bool
# If the smart features have been inhibited, don't go further.
return false if @inhibit_ssf
# Only supported in regular search mode
return false if !@type.regular?
# If filters are present, that's a regular search
return false if !@filters.default?
# Simple heuristics: domain name
return @raw_query.starts_with?(
/(https?:\/\/)?(www\.)?(m\.)?youtu(\.be|be\.com)\//
)
end
end
end

View File

@ -115,7 +115,7 @@ struct Invidious::User
playlists.each do |item|
title = item["title"]?.try &.as_s?.try &.delete("<>")
description = item["description"]?.try &.as_s?.try &.delete("\r")
privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
privacy = item["privacy"]?.try &.as_s?.try { |raw_pl_privacy_state| PlaylistPrivacy.parse? raw_pl_privacy_state }
next if !title
next if !description
@ -124,7 +124,7 @@ struct Invidious::User
playlist = create_playlist(title, privacy, user)
Invidious::Database::Playlists.update_description(playlist.id, description)
videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
if idx > CONFIG.playlist_length_limit
raise InfoException.new("Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
end
@ -161,7 +161,7 @@ struct Invidious::User
# Youtube
# -------------------
private def is_opml?(mimetype : String, extension : String)
private def opml?(mimetype : String, extension : String)
opml_mimetypes = [
"application/xml",
"text/xml",
@ -179,10 +179,10 @@ struct Invidious::User
def from_youtube(user : User, body : String, filename : String, type : String) : Bool
extension = filename.split(".").last
if is_opml?(type, extension)
if opml?(type, extension)
subscriptions = XML.parse(body)
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0]
end
elsif extension == "json" || type == "application/json"
subscriptions = JSON.parse(body)

View File

@ -98,20 +98,51 @@ struct Video
# Methods for parsing streaming data
def convert_url(fmt)
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
sp = cfr["sp"]
url = URI.parse(cfr["url"])
params = url.query_params
LOGGER.debug("Videos: Decoding '#{cfr}'")
unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"])
params[sp] = unsig if unsig
else
url = URI.parse(fmt["url"].as_s)
params = url.query_params
end
n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"])
params["n"] = n if n
if token = CONFIG.po_token
params["pot"] = token
end
params["host"] = url.host.not_nil!
if region = self.info["region"]?.try &.as_s
params["region"] = region
end
url.query_params = params
LOGGER.trace("Videos: new url is '#{url}'")
return url.to_s
rescue ex
LOGGER.debug("Videos: Error when parsing video URL")
LOGGER.trace(ex.inspect_with_backtrace)
return ""
end
def fmt_stream
return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream
fmt_stream = info["streamingData"]?.try &.["formats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
fmt_stream.each do |fmt|
if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) }
s.each do |k, v|
fmt[k] = JSON::Any.new(v)
end
fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}")
end
fmt_stream = info.dig?("streamingData", "formats")
.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}")
fmt["url"] = JSON::Any.new("#{fmt["url"]}&region=#{self.info["region"]}") if self.info["region"]?
fmt_stream.each do |fmt|
fmt["url"] = JSON::Any.new(self.convert_url(fmt))
end
fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
@ -121,21 +152,17 @@ struct Video
def adaptive_fmts
return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts
fmt_stream = info["streamingData"]?.try &.["adaptiveFormats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
fmt_stream.each do |fmt|
if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) }
s.each do |k, v|
fmt[k] = JSON::Any.new(v)
end
fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}")
end
fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}")
fmt["url"] = JSON::Any.new("#{fmt["url"]}&region=#{self.info["region"]}") if self.info["region"]?
fmt_stream = info.dig("streamingData", "adaptiveFormats")
.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
fmt_stream.each do |fmt|
fmt["url"] = JSON::Any.new(self.convert_url(fmt))
end
fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
@adaptive_fmts = fmt_stream
return @adaptive_fmts.as(Array(Hash(String, JSON::Any)))
end
@ -150,65 +177,8 @@ struct Video
# Misc. methods
def storyboards
storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec")
.try &.as_s.split("|")
if !storyboards
if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s
return [{
url: storyboard.split("#")[0],
width: 106,
height: 60,
count: -1,
interval: 5000,
storyboard_width: 3,
storyboard_height: 3,
storyboard_count: -1,
}]
end
end
items = [] of NamedTuple(
url: String,
width: Int32,
height: Int32,
count: Int32,
interval: Int32,
storyboard_width: Int32,
storyboard_height: Int32,
storyboard_count: Int32)
return items if !storyboards
url = URI.parse(storyboards.shift)
params = HTTP::Params.parse(url.query || "")
storyboards.each_with_index do |sb, i|
width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#")
params["sigh"] = sigh
url.query = params.to_s
width = width.to_i
height = height.to_i
count = count.to_i
interval = interval.to_i
storyboard_width = storyboard_width.to_i
storyboard_height = storyboard_height.to_i
storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i
items << {
url: url.to_s.sub("$L", i).sub("$N", "M$M"),
width: width,
height: height,
count: count,
interval: interval,
storyboard_width: storyboard_width,
storyboard_height: storyboard_height,
storyboard_count: storyboard_count,
}
end
items
container = info.dig?("storyboards") || JSON::Any.new("{}")
return IV::Videos::Storyboard.from_yt_json(container, self.length_seconds)
end
def paid
@ -250,10 +220,10 @@ struct Video
end
def genre_url : String?
info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil
info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil
end
def is_vr : Bool?
def vr? : Bool?
return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type
end
@ -334,6 +304,21 @@ struct Video
{% if flag?(:debug_macros) %} {{debug}} {% end %}
end
# Macro to generate ? and = accessor methods for attributes in `info`
private macro predicate_bool(method_name, name)
# Return {{name.stringify}} from `info`
def {{method_name.id.underscore}}? : Bool
return info[{{name.stringify}}]?.try &.as_bool || false
end
# Update {{name.stringify}} into `info`
def {{method_name.id.underscore}}=(value : Bool)
info[{{name.stringify}}] = JSON::Any.new(value)
end
{% if flag?(:debug_macros) %} {{debug}} {% end %}
end
# Method definitions, using the macros above
getset_string author
@ -355,11 +340,12 @@ struct Video
getset_i64 likes
getset_i64 views
# TODO: Make predicate_bool the default as to adhere to Crystal conventions
getset_bool allowRatings
getset_bool authorVerified
getset_bool isFamilyFriendly
getset_bool isListed
getset_bool isUpcoming
predicate_bool upcoming, isUpcoming
end
def get_video(id, refresh = true, region = nil, force_refresh = false)
@ -394,10 +380,6 @@ end
def fetch_video(id, region)
info = extract_video_info(video_id: id)
allowed_regions = info
.dig?("microformat", "playerMicroformatRenderer", "availableCountries")
.try &.as_a.map &.as_s || [] of String
if reason = info["reason"]?
if reason == "Video unavailable"
raise NotFoundException.new(reason.as_s || "")

View File

@ -36,7 +36,13 @@ def parse_description(desc, video_id : String) : String?
return "" if content.empty?
commands = desc["commandRuns"]?.try &.as_a
return content if commands.nil?
if commands.nil?
# Slightly faster than HTML.escape, as we're only doing one pass on
# the string instead of five for the standard library
return String.build do |str|
copy_string(str, content.each_codepoint, content.size)
end
end
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are

View File

@ -55,7 +55,7 @@ def extract_video_info(video_id : String)
client_config = YoutubeAPI::ClientConfig.new
# Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config)
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
@ -102,7 +102,9 @@ def extract_video_info(video_id : String)
new_player_response = nil
if reason.nil?
# Don't use Android client if po_token is passed because po_token doesn't
# work for Android client.
if reason.nil? && CONFIG.po_token.nil?
# Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the
# following issue for an explanation about decrypted URLs:
@ -112,7 +114,10 @@ def extract_video_info(video_id : String)
end
# Last hope
if new_player_response.nil?
# Only trigger if reason found and po_token or didn't work wth Android client.
# TvHtml5ScreenEmbed now requires sig helper for it to work but po_token is not required
# if the IP address is not blocked.
if CONFIG.po_token && reason || CONFIG.po_token.nil? && new_player_response.nil?
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
new_player_response = try_fetch_streaming_data(video_id, client_config)
end
@ -424,7 +429,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
# Video metadata
"genre" => JSON::Any.new(genre.try &.as_s || ""),
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""),
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?),
"license" => JSON::Any.new(license.try &.as_s || ""),
# Music section
"music" => JSON.parse(music_list.to_json),

View File

@ -0,0 +1,122 @@
require "uri"
require "http/params"
module Invidious::Videos
struct Storyboard
# Template URL
getter url : URI
getter proxied_url : URI
# Thumbnail parameters
getter width : Int32
getter height : Int32
getter count : Int32
getter interval : Int32
# Image (storyboard) parameters
getter rows : Int32
getter columns : Int32
getter images_count : Int32
def initialize(
*, @url, @width, @height, @count, @interval,
@rows, @columns, @images_count
)
authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]?
@proxied_url = URI.parse(HOST_URL)
@proxied_url.path = "/sb/#{authority}/#{@url.path.lchop("/sb/")}"
@proxied_url.query = @url.query
end
# Parse the JSON structure from Youtube
def self.from_yt_json(container : JSON::Any, length_seconds : Int32) : Array(Storyboard)
# Livestream storyboards are a bit different
# TODO: document exactly how
if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s
return [Storyboard.new(
url: URI.parse(storyboard.split("#")[0]),
width: 106,
height: 60,
count: -1,
interval: 5000,
rows: 3,
columns: 3,
images_count: -1
)]
end
# Split the storyboard string into chunks
#
# General format (whitespaces added for legibility):
# https://i.ytimg.com/sb/<video_id>/storyboard3_L$L/$N.jpg?sqp=<sig0>
# | 48 # 27 # 100 # 10 # 10 # 0 # default # rs$<sig1>
# | 80 # 45 # 95 # 10 # 10 # 10000 # M$M # rs$<sig2>
# | 160 # 90 # 95 # 5 # 5 # 10000 # M$M # rs$<sig3>
#
storyboards = container.dig?("playerStoryboardSpecRenderer", "spec")
.try &.as_s.split("|")
return [] of Storyboard if !storyboards
# The base URL is the first chunk
base_url = URI.parse(storyboards.shift)
return storyboards.map_with_index do |sb, i|
# Separate the different storyboard parameters:
# width/height: respective dimensions, in pixels, of a single thumbnail
# count: how many thumbnails are displayed across the full video
# columns/rows: maximum amount of thumbnails that can be stuffed in a
# single image, horizontally and vertically.
# interval: interval between two thumbnails, in milliseconds
# name: storyboard filename. Usually "M$M" or "default"
# sigh: URL cryptographic signature
width, height, count, columns, rows, interval, name, sigh = sb.split("#")
width = width.to_i
height = height.to_i
count = count.to_i
interval = interval.to_i
columns = columns.to_i
rows = rows.to_i
# Copy base URL object, so that we can modify it
url = base_url.dup
# Add the signature to the URL
params = url.query_params
params["sigh"] = sigh
url.query_params = params
# Replace the template parts with what we have
url.path = url.path.sub("$L", i).sub("$N", name)
# This value represents the maximum amount of thumbnails that can fit
# in a single image. The last image (or the only one for short videos)
# will contain less thumbnails than that.
thumbnails_per_image = columns * rows
# This value represents the total amount of storyboards required to
# hold all of the thumbnails. It can't be less than 1.
images_count = (count / thumbnails_per_image).ceil.to_i
# Compute the interval when needed (in general, that's only required
# for the first "default" storyboard).
if interval == 0
interval = ((length_seconds / count) * 1_000).to_i
end
Storyboard.new(
url: url,
width: width,
height: height,
count: count,
interval: interval,
rows: rows,
columns: columns,
images_count: images_count,
)
end
end
end
end

View File

@ -1,8 +1,26 @@
module Invidious::Videos
# Namespace for methods primarily relating to Transcripts
module Transcript
record TranscriptLine, start_ms : Time::Span, end_ms : Time::Span, line : String
# A `Transcripts` struct encapsulates a sequence of lines that together forms the whole transcript for a given YouTube video.
# These lines can be categorized into two types: section headings and regular lines representing content from the video.
struct Transcript
# Types
record HeadingLine, start_ms : Time::Span, end_ms : Time::Span, line : String
record RegularLine, start_ms : Time::Span, end_ms : Time::Span, line : String
alias TranscriptLine = HeadingLine | RegularLine
property lines : Array(TranscriptLine)
property language_code : String
property auto_generated : Bool
# User friendly label for the current transcript.
# Example: "English (auto-generated)"
property label : String
# Initializes a new Transcript struct with the contents and associated metadata describing it
def initialize(@lines : Array(TranscriptLine), @language_code : String, @auto_generated : Bool, @label : String)
end
# Generates a protobuf string to fetch the requested transcript from YouTube
def self.generate_param(video_id : String, language_code : String, auto_generated : Bool) : String
kind = auto_generated ? "asr" : ""
@ -30,48 +48,79 @@ module Invidious::Videos
return params
end
def self.convert_transcripts_to_vtt(initial_data : Hash(String, JSON::Any), target_language : String) : String
# Convert into array of TranscriptLine
lines = self.parse(initial_data)
# Constructs a Transcripts struct from the initial YouTube response
def self.from_raw(initial_data : Hash(String, JSON::Any), language_code : String, auto_generated : Bool)
transcript_panel = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer",
"content", "transcriptSearchPanelRenderer")
segment_list = transcript_panel.dig("body", "transcriptSegmentListRenderer")
if !segment_list["initialSegments"]?
raise NotFoundException.new("Requested transcript does not exist")
end
# Extract user-friendly label for the current transcript
footer_language_menu = transcript_panel.dig?(
"footer", "transcriptFooterRenderer", "languageMenu", "sortFilterSubMenuRenderer", "subMenuItems"
)
if footer_language_menu
label = footer_language_menu.as_a.select(&.["selected"].as_bool)[0]["title"].as_s
else
label = language_code
end
# Extract transcript lines
initial_segments = segment_list["initialSegments"].as_a
lines = [] of TranscriptLine
initial_segments.each do |line|
if unpacked_line = line["transcriptSectionHeaderRenderer"]?
line_type = HeadingLine
else
unpacked_line = line["transcriptSegmentRenderer"]
line_type = RegularLine
end
start_ms = unpacked_line["startMs"].as_s.to_i.millisecond
end_ms = unpacked_line["endMs"].as_s.to_i.millisecond
text = extract_text(unpacked_line["snippet"]) || ""
lines << line_type.new(start_ms, end_ms, text)
end
return Transcript.new(
lines: lines,
language_code: language_code,
auto_generated: auto_generated,
label: label
)
end
# Converts transcript lines to a WebVTT file
#
# This is used within Invidious to replace subtitles
# as to workaround YouTube's rate-limited timedtext endpoint.
def to_vtt
settings_field = {
"Kind" => "captions",
"Language" => target_language,
"Language" => @language_code,
}
# Taken from Invidious::Videos::Captions::Metadata.timedtext_to_vtt()
vtt = WebVTT.build(settings_field) do |vtt|
lines.each do |line|
vtt.cue(line.start_ms, line.end_ms, line.line)
vtt = WebVTT.build(settings_field) do |builder|
@lines.each do |line|
# Section headers are excluded from the VTT conversion as to
# match the regular captions returned from YouTube as much as possible
next if line.is_a? HeadingLine
builder.cue(line.start_ms, line.end_ms, line.line)
end
end
return vtt
end
private def self.parse(initial_data : Hash(String, JSON::Any))
body = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer",
"content", "transcriptSearchPanelRenderer", "body", "transcriptSegmentListRenderer",
"initialSegments").as_a
lines = [] of TranscriptLine
body.each do |line|
# Transcript section headers. They are not apart of the captions and as such we can safely skip them.
if line.as_h.has_key?("transcriptSectionHeaderRenderer")
next
end
line = line["transcriptSegmentRenderer"]
start_ms = line["startMs"].as_s.to_i.millisecond
end_ms = line["endMs"].as_s.to_i.millisecond
text = extract_text(line["snippet"]) || ""
lines << TranscriptLine.new(start_ms, end_ms, text)
end
return lines
end
end
end

View File

@ -30,13 +30,13 @@
<meta property="og:site_name" content="Invidious">
<meta property="og:url" content="<%= HOST_URL %>/channel/<%= ucid %>">
<meta property="og:title" content="<%= author %>">
<meta property="og:image" content="/ggpht<%= channel_profile_pic %>">
<meta property="og:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>">
<meta property="og:description" content="<%= channel.description %>">
<meta name="twitter:card" content="summary">
<meta name="twitter:url" content="<%= HOST_URL %>/channel/<%= ucid %>">
<meta name="twitter:title" content="<%= author %>">
<meta name="twitter:description" content="<%= channel.description %>">
<meta name="twitter:image" content="/ggpht<%= channel_profile_pic %>">
<meta name="twitter:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>">
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" />
<%- end -%>

View File

@ -6,4 +6,7 @@
title="<%= translate(locale, "search") %>"
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
</fieldset>
<button type="submit" id="searchbutton" aria-label="<%= translate(locale, "search") %>">
<i class="icon ion-ios-search"></i>
</button>
</form>

View File

@ -1,6 +1,6 @@
<div class="flex-right flexible">
<div class="icon-buttons">
<a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" href="https://www.youtube.com/watch<%=endpoint_params%>">
<a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" rel="noreferrer noopener" href="https://www.youtube.com/watch<%=endpoint_params%>">
<i class="icon ion-logo-youtube"></i>
</a>
<a title="<%=translate(locale, "Audio mode")%>" href="/watch<%=endpoint_params%>&listen=1">

View File

@ -83,7 +83,7 @@
<% if !playlist.is_a? InvidiousPlaylist %>
<div class="pure-u-2-3">
<a href="https://www.youtube.com/playlist?list=<%= playlist.id %>">
<a rel="noreferrer noopener" href="https://www.youtube.com/playlist?list=<%= playlist.id %>">
<%= translate(locale, "View playlist on YouTube") %>
</a>
<span> | </span>

View File

@ -310,7 +310,7 @@
<div class="pure-control-group">
<label for="modified_source_code_url"><%= translate(locale, "adminprefs_modified_source_code_url_label") %></label>
<input name="modified_source_code_url" id="modified_source_code_url" type="input" <% if CONFIG.modified_source_code_url %>checked<% end %>>
<input name="modified_source_code_url" id="modified_source_code_url" type="url" value="<%= CONFIG.modified_source_code_url %>">
</div>
<% end %>

View File

@ -10,7 +10,7 @@
<meta property="og:site_name" content="<%= author %> | Invidious">
<meta property="og:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>">
<meta property="og:title" content="<%= title %>">
<meta property="og:image" content="/vi/<%= video.id %>/maxres.jpg">
<meta property="og:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg">
<meta property="og:description" content="<%= HTML.escape(video.short_description) %>">
<meta property="og:type" content="video.other">
<meta property="og:video:url" content="<%= HOST_URL %>/embed/<%= video.id %>">
@ -62,7 +62,7 @@ we're going to need to do it here in order to allow for translations.
"params" => params,
"preferences" => preferences,
"premiere_timestamp" => video.premiere_timestamp.try &.to_unix,
"vr" => video.is_vr,
"vr" => video.vr?,
"projection_type" => video.projection_type,
"local_disabled" => CONFIG.disabled?("local"),
"support_reddit" => true
@ -123,8 +123,8 @@ we're going to need to do it here in order to allow for translations.
link_yt_embed = IV::HttpServer::Utils.add_params_to_url(link_yt_embed, link_yt_param)
end
-%>
<a id="link-yt-watch" data-base-url="<%= link_yt_watch %>" href="<%= link_yt_watch %>"><%= translate(locale, "videoinfo_watch_on_youTube") %></a>
(<a id="link-yt-embed" data-base-url="<%= link_yt_embed %>" href="<%= link_yt_embed %>"><%= translate(locale, "videoinfo_youTube_embed_link") %></a>)
<a id="link-yt-watch" rel="noreferrer noopener" data-base-url="<%= link_yt_watch %>" href="<%= link_yt_watch %>"><%= translate(locale, "videoinfo_watch_on_youTube") %></a>
(<a id="link-yt-embed" rel="noreferrer noopener" data-base-url="<%= link_yt_embed %>" href="<%= link_yt_embed %>"><%= translate(locale, "videoinfo_youTube_embed_link") %></a>)
</span>
<p id="watch-on-another-invidious-instance">

View File

@ -1,6 +1,6 @@
def add_yt_headers(request)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
@ -24,7 +24,7 @@ struct YoutubeConnectionPool
@pool = build_pool()
end
def client(&block)
def client(&)
conn = pool.checkout
begin
response = yield conn
@ -69,7 +69,7 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false)
return client
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, &block)
def make_client(url : URI, region = nil, force_resolve : Bool = false, &)
client = make_client(url, region, force_resolve)
begin
yield client

View File

@ -109,7 +109,6 @@ private module Parsers
end
live_now = false
paid = false
premium = false
premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) }
@ -856,7 +855,7 @@ end
#
# This function yields the container so that items can be parsed separately.
#
def extract_items(initial_data : InitialData, &block)
def extract_items(initial_data : InitialData, &)
if unpackaged_data = initial_data["contents"]?.try &.as_h
elsif unpackaged_data = initial_data["response"]?.try &.as_h
elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 1).try &.as_h

View File

@ -83,5 +83,5 @@ end
def extract_selected_tab(tabs)
# Extract the selected tab from the array of tabs Youtube returns
return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"]
return tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"]
end

View File

@ -0,0 +1,121 @@
require "uri"
module UrlSanitizer
extend self
ALLOWED_QUERY_PARAMS = {
channel: ["u", "user", "lb"],
playlist: ["list"],
search: ["q", "search_query", "sp"],
watch: [
"v", # Video ID
"list", "index", # Playlist-related
"playlist", # Unnamed playlist (id,id,id,...) (embed-only?)
"t", "time_continue", "start", "end", # Timestamp
"lc", # Highlighted comment (watch page only)
],
}
# Returns whether the given string is an ASCII word. This is the same as
# running the following regex in US-ASCII locale: /^[\w-]+$/
private def ascii_word?(str : String) : Bool
return false if str.bytesize != str.size
str.each_byte do |byte|
next if 'a'.ord <= byte <= 'z'.ord
next if 'A'.ord <= byte <= 'Z'.ord
next if '0'.ord <= byte <= '9'.ord
next if byte == '-'.ord || byte == '_'.ord
return false
end
return true
end
# Return which kind of parameters are allowed based on the
# first path component (breadcrumb 0).
private def determine_allowed(path_root : String)
case path_root
when "watch", "w", "v", "embed", "e", "shorts", "clip"
return :watch
when .starts_with?("@"), "c", "channel", "user", "profile", "attribution_link"
return :channel
when "playlist", "mix"
return :playlist
when "results", "search"
return :search
else # hashtag, post, trending, brand URLs, etc..
return nil
end
end
# Create a new URI::Param containing only the allowed parameters
private def copy_params(unsafe_params : URI::Params, allowed_type) : URI::Params
new_params = URI::Params.new
ALLOWED_QUERY_PARAMS[allowed_type].each do |name|
if unsafe_params[name]?
# Only copy the last parameter, in case there is more than one
new_params[name] = unsafe_params.fetch_all(name)[-1]
end
end
return new_params
end
# Transform any user-supplied youtube URL into something we can trust
# and use across the code.
def process(str : String) : URI
# Because URI follows RFC3986 specifications, URL without a scheme
# will be parsed as a relative path. So we have to add a scheme ourselves.
str = "https://#{str}" if !str.starts_with?(/https?:\/\//)
unsafe_uri = URI.parse(str)
unsafe_host = unsafe_uri.host
unsafe_path = unsafe_uri.path
new_uri = URI.new(path: "/")
# Redirect to homepage for bogus URLs
return new_uri if (unsafe_host.nil? || unsafe_path.nil?)
breadcrumbs = unsafe_path
.split('/', remove_empty: true)
.compact_map do |bc|
# Exclude attempts at path trasversal
next if bc == "." || bc == ".."
# Non-alnum characters are unlikely in a genuine URL
next if !ascii_word?(bc)
bc
end
# If nothing remains, it's either a legit URL to the homepage
# (who does that!?) or because we filtered some junk earlier.
return new_uri if breadcrumbs.empty?
# Replace the original query parameters with the sanitized ones
case unsafe_host
when .ends_with?("youtube.com")
# Use our sanitized path (not forgetting the leading '/')
new_uri.path = "/#{breadcrumbs.join('/')}"
# Then determine which params are allowed, and copy them over
if allowed = determine_allowed(breadcrumbs[0])
new_uri.query_params = copy_params(unsafe_uri.query_params, allowed)
end
when "youtu.be"
# Always redirect to the watch page
new_uri.path = "/watch"
new_params = copy_params(unsafe_uri.query_params, :watch)
new_params["id"] = breadcrumbs[0]
new_uri.query_params = new_params
end
return new_uri
end
end

View File

@ -5,14 +5,11 @@
module YoutubeAPI
extend self
private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
private ANDROID_API_KEY = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w"
# For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
private ANDROID_APP_VERSION = "19.14.42"
private ANDROID_USER_AGENT = "com.google.android.youtube/19.14.42 (Linux; U; Android 12; US) gzip"
private ANDROID_SDK_VERSION = 31_i64
private ANDROID_APP_VERSION = "19.32.34"
private ANDROID_VERSION = "12"
private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; US) gzip"
private ANDROID_SDK_VERSION = 31_i64
private ANDROID_TS_APP_VERSION = "1.9"
private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip"
@ -20,9 +17,9 @@ module YoutubeAPI
# For Apple device names, see https://gist.github.com/adamawolf/3048717
# For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases,
# then go to the dedicated article of the major version you want.
private IOS_APP_VERSION = "19.16.3"
private IOS_USER_AGENT = "com.google.ios.youtube/19.16.3 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)"
private IOS_VERSION = "17.4.0.21E219" # Major.Minor.Patch.Build
private IOS_APP_VERSION = "19.32.8"
private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)"
private IOS_VERSION = "17.6.1.21G93" # Major.Minor.Patch.Build
private WINDOWS_VERSION = "10.0"
@ -51,8 +48,7 @@ module YoutubeAPI
ClientType::Web => {
name: "WEB",
name_proto: "1",
version: "2.20240304.00.00",
api_key: DEFAULT_API_KEY,
version: "2.20240814.00.00",
screen: "WATCH_FULL_SCREEN",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@ -61,8 +57,7 @@ module YoutubeAPI
ClientType::WebEmbeddedPlayer => {
name: "WEB_EMBEDDED_PLAYER",
name_proto: "56",
version: "1.20240303.00.00",
api_key: DEFAULT_API_KEY,
version: "1.20240812.01.00",
screen: "EMBED",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@ -71,8 +66,7 @@ module YoutubeAPI
ClientType::WebMobile => {
name: "MWEB",
name_proto: "2",
version: "2.20240304.08.00",
api_key: DEFAULT_API_KEY,
version: "2.20240813.02.00",
os_name: "Android",
os_version: ANDROID_VERSION,
platform: "MOBILE",
@ -80,8 +74,7 @@ module YoutubeAPI
ClientType::WebScreenEmbed => {
name: "WEB",
name_proto: "1",
version: "2.20240304.00.00",
api_key: DEFAULT_API_KEY,
version: "2.20240814.00.00",
screen: "EMBED",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@ -94,7 +87,6 @@ module YoutubeAPI
name: "ANDROID",
name_proto: "3",
version: ANDROID_APP_VERSION,
api_key: ANDROID_API_KEY,
android_sdk_version: ANDROID_SDK_VERSION,
user_agent: ANDROID_USER_AGENT,
os_name: "Android",
@ -105,13 +97,11 @@ module YoutubeAPI
name: "ANDROID_EMBEDDED_PLAYER",
name_proto: "55",
version: ANDROID_APP_VERSION,
api_key: "AIzaSyCjc_pVEDi4qsv5MtC2dMXzpIaDoRFLsxw",
},
ClientType::AndroidScreenEmbed => {
name: "ANDROID",
name_proto: "3",
version: ANDROID_APP_VERSION,
api_key: DEFAULT_API_KEY,
screen: "EMBED",
android_sdk_version: ANDROID_SDK_VERSION,
user_agent: ANDROID_USER_AGENT,
@ -123,7 +113,6 @@ module YoutubeAPI
name: "ANDROID_TESTSUITE",
name_proto: "30",
version: ANDROID_TS_APP_VERSION,
api_key: ANDROID_API_KEY,
android_sdk_version: ANDROID_SDK_VERSION,
user_agent: ANDROID_TS_USER_AGENT,
os_name: "Android",
@ -137,7 +126,6 @@ module YoutubeAPI
name: "IOS",
name_proto: "5",
version: IOS_APP_VERSION,
api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc",
user_agent: IOS_USER_AGENT,
device_make: "Apple",
device_model: "iPhone14,5",
@ -149,7 +137,6 @@ module YoutubeAPI
name: "IOS_MESSAGES_EXTENSION",
name_proto: "66",
version: IOS_APP_VERSION,
api_key: DEFAULT_API_KEY,
user_agent: IOS_USER_AGENT,
device_make: "Apple",
device_model: "iPhone14,5",
@ -160,9 +147,8 @@ module YoutubeAPI
ClientType::IOSMusic => {
name: "IOS_MUSIC",
name_proto: "26",
version: "6.42",
api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s",
user_agent: "com.google.ios.youtubemusic/6.42 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)",
version: "7.14",
user_agent: "com.google.ios.youtubemusic/7.14 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)",
device_make: "Apple",
device_model: "iPhone14,5",
os_name: "iPhone",
@ -175,14 +161,12 @@ module YoutubeAPI
ClientType::TvHtml5 => {
name: "TVHTML5",
name_proto: "7",
version: "7.20240304.10.00",
api_key: DEFAULT_API_KEY,
version: "7.20240813.07.00",
},
ClientType::TvHtml5ScreenEmbed => {
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
name_proto: "85",
version: "2.0",
api_key: DEFAULT_API_KEY,
screen: "EMBED",
},
}
@ -237,11 +221,6 @@ module YoutubeAPI
HARDCODED_CLIENTS[@client_type][:version]
end
# :ditto:
def api_key : String
HARDCODED_CLIENTS[@client_type][:api_key]
end
# :ditto:
def screen : String
HARDCODED_CLIENTS[@client_type][:screen]? || ""
@ -293,7 +272,7 @@ module YoutubeAPI
# Return, as a Hash, the "context" data required to request the
# youtube API endpoints.
#
private def make_context(client_config : ClientConfig | Nil) : Hash
private def make_context(client_config : ClientConfig | Nil, video_id = "dQw4w9WgXcQ") : Hash
# Use the default client config if nil is passed
client_config ||= DEFAULT_CLIENT_CONFIG
@ -313,7 +292,7 @@ module YoutubeAPI
if client_config.screen == "EMBED"
client_context["thirdParty"] = {
"embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ",
"embedUrl" => "https://www.youtube.com/embed/#{video_id}",
} of String => String | Int64
end
@ -341,6 +320,10 @@ module YoutubeAPI
client_context["client"]["platform"] = platform
end
if CONFIG.visitor_data.is_a?(String)
client_context["client"]["visitorData"] = CONFIG.visitor_data.as(String)
end
return client_context
end
@ -474,19 +457,32 @@ module YoutubeAPI
params : String,
client_config : ClientConfig | Nil = nil
)
# Playback context, separate because it can be different between clients
playback_ctx = {
"html5Preference" => "HTML5_PREF_WANTS",
"referer" => "https://www.youtube.com/watch?v=#{video_id}",
} of String => String | Int64
if {"WEB", "TVHTML5"}.any? { |s| client_config.name.starts_with? s }
if sts = DECRYPT_FUNCTION.try &.get_sts
playback_ctx["signatureTimestamp"] = sts.to_i64
end
end
# JSON Request data, required by the API
data = {
"contentCheckOk" => true,
"videoId" => video_id,
"context" => self.make_context(client_config),
"context" => self.make_context(client_config, video_id),
"racyCheckOk" => true,
"user" => {
"lockedSafetyMode" => false,
},
"playbackContext" => {
"contentPlaybackContext" => {
"html5Preference": "HTML5_PREF_WANTS",
},
"contentPlaybackContext" => playback_ctx,
},
"serviceIntegrityDimensions" => {
"poToken" => CONFIG.po_token,
},
}
@ -606,7 +602,7 @@ module YoutubeAPI
client_config ||= DEFAULT_CLIENT_CONFIG
# Query parameters
url = "#{endpoint}?key=#{client_config.api_key}&prettyPrint=false"
url = "#{endpoint}?prettyPrint=false"
headers = HTTP::Headers{
"Content-Type" => "application/json; charset=UTF-8",
@ -620,6 +616,10 @@ module YoutubeAPI
headers["User-Agent"] = user_agent
end
if CONFIG.visitor_data.is_a?(String)
headers["X-Goog-Visitor-Id"] = CONFIG.visitor_data.as(String)
end
# Logging
LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"")
LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}")