summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/container-release.yml29
-rw-r--r--Makefile6
-rw-r--r--assets/css/default.css242
-rw-r--r--assets/hashtag.svg9
-rw-r--r--config/config.example.yml21
-rw-r--r--docker/Dockerfile11
-rw-r--r--docker/Dockerfile.arm6411
-rw-r--r--locales/ar.json10
-rw-r--r--locales/az.json1
-rw-r--r--locales/cs.json10
-rw-r--r--locales/de.json8
-rw-r--r--locales/en-US.json10
-rw-r--r--locales/eo.json16
-rw-r--r--locales/es.json14
-rw-r--r--locales/fr.json22
-rw-r--r--locales/hi.json15
-rw-r--r--locales/hr.json10
-rw-r--r--locales/it.json14
-rw-r--r--locales/ja.json18
-rw-r--r--locales/ko.json10
-rw-r--r--locales/la.json1
-rw-r--r--locales/nb-NO.json12
-rw-r--r--locales/or.json30
-rw-r--r--locales/pl.json14
-rw-r--r--locales/pt-BR.json12
-rw-r--r--locales/pt.json14
-rw-r--r--locales/ru.json10
-rw-r--r--locales/si.json8
-rw-r--r--locales/sk.json22
-rw-r--r--locales/sl.json12
-rw-r--r--locales/tr.json10
-rw-r--r--locales/uk.json10
-rw-r--r--locales/vi.json36
-rw-r--r--locales/zh-CN.json10
-rw-r--r--locales/zh-TW.json10
-rw-r--r--shard.lock11
-rw-r--r--shard.yml7
-rw-r--r--src/invidious.cr2
-rw-r--r--src/invidious/channels/community.cr72
-rw-r--r--src/invidious/channels/playlists.cr18
-rw-r--r--src/invidious/config.cr2
-rw-r--r--src/invidious/frontend/channel_page.cr2
-rw-r--r--src/invidious/frontend/pagination.cr97
-rw-r--r--src/invidious/helpers/i18n.cr9
-rw-r--r--src/invidious/helpers/serialized_yt_data.cr21
-rw-r--r--src/invidious/http_server/utils.cr20
-rw-r--r--src/invidious/routes/api/v1/channels.cr62
-rw-r--r--src/invidious/routes/channels.cr44
-rw-r--r--src/invidious/routes/feeds.cr34
-rw-r--r--src/invidious/routes/images.cr142
-rw-r--r--src/invidious/routes/playlists.cr45
-rw-r--r--src/invidious/routes/search.cr26
-rw-r--r--src/invidious/routing.cr5
-rw-r--r--src/invidious/videos/parser.cr10
-rw-r--r--src/invidious/views/add_playlist_items.ecr30
-rw-r--r--src/invidious/views/channel.ecr27
-rw-r--r--src/invidious/views/components/channel_info.ecr27
-rw-r--r--src/invidious/views/components/item.ecr257
-rw-r--r--src/invidious/views/components/items_paginated.ecr11
-rw-r--r--src/invidious/views/components/subscribe_widget.ecr6
-rw-r--r--src/invidious/views/components/video-context-buttons.ecr4
-rw-r--r--src/invidious/views/edit_playlist.ecr89
-rw-r--r--src/invidious/views/feeds/history.ecr52
-rw-r--r--src/invidious/views/feeds/subscriptions.ecr25
-rw-r--r--src/invidious/views/hashtag.ecr35
-rw-r--r--src/invidious/views/playlist.ecr97
-rw-r--r--src/invidious/views/search.ecr37
-rw-r--r--src/invidious/views/watch.ecr62
-rw-r--r--src/invidious/yt_backend/connection_pool.cr37
-rw-r--r--src/invidious/yt_backend/extractors.cr84
-rw-r--r--src/invidious/yt_backend/youtube_api.cr14
71 files changed, 1329 insertions, 892 deletions
diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml
index 86aec94f..13bbf34c 100644
--- a/.github/workflows/container-release.yml
+++ b/.github/workflows/container-release.yml
@@ -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,4 @@ 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/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..c31b24e5 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%; }
@@ -347,6 +412,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 +470,7 @@ span > select {
word-wrap: normal;
}
+
/*
* Light theme
*/
@@ -401,9 +483,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 +522,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 +553,7 @@ span > select {
}
}
+
/*
* Dark theme
*/
@@ -465,6 +566,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 +620,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 +668,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 +711,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 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="128" height="128" viewBox="0 0 128 128" version="1.1" id="svg5" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
+ <g>
+ <rect fill="#c84fff" width="128" height="128" x="0" y="0" />
+ <g aria-label="#" transform="matrix(1.1326954,0,0,1.1326954,-20.255282,-23.528147)">
+ <path d="m 87.780593,70.524217 -2.624999,13.666661 h 11.666662 v 5.708331 H 84.030595 L 80.61393,107.73253 H 74.488932 L 77.988931,89.899209 H 65.863936 L 62.447271,107.73253 H 56.447273 L 59.697272,89.899209 H 48.947276 V 84.190878 H 60.822271 L 63.530603,70.524217 H 52.113942 V 64.815886 H 64.57227 l 3.416665,-17.999993 h 6.124997 l -3.416665,17.999993 h 12.208328 l 3.499999,-17.999993 h 5.999997 l -3.499998,17.999993 h 10.916662 v 5.708331 z M 66.947269,84.190878 H 79.072264 L 81.738929,70.524217 H 69.613934 Z" />
+ </g>
+ </g>
+</svg>
diff --git a/config/config.example.yml b/config/config.example.yml
index 34070fe5..e925a5e3 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.
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..877fb9ff 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -540,5 +540,13 @@
"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": "البودكاست"
}
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/cs.json b/locales/cs.json
index 73ed960d..b2cce0bd 100644
--- a/locales/cs.json
+++ b/locales/cs.json
@@ -492,5 +492,13 @@
"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"
}
diff --git a/locales/de.json b/locales/de.json
index 66f2ae6f..6ceaa44b 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -476,5 +476,11 @@
"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 Playlist 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"
}
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..6d1b0bc1 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,13 @@
"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"
}
diff --git a/locales/es.json b/locales/es.json
index b3103a25..b4a56030 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,13 @@
"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"
}
diff --git a/locales/fr.json b/locales/fr.json
index d2607a49..7fea8f14 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -1,4 +1,6 @@
{
+ "generic_channels_count": "{{count}} chaîne",
+ "generic_channels_count_plural": "{{count}} chaînes",
"generic_views_count": "{{count}} vue",
"generic_views_count_plural": "{{count}} vues",
"generic_videos_count": "{{count}} vidéo",
@@ -9,6 +11,11 @@
"generic_subscribers_count_plural": "{{count}} abonnés",
"generic_subscriptions_count": "{{count}} abonnement",
"generic_subscriptions_count_plural": "{{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 +57,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,8 +130,8 @@
"Subscription manager": "Gestionnaire d'abonnement",
"Token manager": "Gestionnaire de token",
"Token": "Token",
- "tokens_count": "{{count}} token",
- "tokens_count_plural": "{{count}} tokens",
+ "tokens_count": "{{count}} jeton",
+ "tokens_count_plural": "{{count}} jetons",
"Import/export": "Importer/Exporter",
"unsubscribe": "se désabonner",
"revoke": "révoquer",
@@ -149,6 +156,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",
@@ -476,5 +484,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..ba3dd5e5 100644
--- a/locales/hr.json
+++ b/locales/hr.json
@@ -492,5 +492,13 @@
"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"
}
diff --git a/locales/it.json b/locales/it.json
index a3d0f5da..894eb97f 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -13,7 +13,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",
@@ -467,7 +467,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 +476,13 @@
"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"
}
diff --git a/locales/ja.json b/locales/ja.json
index 8adcbf6a..6fc02e2d 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": "上記が助けにならないなら、<a href=\"`x`\">GitHub</a> に新しい issue を作成し(英語が好ましい)、メッセージに次のテキストを含めてください(テキストは翻訳しない)。",
@@ -460,5 +460,13 @@
"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": "動画を追加"
}
diff --git a/locales/ko.json b/locales/ko.json
index 9c8db5a1..e02a8316 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -460,5 +460,13 @@
"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": "출시"
}
diff --git a/locales/la.json b/locales/la.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/locales/la.json
@@ -0,0 +1 @@
+{}
diff --git a/locales/nb-NO.json b/locales/nb-NO.json
index 1e0e9e77..216b559f 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,13 @@
"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"
}
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..f1924c8a 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,13 @@
"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"
}
diff --git a/locales/pt-BR.json b/locales/pt-BR.json
index 81290398..68a6e3ab 100644
--- a/locales/pt-BR.json
+++ b/locales/pt-BR.json
@@ -475,6 +475,14 @@
"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"
}
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..5325a9b6 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -492,5 +492,13 @@
"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": "Подкасты"
}
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..fec1cb62 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,13 @@
"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"
}
diff --git a/locales/tr.json b/locales/tr.json
index 22732a51..7f3f2de8 100644
--- a/locales/tr.json
+++ b/locales/tr.json
@@ -476,5 +476,13 @@
"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"
}
diff --git a/locales/uk.json b/locales/uk.json
index 308b10ca..4d8f06a5 100644
--- a/locales/uk.json
+++ b/locales/uk.json
@@ -492,5 +492,13 @@
"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": "Зберегти"
}
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..62f45a29 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -460,5 +460,13 @@
"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": "公告"
}
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 7da2d762..da81922b 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -460,5 +460,13 @@
"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": "發布"
}
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 <omarroth@protonmail.com>
- - 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/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..791f1641 100644
--- a/src/invidious/channels/community.cr
+++ b/src/invidious/channels/community.cr
@@ -1,49 +1,31 @@
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
-
- if response.status_code != 200
- raise NotFoundException.new("This channel does not exist.")
- end
-
- ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>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.")
+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")
+
+ 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":"(?<session_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 extract_channel_community(items, *, ucid, locale, format, thin_mode)
+ if message = items[0]["messageRenderer"]?
error_message = (message["text"]["simpleText"]? ||
message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
.try &.as_s || ""
@@ -59,7 +41,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
json.field "authorId", ucid
json.field "comments" do
json.array do
- posts.each do |post|
+ items.each do |post|
comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? ||
post["backstageCommentsContinuation"]?
@@ -216,6 +198,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,7 +240,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
end
end
end
- if cont = posts.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token")
+ if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token")
json.field "continuation", extract_channel_community_cursor(cont.as_s)
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/config.cr b/src/invidious/config.cr
index e5f1e822..cee33ce1 100644
--- a/src/invidious/config.cr
+++ b/src/invidious/config.cr
@@ -126,8 +126,6 @@ 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
# 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/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 << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
+
+ if locale_is_rtl?(locale)
+ # Inverted arrow ("previous" points to the right)
+ str << translate(locale, "Previous page")
+ str << "&nbsp;&nbsp;"
+ str << %(<i class="icon ion-ios-arrow-forward"></i>)
+ else
+ # Regular arrow ("previous" points to the left)
+ str << %(<i class="icon ion-ios-arrow-back"></i>)
+ str << "&nbsp;&nbsp;"
+ str << translate(locale, "Previous page")
+ end
+
+ str << "</a>"
+ end
+
+ private def next_page(str : String::Builder, locale : String?, url : String)
+ # Link
+ str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
+
+ if locale_is_rtl?(locale)
+ # Inverted arrow ("next" points to the left)
+ str << %(<i class="icon ion-ios-arrow-back"></i>)
+ str << "&nbsp;&nbsp;"
+ str << translate(locale, "Next page")
+ else
+ # Regular arrow ("next" points to the right)
+ str << translate(locale, "Next page")
+ str << "&nbsp;&nbsp;"
+ str << %(<i class="icon ion-ios-arrow-forward"></i>)
+ end
+
+ str << "</a>"
+ end
+
+ def nav_numeric(locale : String?, *, base_url : String | URI, current_page : Int, show_next : Bool = true)
+ return String.build do |str|
+ str << %(<div class="h-box">\n)
+ str << %(<div class="page-nav-container flexible">\n)
+
+ str << %(<div class="page-prev-container flex-left">)
+
+ if current_page > 1
+ params_prev = URI::Params{"page" => (current_page - 1).to_s}
+ url_prev = HttpServer::Utils.add_params_to_url(base_url, params_prev)
+
+ self.previous_page(str, locale, url_prev.to_s)
+ end
+
+ str << %(</div>\n)
+ str << %(<div class="page-next-container flex-right">)
+
+ if show_next
+ params_next = URI::Params{"page" => (current_page + 1).to_s}
+ url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
+
+ self.next_page(str, locale, url_next.to_s)
+ end
+
+ str << %(</div>\n)
+
+ str << %(</div>\n)
+ str << %(</div>\n\n)
+ end
+ end
+
+ def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?)
+ return String.build do |str|
+ str << %(<div class="h-box">\n)
+ str << %(<div class="page-nav-container flexible">\n)
+
+ str << %(<div class="page-prev-container flex-left"></div>\n)
+
+ str << %(<div class="page-next-container flex-right">)
+
+ if !ctoken.nil?
+ params_next = URI::Params{"continuation" => ctoken}
+ url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
+
+ self.next_page(str, locale, url_next.to_s)
+ end
+
+ str << %(</div>\n)
+
+ str << %(</div>\n)
+ str << %(</div>\n\n)
+ end
+ end
+end
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/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr
index 7c12ad0e..e0bd7279 100644
--- a/src/invidious/helpers/serialized_yt_data.cr
+++ b/src/invidious/helpers/serialized_yt_data.cr
@@ -232,6 +232,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 +293,4 @@ struct Continuation
end
end
-alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category
+alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category
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/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr
index bcb4db2c..adf05d30 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
diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr
index 16621994..9892ae2a 100644
--- a/src/invidious/routes/channels.cr
+++ b/src/invidious/routes/channels.cr
@@ -27,7 +27,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 +105,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)
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.<br>#{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..9c43171c 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -118,6 +118,8 @@ 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
@@ -228,6 +230,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"} %}
diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr
index 9cc0ffdc..2a09d187 100644
--- a/src/invidious/videos/parser.cr
+++ b/src/invidious/videos/parser.cr
@@ -55,8 +55,9 @@ 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)
+ # CgIQBg is a workaround for streaming URLs that returns a 403.
+ # See https://github.com/iv-org/invidious/issues/4027#issuecomment-1666944520
+ player_response = YoutubeAPI.player(video_id: video_id, params: "CgIQBg", client_config: client_config)
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
@@ -135,8 +136,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/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 @@
</script>
<script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
-<div class="pure-g">
- <% videos.each_slice(4) do |slice| %>
- <% slice.each do |item| %>
- <%= rendered "components/item" %>
- <% end %>
- <% end %>
-</div>
-<script src="/js/watched_indicator.js"></script>
-
-<% if query %>
- <%- query_encoded = URI.encode_www_form(query.text, space_to_plus: true) -%>
- <div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if query.page > 1 %>
- <a href="/add_playlist_items?list=<%= plid %>&q=<%= query_encoded %>&page=<%= page - 1 %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if videos.size >= 20 %>
- <a href="/add_playlist_items?list=<%= plid %>&q=<%= query_encoded %>&page=<%= page + 1 %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
- </div>
-<% 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 @@
<hr>
</div>
-<div class="pure-g">
-<% items.each do |item| %>
- <%= rendered "components/item" %>
-<% end %>
-</div>
-<script src="/js/watched_indicator.js"></script>
-
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-md-4-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if next_continuation %>
- <a href="<%= relative_url %>?continuation=<%= next_continuation %><% if sort_options.any? sort_by %>&sort_by=<%= sort_by %><% end %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%= rendered "components/items_paginated" %>
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 @@
</div>
<% end %>
-<div class="pure-g h-box">
- <div class="pure-u-2-3">
+<div class="pure-g h-box flexible title">
+ <div class="pure-u-1-2 flex-left flexible">
<div class="channel-profile">
<img src="/ggpht<%= channel_profile_pic %>" alt="" />
<span><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
</div>
</div>
- <div class="pure-u-1-3">
- <h3 style="text-align:right">
- <a href="/feed/channel/<%= ucid %>"><i class="icon ion-logo-rss"></i></a>
- </h3>
- </div>
-</div>
-<div class="h-box">
- <div id="descriptionWrapper">
- <p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p>
+ <div class="pure-u-1-2 flex-right flexible button-container">
+ <div class="pure-u">
+ <% sub_count_text = number_to_short_text(channel.sub_count) %>
+ <%= rendered "components/subscribe_widget" %>
+ </div>
+
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary" dir="auto" href="/feed/channel/<%= ucid %>">
+ <i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "generic_button_rss") %>
+ </a>
+ </div>
</div>
</div>
<div class="h-box">
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
- <%= rendered "components/subscribe_widget" %>
+ <div id="descriptionWrapper"><p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p></div>
</div>
<div class="pure-g h-box">
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index 7cfd38db..c29ec47b 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -1,157 +1,170 @@
-<% 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
+-%>
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<% case item when %>
<% when SearchChannel %>
- <a href="/channel/<%= item.ucid %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
+ <% if !thin_mode %>
+ <a tabindex="-1" href="/channel/<%= item.ucid %>">
<center>
- <img loading="lazy" tabindex="-1" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" />
+ <img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" />
</center>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></p>
- </a>
+ </a>
+ <%- else -%>
+ <div class="thumbnail-placeholder" style="width:56.25%"></div>
+ <% end %>
+
+ <div class="video-card-row flexible">
+ <div class="flex-left"><a href="/channel/<%= item.ucid %>">
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
+ </a></div>
+ </div>
+
<p><%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %></p>
<% if !item.auto_generated %><p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p><% end %>
<h5><%= item.description_html %></h5>
- <% 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}" %>
+ <% when SearchHashtag %>
+ <% if !thin_mode %>
+ <a tabindex="-1" href="<%= item.url %>">
+ <center><img style="width:56.25%" src="/hashtag.svg" alt="" /></center>
+ </a>
+ <%- else -%>
+ <div class="thumbnail-placeholder" style="width:56.25%"></div>
<% end %>
- <a style="width:100%" href="<%= url %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>" alt="" />
- <p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
- </div>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.title) %></p>
- </a>
- <a href="/channel/<%= item.ucid %>">
- <p dir="auto"><b><%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b></p>
- </a>
- <% when MixVideo %>
- <a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
- <% if item.length_seconds != 0 %>
- <p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
- <% end %>
+ <div class="video-card-row">
+ <div class="flex-left"><a href="<%= item.url %>"><%= HTML.escape(item.title) %></a></div>
+ </div>
- <% if item_watched %>
- <div class="watched-overlay"></div>
- <div class="watched-indicator" data-length="<%= item.length_seconds %>" data-id="<%= item.id %>"></div>
- <% end %>
- </div>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.title) %></p>
- </a>
- <a href="/channel/<%= item.ucid %>">
- <p dir="auto"><b><%= HTML.escape(item.author) %></b></p>
- </a>
- <% when PlaylistVideo %>
- <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
-
- <% if plid_form = env.get?("remove_playlist_items") %>
- <form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <p class="watched">
- <button type="submit" style="all:unset" data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>"><i class="icon ion-md-trash"></i></button>
- </p>
- </form>
- <% end %>
+ <div class="video-card-row">
+ <%- if item.video_count != 0 -%>
+ <p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
+ <%- end -%>
+ </div>
- <% if item.responds_to?(:live_now) && item.live_now %>
- <p class="length"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p>
- <% elsif item.length_seconds != 0 %>
- <p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
- <% end %>
+ <div class="video-card-row">
+ <%- if item.channel_count != 0 -%>
+ <p><%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %></p>
+ <%- end -%>
+ </div>
+ <% 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 item_watched %>
- <div class="watched-overlay"></div>
- <div class="watched-indicator" data-length="<%= item.length_seconds %>" data-id="<%= item.id %>"></div>
- <% end %>
- </div>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.title) %></p>
- </a>
+ <div class="thumbnail">
+ <%- if !thin_mode %>
+ <a tabindex="-1" href="<%= link_url %>">
+ <img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>" alt="" />
+ </a>
+ <%- else -%>
+ <div class="thumbnail-placeholder"></div>
+ <%- end -%>
- <div class="video-card-row flexible">
- <div class="flex-left"><a href="/channel/<%= item.ucid %>">
- <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %></p>
- </a></div>
- <% endpoint_params = "?v=#{item.id}&list=#{item.plid}" %>
- <%= rendered "components/video-context-buttons" %>
+ <div class="bottom-right-overlay">
+ <p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
+ </div>
</div>
- <div class="video-card-row flexible">
- <div class="flex-left">
- <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
- <p dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
- <% elsif Time.utc - item.published > 1.minute %>
- <p dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
- <% end %>
- </div>
+ <div class="video-card-row">
+ <a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a>
+ </div>
- <% if item.responds_to?(:views) && item.views %>
- <div class="flex-right">
- <p dir="auto"><%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %></p>
- </div>
- <% end %>
+ <div class="video-card-row flexible">
+ <div class="flex-left"><a href="/channel/<%= item.ucid %>">
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
+ </a></div>
</div>
<% when Category %>
<% else %>
- <a style="width:100%" href="/watch?v=<%= item.id %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
- <% if env.get? "show_watched" %>
- <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <p class="watched">
- <button type="submit" style="all:unset" data-onclick="mark_watched" data-id="<%= item.id %>">
- <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" class="icon ion-ios-eye"></i>
- </button>
- </p>
- </form>
- <% elsif plid_form = env.get? "add_playlist_items" %>
- <form data-onsubmit="return_false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <p class="watched">
- <button type="submit" style="all:unset" data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
- </p>
- </form>
- <% 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 %>
- <p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p>
- <% elsif item.length_seconds != 0 %>
- <p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
- <% end %>
+ <div class="thumbnail">
+ <%- if !thin_mode -%>
+ <a tabindex="-1" href="<%= link_url %>">
+ <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
<% if item_watched %>
<div class="watched-overlay"></div>
<div class="watched-indicator" data-length="<%= item.length_seconds %>" data-id="<%= item.id %>"></div>
<% end %>
- </div>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.title) %></p>
- </a>
+ </a>
+ <%- else -%>
+ <div class="thumbnail-placeholder"></div>
+ <%- end -%>
+
+ <div class="top-left-overlay">
+ <%- if env.get? "show_watched" -%>
+ <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
+ <button type="submit" class="pure-button pure-button-secondary low-profile"
+ data-onclick="mark_watched" data-id="<%= item.id %>">
+ <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" class="icon ion-ios-eye"></i>
+ </button>
+ </form>
+ <%- 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")}" -%>
+ <form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
+ <button type="submit" class="pure-button pure-button-secondary low-profile"
+ data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
+ </form>
+ <%- 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")}" -%>
+ <form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
+ <button type="submit" class="pure-button pure-button-secondary low-profile"
+ data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>"><i class="icon ion-md-trash"></i></button>
+ </form>
+ <%- end -%>
+ </div>
+
+ <div class="bottom-right-overlay">
+ <%- if item.responds_to?(:live_now) && item.live_now -%>
+ <p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i>&nbsp;<%= translate(locale, "LIVE") %></p>
+ <%- elsif item.length_seconds != 0 -%>
+ <p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
+ <%- end -%>
+ </div>
+ </div>
+
+ <div class="video-card-row">
+ <a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a>
+ </div>
<div class="video-card-row flexible">
<div class="flex-left"><a href="/channel/<%= item.ucid %>">
- <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %><% if !item.is_a?(ChannelVideo) && !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></p>
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
</a></div>
- <% endpoint_params = "?v=#{item.id}" %>
<%= rendered "components/video-context-buttons" %>
</div>
@@ -159,7 +172,7 @@
<div class="flex-left">
<% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
<p class="video-data" dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
- <% elsif Time.utc - item.published > 1.minute %>
+ <% elsif item.responds_to?(:published) && (Time.utc - item.published) > 1.minute %>
<p class="video-data" dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
<% end %>
</div>
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 %>
+
+<div class="pure-g">
+ <%- items.each do |item| -%>
+ <%= rendered "components/item" %>
+ <%- end -%>
+</div>
+
+<%= page_nav_html %>
+
+<script src="/js/watched_indicator.js"></script>
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 %>
- <p>
<form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</button>
</form>
- </p>
<% else %>
- <p>
<form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
</button>
</form>
- </p>
<% end %>
<script id="subscribe_data" type="application/json">
@@ -33,10 +29,8 @@
</script>
<script src="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% else %>
- <p>
<a id="subscribe" class="pure-button pure-button-primary"
href="/login?referer=<%= env.get("current_page") %>">
<b><%= translate(locale, "Subscribe") %> | <%= sub_count_text %></b>
</a>
- </p>
<% 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 @@
-<div class="flex-right">
+<div class="flex-right flexible">
<div class="icon-buttons">
<a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" href="https://www.youtube.com/watch<%=endpoint_params%>">
<i class="icon ion-logo-youtube"></i>
@@ -6,7 +6,7 @@
<a title="<%=translate(locale, "Audio mode")%>" href="/watch<%=endpoint_params%>&listen=1">
<i class="icon ion-md-headset"></i>
</a>
-
+
<% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>">
<i class="icon ion-md-jet"></i>
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 %>
<form class="pure-form" action="/edit_playlist?list=<%= plid %>" method="post">
- <div class="pure-g h-box">
- <div class="pure-u-2-3">
+ <div class="h-box flexible">
+ <div class="flex-right button-container">
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/playlist?list=<%= plid %>">
+ <i class="icon ion-md-close"></i>&nbsp;<%= translate(locale, "generic_button_cancel") %>
+ </a>
+ </div>
+ <div class="pure-u">
+ <button class="pure-button pure-button-secondary low-profile" dir="auto" type="submit">
+ <i class="icon ion-md-save"></i>&nbsp;<%= translate(locale, "generic_button_save") %>
+ </button>
+ </div>
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
+ <i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "generic_button_delete") %>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <div class="h-box flexible title">
+ <div>
<h3><input class="pure-input-1" maxlength="150" name="title" type="text" value="<%= title %>"></h3>
+ </div>
+ </div>
+
+ <div class="h-box">
+ <div class="pure-u-1-1">
<b>
<%= HTML.escape(playlist.author) %> |
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
- <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
- <i class="icon <%= {"ion-md-globe", "ion-ios-unlock", "ion-ios-lock"}[playlist.privacy.value] %>"></i>
- <select name="privacy">
- <% {"Public", "Unlisted", "Private"}.each do |option| %>
- <option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= translate(locale, option) %></option>
- <% end %>
- </select>
</b>
- </div>
- <div class="pure-u-1-3" style="text-align:right">
- <h3>
- <div class="pure-g user-field">
- <div class="pure-u-1-3">
- <a href="javascript:void(0)">
- <button type="submit" style="all:unset">
- <i class="icon ion-md-save"></i>
- </button>
- </a>
- </div>
- <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div>
- <div class="pure-u-1-3"><a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a></div>
- </div>
- </h3>
+ <select name="privacy">
+ <%- {"Public", "Unlisted", "Private"}.each do |option| -%>
+ <option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= translate(locale, option) %></option>
+ <%- end -%>
+ </select>
</div>
</div>
@@ -44,40 +52,9 @@
<input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</form>
-<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
-<div class="h-box" style="text-align:right">
- <h3>
- <a href="/add_playlist_items?list=<%= plid %>"><i class="icon ion-md-add"></i></a>
- </h3>
-</div>
-<% end %>
-
<div class="h-box">
<hr>
</div>
-<div class="pure-g">
-<% videos.each do |item| %>
- <%= rendered "components/item" %>
-<% end %>
-</div>
-
-<script src="/js/watched_indicator.js"></script>
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if page > 1 %>
- <a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if videos.size == 100 %>
- <a href="/playlist?list=<%= playlist.id %>&page=<%= page + 1 %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%= 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| %>
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
- <a style="width:100%" href="/watch?v=<%= item %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" />
- <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
- <p class="watched">
- <button type="submit" style="all:unset" data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
- </p>
- </form>
- </div>
- <p></p>
- <% end %>
- </a>
+ <div class="thumbnail">
+ <a style="width:100%" href="/watch?v=<%= item %>">
+ <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" />
+ </a>
+
+ <div class="top-left-overlay"><div class="watched">
+ <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
+ <button type="submit" class="pure-button pure-button-secondary low-profile"
+ data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
+ </form>
+ </div></div>
+ </div>
+ <p></p>
</div>
</div>
<% end %>
</div>
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if page > 1 %>
- <a href="/feed/history?page=<%= page - 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if watched.size >= max_results %>
- <a href="/feed/history?page=<%= page + 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%=
+ 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 @@
</script>
<script src="/js/watched_widget.js"></script>
+
<div class="pure-g">
<% videos.each do |item| %>
<%= rendered "components/item" %>
@@ -64,20 +65,10 @@
<script src="/js/watched_indicator.js"></script>
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if page > 1 %>
- <a href="/feed/subscriptions?page=<%= page - 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if (videos.size + notifications.size) == max_results %>
- <a href="/feed/subscriptions?page=<%= page + 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%=
+ 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 @@
<hr/>
-<div class="pure-g h-box v-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <%- if page > 1 -%>
- <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
- <%- end -%>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <%- if videos.size >= 60 -%>
- <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
- <%- end -%>
- </div>
-</div>
-<div class="pure-g">
- <%- videos.each do |item| -%>
- <%= rendered "components/item" %>
- <%- end -%>
-</div>
-
-<script src="/js/watched_indicator.js"></script>
-
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <%- if page > 1 -%>
- <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
- <%- end -%>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <%- if videos.size >= 60 -%>
- <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
- <%- end -%>
- </div>
-</div>
+<%= rendered "components/items_paginated" %>
diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr
index a04acf4c..ee9ba87b 100644
--- a/src/invidious/views/playlist.ecr
+++ b/src/invidious/views/playlist.ecr
@@ -6,9 +6,50 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" />
<% end %>
-<div class="pure-g h-box">
- <div class="pure-u-2-3">
- <h3><%= title %></h3>
+<div class="h-box flexible title">
+ <div class="flex-left"><h3><%= title %></h3></div>
+
+ <div class="flex-right button-container">
+ <%- if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email -%>
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/add_playlist_items?list=<%= plid %>">
+ <i class="icon ion-md-add"></i>&nbsp;<%= translate(locale, "playlist_button_add_items") %>
+ </a>
+ </div>
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/edit_playlist?list=<%= plid %>">
+ <i class="icon ion-md-create"></i>&nbsp;<%= translate(locale, "generic_button_edit") %>
+ </a>
+ </div>
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
+ <i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "generic_button_delete") %>
+ </a>
+ </div>
+ <%- else -%>
+ <div class="pure-u">
+ <%- if IV::Database::Playlists.exists?(playlist.id) -%>
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/subscribe_playlist?list=<%= plid %>">
+ <i class="icon ion-md-add"></i>&nbsp;<%= translate(locale, "Subscribe") %>
+ </a>
+ <%- else -%>
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
+ <i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "Unsubscribe") %>
+ </a>
+ <%- end -%>
+ </div>
+ <%- end -%>
+
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/feed/playlist/<%= plid %>">
+ <i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "generic_button_rss") %>
+ </a>
+ </div>
+ </div>
+</div>
+
+<div class="h-box">
+ <div class="pure-u-1-1">
<% if playlist.is_a? InvidiousPlaylist %>
<b>
<% if playlist.author == user.try &.email %>
@@ -54,37 +95,12 @@
</div>
<% end %>
</div>
- <div class="pure-u-1-3" style="text-align:right">
- <h3>
- <div class="pure-g user-field">
- <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
- <div class="pure-u-1-3"><a href="/edit_playlist?list=<%= plid %>"><i class="icon ion-md-create"></i></a></div>
- <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div>
- <% else %>
- <% if Invidious::Database::Playlists.exists?(playlist.id) %>
- <div class="pure-u-1-3"><a href="/subscribe_playlist?list=<%= plid %>"><i class="icon ion-md-add"></i></a></div>
- <% else %>
- <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div>
- <% end %>
- <% end %>
- <div class="pure-u-1-3"><a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a></div>
- </div>
- </h3>
- </div>
</div>
<div class="h-box">
<div id="descriptionWrapper"><%= playlist.description_html %></div>
</div>
-<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
-<div class="h-box" style="text-align:right">
- <h3>
- <a href="/add_playlist_items?list=<%= plid %>"><i class="icon ion-md-add"></i></a>
- </h3>
-</div>
-<% end %>
-
<div class="h-box">
<hr>
</div>
@@ -100,28 +116,5 @@
<script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% end %>
-<div class="pure-g">
-<% videos.each do |item| %>
- <%= rendered "components/item" %>
-<% end %>
-</div>
-
-<script src="/js/watched_indicator.js"></script>
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if page > 1 %>
- <a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if page_count != 1 && page < page_count %>
- <a href="/playlist?list=<%= playlist.id %>&page=<%= page + 1 %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%= rendered "components/items_paginated" %>
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) %>
<hr/>
-<div class="pure-g h-box v-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <%- if query.page > 1 -%>
- <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
- <%- end -%>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <%- if videos.size >= 20 -%>
- <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
- <%- end -%>
- </div>
-</div>
-<%- if videos.empty? -%>
+<%- if items.empty? -%>
<div class="h-box no-results-error">
<div>
<%= translate(locale, "search_message_no_results") %><br/><br/>
@@ -30,25 +17,5 @@
</div>
</div>
<%- else -%>
-<div class="pure-g">
- <%- videos.each do |item| -%>
- <%= rendered "components/item" %>
- <%- end -%>
-</div>
+ <%= rendered "components/items_paginated" %>
<%- end -%>
-
-<script src="/js/watched_indicator.js"></script>
-
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <%- if query.page > 1 -%>
- <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
- <%- end -%>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <%- if videos.size >= 20 -%>
- <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
- <%- end -%>
- </div>
-</div>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 5b3190f3..498d57a1 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -204,19 +204,28 @@ we're going to need to do it here in order to allow for translations.
</div>
<div class="pure-u-1 <% if params.related_videos || plid %>pure-u-lg-3-5<% else %>pure-u-md-4-5<% end %>">
- <div class="h-box">
- <a href="/channel/<%= video.ucid %>" style="display:block;width:fit-content;width:-moz-fit-content">
- <div class="channel-profile">
- <% if !video.author_thumbnail.empty? %>
- <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" />
- <% end %>
- <span id="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></span>
- </div>
- </a>
- <% sub_count_text = video.sub_count_text %>
- <%= rendered "components/subscribe_widget" %>
+ <div class="pure-g h-box flexible title">
+ <div class="pure-u-1-2 flex-left flexible">
+ <a href="/channel/<%= video.ucid %>">
+ <div class="channel-profile">
+ <% if !video.author_thumbnail.empty? %>
+ <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" />
+ <% end %>
+ <span id="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></span>
+ </div>
+ </a>
+ </div>
+ <div class="pure-u-1-2 flex-right flexible button-container">
+ <div class="pure-u">
+ <% sub_count_text = video.sub_count_text %>
+ <%= rendered "components/subscribe_widget" %>
+ </div>
+ </div>
+ </div>
+
+ <div class="h-box">
<p id="published-date">
<% if video.premiere_timestamp.try &.> Time.utc %>
<b><%= video.premiere_timestamp.try { |t| translate(locale, "Premieres `x`", t.to_s("%B %-d, %R UTC")) } %></b>
@@ -295,15 +304,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"]? %>
- <a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
+ <div class="pure-u-1">
+
+ <div class="thumbnail">
+ <%- if !env.get("preferences").as(Preferences).thin_mode -%>
+ <a tabindex="-1" href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>">
<img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg" alt="" />
- <p class="length"><%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %></p>
- </div>
- <% end %>
- <p style="width:100%"><%= rv["title"] %></p>
- </a>
+ </a>
+ <%- else -%>
+ <div class="thumbnail-placeholder"></div>
+ <%- end -%>
+
+ <div class="bottom-right-overlay">
+ <%- if (length_seconds = rv["length_seconds"]?.try &.to_i?) && length_seconds != 0 -%>
+ <p class="length"><%= recode_length_seconds(length_seconds) %></p>
+ <%- end -%>
+ </div>
+ </div>
+
+ <div class="video-card-row">
+ <a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>"><p dir="auto"><%= HTML.escape(rv["title"]) %></p></a>
+ </div>
+
<h5 class="pure-g">
<div class="pure-u-14-24">
<% if rv["ucid"]? %>
@@ -321,6 +343,8 @@ we're going to need to do it here in order to allow for translations.
%></b>
</div>
</h5>
+
+ </div>
<% end %>
<% end %>
</div>
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..aaf7772e 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)
@@ -210,6 +211,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 +459,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 +472,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 +659,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
+
+ return raw_items
+ end
- items_container["items"]?.try &.as_a.each do |item|
+ 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..aef9ddd9 100644
--- a/src/invidious/yt_backend/youtube_api.cr
+++ b/src/invidious/yt_backend/youtube_api.cr
@@ -595,17 +595,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