diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96903fc7..1ca0dc96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,11 +38,10 @@ jobs: matrix: stable: [true] crystal: - - 1.4.1 - - 1.5.1 - 1.6.2 - 1.7.3 - - 1.8.1 + - 1.8.2 + - 1.9.2 include: - crystal: nightly stable: false @@ -53,7 +52,7 @@ jobs: submodules: true - name: Install Crystal - uses: crystal-lang/install-crystal@v1.7.0 + uses: crystal-lang/install-crystal@v1.8.0 with: crystal: ${{ matrix.crystal }} diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 86aec94f..c2756fcc 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -25,9 +25,9 @@ jobs: uses: actions/checkout@v3 - name: Install Crystal - uses: crystal-lang/install-crystal@v1.6.0 + uses: crystal-lang/install-crystal@v1.8.0 with: - crystal: 1.5.0 + crystal: 1.9.2 - name: Run lint run: | @@ -52,7 +52,7 @@ jobs: username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_PASSWORD }} - - name: Build and push Docker AMD64 image without QUIC for Push Event + - name: Build and push Docker AMD64 image for Push Event if: github.ref == 'refs/heads/master' uses: docker/build-push-action@v3 with: @@ -64,9 +64,8 @@ jobs: tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest build-args: | "release=1" - "disable_quic=1" - - name: Build and push Docker ARM64 image without QUIC for Push Event + - name: Build and push Docker ARM64 image for Push Event if: github.ref == 'refs/heads/master' uses: docker/build-push-action@v3 with: @@ -78,28 +77,3 @@ jobs: tags: quay.io/invidious/invidious:${{ github.sha }}-arm64,quay.io/invidious/invidious:latest-arm64 build-args: | "release=1" - "disable_quic=1" - - - name: Build and push Docker AMD64 image with QUIC for Push Event - if: github.ref == 'refs/heads/master' - uses: docker/build-push-action@v3 - with: - context: . - file: docker/Dockerfile - platforms: linux/amd64 - labels: quay.expires-after=12w - push: true - tags: quay.io/invidious/invidious:${{ github.sha }}-quic,quay.io/invidious/invidious:latest-quic - build-args: release=1 - - - name: Build and push Docker ARM64 image with QUIC for Push Event - if: github.ref == 'refs/heads/master' - uses: docker/build-push-action@v3 - with: - context: . - file: docker/Dockerfile.arm64 - platforms: linux/arm64/v8 - labels: quay.expires-after=12w - push: true - tags: quay.io/invidious/invidious:${{ github.sha }}-arm64-quic,quay.io/invidious/invidious:latest-arm64-quic - build-args: release=1 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a945da58..a7e218a2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -14,7 +14,7 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 365 - days-before-pr-stale: 45 # PRs should be active. Anything that hasn't had activity in more than 45 days should be considered abandoned. + days-before-pr-stale: 90 days-before-close: 30 exempt-pr-labels: blocked stale-issue-message: 'This issue has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely outdated. If you think this issue is still relevant and applicable, you just have to post a comment and it will be unmarked.' diff --git a/Makefile b/Makefile index d4657792..9eb195df 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,6 @@ RELEASE := 1 STATIC := 0 -DISABLE_QUIC := 1 NO_DBG_SYMBOLS := 0 @@ -27,10 +26,6 @@ else FLAGS += --debug endif -ifeq ($(DISABLE_QUIC), 1) - FLAGS += -Ddisable_quic -endif - ifeq ($(API_ONLY), 1) FLAGS += -Dapi_only endif @@ -115,7 +110,6 @@ help: @echo " STATIC Link libraries statically (Default: 0)" @echo "" @echo " API_ONLY Build invidious without a GUI (Default: 0)" - @echo " DISABLE_QUIC Disable support for QUIC (Default: 0)" @echo " NO_DBG_SYMBOLS Strip debug symbols (Default: 0)" diff --git a/assets/css/default.css b/assets/css/default.css index 431a0427..b4053b5c 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -1,3 +1,7 @@ +/* + * Common attributes + */ + html, body { font-family: BlinkMacSystemFont, -apple-system, "Segoe UI", Roboto, Oxygen, @@ -11,6 +15,16 @@ body { min-height: 100vh; } +.h-box { + padding-left: 1em; + padding-right: 1em; +} + +.v-box { + padding-top: 1em; + padding-bottom: 1em; +} + .deleted { background-color: rgb(255, 0, 0, 0.5); } @@ -20,6 +34,34 @@ body { margin-bottom: 20px; } +.title { + margin: 0.5em 0 1em 0; +} + +/* A flex container */ +.flexible { + display: flex; + align-items: center; +} + +.flex-left { + display: flex; + flex: 1 1 auto; + flex-flow: row wrap; + justify-content: flex-start; +} +.flex-right { + display: flex; + flex: 2 0 auto; + flex-flow: row nowrap; + justify-content: flex-end; +} + + +/* + * Channel page + */ + .channel-profile > * { font-size: 1.17em; font-weight: bold; @@ -90,16 +132,6 @@ body a.channel-owner { } } -.h-box { - padding-left: 1em; - padding-right: 1em; -} - -.v-box { - padding-top: 1em; - padding-bottom: 1em; -} - div { overflow-wrap: break-word; word-wrap: break-word; @@ -115,6 +147,11 @@ div { padding-right: 10px; } + +/* + * Buttons + */ + body a.pure-button { color: rgba(0,0,0,.8); } @@ -127,30 +164,48 @@ body a.pure-button-primary, color: rgba(35, 35, 35, 1); } -button.pure-button-primary:hover, -body a.pure-button-primary:hover, -button.pure-button-primary:focus, -body a.pure-button-primary:focus { - background-color: rgba(0, 182, 240, 1); - color: #fff; +.pure-button-primary, +.pure-button-secondary { + border: 1px solid #a0a0a0; + border-radius: 3px; + margin: 0 .4em; } +.pure-button-secondary.low-profile { + padding: 5px 10px; + margin: 0; +} + +/* Has to be combined with flex-left/right */ +.button-container { + flex-flow: wrap; + gap: 0.5em 0.75em; +} + + +/* + * Video thumbnails + */ + div.thumbnail { - padding: 28.125%; position: relative; + width: 100%; box-sizing: border-box; } img.thumbnail { - position: absolute; + display: block; /* See: https://stackoverflow.com/a/11635197 */ width: 100%; - height: 100%; - left: 0; - top: 0; object-fit: cover; } +.thumbnail-placeholder { + min-height: 50px; + border: 2px dotted; +} + div.watched-overlay { + z-index: 50; position: absolute; top: 0; left: 0; @@ -168,30 +223,31 @@ div.watched-indicator { background-color: red; } -.length { +div.thumbnail > .top-left-overlay, +div.thumbnail > .bottom-right-overlay { z-index: 100; position: absolute; - background-color: rgba(35, 35, 35, 0.75); - color: #fff; - border-radius: 2px; - padding: 2px; + padding: 0; + margin: 0; font-size: 16px; - right: 0.25em; - bottom: -0.75em; } -.watched { - z-index: 100; - position: absolute; - background-color: rgba(35, 35, 35, 0.75); +.top-left-overlay { top: 0.6em; left: 0.6em; } +.bottom-right-overlay { bottom: 0.6em; right: 0.6em; } + +.length { + padding: 1px; + margin: -2px 0; color: #fff; - border-radius: 2px; - padding: 4px 8px 4px 8px; - font-size: 16px; - left: 0.2em; - top: -0.7em; + border-radius: 3px; } +.length, .top-left-overlay button { + color: #eee; + background-color: rgba(35, 35, 35, 0.85) !important; +} + + /* * Navbar */ @@ -267,6 +323,11 @@ input[type="search"]::-webkit-search-cancel-button { margin-right: 1em; } + +/* + * Responsive rules + */ + @media only screen and (max-aspect-ratio: 16/9) { .player-dimensions.vjs-fluid { padding-top: 46.86% !important; @@ -285,20 +346,28 @@ input[type="search"]::-webkit-search-cancel-button { .navbar > div { display: flex; justify-content: center; - } - - .navbar > div:not(:last-child) { - margin-bottom: 1em; + margin-bottom: 25px; } .navbar > .searchbar > form { - width: 60%; + width: 75%; } h1 { font-size: 1.25em; margin: 0.42em 0; } + + /* Space out the subscribe & RSS buttons and align them to the left */ + .title.flexible { display: block; } + .title.flexible > .flex-right { margin: 0.75em 0; justify-content: flex-start; } + + /* Space out buttons to make them easier to tap */ + .user-field { font-size: 125%; } + .user-field > :not(:last-child) { margin-right: 1.75em; } + + .icon-buttons { font-size: 125%; } + .icon-buttons > :not(:last-child) { margin-right: 0.75em; } } @media screen and (max-width: 320px) { @@ -315,10 +384,6 @@ input[type="search"]::-webkit-search-cancel-button { .video-card-row { margin: 15px 0; } -.flexible { display: flex; } -.flex-left { flex: 1 1 100%; flex-wrap: wrap; } -.flex-right { flex: 1 0 auto; flex-wrap: nowrap; } - p.channel-name { margin: 0; } p.video-data { margin: 0; font-weight: bold; font-size: 80%; } @@ -327,11 +392,19 @@ p.video-data { margin: 0; font-weight: bold; font-size: 80%; } * Comments & community posts */ -#comments { +.comments { max-width: 800px; margin: auto; } +/* + * We don't want the top and bottom margin on the post page. + */ +.comments.post-comments { + margin-bottom: 0; + margin-top: 0; +} + .video-iframe-wrapper { position: relative; height: 0; @@ -347,6 +420,22 @@ p.video-data { margin: 0; font-weight: bold; font-size: 80%; } border: none; } + +/* + * Page navigation + */ + +.page-nav-container { margin: 15px 0 30px 0; } + +.page-prev-container { text-align: start; } +.page-next-container { text-align: end; } + +.page-prev-container, +.page-next-container { + display: inline-block; +} + + /* * Footer */ @@ -389,6 +478,7 @@ span > select { word-wrap: normal; } + /* * Light theme */ @@ -401,9 +491,18 @@ span > select { color: #075A9E !important; } -.light-theme a.pure-button-primary:hover, -.light-theme a.pure-button-primary:focus { +.light-theme .pure-button-primary:hover, +.light-theme .pure-button-primary:focus, +.light-theme .pure-button-secondary:hover, +.light-theme .pure-button-secondary:focus { color: #fff !important; + border-color: rgba(0, 182, 240, 0.75) !important; + background-color: rgba(0, 182, 240, 0.75) !important; +} + +.light-theme .pure-button-secondary:not(.low-profile) { + color: #335d7a; + background-color: #fff2; } .light-theme a { @@ -431,9 +530,18 @@ span > select { color: #075A9E !important; } - .no-theme a.pure-button-primary:hover, - .no-theme a.pure-button-primary:focus { + .no-theme .pure-button-primary:hover, + .no-theme .pure-button-primary:focus, + .no-theme .pure-button-secondary:hover, + .no-theme .pure-button-secondary:focus { color: #fff !important; + border-color: rgba(0, 182, 240, 0.75) !important; + background-color: rgba(0, 182, 240, 0.75) !important; + } + + .no-theme .pure-button-secondary:not(.low-profile) { + color: #335d7a; + background-color: #fff2; } .no-theme a { @@ -453,6 +561,7 @@ span > select { } } + /* * Dark theme */ @@ -465,6 +574,20 @@ span > select { color: rgb(0, 182, 240); } +.dark-theme .pure-button-primary:hover, +.dark-theme .pure-button-primary:focus, +.dark-theme .pure-button-secondary:hover, +.dark-theme .pure-button-secondary:focus { + color: #fff !important; + border-color: rgb(0, 182, 240) !important; + background-color: rgba(0, 182, 240, 1) !important; +} + +.dark-theme .pure-button-secondary { + background-color: #0002; + color: #ddd; +} + .dark-theme a { color: #a0a0a0; text-decoration: none; @@ -505,6 +628,20 @@ body.dark-theme { color: rgb(0, 182, 240); } + .no-theme .pure-button-primary:hover, + .no-theme .pure-button-primary:focus, + .no-theme .pure-button-secondary:hover, + .no-theme .pure-button-secondary:focus { + color: #fff !important; + border-color: rgb(0, 182, 240) !important; + background-color: rgba(0, 182, 240, 1) !important; + } + + .no-theme .pure-button-secondary { + background-color: #0002; + color: #ddd; + } + .no-theme a { color: #a0a0a0; text-decoration: none; @@ -539,6 +676,12 @@ body.dark-theme { } } + +/* + * Miscellanous + */ + + /*With commit d9528f5 all contents of the page is now within a flexbox. However, the hr element is rendered improperly within one. See https://stackoverflow.com/a/34372979 for more info */ @@ -576,12 +719,7 @@ label[for="music-desc-expansion"]:hover { } /* Bidi (bidirectional text) support */ -h1, -h2, -h3, -h4, -h5, -p, +h1, h2, h3, h4, h5, p, #descriptionWrapper, #description-box, #music-description-box { diff --git a/assets/hashtag.svg b/assets/hashtag.svg new file mode 100644 index 00000000..55109825 --- /dev/null +++ b/assets/hashtag.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/js/comments.js b/assets/js/comments.js new file mode 100644 index 00000000..35ffa96e --- /dev/null +++ b/assets/js/comments.js @@ -0,0 +1,174 @@ +var video_data = JSON.parse(document.getElementById('video_data').textContent); + +var spinnerHTML = '

'; +var spinnerHTMLwithHR = spinnerHTML + '
'; + +String.prototype.supplant = function (o) { + return this.replace(/{([^{}]*)}/g, function (a, b) { + var r = o[b]; + return typeof r === 'string' || typeof r === 'number' ? r : a; + }); +}; + +function toggle_comments(event) { + var target = event.target; + var body = target.parentNode.parentNode.parentNode.children[1]; + if (body.style.display === 'none') { + target.textContent = '[ − ]'; + body.style.display = ''; + } else { + target.textContent = '[ + ]'; + body.style.display = 'none'; + } +} + +function hide_youtube_replies(event) { + var target = event.target; + + var sub_text = target.getAttribute('data-inner-text'); + var inner_text = target.getAttribute('data-sub-text'); + + var body = target.parentNode.parentNode.children[1]; + body.style.display = 'none'; + + target.textContent = sub_text; + target.onclick = show_youtube_replies; + target.setAttribute('data-inner-text', inner_text); + target.setAttribute('data-sub-text', sub_text); +} + +function show_youtube_replies(event) { + var target = event.target; + + var sub_text = target.getAttribute('data-inner-text'); + var inner_text = target.getAttribute('data-sub-text'); + + var body = target.parentNode.parentNode.children[1]; + body.style.display = ''; + + target.textContent = sub_text; + target.onclick = hide_youtube_replies; + target.setAttribute('data-inner-text', inner_text); + target.setAttribute('data-sub-text', sub_text); +} + +function get_youtube_comments() { + var comments = document.getElementById('comments'); + + var fallback = comments.innerHTML; + comments.innerHTML = spinnerHTML; + + var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id + var url = baseUrl + + '?format=html' + + '&hl=' + video_data.preferences.locale + + '&thin_mode=' + video_data.preferences.thin_mode; + + if (video_data.ucid) { + url += '&ucid=' + video_data.ucid + } + + var onNon200 = function (xhr) { comments.innerHTML = fallback; }; + if (video_data.params.comments[1] === 'youtube') + onNon200 = function (xhr) {}; + + helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, { + on200: function (response) { + var commentInnerHtml = ' \ +
\ +

\ + [ − ] \ + {commentsText} \ +

\ + \ + ' + if (video_data.support_reddit) { + commentInnerHtml += ' \ + {redditComments} \ + \ + ' + } + commentInnerHtml += ' \ +
\ +
{contentHtml}
\ +
' + commentInnerHtml = commentInnerHtml.supplant({ + contentHtml: response.contentHtml, + redditComments: video_data.reddit_comments_text, + commentsText: video_data.comments_text.supplant({ + // toLocaleString correctly splits number with local thousands separator. e.g.: + // '1,234,567.89' for user with English locale + // '1 234 567,89' for user with Russian locale + // '1.234.567,89' for user with Portuguese locale + commentCount: response.commentCount.toLocaleString() + }) + }); + comments.innerHTML = commentInnerHtml; + comments.children[0].children[0].children[0].onclick = toggle_comments; + if (video_data.support_reddit) { + comments.children[0].children[1].children[0].onclick = swap_comments; + } + }, + onNon200: onNon200, // declared above + onError: function (xhr) { + comments.innerHTML = spinnerHTML; + }, + onTimeout: function (xhr) { + comments.innerHTML = spinnerHTML; + } + }); +} + +function get_youtube_replies(target, load_more, load_replies) { + var continuation = target.getAttribute('data-continuation'); + + var body = target.parentNode.parentNode; + var fallback = body.innerHTML; + body.innerHTML = spinnerHTML; + var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id + var url = baseUrl + + '?format=html' + + '&hl=' + video_data.preferences.locale + + '&thin_mode=' + video_data.preferences.thin_mode + + '&continuation=' + continuation; + + if (video_data.ucid) { + url += '&ucid=' + video_data.ucid + } + if (load_replies) url += '&action=action_get_comment_replies'; + + helpers.xhr('GET', url, {}, { + on200: function (response) { + if (load_more) { + body = body.parentNode.parentNode; + body.removeChild(body.lastElementChild); + body.insertAdjacentHTML('beforeend', response.contentHtml); + } else { + body.removeChild(body.lastElementChild); + + var p = document.createElement('p'); + var a = document.createElement('a'); + p.appendChild(a); + + a.href = 'javascript:void(0)'; + a.onclick = hide_youtube_replies; + a.setAttribute('data-sub-text', video_data.hide_replies_text); + a.setAttribute('data-inner-text', video_data.show_replies_text); + a.textContent = video_data.hide_replies_text; + + var div = document.createElement('div'); + div.innerHTML = response.contentHtml; + + body.appendChild(p); + body.appendChild(div); + } + }, + onNon200: function (xhr) { + body.innerHTML = fallback; + }, + onTimeout: function (xhr) { + console.warn('Pulling comments failed'); + body.innerHTML = fallback; + } + }); +} \ No newline at end of file diff --git a/assets/js/post.js b/assets/js/post.js new file mode 100644 index 00000000..fcbc9155 --- /dev/null +++ b/assets/js/post.js @@ -0,0 +1,3 @@ +addEventListener('load', function (e) { + get_youtube_comments(); +}); diff --git a/assets/js/watch.js b/assets/js/watch.js index 36506abd..26ad138f 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -1,14 +1,4 @@ 'use strict'; -var video_data = JSON.parse(document.getElementById('video_data').textContent); -var spinnerHTML = '

'; -var spinnerHTMLwithHR = spinnerHTML + '
'; - -String.prototype.supplant = function (o) { - return this.replace(/{([^{}]*)}/g, function (a, b) { - var r = o[b]; - return typeof r === 'string' || typeof r === 'number' ? r : a; - }); -}; function toggle_parent(target) { var body = target.parentNode.parentNode.children[1]; @@ -21,18 +11,6 @@ function toggle_parent(target) { } } -function toggle_comments(event) { - var target = event.target; - var body = target.parentNode.parentNode.parentNode.children[1]; - if (body.style.display === 'none') { - target.textContent = '[ − ]'; - body.style.display = ''; - } else { - target.textContent = '[ + ]'; - body.style.display = 'none'; - } -} - function swap_comments(event) { var source = event.target.getAttribute('data-comments'); @@ -43,36 +21,6 @@ function swap_comments(event) { } } -function hide_youtube_replies(event) { - var target = event.target; - - var sub_text = target.getAttribute('data-inner-text'); - var inner_text = target.getAttribute('data-sub-text'); - - var body = target.parentNode.parentNode.children[1]; - body.style.display = 'none'; - - target.textContent = sub_text; - target.onclick = show_youtube_replies; - target.setAttribute('data-inner-text', inner_text); - target.setAttribute('data-sub-text', sub_text); -} - -function show_youtube_replies(event) { - var target = event.target; - - var sub_text = target.getAttribute('data-inner-text'); - var inner_text = target.getAttribute('data-sub-text'); - - var body = target.parentNode.parentNode.children[1]; - body.style.display = ''; - - target.textContent = sub_text; - target.onclick = hide_youtube_replies; - target.setAttribute('data-inner-text', inner_text); - target.setAttribute('data-sub-text', sub_text); -} - var continue_button = document.getElementById('continue'); if (continue_button) { continue_button.onclick = continue_autoplay; @@ -208,111 +156,6 @@ function get_reddit_comments() { }); } -function get_youtube_comments() { - var comments = document.getElementById('comments'); - - var fallback = comments.innerHTML; - comments.innerHTML = spinnerHTML; - - var url = '/api/v1/comments/' + video_data.id + - '?format=html' + - '&hl=' + video_data.preferences.locale + - '&thin_mode=' + video_data.preferences.thin_mode; - - var onNon200 = function (xhr) { comments.innerHTML = fallback; }; - if (video_data.params.comments[1] === 'youtube') - onNon200 = function (xhr) {}; - - helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, { - on200: function (response) { - comments.innerHTML = ' \ -
\ -

\ - [ − ] \ - {commentsText} \ -

\ - \ - \ - {redditComments} \ - \ - \ -
\ -
{contentHtml}
\ -
'.supplant({ - contentHtml: response.contentHtml, - redditComments: video_data.reddit_comments_text, - commentsText: video_data.comments_text.supplant({ - // toLocaleString correctly splits number with local thousands separator. e.g.: - // '1,234,567.89' for user with English locale - // '1 234 567,89' for user with Russian locale - // '1.234.567,89' for user with Portuguese locale - commentCount: response.commentCount.toLocaleString() - }) - }); - - comments.children[0].children[0].children[0].onclick = toggle_comments; - comments.children[0].children[1].children[0].onclick = swap_comments; - }, - onNon200: onNon200, // declared above - onError: function (xhr) { - comments.innerHTML = spinnerHTML; - }, - onTimeout: function (xhr) { - comments.innerHTML = spinnerHTML; - } - }); -} - -function get_youtube_replies(target, load_more, load_replies) { - var continuation = target.getAttribute('data-continuation'); - - var body = target.parentNode.parentNode; - var fallback = body.innerHTML; - body.innerHTML = spinnerHTML; - - var url = '/api/v1/comments/' + video_data.id + - '?format=html' + - '&hl=' + video_data.preferences.locale + - '&thin_mode=' + video_data.preferences.thin_mode + - '&continuation=' + continuation; - if (load_replies) url += '&action=action_get_comment_replies'; - - helpers.xhr('GET', url, {}, { - on200: function (response) { - if (load_more) { - body = body.parentNode.parentNode; - body.removeChild(body.lastElementChild); - body.insertAdjacentHTML('beforeend', response.contentHtml); - } else { - body.removeChild(body.lastElementChild); - - var p = document.createElement('p'); - var a = document.createElement('a'); - p.appendChild(a); - - a.href = 'javascript:void(0)'; - a.onclick = hide_youtube_replies; - a.setAttribute('data-sub-text', video_data.hide_replies_text); - a.setAttribute('data-inner-text', video_data.show_replies_text); - a.textContent = video_data.hide_replies_text; - - var div = document.createElement('div'); - div.innerHTML = response.contentHtml; - - body.appendChild(p); - body.appendChild(div); - } - }, - onNon200: function (xhr) { - body.innerHTML = fallback; - }, - onTimeout: function (xhr) { - console.warn('Pulling comments failed'); - body.innerHTML = fallback; - } - }); -} - if (video_data.play_next) { player.on('ended', function () { var url = new URL('https://example.com/watch?v=' + video_data.next_video); diff --git a/assets/site.webmanifest b/assets/site.webmanifest index af9432d7..2db6ed9e 100644 --- a/assets/site.webmanifest +++ b/assets/site.webmanifest @@ -15,5 +15,7 @@ ], "theme_color": "#575757", "background_color": "#575757", - "display": "standalone" + "display": "standalone", + "description": "An alternative front-end to YouTube", + "start_url": "/" } diff --git a/config/config.example.yml b/config/config.example.yml index 34070fe5..b44fcc0e 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -140,27 +140,6 @@ https_only: false ## #pool_size: 100 -## -## Enable/Disable the use of QUIC (HTTP/3) when connecting -## to the youtube API and websites ('youtube.com', 'ytimg.com'). -## QUIC's main advantages are its lower latency and lower bandwidth -## use, compared to its predecessors. However, the current version -## of QUIC used in invidious is still based on the IETF draft 31, -## meaning that the underlying library may still not be fully -## optimized. You can read more about QUIC at the link below: -## https://datatracker.ietf.org/doc/html/draft-ietf-quic-transport-31 -## -## Note: you should try both options and see what is the best for your -## instance. In general QUIC is recommended for public instances. Your -## mileage may vary. -## -## Note 2: Using QUIC prevents some captcha challenges from appearing. -## See: https://github.com/iv-org/invidious/issues/957#issuecomment-576424042 -## -## Accepted values: true, false -## Default: false -## -#use_quic: false ## ## Additional cookies to be sent when requesting the youtube API. @@ -182,6 +161,19 @@ https_only: false #force_resolve: +## +## Use Innertube's transcripts API instead of timedtext for closed captions +## +## Useful for larger instances as InnerTube is **not ratelimited**. See https://github.com/iv-org/invidious/issues/2567 +## +## Subtitle experience may differ slightly on Invidious. +## +## Accepted values: true, false +## Default: false +## +# use_innertube_for_captions: false + + # ----------------------------- # Logging # ----------------------------- diff --git a/docker/Dockerfile b/docker/Dockerfile index 57864883..761bbdca 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,15 +2,12 @@ FROM crystallang/crystal:1.4.1-alpine AS builder RUN apk add --no-cache sqlite-static yaml-static ARG release -ARG disable_quic WORKDIR /invidious COPY ./shard.yml ./shard.yml COPY ./shard.lock ./shard.lock RUN shards install --production -COPY --from=quay.io/invidious/lsquic-compiled /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a - COPY ./src/ ./src/ # TODO: .git folder is required for building – this is destructive. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. @@ -24,13 +21,7 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml RUN crystal spec --warnings all \ --link-flags "-lxml2 -llzma" -RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \ - crystal build ./src/invidious.cr \ - --release \ - -Ddisable_quic \ - --static --warnings all \ - --link-flags "-lxml2 -llzma"; \ - elif [[ "${release}" == 1 ]] ; then \ +RUN if [[ "${release}" == 1 ]] ; then \ crystal build ./src/invidious.cr \ --release \ --static --warnings all \ diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 index 10135efb..cf9231fb 100644 --- a/docker/Dockerfile.arm64 +++ b/docker/Dockerfile.arm64 @@ -2,15 +2,12 @@ FROM alpine:3.16 AS builder RUN apk add --no-cache 'crystal=1.4.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev ARG release -ARG disable_quic WORKDIR /invidious COPY ./shard.yml ./shard.yml COPY ./shard.lock ./shard.lock RUN shards install --production -COPY --from=quay.io/invidious/lsquic-compiled /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a - COPY ./src/ ./src/ # TODO: .git folder is required for building – this is destructive. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. @@ -24,13 +21,7 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml RUN crystal spec --warnings all \ --link-flags "-lxml2 -llzma" -RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \ - crystal build ./src/invidious.cr \ - --release \ - -Ddisable_quic \ - --static --warnings all \ - --link-flags "-lxml2 -llzma"; \ - elif [[ "${release}" == 1 ]] ; then \ +RUN if [[ "${release}" == 1 ]] ; then \ crystal build ./src/invidious.cr \ --release \ --static --warnings all \ diff --git a/locales/ar.json b/locales/ar.json index c137d1a3..18298913 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -540,5 +540,19 @@ "Channel Sponsor": "راعي القناة", "Standard YouTube license": "ترخيص YouTube القياسي", "Download is disabled": "تم تعطيل التحميلات", - "Import YouTube playlist (.csv)": "استيراد قائمة تشغيل YouTube (.csv)" + "Import YouTube playlist (.csv)": "استيراد قائمة تشغيل YouTube (.csv)", + "generic_button_save": "حفظ", + "generic_button_delete": "حذف", + "generic_button_edit": "تحرير", + "generic_button_cancel": "الغاء", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "الإصدارات", + "playlist_button_add_items": "إضافة مقاطع فيديو", + "channel_tab_podcasts_label": "البودكاست", + "generic_channels_count_0": "{{count}} قناة", + "generic_channels_count_1": "{{count}} قناة", + "generic_channels_count_2": "{{count}} قناتان", + "generic_channels_count_3": "{{count}} قنوات", + "generic_channels_count_4": "{{count}} قنوات", + "generic_channels_count_5": "{{count}} قناة" } diff --git a/locales/az.json b/locales/az.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/az.json @@ -0,0 +1 @@ +{} diff --git a/locales/be.json b/locales/be.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/be.json @@ -0,0 +1 @@ +{} diff --git a/locales/bg.json b/locales/bg.json new file mode 100644 index 00000000..82591ed8 --- /dev/null +++ b/locales/bg.json @@ -0,0 +1,490 @@ +{ + "Korean (auto-generated)": "Корейски (автоматично генерирано)", + "search_filters_features_option_three_sixty": "360°", + "published - reverse": "публикувани - в обратен ред", + "preferences_quality_dash_option_worst": "Най-ниско качество", + "Password is a required field": "Парола е задължитело поле", + "channel_tab_podcasts_label": "Подкасти", + "Token is expired, please try again": "Токенът е изтекъл, моля опитайте отново", + "Turkish": "Турски", + "preferences_save_player_pos_label": "Запази позицията на плейъра: ", + "View Reddit comments": "Виж Reddit коментари", + "Export data as JSON": "Експортиране на Invidious информацията като JSON", + "About": "За сайта", + "Save preferences": "Запази промените", + "Load more": "Зареди още", + "Import/export": "Импортиране/експортиране", + "Albanian": "Албански", + "New password": "Нова парола", + "Southern Sotho": "Южен Сото", + "channel_tab_videos_label": "Видеа", + "Spanish (Mexico)": "Испански (Мексико)", + "preferences_player_style_label": "Стил на плейъра: ", + "preferences_region_label": "Държавата на съдържанието: ", + "Premieres in `x`": "Премиера в `x`", + "Watch history": "История на гледане", + "generic_subscriptions_count": "{{count}} абонамент", + "generic_subscriptions_count_plural": "{{count}} абонамента", + "preferences_continue_label": "Пускай следващото видео автоматично: ", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Здравей! Изглежда си изключил JavaScript. Натисни тук за да видиш коментарите, но обърни внимание, че може да отнеме повече време да заредят.", + "Polish": "Полски", + "Icelandic": "Исландски", + "preferences_local_label": "Пускане на видеа през прокси: ", + "Hebrew": "Иврит", + "Fallback captions: ": "Резервни надписи: ", + "search_filters_title": "Филтри", + "search_filters_apply_button": "Приложете избрани филтри", + "Download is disabled": "Изтеглянето е деактивирано", + "User ID is a required field": "Потребителско име е задължително поле", + "comments_points_count": "{{count}} точка", + "comments_points_count_plural": "{{count}} точки", + "next_steps_error_message_go_to_youtube": "Отидеш в YouTube", + "preferences_quality_dash_option_2160p": "2160p", + "search_filters_type_option_video": "Видео", + "Spanish (Latin America)": "Испански (Латинска Америка)", + "Download as: ": "Изтегли като: ", + "Default": "По подразбиране", + "search_filters_sort_option_views": "Гледания", + "search_filters_features_option_four_k": "4K", + "Igbo": "Игбо", + "Subscriptions": "Абонаменти", + "German (auto-generated)": "Немски (автоматично генерирано)", + "`x` is live": "`x` е на живо", + "Azerbaijani": "Азербайджански", + "Premieres `x`": "Премиера `x`", + "Japanese (auto-generated)": "Японски (автоматично генерирано)", + "preferences_quality_option_medium": "Средно", + "footer_donate_page": "Даряване", + "Show replies": "Покажи отговорите", + "Esperanto": "Есперанто", + "search_message_change_filters_or_query": "Опитай да разшириш търсенето си и/или да смениш филтрите.", + "CAPTCHA enabled: ": "Активиране на CAPTCHA: ", + "View playlist on YouTube": "Виж плейлиста в YouTube", + "crash_page_before_reporting": "Преди докладването на бъг, бъди сигурен, че си:", + "Top enabled: ": "Активиране на страница с топ видеа: ", + "preferences_quality_dash_option_best": "Най-високо", + "search_filters_duration_label": "Продължителност", + "Slovak": "Словашки", + "Channel Sponsor": "Канален спонсор", + "generic_videos_count": "{{count}} видео", + "generic_videos_count_plural": "{{count}} видеа", + "videoinfo_started_streaming_x_ago": "Започна да излъчва преди `x`", + "videoinfo_youTube_embed_link": "Вграждане", + "channel_tab_streams_label": "Стриймове", + "oldest": "най-стари", + "playlist_button_add_items": "Добавяне на видеа", + "Import NewPipe data (.zip)": "Импортиране на NewPipe информация (.zip)", + "Clear watch history": "Изчистване на историята на гледане", + "generic_count_minutes": "{{count}} минута", + "generic_count_minutes_plural": "{{count}} минути", + "published": "публикувани", + "Show annotations": "Покажи анотации", + "Login enabled: ": "Активиране на впизване: ", + "Somali": "Сомалийски", + "YouTube comment permalink": "Постоянна връзка на коментарите на YouTube", + "Kurdish": "Кюрдски", + "search_filters_date_option_hour": "Последния час", + "Lao": "Лаоски", + "Maltese": "Малтийски", + "Register": "Регистрация", + "View channel on YouTube": "Виж канала в YouTube", + "Playlist privacy": "Поверителен плейлист", + "preferences_unseen_only_label": "Показвай само негледаните: ", + "Gujarati": "Гуджарати", + "Please log in": "Моля влезте", + "search_filters_sort_option_rating": "Рейтинг", + "Manage subscriptions": "Управление на абонаментите", + "preferences_quality_dash_option_720p": "720p", + "preferences_watch_history_label": "Активирай историята на гледане: ", + "user_saved_playlists": "`x` запази плейлисти", + "preferences_extend_desc_label": "Автоматично разшири описанието на видеото ", + "preferences_max_results_label": "Брой видеа показани на началната страница: ", + "Spanish (Spain)": "Испански (Испания)", + "invidious": "Invidious", + "crash_page_refresh": "пробвал да опресниш страницата", + "Image CAPTCHA": "CAPTCHA с Изображение", + "search_filters_features_option_hd": "HD", + "Chinese (Hong Kong)": "Китайски (Хонг Конг)", + "Import Invidious data": "Импортиране на Invidious JSON информацията", + "Blacklisted regions: ": "Неразрешени региони: ", + "Only show latest video from channel: ": "Показвай само най-новите видеа в канала: ", + "Hmong": "Хмонг", + "French": "Френски", + "search_filters_type_option_channel": "Канал", + "Artist: ": "Артист: ", + "generic_count_months": "{{count}} месец", + "generic_count_months_plural": "{{count}} месеца", + "preferences_annotations_subscribed_label": "Показвай анотаций по подразбиране за абонирани канали? ", + "search_message_use_another_instance": " Можеш също да търсиш на друга инстанция.", + "Danish": "Датски", + "generic_subscribers_count": "{{count}} абонат", + "generic_subscribers_count_plural": "{{count}} абоната", + "Galician": "Галисий", + "newest": "най-нови", + "Empty playlist": "Плейлиста е празен", + "download_subtitles": "Субритри - `x` (.vtt)", + "preferences_category_misc": "Различни предпочитания", + "Uzbek": "Узбекски", + "View JavaScript license information.": "Виж Javascript лиценза.", + "Filipino": "Филипински", + "Malagasy": "Мадагаскарски", + "generic_button_save": "Запиши", + "Dark mode: ": "Тъмен режим: ", + "Public": "Публичен", + "Basque": "Баскски", + "channel:`x`": "Канал:`x`", + "Armenian": "Арменски", + "This channel does not exist.": "Този канал не съществува.", + "Luxembourgish": "Люксембургски", + "preferences_related_videos_label": "Покажи подобни видеа: ", + "English": "Английски", + "Delete account": "Изтриване на акаунт", + "Gaming": "Игри", + "Video mode": "Видео режим", + "preferences_dark_mode_label": "Тема: ", + "crash_page_search_issue": "потърсил за съществуващи проблеми в GitHub", + "preferences_category_subscription": "Предпочитания за абонаменти", + "last": "най-скорощни", + "Chinese (Simplified)": "Китайски (Опростен)", + "Could not create mix.": "Създаването на микс е неуспешно.", + "generic_button_cancel": "Отказ", + "search_filters_type_option_movie": "Филм", + "search_filters_date_option_year": "Тази година", + "Swedish": "Шведски", + "Previous page": "Предишна страница", + "none": "нищо", + "popular": "най-популярни", + "Unsubscribe": "Отписване", + "Slovenian": "Словенски", + "Nepali": "Непалски", + "Time (h:mm:ss):": "Време (h:mm:ss):", + "English (auto-generated)": "Английски (автоматично генерирано)", + "search_filters_sort_label": "Сортирай по", + "View more comments on Reddit": "Виж повече коментари в Reddit", + "Sinhala": "Синхалски", + "preferences_feed_menu_label": "Меню с препоръки: ", + "preferences_autoplay_label": "Автоматично пускане: ", + "Pashto": "Пущунски", + "English (United States)": "Английски (САЩ)", + "Sign In": "Вход", + "subscriptions_unseen_notifs_count": "{{count}} невидяно известие", + "subscriptions_unseen_notifs_count_plural": "{{count}} невидяни известия", + "Log in": "Вход", + "Engagement: ": "Участие: ", + "Album: ": "Албум: ", + "preferences_speed_label": "Скорост по подразбиране: ", + "Import FreeTube subscriptions (.db)": "Импортиране на FreeTube абонаменти (.db)", + "preferences_quality_option_dash": "DASH (адаптивно качество)", + "preferences_show_nick_label": "Показвай потребителското име отгоре: ", + "Private": "Частен", + "Samoan": "Самоански", + "preferences_notifications_only_label": "Показвай само известията (ако има такива): ", + "Create playlist": "Създаване на плейлист", + "next_steps_error_message_refresh": "Опресниш", + "Top": "Топ", + "preferences_quality_dash_option_1080p": "1080p", + "Malayalam": "Малаялам", + "Token": "Токен", + "preferences_comments_label": "Коментари по подразбиране: ", + "Movies": "Филми", + "light": "светла", + "Unlisted": "Скрит", + "preferences_category_admin": "Администраторни предпочитания", + "Erroneous token": "Невалиден токен", + "No": "Не", + "CAPTCHA is a required field": "CAPTCHA е задължително поле", + "Video unavailable": "Неналично видео", + "footer_source_code": "Изходен код", + "New passwords must match": "Новите пароли трябва да съвпадат", + "Playlist does not exist.": "Плейлиста не съществува.", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Експортиране на абонаментите като OPML (за NewPipe и FreeTube)", + "search_filters_duration_option_short": "Кратко (< 4 минути)", + "search_filters_duration_option_long": "Дълго (> 20 минути)", + "tokens_count": "{{count}} токен", + "tokens_count_plural": "{{count}} токена", + "Yes": "Да", + "Dutch": "Холандски", + "Arabic": "Арабски", + "An alternative front-end to YouTube": "Алтернативен преден план на YouTube", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Виж `x` коментар", + "": "Виж `x` коментари" + }, + "Chinese (China)": "Китайски (Китай)", + "Italian (auto-generated)": "Италиански (автоматично генерирано)", + "alphabetically - reverse": "обратно на азбучния ред", + "channel_tab_shorts_label": "Shorts", + "`x` marked it with a ❤": "`x` го маркира със ❤", + "Current version: ": "Текуща версия: ", + "channel_tab_community_label": "Общност", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_360p": "360p", + "`x` uploaded a video": "`x` качи видео", + "Welsh": "Уелски", + "search_message_no_results": "Няма намерени резултати.", + "channel_tab_releases_label": "Версии", + "Bangla": "Бенгалски", + "preferences_quality_dash_option_144p": "144p", + "Indonesian": "Индонезийски", + "`x` ago": "преди `x`", + "Invidious Private Feed for `x`": "Invidious персонални видеа за `x`", + "Finnish": "Финландски", + "Amharic": "Амхарски", + "Malay": "Малайски", + "Interlingue": "Интерлинг", + "search_filters_date_option_month": "Този месец", + "Georgian": "Грузински", + "Xhosa": "Кхоса", + "Marathi": "Маратхи", + "Yoruba": "Йоруба", + "Song: ": "Музика: ", + "Scottish Gaelic": "Шотландски гелски", + "search_filters_features_label": "Функции", + "preferences_quality_label": "Предпочитано качество на видеото: ", + "generic_channels_count": "{{count}} канал", + "generic_channels_count_plural": "{{count}} канала", + "Croatian": "Хърватски", + "Thai": "Тайски", + "Chinese (Taiwan)": "Китайски (Тайван)", + "youtube": "YouTube", + "Source available here.": "Източник наличен тук.", + "LIVE": "На живо", + "Ukrainian": "Украински", + "Russian": "Руски", + "Tajik": "Таджикски", + "Token manager": "Управляване на токени", + "preferences_quality_dash_label": "Предпочитано DASH качество на видеото: ", + "adminprefs_modified_source_code_url_label": "URL до хранилището на променения изходен код", + "Japanese": "Японски", + "Title": "Заглавие", + "Authorize token for `x`?": "Разреши токена за `x`?", + "reddit": "Reddit", + "permalink": "постоянна връзка", + "Trending": "На върха", + "Turkish (auto-generated)": "Турски (автоматично генерирано)", + "Bulgarian": "Български", + "Indonesian (auto-generated)": "Индонезийски (автоматично генерирано)", + "Enable web notifications": "Активирай уеб известия", + "Western Frisian": "Западен фризски", + "search_filters_date_option_week": "Тази седмица", + "Yiddish": "Идиш", + "preferences_category_player": "Предпочитания за плейъра", + "Shared `x` ago": "Споделено преди `x`", + "Swahili": "Суахили", + "Portuguese (auto-generated)": "Португалски (автоматично генерирано)", + "generic_count_years": "{{count}} година", + "generic_count_years_plural": "{{count}} години", + "Wilson score: ": "Wilson оценка: ", + "Genre: ": "Жанр: ", + "videoinfo_invidious_embed_link": "Вграждане на линк", + "Popular enabled: ": "Активиране на популярната страница: ", + "Wrong username or password": "Грешно потребителско име или парола", + "Vietnamese": "Виетнамски", + "alphabetically": "по азбучен ред", + "Afrikaans": "Африкаанс", + "Zulu": "Зулуски", + "(edited)": "(редактирано)", + "Whitelisted regions: ": "Разрешени региони: ", + "Spanish (auto-generated)": "Испански (автоматично генерирано)", + "Could not fetch comments": "Получаването на коментарите е неуспешно", + "Sindhi": "Синдхи", + "News": "Новини", + "preferences_video_loop_label": "Винаги повтаряй: ", + "%A %B %-d, %Y": "%-d %B %Y, %A", + "preferences_quality_option_small": "Ниско", + "English (United Kingdom)": "Английски (Великобритания)", + "Rating: ": "Рейтинг: ", + "channel_tab_playlists_label": "Плейлисти", + "generic_button_edit": "Редактирай", + "Report statistics: ": "Активиране на статистики за репортиране: ", + "Cebuano": "Себуано", + "Chinese (Traditional)": "Китайски (Традиционен)", + "generic_playlists_count": "{{count}} плейлист", + "generic_playlists_count_plural": "{{count}} плейлиста", + "Import NewPipe subscriptions (.json)": "Импортиране на NewPipe абонаменти (.json)", + "Preferences": "Предпочитания", + "Subscribe": "Абониране", + "Import and Export Data": "Импортиране и експортиране на информация", + "preferences_quality_option_hd720": "HD720", + "search_filters_type_option_playlist": "Плейлист", + "Serbian": "Сръбски", + "Kazakh": "Казахски", + "Telugu": "Телугу", + "search_filters_features_option_purchased": "Купено", + "revoke": "отмяна", + "search_filters_sort_option_date": "Дата на качване", + "preferences_category_data": "Предпочитания за информацията", + "search_filters_date_option_none": "Всякаква дата", + "Log out": "Излизане", + "Search": "Търсене", + "preferences_quality_dash_option_auto": "Автоматично", + "dark": "тъмна", + "Cantonese (Hong Kong)": "Кантонски (Хонг Конг)", + "crash_page_report_issue": "Ако никои от горепосочените не помогнаха, моля отворете нов проблем в GitHub (предпочитано на Английски) и добавете следния текст в съобщението (НЕ превеждайте този текст):", + "Czech": "Чешки", + "crash_page_switch_instance": "пробвал да ползваш друга инстанция", + "generic_count_weeks": "{{count}} седмица", + "generic_count_weeks_plural": "{{count}} седмици", + "search_filters_features_option_subtitles": "Субтитри", + "videoinfo_watch_on_youTube": "Виж в YouTube", + "Portuguese": "Португалски", + "Music in this video": "Музика в това видео", + "Hide replies": "Скрий отговорите", + "Password cannot be longer than 55 characters": "Паролата не може да бъде по-дълга от 55 символа", + "footer_modfied_source_code": "Променен изходен код", + "Bosnian": "Босненски", + "Deleted or invalid channel": "Изтрит или невалиден канал", + "Popular": "Популярно", + "search_filters_type_label": "Тип", + "preferences_locale_label": "Език: ", + "Playlists": "Плейлисти", + "generic_button_rss": "RSS", + "Export": "Експортиране", + "preferences_quality_dash_option_4320p": "4320p", + "Erroneous challenge": "Невалиден тест", + "History": "История", + "generic_count_hours": "{{count}} час", + "generic_count_hours_plural": "{{count}} часа", + "Registration enabled: ": "Активиране на регистрация: ", + "Music": "Музика", + "Incorrect password": "Грешна парола", + "Persian": "Перскийски", + "Import": "Импортиране", + "Import/export data": "Импортиране/Експортиране на информация", + "Shared `x`": "Споделено `x`", + "Javanese": "Явански", + "French (auto-generated)": "Френски (автоматично генерирано)", + "Norwegian Bokmål": "Норвежки", + "Catalan": "Каталунски", + "Hindi": "Хинди", + "Tamil": "Тамилски", + "search_filters_features_option_live": "На живо", + "crash_page_read_the_faq": "прочел Често задавани въпроси (FAQ)", + "preferences_default_home_label": "Начална страница по подразбиране: ", + "Download": "Изтегляне", + "Show less": "Покажи по-малко", + "Password": "Парола", + "User ID": "Потребителско име", + "Subscription manager": "Управляване на абонаменти", + "search": "търсене", + "No such user": "Няма такъв потребител", + "View privacy policy.": "Виж политиката за поверителност.", + "Only show latest unwatched video from channel: ": "Показвай само най-новите негледани видеа в канала: ", + "user_created_playlists": "`x` създаде плейлисти", + "Editing playlist `x`": "Редактиране на плейлист `x`", + "preferences_thin_mode_label": "Тънък режим: ", + "E-mail": "Имейл", + "Haitian Creole": "Хаитянски креол", + "Irish": "Ирландски", + "channel_tab_channels_label": "Канали", + "Delete account?": "Изтрий акаунта?", + "Redirect homepage to feed: ": "Препращане на началната страница до препоръки ", + "Urdu": "Урду", + "preferences_vr_mode_label": "Интерактивни 360 градусови видеа (изисква WebGL): ", + "Password cannot be empty": "Паролата не може да бъде празна", + "Mongolian": "Монголски", + "Authorize token?": "Разреши токена?", + "search_filters_type_option_all": "Всякакъв тип", + "Romanian": "Румънски", + "Belarusian": "Беларуски", + "channel name - reverse": "име на канал - в обратен ред", + "Erroneous CAPTCHA": "Невалидна CAPTCHA", + "Watch on YouTube": "Гледай в YouTube", + "search_filters_features_option_location": "Местоположение", + "Could not pull trending pages.": "Получаването на трендинг страниците е неуспешно.", + "German": "Немски", + "search_filters_features_option_c_commons": "Creative Commons", + "Family friendly? ": "За всяка възраст? ", + "Hidden field \"token\" is a required field": "Скритото поле \"токен\" е задължително поле", + "Russian (auto-generated)": "Руски (автоматично генерирано)", + "preferences_quality_dash_option_480p": "480p", + "Corsican": "Корсикански", + "Macedonian": "Македонски", + "comments_view_x_replies": "Виж {{count}} отговор", + "comments_view_x_replies_plural": "Виж {{count}} отговора", + "footer_original_source_code": "Оригинален изходен код", + "Import YouTube subscriptions": "Импортиране на YouTube/OPML абонаменти", + "Lithuanian": "Литовски", + "Nyanja": "Нянджа", + "Updated `x` ago": "Актуализирано преди `x`", + "JavaScript license information": "Информация за Javascript лиценза", + "Spanish": "Испански", + "Latin": "Латински", + "Shona": "Шона", + "Portuguese (Brazil)": "Португалски (Бразилия)", + "Show more": "Покажи още", + "Clear watch history?": "Изчисти историята на търсене?", + "Manage tokens": "Управление на токени", + "Hausa": "Хауса", + "search_filters_features_option_vr180": "VR180", + "preferences_category_visual": "Визуални предпочитания", + "Italian": "Италиански", + "preferences_volume_label": "Сила на звука на плейъра: ", + "error_video_not_in_playlist": "Заявеното видео не съществува в този плейлист. Натиснете тук за началната страница на плейлиста.", + "preferences_listen_label": "Само звук по подразбиране: ", + "Dutch (auto-generated)": "Холандски (автоматично генерирано)", + "preferences_captions_label": "Надписи по подразбиране: ", + "generic_count_days": "{{count}} ден", + "generic_count_days_plural": "{{count}} дни", + "Hawaiian": "Хавайски", + "Could not get channel info.": "Получаването на информация за канала е неуспешно.", + "View as playlist": "Виж като плейлист", + "Vietnamese (auto-generated)": "Виетнамски (автоматично генерирано)", + "search_filters_duration_option_none": "Всякаква продължителност", + "preferences_quality_dash_option_240p": "240p", + "Latvian": "Латвийски", + "search_filters_features_option_hdr": "HDR", + "preferences_sort_label": "Сортирай видеата по: ", + "Estonian": "Естонски", + "Hidden field \"challenge\" is a required field": "Скритото поле \"тест\" е задължително поле", + "footer_documentation": "Документация", + "Kyrgyz": "Киргизски", + "preferences_continue_autoplay_label": "Пускай следващотото видео автоматично: ", + "Chinese": "Китайски", + "search_filters_sort_option_relevance": "Уместност", + "source": "източник", + "Fallback comments: ": "Резервни коментари: ", + "preferences_automatic_instance_redirect_label": "Автоматично препращане на инстанция (чрез redirect.invidious.io): ", + "Maori": "Маори", + "generic_button_delete": "Изтрий", + "Import YouTube playlist (.csv)": "Импортиране на YouTube плейлист (.csv)", + "Switch Invidious Instance": "Смени Invidious инстанция", + "channel name": "име на канал", + "Audio mode": "Аудио режим", + "search_filters_type_option_show": "Сериал", + "search_filters_date_option_today": "Днес", + "search_filters_features_option_three_d": "3D", + "next_steps_error_message": "След което можеш да пробваш да: ", + "Hide annotations": "Скрий анотации", + "Standard YouTube license": "Стандартен YouTube лиценз", + "Text CAPTCHA": "Текст CAPTCHA", + "Log in/register": "Вход/регистрация", + "Punjabi": "Пенджаби", + "Change password": "Смяна на паролата", + "License: ": "Лиценз: ", + "search_filters_duration_option_medium": "Средно (4 - 20 минути)", + "Delete playlist": "Изтриване на плейлист", + "Delete playlist `x`?": "Изтрий плейлиста `x`?", + "Korean": "Корейски", + "Export subscriptions as OPML": "Експортиране на абонаментите като OPML", + "unsubscribe": "отписване", + "View YouTube comments": "Виж YouTube коментарите", + "Kannada": "Каннада", + "Not a playlist.": "Невалиден плейлист.", + "Wrong answer": "Грешен отговор", + "Released under the AGPLv3 on Github.": "Публикувано под AGPLv3 в GitHub.", + "Burmese": "Бирмански", + "Sundanese": "Сундански", + "Hungarian": "Унгарски", + "generic_count_seconds": "{{count}} секунда", + "generic_count_seconds_plural": "{{count}} секунди", + "search_filters_date_label": "Дата на качване", + "Greek": "Гръцки", + "crash_page_you_found_a_bug": "Изглежда намери бъг в Invidious!", + "View all playlists": "Виж всички плейлисти", + "Khmer": "Кхмерски", + "preferences_annotations_label": "Покажи анотаций по подразбиране: ", + "generic_views_count": "{{count}} гледане", + "generic_views_count_plural": "{{count}} гледания", + "Next page": "Следваща страница" +} diff --git a/locales/ca.json b/locales/ca.json index 4392c2a9..a718eb2b 100644 --- a/locales/ca.json +++ b/locales/ca.json @@ -476,5 +476,15 @@ "Redirect homepage to feed: ": "Redirigeix la pàgina d'inici al feed: ", "Standard YouTube license": "Llicència estàndard de YouTube", "Download is disabled": "Les baixades s'han inhabilitat", - "Import YouTube playlist (.csv)": "Importar llista de reproducció de YouTube (.csv)" + "Import YouTube playlist (.csv)": "Importar llista de reproducció de YouTube (.csv)", + "channel_tab_podcasts_label": "Podcasts", + "playlist_button_add_items": "Afegeix vídeos", + "generic_button_save": "Desa", + "generic_button_cancel": "Cancel·la", + "channel_tab_releases_label": "Publicacions", + "generic_channels_count": "{{count}} canal", + "generic_channels_count_plural": "{{count}} canals", + "generic_button_edit": "Edita", + "generic_button_rss": "RSS", + "generic_button_delete": "Suprimeix" } diff --git a/locales/cs.json b/locales/cs.json index 73ed960d..10c114eb 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -492,5 +492,16 @@ "Song: ": "Skladba: ", "Standard YouTube license": "Standardní licence YouTube", "Download is disabled": "Stahování je zakázáno", - "Import YouTube playlist (.csv)": "Importovat YouTube playlist (.csv)" + "Import YouTube playlist (.csv)": "Importovat YouTube playlist (.csv)", + "generic_button_save": "Uložit", + "generic_button_delete": "Odstranit", + "generic_button_cancel": "Zrušit", + "channel_tab_podcasts_label": "Podcasty", + "channel_tab_releases_label": "Vydání", + "generic_button_edit": "Upravit", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Přidat videa", + "generic_channels_count_0": "{{count}} kanál", + "generic_channels_count_1": "{{count}} kanály", + "generic_channels_count_2": "{{count}} kanálů" } diff --git a/locales/de.json b/locales/de.json index 66f2ae6f..59c6a49c 100644 --- a/locales/de.json +++ b/locales/de.json @@ -97,7 +97,7 @@ "Change password": "Passwort ändern", "Manage subscriptions": "Abonnements verwalten", "Manage tokens": "Tokens verwalten", - "Watch history": "Verlauf", + "Watch history": "Wiedergabeverlauf", "Delete account": "Account löschen", "preferences_category_admin": "Administrator-Einstellungen", "preferences_default_home_label": "Standard-Startseite: ", @@ -476,5 +476,15 @@ "Standard YouTube license": "Standard YouTube-Lizenz", "Song: ": "Musik: ", "Download is disabled": "Herunterladen ist deaktiviert", - "Import YouTube playlist (.csv)": "YouTube Playlist Importieren (.csv)" + "Import YouTube playlist (.csv)": "YouTube Wiedergabeliste importieren (.csv)", + "generic_button_delete": "Löschen", + "generic_button_edit": "Bearbeiten", + "generic_button_save": "Speichern", + "generic_button_cancel": "Abbrechen", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Videos hinzufügen", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Veröffentlichungen", + "generic_channels_count": "{{count}} Kanal", + "generic_channels_count_plural": "{{count}} Kanäle" } diff --git a/locales/el.json b/locales/el.json index 13cff649..1d827eba 100644 --- a/locales/el.json +++ b/locales/el.json @@ -41,7 +41,7 @@ "Time (h:mm:ss):": "Ώρα (ω:λλ:δδ):", "Text CAPTCHA": "Κείμενο CAPTCHA", "Image CAPTCHA": "Εικόνα CAPTCHA", - "Sign In": "Σύνδεση", + "Sign In": "Εγγραφή", "Register": "Εγγραφή", "E-mail": "Ηλεκτρονικό ταχυδρομείο", "Preferences": "Προτιμήσεις", @@ -145,7 +145,7 @@ "View YouTube comments": "Προβολή σχολίων από το YouTube", "View more comments on Reddit": "Προβολή περισσότερων σχολίων στο Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` σχολίων", + "([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` σχολίου", "": "Προβολή `x` σχολίων" }, "View Reddit comments": "Προβολή σχολίων από το Reddit", @@ -349,7 +349,7 @@ "crash_page_you_found_a_bug": "Φαίνεται ότι βρήκατε ένα σφάλμα στο Invidious!", "crash_page_before_reporting": "Πριν αναφέρετε ένα σφάλμα, βεβαιωθείτε ότι έχετε:", "crash_page_refresh": "προσπαθήσει να ανανεώσετε τη σελίδα", - "crash_page_read_the_faq": "διαβάσει τις Συχνές Ερωτήσεις (ΣΕ)", + "crash_page_read_the_faq": "διαβάστε τις Συχνές Ερωτήσεις (ΣΕ)", "crash_page_search_issue": "αναζητήσει για υπάρχοντα θέματα στο GitHub", "generic_views_count": "{{count}} προβολή", "generic_views_count_plural": "{{count}} προβολές", @@ -442,5 +442,49 @@ "search_filters_type_option_show": "Μπάρα προόδου διαβάσματος", "preferences_watch_history_label": "Ενεργοποίηση ιστορικού παρακολούθησης: ", "search_filters_title": "Φίλτρο", - "search_message_no_results": "Δε βρέθηκαν αποτελέσματα." + "search_message_no_results": "Δε βρέθηκαν αποτελέσματα.", + "channel_tab_podcasts_label": "Podcast", + "preferences_save_player_pos_label": "Αποθήκευση σημείου αναπαραγωγής: ", + "search_filters_apply_button": "Εφαρμογή επιλεγμένων φίλτρων", + "Download is disabled": "Είναι απενεργοποιημένη η λήψη", + "comments_points_count": "{{count}} βαθμός", + "comments_points_count_plural": "{{count}} βαθμοί", + "search_filters_sort_option_views": "Προβολές", + "search_message_change_filters_or_query": "Προσπαθήστε να διευρύνετε το ερώτημα αναζήτησης ή/και να αλλάξετε τα φίλτρα.", + "Channel Sponsor": "Χορηγός Καναλιού", + "channel_tab_streams_label": "Ζωντανή μετάδοση", + "playlist_button_add_items": "Προσθήκη βίντεο", + "Artist: ": "Καλλιτέχνης: ", + "search_message_use_another_instance": " Μπορείτε επίσης να αναζητήσετε σε άλλο instance.", + "generic_button_save": "Αποθήκευση", + "generic_button_cancel": "Ακύρωση", + "subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση", + "subscriptions_unseen_notifs_count_plural": "{{count}} μη αναγνωσμένες ειδοποιήσεις", + "Album: ": "Δίσκος: ", + "tokens_count": "{{count}} σύμβολο", + "tokens_count_plural": "{{count}} σύμβολα", + "channel_tab_shorts_label": "Short", + "channel_tab_releases_label": "Κυκλοφορίες", + "Song: ": "Τραγούδι: ", + "generic_channels_count": "{{count}} κανάλι", + "generic_channels_count_plural": "{{count}} κανάλια", + "Popular enabled: ": "Ενεργοποιημένα Δημοφιλή: ", + "channel_tab_playlists_label": "Λίστες αναπαραγωγής", + "generic_button_edit": "Επεξεργασία", + "search_filters_date_option_none": "Οποιαδήποτε ημερομηνία", + "crash_page_switch_instance": "προσπάθεια χρήσης άλλου instance", + "Music in this video": "Μουσική σε αυτό το βίντεο", + "generic_button_rss": "RSS", + "channel_tab_channels_label": "Κανάλια", + "search_filters_type_option_all": "Οποιοσδήποτε τύπος", + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Το αιτούμενο βίντεο δεν υπάρχει στη δεδομένη λίστα αναπαραγωγής. Πατήστε εδώ για επιστροφή στη κεντρική σελίδα λιστών αναπαραγωγής.", + "search_filters_duration_option_none": "Οποιαδήποτε διάρκεια", + "preferences_automatic_instance_redirect_label": "Αυτόματη ανακατεύθυνση instance (εναλλακτική σε redirect.invidious.io): ", + "generic_button_delete": "Διαγραφή", + "Import YouTube playlist (.csv)": "Εισαγωγή λίστας αναπαραγωγής YouTube (.csv)", + "Switch Invidious Instance": "Αλλαγή Instance Invidious", + "Standard YouTube license": "Τυπική άδεια YouTube", + "search_filters_duration_option_medium": "Μεσαία (4 - 20 λεπτά)", + "search_filters_date_label": "Ημερομηνία αναφόρτωσης" } diff --git a/locales/en-US.json b/locales/en-US.json index e13ba968..06d095dc 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -1,4 +1,6 @@ { + "generic_channels_count": "{{count}} channel", + "generic_channels_count_plural": "{{count}} channels", "generic_views_count": "{{count}} view", "generic_views_count_plural": "{{count}} views", "generic_videos_count": "{{count}} video", @@ -9,6 +11,11 @@ "generic_subscribers_count_plural": "{{count}} subscribers", "generic_subscriptions_count": "{{count}} subscription", "generic_subscriptions_count_plural": "{{count}} subscriptions", + "generic_button_delete": "Delete", + "generic_button_edit": "Edit", + "generic_button_save": "Save", + "generic_button_cancel": "Cancel", + "generic_button_rss": "RSS", "LIVE": "LIVE", "Shared `x` ago": "Shared `x` ago", "Unsubscribe": "Unsubscribe", @@ -170,6 +177,7 @@ "Title": "Title", "Playlist privacy": "Playlist privacy", "Editing playlist `x`": "Editing playlist `x`", + "playlist_button_add_items": "Add videos", "Show more": "Show more", "Show less": "Show less", "Watch on YouTube": "Watch on YouTube", @@ -474,6 +482,8 @@ "channel_tab_videos_label": "Videos", "channel_tab_shorts_label": "Shorts", "channel_tab_streams_label": "Livestreams", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Releases", "channel_tab_playlists_label": "Playlists", "channel_tab_community_label": "Community", "channel_tab_channels_label": "Channels" diff --git a/locales/eo.json b/locales/eo.json index a4b46bef..7276c890 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -154,7 +154,7 @@ "View YouTube comments": "Vidi komentojn de JuTubo", "View more comments on Reddit": "Vidi pli komentoj en Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` komentojn", + "([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` komenton", "": "Vidi `x` komentojn" }, "View Reddit comments": "Vidi komentojn de Reddit", @@ -447,8 +447,8 @@ "French (auto-generated)": "Franca (aŭtomate generita)", "Spanish (Mexico)": "Hispana (Meksiko)", "Spanish (auto-generated)": "Hispana (aŭtomate generita)", - "generic_count_days": "{{count}} jaro", - "generic_count_days_plural": "{{count}} jaroj", + "generic_count_days": "{{count}} tago", + "generic_count_days_plural": "{{count}} tagoj", "search_filters_type_option_all": "Ajna speco", "search_filters_duration_option_none": "Ajna daŭro", "search_filters_apply_button": "Uzi elektitajn filtrilojn", @@ -476,5 +476,15 @@ "Song: ": "Muzikaĵo: ", "Standard YouTube license": "Implicita YouTube-licenco", "Download is disabled": "Elŝuto estas malebligita", - "Import YouTube playlist (.csv)": "Importi YouTube-ludliston (.csv)" + "Import YouTube playlist (.csv)": "Importi YouTube-ludliston (.csv)", + "generic_button_edit": "Redakti", + "playlist_button_add_items": "Aldoni videojn", + "generic_button_rss": "RSS", + "generic_button_delete": "Forigi", + "channel_tab_podcasts_label": "Podkastoj", + "generic_button_cancel": "Nuligi", + "channel_tab_releases_label": "Eldonoj", + "generic_button_save": "Konservi", + "generic_channels_count": "{{count}} kanalo", + "generic_channels_count_plural": "{{count}} kanaloj" } diff --git a/locales/es.json b/locales/es.json index b3103a25..0b8463ea 100644 --- a/locales/es.json +++ b/locales/es.json @@ -113,7 +113,7 @@ "Token manager": "Gestor de tokens", "Token": "Ficha", "Import/export": "Importar/Exportar", - "unsubscribe": "Desuscribirse", + "unsubscribe": "desuscribirse", "revoke": "revocar", "Subscriptions": "Suscripciones", "search": "buscar", @@ -154,7 +154,7 @@ "View YouTube comments": "Ver los comentarios de YouTube", "View more comments on Reddit": "Ver más comentarios en Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentarios", + "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentario", "": "Ver `x` comentarios" }, "View Reddit comments": "Ver los comentarios de Reddit", @@ -476,5 +476,15 @@ "Channel Sponsor": "Patrocinador del canal", "Standard YouTube license": "Licencia de YouTube estándar", "Download is disabled": "La descarga está deshabilitada", - "Import YouTube playlist (.csv)": "Importar lista de reproducción de YouTube (.csv)" + "Import YouTube playlist (.csv)": "Importar lista de reproducción de YouTube (.csv)", + "playlist_button_add_items": "Añadir vídeos", + "generic_button_edit": "Editar", + "generic_button_save": "Guardar", + "generic_button_delete": "Borrar", + "generic_button_cancel": "Cancelar", + "generic_button_rss": "RSS", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Publicaciones", + "generic_channels_count": "{{count}} canal", + "generic_channels_count_plural": "{{count}} canales" } diff --git a/locales/fr.json b/locales/fr.json index d2607a49..772c81c8 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1,14 +1,27 @@ { - "generic_views_count": "{{count}} vue", - "generic_views_count_plural": "{{count}} vues", - "generic_videos_count": "{{count}} vidéo", - "generic_videos_count_plural": "{{count}} vidéos", - "generic_playlists_count": "{{count}} liste de lecture", - "generic_playlists_count_plural": "{{count}} listes de lecture", - "generic_subscribers_count": "{{count}} abonné", - "generic_subscribers_count_plural": "{{count}} abonnés", - "generic_subscriptions_count": "{{count}} abonnement", - "generic_subscriptions_count_plural": "{{count}} abonnements", + "generic_channels_count_0": "{{count}} chaîne", + "generic_channels_count_1": "{{count}} de chaînes", + "generic_channels_count_2": "{{count}} chaînes", + "generic_views_count_0": "{{count}} vue", + "generic_views_count_1": "{{count}} de vues", + "generic_views_count_2": "{{count}} vues", + "generic_videos_count_0": "{{count}} vidéo", + "generic_videos_count_1": "{{count}} de vidéos", + "generic_videos_count_2": "{{count}} vidéos", + "generic_playlists_count_0": "{{count}} liste de lecture", + "generic_playlists_count_1": "{{count}} listes de lecture", + "generic_playlists_count_2": "{{count}} listes de lecture", + "generic_subscribers_count_0": "{{count}} abonné", + "generic_subscribers_count_1": "{{count}} d'abonnés", + "generic_subscribers_count_2": "{{count}} abonnés", + "generic_subscriptions_count_0": "{{count}} abonnement", + "generic_subscriptions_count_1": "{{count}} d'abonnements", + "generic_subscriptions_count_2": "{{count}} abonnements", + "generic_button_delete": "Supprimer", + "generic_button_edit": "Editer", + "generic_button_save": "Enregistrer", + "generic_button_cancel": "Annuler", + "generic_button_rss": "RSS", "LIVE": "EN DIRECT", "Shared `x` ago": "Ajoutée il y a `x`", "Unsubscribe": "Se désabonner", @@ -50,10 +63,10 @@ "Password": "Mot de passe", "Time (h:mm:ss):": "Heure (h:mm:ss) :", "Text CAPTCHA": "CAPTCHA textuel", - "Image CAPTCHA": "CAPTCHA graphique", - "Sign In": "Se connecter", + "Image CAPTCHA": "CAPTCHA pictural", + "Sign In": "S'identifier", "Register": "S'inscrire", - "E-mail": "E-mail", + "E-mail": "Courriel", "Preferences": "Préférences", "preferences_category_player": "Préférences du lecteur", "preferences_video_loop_label": "Lire en boucle : ", @@ -123,14 +136,16 @@ "Subscription manager": "Gestionnaire d'abonnement", "Token manager": "Gestionnaire de token", "Token": "Token", - "tokens_count": "{{count}} token", - "tokens_count_plural": "{{count}} tokens", + "tokens_count_0": "{{count}} jeton", + "tokens_count_1": "{{count}} de jetons", + "tokens_count_2": "{{count}} jetons", "Import/export": "Importer/Exporter", "unsubscribe": "se désabonner", "revoke": "révoquer", "Subscriptions": "Abonnements", - "subscriptions_unseen_notifs_count": "{{count}} notification non vue", - "subscriptions_unseen_notifs_count_plural": "{{count}} notifications non vues", + "subscriptions_unseen_notifs_count_0": "{{count}} notification non vue", + "subscriptions_unseen_notifs_count_1": "{{count}} de notifications non vues", + "subscriptions_unseen_notifs_count_2": "{{count}} notifications non vues", "search": "rechercher", "Log out": "Se déconnecter", "Released under the AGPLv3 on Github.": "Publié sous licence AGPLv3 sur GitHub.", @@ -149,6 +164,7 @@ "Title": "Titre", "Playlist privacy": "Paramètres de confidentialité de la liste de lecture", "Editing playlist `x`": "Modifier la liste de lecture `x`", + "playlist_button_add_items": "Ajouter des vidéos", "Show more": "Afficher plus", "Show less": "Afficher moins", "Watch on YouTube": "Voir la vidéo sur Youtube", @@ -191,12 +207,14 @@ "This channel does not exist.": "Cette chaine n'existe pas.", "Could not get channel info.": "Impossible de charger les informations de cette chaîne.", "Could not fetch comments": "Impossible de charger les commentaires", - "comments_view_x_replies": "Voir {{count}} réponse", - "comments_view_x_replies_plural": "Voir {{count}} réponses", + "comments_view_x_replies_0": "Voir {{count}} réponse", + "comments_view_x_replies_1": "Voir {{count}} de réponses", + "comments_view_x_replies_2": "Voir {{count}} réponses", "`x` ago": "il y a `x`", "Load more": "Voir plus", - "comments_points_count": "{{count}} point", - "comments_points_count_plural": "{{count}} points", + "comments_points_count_0": "{{count}} point", + "comments_points_count_1": "{{count}} de points", + "comments_points_count_2": "{{count}} points", "Could not create mix.": "Impossible de charger cette liste de lecture.", "Empty playlist": "La liste de lecture est vide", "Not a playlist.": "La liste de lecture est invalide.", @@ -314,20 +332,27 @@ "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zoulou", - "generic_count_years": "{{count}} an", - "generic_count_years_plural": "{{count}} ans", - "generic_count_months": "{{count}} mois", - "generic_count_months_plural": "{{count}} mois", - "generic_count_weeks": "{{count}} semaine", - "generic_count_weeks_plural": "{{count}} semaines", - "generic_count_days": "{{count}} jour", - "generic_count_days_plural": "{{count}} jours", - "generic_count_hours": "{{count}} heure", - "generic_count_hours_plural": "{{count}} heures", - "generic_count_minutes": "{{count}} minute", - "generic_count_minutes_plural": "{{count}} minutes", - "generic_count_seconds": "{{count}} seconde", - "generic_count_seconds_plural": "{{count}} secondes", + "generic_count_years_0": "{{count}} an", + "generic_count_years_1": "{{count}} ans", + "generic_count_years_2": "{{count}} ans", + "generic_count_months_0": "{{count}} mois", + "generic_count_months_1": "{{count}} mois", + "generic_count_months_2": "{{count}} mois", + "generic_count_weeks_0": "{{count}} semaine", + "generic_count_weeks_1": "{{count}} semaines", + "generic_count_weeks_2": "{{count}} semaines", + "generic_count_days_0": "{{count}} jour", + "generic_count_days_1": "{{count}} jours", + "generic_count_days_2": "{{count}} jours", + "generic_count_hours_0": "{{count}} heure", + "generic_count_hours_1": "{{count}} heures", + "generic_count_hours_2": "{{count}} heures", + "generic_count_minutes_0": "{{count}} minute", + "generic_count_minutes_1": "{{count}} minutes", + "generic_count_minutes_2": "{{count}} minutes", + "generic_count_seconds_0": "{{count}} seconde", + "generic_count_seconds_1": "{{count}} secondes", + "generic_count_seconds_2": "{{count}} secondes", "Fallback comments: ": "Commentaires alternatifs : ", "Popular": "Populaire", "Search": "Rechercher", @@ -476,5 +501,7 @@ "Music in this video": "Musique dans cette vidéo", "Channel Sponsor": "Soutien de la chaîne", "Download is disabled": "Le téléchargement est désactivé", - "Import YouTube playlist (.csv)": "Importer des listes de lecture de Youtube (.csv)" + "Import YouTube playlist (.csv)": "Importer des listes de lecture de Youtube (.csv)", + "channel_tab_releases_label": "Parutions", + "channel_tab_podcasts_label": "Émissions audio" } diff --git a/locales/hi.json b/locales/hi.json index dcb7294d..21807c50 100644 --- a/locales/hi.json +++ b/locales/hi.json @@ -471,5 +471,18 @@ "channel_tab_shorts_label": "शॉर्ट्स", "channel_tab_streams_label": "लाइवस्ट्रीम्स", "channel_tab_playlists_label": "प्लेलिस्ट्स", - "channel_tab_channels_label": "चैनल्स" + "channel_tab_channels_label": "चैनल्स", + "generic_button_save": "सहेजें", + "generic_button_cancel": "रद्द करें", + "generic_button_rss": "आरएसएस", + "generic_button_edit": "संपादित करें", + "generic_button_delete": "मिटाएं", + "playlist_button_add_items": "वीडियो जोड़ें", + "Song: ": "गाना: ", + "channel_tab_podcasts_label": "पाॅडकास्ट", + "channel_tab_releases_label": "रिलीज़ेस्", + "Import YouTube playlist (.csv)": "YouTube प्लेलिस्ट (.csv) आयात करें", + "Standard YouTube license": "मानक यूट्यूब लाइसेंस", + "Channel Sponsor": "चैनल प्रायोजक", + "Download is disabled": "डाउनलोड करना अक्षम है" } diff --git a/locales/hr.json b/locales/hr.json index 0549fa70..ef931202 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -492,5 +492,16 @@ "Song: ": "Pjesma: ", "Standard YouTube license": "Standardna YouTube licenca", "Download is disabled": "Preuzimanje je deaktivirano", - "Import YouTube playlist (.csv)": "Uvezi YouTube zbirku (.csv)" + "Import YouTube playlist (.csv)": "Uvezi YouTube zbirku (.csv)", + "generic_button_delete": "Izbriši", + "playlist_button_add_items": "Dodaj videa", + "channel_tab_podcasts_label": "Podcasti", + "generic_button_edit": "Uredi", + "generic_button_save": "Spremi", + "generic_button_cancel": "Odustani", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "Izdanja", + "generic_channels_count_0": "{{count}} kanal", + "generic_channels_count_1": "{{count}} kanala", + "generic_channels_count_2": "{{count}} kanala" } diff --git a/locales/id.json b/locales/id.json index ef677251..8961880b 100644 --- a/locales/id.json +++ b/locales/id.json @@ -446,5 +446,28 @@ "crash_page_read_the_faq": "baca Soal Sering Ditanya (SSD/FAQ)", "crash_page_search_issue": "mencari isu yang ada di GitHub", "crash_page_report_issue": "Jika yang di atas tidak membantu, buka isu baru di GitHub (sebaiknya dalam bahasa Inggris) dan sertakan teks berikut dalam pesan Anda (JANGAN terjemahkan teks tersebut):", - "Popular enabled: ": "Populer diaktifkan: " + "Popular enabled: ": "Populer diaktifkan: ", + "channel_tab_podcasts_label": "Podcast", + "Download is disabled": "Download dinonaktifkan", + "Channel Sponsor": "Saluran Sponsor", + "channel_tab_streams_label": "Streaming langsung", + "playlist_button_add_items": "Tambahkan video", + "Artist: ": "Artis: ", + "generic_button_save": "Simpan", + "generic_button_cancel": "Batal", + "Album: ": "Album: ", + "channel_tab_shorts_label": "Shorts", + "channel_tab_releases_label": "Terbit", + "Interlingue": "Interlingue", + "Song: ": "Lagu: ", + "generic_channels_count_0": "Saluran {{count}}", + "channel_tab_playlists_label": "Daftar putar", + "generic_button_edit": "Ubah", + "Music in this video": "Musik dalam video ini", + "generic_button_rss": "RSS", + "channel_tab_channels_label": "Saluran", + "error_video_not_in_playlist": "Video yang diminta tidak ada dalam daftar putar ini. Klik di sini untuk halaman beranda daftar putar.", + "generic_button_delete": "Hapus", + "Import YouTube playlist (.csv)": "Impor daftar putar YouTube (.csv)", + "Standard YouTube license": "Lisensi YouTube standar" } diff --git a/locales/it.json b/locales/it.json index a3d0f5da..7e1b12c6 100644 --- a/locales/it.json +++ b/locales/it.json @@ -1,10 +1,13 @@ { - "generic_subscribers_count": "{{count}} iscritto", - "generic_subscribers_count_plural": "{{count}} iscritti", - "generic_videos_count": "{{count}} video", - "generic_videos_count_plural": "{{count}} video", - "generic_playlists_count": "{{count}} playlist", - "generic_playlists_count_plural": "{{count}} playlist", + "generic_subscribers_count_0": "{{count}} iscritto", + "generic_subscribers_count_1": "{{count}} iscritti", + "generic_subscribers_count_2": "{{count}} iscritti", + "generic_videos_count_0": "{{count}} video", + "generic_videos_count_1": "{{count}} video", + "generic_videos_count_2": "{{count}} video", + "generic_playlists_count_0": "{{count}} playlist", + "generic_playlists_count_1": "{{count}} playlist", + "generic_playlists_count_2": "{{count}} playlist", "LIVE": "IN DIRETTA", "Shared `x` ago": "Condiviso `x` fa", "Unsubscribe": "Disiscriviti", @@ -13,7 +16,7 @@ "View playlist on YouTube": "Vedi playlist su YouTube", "newest": "più recente", "oldest": "più vecchio", - "popular": "Tendenze", + "popular": "popolare", "last": "ultimo", "Next page": "Pagina successiva", "Previous page": "Pagina precedente", @@ -113,16 +116,19 @@ "Subscription manager": "Gestione delle iscrizioni", "Token manager": "Gestione dei gettoni", "Token": "Gettone", - "generic_subscriptions_count": "{{count}} iscrizione", - "generic_subscriptions_count_plural": "{{count}} iscrizioni", - "tokens_count": "{{count}} gettone", - "tokens_count_plural": "{{count}} gettoni", + "generic_subscriptions_count_0": "{{count}} iscrizione", + "generic_subscriptions_count_1": "{{count}} iscrizioni", + "generic_subscriptions_count_2": "{{count}} iscrizioni", + "tokens_count_0": "{{count}} gettone", + "tokens_count_1": "{{count}} gettoni", + "tokens_count_2": "{{count}} gettoni", "Import/export": "Importa/esporta", "unsubscribe": "disiscriviti", "revoke": "revoca", "Subscriptions": "Iscrizioni", - "subscriptions_unseen_notifs_count": "{{count}} notifica non visualizzata", - "subscriptions_unseen_notifs_count_plural": "{{count}} notifiche non visualizzate", + "subscriptions_unseen_notifs_count_0": "{{count}} notifica non visualizzata", + "subscriptions_unseen_notifs_count_1": "{{count}} notifiche non visualizzate", + "subscriptions_unseen_notifs_count_2": "{{count}} notifiche non visualizzate", "search": "Cerca", "Log out": "Esci", "Source available here.": "Codice sorgente.", @@ -151,8 +157,9 @@ "Whitelisted regions: ": "Regioni in lista bianca: ", "Blacklisted regions: ": "Regioni in lista nera: ", "Shared `x`": "Condiviso `x`", - "generic_views_count": "{{count}} visualizzazione", - "generic_views_count_plural": "{{count}} visualizzazioni", + "generic_views_count_0": "{{count}} visualizzazione", + "generic_views_count_1": "{{count}} visualizzazioni", + "generic_views_count_2": "{{count}} visualizzazioni", "Premieres in `x`": "In anteprima in `x`", "Premieres `x`": "In anteprima `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.": "Ciao, Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti, ma considera che il caricamento potrebbe richiedere più tempo.", @@ -300,20 +307,27 @@ "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zulu", - "generic_count_years": "{{count}} anno", - "generic_count_years_plural": "{{count}} anni", - "generic_count_months": "{{count}} mese", - "generic_count_months_plural": "{{count}} mesi", - "generic_count_weeks": "{{count}} settimana", - "generic_count_weeks_plural": "{{count}} settimane", - "generic_count_days": "{{count}} giorno", - "generic_count_days_plural": "{{count}} giorni", - "generic_count_hours": "{{count}} ora", - "generic_count_hours_plural": "{{count}} ore", - "generic_count_minutes": "{{count}} minuto", - "generic_count_minutes_plural": "{{count}} minuti", - "generic_count_seconds": "{{count}} secondo", - "generic_count_seconds_plural": "{{count}} secondi", + "generic_count_years_0": "{{count}} anno", + "generic_count_years_1": "{{count}} anni", + "generic_count_years_2": "{{count}} anni", + "generic_count_months_0": "{{count}} mese", + "generic_count_months_1": "{{count}} mesi", + "generic_count_months_2": "{{count}} mesi", + "generic_count_weeks_0": "{{count}} settimana", + "generic_count_weeks_1": "{{count}} settimane", + "generic_count_weeks_2": "{{count}} settimane", + "generic_count_days_0": "{{count}} giorno", + "generic_count_days_1": "{{count}} giorni", + "generic_count_days_2": "{{count}} giorni", + "generic_count_hours_0": "{{count}} ora", + "generic_count_hours_1": "{{count}} ore", + "generic_count_hours_2": "{{count}} ore", + "generic_count_minutes_0": "{{count}} minuto", + "generic_count_minutes_1": "{{count}} minuti", + "generic_count_minutes_2": "{{count}} minuti", + "generic_count_seconds_0": "{{count}} secondo", + "generic_count_seconds_1": "{{count}} secondi", + "generic_count_seconds_2": "{{count}} secondi", "Fallback comments: ": "Commenti alternativi: ", "Popular": "Popolare", "Search": "Cerca", @@ -417,10 +431,12 @@ "search_filters_duration_option_short": "Corto (< 4 minuti)", "search_filters_duration_option_long": "Lungo (> 20 minuti)", "search_filters_features_option_purchased": "Acquistato", - "comments_view_x_replies": "Vedi {{count}} risposta", - "comments_view_x_replies_plural": "Vedi {{count}} risposte", - "comments_points_count": "{{count}} punto", - "comments_points_count_plural": "{{count}} punti", + "comments_view_x_replies_0": "Vedi {{count}} risposta", + "comments_view_x_replies_1": "Vedi {{count}} risposte", + "comments_view_x_replies_2": "Vedi {{count}} risposte", + "comments_points_count_0": "{{count}} punto", + "comments_points_count_1": "{{count}} punti", + "comments_points_count_2": "{{count}} punti", "Portuguese (auto-generated)": "Portoghese (generati automaticamente)", "crash_page_you_found_a_bug": "Sembra che tu abbia trovato un bug in Invidious!", "crash_page_switch_instance": "provato a usare un'altra istanza", @@ -467,7 +483,7 @@ "channel_tab_shorts_label": "Short", "channel_tab_playlists_label": "Playlist", "channel_tab_channels_label": "Canali", - "channel_tab_streams_label": "Livestream", + "channel_tab_streams_label": "Trasmissioni in diretta", "channel_tab_community_label": "Comunità", "Music in this video": "Musica in questo video", "Artist: ": "Artista: ", @@ -476,5 +492,16 @@ "Song: ": "Canzone: ", "Standard YouTube license": "Licenza standard di YouTube", "Channel Sponsor": "Sponsor del canale", - "Import YouTube playlist (.csv)": "Importa playlist di YouTube (.csv)" + "Import YouTube playlist (.csv)": "Importa playlist di YouTube (.csv)", + "generic_button_edit": "Modifica", + "generic_button_cancel": "Annulla", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "Pubblicazioni", + "generic_button_delete": "Elimina", + "generic_button_save": "Salva", + "playlist_button_add_items": "Aggiungi video", + "channel_tab_podcasts_label": "Podcast", + "generic_channels_count_0": "{{count}} canale", + "generic_channels_count_1": "{{count}} canali", + "generic_channels_count_2": "{{count}} canali" } diff --git a/locales/ja.json b/locales/ja.json index 8adcbf6a..17e60998 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -81,7 +81,7 @@ "preferences_category_subscription": "登録チャンネル設定", "preferences_annotations_subscribed_label": "最初から登録チャンネルのアノテーションを表示 ", "Redirect homepage to feed: ": "ホームからフィードにリダイレクト: ", - "preferences_max_results_label": "フィードに表示する動画の量: ", + "preferences_max_results_label": "フィードに表示する動画数: ", "preferences_sort_label": "動画を並び替え: ", "published": "投稿日", "published - reverse": "投稿日 - 逆順", @@ -366,13 +366,13 @@ "next_steps_error_message": "下記のものを試して下さい: ", "next_steps_error_message_refresh": "再読込", "next_steps_error_message_go_to_youtube": "YouTubeへ", - "search_filters_duration_option_short": "4 分未満", + "search_filters_duration_option_short": "4分未満", "footer_documentation": "説明書", "footer_source_code": "ソースコード", "footer_original_source_code": "元のソースコード", "footer_modfied_source_code": "改変して使用", "adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリのURL", - "search_filters_duration_option_long": "20 分以上", + "search_filters_duration_option_long": "20分以上", "preferences_region_label": "地域: ", "footer_donate_page": "寄付する", "preferences_quality_dash_label": "優先するDASH画質: ", @@ -443,7 +443,7 @@ "search_filters_date_option_none": "すべて", "search_filters_type_option_all": "すべての種類", "search_filters_duration_option_none": "すべての長さ", - "search_filters_duration_option_medium": "4 ~ 20 分", + "search_filters_duration_option_medium": "4 ~ 20分", "preferences_save_player_pos_label": "再生位置を保存: ", "crash_page_before_reporting": "バグを報告する前に、次のことを確認してください。", "crash_page_report_issue": "上記が助けにならないなら、GitHub に新しい issue を作成し(英語が好ましい)、メッセージに次のテキストを含めてください(テキストは翻訳しない)。", @@ -460,5 +460,14 @@ "Channel Sponsor": "チャンネルのスポンサー", "Standard YouTube license": "標準 Youtube ライセンス", "Download is disabled": "ダウンロード: このインスタンスでは未対応", - "Import YouTube playlist (.csv)": "YouTube 再生リストをインポート (.csv)" + "Import YouTube playlist (.csv)": "YouTube 再生リストをインポート (.csv)", + "generic_button_delete": "削除", + "generic_button_cancel": "キャンセル", + "channel_tab_podcasts_label": "ポッドキャスト", + "channel_tab_releases_label": "リリース", + "generic_button_edit": "編集", + "generic_button_save": "保存", + "generic_button_rss": "RSS", + "playlist_button_add_items": "動画を追加", + "generic_channels_count_0": "{{count}}個のチャンネル" } diff --git a/locales/ko.json b/locales/ko.json index 9c8db5a1..e496bd2a 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -460,5 +460,14 @@ "Music in this video": "동영상 속 음악", "Artist: ": "아티스트: ", "Download is disabled": "다운로드가 비활성화 되어있음", - "Import YouTube playlist (.csv)": "유튜브 플레이리스트 가져오기 (.csv)" + "Import YouTube playlist (.csv)": "유튜브 플레이리스트 가져오기 (.csv)", + "playlist_button_add_items": "동영상 추가", + "channel_tab_podcasts_label": "팟캐스트", + "generic_button_delete": "삭제", + "generic_button_edit": "편집", + "generic_button_save": "저장", + "generic_button_cancel": "취소", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "출시", + "generic_channels_count_0": "{{count}} 채널" } diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 1e0e9e77..08b1e0e2 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -154,7 +154,7 @@ "View YouTube comments": "Vis YouTube-kommentarer", "View more comments on Reddit": "Vis flere kommenterer på Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` kommentarer", + "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` kommentar", "": "Vis `x` kommentarer" }, "View Reddit comments": "Vis Reddit-kommentarer", @@ -476,5 +476,15 @@ "Album: ": "Album: ", "Download is disabled": "Nedlasting er avskrudd", "Channel Sponsor": "Kanalsponsor", - "Import YouTube playlist (.csv)": "Importer YouTube-spilleliste (.csv)" + "Import YouTube playlist (.csv)": "Importer YouTube-spilleliste (.csv)", + "channel_tab_podcasts_label": "Podkaster", + "channel_tab_releases_label": "Utgaver", + "generic_button_delete": "Slett", + "generic_button_edit": "Endre", + "generic_button_save": "Lagre", + "generic_button_cancel": "Avbryt", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Legg til videoer", + "generic_channels_count": "{{count}} kanal", + "generic_channels_count_plural": "{{count}} kanaler" } diff --git a/locales/or.json b/locales/or.json index 0967ef42..948610f1 100644 --- a/locales/or.json +++ b/locales/or.json @@ -1 +1,29 @@ -{} +{ + "preferences_quality_dash_option_720p": "୭୨୦ପି", + "preferences_quality_dash_option_4320p": "୪୩୨୦ପି", + "preferences_quality_dash_option_240p": "୨୪୦ପି", + "preferences_quality_dash_option_2160p": "୨୧୬୦ପି", + "preferences_quality_dash_option_144p": "୧୪୪ପି", + "reddit": "Reddit", + "preferences_quality_dash_option_480p": "୪୮୦ପି", + "preferences_dark_mode_label": "ଥିମ୍: ", + "dark": "ଗାଢ଼", + "published": "ପ୍ରକାଶିତ", + "generic_videos_count": "{{count}}ଟିଏ ଵିଡ଼ିଓ", + "generic_videos_count_plural": "{{count}}ଟି ଵିଡ଼ିଓ", + "generic_button_edit": "ସମ୍ପାଦନା", + "light": "ହାଲୁକା", + "last": "ଗତ", + "New password": "ନୂଆ ପାସ୍‌ୱର୍ଡ଼", + "preferences_quality_dash_option_1440p": "୧୪୪୦ପି", + "preferences_quality_dash_option_360p": "୩୬୦ପି", + "preferences_quality_option_medium": "ମଧ୍ୟମ", + "preferences_quality_dash_option_1080p": "୧୦୮୦ପି", + "youtube": "YouTube", + "preferences_quality_option_hd720": "HD୭୨୦", + "invidious": "Invidious", + "generic_playlists_count": "{{count}}ଟିଏ ଚାଳନାତାଲିକା", + "generic_playlists_count_plural": "{{count}}ଟି ଚାଳନାତାଲିକା", + "Yes": "ହଁ", + "No": "ନାହିଁ" +} diff --git a/locales/pl.json b/locales/pl.json index e237db8b..313f11cb 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -148,12 +148,12 @@ "Blacklisted regions: ": "Niedostępny na obszarach: ", "Shared `x`": "Udostępniono `x`", "Premieres in `x`": "Publikacja za `x`", - "Premieres `x`": "Publikacja za `x`", + "Premieres `x`": "Publikacja `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.": "Cześć! Wygląda na to, że masz wyłączoną obsługę JavaScriptu. Kliknij tutaj, żeby zobaczyć komentarze. Pamiętaj, że wczytywanie może potrwać dłużej.", "View YouTube comments": "Wyświetl komentarze z YouTube", "View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Wyświetl `x` komentarzy", + "([^.,0-9]|^)1([^.,0-9]|$)": "Wyświetl `x` komentarz", "": "Wyświetl `x` komentarzy" }, "View Reddit comments": "Wyświetl komentarze z Redditta", @@ -492,5 +492,16 @@ "Song: ": "Piosenka: ", "Channel Sponsor": "Sponsor kanału", "Standard YouTube license": "Standardowa licencja YouTube", - "Import YouTube playlist (.csv)": "Importuj playlistę YouTube (.csv)" + "Import YouTube playlist (.csv)": "Importuj playlistę YouTube (.csv)", + "generic_button_edit": "Edytuj", + "generic_button_cancel": "Anuluj", + "generic_button_rss": "RSS", + "channel_tab_podcasts_label": "Podkasty", + "channel_tab_releases_label": "Wydania", + "generic_button_delete": "Usuń", + "generic_button_save": "Zapisz", + "playlist_button_add_items": "Dodaj filmy", + "generic_channels_count_0": "{{count}} kanał", + "generic_channels_count_1": "{{count}} kanały", + "generic_channels_count_2": "{{count}} kanałów" } diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 81290398..1e089723 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -112,8 +112,9 @@ "Subscription manager": "Gerenciador de inscrições", "Token manager": "Gerenciador de tokens", "Token": "Token", - "tokens_count": "{{count}} token", - "tokens_count_plural": "{{count}} tokens", + "tokens_count_0": "{{count}} token", + "tokens_count_1": "{{count}} tokens", + "tokens_count_2": "{{count}} tokens", "Import/export": "Importar/Exportar", "unsubscribe": "cancelar inscrição", "revoke": "revogar", @@ -297,20 +298,27 @@ "Yiddish": "Iídiche", "Yoruba": "Iorubá", "Zulu": "Zulu", - "generic_count_years": "{{count}} ano", - "generic_count_years_plural": "{{count}} anos", - "generic_count_months": "{{count}} mês", - "generic_count_months_plural": "{{count}} meses", - "generic_count_weeks": "{{count}} semana", - "generic_count_weeks_plural": "{{count}} semanas", - "generic_count_days": "{{count}} dia", - "generic_count_days_plural": "{{count}} dias", - "generic_count_hours": "{{count}} hora", - "generic_count_hours_plural": "{{count}} horas", - "generic_count_minutes": "{{count}} minuto", - "generic_count_minutes_plural": "{{count}} minutos", - "generic_count_seconds": "{{count}} segundo", - "generic_count_seconds_plural": "{{count}} segundos", + "generic_count_years_0": "{{count}} ano", + "generic_count_years_1": "{{count}} anos", + "generic_count_years_2": "{{count}} anos", + "generic_count_months_0": "{{count}} mês", + "generic_count_months_1": "{{count}} meses", + "generic_count_months_2": "{{count}} meses", + "generic_count_weeks_0": "{{count}} semana", + "generic_count_weeks_1": "{{count}} semanas", + "generic_count_weeks_2": "{{count}} semanas", + "generic_count_days_0": "{{count}} dia", + "generic_count_days_1": "{{count}} dias", + "generic_count_days_2": "{{count}} dias", + "generic_count_hours_0": "{{count}} hora", + "generic_count_hours_1": "{{count}} horas", + "generic_count_hours_2": "{{count}} horas", + "generic_count_minutes_0": "{{count}} minuto", + "generic_count_minutes_1": "{{count}} minutos", + "generic_count_minutes_2": "{{count}} minutos", + "generic_count_seconds_0": "{{count}} segundo", + "generic_count_seconds_1": "{{count}} segundos", + "generic_count_seconds_2": "{{count}} segundos", "Fallback comments: ": "Comentários alternativos: ", "Popular": "Populares", "Search": "Procurar", @@ -377,20 +385,27 @@ "preferences_quality_dash_label": "Qualidade de vídeo do painel preferida: ", "preferences_region_label": "País do conteúdo: ", "preferences_quality_dash_option_4320p": "4320p", - "generic_videos_count": "{{count}} vídeo", - "generic_videos_count_plural": "{{count}} vídeos", - "generic_playlists_count": "{{count}} lista de reprodução", - "generic_playlists_count_plural": "{{count}} listas de reprodução", - "generic_subscribers_count": "{{count}} inscrito", - "generic_subscribers_count_plural": "{{count}} inscritos", - "generic_subscriptions_count": "{{count}} inscrição", - "generic_subscriptions_count_plural": "{{count}} inscrições", - "subscriptions_unseen_notifs_count": "{{count}} notificação não vista", - "subscriptions_unseen_notifs_count_plural": "{{count}} notificações não vistas", - "comments_view_x_replies": "Ver {{count}} resposta", - "comments_view_x_replies_plural": "Ver {{count}} respostas", - "comments_points_count": "{{count}} ponto", - "comments_points_count_plural": "{{count}} pontos", + "generic_videos_count_0": "{{count}} vídeo", + "generic_videos_count_1": "{{count}} vídeos", + "generic_videos_count_2": "{{count}} vídeos", + "generic_playlists_count_0": "{{count}} lista de reprodução", + "generic_playlists_count_1": "{{count}} listas de reprodução", + "generic_playlists_count_2": "{{count}} listas de reprodução", + "generic_subscribers_count_0": "{{count}} inscrito", + "generic_subscribers_count_1": "{{count}} inscritos", + "generic_subscribers_count_2": "{{count}} inscritos", + "generic_subscriptions_count_0": "{{count}} inscrição", + "generic_subscriptions_count_1": "{{count}} inscrições", + "generic_subscriptions_count_2": "{{count}} inscrições", + "subscriptions_unseen_notifs_count_0": "{{count}} notificação não vista", + "subscriptions_unseen_notifs_count_1": "{{count}} notificações não vistas", + "subscriptions_unseen_notifs_count_2": "{{count}} notificações não vistas", + "comments_view_x_replies_0": "Ver {{count}} resposta", + "comments_view_x_replies_1": "Ver {{count}} respostas", + "comments_view_x_replies_2": "Ver {{count}} respostas", + "comments_points_count_0": "{{count}} ponto", + "comments_points_count_1": "{{count}} pontos", + "comments_points_count_2": "{{count}} pontos", "crash_page_you_found_a_bug": "Parece que você encontrou um erro no Invidious!", "crash_page_before_reporting": "Antes de reportar um erro, verifique se você:", "preferences_save_player_pos_label": "Salvar a posição de reprodução: ", @@ -400,8 +415,9 @@ "crash_page_search_issue": "procurou por um erro existente no GitHub", "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor abra um novo problema no Github (preferencialmente em inglês) e inclua o seguinte texto (NÃO traduza):", "crash_page_read_the_faq": "leia as Perguntas frequentes (FAQ)", - "generic_views_count": "{{count}} visualização", - "generic_views_count_plural": "{{count}} visualizações", + "generic_views_count_0": "{{count}} visualização", + "generic_views_count_1": "{{count}} visualizações", + "generic_views_count_2": "{{count}} visualizações", "preferences_quality_option_dash": "DASH (qualidade adaptável)", "preferences_quality_option_hd720": "HD720", "preferences_quality_option_small": "Pequeno", @@ -475,6 +491,17 @@ "Standard YouTube license": "Licença padrão do YouTube", "Song: ": "Música: ", "Channel Sponsor": "Patrocinador do Canal", - "Download is disabled": "Download está desativado", - "Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)" + "Download is disabled": "Download está desabilitado", + "Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)", + "generic_button_delete": "Apagar", + "generic_button_save": "Salvar", + "generic_button_edit": "Editar", + "playlist_button_add_items": "Adicionar vídeos", + "channel_tab_releases_label": "Lançamentos", + "channel_tab_podcasts_label": "Podcasts", + "generic_button_cancel": "Cancelar", + "generic_button_rss": "RSS", + "generic_channels_count_0": "{{count}} canal", + "generic_channels_count_1": "{{count}} canais", + "generic_channels_count_2": "{{count}} canais" } diff --git a/locales/pt.json b/locales/pt.json index dfa411c3..e7cc4810 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -19,7 +19,7 @@ "search_filters_features_option_hdr": "HDR", "search_filters_features_option_location": "Localização", "search_filters_features_option_four_k": "4K", - "search_filters_features_option_live": "Em direto", + "search_filters_features_option_live": "Ao Vivo", "search_filters_features_option_three_d": "3D", "search_filters_features_option_c_commons": "Creative Commons", "search_filters_features_option_subtitles": "Legendas", @@ -365,7 +365,7 @@ "Subscribe": "Subscrever", "Unsubscribe": "Anular subscrição", "Shared `x` ago": "Partilhado `x` atrás", - "LIVE": "Em direto", + "LIVE": "AO VIVO", "search_filters_duration_option_short": "Curto (< 4 minutos)", "search_filters_duration_option_long": "Longo (> 20 minutos)", "footer_source_code": "Código-fonte", @@ -476,5 +476,13 @@ "Channel Sponsor": "Patrocinador do canal", "Standard YouTube license": "Licença padrão do YouTube", "Download is disabled": "A descarga está desativada", - "Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)" + "Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)", + "generic_button_delete": "Deletar", + "generic_button_edit": "Editar", + "generic_button_rss": "RSS", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Lançamentos", + "generic_button_save": "Salvar", + "generic_button_cancel": "Cancelar", + "playlist_button_add_items": "Adicionar vídeos" } diff --git a/locales/ru.json b/locales/ru.json index a93207ad..2769f3ab 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -492,5 +492,16 @@ "Standard YouTube license": "Стандартная лицензия YouTube", "Channel Sponsor": "Спонсор канала", "Download is disabled": "Загрузка отключена", - "Import YouTube playlist (.csv)": "Импорт плейлиста YouTube (.csv)" + "Import YouTube playlist (.csv)": "Импорт плейлиста YouTube (.csv)", + "channel_tab_releases_label": "Релизы", + "generic_button_delete": "Удалить", + "generic_button_edit": "Редактировать", + "generic_button_save": "Сохранить", + "generic_button_cancel": "Отменить", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Добавить видео", + "channel_tab_podcasts_label": "Подкасты", + "generic_channels_count_0": "{{count}} канал", + "generic_channels_count_1": "{{count}} канала", + "generic_channels_count_2": "{{count}} каналов" } diff --git a/locales/si.json b/locales/si.json index 19f34fac..4637cbd2 100644 --- a/locales/si.json +++ b/locales/si.json @@ -89,7 +89,7 @@ "preferences_quality_option_hd720": "HD720", "preferences_quality_dash_option_auto": "ස්වයංක්‍රීය", "preferences_quality_option_small": "කුඩා", - "preferences_quality_dash_option_best": "උසස්", + "preferences_quality_dash_option_best": "හොඳම", "preferences_quality_dash_option_2160p": "2160p", "preferences_quality_dash_option_1440p": "1440p", "preferences_quality_dash_option_720p": "720p", @@ -119,5 +119,9 @@ "Only show latest unwatched video from channel: ": "නාලිකාවේ නවතම නැරඹන නොලද වීඩියෝව පමණක් පෙන්වන්න: ", "preferences_category_data": "දත්ත මනාප", "Clear watch history": "නැරඹුම් ඉතිහාසය මකාදැමීම", - "Subscriptions": "දායකත්ව" + "Subscriptions": "දායකත්ව", + "generic_button_rss": "RSS", + "generic_button_save": "සුරකින්න", + "generic_button_cancel": "අවලංගු කරන්න", + "preferences_quality_dash_option_worst": "නරකම" } diff --git a/locales/sk.json b/locales/sk.json index 7346dc58..8add0f57 100644 --- a/locales/sk.json +++ b/locales/sk.json @@ -9,7 +9,7 @@ "last": "posledné", "Next page": "Ďalšia strana", "Previous page": "Predchádzajúca strana", - "Clear watch history?": "Vymazať históriu sledovania?", + "Clear watch history?": "Vymazať históriu pozerania?", "New password": "Nové heslo", "New passwords must match": "Nové heslá sa musia zhodovať", "Authorize token?": "Autorizovať token?", @@ -99,5 +99,23 @@ "generic_subscriptions_count_1": "{{count}} odbery", "generic_subscriptions_count_2": "{{count}} odberov", "Authorize token for `x`?": "Autorizovať token pre `x`?", - "View playlist on YouTube": "Zobraziť playlist na YouTube" + "View playlist on YouTube": "Zobraziť playlist na YouTube", + "preferences_quality_dash_option_best": "Najlepšia", + "preferences_quality_dash_option_worst": "Najhoršia", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_label": "Preferovaná video kvalita DASH: ", + "preferences_quality_option_dash": "DASH (adaptívna kvalita)", + "preferences_quality_option_small": "Malá", + "preferences_watch_history_label": "Zapnúť históriu pozerania: ", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_dash_option_2160p": "2160p", + "invidious": "Invidious", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_360p": "360p" } diff --git a/locales/sl.json b/locales/sl.json index 45f63c6b..9a912f2d 100644 --- a/locales/sl.json +++ b/locales/sl.json @@ -222,7 +222,7 @@ "search_filters_date_option_week": "Ta teden", "search_filters_type_label": "Vrsta", "search_filters_type_option_all": "Katerakoli vrsta", - "search_filters_type_option_playlist": "Seznami predvajanja", + "search_filters_type_option_playlist": "Seznam predvajanja", "search_filters_features_option_subtitles": "Podnapisi/CC", "search_filters_features_option_location": "Lokacija", "footer_donate_page": "Prispevaj", @@ -508,5 +508,17 @@ "Standard YouTube license": "Standardna licenca YouTube", "Channel Sponsor": "Sponzor kanala", "Download is disabled": "Prenos je onemogočen", - "Import YouTube playlist (.csv)": "Uvoz seznama predvajanja YouTube (.csv)" + "Import YouTube playlist (.csv)": "Uvoz seznama predvajanja YouTube (.csv)", + "generic_button_delete": "Izbriši", + "generic_button_edit": "Uredi", + "generic_button_save": "Shrani", + "generic_button_cancel": "Prekliči", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Dodaj videoposnetke", + "channel_tab_podcasts_label": "Poddaje", + "channel_tab_releases_label": "Izdaje", + "generic_channels_count_0": "{{count}} kanal", + "generic_channels_count_1": "{{count}} kanala", + "generic_channels_count_2": "{{count}} kanali", + "generic_channels_count_3": "{{count}} kanalov" } diff --git a/locales/sq.json b/locales/sq.json index d28eb784..41d4161c 100644 --- a/locales/sq.json +++ b/locales/sq.json @@ -257,7 +257,7 @@ "Video mode": "Mënyrë video", "channel_tab_videos_label": "Video", "search_filters_sort_option_rating": "Vlerësim", - "search_filters_sort_option_date": "Datë ngarkimi", + "search_filters_sort_option_date": "Datë Ngarkimi", "search_filters_sort_option_views": "Numër parjesh", "search_filters_type_label": "Lloj", "search_filters_duration_label": "Kohëzgjatje", @@ -345,7 +345,7 @@ "View YouTube comments": "Shihni komente Youtube", "View more comments on Reddit": "Shihni më tepër komente në Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Shihni `x` komente", + "([^.,0-9]|^)1([^.,0-9]|$)": "Shihni `x` koment", "": "Shihni `x` komente" }, "View Reddit comments": "Shihni komente Reddit", @@ -462,5 +462,20 @@ "channel_tab_channels_label": "Kanale", "Music in this video": "Muzikë në këtë video", "channel_tab_shorts_label": "Të shkurtra", - "channel_tab_streams_label": "Transmetime të drejtpërdrejta" + "channel_tab_streams_label": "Transmetime të drejtpërdrejta", + "generic_button_cancel": "Anuloje", + "generic_channels_count": "{{count}} kanal", + "generic_channels_count_plural": "{{count}} kanale", + "generic_button_rss": "RSS", + "generic_button_delete": "Fshije", + "generic_button_save": "Ruaje", + "generic_button_edit": "Përpunoni", + "playlist_button_add_items": "Shtoni video", + "Report statistics: ": "Statistika raportimesh: ", + "Download is disabled": "Shkarkimi është i çaktivizuar", + "Channel Sponsor": "Sponsor Kanali", + "channel_tab_releases_label": "Hedhje në qarkullim", + "Song: ": "Pjesë: ", + "Import YouTube playlist (.csv)": "Importoni luajlistë YouTube (.csv)", + "Standard YouTube license": "Licencë YouTube standarde" } diff --git a/locales/sr.json b/locales/sr.json index a2853b68..f0e5518d 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -1,90 +1,90 @@ { "LIVE": "UŽIVO", - "Shared `x` ago": "Podeljeno pre `x`", + "Shared `x` ago": "Deljeno pre `x`", "Unsubscribe": "Prekini praćenje", - "Subscribe": "Prati", + "Subscribe": "Zaprati", "View channel on YouTube": "Pogledaj kanal na YouTube-u", - "View playlist on YouTube": "Pogledaj spisak izvođenja na YouTube-u", + "View playlist on YouTube": "Pogledaj plejlistu na YouTube-u", "newest": "najnovije", "oldest": "najstarije", "popular": "popularno", "last": "poslednje", "Next page": "Sledeća stranica", "Previous page": "Prethodna stranica", - "Clear watch history?": "Izbrisati povest pregledanja?", + "Clear watch history?": "Očistiti istoriju gledanja?", "New password": "Nova lozinka", - "New passwords must match": "Nove lozinke moraju biti istovetne", - "Authorize token?": "Ovlasti žeton?", - "Authorize token for `x`?": "Ovlasti žeton za `x`?", + "New passwords must match": "Nove lozinke moraju da se podudaraju", + "Authorize token?": "Autorizovati token?", + "Authorize token for `x`?": "Autorizovati token za `x`?", "Yes": "Da", "No": "Ne", - "Import and Export Data": "Uvoz i Izvoz Podataka", + "Import and Export Data": "Uvoz i izvoz podataka", "Import": "Uvezi", - "Import Invidious data": "Uvezi podatke sa Invidious-a", - "Import YouTube subscriptions": "Uvezi praćenja sa YouTube-a", - "Import FreeTube subscriptions (.db)": "Uvezi praćenja sa FreeTube-a (.db)", - "Import NewPipe subscriptions (.json)": "Uvezi praćenja sa NewPipe-a (.json)", - "Import NewPipe data (.zip)": "Uvezi podatke sa NewPipe-a (.zip)", + "Import Invidious data": "Uvezi Invidious JSON podatke", + "Import YouTube subscriptions": "Uvezi YouTube/OPML praćenja", + "Import FreeTube subscriptions (.db)": "Uvezi FreeTube praćenja (.db)", + "Import NewPipe subscriptions (.json)": "Uvezi NewPipe praćenja (.json)", + "Import NewPipe data (.zip)": "Uvezi NewPipe podatke (.zip)", "Export": "Izvezi", - "Export subscriptions as OPML": "Izvezi praćenja kao OPML datoteku", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvezi praćenja kao OPML datoteku (za NewPipe i FreeTube)", - "Export data as JSON": "Izvezi podatke kao JSON datoteku", - "Delete account?": "Izbrišite nalog?", + "Export subscriptions as OPML": "Izvezi praćenja kao OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvezi praćenja kao OPML (za NewPipe i FreeTube)", + "Export data as JSON": "Izvezi Invidious podatke kao JSON", + "Delete account?": "Izbrisati nalog?", "History": "Istorija", - "An alternative front-end to YouTube": "Zamenski korisnički sloj za YouTube", - "JavaScript license information": "Izveštaj o JavaScript odobrenju", + "An alternative front-end to YouTube": "Alternativni front-end za YouTube", + "JavaScript license information": "Informacije o JavaScript licenci", "source": "izvor", - "Log in": "Prijavi se", - "Log in/register": "Prijavi se/Otvori nalog", - "User ID": "Korisnički ID", + "Log in": "Prijava", + "Log in/register": "Prijava/registracija", + "User ID": "ID korisnika", "Password": "Lozinka", "Time (h:mm:ss):": "Vreme (č:mm:ss):", - "Text CAPTCHA": "Znakovni CAPTCHA", - "Image CAPTCHA": "Slikovni CAPTCHA", + "Text CAPTCHA": "Tekst CAPTCHA", + "Image CAPTCHA": "Slika CAPTCHA", "Sign In": "Prijava", - "Register": "Otvori nalog", - "E-mail": "E-pošta", + "Register": "Registracija", + "E-mail": "Imejl", "Preferences": "Podešavanja", - "preferences_category_player": "Podešavanja reproduktora", + "preferences_category_player": "Podešavanja plejera", "preferences_video_loop_label": "Uvek ponavljaj: ", - "preferences_autoplay_label": "Samopuštanje: ", - "preferences_continue_label": "Uvek podrazumevano puštaj sledeće: ", - "preferences_continue_autoplay_label": "Samopuštanje sledećeg video zapisa: ", - "preferences_listen_label": "Uvek podrazumevano uključen samo zvuk: ", - "preferences_local_label": "Prikaz video zapisa preko posrednika: ", - "Playlist privacy": "Podešavanja privatnosti plej liste", - "Editing playlist `x`": "Izmena plej liste `x`", - "Playlist does not exist.": "Nepostojeća plej lista.", + "preferences_autoplay_label": "Automatski pusti: ", + "preferences_continue_label": "Podrazumevano pusti sledeće: ", + "preferences_continue_autoplay_label": "Automatski pusti sledeći video snimak: ", + "preferences_listen_label": "Podrazumevano uključi samo zvuk: ", + "preferences_local_label": "Proksi video snimci: ", + "Playlist privacy": "Privatnost plejliste", + "Editing playlist `x`": "Izmenjivanje plejliste `x`", + "Playlist does not exist.": "Plejlista ne postoji.", "Erroneous challenge": "Pogrešan izazov", "Maltese": "Malteški", "Download": "Preuzmi", - "Download as: ": "Preuzmi kao: ", - "Bangla": "Bangla/Bengalski", - "preferences_quality_dash_label": "Preferirani kvalitet DASH video formata: ", - "Token manager": "Upravljanje žetonima", - "Token": "Žeton", - "Import/export": "Uvezi/Izvezi", + "Download as: ": "Preuzeti kao: ", + "Bangla": "Bengalski", + "preferences_quality_dash_label": "Preferirani DASH kvalitet video snimka: ", + "Token manager": "Upravljanje tokenima", + "Token": "Token", + "Import/export": "Uvoz/izvoz", "revoke": "opozovi", "search": "pretraga", "Log out": "Odjava", - "Source available here.": "Izvorna koda je ovde dostupna.", + "Source available here.": "Izvorni kôd je dostupan ovde.", "Trending": "U trendu", "Updated `x` ago": "Ažurirano pre `x`", - "Delete playlist `x`?": "Obriši plej listu `x`?", - "Create playlist": "Napravi plej listu", + "Delete playlist `x`?": "Izbrisati plejlistu `x`?", + "Create playlist": "Napravi plejlistu", "Show less": "Prikaži manje", "Switch Invidious Instance": "Promeni Invidious instancu", "Hide annotations": "Sakrij napomene", - "User ID is a required field": "Korisnički ID je obavezno polje", + "User ID is a required field": "ID korisnika je obavezno polje", "Wrong username or password": "Pogrešno korisničko ime ili lozinka", - "Please log in": "Molimo vas da se prijavite", + "Please log in": "Molimo, prijavite se", "channel:`x`": "kanal:`x`", - "Could not fetch comments": "Uzimanje komentara nije uspelo", - "Could not create mix.": "Pravljenje miksa nije uspelo.", - "Empty playlist": "Prazna plej lista", - "Not a playlist.": "Nije plej lista.", - "Could not pull trending pages.": "Učitavanje 'U toku' stranica nije uspelo.", - "Token is expired, please try again": "Žeton je istekao, molimo vas da pokušate ponovo", + "Could not fetch comments": "Nije moguće prikupiti komentare", + "Could not create mix.": "Nije moguće napraviti miks.", + "Empty playlist": "Prazna plejlista", + "Not a playlist.": "Nije plejlista.", + "Could not pull trending pages.": "Nije moguće povući stranice „U trendu“.", + "Token is expired, please try again": "Token je istekao, pokušajte ponovo", "English (auto-generated)": "Engleski (automatski generisano)", "Afrikaans": "Afrikans", "Albanian": "Albanski", @@ -95,19 +95,19 @@ "Bulgarian": "Bugarski", "Burmese": "Burmanski", "Catalan": "Katalonski", - "Cebuano": "Sebuano", + "Cebuano": "Cebuanski", "Chinese (Traditional)": "Kineski (Tradicionalni)", "Corsican": "Korzikanski", "Danish": "Danski", - "Kannada": "Kanada (Jezik)", + "Kannada": "Kanada", "Kazakh": "Kazaški", "Russian": "Ruski", "Scottish Gaelic": "Škotski Gelski", - "Sinhala": "Sinhaleški", + "Sinhala": "Sinhalski", "Slovak": "Slovački", "Spanish": "Španski", - "Spanish (Latin America)": "Španski (Južna Amerika)", - "Sundanese": "Sundski", + "Spanish (Latin America)": "Španski (Latinska Amerika)", + "Sundanese": "Sundanski", "Swedish": "Švedski", "Tajik": "Tadžički", "Telugu": "Telugu", @@ -116,77 +116,77 @@ "Urdu": "Urdu", "Uzbek": "Uzbečki", "Vietnamese": "Vijetnamski", - "Rating: ": "Ocena/e: ", - "View as playlist": "Pogledaj kao plej listu", - "Default": "Podrazumevan/o", - "Gaming": "Igrice", + "Rating: ": "Ocena: ", + "View as playlist": "Pogledaj kao plejlistu", + "Default": "Podrazumevano", + "Gaming": "Video igre", "Movies": "Filmovi", "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(izmenjeno)", - "YouTube comment permalink": "YouTube komentar trajna veza", - "Audio mode": "Audio mod", - "Playlists": "Plej liste", + "YouTube comment permalink": "Trajni link YouTube komentara", + "Audio mode": "Režim audio snimka", + "Playlists": "Plejliste", "search_filters_sort_option_relevance": "Relevantnost", - "search_filters_sort_option_rating": "Ocene", + "search_filters_sort_option_rating": "Ocena", "search_filters_sort_option_date": "Datum otpremanja", "search_filters_sort_option_views": "Broj pregleda", - "`x` marked it with a ❤": "`x` je označio/la ovo sa ❤", + "`x` marked it with a ❤": "`x` je označio/la sa ❤", "search_filters_duration_label": "Trajanje", "search_filters_features_label": "Karakteristike", "search_filters_date_option_hour": "Poslednji sat", - "search_filters_date_option_week": "Ove sedmice", - "search_filters_date_option_month": "Ovaj mesec", + "search_filters_date_option_week": "Ove nedelje", + "search_filters_date_option_month": "Ovog meseca", "search_filters_date_option_year": "Ove godine", - "search_filters_type_option_video": "Video", - "search_filters_type_option_playlist": "Plej lista", + "search_filters_type_option_video": "Video snimak", + "search_filters_type_option_playlist": "Plejlista", "search_filters_type_option_movie": "Film", "search_filters_duration_option_long": "Dugo (> 20 minuta)", "search_filters_features_option_hd": "HD", - "search_filters_features_option_c_commons": "Creative Commons (Licenca)", + "search_filters_features_option_c_commons": "Creative Commons", "search_filters_features_option_three_d": "3D", - "search_filters_features_option_hdr": "Video Visoke Rezolucije", - "next_steps_error_message": "Nakon čega bi trebali probati: ", - "next_steps_error_message_go_to_youtube": "Idi na YouTube", + "search_filters_features_option_hdr": "HDR", + "next_steps_error_message": "Nakon toga treba da pokušate da: ", + "next_steps_error_message_go_to_youtube": "Odete na YouTube", "footer_documentation": "Dokumentacija", - "preferences_region_label": "Država porekla sadržaja: ", + "preferences_region_label": "Država sadržaja: ", "preferences_player_style_label": "Stil plejera: ", - "preferences_dark_mode_label": "Izgled/Tema: ", - "light": "svetlo", + "preferences_dark_mode_label": "Tema: ", + "light": "svetla", "preferences_thin_mode_label": "Kompaktni režim: ", "preferences_category_misc": "Ostala podešavanja", - "preferences_automatic_instance_redirect_label": "Automatsko prebacivanje na drugu instancu u slučaju otkazivanja (preči će nazad na redirect.invidious.io): ", - "alphabetically - reverse": "po alfabetu - obrnuto", - "Enable web notifications": "Omogući obaveštenja u veb pretraživaču", - "`x` is live": "`x` prenosi uživo", - "Manage tokens": "Upravljaj žetonima", + "preferences_automatic_instance_redirect_label": "Automatsko preusmeravanje instance (povratak na redirect.invidious.io): ", + "alphabetically - reverse": "abecedno - obrnuto", + "Enable web notifications": "Omogući veb obaveštenja", + "`x` is live": "`x` je uživo", + "Manage tokens": "Upravljaj tokenima", "Watch history": "Istorija gledanja", - "preferences_feed_menu_label": "Dovodna stranica: ", + "preferences_feed_menu_label": "Fid meni: ", "preferences_show_nick_label": "Prikaži nadimke na vrhu: ", "CAPTCHA enabled: ": "CAPTCHA omogućena: ", "Registration enabled: ": "Registracija omogućena: ", "Subscription manager": "Upravljanje praćenjima", - "Wilson score: ": "Wilsonova ocena: ", + "Wilson score: ": "Vilsonova ocena: ", "Engagement: ": "Angažovanje: ", - "Whitelisted regions: ": "Dozvoljene oblasti: ", - "Shared `x`": "Podeljeno `x`", - "Premieres in `x`": "Premera u `x`", - "Premieres `x`": "Premere u `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.": "Hej! Izgleda da ste onemogućili JavaScript. Kliknite ovde da vidite komentare, čuvajte na umu da ovo može da potraje duže dok se ne učitaju.", + "Whitelisted regions: ": "Dostupni regioni: ", + "Shared `x`": "Deljeno `x`", + "Premieres in `x`": "Premijera u `x`", + "Premieres `x`": "Premijera `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.": "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]|$)": "Prikaži `x` komentar", - "": "Prikaži `x` komentara" + "([^.,0-9]|^)1([^.,0-9]|$)": "Pogledaj `x` komentar", + "": "Pogledaj`x` komentare" }, - "View Reddit comments": "Prikaži Reddit komentare", + "View Reddit comments": "Pogledaj Reddit komentare", "CAPTCHA is a required field": "CAPTCHA je obavezno polje", "Croatian": "Hrvatski", "Estonian": "Estonski", - "Filipino": "Filipino", + "Filipino": "Filipinski", "French": "Francuski", "Galician": "Galicijski", "German": "Nemački", "Greek": "Grčki", "Hausa": "Hausa", - "Italian": "Talijanski", + "Italian": "Italijanski", "Khmer": "Kmerski", "Kurdish": "Kurdski", "Kyrgyz": "Kirgiski", @@ -195,68 +195,68 @@ "Macedonian": "Makedonski", "Malagasy": "Malgaški", "Malay": "Malajski", - "Marathi": "Marathi", + "Marathi": "Maratski", "Mongolian": "Mongolski", "Norwegian Bokmål": "Norveški Bokmal", - "Nyanja": "Čeva", + "Nyanja": "Nijandža", "Pashto": "Paštunski", "Persian": "Persijski", - "Punjabi": "Pundžabi", + "Punjabi": "Pandžapski", "Romanian": "Rumunski", "Welsh": "Velški", "Western Frisian": "Zapadnofrizijski", - "Fallback comments: ": "Komentari u slučaju otkazivanja: ", + "Fallback comments: ": "Rezervni komentari: ", "Popular": "Popularno", "Search": "Pretraga", - "About": "O programu", - "footer_source_code": "Izvorna Koda", - "footer_original_source_code": "Originalna Izvorna Koda", - "preferences_related_videos_label": "Prikaži slične video klipove: ", - "preferences_annotations_label": "Prikaži napomene podrazumevano: ", - "preferences_extend_desc_label": "Automatski prikaži ceo opis videa: ", - "preferences_vr_mode_label": "Interaktivni video klipovi u 360 stepeni: ", - "preferences_category_visual": "Vizuelne preference", - "preferences_captions_label": "Podrazumevani titl: ", + "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_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): ", + "preferences_category_visual": "Vizuelna podešavanja", + "preferences_captions_label": "Podrazumevani titlovi: ", "Music": "Muzika", - "search_filters_type_label": "Tip", + "search_filters_type_label": "Vrsta", "Tamil": "Tamilski", "Save preferences": "Sačuvaj podešavanja", - "Only show latest unwatched video from channel: ": "Prikaži samo poslednje video klipove koji nisu pogledani sa kanala: ", - "Xhosa": "Kosa (Jezik)", + "Only show latest unwatched video from channel: ": "Prikaži samo najnoviji neodgledani video snimak sa kanala: ", + "Xhosa": "Kosa (Khosa)", "search_filters_type_option_channel": "Kanal", "Hungarian": "Mađarski", - "Maori": "Maori (Jezik)", - "Manage subscriptions": "Upravljaj zapisima", + "Maori": "Maorski", + "Manage subscriptions": "Upravljaj praćenjima", "Hindi": "Hindi", "`x` ago": "pre `x`", "Import/export data": "Uvezi/Izvezi podatke", - "`x` uploaded a video": "`x` je otpremio/la video klip", - "Delete account": "Obriši nalog", + "`x` uploaded a video": "`x` je otpremio/la video snimak", + "Delete account": "Izbriši nalog", "preferences_default_home_label": "Podrazumevana početna stranica: ", "Serbian": "Srpski", "License: ": "Licenca: ", "search_filters_features_option_live": "Uživo", - "Report statistics: ": "Izveštavaj o statistici: ", - "Only show latest video from channel: ": "Prikazuj poslednje video klipove samo sa kanala: ", + "Report statistics: ": "Izveštavaj statistike: ", + "Only show latest video from channel: ": "Prikaži samo najnoviji video snimak sa kanala: ", "channel name - reverse": "ime kanala - obrnuto", - "Could not get channel info.": "Uzimanje podataka o kanalu nije uspelo.", - "View privacy policy.": "Pogledaj izveštaj o privatnosti.", + "Could not get channel info.": "Nije moguće prikupiti informacije o kanalu.", + "View privacy policy.": "Pogledaj politiku privatnosti.", "Change password": "Promeni lozinku", - "Malayalam": "Malajalam", - "View more comments on Reddit": "Prikaži više komentara na Reddit-u", + "Malayalam": "Malajalamski", + "View more comments on Reddit": "Pogledaj više komentara na Reddit-u", "Portuguese": "Portugalski", - "View YouTube comments": "Prikaži YouTube komentare", + "View YouTube comments": "Pogledaj YouTube komentare", "published - reverse": "objavljeno - obrnuto", "Dutch": "Holandski", - "preferences_volume_label": "Jačina zvuka: ", + "preferences_volume_label": "Jačina zvuka plejera: ", "preferences_locale_label": "Jezik: ", - "adminprefs_modified_source_code_url_label": "URL veza do skladišta sa Izmenjenom Izvornom Kodom", + "adminprefs_modified_source_code_url_label": "URL adresa do repozitorijuma izmenjenog izvornog koda", "channel_tab_community_label": "Zajednica", - "Video mode": "Video mod", - "Fallback captions: ": "Titl u slučaju da glavni nije dostupan: ", + "Video mode": "Režim video snimka", + "Fallback captions: ": "Rezervni titlovi: ", "Private": "Privatno", - "alphabetically": "po alfabetu", - "No such user": "Nepostojeći korisnik", + "alphabetically": "abecedno", + "No such user": "Ne postoji korisnik", "Subscriptions": "Praćenja", "search_filters_date_option_today": "Danas", "Finnish": "Finski", @@ -265,30 +265,30 @@ "Shona": "Šona", "search_filters_features_option_location": "Lokacija", "Load more": "Učitaj više", - "Released under the AGPLv3 on Github.": "Izbačeno pod licencom AGPLv3 na GitHub-u.", + "Released under the AGPLv3 on Github.": "Objavljeno pod licencom AGPLv3 na GitHub-u.", "Slovenian": "Slovenački", - "View JavaScript license information.": "Pogledaj informacije licence vezane za JavaScript.", + "View JavaScript license information.": "Pogledaj informacije o JavaScript licenci.", "Chinese (Simplified)": "Kineski (Pojednostavljeni)", "preferences_comments_label": "Podrazumevani komentari: ", "Incorrect password": "Netačna lozinka", "Show replies": "Prikaži odgovore", - "Invidious Private Feed for `x`": "Invidious Privatni Dovod za `x`", + "Invidious Private Feed for `x`": "Invidious privatni fid za `x`", "Watch on YouTube": "Gledaj na YouTube-u", "Wrong answer": "Pogrešan odgovor", - "preferences_quality_label": "Preferirani video kvalitet: ", + "preferences_quality_label": "Preferirani kvalitet video snimka: ", "Hide replies": "Sakrij odgovore", "Erroneous CAPTCHA": "Pogrešna CAPTCHA", - "Erroneous token": "Pogrešan žeton", + "Erroneous token": "Pogrešan token", "Czech": "Češki", "Latin": "Latinski", - "channel_tab_videos_label": "Video klipovi", + "channel_tab_videos_label": "Video snimci", "search_filters_features_option_four_k": "4К", "footer_donate_page": "Doniraj", "English": "Engleski", "Arabic": "Arapski", - "Unlisted": "Nenavedeno", - "Hidden field \"challenge\" is a required field": "Sakriveno \"challenge\" polje je obavezno", - "Hidden field \"token\" is a required field": "Sakriveno \"token\" polje je obavezno", + "Unlisted": "Po pozivu", + "Hidden field \"challenge\" is a required field": "Skriveno polje „izazov“ je obavezno polje", + "Hidden field \"token\" is a required field": "Skriveno polje „token“ je obavezno polje", "Georgian": "Gruzijski", "Hawaiian": "Havajski", "Hebrew": "Hebrejski", @@ -297,68 +297,211 @@ "Japanese": "Japanski", "Javanese": "Javanski", "Sindhi": "Sindi", - "Swahili": "Svahili", + "Swahili": "Suvali", "Yiddish": "Jidiš", "Zulu": "Zulu", - "search_filters_features_option_subtitles": "Titl/Prevod", - "Password cannot be longer than 55 characters": "Lozinka ne može biti duža od 55 karaktera", + "search_filters_features_option_subtitles": "Titlovi/Skriveni titlovi", + "Password cannot be longer than 55 characters": "Lozinka ne može biti duža od 55 znakova", "This channel does not exist.": "Ovaj kanal ne postoji.", "Belarusian": "Beloruski", "Gujarati": "Gudžarati", "Haitian Creole": "Haićanski Kreolski", "Somali": "Somalijski", - "Top": "Vrh", - "footer_modfied_source_code": "Izmenjena Izvorna Koda", + "Top": "Top", + "footer_modfied_source_code": "Izmenjeni izvorni kôd", "preferences_category_subscription": "Podešavanja praćenja", "preferences_annotations_subscribed_label": "Podrazumevano prikazati napomene za kanale koje pratite? ", - "preferences_max_results_label": "Broj video klipova prikazanih u dovodnoj listi: ", - "preferences_sort_label": "Sortiraj video klipove po: ", - "preferences_unseen_only_label": "Prikaži samo video klipove koji nisu pogledani: ", - "preferences_notifications_only_label": "Prikaži samo obaveštenja (ako ih uopšte ima): ", + "preferences_max_results_label": "Broj video snimaka prikazanih u fidu: ", + "preferences_sort_label": "Sortiraj video snimke po: ", + "preferences_unseen_only_label": "Prikaži samo neodgledano: ", + "preferences_notifications_only_label": "Prikaži samo obaveštenja (ako ih ima): ", "preferences_category_data": "Podešavanja podataka", - "Clear watch history": "Obriši istoriju gledanja", - "preferences_category_admin": "Administratorska podešavanja", + "Clear watch history": "Očisti istoriju gledanja", + "preferences_category_admin": "Podešavanja administratora", "published": "objavljeno", - "search_filters_sort_label": "Poredaj prema", + "search_filters_sort_label": "Sortiranje po", "search_filters_type_option_show": "Emisija", - "search_filters_duration_option_short": "Kratko (< 4 minute)", + "search_filters_duration_option_short": "Kratko (< 4 minuta)", "Current version: ": "Trenutna verzija: ", - "Top enabled: ": "Vrh omogućen: ", + "Top enabled: ": "Top omogućeno: ", "Public": "Javno", - "Delete playlist": "Obriši plej listu", + "Delete playlist": "Izbriši plejlistu", "Title": "Naslov", "Show annotations": "Prikaži napomene", "Password cannot be empty": "Lozinka ne može biti prazna", - "Deleted or invalid channel": "Obrisan ili nepostojeći kanal", + "Deleted or invalid channel": "Izbrisan ili nevažeći kanal", "Esperanto": "Esperanto", "Hmong": "Hmong", "Luxembourgish": "Luksemburški", "Nepali": "Nepalski", "Samoan": "Samoanski", "News": "Vesti", - "permalink": "trajna veza", + "permalink": "trajni link", "Password is a required field": "Lozinka je obavezno polje", "Amharic": "Amharski", - "Indonesian": "Indonežanski", + "Indonesian": "Indonezijski", "Irish": "Irski", "Korean": "Korejski", "Southern Sotho": "Južni Soto", "Thai": "Tajski", "preferences_speed_label": "Podrazumevana brzina: ", "Dark mode: ": "Tamni režim: ", - "dark": "tamno", - "Redirect homepage to feed: ": "Prebaci sa početne stranice na dovodnu listu: ", + "dark": "tamna", + "Redirect homepage to feed: ": "Preusmeri početnu stranicu na fid: ", "channel name": "ime kanala", - "View all playlists": "Pregledaj sve plej liste", + "View all playlists": "Pogledaj sve plejliste", "Show more": "Prikaži više", "Genre: ": "Žanr: ", "Family friendly? ": "Pogodno za porodicu? ", - "next_steps_error_message_refresh": "Osveži stranicu", + "next_steps_error_message_refresh": "Osvežite", "youtube": "YouTube", "reddit": "Reddit", - "unsubscribe": "prekini sa praćenjem", - "Blacklisted regions: ": "Zabranjene oblasti: ", + "unsubscribe": "prekini praćenje", + "Blacklisted regions: ": "Nedostupni regioni: ", "Polish": "Poljski", "Yoruba": "Joruba", - "search_filters_title": "Filter" + "search_filters_title": "Filteri", + "Korean (auto-generated)": "Korejski (automatski generisano)", + "search_filters_features_option_three_sixty": "360°", + "preferences_quality_dash_option_worst": "Najgore", + "channel_tab_podcasts_label": "Podkasti", + "preferences_save_player_pos_label": "Sačuvaj poziciju reprodukcije: ", + "Spanish (Mexico)": "Španski (Meksiko)", + "generic_subscriptions_count_0": "{{count}} praćenje", + "generic_subscriptions_count_1": "{{count}} praćenja", + "generic_subscriptions_count_2": "{{count}} praćenja", + "search_filters_apply_button": "Primeni izabrane filtere", + "Download is disabled": "Preuzimanje je onemogućeno", + "comments_points_count_0": "{{count}} poen", + "comments_points_count_1": "{{count}} poena", + "comments_points_count_2": "{{count}} poena", + "preferences_quality_dash_option_2160p": "2160p", + "German (auto-generated)": "Nemački (automatski generisano)", + "Japanese (auto-generated)": "Japanski (automatski generisano)", + "preferences_quality_option_medium": "Srednje", + "search_message_change_filters_or_query": "Pokušajte da proširite upit za pretragu i/ili promenite filtere.", + "crash_page_before_reporting": "Pre nego što prijavite grešku, uverite se da ste:", + "preferences_quality_dash_option_best": "Najbolje", + "Channel Sponsor": "Sponzor kanala", + "generic_videos_count_0": "{{count}} video snimak", + "generic_videos_count_1": "{{count}} video snimka", + "generic_videos_count_2": "{{count}} video snimaka", + "videoinfo_started_streaming_x_ago": "Započeto strimovanje pre `x`", + "videoinfo_youTube_embed_link": "Ugrađeno", + "channel_tab_streams_label": "Strimovi uživo", + "playlist_button_add_items": "Dodaj video snimke", + "generic_count_minutes_0": "{{count}} minut", + "generic_count_minutes_1": "{{count}} minuta", + "generic_count_minutes_2": "{{count}} minuta", + "preferences_quality_dash_option_720p": "720p", + "preferences_watch_history_label": "Omogući istoriju gledanja: ", + "user_saved_playlists": "Sačuvanih plejlista: `x`", + "Spanish (Spain)": "Španski (Španija)", + "invidious": "Invidious", + "crash_page_refresh": "pokušali da osvežite stranicu", + "Chinese (Hong Kong)": "Kineski (Hong Kong)", + "Artist: ": "Izvođač: ", + "generic_count_months_0": "{{count}} mesec", + "generic_count_months_1": "{{count}} meseca", + "generic_count_months_2": "{{count}} meseci", + "search_message_use_another_instance": " Takođe, možete pretraživati na drugoj instanci.", + "generic_subscribers_count_0": "{{count}} pratilac", + "generic_subscribers_count_1": "{{count}} pratioca", + "generic_subscribers_count_2": "{{count}} pratilaca", + "download_subtitles": "Titlovi - `x` (.vtt)", + "generic_button_save": "Sačuvaj", + "crash_page_search_issue": "pretražili postojeće izveštaje o problemima na GitHub-u", + "generic_button_cancel": "Otkaži", + "none": "nijedno", + "English (United States)": "Engleski (Sjedinjene Američke Države)", + "subscriptions_unseen_notifs_count_0": "{{count}} neviđeno obaveštenje", + "subscriptions_unseen_notifs_count_1": "{{count}} neviđena obaveštenja", + "subscriptions_unseen_notifs_count_2": "{{count}} neviđenih obaveštenja", + "Album: ": "Album: ", + "preferences_quality_option_dash": "DASH (adaptivni kvalitet)", + "preferences_quality_dash_option_1080p": "1080p", + "Video unavailable": "Video snimak nedostupan", + "tokens_count_0": "{{count}} token", + "tokens_count_1": "{{count}} tokena", + "tokens_count_2": "{{count}} tokena", + "Chinese (China)": "Kineski (Kina)", + "Italian (auto-generated)": "Italijanski (automatski generisano)", + "channel_tab_shorts_label": "Shorts", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_360p": "360p", + "search_message_no_results": "Nisu pronađeni rezultati.", + "channel_tab_releases_label": "Izdanja", + "preferences_quality_dash_option_144p": "144p", + "Interlingue": "Interlingva", + "Song: ": "Pesma: ", + "generic_channels_count_0": "{{count}} kanal", + "generic_channels_count_1": "{{count}} kanala", + "generic_channels_count_2": "{{count}} kanala", + "Chinese (Taiwan)": "Kineski (Tajvan)", + "Turkish (auto-generated)": "Turski (automatski generisano)", + "Indonesian (auto-generated)": "Indonezijski (automatski generisano)", + "Portuguese (auto-generated)": "Portugalski (automatski generisano)", + "generic_count_years_0": "{{count}} godina", + "generic_count_years_1": "{{count}} godine", + "generic_count_years_2": "{{count}} godina", + "videoinfo_invidious_embed_link": "Ugrađeni link", + "Popular enabled: ": "Popularno omogućeno: ", + "Spanish (auto-generated)": "Španski (automatski generisano)", + "preferences_quality_option_small": "Malo", + "English (United Kingdom)": "Engleski (Ujedinjeno Kraljevstvo)", + "channel_tab_playlists_label": "Plejliste", + "generic_button_edit": "Izmeni", + "generic_playlists_count_0": "{{count}} plejlista", + "generic_playlists_count_1": "{{count}} plejliste", + "generic_playlists_count_2": "{{count}} plejlista", + "preferences_quality_option_hd720": "HD720", + "search_filters_features_option_purchased": "Kupljeno", + "search_filters_date_option_none": "Bilo koji datum", + "preferences_quality_dash_option_auto": "Automatski", + "Cantonese (Hong Kong)": "Kantonski (Hong Kong)", + "crash_page_report_issue": "Ako ništa od gorenavedenog nije pomoglo, otvorite novi izveštaj o problemu na GitHub-u (po mogućnosti na engleskom) i uključite sledeći tekst u svoju poruku (NE prevodite taj tekst):", + "crash_page_switch_instance": "pokušali da koristite drugu instancu", + "generic_count_weeks_0": "{{count}} nedelja", + "generic_count_weeks_1": "{{count}} nedelje", + "generic_count_weeks_2": "{{count}} nedelja", + "videoinfo_watch_on_youTube": "Gledaj na YouTube-u", + "Music in this video": "Muzika u ovom video snimku", + "generic_button_rss": "RSS", + "preferences_quality_dash_option_4320p": "4320p", + "generic_count_hours_0": "{{count}} sat", + "generic_count_hours_1": "{{count}} sata", + "generic_count_hours_2": "{{count}} sati", + "French (auto-generated)": "Francuski (automatski generisano)", + "crash_page_read_the_faq": "pročitali Često Postavljana Pitanja (ČPP)", + "user_created_playlists": "Napravljenih plejlista: `x`", + "channel_tab_channels_label": "Kanali", + "search_filters_type_option_all": "Bilo koja vrsta", + "Russian (auto-generated)": "Ruski (automatski generisano)", + "preferences_quality_dash_option_480p": "480p", + "comments_view_x_replies_0": "Pogledaj {{count}} odgovor", + "comments_view_x_replies_1": "Pogledaj {{count}} odgovora", + "comments_view_x_replies_2": "Pogledaj {{count}} odgovora", + "Portuguese (Brazil)": "Portugalski (Brazil)", + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Traženi video snimak ne postoji na ovoj plejlisti. Kliknite ovde za početnu stranicu plejliste.", + "Dutch (auto-generated)": "Holandski (automatski generisano)", + "generic_count_days_0": "{{count}} dan", + "generic_count_days_1": "{{count}} dana", + "generic_count_days_2": "{{count}} dana", + "Vietnamese (auto-generated)": "Vijetnamski (automatski generisano)", + "search_filters_duration_option_none": "Bilo koje trajanje", + "preferences_quality_dash_option_240p": "240p", + "Chinese": "Kineski", + "generic_button_delete": "Izbriši", + "Import YouTube playlist (.csv)": "Uvezi YouTube plejlistu (.csv)", + "Standard YouTube license": "Standardna YouTube licenca", + "search_filters_duration_option_medium": "Srednje (4 - 20 minuta)", + "generic_count_seconds_0": "{{count}} sekunda", + "generic_count_seconds_1": "{{count}} sekunde", + "generic_count_seconds_2": "{{count}} sekundi", + "search_filters_date_label": "Datum otpremanja", + "crash_page_you_found_a_bug": "Izgleda da ste pronašli grešku u Invidious-u!", + "generic_views_count_0": "{{count}} pregled", + "generic_views_count_1": "{{count}} pregleda", + "generic_views_count_2": "{{count}} pregleda" } diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index 218f31c9..bf439b28 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -1,166 +1,166 @@ { "LIVE": "УЖИВО", - "Shared `x` ago": "Подељено пре `x`", + "Shared `x` ago": "Дељено пре `x`", "Unsubscribe": "Прекини праћење", - "Subscribe": "Прати", + "Subscribe": "Запрати", "View channel on YouTube": "Погледај канал на YouTube-у", - "View playlist on YouTube": "Погледај списак извођења на YоуТубе-у", + "View playlist on YouTube": "Погледај плејлисту на YouTube-у", "newest": "најновије", "oldest": "најстарије", "popular": "популарно", "last": "последње", - "Next page": "Следећа страна", - "Previous page": "Претходна страна", - "Clear watch history?": "Избрисати повест прегледања?", + "Next page": "Следећа страница", + "Previous page": "Претходна страница", + "Clear watch history?": "Очистити историју гледања?", "New password": "Нова лозинка", - "New passwords must match": "Нове лозинке морају бити истоветне", - "Authorize token?": "Овласти жетон?", - "Authorize token for `x`?": "Овласти жетон за `x`?", + "New passwords must match": "Нове лозинке морају да се подударају", + "Authorize token?": "Ауторизовати токен?", + "Authorize token for `x`?": "Ауторизовати токен за `x`?", "Yes": "Да", "No": "Не", "Import and Export Data": "Увоз и извоз података", "Import": "Увези", - "Import Invidious data": "Увези податке са Individious-а", - "Import YouTube subscriptions": "Увези праћења са YouTube-а", - "Import FreeTube subscriptions (.db)": "Увези праћења са FreeTube-а (.db)", - "Import NewPipe subscriptions (.json)": "Увези праћења са NewPipe-а (.json)", - "Import NewPipe data (.zip)": "Увези податке са NewPipe-a (.zip)", + "Import Invidious data": "Увези Invidious JSON податке", + "Import YouTube subscriptions": "Увези YouTube/OPML праћења", + "Import FreeTube subscriptions (.db)": "Увези FreeTube праћења (.db)", + "Import NewPipe subscriptions (.json)": "Увези NewPipe праћења (.json)", + "Import NewPipe data (.zip)": "Увези NewPipe податке (.zip)", "Export": "Извези", - "Export subscriptions as OPML": "Извези праћења као ОПМЛ датотеку", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извези праћења као ОПМЛ датотеку (за NewPipe и FreeTube)", - "Export data as JSON": "Извези податке као JSON датотеку", - "Delete account?": "Избришите налог?", + "Export subscriptions as OPML": "Извези праћења као OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извези праћења као OPML (за NewPipe и FreeTube)", + "Export data as JSON": "Извези Invidious податке као JSON", + "Delete account?": "Избрисати налог?", "History": "Историја", - "An alternative front-end to YouTube": "Заменски кориснички слој за YouTube", - "JavaScript license information": "Извештај о JavaScript одобрењу", + "An alternative front-end to YouTube": "Алтернативни фронт-енд за YouTube", + "JavaScript license information": "Информације о JavaScript лиценци", "source": "извор", - "Log in": "Пријави се", - "Log in/register": "Пријави се/Отворите налог", - "User ID": "Кориснички ИД", + "Log in": "Пријава", + "Log in/register": "Пријава/регистрација", + "User ID": "ID корисника", "Password": "Лозинка", "Time (h:mm:ss):": "Време (ч:мм:сс):", - "Text CAPTCHA": "Знаковни ЦАПТЧА", - "Image CAPTCHA": "Сликовни CAPTCHA", + "Text CAPTCHA": "Текст CAPTCHA", + "Image CAPTCHA": "Слика CAPTCHA", "Sign In": "Пријава", - "Register": "Отвори налог", - "E-mail": "Е-пошта", + "Register": "Регистрација", + "E-mail": "Имејл", "Preferences": "Подешавања", - "preferences_category_player": "Подешавања репродуктора", + "preferences_category_player": "Подешавања плејера", "preferences_video_loop_label": "Увек понављај: ", - "preferences_autoplay_label": "Самопуштање: ", - "preferences_continue_label": "Увек подразумевано пуштај следеће: ", - "preferences_continue_autoplay_label": "Самопуштање следећег видео записа: ", - "preferences_listen_label": "Увек подразумевано укључен само звук: ", - "preferences_local_label": "Приказ видео записа преко посредника: ", + "preferences_autoplay_label": "Аутоматски пусти: ", + "preferences_continue_label": "Подразумевано пусти следеће: ", + "preferences_continue_autoplay_label": "Аутоматски пусти следећи видео снимак: ", + "preferences_listen_label": "Подразумевано укључи само звук: ", + "preferences_local_label": "Прокси видео снимци: ", "preferences_speed_label": "Подразумевана брзина: ", - "preferences_quality_label": "Преферирани видео квалитет: ", - "preferences_volume_label": "Јачина звука: ", + "preferences_quality_label": "Преферирани квалитет видео снимка: ", + "preferences_volume_label": "Јачина звука плејера: ", "preferences_comments_label": "Подразумевани коментари: ", "youtube": "YouTube", "reddit": "Reddit", - "preferences_captions_label": "Подразумевани титл: ", - "Fallback captions: ": "Титл у случају да главни није доступан: ", - "preferences_related_videos_label": "Прикажи сличне видео клипове: ", - "preferences_annotations_label": "Прикажи напомене подразумевано: ", - "preferences_category_visual": "Визуелне преференце", + "preferences_captions_label": "Подразумевани титлови: ", + "Fallback captions: ": "Резервни титлови: ", + "preferences_related_videos_label": "Прикажи повезане видео снимке: ", + "preferences_annotations_label": "Подразумевано прикажи напомене: ", + "preferences_category_visual": "Визуелна подешавања", "preferences_player_style_label": "Стил плејера: ", "Dark mode: ": "Тамни режим: ", - "preferences_dark_mode_label": "Изглед/Тема: ", - "dark": "тамно", - "light": "светло", + "preferences_dark_mode_label": "Тема: ", + "dark": "тамна", + "light": "светла", "preferences_thin_mode_label": "Компактни режим: ", "preferences_category_subscription": "Подешавања праћења", "preferences_annotations_subscribed_label": "Подразумевано приказати напомене за канале које пратите? ", - "Redirect homepage to feed: ": "Пребаци са почетне странице на доводну листу: ", - "preferences_max_results_label": "Број видео клипова приказаних у доводној листи: ", - "preferences_sort_label": "Сортирај видео клипове по: ", + "Redirect homepage to feed: ": "Преусмери почетну страницу на фид: ", + "preferences_max_results_label": "Број видео снимака приказаних у фиду: ", + "preferences_sort_label": "Сортирај видео снимке по: ", "published": "објављено", "published - reverse": "објављено - обрнуто", - "alphabetically": "по алфабету", - "alphabetically - reverse": "по алфабету - обрнуто", + "alphabetically": "абецедно", + "alphabetically - reverse": "абецедно - обрнуто", "channel name": "име канала", "channel name - reverse": "име канала - обрнуто", - "Only show latest video from channel: ": "Приказуј последње видео клипове само са канала: ", - "Only show latest unwatched video from channel: ": "Прикажи само последње видео клипове који нису погледани са канала: ", - "preferences_unseen_only_label": "Прикажи само видео клипове који нису погледани: ", - "preferences_notifications_only_label": "Прикажи само обавештења (ако их уопште има): ", - "Enable web notifications": "Омогући обавештења у веб претраживачу", - "`x` uploaded a video": "`x` је отпремио/ла видео клип", - "`x` is live": "`x` преноси уживо", + "Only show latest video from channel: ": "Прикажи само најновији видео снимак са канала: ", + "Only show latest unwatched video from channel: ": "Прикажи само најновији неодгледани видео снимак са канала: ", + "preferences_unseen_only_label": "Прикажи само недогледано: ", + "preferences_notifications_only_label": "Прикажи само обавештења (ако их има): ", + "Enable web notifications": "Омогући веб обавештења", + "`x` uploaded a video": "`x` је отпремио/ла видео снимак", + "`x` is live": "`x` је уживо", "preferences_category_data": "Подешавања података", - "Clear watch history": "Обриши историју гледања", + "Clear watch history": "Очисти историју гледања", "Import/export data": "Увези/Извези податке", "Change password": "Промени лозинку", - "Manage subscriptions": "Управљај записима", - "Manage tokens": "Управљај жетонима", + "Manage subscriptions": "Управљај праћењима", + "Manage tokens": "Управљај токенима", "Watch history": "Историја гледања", - "Delete account": "Обриши налог", - "preferences_category_admin": "Администраторска подешавања", + "Delete account": "Избриши налог", + "preferences_category_admin": "Подешавања администратора", "preferences_default_home_label": "Подразумевана почетна страница: ", - "preferences_feed_menu_label": "Доводна страница: ", + "preferences_feed_menu_label": "Фид мени: ", "CAPTCHA enabled: ": "CAPTCHA омогућена: ", "Login enabled: ": "Пријава омогућена: ", "Registration enabled: ": "Регистрација омогућена: ", "Save preferences": "Сачувај подешавања", "Subscription manager": "Управљање праћењима", - "Token manager": "Управљање жетонима", - "Token": "Жетон", - "Import/export": "Увези/Извези", - "unsubscribe": "прекини са праћењем", + "Token manager": "Управљање токенима", + "Token": "Токен", + "Import/export": "Увоз/извоз", + "unsubscribe": "прекини праћење", "revoke": "опозови", "Subscriptions": "Праћења", "search": "претрага", "Log out": "Одјава", - "Source available here.": "Изворна кода је овде доступна.", - "View JavaScript license information.": "Погледај информације лиценце везане за JavaScript.", - "View privacy policy.": "Погледај извештај о приватности.", + "Source available here.": "Изворни кôд је доступан овде.", + "View JavaScript license information.": "Погледај информације о JavaScript лиценци.", + "View privacy policy.": "Погледај политику приватности.", "Trending": "У тренду", "Public": "Јавно", - "Unlisted": "Ненаведено", + "Unlisted": "По позиву", "Private": "Приватно", - "View all playlists": "Прегледај све плеј листе", + "View all playlists": "Погледај све плејлисте", "Updated `x` ago": "Ажурирано пре `x`", - "Delete playlist `x`?": "Обриши плеј листу `x`?", - "Delete playlist": "Обриши плеј листу", - "Create playlist": "Направи плеј листу", + "Delete playlist `x`?": "Избрисати плејлисту `x`?", + "Delete playlist": "Избриши плејлисту", + "Create playlist": "Направи плејлисту", "Title": "Наслов", - "Playlist privacy": "Подешавања приватности плеј листе", - "Editing playlist `x`": "Измена плеј листе `x`", + "Playlist privacy": "Приватност плејлисте", + "Editing playlist `x`": "Измењивање плејлисте `x`", "Watch on YouTube": "Гледај на YouTube-у", "Hide annotations": "Сакриј напомене", "Show annotations": "Прикажи напомене", "Genre: ": "Жанр: ", "License: ": "Лиценца: ", "Engagement: ": "Ангажовање: ", - "Whitelisted regions: ": "Дозвољене области: ", - "Blacklisted regions: ": "Забрањене области: ", - "Premieres in `x`": "Премера у `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.": "Хеј! Изгледа да сте онемогућили JavaScript. Кликните овде да видите коментаре, чувајте на уму да ово може да потраје дуже док се не учитају.", - "View YouTube comments": "Прикажи YouTube коментаре", - "View more comments on Reddit": "Прикажи више коментара на Reddit-у", - "View Reddit comments": "Прикажи Reddit коментаре", + "Whitelisted regions: ": "Доступни региони: ", + "Blacklisted regions: ": "Недоступни региони: ", + "Premieres in `x`": "Премијера у `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.": "Хеј! Изгледа да сте искључили JavaScript. Кликните овде да бисте видели коментаре, имајте на уму да ће можда потрајати мало дуже да се учитају.", + "View YouTube comments": "Погледај YouTube коментаре", + "View more comments on Reddit": "Погледај више коментара на Reddit-у", + "View Reddit comments": "Погледај Reddit коментаре", "Hide replies": "Сакриј одговоре", "Show replies": "Прикажи одговоре", "Incorrect password": "Нетачна лозинка", "Current version: ": "Тренутна верзија: ", - "Wilson score: ": "Wилсонова оцена: ", + "Wilson score: ": "Вилсонова оцена: ", "Burmese": "Бурмански", - "preferences_quality_dash_label": "Преферирани квалитет DASH видео формата: ", - "Erroneous token": "Погрешан жетон", + "preferences_quality_dash_label": "Преферирани DASH квалитет видео снимка: ", + "Erroneous token": "Погрешан токен", "CAPTCHA is a required field": "CAPTCHA је обавезно поље", - "No such user": "Непостојећи корисник", + "No such user": "Не постоји корисник", "Chinese (Traditional)": "Кинески (Традиционални)", - "adminprefs_modified_source_code_url_label": "УРЛ веза до складишта са Измењеном Изворном Кодом", + "adminprefs_modified_source_code_url_label": "URL адреса до репозиторијума измењеног изворног кода", "Lao": "Лаоски", "Czech": "Чешки", - "Kannada": "Канада (Језик)", + "Kannada": "Канада", "Polish": "Пољски", - "Cebuano": "Себуано", + "Cebuano": "Цебуански", "preferences_show_nick_label": "Прикажи надимке на врху: ", - "Report statistics: ": "Извештавај о статистици: ", + "Report statistics: ": "Извештавај статистике: ", "Show more": "Прикажи више", "Wrong answer": "Погрешан одговор", - "Hidden field \"token\" is a required field": "Сакривено \"token\" поље је обавезно", + "Hidden field \"token\" is a required field": "Скривено поље „токен“ је обавезно поље", "English": "Енглески", "Albanian": "Албански", "Amharic": "Амхарски", @@ -176,38 +176,38 @@ "Georgian": "Грузијски", "Greek": "Грчки", "Hausa": "Хауса", - "search_filters_type_option_video": "Видео", - "search_filters_type_option_playlist": "Плеј листа", + "search_filters_type_option_video": "Видео снимак", + "search_filters_type_option_playlist": "Плејлиста", "search_filters_type_option_movie": "Филм", "search_filters_duration_option_long": "Дуго (> 20 минута)", - "search_filters_features_option_c_commons": "Creative Commons (Лиценца)", + "search_filters_features_option_c_commons": "Creative Commons", "search_filters_features_option_live": "Уживо", "search_filters_features_option_location": "Локација", - "next_steps_error_message": "Након чега би требали пробати: ", + "next_steps_error_message": "Након тога би требало да покушате да: ", "footer_donate_page": "Донирај", "footer_documentation": "Документација", - "footer_modfied_source_code": "Измењена Изворна Кода", - "preferences_region_label": "Држава порекла садржаја: ", + "footer_modfied_source_code": "Измењени изворни кôд", + "preferences_region_label": "Држава садржаја: ", "preferences_category_misc": "Остала подешавања", - "User ID is a required field": "Кориснички ИД је обавезно поље", + "User ID is a required field": "ID корисника је обавезно поље", "Password is a required field": "Лозинка је обавезно поље", "Wrong username or password": "Погрешно корисничко име или лозинка", "Password cannot be empty": "Лозинка не може бити празна", - "Password cannot be longer than 55 characters": "Лозинка не може бити дужа од 55 карактера", - "Invidious Private Feed for `x`": "Инвидиоус Приватни Довод за `x`", - "Deleted or invalid channel": "Обрисан или непостојећи канал", + "Password cannot be longer than 55 characters": "Лозинка не може бити дужа од 55 знакова", + "Invidious Private Feed for `x`": "Invidious приватни фид за `x`", + "Deleted or invalid channel": "Избрисан или неважећи канал", "This channel does not exist.": "Овај канал не постоји.", - "Could not create mix.": "Прављење микса није успело.", - "Empty playlist": "Празна плеј листа", - "Not a playlist.": "Није плеј листа.", - "Playlist does not exist.": "Непостојећа плеј листа.", - "Could not pull trending pages.": "Учитавање 'У току' страница није успело.", - "Hidden field \"challenge\" is a required field": "Сакривено \"challenge\" поље је обавезно", + "Could not create mix.": "Није могуће направити микс.", + "Empty playlist": "Празна плејлиста", + "Not a playlist.": "Није плејлиста.", + "Playlist does not exist.": "Плејлиста не постоји.", + "Could not pull trending pages.": "Није могуће повући странице „У тренду“.", + "Hidden field \"challenge\" is a required field": "Скривено поље „изазов“ је обавезно поље", "Telugu": "Телугу", "Turkish": "Турски", "Urdu": "Урду", - "Western Frisian": "Западнофрисијски", - "Xhosa": "Коса (Језик)", + "Western Frisian": "Западнофризијски", + "Xhosa": "Коса (Кхоса)", "Yiddish": "Јидиш", "Hawaiian": "Хавајски", "Hmong": "Хмонг", @@ -217,58 +217,58 @@ "Khmer": "Кмерски", "Kyrgyz": "Киргиски", "Macedonian": "Македонски", - "Maori": "Маори (Језик)", - "Marathi": "Маратхи", + "Maori": "Маорски", + "Marathi": "Маратски", "Nepali": "Непалски", "Norwegian Bokmål": "Норвешки Бокмал", - "Nyanja": "Чева", + "Nyanja": "Нијанџа", "Russian": "Руски", "Scottish Gaelic": "Шкотски Гелски", "Shona": "Шона", "Slovak": "Словачки", - "Spanish (Latin America)": "Шпански (Јужна Америка)", - "Sundanese": "Сундски", - "Swahili": "Свахили", + "Spanish (Latin America)": "Шпански (Латинска Америка)", + "Sundanese": "Сундански", + "Swahili": "Сували", "Tajik": "Таџички", "Search": "Претрага", - "Rating: ": "Ocena/e: ", - "Default": "Подразумеван/о", + "Rating: ": "Оцена: ", + "Default": "Подразумевано", "News": "Вести", "Download": "Преузми", "(edited)": "(измењено)", - "`x` marked it with a ❤": "`x` је означио/ла ово са ❤", - "Audio mode": "Аудио мод", - "channel_tab_videos_label": "Видео клипови", + "`x` marked it with a ❤": "`x` је означио/ла са ❤", + "Audio mode": "Режим аудио снимка", + "channel_tab_videos_label": "Видео снимци", "search_filters_sort_option_views": "Број прегледа", "search_filters_features_label": "Карактеристике", "search_filters_date_option_today": "Данас", "%A %B %-d, %Y": "%A %B %-d, %Y", "preferences_locale_label": "Језик: ", - "Persian": "Перзијски", + "Persian": "Персијски", "View `x` comments": { - "": "Прикажи `x` коментара", - "([^.,0-9]|^)1([^.,0-9]|$)": "Прикажи `x` коментар" + "": "Погледај `x` коментаре", + "([^.,0-9]|^)1([^.,0-9]|$)": "Погледај `x` коментар" }, "search_filters_type_option_channel": "Канал", "Haitian Creole": "Хаићански Креолски", "Armenian": "Јерменски", - "next_steps_error_message_go_to_youtube": "Иди на YouTube", - "Indonesian": "Индонежански", - "preferences_vr_mode_label": "Интерактивни видео клипови у 360 степени: ", + "next_steps_error_message_go_to_youtube": "Одете на YouTube", + "Indonesian": "Индонезијски", + "preferences_vr_mode_label": "Интерактивни видео снимци од 360 степени (захтева WebGL): ", "Switch Invidious Instance": "Промени Invidious инстанцу", "Portuguese": "Португалски", - "search_filters_date_option_week": "Ове седмице", + "search_filters_date_option_week": "Ове недеље", "search_filters_type_option_show": "Емисија", - "Fallback comments: ": "Коментари у случају отказивања: ", - "search_filters_features_option_hdr": "Видео Високе Резолуције", - "About": "О програму", + "Fallback comments: ": "Резервни коментари: ", + "search_filters_features_option_hdr": "HDR", + "About": "О сајту", "Kazakh": "Казашки", - "Shared `x`": "Подељено `x`", - "Playlists": "Плеј листе", + "Shared `x`": "Дељено `x`", + "Playlists": "Плејлисте", "Yoruba": "Јоруба", "Erroneous challenge": "Погрешан изазов", "Danish": "Дански", - "Could not get channel info.": "Узимање података о каналу није успело.", + "Could not get channel info.": "Није могуће прикупити информације о каналу.", "search_filters_features_option_hd": "HD", "Slovenian": "Словеначки", "Load more": "Учитај више", @@ -276,53 +276,53 @@ "Luxembourgish": "Луксембуршки", "Mongolian": "Монголски", "Latvian": "Летонски", - "channel:`x`": "kanal:`x`", + "channel:`x`": "канал:`x`", "Southern Sotho": "Јужни Сото", "Popular": "Популарно", "Gujarati": "Гуџарати", "search_filters_date_option_year": "Ове године", "Irish": "Ирски", - "YouTube comment permalink": "YouTube коментар трајна веза", + "YouTube comment permalink": "Трајни линк YouTube коментара", "Malagasy": "Малгашки", - "Token is expired, please try again": "Жетон је истекао, молимо вас да покушате поново", - "search_filters_duration_option_short": "Кратко (< 4 минуте)", + "Token is expired, please try again": "Токен је истекао, покушајте поново", + "search_filters_duration_option_short": "Кратко (< 4 минута)", "Samoan": "Самоански", "Tamil": "Тамилски", "Ukrainian": "Украјински", - "permalink": "трајна веза", + "permalink": "трајни линк", "Pashto": "Паштунски", "channel_tab_community_label": "Заједница", "Sindhi": "Синди", - "Could not fetch comments": "Узимање коментара није успело", - "Bangla": "Бангла/Бенгалски", + "Could not fetch comments": "Није могуће прикупити коментаре", + "Bangla": "Бенгалски", "Uzbek": "Узбечки", "Lithuanian": "Литвански", "Icelandic": "Исландски", "Thai": "Тајски", - "search_filters_date_option_month": "Овај месец", - "search_filters_type_label": "Тип", + "search_filters_date_option_month": "Овог месеца", + "search_filters_type_label": "Врста", "search_filters_date_option_hour": "Последњи сат", "Spanish": "Шпански", "search_filters_sort_option_date": "Датум отпремања", - "View as playlist": "Погледај као плеј листу", + "View as playlist": "Погледај као плејлисту", "search_filters_sort_option_relevance": "Релевантност", "Estonian": "Естонски", - "Sinhala": "Синхалешки", + "Sinhala": "Синхалски", "Corsican": "Корзикански", - "Filipino": "Филипино", - "Gaming": "Игрице", + "Filipino": "Филипински", + "Gaming": "Видео игре", "Movies": "Филмови", - "search_filters_sort_option_rating": "Оцене", - "Top enabled: ": "Врх омогућен: ", - "Released under the AGPLv3 on Github.": "Избачено под лиценцом AGPLv3 на GitHub-у.", + "search_filters_sort_option_rating": "Оцена", + "Top enabled: ": "Топ омогућено: ", + "Released under the AGPLv3 on Github.": "Објављено под лиценцом AGPLv3 на GitHub-у.", "Afrikaans": "Африканс", - "preferences_automatic_instance_redirect_label": "Аутоматско пребацивање на другу инстанцу у случају отказивања (пречи ће назад на редирецт.инвидиоус.ио): ", - "Please log in": "Молимо вас да се пријавите", + "preferences_automatic_instance_redirect_label": "Аутоматско преусмеравање инстанце (повратак на redirect.invidious.io): ", + "Please log in": "Молимо, пријавите се", "English (auto-generated)": "Енглески (аутоматски генерисано)", "Hindi": "Хинди", - "Italian": "Талијански", - "Malayalam": "Малајалам", - "Punjabi": "Пунџаби", + "Italian": "Италијански", + "Malayalam": "Малајаламски", + "Punjabi": "Панџапски", "Somali": "Сомалијски", "Vietnamese": "Вијетнамски", "Welsh": "Велшки", @@ -330,25 +330,25 @@ "Maltese": "Малтешки", "Swedish": "Шведски", "Music": "Музика", - "Download as: ": "Преузми као: ", + "Download as: ": "Преузети као: ", "search_filters_duration_label": "Трајање", - "search_filters_sort_label": "Поредај према", - "search_filters_features_option_subtitles": "Титл/Превод", - "preferences_extend_desc_label": "Аутоматски прикажи цео опис видеа: ", + "search_filters_sort_label": "Сортирање по", + "search_filters_features_option_subtitles": "Титлови/Скривени титлови", + "preferences_extend_desc_label": "Аутоматски прошири опис видео снимка: ", "Show less": "Прикажи мање", "Family friendly? ": "Погодно за породицу? ", - "Premieres `x`": "Премерe у `x`", + "Premieres `x`": "Премијера `x`", "Bosnian": "Босански", "Catalan": "Каталонски", "Japanese": "Јапански", "Latin": "Латински", - "next_steps_error_message_refresh": "Освежи страницу", - "footer_original_source_code": "Оригинална Изворна Кода", + "next_steps_error_message_refresh": "Освежите", + "footer_original_source_code": "Оригинални изворни кôд", "Romanian": "Румунски", "Serbian": "Српски", - "Top": "Врх", - "Video mode": "Видео мод", - "footer_source_code": "Изворна Кода", + "Top": "Топ", + "Video mode": "Режим видео снимка", + "footer_source_code": "Изворни кôд", "search_filters_features_option_three_d": "3D", "search_filters_features_option_four_k": "4K", "Erroneous CAPTCHA": "Погрешна CAPTCHA", @@ -360,5 +360,148 @@ "Korean": "Корејски", "Kurdish": "Курдски", "Malay": "Малајски", - "search_filters_title": "Филтер" + "search_filters_title": "Филтери", + "Korean (auto-generated)": "Корејски (аутоматски генерисано)", + "search_filters_features_option_three_sixty": "360°", + "preferences_quality_dash_option_worst": "Најгоре", + "channel_tab_podcasts_label": "Подкасти", + "preferences_save_player_pos_label": "Сачувај позицију репродукције: ", + "Spanish (Mexico)": "Шпански (Мексико)", + "generic_subscriptions_count_0": "{{count}} праћење", + "generic_subscriptions_count_1": "{{count}} праћења", + "generic_subscriptions_count_2": "{{count}} праћења", + "search_filters_apply_button": "Примени изабране филтере", + "Download is disabled": "Преузимање је онемогућено", + "comments_points_count_0": "{{count}} поен", + "comments_points_count_1": "{{count}} поена", + "comments_points_count_2": "{{count}} поена", + "preferences_quality_dash_option_2160p": "2160p", + "German (auto-generated)": "Немачки (аутоматски генерисано)", + "Japanese (auto-generated)": "Јапански (аутоматски генерисано)", + "preferences_quality_option_medium": "Средње", + "search_message_change_filters_or_query": "Покушајте да проширите упит за претрагу и/или промените филтере.", + "crash_page_before_reporting": "Пре него што пријавите грешку, уверите се да сте:", + "preferences_quality_dash_option_best": "Најбоље", + "Channel Sponsor": "Спонзор канала", + "generic_videos_count_0": "{{count}} видео снимак", + "generic_videos_count_1": "{{count}} видео снимка", + "generic_videos_count_2": "{{count}} видео снимака", + "videoinfo_started_streaming_x_ago": "Започето стримовање пре `x`", + "videoinfo_youTube_embed_link": "Уграђено", + "channel_tab_streams_label": "Стримови уживо", + "playlist_button_add_items": "Додај видео снимке", + "generic_count_minutes_0": "{{count}} минут", + "generic_count_minutes_1": "{{count}} минута", + "generic_count_minutes_2": "{{count}} минута", + "preferences_quality_dash_option_720p": "720p", + "preferences_watch_history_label": "Омогући историју гледања: ", + "user_saved_playlists": "Сачуваних плејлиста: `x`", + "Spanish (Spain)": "Шпански (Шпанија)", + "invidious": "Invidious", + "crash_page_refresh": "покушали да освежите страницу", + "Chinese (Hong Kong)": "Кинески (Хонг Конг)", + "Artist: ": "Извођач: ", + "generic_count_months_0": "{{count}} месец", + "generic_count_months_1": "{{count}} месеца", + "generic_count_months_2": "{{count}} месеци", + "search_message_use_another_instance": " Такође, можете претраживати на другој инстанци.", + "generic_subscribers_count_0": "{{count}} пратилац", + "generic_subscribers_count_1": "{{count}} пратиоца", + "generic_subscribers_count_2": "{{count}} пратилаца", + "download_subtitles": "Титлови - `x` (.vtt)", + "generic_button_save": "Сачувај", + "crash_page_search_issue": "претражили постојеће извештаје о проблемима на GitHub-у", + "generic_button_cancel": "Откажи", + "none": "ниједно", + "English (United States)": "Енглески (Сједињене Америчке Државе)", + "subscriptions_unseen_notifs_count_0": "{{count}} невиђено обавештење", + "subscriptions_unseen_notifs_count_1": "{{count}} невиђена обавештења", + "subscriptions_unseen_notifs_count_2": "{{count}} невиђених обавештења", + "Album: ": "Албум: ", + "preferences_quality_option_dash": "DASH (адаптивни квалитет)", + "preferences_quality_dash_option_1080p": "1080p", + "Video unavailable": "Видео снимак недоступан", + "tokens_count_0": "{{count}} токен", + "tokens_count_1": "{{count}} токена", + "tokens_count_2": "{{count}} токена", + "Chinese (China)": "Кинески (Кина)", + "Italian (auto-generated)": "Италијански (аутоматски генерисано)", + "channel_tab_shorts_label": "Shorts", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_360p": "360p", + "search_message_no_results": "Нису пронађени резултати.", + "channel_tab_releases_label": "Издања", + "preferences_quality_dash_option_144p": "144p", + "Interlingue": "Интерлингва", + "Song: ": "Песма: ", + "generic_channels_count_0": "{{count}} канал", + "generic_channels_count_1": "{{count}} канала", + "generic_channels_count_2": "{{count}} канала", + "Chinese (Taiwan)": "Кинески (Тајван)", + "Turkish (auto-generated)": "Турски (аутоматски генерисано)", + "Indonesian (auto-generated)": "Индонезијски (аутоматски генерисано)", + "Portuguese (auto-generated)": "Португалски (аутоматски генерисано)", + "generic_count_years_0": "{{count}} година", + "generic_count_years_1": "{{count}} године", + "generic_count_years_2": "{{count}} година", + "videoinfo_invidious_embed_link": "Уграђени линк", + "Popular enabled: ": "Популарно омогућено: ", + "Spanish (auto-generated)": "Шпански (аутоматски генерисано)", + "preferences_quality_option_small": "Мало", + "English (United Kingdom)": "Енглески (Уједињено Краљевство)", + "channel_tab_playlists_label": "Плејлисте", + "generic_button_edit": "Измени", + "generic_playlists_count_0": "{{count}} плејлиста", + "generic_playlists_count_1": "{{count}} плејлисте", + "generic_playlists_count_2": "{{count}} плејлиста", + "preferences_quality_option_hd720": "HD720", + "search_filters_features_option_purchased": "Купљено", + "search_filters_date_option_none": "Било који датум", + "preferences_quality_dash_option_auto": "Аутоматски", + "Cantonese (Hong Kong)": "Кантонски (Хонг Конг)", + "crash_page_report_issue": "Ако ништа од горенаведеног није помогло, отворите нови извештај о проблему на GitHub-у (по могућности на енглеском) и укључите следећи текст у своју поруку (НЕ преводите тај текст):", + "crash_page_switch_instance": "покушали да користите другу инстанцу", + "generic_count_weeks_0": "{{count}} недеља", + "generic_count_weeks_1": "{{count}} недеље", + "generic_count_weeks_2": "{{count}} недеља", + "videoinfo_watch_on_youTube": "Гледај на YouTube-у", + "Music in this video": "Музика у овом видео снимку", + "generic_button_rss": "RSS", + "preferences_quality_dash_option_4320p": "4320p", + "generic_count_hours_0": "{{count}} сат", + "generic_count_hours_1": "{{count}} сата", + "generic_count_hours_2": "{{count}} сати", + "French (auto-generated)": "Француски (аутоматски генерисано)", + "crash_page_read_the_faq": "прочитали Често Постављана Питања (ЧПП)", + "user_created_playlists": "Направљених плејлиста: `x`", + "channel_tab_channels_label": "Канали", + "search_filters_type_option_all": "Било која врста", + "Russian (auto-generated)": "Руски (аутоматски генерисано)", + "preferences_quality_dash_option_480p": "480p", + "comments_view_x_replies_0": "Погледај {{count}} одговор", + "comments_view_x_replies_1": "Погледај {{count}} одговора", + "comments_view_x_replies_2": "Погледај {{count}} одговора", + "Portuguese (Brazil)": "Португалски (Бразил)", + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Тражени видео снимак не постоји на овој плејлисти. Кликните овде за почетну страницу плејлисте.", + "Dutch (auto-generated)": "Холандски (аутоматски генерисано)", + "generic_count_days_0": "{{count}} дан", + "generic_count_days_1": "{{count}} дана", + "generic_count_days_2": "{{count}} дана", + "Vietnamese (auto-generated)": "Вијетнамски (аутоматски генерисано)", + "search_filters_duration_option_none": "Било које трајање", + "preferences_quality_dash_option_240p": "240p", + "Chinese": "Кинески", + "generic_button_delete": "Избриши", + "Import YouTube playlist (.csv)": "Увези YouTube плејлисту (.csv)", + "Standard YouTube license": "Стандардна YouTube лиценца", + "search_filters_duration_option_medium": "Средње (4 - 20 минута)", + "generic_count_seconds_0": "{{count}} секунда", + "generic_count_seconds_1": "{{count}} секунде", + "generic_count_seconds_2": "{{count}} секунди", + "search_filters_date_label": "Датум отпремања", + "crash_page_you_found_a_bug": "Изгледа да сте пронашли грешку у Invidious-у!", + "generic_views_count_0": "{{count}} преглед", + "generic_views_count_1": "{{count}} прегледа", + "generic_views_count_2": "{{count}} прегледа" } diff --git a/locales/tr.json b/locales/tr.json index 22732a51..0575a4dd 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -476,5 +476,15 @@ "Song: ": "Şarkı: ", "Standard YouTube license": "Standart YouTube lisansı", "Download is disabled": "İndirme devre dışı", - "Import YouTube playlist (.csv)": "YouTube Oynatma Listesini İçe Aktar (.csv)" + "Import YouTube playlist (.csv)": "YouTube Oynatma Listesini İçe Aktar (.csv)", + "generic_button_delete": "Sil", + "generic_button_edit": "Düzenle", + "generic_button_save": "Kaydet", + "generic_button_cancel": "İptal", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "Yayınlar", + "playlist_button_add_items": "Video ekle", + "channel_tab_podcasts_label": "Podcast'ler", + "generic_channels_count": "{{count}} kanal", + "generic_channels_count_plural": "{{count}} kanal" } diff --git a/locales/uk.json b/locales/uk.json index 308b10ca..c26618fe 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -492,5 +492,16 @@ "Channel Sponsor": "Спонсор каналу", "Standard YouTube license": "Стандартна ліцензія YouTube", "Download is disabled": "Завантаження вимкнено", - "Import YouTube playlist (.csv)": "Імпорт списку відтворення YouTube (.csv)" + "Import YouTube playlist (.csv)": "Імпорт списку відтворення YouTube (.csv)", + "channel_tab_podcasts_label": "Подкасти", + "playlist_button_add_items": "Додати відео", + "generic_button_cancel": "Скасувати", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "Випуски", + "generic_button_delete": "Видалити", + "generic_button_edit": "Змінити", + "generic_button_save": "Зберегти", + "generic_channels_count_0": "{{count}} канал", + "generic_channels_count_1": "{{count}} канали", + "generic_channels_count_2": "{{count}} каналів" } diff --git a/locales/vi.json b/locales/vi.json index d79c684c..9cb87d3e 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -2,7 +2,7 @@ "generic_videos_count_0": "{{count}} video", "generic_subscribers_count_0": "{{count}} người theo dõi", "LIVE": "TRỰC TIẾP", - "Shared `x` ago": "Đã chia sẻ` x` trước", + "Shared `x` ago": "Đã chia sẻ `x` trước", "Unsubscribe": "Hủy theo dõi", "Subscribe": "Theo dõi", "View channel on YouTube": "Xem kênh trên YouTube", @@ -71,7 +71,7 @@ "Dark mode: ": "Chế độ tối: ", "preferences_dark_mode_label": "Chủ đề: ", "dark": "tối", - "light": "ánh sáng", + "light": "sáng", "preferences_thin_mode_label": "Chế độ mỏng: ", "preferences_category_misc": "Tùy chọn khác", "preferences_automatic_instance_redirect_label": "Tự động chuyển hướng phiên bản (dự phòng về redirect.invidious.io): ", @@ -120,7 +120,7 @@ "View JavaScript license information.": "Xem thông tin giấy phép JavaScript.", "View privacy policy.": "Xem chính sách bảo mật.", "Trending": "Xu hướng", - "Public": "Công cộng", + "Public": "Công khai", "Unlisted": "Không hiển thị", "Private": "Riêng tư", "View all playlists": "Xem tất cả danh sách phát", @@ -182,17 +182,17 @@ "Amharic": "Amharic", "Arabic": "Tiếng Ả Rập", "Armenian": "Tiếng Armenia", - "Azerbaijani": "Azerbaijan", - "Bangla": "Bangla", + "Azerbaijani": "Tiếng Azerbaijan", + "Bangla": "Tiếng Bengal", "Basque": "Tiếng Basque", - "Belarusian": "Người Belarus", + "Belarusian": "Tiếng Belarus", "Bosnian": "Tiếng Bosnia", "Bulgarian": "Tiếng Bungari", "Burmese": "Tiếng Miến Điện", "Catalan": "Tiếng Catalan", "Cebuano": "Cebuano", "Chinese (Simplified)": "Tiếng Trung (Giản thể)", - "Chinese (Traditional)": "Truyền thống Trung Hoa)", + "Chinese (Traditional)": "Tiếng Trung (Phồn thể)", "Corsican": "Corsican", "Croatian": "Tiếng Croatia", "Czech": "Tiếng Séc", @@ -219,22 +219,22 @@ "Igbo": "Igbo", "Indonesian": "Tiếng Indonesia", "Irish": "Tiếng Ailen", - "Italian": "Người Ý", + "Italian": "Tiếng Ý", "Japanese": "Tiếng Nhật", "Javanese": "Tiếng Java", "Kannada": "Tiếng Kannada", "Kazakh": "Tiếng Kazakh", "Khmer": "Tiếng Khmer", - "Korean": "Hàn Quốc", + "Korean": "Tiếng Hàn", "Kurdish": "Tiếng Kurd", - "Kyrgyz": "Kyrgyz", - "Lao": "Lào", - "Latin": "Latin", + "Kyrgyz": "Tiếng Kyrgyz", + "Lao": "Tiếng Lào", + "Latin": "Tiếng Latin", "Latvian": "Tiếng Latvia", "Lithuanian": "Tiếng Litva", "Luxembourgish": "Tiếng Luxembourg", - "Macedonian": "Người Macedonian", - "Malagasy": "Malagasy", + "Macedonian": "Tiếng Macedonian", + "Malagasy": "Tiếng Malagasy", "Malay": "Tiếng Mã Lai", "Malayalam": "Tiếng Malayalam", "Maltese": "Cây nho", @@ -364,7 +364,7 @@ "Import/export": "Xuất/nhập dữ liệu", "preferences_quality_dash_option_4320p": "4320p", "preferences_quality_option_dash": "DASH (tự tối ưu chất lượng)", - "generic_subscriptions_count_0": "{{count}} thuê bao", + "generic_subscriptions_count_0": "{{count}} người đăng kí", "preferences_quality_dash_option_1440p": "1440p", "preferences_quality_dash_option_480p": "480p", "preferences_quality_dash_option_2160p": "2160p", @@ -383,5 +383,9 @@ "Standard YouTube license": "Giấy phép YouTube thông thường", "Album: ": "Album: ", "preferences_save_player_pos_label": "Lưu vị trí xem cuối cùng ", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Xin chào! Có vẻ như bạn đã tắt JavaScript. Bấm vào đây để xem bình luận, lưu ý rằng thời gian tải có thể lâu hơn." + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Xin chào! Có vẻ như bạn đã tắt JavaScript. Bấm vào đây để xem bình luận, lưu ý rằng thời gian tải có thể lâu hơn.", + "Chinese (China)": "Tiếng Trung (Trung Quốc)", + "generic_button_cancel": "Hủy", + "Chinese": "Tiếng Trung", + "generic_button_delete": "Xóa" } diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 58b834fa..5e5d0ebb 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -460,5 +460,14 @@ "Channel Sponsor": "频道赞助者", "Standard YouTube license": "标准 YouTube 许可证", "Download is disabled": "已禁用下载", - "Import YouTube playlist (.csv)": "导入 YouTube 播放列表(.csv)" + "Import YouTube playlist (.csv)": "导入 YouTube 播放列表(.csv)", + "generic_button_cancel": "取消", + "playlist_button_add_items": "添加视频", + "generic_button_delete": "删除", + "channel_tab_podcasts_label": "播客", + "generic_button_edit": "编辑", + "generic_button_save": "保存", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "公告", + "generic_channels_count_0": "{{count}} 个频道" } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 7da2d762..de659c92 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -460,5 +460,14 @@ "Song: ": "歌曲: ", "Standard YouTube license": "標準 YouTube 授權條款", "Download is disabled": "已停用下載", - "Import YouTube playlist (.csv)": "匯入 YouTube 播放清單 (.csv)" + "Import YouTube playlist (.csv)": "匯入 YouTube 播放清單 (.csv)", + "generic_button_cancel": "取消", + "generic_button_edit": "編輯", + "generic_button_save": "儲存", + "generic_button_rss": "RSS", + "generic_button_delete": "刪除", + "playlist_button_add_items": "新增影片", + "channel_tab_podcasts_label": "Podcast", + "channel_tab_releases_label": "發布", + "generic_channels_count_0": "{{count}} 個頻道" } diff --git a/shard.lock b/shard.lock index 235e4c25..efb60a59 100644 --- a/shard.lock +++ b/shard.lock @@ -1,5 +1,9 @@ version: 2.0 shards: + ameba: + git: https://github.com/crystal-ameba/ameba.git + version: 1.5.0 + athena-negotiation: git: https://github.com/athena-framework/negotiation.git version: 0.1.1 @@ -24,10 +28,6 @@ shards: git: https://github.com/jeromegn/kilt.git version: 0.6.1 - lsquic: - git: https://github.com/iv-org/lsquic.cr.git - version: 2.18.1-2 - pg: git: https://github.com/will/crystal-pg.git version: 0.24.0 @@ -48,6 +48,3 @@ shards: git: https://github.com/crystal-lang/crystal-sqlite3.git version: 0.18.0 - ameba: - git: https://github.com/crystal-ameba/ameba.git - version: 0.14.3 diff --git a/shard.yml b/shard.yml index 7ee0bb2a..be06a7df 100644 --- a/shard.yml +++ b/shard.yml @@ -3,7 +3,7 @@ version: 0.20.1 authors: - Omar Roth - - Invidous team + - Invidious team targets: invidious: @@ -25,9 +25,6 @@ dependencies: protodec: github: iv-org/protodec version: ~> 0.1.5 - lsquic: - github: iv-org/lsquic.cr - version: ~> 2.18.1-2 athena-negotiation: github: athena-framework/negotiation version: ~> 0.1.1 @@ -38,7 +35,7 @@ development_dependencies: version: ~> 0.10.4 ameba: github: crystal-ameba/ameba - version: ~> 0.14.3 + version: ~> 1.5.0 crystal: ">= 1.0.0, < 2.0.0" diff --git a/spec/helpers/vtt/builder_spec.cr b/spec/helpers/vtt/builder_spec.cr new file mode 100644 index 00000000..7b543ddc --- /dev/null +++ b/spec/helpers/vtt/builder_spec.cr @@ -0,0 +1,64 @@ +require "../../spec_helper.cr" + +MockLines = [ + { + "start_time": Time::Span.new(seconds: 1), + "end_time": Time::Span.new(seconds: 2), + "text": "Line 1", + }, + + { + "start_time": Time::Span.new(seconds: 2), + "end_time": Time::Span.new(seconds: 3), + "text": "Line 2", + }, +] + +Spectator.describe "WebVTT::Builder" do + it "correctly builds a vtt file" do + result = WebVTT.build do |vtt| + MockLines.each do |line| + vtt.cue(line["start_time"], line["end_time"], line["text"]) + end + end + + expect(result).to eq([ + "WEBVTT", + "", + "00:00:01.000 --> 00:00:02.000", + "Line 1", + "", + "00:00:02.000 --> 00:00:03.000", + "Line 2", + "", + "", + ].join('\n')) + end + + it "correctly builds a vtt file with setting fields" do + setting_fields = { + "Kind" => "captions", + "Language" => "en", + } + + result = WebVTT.build(setting_fields) do |vtt| + MockLines.each do |line| + vtt.cue(line["start_time"], line["end_time"], line["text"]) + end + end + + expect(result).to eq([ + "WEBVTT", + "Kind: captions", + "Language: en", + "", + "00:00:01.000 --> 00:00:02.000", + "Line 1", + "", + "00:00:02.000 --> 00:00:03.000", + "Line 2", + "", + "", + ].join('\n')) + end +end diff --git a/spec/i18next_plurals_spec.cr b/spec/i18next_plurals_spec.cr index ee9ff394..dab97710 100644 --- a/spec/i18next_plurals_spec.cr +++ b/spec/i18next_plurals_spec.cr @@ -15,12 +15,15 @@ FORM_TESTS = { "ar" => I18next::Plurals::PluralForms::Special_Arabic, "be" => I18next::Plurals::PluralForms::Dual_Slavic, "cy" => I18next::Plurals::PluralForms::Special_Welsh, + "fr" => I18next::Plurals::PluralForms::Special_French_Portuguese, "en" => I18next::Plurals::PluralForms::Single_not_one, - "fr" => I18next::Plurals::PluralForms::Single_gt_one, + "es" => I18next::Plurals::PluralForms::Single_not_one, "ga" => I18next::Plurals::PluralForms::Special_Irish, "gd" => I18next::Plurals::PluralForms::Special_Scottish_Gaelic, "he" => I18next::Plurals::PluralForms::Special_Hebrew, + "hr" => I18next::Plurals::PluralForms::Special_Hungarian_Serbian, "is" => I18next::Plurals::PluralForms::Special_Icelandic, + "it" => I18next::Plurals::PluralForms::Special_Spanish_Italian, "jv" => I18next::Plurals::PluralForms::Special_Javanese, "kw" => I18next::Plurals::PluralForms::Special_Cornish, "lt" => I18next::Plurals::PluralForms::Special_Lithuanian, @@ -31,12 +34,12 @@ FORM_TESTS = { "or" => I18next::Plurals::PluralForms::Special_Odia, "pl" => I18next::Plurals::PluralForms::Special_Polish_Kashubian, "pt" => I18next::Plurals::PluralForms::Single_gt_one, - "pt-PT" => I18next::Plurals::PluralForms::Single_not_one, - "pt-BR" => I18next::Plurals::PluralForms::Single_gt_one, + "pt-BR" => I18next::Plurals::PluralForms::Special_French_Portuguese, "ro" => I18next::Plurals::PluralForms::Special_Romanian, - "su" => I18next::Plurals::PluralForms::None, "sk" => I18next::Plurals::PluralForms::Special_Czech_Slovak, "sl" => I18next::Plurals::PluralForms::Special_Slovenian, + "su" => I18next::Plurals::PluralForms::None, + "sr" => I18next::Plurals::PluralForms::Special_Hungarian_Serbian, } SUFFIX_TESTS = { @@ -73,10 +76,18 @@ SUFFIX_TESTS = { {num: 1, suffix: ""}, {num: 10, suffix: "_plural"}, ], - "fr" => [ - {num: 0, suffix: ""}, + "es" => [ + {num: 0, suffix: "_plural"}, {num: 1, suffix: ""}, {num: 10, suffix: "_plural"}, + {num: 6_000_000, suffix: "_plural"}, + ], + "fr" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_0"}, + {num: 10, suffix: "_2"}, + {num: 4_000_000, suffix: "_1"}, + {num: 6_260_000, suffix: "_2"}, ], "ga" => [ {num: 1, suffix: "_0"}, @@ -155,31 +166,24 @@ SUFFIX_TESTS = { {num: 1, suffix: "_0"}, {num: 5, suffix: "_2"}, ], - "pt" => [ - {num: 0, suffix: ""}, - {num: 1, suffix: ""}, - {num: 10, suffix: "_plural"}, + "pt-BR" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_0"}, + {num: 10, suffix: "_2"}, + {num: 42, suffix: "_2"}, + {num: 9_000_000, suffix: "_1"}, ], "pt-PT" => [ - {num: 0, suffix: "_plural"}, - {num: 1, suffix: ""}, - {num: 10, suffix: "_plural"}, - ], - "pt-BR" => [ {num: 0, suffix: ""}, {num: 1, suffix: ""}, {num: 10, suffix: "_plural"}, + {num: 9_000_000, suffix: "_plural"}, ], "ro" => [ {num: 0, suffix: "_1"}, {num: 1, suffix: "_0"}, {num: 20, suffix: "_2"}, ], - "su" => [ - {num: 0, suffix: "_0"}, - {num: 1, suffix: "_0"}, - {num: 10, suffix: "_0"}, - ], "sk" => [ {num: 0, suffix: "_2"}, {num: 1, suffix: "_0"}, @@ -191,6 +195,18 @@ SUFFIX_TESTS = { {num: 2, suffix: "_2"}, {num: 3, suffix: "_3"}, ], + "su" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_0"}, + {num: 10, suffix: "_0"}, + ], + "sr" => [ + {num: 1, suffix: "_0"}, + {num: 51, suffix: "_0"}, + {num: 32, suffix: "_1"}, + {num: 100, suffix: "_2"}, + {num: 100_000, suffix: "_2"}, + ], } Spectator.describe "i18next_Plural_Resolver" do diff --git a/src/invidious.cr b/src/invidious.cr index 84e1895d..e0bd0101 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -90,7 +90,7 @@ SOFTWARE = { "branch" => "#{CURRENT_BRANCH}", } -YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, use_quic: CONFIG.use_quic) +YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size) # CLI Kemal.config.extra_options do |parser| diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index aac4bc8a..49ffd990 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -1,49 +1,57 @@ private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000} # TODO: Add "sort_by" -def fetch_channel_community(ucid, continuation, locale, format, thin_mode) - response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") - if response.status_code != 200 - response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") - end +def fetch_channel_community(ucid, cursor, locale, format, thin_mode) + if cursor.nil? + # Egljb21tdW5pdHk%3D is the protobuf object to load "community" + initial_data = YoutubeAPI.browse(ucid, params: "Egljb21tdW5pdHk%3D") - if response.status_code != 200 - raise NotFoundException.new("This channel does not exist.") - end - - ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] - - if !continuation || continuation.empty? - initial_data = extract_initial_data(response.body) - body = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] - - if !body - raise InfoException.new("Could not extract community tab.") + items = [] of JSON::Any + extract_items(initial_data) do |item| + items << item end else - continuation = produce_channel_community_continuation(ucid, continuation) + continuation = produce_channel_community_continuation(ucid, cursor) + initial_data = YoutubeAPI.browse(continuation: continuation) - headers = HTTP::Headers.new - headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"] + container = initial_data.dig?("continuationContents", "itemSectionContinuation", "contents") - session_token = response.body.match(/"XSRF_TOKEN":"(?[^"]+)"/).try &.["session_token"]? || "" - post_req = { - session_token: session_token, - } + raise InfoException.new("Can't extract community data") if container.nil? - body = YoutubeAPI.browse(continuation) - - body = body.dig?("continuationContents", "itemSectionContinuation") || - body.dig?("continuationContents", "backstageCommentsContinuation") - - if !body - raise InfoException.new("Could not extract continuation.") - end + items = container.as_a end - posts = body["contents"].as_a + return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode) +end - if message = posts[0]["messageRenderer"]? +def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode) + object = { + "2:string" => "community", + "25:embedded" => { + "22:string" => post_id.to_s, + }, + "45:embedded" => { + "2:varint" => 1_i64, + "3:varint" => 1_i64, + }, + } + params = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + initial_data = YoutubeAPI.browse(ucid, params: params) + + items = [] of JSON::Any + extract_items(initial_data) do |item| + items << item + end + + return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode, is_single_post: true) +end + +def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_single_post : Bool = false) + if message = items[0]["messageRenderer"]? error_message = (message["text"]["simpleText"]? || message["text"]["runs"]?.try &.[0]?.try &.["text"]?) .try &.as_s || "" @@ -57,9 +65,12 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) response = JSON.build do |json| json.object do json.field "authorId", ucid + if is_single_post + json.field "singlePost", true + end json.field "comments" do json.array do - posts.each do |post| + items.each do |post| comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? || post["backstageCommentsContinuation"]? @@ -216,6 +227,22 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) parse_item(attachment) .as(SearchPlaylist) .to_json(locale, json) + when .has_key?("quizRenderer") + json.object do + attachment = attachment["quizRenderer"] + json.field "type", "quiz" + json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) + json.field "choices" do + json.array do + attachment["choices"].as_a.each do |choice| + json.object do + json.field "text", choice.dig("text", "runs", 0, "text").as_s + json.field "isCorrect", choice["isCorrect"].as_bool + end + end + end + end + end else json.object do json.field "type", "unknown" @@ -242,8 +269,10 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - if cont = posts.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") - json.field "continuation", extract_channel_community_cursor(cont.as_s) + if !is_single_post + if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") + json.field "continuation", extract_channel_community_cursor(cont.as_s) + end end end end diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index 8dc824b2..91029fe3 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -26,3 +26,21 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) return extract_items(initial_data, author, ucid) end + +def fetch_channel_podcasts(ucid, author, continuation) + if continuation + initial_data = YoutubeAPI.browse(continuation) + else + initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA") + end + return extract_items(initial_data, author, ucid) +end + +def fetch_channel_releases(ucid, author, continuation) + if continuation + initial_data = YoutubeAPI.browse(continuation) + else + initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA") + end + return extract_items(initial_data, author, ucid) +end diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr index 1ba1b534..185d8e43 100644 --- a/src/invidious/comments/youtube.cr +++ b/src/invidious/comments/youtube.cr @@ -13,6 +13,51 @@ module Invidious::Comments client_config = YoutubeAPI::ClientConfig.new(region: region) response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) + return parse_youtube(id, response, format, locale, thin_mode, sort_by) + end + + def fetch_community_post_comments(ucid, post_id) + object = { + "2:string" => "community", + "25:embedded" => { + "22:string" => post_id, + }, + "45:embedded" => { + "2:varint" => 1_i64, + "3:varint" => 1_i64, + }, + "53:embedded" => { + "4:embedded" => { + "6:varint" => 0_i64, + "27:varint" => 1_i64, + "29:string" => post_id, + "30:string" => ucid, + }, + "8:string" => "comments-section", + }, + } + + object_parsed = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + + object2 = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => object_parsed, + }, + } + + continuation = object2.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + initial_data = YoutubeAPI.browse(continuation: continuation) + return initial_data + end + + def parse_youtube(id, response, format, locale, thin_mode, sort_by = "top", isPost = false) contents = nil if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? @@ -68,7 +113,11 @@ module Invidious::Comments json.field "commentCount", comment_count end - json.field "videoId", id + if isPost + json.field "postId", id + else + json.field "videoId", id + end json.field "comments" do json.array do diff --git a/src/invidious/config.cr b/src/invidious/config.cr index e5f1e822..429d9246 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -126,8 +126,9 @@ class Config property host_binding : String = "0.0.0.0" # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) property pool_size : Int32 = 100 - # Use quic transport for youtube api - property use_quic : Bool = false + + # Use Innertube's transcripts API instead of timedtext for closed captions + property use_innertube_for_captions : Bool = false # Saved cookies in "name1=value1; name2=value2..." format @[YAML::Field(converter: Preferences::StringToCookies)] diff --git a/src/invidious/frontend/channel_page.cr b/src/invidious/frontend/channel_page.cr index 53745dd5..fe7d6d6e 100644 --- a/src/invidious/frontend/channel_page.cr +++ b/src/invidious/frontend/channel_page.cr @@ -5,6 +5,8 @@ module Invidious::Frontend::ChannelPage Videos Shorts Streams + Podcasts + Releases Playlists Community Channels diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr index 41f43f04..ecc0bc1b 100644 --- a/src/invidious/frontend/comments_youtube.cr +++ b/src/invidious/frontend/comments_youtube.cr @@ -23,6 +23,24 @@ module Invidious::Frontend::Comments END_HTML + elsif comments["authorId"]? && !comments["singlePost"]? + # for posts we should display a link to the post + replies_count_text = translate_count(locale, + "comments_view_x_replies", + child["replyCount"].as_i64 || 0, + NumberFormatting::Separator + ) + + replies_html = <<-END_HTML +
+
+ +
+ END_HTML end if !thin_mode diff --git a/src/invidious/frontend/pagination.cr b/src/invidious/frontend/pagination.cr new file mode 100644 index 00000000..3f931f4e --- /dev/null +++ b/src/invidious/frontend/pagination.cr @@ -0,0 +1,97 @@ +require "uri" + +module Invidious::Frontend::Pagination + extend self + + private def previous_page(str : String::Builder, locale : String?, url : String) + # Link + str << %() + + if locale_is_rtl?(locale) + # Inverted arrow ("previous" points to the right) + str << translate(locale, "Previous page") + str << "  " + str << %() + else + # Regular arrow ("previous" points to the left) + str << %() + str << "  " + str << translate(locale, "Previous page") + end + + str << "" + end + + private def next_page(str : String::Builder, locale : String?, url : String) + # Link + str << %() + + if locale_is_rtl?(locale) + # Inverted arrow ("next" points to the left) + str << %() + str << "  " + str << translate(locale, "Next page") + else + # Regular arrow ("next" points to the right) + str << translate(locale, "Next page") + str << "  " + str << %() + end + + str << "" + end + + def nav_numeric(locale : String?, *, base_url : String | URI, current_page : Int, show_next : Bool = true) + return String.build do |str| + str << %(
\n) + str << %(\n) + str << %(
\n\n) + end + end + + def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?) + return String.build do |str| + str << %(
\n) + str << %(\n) + str << %(
\n\n) + end + end +end diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index e3214469..5fd81168 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -7,7 +7,7 @@ module Invidious::Frontend::WatchPage getter full_videos : Array(Hash(String, JSON::Any)) getter video_streams : Array(Hash(String, JSON::Any)) getter audio_streams : Array(Hash(String, JSON::Any)) - getter captions : Array(Invidious::Videos::Caption) + getter captions : Array(Invidious::Videos::Captions::Metadata) def initialize( @full_videos, diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index a9ed1f64..76e477a4 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -165,3 +165,12 @@ def translate_bool(locale : String?, translation : Bool) return translate(locale, "No") end end + +def locale_is_rtl?(locale : String?) + # Fallback to en-US + return false if locale.nil? + + # Arabic, Persian, Hebrew + # See https://en.wikipedia.org/wiki/Right-to-left_script#List_of_RTL_scripts + return {"ar", "fa", "he"}.includes? locale +end diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr index e84f88fb..252af6b9 100644 --- a/src/invidious/helpers/i18next.cr +++ b/src/invidious/helpers/i18next.cr @@ -35,19 +35,27 @@ module I18next::Plurals Special_Slovenian = 21 Special_Hebrew = 22 Special_Odia = 23 + + # Mixed v3/v4 rules in Weblate + # `es`, `pt` and `pt-PT` doesn't seem to have been refreshed + # by weblate yet, but I suspect it will happen one day. + # See: https://github.com/translate/translate/issues/4873 + Special_French_Portuguese + Special_Hungarian_Serbian + Special_Spanish_Italian end private PLURAL_SETS = { PluralForms::Single_gt_one => [ - "ach", "ak", "am", "arn", "br", "fil", "fr", "gun", "ln", "mfe", "mg", - "mi", "oc", "pt", "pt-BR", "tg", "tl", "ti", "tr", "uz", "wa", + "ach", "ak", "am", "arn", "br", "fil", "gun", "ln", "mfe", "mg", + "mi", "oc", "pt", "tg", "tl", "ti", "tr", "uz", "wa", ], PluralForms::Single_not_one => [ "af", "an", "ast", "az", "bg", "bn", "ca", "da", "de", "dev", "el", "en", "eo", "es", "et", "eu", "fi", "fo", "fur", "fy", "gl", "gu", "ha", "hi", - "hu", "hy", "ia", "it", "kk", "kn", "ku", "lb", "mai", "ml", "mn", "mr", + "hu", "hy", "ia", "kk", "kn", "ku", "lb", "mai", "ml", "mn", "mr", "nah", "nap", "nb", "ne", "nl", "nn", "no", "nso", "pa", "pap", "pms", - "ps", "pt-PT", "rm", "sco", "se", "si", "so", "son", "sq", "sv", "sw", + "ps", "rm", "sco", "se", "si", "so", "son", "sq", "sv", "sw", "ta", "te", "tk", "ur", "yo", ], PluralForms::None => [ @@ -55,7 +63,7 @@ module I18next::Plurals "lo", "ms", "sah", "su", "th", "tt", "ug", "vi", "wo", "zh", ], PluralForms::Dual_Slavic => [ - "be", "bs", "cnr", "dz", "hr", "ru", "sr", "uk", + "be", "bs", "cnr", "dz", "ru", "uk", ], } @@ -81,6 +89,12 @@ module I18next::Plurals "ro" => PluralForms::Special_Romanian, "sk" => PluralForms::Special_Czech_Slovak, "sl" => PluralForms::Special_Slovenian, + # Mixed v3/v4 rules + "fr" => PluralForms::Special_French_Portuguese, + "hr" => PluralForms::Special_Hungarian_Serbian, + "it" => PluralForms::Special_Spanish_Italian, + "pt-BR" => PluralForms::Special_French_Portuguese, + "sr" => PluralForms::Special_Hungarian_Serbian, } # These are the v1 and v2 compatible suffixes. @@ -150,9 +164,8 @@ module I18next::Plurals end def get_plural_form(locale : String) : PluralForms - # Extract the ISO 639-1 or 639-2 code from an RFC 5646 language code, - # except for pt-BR and pt-PT which needs to be kept as-is. - if !locale.matches?(/^pt-(BR|PT)$/) + # Extract the ISO 639-1 or 639-2 code from an RFC 5646 language code + if !locale.matches?(/^pt-BR$/) locale = locale.split('-')[0] end @@ -246,6 +259,10 @@ module I18next::Plurals when .special_slovenian? then return special_slovenian(count) when .special_hebrew? then return special_hebrew(count) when .special_odia? then return special_odia(count) + # Mixed v3/v4 forms + when .special_spanish_italian? then return special_cldr_Spanish_Italian(count) + when .special_french_portuguese? then return special_cldr_French_Portuguese(count) + when .special_hungarian_serbian? then return special_cldr_Hungarian_Serbian(count) else # default, if nothing matched above return 0_u8 @@ -507,5 +524,42 @@ module I18next::Plurals def self.special_odia(count : Int) : UInt8 return (count == 1) ? 0_u8 : 1_u8 end + + # ------------------- + # "v3.5" rules + # ------------------- + + # Plural form for Spanish & Italian languages + # + # This rule is mostly compliant to CLDR v42 + # + def self.special_cldr_Spanish_Italian(count : Int) : UInt8 + return 0_u8 if (count == 1) # one + return 1_u8 if (count != 0 && count % 1_000_000 == 0) # many + return 2_u8 # other + end + + # Plural form for French and Portuguese + # + # This rule is mostly compliant to CLDR v42 + # + def self.special_cldr_French_Portuguese(count : Int) : UInt8 + return 0_u8 if (count == 0 || count == 1) # one + return 1_u8 if (count % 1_000_000 == 0) # many + return 2_u8 # other + end + + # Plural form for Hungarian and Serbian + # + # This rule is mostly compliant to CLDR v42 + # + def self.special_cldr_Hungarian_Serbian(count : Int) : UInt8 + n_mod_10 = count % 10 + n_mod_100 = count % 100 + + return 0_u8 if (n_mod_10 == 1 && n_mod_100 != 11) # one + return 1_u8 if (2 <= n_mod_10 <= 4 && (n_mod_100 < 12 || 14 < n_mod_100)) # few + return 2_u8 # other + end end end diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 7c12ad0e..31a3cf44 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -186,6 +186,7 @@ struct SearchChannel property author_thumbnail : String property subscriber_count : Int32 property video_count : Int32 + property channel_handle : String? property description_html : String property auto_generated : Bool property author_verified : Bool @@ -214,6 +215,7 @@ struct SearchChannel json.field "autoGenerated", self.auto_generated json.field "subCount", self.subscriber_count json.field "videoCount", self.video_count + json.field "channelHandle", self.channel_handle json.field "description", html_to_content(self.description_html) json.field "descriptionHtml", self.description_html @@ -232,6 +234,25 @@ struct SearchChannel end end +struct SearchHashtag + include DB::Serializable + + property title : String + property url : String + property video_count : Int64 + property channel_count : Int64 + + def to_json(locale : String?, json : JSON::Builder) + json.object do + json.field "type", "hashtag" + json.field "title", self.title + json.field "url", self.url + json.field "videoCount", self.video_count + json.field "channelCount", self.channel_count + end + end +end + class Category include DB::Serializable @@ -274,4 +295,4 @@ struct Continuation end end -alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category +alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr new file mode 100644 index 00000000..56f761ed --- /dev/null +++ b/src/invidious/helpers/webvtt.cr @@ -0,0 +1,67 @@ +# Namespace for logic relating to generating WebVTT files +# +# Probably not compliant to WebVTT's specs but it is enough for Invidious. +module WebVTT + # A WebVTT builder generates WebVTT files + private class Builder + def initialize(@io : IO) + end + + # Writes an vtt cue with the specified time stamp and contents + def cue(start_time : Time::Span, end_time : Time::Span, text : String) + timestamp(start_time, end_time) + @io << text + @io << "\n\n" + end + + private def timestamp(start_time : Time::Span, end_time : Time::Span) + timestamp_component(start_time) + @io << " --> " + timestamp_component(end_time) + + @io << '\n' + end + + private def timestamp_component(timestamp : Time::Span) + @io << timestamp.hours.to_s.rjust(2, '0') + @io << ':' << timestamp.minutes.to_s.rjust(2, '0') + @io << ':' << timestamp.seconds.to_s.rjust(2, '0') + @io << '.' << timestamp.milliseconds.to_s.rjust(3, '0') + end + + def document(setting_fields : Hash(String, String)? = nil, &) + @io << "WEBVTT\n" + + if setting_fields + setting_fields.each do |name, value| + @io << name << ": " << value << '\n' + end + end + + @io << '\n' + + yield + end + end + + # Returns the resulting `String` of writing WebVTT to the yielded `WebVTT::Builder` + # + # ``` + # string = WebVTT.build do |vtt| + # vtt.cue(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1") + # vtt.cue(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2") + # end + # + # string # => "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nLine 1\n\n00:00:02.000 --> 00:00:03.000\nLine 2\n\n" + # ``` + # + # Accepts an optional settings fields hash to add settings attribute to the resulting vtt file. + def self.build(setting_fields : Hash(String, String)? = nil, &) + String.build do |str| + builder = Builder.new(str) + builder.document(setting_fields) do + yield builder + end + end + end +end diff --git a/src/invidious/http_server/utils.cr b/src/invidious/http_server/utils.cr index e3f1fa0f..222dfc4a 100644 --- a/src/invidious/http_server/utils.cr +++ b/src/invidious/http_server/utils.cr @@ -1,3 +1,5 @@ +require "uri" + module Invidious::HttpServer module Utils extend self @@ -16,5 +18,23 @@ module Invidious::HttpServer return "#{url.request_target}?#{params}" end end + + def add_params_to_url(url : String | URI, params : URI::Params) : URI + url = URI.parse(url) if url.is_a?(String) + + url_query = url.query || "" + + # Append the parameters + url.query = String.build do |str| + if !url_query.empty? + str << url_query + str << '&' + end + + str << params + end + + return url + end end end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 013be268..955e0855 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -89,6 +89,7 @@ struct Playlist property views : Int64 property updated : Time property thumbnail : String? + property subtitle : String? def to_json(offset, json : JSON::Builder, video_id : String? = nil) json.object do @@ -100,6 +101,7 @@ struct Playlist json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" + json.field "subtitle", self.subtitle json.field "authorThumbnails" do json.array do @@ -356,6 +358,8 @@ def fetch_playlist(plid : String) updated = Time.utc video_count = 0 + subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle")) + playlist_info["stats"]?.try &.as_a.each do |stat| text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s next if !text @@ -397,6 +401,7 @@ def fetch_playlist(plid : String) views: views, updated: updated, thumbnail: thumbnail, + subtitle: subtitle, }) end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index bcb4db2c..67018660 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -245,7 +245,7 @@ module Invidious::Routes::API::V1::Channels channel = nil # Make the compiler happy get_channel() - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + items, next_continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) JSON.build do |json| json.object do @@ -257,7 +257,65 @@ module Invidious::Routes::API::V1::Channels end end - json.field "continuation", continuation + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.podcasts(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + items, next_continuation = fetch_channel_podcasts(channel.ucid, channel.author, continuation) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.releases(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + items, next_continuation = fetch_channel_releases(channel.ucid, channel.author, continuation) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", next_continuation if next_continuation end end end @@ -285,6 +343,59 @@ module Invidious::Routes::API::V1::Channels end end + def self.post(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + id = env.params.url["id"].to_s + ucid = env.params.query["ucid"]? + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + if ucid.nil? + response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") + return error_json(400, "Invalid post ID") if response["error"]? + ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s + else + ucid = ucid.to_s + end + + begin + fetch_channel_community_post(ucid, id, locale, format, thin_mode) + rescue ex + return error_json(500, ex) + end + end + + def self.post_comments(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + id = env.params.url["id"] + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + continuation = env.params.query["continuation"]? + + case continuation + when nil, "" + ucid = env.params.query["ucid"] + comments = Comments.fetch_community_post_comments(ucid, id) + else + comments = YoutubeAPI.browse(continuation: continuation) + end + return Comments.parse_youtube(id, comments, format, locale, thin_mode, isPost: true) + end + def self.channels(env) locale = env.get("preferences").as(Preferences).locale ucid = env.params.url["ucid"] diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index e499f4d6..8a92e160 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -162,17 +162,20 @@ module Invidious::Routes::API::V1::Misc resolved_url = YoutubeAPI.resolve_url(url.as(String)) endpoint = resolved_url["endpoint"] pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" - if resolved_ucid = endpoint.dig?("watchEndpoint", "videoId") - elsif resolved_ucid = endpoint.dig?("browseEndpoint", "browseId") - elsif pageType == "WEB_PAGE_TYPE_UNKNOWN" + if pageType == "WEB_PAGE_TYPE_UNKNOWN" return error_json(400, "Unknown url") end + + sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint + params = sub_endpoint.try &.dig?("params") rescue ex return error_json(500, ex) end JSON.build do |json| json.object do - json.field "ucid", resolved_ucid.try &.as_s || "" + json.field "ucid", sub_endpoint["browseId"].as_s if sub_endpoint["browseId"]? + json.field "videoId", sub_endpoint["videoId"].as_s if sub_endpoint["videoId"]? + json.field "params", params.try &.as_s json.field "pageType", pageType end end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index af4fc806..449c9f9b 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -87,70 +87,67 @@ module Invidious::Routes::API::V1::Videos caption = caption[0] end - url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target + 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) - # Auto-generated captions often have cues that aren't aligned properly with the video, - # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.includes? "auto-generated" - caption_xml = YT_POOL.client &.get(url).body + webvtt = Invidious::Videos::Transcript.convert_transcripts_to_vtt(initial_data, caption.language_code) + else + # Timedtext API handling + url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target - if caption_xml.starts_with?(" "captions", + "Language" => "#{tlang || caption.language_code}", + } + if caption_xml.starts_with?(" i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end - if caption_nodes.size > i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end + + webvtt.cue(start_time, end_time, text) end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" - end - - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE end end - end - else - # Some captions have "align:[start/end]" and "position:[num]%" - # attributes. Those are causing issues with VideoJS, which is unable - # to properly align the captions on the video, so we remove them. - # - # See: https://github.com/iv-org/invidious/issues/2391 - webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - if webvtt.starts_with?(" [0-9:.]{12}).+/, "\\1") + if webvtt.starts_with?(" [0-9:.]{12}).+/, "\\1") + end end end @@ -207,11 +204,7 @@ module Invidious::Routes::API::V1::Videos storyboard = storyboard[0] end - String.build do |str| - str << <<-END_VTT - WEBVTT - END_VTT - + WebVTT.build do |vtt| start_time = 0.milliseconds end_time = storyboard[:interval].milliseconds @@ -223,12 +216,8 @@ module Invidious::Routes::API::V1::Videos storyboard[:storyboard_height].times do |j| storyboard[:storyboard_width].times do |k| - str << <<-END_CUE - #{start_time}.000 --> #{end_time}.000 - #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} - - - END_CUE + 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) start_time += storyboard[:interval].milliseconds end_time += storyboard[:interval].milliseconds diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 16621994..d4d8b1c1 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -1,6 +1,12 @@ {% skip_file if flag?(:api_only) %} module Invidious::Routes::Channels + # Redirection for unsupported routes ("tabs") + def self.redirect_home(env) + ucid = env.params.url["ucid"] + return env.redirect "/channel/#{URI.encode_www_form(ucid)}" + end + def self.home(env) self.videos(env) end @@ -27,7 +33,7 @@ module Invidious::Routes::Channels item.author end end - items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) + items = items.select(SearchPlaylist) items.each(&.author = "") else sort_options = {"newest", "oldest", "popular"} @@ -105,13 +111,53 @@ module Invidious::Routes::Channels channel.ucid, channel.author, continuation, (sort_by || "last") ) - items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) + items = items.select(SearchPlaylist) items.each(&.author = "") selected_tab = Frontend::ChannelPage::TabsAvailable::Playlists templated "channel" end + def self.podcasts(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + sort_by = "" + sort_options = [] of String + + items, next_continuation = fetch_channel_podcasts( + channel.ucid, channel.author, continuation + ) + + items = items.select(SearchPlaylist) + items.each(&.author = "") + + selected_tab = Frontend::ChannelPage::TabsAvailable::Podcasts + templated "channel" + end + + def self.releases(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + sort_by = "" + sort_options = [] of String + + items, next_continuation = fetch_channel_releases( + channel.ucid, channel.author, continuation + ) + + items = items.select(SearchPlaylist) + items.each(&.author = "") + + selected_tab = Frontend::ChannelPage::TabsAvailable::Releases + templated "channel" + end + def self.community(env) data = self.fetch_basic_information(env) if !data.is_a?(Tuple) @@ -119,6 +165,11 @@ module Invidious::Routes::Channels end locale, user, subscriptions, continuation, ucid, channel = data + # redirect to post page + if lb = env.params.query["lb"]? + env.redirect "/post/#{URI.encode_www_form(lb)}?ucid=#{URI.encode_www_form(ucid)}" + end + thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode thin_mode = thin_mode == "true" @@ -147,6 +198,44 @@ module Invidious::Routes::Channels templated "community" end + def self.post(env) + # /post/{postId} + id = env.params.url["id"] + ucid = env.params.query["ucid"]? + + prefs = env.get("preferences").as(Preferences) + + locale = prefs.locale + + thin_mode = env.params.query["thin_mode"]? || prefs.thin_mode + thin_mode = thin_mode == "true" + + nojs = env.params.query["nojs"]? + + nojs ||= "0" + nojs = nojs == "1" + + if !ucid.nil? + ucid = ucid.to_s + post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) + else + # resolve the url to get the author's UCID + response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") + return error_template(400, "Invalid post ID") if response["error"]? + + ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s + post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) + end + + post_response = JSON.parse(post_response) + + if nojs + comments = Comments.fetch_community_post_comments(ucid, id) + comment_html = JSON.parse(Comments.parse_youtube(id, comments, "html", locale, thin_mode, isPost: true))["contentHtml"] + end + templated "post" + end + def self.channels(env) data = self.fetch_basic_information(env) return data if !data.is_a?(Tuple) @@ -177,6 +266,11 @@ module Invidious::Routes::Channels env.redirect "/channel/#{ucid}" end + private KNOWN_TABS = { + "home", "videos", "shorts", "streams", "podcasts", + "releases", "playlists", "community", "channels", "about", + } + # Redirects brand url channels to a normal /channel/:ucid route def self.brand_redirect(env) locale = env.get("preferences").as(Preferences).locale @@ -187,7 +281,10 @@ module Invidious::Routes::Channels yt_url_params = URI::Params.encode(env.params.query.to_h.select(["a", "u", "user"])) # Retrieves URL params that only Invidious uses - invidious_url_params = URI::Params.encode(env.params.query.to_h.select!(["a", "u", "user"])) + invidious_url_params = env.params.query.dup + invidious_url_params.delete_all("a") + invidious_url_params.delete_all("u") + invidious_url_params.delete_all("user") begin resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}") @@ -196,14 +293,17 @@ module Invidious::Routes::Channels return error_template(404, translate(locale, "This channel does not exist.")) end - selected_tab = env.request.path.split("/")[-1] - if {"home", "videos", "shorts", "streams", "playlists", "community", "channels", "about"}.includes? selected_tab + selected_tab = env.params.url["tab"]? + + if KNOWN_TABS.includes? selected_tab url = "/channel/#{ucid}/#{selected_tab}" else url = "/channel/#{ucid}" end - env.redirect url + url += "?#{invidious_url_params}" if !invidious_url_params.empty? + + return env.redirect url end # Handles redirects for the /profile endpoint diff --git a/src/invidious/routes/errors.cr b/src/invidious/routes/errors.cr index b138b562..1e9ab44e 100644 --- a/src/invidious/routes/errors.cr +++ b/src/invidious/routes/errors.cr @@ -1,5 +1,10 @@ module Invidious::Routes::ErrorRoutes def self.error_404(env) + # Workaround for #3117 + if HOST_URL.empty? && env.request.path.starts_with?("/v1/storyboards/sb") + return env.redirect "#{env.request.path[15..]}?#{env.params.query}" + end + if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) item = md["id"] diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index fc62c5a3..40bca008 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -102,6 +102,10 @@ module Invidious::Routes::Feeds end env.set "user", user + # Used for pagination links + base_url = "/feed/subscriptions" + base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results") + templated "feeds/subscriptions" end @@ -129,6 +133,10 @@ module Invidious::Routes::Feeds end watched ||= [] of String + # Used for pagination links + base_url = "/feed/history" + base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results") + templated "feeds/history" end @@ -154,20 +162,26 @@ module Invidious::Routes::Feeds return error_atom(500, ex) end + namespaces = { + "yt" => "http://www.youtube.com/xml/schemas/2015", + "media" => "http://search.yahoo.com/mrss/", + "default" => "http://www.w3.org/2005/Atom", + } + response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") - rss = XML.parse_html(response.body) + rss = XML.parse(response.body) - videos = rss.xpath_nodes("//feed/entry").map do |entry| - video_id = entry.xpath_node("videoid").not_nil!.content - title = entry.xpath_node("title").not_nil!.content + videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry| + video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content + title = entry.xpath_node("default:title", namespaces).not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) + published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) - author = entry.xpath_node("author/name").not_nil!.content - ucid = entry.xpath_node("channelid").not_nil!.content - description_html = entry.xpath_node("group/description").not_nil!.to_s - views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 + author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content + ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content + description_html = entry.xpath_node("media:group/media:description", namespaces).not_nil!.to_s + views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64 SearchVideo.new({ title: title, diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index 594a7869..b6a2e110 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -3,17 +3,7 @@ module Invidious::Routes::Images def self.ggpht(env) url = env.request.path.lchop("/ggpht") - headers = ( - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - HTTP::Headers{":authority" => "yt3.ggpht.com"} - else - HTTP::Headers.new - end - {% else %} - HTTP::Headers.new - {% end %} - ) + headers = HTTP::Headers.new REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? @@ -42,22 +32,9 @@ module Invidious::Routes::Images } begin - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - YT_POOL.client &.get(url, headers) do |resp| - return request_proc.call(resp) - end - else - HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| - return request_proc.call(resp) - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| - return request_proc.call(resp) - end - {% end %} + HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| + return request_proc.call(resp) + end rescue ex end end @@ -78,10 +55,6 @@ module Invidious::Routes::Images headers = HTTP::Headers.new - {% unless flag?(:disable_quic) %} - headers[":authority"] = "#{authority}.ytimg.com" - {% end %} - REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? headers[header] = env.request.headers[header] @@ -107,22 +80,9 @@ module Invidious::Routes::Images } begin - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - YT_POOL.client &.get(url, headers) do |resp| - return request_proc.call(resp) - end - else - HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - {% end %} + HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end rescue ex end end @@ -133,17 +93,7 @@ module Invidious::Routes::Images name = env.params.url["name"] url = env.request.resource - headers = ( - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - HTTP::Headers{":authority" => "i9.ytimg.com"} - else - HTTP::Headers.new - end - {% else %} - HTTP::Headers.new - {% end %} - ) + headers = HTTP::Headers.new REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? @@ -169,22 +119,9 @@ module Invidious::Routes::Images } begin - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - YT_POOL.client &.get(url, headers) do |resp| - return request_proc.call(resp) - end - else - HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - {% end %} + HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end rescue ex end end @@ -223,41 +160,16 @@ module Invidious::Routes::Images id = env.params.url["id"] name = env.params.url["name"] - headers = ( - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - HTTP::Headers{":authority" => "i.ytimg.com"} - else - HTTP::Headers.new - end - {% else %} - HTTP::Headers.new - {% end %} - ) + headers = HTTP::Headers.new if name == "maxres.jpg" build_thumbnails(id).each do |thumb| thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" - # Logic here is short enough that manually typing them out should be fine. - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - if YT_POOL.client &.head(thumbnail_resource_path, headers).status_code == 200 - name = thumb[:url] + ".jpg" - break - end - else - if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 - name = thumb[:url] + ".jpg" - break - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 - name = thumb[:url] + ".jpg" - break - end - {% end %} + # This can likely be optimized into a (small) pool sometime in the future. + if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 + name = thumb[:url] + ".jpg" + break + end end end @@ -287,22 +199,10 @@ module Invidious::Routes::Images } begin - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - YT_POOL.client &.get(url, headers) do |resp| - return request_proc.call(resp) - end - else - HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - {% end %} + # This can likely be optimized into a (small) pool sometime in the future. + HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end rescue ex end end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 1dd3f32e..9c6843e9 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -163,13 +163,20 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(playlist, offset: (page - 1) * 100) + items = get_playlist_videos(playlist, offset: (page - 1) * 100) rescue ex - videos = [] of PlaylistVideo + items = [] of PlaylistVideo end csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY) + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/playlist?list=#{playlist.id}", + current_page: page, + show_next: (items.size == 100) + ) + templated "edit_playlist" end @@ -247,11 +254,19 @@ module Invidious::Routes::Playlists begin query = Invidious::Search::Query.new(env.params.query, :playlist, region) - videos = query.process.select(SearchVideo).map(&.as(SearchVideo)) + items = query.process.select(SearchVideo).map(&.as(SearchVideo)) rescue ex - videos = [] of SearchVideo + items = [] of SearchVideo end + # Pagination + query_encoded = URI.encode_www_form(query.try &.text || "", space_to_plus: true) + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/add_playlist_items?list=#{playlist.id}&q=#{query_encoded}", + current_page: page, + show_next: (items.size >= 20) + ) + env.set "add_playlist_items", plid templated "add_playlist_items" end @@ -406,8 +421,13 @@ module Invidious::Routes::Playlists return error_template(500, ex) end - page_count = (playlist.video_count / 200).to_i - page_count += 1 if (playlist.video_count % 200) > 0 + if playlist.is_a? InvidiousPlaylist + page_count = (playlist.video_count / 100).to_i + page_count += 1 if (playlist.video_count % 100) > 0 + else + page_count = (playlist.video_count / 200).to_i + page_count += 1 if (playlist.video_count % 200) > 0 + end if page > page_count return env.redirect "/playlist?list=#{plid}&page=#{page_count}" @@ -418,7 +438,11 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(playlist, offset: (page - 1) * 200) + if playlist.is_a? InvidiousPlaylist + items = get_playlist_videos(playlist, offset: (page - 1) * 100) + else + items = get_playlist_videos(playlist, offset: (page - 1) * 200) + end rescue ex return error_template(500, "Error encountered while retrieving playlist videos.
#{ex.message}") end @@ -427,6 +451,13 @@ module Invidious::Routes::Playlists env.set "remove_playlist_items", plid end + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/playlist?list=#{playlist.id}", + current_page: page, + show_next: (page_count != 1 && page < page_count) + ) + templated "playlist" end diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 6c3088de..5be33533 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -52,24 +52,28 @@ module Invidious::Routes::Search user = env.get? "user" begin - videos = query.process + items = query.process rescue ex : ChannelSearchException return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex return error_template(500, ex) end - params = query.to_http_params - url_prev_page = "/search?#{params}&page=#{query.page - 1}" - url_next_page = "/search?#{params}&page=#{query.page + 1}" - redirect_url = Invidious::Frontend::Misc.redirect_url(env) + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/search?#{query.to_http_params}", + current_page: query.page, + show_next: (items.size >= 20) + ) + if query.type == Invidious::Search::Query::Type::Channel env.set "search", "channel:#{query.channel} #{query.text}" else env.set "search", query.text end + templated "search" end end @@ -91,16 +95,18 @@ module Invidious::Routes::Search end begin - videos = Invidious::Hashtag.fetch(hashtag, page) + items = Invidious::Hashtag.fetch(hashtag, page) rescue ex return error_template(500, ex) end - params = env.params.query.empty? ? "" : "&#{env.params.query}" - + # Pagination hashtag_encoded = URI.encode_www_form(hashtag, space_to_plus: false) - url_prev_page = "/hashtag/#{hashtag_encoded}?page=#{page - 1}#{params}" - url_next_page = "/hashtag/#{hashtag_encoded}?page=#{page + 1}#{params}" + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/hashtag/#{hashtag_encoded}", + current_page: page, + show_next: (items.size >= 60) + ) templated "hashtag" end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index daaf4d88..d6bd991c 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -118,32 +118,48 @@ module Invidious::Routing get "/channel/:ucid/videos", Routes::Channels, :videos get "/channel/:ucid/shorts", Routes::Channels, :shorts get "/channel/:ucid/streams", Routes::Channels, :streams + get "/channel/:ucid/podcasts", Routes::Channels, :podcasts + get "/channel/:ucid/releases", Routes::Channels, :releases get "/channel/:ucid/playlists", Routes::Channels, :playlists get "/channel/:ucid/community", Routes::Channels, :community get "/channel/:ucid/channels", Routes::Channels, :channels get "/channel/:ucid/about", Routes::Channels, :about + get "/channel/:ucid/live", Routes::Channels, :live get "/user/:user/live", Routes::Channels, :live get "/c/:user/live", Routes::Channels, :live + get "/post/:id", Routes::Channels, :post - {"", "/videos", "/shorts", "/streams", "/playlists", "/community", "/about"}.each do |path| - # /c/LinusTechTips - get "/c/:user#{path}", Routes::Channels, :brand_redirect - # /user/linustechtips | Not always the same as /c/ - get "/user/:user#{path}", Routes::Channels, :brand_redirect - # /@LinusTechTips | Handle - get "/@:user#{path}", Routes::Channels, :brand_redirect - # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow - get "/attribution_link#{path}", Routes::Channels, :brand_redirect - # /profile?user=linustechtips - get "/profile/#{path}", Routes::Channels, :profile - end + # Channel catch-all, to redirect future routes to the channel's home + # NOTE: defined last in order to be processed after the other routes + get "/channel/:ucid/*", Routes::Channels, :redirect_home + + # /c/LinusTechTips + get "/c/:user", Routes::Channels, :brand_redirect + get "/c/:user/:tab", Routes::Channels, :brand_redirect + + # /user/linustechtips (Not always the same as /c/) + get "/user/:user", Routes::Channels, :brand_redirect + get "/user/:user/:tab", Routes::Channels, :brand_redirect + + # /@LinusTechTips (Handle) + get "/@:user", Routes::Channels, :brand_redirect + get "/@:user/:tab", Routes::Channels, :brand_redirect + + # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow + get "/attribution_link", Routes::Channels, :brand_redirect + get "/attribution_link/:tab", Routes::Channels, :brand_redirect + + # /profile?user=linustechtips + get "/profile", Routes::Channels, :profile + get "/profile/*", Routes::Channels, :profile end def register_watch_routes get "/watch", Routes::Watch, :handle post "/watch_ajax", Routes::Watch, :mark_watched get "/watch/:id", Routes::Watch, :redirect + get "/live/:id", Routes::Watch, :redirect get "/shorts/:id", Routes::Watch, :redirect get "/clip/:clip", Routes::Watch, :clip get "/w/:id", Routes::Watch, :redirect @@ -228,6 +244,9 @@ module Invidious::Routing get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams + get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts + get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases + get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels {% for route in {"videos", "latest", "playlists", "community", "search"} %} @@ -235,6 +254,10 @@ module Invidious::Routing get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} {% end %} + # Posts + get "/api/v1/post/:id", {{namespace}}::Channels, :post + get "/api/v1/post/:id/comments", {{namespace}}::Channels, :post_comments + # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect @@ -244,6 +267,7 @@ module Invidious::Routing get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions get "/api/v1/hashtag/:hashtag", {{namespace}}::Search, :hashtag + # Authenticated # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 0a2fe1e2..86d0ce6e 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -133,7 +133,7 @@ struct Invidious::User next if !video_id begin - video = get_video(video_id) + video = get_video(video_id, false) rescue ex next end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index f38b33e5..9fbd1374 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -24,7 +24,7 @@ struct Video property updated : Time @[DB::Field(ignore: true)] - @captions = [] of Invidious::Videos::Caption + @captions = [] of Invidious::Videos::Captions::Metadata @[DB::Field(ignore: true)] property adaptive_fmts : Array(Hash(String, JSON::Any))? @@ -215,9 +215,9 @@ struct Video keywords.includes? "YouTube Red" end - def captions : Array(Invidious::Videos::Caption) + def captions : Array(Invidious::Videos::Captions::Metadata) if @captions.empty? && @info.has_key?("captions") - @captions = Invidious::Videos::Caption.from_yt_json(info["captions"]) + @captions = Invidious::Videos::Captions::Metadata.from_yt_json(info["captions"]) end return @captions diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 13f81a31..484e61d2 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -1,100 +1,89 @@ require "json" module Invidious::Videos - struct Caption - property name : String - property language_code : String - property base_url : String + module Captions + struct Metadata + property name : String + property language_code : String + property base_url : String - def initialize(@name, @language_code, @base_url) - end + property auto_generated : Bool - # Parse the JSON structure from Youtube - def self.from_yt_json(container : JSON::Any) : Array(Caption) - caption_tracks = container - .dig?("playerCaptionsTracklistRenderer", "captionTracks") - .try &.as_a - - captions_list = [] of Caption - return captions_list if caption_tracks.nil? - - caption_tracks.each do |caption| - name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] - name = name.to_s.split(" - ")[0] - - language_code = caption["languageCode"].to_s - base_url = caption["baseUrl"].to_s - - captions_list << Caption.new(name, language_code, base_url) + def initialize(@name, @language_code, @base_url, @auto_generated) end - return captions_list - end + # Parse the JSON structure from Youtube + def self.from_yt_json(container : JSON::Any) : Array(Captions::Metadata) + caption_tracks = container + .dig?("playerCaptionsTracklistRenderer", "captionTracks") + .try &.as_a - def timedtext_to_vtt(timedtext : String, tlang = nil) : String - # In the future, we could just directly work with the url. This is more of a POC - cues = [] of XML::Node - tree = XML.parse(timedtext) - tree = tree.children.first + captions_list = [] of Captions::Metadata + return captions_list if caption_tracks.nil? - tree.children.each do |item| - if item.name == "body" - item.children.each do |cue| - if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") - cues << cue + caption_tracks.each do |caption| + name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] + name = name.to_s.split(" - ")[0] + + language_code = caption["languageCode"].to_s + base_url = caption["baseUrl"].to_s + + auto_generated = (caption["kind"]? == "asr") + + captions_list << Captions::Metadata.new(name, language_code, base_url, auto_generated) + end + + return captions_list + end + + def timedtext_to_vtt(timedtext : String, tlang = nil) : String + # In the future, we could just directly work with the url. This is more of a POC + cues = [] of XML::Node + tree = XML.parse(timedtext) + tree = tree.children.first + + tree.children.each do |item| + if item.name == "body" + item.children.each do |cue| + if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") + cues << cue + end end + break end - break end - end - result = String.build do |result| - result << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || @language_code} + settings_field = { + "Kind" => "captions", + "Language" => "#{tlang || @language_code}", + } - END_VTT + result = WebVTT.build(settings_field) do |vtt| + cues.each_with_index do |node, i| + start_time = node["t"].to_f.milliseconds - result << "\n\n" + duration = node["d"]?.try &.to_f.milliseconds - cues.each_with_index do |node, i| - start_time = node["t"].to_f.milliseconds + duration ||= start_time - duration = node["d"]?.try &.to_f.milliseconds + if cues.size > i + 1 + end_time = cues[i + 1]["t"].to_f.milliseconds + else + end_time = start_time + duration + end - duration ||= start_time + text = String.build do |io| + node.children.each do |s| + io << s.content + end + end - if cues.size > i + 1 - end_time = cues[i + 1]["t"].to_f.milliseconds - else - end_time = start_time + duration + vtt.cue(start_time, end_time, text) end - - # start_time - result << start_time.hours.to_s.rjust(2, '0') - result << ':' << start_time.minutes.to_s.rjust(2, '0') - result << ':' << start_time.seconds.to_s.rjust(2, '0') - result << '.' << start_time.milliseconds.to_s.rjust(3, '0') - - result << " --> " - - # end_time - result << end_time.hours.to_s.rjust(2, '0') - result << ':' << end_time.minutes.to_s.rjust(2, '0') - result << ':' << end_time.seconds.to_s.rjust(2, '0') - result << '.' << end_time.milliseconds.to_s.rjust(3, '0') - - result << "\n" - - node.children.each do |s| - result << s.content - end - result << "\n" - result << "\n" end + + return result end - return result end # List of all caption languages available on Youtube. diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 9cc0ffdc..06ff96b1 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -55,8 +55,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) # Fetch data from the player endpoint - # 8AEB param is used to fetch YouTube stories - player_response = YoutubeAPI.player(video_id: video_id, params: "8AEB", client_config: client_config) + player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s @@ -119,6 +118,9 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) # Replace player response and reset reason if !new_player_response.nil? + # Preserve storyboard data before replacement + new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]? + player_response = new_player_response params.delete("reason") end @@ -135,8 +137,9 @@ end def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") - # 8AEB param is used to fetch YouTube stories - response = YoutubeAPI.player(video_id: id, params: "8AEB", client_config: client_config) + # CgIQBg is a workaround for streaming URLs that returns a 403. + # See https://github.com/iv-org/invidious/issues/4027#issuecomment-1666944520 + response = YoutubeAPI.player(video_id: id, params: "CgIQBg", client_config: client_config) playability_status = response["playabilityStatus"]["status"] LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr new file mode 100644 index 00000000..dac00eea --- /dev/null +++ b/src/invidious/videos/transcript.cr @@ -0,0 +1,77 @@ +module Invidious::Videos + # Namespace for methods primarily relating to Transcripts + module Transcript + record TranscriptLine, start_ms : Time::Span, end_ms : Time::Span, line : String + + def self.generate_param(video_id : String, language_code : String, auto_generated : Bool) : String + kind = auto_generated ? "asr" : "" + + object = { + "1:0:string" => video_id, + + "2:base64" => { + "1:string" => kind, + "2:string" => language_code, + "3:string" => "", + }, + + "3:varint" => 1_i64, + "5:string" => "engagement-panel-searchable-transcript-search-panel", + "6:varint" => 1_i64, + "7:varint" => 1_i64, + "8:varint" => 1_i64, + } + + params = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return params + end + + 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) + + settings_field = { + "Kind" => "captions", + "Language" => target_language, + } + + # 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) + 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 diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index bcba74cf..6aea82ae 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -31,33 +31,5 @@ -
- <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -
- - -<% if query %> - <%- query_encoded = URI.encode_www_form(query.text, space_to_plus: true) -%> -
-
- <% if query.page > 1 %> - - <%= translate(locale, "Previous page") %> - - <% end %> -
-
-
- <% if videos.size >= 20 %> - - <%= translate(locale, "Next page") %> - - <% end %> -
-
-<% end %> +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 6e62a471..09df106d 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -9,13 +9,20 @@ when .streams? then "/channel/#{ucid}/streams" when .playlists? then "/channel/#{ucid}/playlists" when .channels? then "/channel/#{ucid}/channels" + when .podcasts? then "/channel/#{ucid}/podcasts" + when .releases? then "/channel/#{ucid}/releases" else "/channel/#{ucid}" end youtube_url = "https://www.youtube.com#{relative_url}" redirect_url = Invidious::Frontend::Misc.redirect_url(env) --%> + + page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale, + base_url: relative_url, + ctoken: next_continuation + ) +%> <% content_for "header" do %> <%- if selected_tab.videos? -%> @@ -43,21 +50,5 @@
-
-<% items.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
- - -
-
-
- <% if next_continuation %> - - <%= translate(locale, "Next page") %> - - <% end %> -
-
+<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 24efc34e..d2a305d3 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -26,7 +26,7 @@

<%= error_message %>

<% else %> -
+
<%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %>
<% end %> diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr index 59888760..f4164f31 100644 --- a/src/invidious/views/components/channel_info.ecr +++ b/src/invidious/views/components/channel_info.ecr @@ -8,29 +8,30 @@
<% end %> -
-
+
+
<%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
-
-

- -

+ +
+
+ <% sub_count_text = number_to_short_text(channel.sub_count) %> + <%= rendered "components/subscribe_widget" %> +
+ +
-
-

<%= channel.description_html %>

-
-
- -
- <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> +

<%= channel.description_html %>

diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 7cfd38db..031b46da 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -1,157 +1,171 @@ -<% item_watched = !item.is_a?(SearchChannel | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil %> +<%- + thin_mode = env.get("preferences").as(Preferences).thin_mode + item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil + author_verified = item.responds_to?(:author_verified) && item.author_verified +-%>
<% case item when %> <% when SearchChannel %> - - <% if !env.get("preferences").as(Preferences).thin_mode %> + <% if !thin_mode %> +
- " alt="" /> + " alt="" />
- <% end %> -

<%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

-
-

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

- <% if !item.auto_generated %>

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

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

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

-
- <% end %> -

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

-
- -

<%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

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

<%= recode_length_seconds(item.length_seconds) %>

- <% end %> - - <% if item_watched %> -
-
- <% end %> -
- <% end %> -

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

-
- -

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

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

- -

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

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

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

<%= recode_length_seconds(item.length_seconds) %>

- <% end %> - - <% if item_watched %> -
-
- <% end %> -
- <% end %> -

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

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

<%= item.channel_handle %>

<% end %> +

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-
- <% end %> +
<% when Category %> <% else %> - - <% if !env.get("preferences").as(Preferences).thin_mode %> -
- - <% if env.get? "show_watched" %> -
" method="post"> - "> -

- -

-
- <% elsif plid_form = env.get? "add_playlist_items" %> -
" method="post"> - "> -

- -

-
- <% end %> + <%- + # `endpoint_params` is used for the "video-context-buttons" component + if item.is_a?(PlaylistVideo) + link_url = "/watch?v=#{item.id}&list=#{item.plid}&index=#{item.index}" + endpoint_params = "?v=#{item.id}&list=#{item.plid}" + elsif item.is_a?(MixVideo) + link_url = "/watch?v=#{item.id}&list=#{item.rdid}" + endpoint_params = "?v=#{item.id}&list=#{item.rdid}" + else + link_url = "/watch?v=#{item.id}" + endpoint_params = "?v=#{item.id}" + end + -%> - <% if item.responds_to?(:live_now) && item.live_now %> -

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

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

<%= recode_length_seconds(item.length_seconds) %>

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

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

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

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

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

<%= recode_length_seconds(item.length_seconds) %>

+ <%- end -%> +
+
+ + @@ -159,7 +173,7 @@
<% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>

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

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

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

<% end %>
diff --git a/src/invidious/views/components/items_paginated.ecr b/src/invidious/views/components/items_paginated.ecr new file mode 100644 index 00000000..4534a0a3 --- /dev/null +++ b/src/invidious/views/components/items_paginated.ecr @@ -0,0 +1,11 @@ +<%= page_nav_html %> + +
+ <%- items.each do |item| -%> + <%= rendered "components/item" %> + <%- end -%> +
+ +<%= page_nav_html %> + + diff --git a/src/invidious/views/components/subscribe_widget.ecr b/src/invidious/views/components/subscribe_widget.ecr index b9d5f783..05e4e253 100644 --- a/src/invidious/views/components/subscribe_widget.ecr +++ b/src/invidious/views/components/subscribe_widget.ecr @@ -1,22 +1,18 @@ <% if user %> <% if subscriptions.includes? ucid %> -

" method="post"> ">
-

<% else %> -

" method="post"> ">
-

<% end %> <% else %> -

"> <%= translate(locale, "Subscribe") %> | <%= sub_count_text %> -

<% end %> diff --git a/src/invidious/views/components/video-context-buttons.ecr b/src/invidious/views/components/video-context-buttons.ecr index ddb6c983..385ed6b3 100644 --- a/src/invidious/views/components/video-context-buttons.ecr +++ b/src/invidious/views/components/video-context-buttons.ecr @@ -1,4 +1,4 @@ -
+
" href="https://www.youtube.com/watch<%=endpoint_params%>"> @@ -6,7 +6,7 @@ " href="/watch<%=endpoint_params%>&listen=1"> - + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> " href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>"> diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr index 548104c8..34157c67 100644 --- a/src/invidious/views/edit_playlist.ecr +++ b/src/invidious/views/edit_playlist.ecr @@ -6,35 +6,43 @@ <% end %>
-
-
+ + +
+

+
+
+ +
+
<%= HTML.escape(playlist.author) %> | <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> | - <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | - "> - -
-
-

-
- -
-
-
-

+
@@ -44,40 +52,9 @@ -<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> -
-

- -

-
-<% end %> -

-
-<% videos.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
- - -
-
- <% if page > 1 %> - - <%= translate(locale, "Previous page") %> - - <% end %> -
-
-
- <% if videos.size == 100 %> - - <%= translate(locale, "Next page") %> - - <% end %> -
-
+<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index 2234b297..bda4e1f3 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -31,39 +31,29 @@ <% watched.each do |item| %> <% end %>
- +<%= + IV::Frontend::Pagination.nav_numeric(locale, + base_url: base_url, + current_page: page, + show_next: (watched.size >= max_results) + ) +%> diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr index 9c69c5b0..c36bd00f 100644 --- a/src/invidious/views/feeds/subscriptions.ecr +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -56,6 +56,7 @@ +
<% videos.each do |item| %> <%= rendered "components/item" %> @@ -64,20 +65,10 @@ -
- -
-
- <% if (videos.size + notifications.size) == max_results %> - &max_results=<%= max_results %><% end %>"> - <%= translate(locale, "Next page") %> - - <% end %> -
-
+<%= + IV::Frontend::Pagination.nav_numeric(locale, + base_url: base_url, + current_page: page, + show_next: ((videos.size + notifications.size) == max_results) + ) +%> diff --git a/src/invidious/views/hashtag.ecr b/src/invidious/views/hashtag.ecr index 3351c21c..2000337e 100644 --- a/src/invidious/views/hashtag.ecr +++ b/src/invidious/views/hashtag.ecr @@ -4,38 +4,5 @@
-
-
- <%- if page > 1 -%> - <%= translate(locale, "Previous page") %> - <%- end -%> -
-
-
- <%- if videos.size >= 60 -%> - <%= translate(locale, "Next page") %> - <%- end -%> -
-
-
- <%- videos.each do |item| -%> - <%= rendered "components/item" %> - <%- end -%> -
- - - -
-
- <%- if page > 1 -%> - <%= translate(locale, "Previous page") %> - <%- end -%> -
-
-
- <%- if videos.size >= 60 -%> - <%= translate(locale, "Next page") %> - <%- end -%> -
-
+<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index a04acf4c..24ba437d 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -6,9 +6,50 @@ <% end %> -
-
-

<%= title %>

+
+

<%= title %>

+ +
+ <%- if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email -%> + + + + <%- else -%> +
+ <%- if IV::Database::Playlists.exists?(playlist.id) -%> + +  <%= translate(locale, "Subscribe") %> + + <%- else -%> + +  <%= translate(locale, "Unsubscribe") %> + + <%- end -%> +
+ <%- end -%> + + +
+
+ +
+
<% if playlist.is_a? InvidiousPlaylist %> <% if playlist.author == user.try &.email %> @@ -29,7 +70,12 @@ <% else %> - <%= author %> | + <% if !author.empty? %> + <%= author %> | + <% elsif !playlist.subtitle.nil? %> + <% subtitle = playlist.subtitle || "" %> + <%= HTML.escape(subtitle[0..subtitle.rindex(" • ") || subtitle.size]) %> | + <% end %> <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> @@ -54,37 +100,12 @@
<% end %>
-
-

-
- <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> -
-
- <% else %> - <% if Invidious::Database::Playlists.exists?(playlist.id) %> -
- <% else %> -
- <% end %> - <% end %> -
-
-

-
<%= playlist.description_html %>
-<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> -
-

- -

-
-<% end %> -

@@ -100,28 +121,5 @@ <% end %> -
-<% videos.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
- - -
-
- <% if page > 1 %> - - <%= translate(locale, "Previous page") %> - - <% end %> -
-
-
- <% if page_count != 1 && page < page_count %> - - <%= translate(locale, "Next page") %> - - <% end %> -
-
+<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/post.ecr b/src/invidious/views/post.ecr new file mode 100644 index 00000000..fb03a44c --- /dev/null +++ b/src/invidious/views/post.ecr @@ -0,0 +1,48 @@ +<% content_for "header" do %> +Invidious +<% end %> + +
+
+ <%= IV::Frontend::Comments.template_youtube(post_response.not_nil!, locale, thin_mode) %> +
+ + <% if nojs %> +
+ <% end %> +
+ +
+ <% if nojs %> + <%= comment_html %> + <% else %> + + <% end %> +
+
+ + + + \ No newline at end of file diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index a7469e36..b1300214 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -7,21 +7,8 @@ <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
-
-
- <%- if query.page > 1 -%> - <%= translate(locale, "Previous page") %> - <%- end -%> -
-
-
- <%- if videos.size >= 20 -%> - <%= translate(locale, "Next page") %> - <%- end -%> -
-
-<%- if videos.empty? -%> +<%- if items.empty? -%>
<%= translate(locale, "search_message_no_results") %>

@@ -30,25 +17,5 @@
<%- else -%> -
- <%- videos.each do |item| -%> - <%= rendered "components/item" %> - <%- end -%> -
+ <%= rendered "components/items_paginated" %> <%- end -%> - - - -
-
- <%- if query.page > 1 -%> - <%= translate(locale, "Previous page") %> - <%- end -%> -
-
-
- <%- if videos.size >= 20 -%> - <%= translate(locale, "Next page") %> - <%- end -%> -
-
diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index dfda1434..55349c5a 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -89,7 +89,7 @@ <% preferences.captions.each_with_index do |caption, index| %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 5b3190f3..62a154a4 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -64,7 +64,8 @@ we're going to need to do it here in order to allow for translations. "premiere_timestamp" => video.premiere_timestamp.try &.to_unix, "vr" => video.is_vr, "projection_type" => video.projection_type, - "local_disabled" => CONFIG.disabled?("local") + "local_disabled" => CONFIG.disabled?("local"), + "support_reddit" => true }.to_pretty_json %> @@ -204,19 +205,28 @@ we're going to need to do it here in order to allow for translations.
-
- -
- <% if !video.author_thumbnail.empty? %> - - <% end %> - <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %> + +
+ + +
+
+ <% sub_count_text = video.sub_count_text %> + <%= rendered "components/subscribe_widget" %>
- - - <% sub_count_text = video.sub_count_text %> - <%= rendered "components/subscribe_widget" %> +
+
+

<% if video.premiere_timestamp.try &.> Time.utc %> <%= video.premiere_timestamp.try { |t| translate(locale, "Premieres `x`", t.to_s("%B %-d, %R UTC")) } %> @@ -261,7 +271,7 @@ we're going to need to do it here in order to allow for translations.


<% end %> -
+
<% if nojs %> <%= comment_html %> <% else %> @@ -295,15 +305,28 @@ we're going to need to do it here in order to allow for translations. <% video.related_videos.each do |rv| %> <% if rv["id"]? %> - &listen=<%= params.listen %>"> - <% if !env.get("preferences").as(Preferences).thin_mode %> -
+
+ + - <% end %> -

<%= rv["title"] %>

-
+ + <%- else -%> +
+ <%- end -%> + +
+ <%- if (length_seconds = rv["length_seconds"]?.try &.to_i?) && length_seconds != 0 -%> +

<%= recode_length_seconds(length_seconds) %>

+ <%- end -%> +
+
+ + +
<% if rv["ucid"]? %> @@ -321,6 +344,8 @@ we're going to need to do it here in order to allow for translations. %>
+ +
<% end %> <% end %>
@@ -328,4 +353,5 @@ we're going to need to do it here in order to allow for translations.
<% end %>
+ diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 658731cf..e9eb726c 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -1,11 +1,3 @@ -{% unless flag?(:disable_quic) %} - require "lsquic" - - alias HTTPClientType = QUIC::Client | HTTP::Client -{% else %} - alias HTTPClientType = HTTP::Client -{% end %} - def add_yt_headers(request) 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" @@ -26,11 +18,11 @@ struct YoutubeConnectionPool property! url : URI property! capacity : Int32 property! timeout : Float64 - property pool : DB::Pool(HTTPClientType) + property pool : DB::Pool(HTTP::Client) - def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true) + def initialize(url : URI, @capacity = 5, @timeout = 5.0) @url = url - @pool = build_pool(use_quic) + @pool = build_pool() end def client(region = nil, &block) @@ -43,11 +35,7 @@ struct YoutubeConnectionPool response = yield conn rescue ex conn.close - {% unless flag?(:disable_quic) %} - conn = CONFIG.use_quic ? QUIC::Client.new(url) : HTTP::Client.new(url) - {% else %} - conn = HTTP::Client.new(url) - {% end %} + conn = HTTP::Client.new(url) conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC @@ -61,19 +49,9 @@ struct YoutubeConnectionPool response end - private def build_pool(use_quic) - DB::Pool(HTTPClientType).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do - conn = nil # Declare - {% unless flag?(:disable_quic) %} - if use_quic - conn = QUIC::Client.new(url) - else - conn = HTTP::Client.new(url) - end - {% else %} - conn = HTTP::Client.new(url) - {% end %} - + private def build_pool + DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do + conn = HTTP::Client.new(url) conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" @@ -83,7 +61,6 @@ struct YoutubeConnectionPool end def make_client(url : URI, region = nil) - # TODO: Migrate any applicable endpoints to QUIC client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure) client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 6686e6e7..56325cf7 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -11,15 +11,16 @@ private ITEM_CONTAINER_EXTRACTOR = { } private ITEM_PARSERS = { + Parsers::RichItemRendererParser, Parsers::VideoRendererParser, Parsers::ChannelRendererParser, Parsers::GridPlaylistRendererParser, Parsers::PlaylistRendererParser, Parsers::CategoryRendererParser, - Parsers::RichItemRendererParser, Parsers::ReelItemRendererParser, Parsers::ItemSectionRendererParser, Parsers::ContinuationItemRendererParser, + Parsers::HashtagRendererParser, } private alias InitialData = Hash(String, JSON::Any) @@ -174,17 +175,18 @@ private module Parsers # Always simpleText # TODO change default value to nil - subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") + subscriber_count = item_contents.dig?("subscriberCountText", "simpleText").try &.as_s + channel_handle = subscriber_count if (subscriber_count.try &.starts_with? "@") # Since youtube added channel handles, `VideoCountText` holds the number of # subscribers and `subscriberCountText` holds the handle, except when the # channel doesn't have a handle (e.g: some topic music channels). # See https://github.com/iv-org/invidious/issues/3394#issuecomment-1321261688 - if !subscriber_count || !subscriber_count.as_s.includes? " subscriber" - subscriber_count = item_contents.dig?("videoCountText", "simpleText") + if !subscriber_count || !subscriber_count.includes? " subscriber" + subscriber_count = item_contents.dig?("videoCountText", "simpleText").try &.as_s end subscriber_count = subscriber_count - .try { |s| short_text_to_number(s.as_s.split(" ")[0]).to_i32 } || 0 + .try { |s| short_text_to_number(s.split(" ")[0]).to_i32 } || 0 # Auto-generated channels doesn't have videoCountText # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922 @@ -199,6 +201,7 @@ private module Parsers author_thumbnail: author_thumbnail, subscriber_count: subscriber_count, video_count: video_count, + channel_handle: channel_handle, description_html: description_html, auto_generated: auto_generated, author_verified: author_verified, @@ -210,6 +213,56 @@ private module Parsers end end + # Parses an Innertube `hashtagTileRenderer` into a `SearchHashtag`. + # Returns `nil` when the given object is not a `hashtagTileRenderer`. + # + # A `hashtagTileRenderer` is a kind of search result. + # It can be found when searching for any hashtag (e.g "#hi" or "#shorts") + module HashtagRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["hashtagTileRenderer"]? + return self.parse(item_contents) + end + end + + private def self.parse(item_contents) + title = extract_text(item_contents["hashtag"]).not_nil! # E.g "#hi" + + # E.g "/hashtag/hi" + url = item_contents.dig?("onTapCommand", "commandMetadata", "webCommandMetadata", "url").try &.as_s + url ||= URI.encode_path("/hashtag/#{title.lchop('#')}") + + video_count_txt = extract_text(item_contents["hashtagVideoCount"]?) # E.g "203K videos" + channel_count_txt = extract_text(item_contents["hashtagChannelCount"]?) # E.g "81K channels" + + # Fallback for video/channel counts + if channel_count_txt.nil? || video_count_txt.nil? + # E.g: "203K videos • 81K channels" + info_text = extract_text(item_contents["hashtagInfoText"]?).try &.split(" • ") + + if info_text && info_text.size == 2 + video_count_txt ||= info_text[0] + channel_count_txt ||= info_text[1] + end + end + + return SearchHashtag.new({ + title: title, + url: url, + video_count: short_text_to_number(video_count_txt || ""), + channel_count: short_text_to_number(channel_count_txt || ""), + }) + rescue ex + LOGGER.debug("HashtagRendererParser: Failed to extract renderer.") + LOGGER.debug("HashtagRendererParser: Got exception: #{ex.message}") + return nil + end + + def self.parser_name + return {{@type.name}} + end + end + # Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer # # A gridPlaylistRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI. @@ -408,8 +461,8 @@ private module Parsers # Returns nil when the given object isn't a RichItemRenderer # # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used - # by the result page for hashtags. It is located inside a continuationItems - # container. + # by the result page for hashtags and for the podcast tab on channels. + # It is located inside a continuationItems container for hashtags. # module RichItemRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) @@ -421,6 +474,7 @@ private module Parsers private def self.parse(item_contents, author_fallback) child = VideoRendererParser.process(item_contents, author_fallback) child ||= ReelItemRendererParser.process(item_contents, author_fallback) + child ||= PlaylistRendererParser.process(item_contents, author_fallback) return child end @@ -607,19 +661,25 @@ private module Extractors private def self.unpack_section_list(contents) raw_items = [] of JSON::Any - contents.as_a.each do |renderer_container| - renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] - - # Category extraction - if items_container = renderer_container_contents["shelfRenderer"]? - raw_items << renderer_container_contents - next - elsif items_container = renderer_container_contents["gridRenderer"]? + contents.as_a.each do |item| + if item_section_content = item.dig?("itemSectionRenderer", "contents") + raw_items += self.unpack_item_section(item_section_content) else - items_container = renderer_container_contents + raw_items << item end + end - items_container["items"]?.try &.as_a.each do |item| + return raw_items + end + + private def self.unpack_item_section(contents) + raw_items = [] of JSON::Any + + contents.as_a.each do |item| + # Category extraction + if container = item.dig?("gridRenderer", "items") || item.dig?("items") + raw_items += container.as_a + else raw_items << item end end diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 3dd9e9d8..a5e621f2 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -557,6 +557,30 @@ module YoutubeAPI return self._post_json("/youtubei/v1/search", data, client_config) end + #################################################################### + # get_transcript(params, client_config?) + # + # Requests the youtubei/v1/get_transcript endpoint with the required headers + # and POST data in order to get a JSON reply. + # + # The requested data is a specially encoded protobuf string that denotes the specific language requested. + # + # An optional ClientConfig parameter can be passed, too (see + # `struct ClientConfig` above for more details). + # + + def get_transcript( + params : String, + client_config : ClientConfig | Nil = nil + ) : Hash(String, JSON::Any) + data = { + "context" => self.make_context(client_config), + "params" => params, + } + + return self._post_json("/youtubei/v1/get_transcript", data, client_config) + end + #################################################################### # _post_json(endpoint, data, client_config?) # @@ -595,17 +619,9 @@ module YoutubeAPI LOGGER.trace("YoutubeAPI: POST data: #{data}") # Send the POST request - if {{ !flag?(:disable_quic) }} && CONFIG.use_quic - # Using QUIC client - body = YT_POOL.client(client_config.proxy_region, - &.post(url, headers: headers, body: data.to_json) - ).body - else - # Using HTTP client - body = YT_POOL.client(client_config.proxy_region) do |client| - client.post(url, headers: headers, body: data.to_json) do |response| - self._decompress(response.body_io, response.headers["Content-Encoding"]?) - end + body = YT_POOL.client(client_config.proxy_region) do |client| + client.post(url, headers: headers, body: data.to_json) do |response| + self._decompress(response.body_io, response.headers["Content-Encoding"]?) end end