Compare commits

...

74 Commits

Author SHA1 Message Date
Samantaz Fox
81fdf7b089
WIP 2024-07-25 22:26:08 +02:00
Samantaz Fox
d8dee8e767
Use new cache system for 'Video' objects 2024-07-25 22:26:08 +02:00
Samantaz Fox
c847e357f9
temp fix, while cacheable item is not integrated 2024-07-25 22:26:08 +02:00
Samantaz Fox
3a4ca20309
Misc: Add an utility function to for 'region' URL parameter 2024-07-25 22:26:08 +02:00
Samantaz Fox
8caa317c63
Cache: Create the base of the caching subsystem 2024-07-25 22:26:08 +02:00
Samantaz Fox
2d5575e036
Config: clean up the various converters 2024-07-25 22:26:08 +02:00
Samantaz Fox
a842f4358c
Config: Add scheme support to DBConfig 2024-07-25 22:26:08 +02:00
Samantaz Fox
eab6efc42a
Shards: Add required dependencies and update lock file 2024-07-25 22:26:08 +02:00
Samantaz Fox
325561e755
Channel: parse subscriber count and channel banner (#4785)
This PR adds support for parsing the newer channel header format
(banner + subscription parsing)

Before this change:
* 0 subscribers
* No banner image

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No associated open issue
2024-07-21 17:23:44 +02:00
Samantaz Fox
bad92093bf
Channels: Add sort options to streams (#4224) 2024-07-10 22:28:22 +02:00
Samantaz Fox
436a61e3bb
API: Fix error code for disabled popular endpoint (#4296)
When visiting /api/v1/popular and popular endpoint is disabled
Before:

500 {"error":"Closed stream"}

After

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,6 +42,24 @@ db:
#########################################
#
# Cache configuration
#
#########################################
cache:
##
## URL of the caching server. To not use a caching server,
## set to an empty string or leave empty.
##
## Note: The same "long" format as the 'db' parameter is
## also supported.
##
url: ""
######################################### #########################################
# #
# Server config # Server config

View File

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

View File

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

View File

@ -2,7 +2,7 @@ version: 2.0
shards: shards:
ameba: ameba:
git: https://github.com/crystal-ameba/ameba.git git: https://github.com/crystal-ameba/ameba.git
version: 1.5.0 version: 1.6.1
athena-negotiation: athena-negotiation:
git: https://github.com/athena-framework/negotiation.git git: https://github.com/athena-framework/negotiation.git
@ -32,6 +32,10 @@ shards:
git: https://github.com/will/crystal-pg.git git: https://github.com/will/crystal-pg.git
version: 0.24.0 version: 0.24.0
pool:
git: https://github.com/ysbaddaden/pool.git
version: 0.2.4
protodec: protodec:
git: https://github.com/iv-org/protodec.git git: https://github.com/iv-org/protodec.git
version: 0.1.5 version: 0.1.5
@ -40,6 +44,10 @@ shards:
git: https://github.com/luislavena/radix.git git: https://github.com/luislavena/radix.git
version: 0.4.1 version: 0.4.1
redis:
git: https://github.com/stefanwille/crystal-redis.git
version: 2.8.3
spectator: spectator:
git: https://github.com/icy-arctic-fox/spectator.git git: https://github.com/icy-arctic-fox/spectator.git
version: 0.10.4 version: 0.10.4

View File

@ -10,32 +10,42 @@ targets:
main: src/invidious.cr main: src/invidious.cr
dependencies: dependencies:
# Database
pg: pg:
github: will/crystal-pg github: will/crystal-pg
version: ~> 0.24.0 version: ~> 0.24.0
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
version: ~> 0.18.0 version: ~> 0.18.0
# Web server
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
version: ~> 1.1.2 version: ~> 1.1.2
kilt: kilt:
github: jeromegn/kilt github: jeromegn/kilt
version: ~> 0.6.1 version: ~> 0.6.1
protodec:
github: iv-org/protodec
version: ~> 0.1.5
athena-negotiation: athena-negotiation:
github: athena-framework/negotiation github: athena-framework/negotiation
version: ~> 0.1.1 version: ~> 0.1.1
# Youtube backend
protodec:
github: iv-org/protodec
version: ~> 0.1.5
# Caching
redis:
github: stefanwille/crystal-redis
version: ~> 2.8.3
development_dependencies: development_dependencies:
spectator: spectator:
github: icy-arctic-fox/spectator github: icy-arctic-fox/spectator
version: ~> 0.10.4 version: ~> 0.10.4
ameba: ameba:
github: crystal-ameba/ameba github: crystal-ameba/ameba
version: ~> 1.5.0 version: ~> 1.6.1
crystal: ">= 1.0.0, < 2.0.0" crystal: ">= 1.0.0, < 2.0.0"

View File

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

View File

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

36
src/invidious/cache.cr Normal file
View File

@ -0,0 +1,36 @@
require "./cache/*"
module Invidious::Cache
extend self
private INSTANCE = self.init(CONFIG.cache)
def init(cfg : Config::CacheConfig) : ItemStore
# Environment variable takes precedence over local config
url = ENV.fetch("INVIDIOUS_CACHE_URL", nil).try { |u| URI.parse(u) }
url ||= cfg.url
url ||= URI.new
# Determine cache type from URL scheme
type = StoreType.parse?(url.scheme || "none") || StoreType::None
case type
when .none?
return NullItemStore.new
when .redis?
if url.nil?
raise InvalidConfigException.new "Redis cache requires an URL."
end
return RedisItemStore.new(url)
else
raise InvalidConfigException.new "Invalid cache url. Only redis:// URL are currently supported."
end
end
# Shortcut methods to not have to specify INSTANCE everywhere in the code
{% for method in ["fetch", "store", "delete", "clear"] %}
def {{method.id}}(*args, **kwargs)
INSTANCE.{{method.id}}(*args, **kwargs)
end
{% end %}
end

22
src/invidious/cache/item_store.cr vendored Normal file
View File

@ -0,0 +1,22 @@
require "./cacheable_item"
module Invidious::Cache
# Abstract class from which any cached element should inherit
# Note: class is used here, instead of a module, in order to benefit
# from various compiler checks (e.g methods must be implemented)
abstract class ItemStore
# Retrieves an item from the store
# Returns nil if item wasn't found or is expired
abstract def fetch(key : String)
# Stores a given item into cache
abstract def store(key : String, value : String, expires : Time::Span)
# Prematurely deletes item(s) from the cache
abstract def delete(key : String)
abstract def delete(keys : Array(String))
# Removes all the items stored in the cache
abstract def clear
end
end

24
src/invidious/cache/null_item_store.cr vendored Normal file
View File

@ -0,0 +1,24 @@
require "./item_store"
module Invidious::Cache
class NullItemStore < ItemStore
def initialize
end
def fetch(key : String) : String?
return nil
end
def store(key : String, value : String, expires : Time::Span)
end
def delete(key : String)
end
def delete(keys : Array(String))
end
def clear
end
end
end

33
src/invidious/cache/redis_item_store.cr vendored Normal file
View File

@ -0,0 +1,33 @@
require "./item_store"
require "json"
require "redis"
module Invidious::Cache
class RedisItemStore < ItemStore
@redis : Redis::PooledClient
def initialize(url : URI)
@redis = Redis::PooledClient.new(url: url.to_s)
end
def fetch(key : String) : String?
return @redis.get(key)
end
def store(key : String, value : String, expires : Time::Span)
@redis.set(key, value, ex: expires.to_i)
end
def delete(key : String)
@redis.del(key)
end
def delete(keys : Array(String))
@redis.del(keys)
end
def clear
@redis.flushdb
end
end
end

6
src/invidious/cache/store_type.cr vendored Normal file
View File

@ -0,0 +1,6 @@
module Invidious::Cache
enum StoreType
None
Redis
end
end

View File

@ -72,6 +72,7 @@ def get_about_info(ucid, locale) : AboutChannel
# Raises a KeyError on failure. # Raises a KeyError on failure.
banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources")
banner = banners.try &.[-1]?.try &.["url"].as_s? banner = banners.try &.[-1]?.try &.["url"].as_s?
# if banner.includes? "channels/c4/default_banner" # if banner.includes? "channels/c4/default_banner"
@ -147,9 +148,17 @@ def get_about_info(ucid, locale) : AboutChannel
end end
end end
sub_count = initdata sub_count = 0
.dig?("header", "c4TabbedHeaderRenderer", "subscriberCountText", "simpleText").try &.as_s?
.try { |text| short_text_to_number(text.split(" ")[0]).to_i32 } || 0 if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a)
metadata_rows.each do |row|
metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") }
if !metadata_part.nil?
sub_count = short_text_to_number(metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32
end
break if sub_count != 0
end
end
AboutChannel.new( AboutChannel.new(
ucid: ucid, ucid: ucid,

View File

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

View File

@ -1,12 +1,5 @@
struct DBConfig require "yaml"
include YAML::Serializable require "./config/*"
property user : String
property password : String
property host : String
property port : Int32
property dbname : String
end
struct ConfigPreferences struct ConfigPreferences
include YAML::Serializable include YAML::Serializable
@ -60,7 +53,7 @@ class Config
# Number of threads to use for crawling videos from channels (for updating subscriptions) # Number of threads to use for crawling videos from channels (for updating subscriptions)
property channel_threads : Int32 = 1 property channel_threads : Int32 = 1
# Time interval between two executions of the job that crawls channel videos (subscriptions update). # Time interval between two executions of the job that crawls channel videos (subscriptions update).
@[YAML::Field(converter: Preferences::TimeSpanConverter)] @[YAML::Field(converter: IV::Config::TimeSpanConverter)]
property channel_refresh_interval : Time::Span = 30.minutes property channel_refresh_interval : Time::Span = 30.minutes
# Number of threads to use for updating feeds # Number of threads to use for updating feeds
property feed_threads : Int32 = 1 property feed_threads : Int32 = 1
@ -69,10 +62,10 @@ class Config
# Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
property log_level : LogLevel = LogLevel::Info property log_level : LogLevel = LogLevel::Info
# Database configuration with separate parameters (username, hostname, etc) # Database configuration with separate parameters (username, hostname, etc)
property db : DBConfig? = nil property db : IV::Config::DBConfig? = nil
# Database configuration using 12-Factor "Database URL" syntax # Database configuration using 12-Factor "Database URL" syntax
@[YAML::Field(converter: Preferences::URIConverter)] @[YAML::Field(converter: IV::Config::URIConverter)]
property database_url : URI = URI.parse("") property database_url : URI = URI.parse("")
# Use polling to keep decryption function up to date # Use polling to keep decryption function up to date
property decrypt_polling : Bool = false property decrypt_polling : Bool = false
@ -81,6 +74,8 @@ class Config
# Jobs config structure. See jobs.cr and jobs/base_job.cr # Jobs config structure. See jobs.cr and jobs/base_job.cr
property jobs = Invidious::Jobs::JobsConfig.new property jobs = Invidious::Jobs::JobsConfig.new
# Cache configuration. See cache/cache.cr
property cache = Invidious::Config::CacheConfig.new
# Used to tell Invidious it is behind a proxy, so links to resources should be https:// # Used to tell Invidious it is behind a proxy, so links to resources should be https://
property https_only : Bool? property https_only : Bool?
@ -118,8 +113,9 @@ class Config
property modified_source_code_url : String? = nil property modified_source_code_url : String? = nil
# Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
@[YAML::Field(converter: Preferences::FamilyConverter)] @[YAML::Field(converter: IV::Config::FamilyConverter)]
property force_resolve : Socket::Family = Socket::Family::UNSPEC property force_resolve : Socket::Family = Socket::Family::UNSPEC
# Port to listen for connections (overridden by command line argument) # Port to listen for connections (overridden by command line argument)
property port : Int32 = 3000 property port : Int32 = 3000
# Host to bind (overridden by command line argument) # Host to bind (overridden by command line argument)
@ -131,7 +127,7 @@ class Config
property use_innertube_for_captions : Bool = false property use_innertube_for_captions : Bool = false
# Saved cookies in "name1=value1; name2=value2..." format # Saved cookies in "name1=value1; name2=value2..." format
@[YAML::Field(converter: Preferences::StringToCookies)] @[YAML::Field(converter: IV::Config::CookiesConverter)]
property cookies : HTTP::Cookies = HTTP::Cookies.new property cookies : HTTP::Cookies = HTTP::Cookies.new
# Playlist length limit # Playlist length limit
@ -214,14 +210,8 @@ class Config
# Build database_url from db.* if it's not set directly # Build database_url from db.* if it's not set directly
if config.database_url.to_s.empty? if config.database_url.to_s.empty?
if db = config.db if db = config.db
config.database_url = URI.new( db.scheme = "postgres"
scheme: "postgres", config.database_url = db.to_uri
user: db.user,
password: db.password,
host: db.host,
port: db.port,
path: db.dbname,
)
else else
puts "Config: Either database_url or db.* is required" puts "Config: Either database_url or db.* is required"
exit(1) exit(1)

View File

@ -0,0 +1,14 @@
require "../cache/store_type"
module Invidious::Config
struct CacheConfig
include YAML::Serializable
@[YAML::Field(converter: IV::Config::URIConverter)]
property url : URI? = URI.new
# Required because of YAML serialization
def initialize
end
end
end

View File

@ -0,0 +1,74 @@
module Invidious::Config
module CookiesConverter
def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder)
(value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml)
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies
unless node.is_a?(YAML::Nodes::Scalar)
node.raise "Expected scalar, not #{node.class}"
end
cookies = HTTP::Cookies.new
node.value.split(";").each do |cookie|
next if cookie.strip.empty?
name, value = cookie.split("=", 2)
cookies << HTTP::Cookie.new(name.strip, value.strip)
end
return cookies
end
end
module FamilyConverter
def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder)
case value
when Socket::Family::UNSPEC then yaml.scalar nil
when Socket::Family::INET then yaml.scalar "ipv4"
when Socket::Family::INET6 then yaml.scalar "ipv6"
when Socket::Family::UNIX then raise "Invalid socket family #{value}"
end
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family
if node.is_a?(YAML::Nodes::Scalar)
case node.value.downcase
when "ipv4" then Socket::Family::INET
when "ipv6" then Socket::Family::INET6
else
Socket::Family::UNSPEC
end
else
node.raise "Expected scalar, not #{node.class}"
end
end
end
module URIConverter
def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder)
yaml.scalar value.normalize!
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI
if node.is_a?(YAML::Nodes::Scalar)
URI.parse node.value
else
node.raise "Expected scalar, not #{node.class}"
end
end
end
module TimeSpanConverter
def self.to_yaml(value : Time::Span, yaml : YAML::Nodes::Builder)
return yaml.scalar value.total_minutes.to_i32
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Span
if node.is_a?(YAML::Nodes::Scalar)
return decode_interval(node.value)
else
node.raise "Expected scalar, not #{node.class}"
end
end
end
end

View File

@ -0,0 +1,23 @@
module Invidious::Config
struct DBConfig
include YAML::Serializable
property scheme : String
property user : String
property password : String
property host : String
property port : Int32
property dbname : String
def to_uri
return URI.new(
scheme: @scheme,
user: @user,
password: @password,
host: @host,
port: @port,
path: @dbname,
)
end
end
end

View File

@ -18,7 +18,6 @@ module Invidious::Database
Invidious::Database.check_table("nonces", Nonce) Invidious::Database.check_table("nonces", Nonce)
Invidious::Database.check_table("session_ids", SessionId) Invidious::Database.check_table("session_ids", SessionId)
Invidious::Database.check_table("users", User) Invidious::Database.check_table("users", User)
Invidious::Database.check_table("videos", Video)
if cfg.cache_annotations if cfg.cache_annotations
Invidious::Database.check_table("annotations", Annotation) Invidious::Database.check_table("annotations", Annotation)

View File

@ -38,3 +38,7 @@ end
# some important informations, and that the query should be sent again. # some important informations, and that the query should be sent again.
class RetryOnceException < Exception class RetryOnceException < Exception
end end
# Exception used to indicate that the config file contains some errors
class InvalidConfigException < Exception
end

View File

@ -74,7 +74,7 @@ def create_notification_stream(env, topics, connection_channel)
published = Time.utc - Time::Span.new(days: time_span[0], hours: time_span[1], minutes: time_span[2], seconds: time_span[3]) published = Time.utc - Time::Span.new(days: time_span[0], hours: time_span[1], minutes: time_span[2], seconds: time_span[3])
video_id = TEST_IDS[rand(TEST_IDS.size)] video_id = TEST_IDS[rand(TEST_IDS.size)]
video = get_video(video_id) video = Video.get(video_id)
video.published = published video.published = published
response = JSON.parse(video.to_json(locale, nil)) response = JSON.parse(video.to_json(locale, nil))
@ -133,7 +133,7 @@ def create_notification_stream(env, topics, connection_channel)
next next
end end
video = get_video(video_id) video = Video.get(video_id)
video.published = Time.unix(published) video.published = Time.unix(published)
response = JSON.parse(video.to_json(locale, nil)) response = JSON.parse(video.to_json(locale, nil))

View File

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

View File

@ -114,25 +114,31 @@ module Invidious::JSONify::APIv1
json.field "projectionType", fmt["projectionType"] json.field "projectionType", fmt["projectionType"]
if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) height = fmt["height"]?.try &.as_i
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 width = fmt["width"]?.try &.as_i
fps = fmt["fps"]?.try &.as_i
if fps
json.field "fps", fps json.field "fps", fps
end
if height && width
json.field "size", "#{width}x#{height}"
json.field "resolution", "#{height}p"
quality_label = "#{width > height ? height : width}p"
if fps && fps > 30
quality_label += fps.to_s
end
json.field "qualityLabel", quality_label
end
if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
json.field "container", fmt_info["ext"] json.field "container", fmt_info["ext"]
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
if fmt_info["height"]?
json.field "resolution", "#{fmt_info["height"]}p"
quality_label = "#{fmt_info["height"]}p"
if fps > 30
quality_label += "60"
end
json.field "qualityLabel", quality_label
if fmt_info["width"]?
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
end
end
end end
# Livestream chunk infos # Livestream chunk infos
@ -163,26 +169,31 @@ module Invidious::JSONify::APIv1
json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]?
fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) height = fmt["height"]?.try &.as_i
if fmt_info width = fmt["width"]?.try &.as_i
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
fps = fmt["fps"]?.try &.as_i
if fps
json.field "fps", fps json.field "fps", fps
end
if height && width
json.field "size", "#{width}x#{height}"
json.field "resolution", "#{height}p"
quality_label = "#{width > height ? height : width}p"
if fps && fps > 30
quality_label += fps.to_s
end
json.field "qualityLabel", quality_label
end
if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
json.field "container", fmt_info["ext"] json.field "container", fmt_info["ext"]
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
if fmt_info["height"]?
json.field "resolution", "#{fmt_info["height"]}p"
quality_label = "#{fmt_info["height"]}p"
if fps > 30
quality_label += "60"
end
json.field "qualityLabel", quality_label
if fmt_info["width"]?
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
end
end
end end
end end
end end

View File

@ -366,6 +366,8 @@ def fetch_playlist(plid : String)
if text.includes? "video" if text.includes? "video"
video_count = text.gsub(/\D/, "").to_i? || 0 video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "episode"
video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "view" elsif text.includes? "view"
views = text.gsub(/\D/, "").to_i64? || 0_i64 views = text.gsub(/\D/, "").to_i64? || 0_i64
else else

View File

@ -6,14 +6,14 @@ module Invidious::Routes::API::Manifest
local = env.params.query["local"]?.try &.== "true" local = env.params.query["local"]?.try &.== "true"
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
# Since some implementations create playlists based on resolution regardless of different codecs, # Since some implementations create playlists based on resolution regardless of different codecs,
# we can opt to only add a source to a representation if it has a unique height within that representation # we can opt to only add a source to a representation if it has a unique height within that representation
unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }
begin begin
video = get_video(id, region: region) video = Video.get(id, region: region)
rescue ex : NotFoundException rescue ex : NotFoundException
haltf env, status_code: 404 haltf env, status_code: 404
rescue ex rescue ex

View File

@ -314,7 +314,7 @@ module Invidious::Routes::API::V1::Authenticated
end end
begin begin
video = get_video(video_id) video = Video.get(video_id)
rescue ex : NotFoundException rescue ex : NotFoundException
return error_json(404, ex) return error_json(404, ex)
rescue ex rescue ex

View File

@ -208,11 +208,12 @@ module Invidious::Routes::API::V1::Channels
get_channel() get_channel()
# Retrieve continuation from URL parameters # Retrieve continuation from URL parameters
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
continuation = env.params.query["continuation"]? continuation = env.params.query["continuation"]?
begin begin
videos, next_continuation = Channel::Tabs.get_60_livestreams( videos, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation channel, continuation: continuation, sort_by: sort_by
) )
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
@ -430,7 +431,7 @@ module Invidious::Routes::API::V1::Channels
def self.search(env) def self.search(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
env.response.content_type = "application/json" env.response.content_type = "application/json"

View File

@ -4,7 +4,7 @@ module Invidious::Routes::API::V1::Feeds
env.response.content_type = "application/json" env.response.content_type = "application/json"
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
trending_type = env.params.query["type"]? trending_type = env.params.query["type"]?
begin begin
@ -31,7 +31,7 @@ module Invidious::Routes::API::V1::Feeds
if !CONFIG.popular_enabled if !CONFIG.popular_enabled
error_message = {"error" => "Administrator has disabled this endpoint."}.to_json error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
haltf env, 400, error_message haltf env, 403, error_message
end end
JSON.build do |json| JSON.build do |json|

View File

@ -74,7 +74,9 @@ module Invidious::Routes::API::V1::Misc
response = playlist.to_json(offset, video_id: video_id) response = playlist.to_json(offset, video_id: video_id)
json_response = JSON.parse(response) json_response = JSON.parse(response)
if json_response["videos"].as_a[0]["index"] != offset if json_response["videos"].as_a.empty?
json_response = JSON.parse(response)
elsif json_response["videos"].as_a[0]["index"] != offset
offset = json_response["videos"].as_a[0]["index"].as_i offset = json_response["videos"].as_a[0]["index"].as_i
lookback = offset < 50 ? offset : 50 lookback = offset < 50 ? offset : 50
response = playlist.to_json(offset - lookback) response = playlist.to_json(offset - lookback)

View File

@ -1,7 +1,7 @@
module Invidious::Routes::API::V1::Search module Invidious::Routes::API::V1::Search
def self.search(env) def self.search(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -24,7 +24,7 @@ module Invidious::Routes::API::V1::Search
def self.search_suggestions(env) def self.search_suggestions(env)
preferences = env.get("preferences").as(Preferences) preferences = env.get("preferences").as(Preferences)
region = env.params.query["region"]? || preferences.region region = find_region(env.params.query["region"]?) || preferences.region
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -65,7 +65,7 @@ module Invidious::Routes::API::V1::Search
page = env.params.query["page"]?.try &.to_i? || 1 page = env.params.query["page"]?.try &.to_i? || 1
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
env.response.content_type = "application/json" env.response.content_type = "application/json"
begin begin

View File

@ -5,11 +5,11 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "application/json" env.response.content_type = "application/json"
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
proxy = {"1", "true"}.any? &.== env.params.query["local"]? proxy = {"1", "true"}.any? &.== env.params.query["local"]?
begin begin
video = get_video(id, region: region) video = Video.get(id, region: region)
rescue ex : NotFoundException rescue ex : NotFoundException
return error_json(404, ex) return error_json(404, ex)
rescue ex rescue ex
@ -25,7 +25,7 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "application/json" env.response.content_type = "application/json"
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? || env.params.body["region"]? region = find_region(env.params.query["region"]? || env.params.body["region"]?)
if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/) if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/)
return error_json(400, "Invalid video ID") return error_json(400, "Invalid video ID")
@ -40,7 +40,7 @@ module Invidious::Routes::API::V1::Videos
# getting video info. # getting video info.
begin begin
video = get_video(id, region: region) video = Video.get(id, region: region)
rescue ex : NotFoundException rescue ex : NotFoundException
haltf env, 404 haltf env, 404
rescue ex rescue ex
@ -89,9 +89,14 @@ module Invidious::Routes::API::V1::Videos
if CONFIG.use_innertube_for_captions if CONFIG.use_innertube_for_captions
params = Invidious::Videos::Transcript.generate_param(id, caption.language_code, caption.auto_generated) params = Invidious::Videos::Transcript.generate_param(id, caption.language_code, caption.auto_generated)
initial_data = YoutubeAPI.get_transcript(params)
webvtt = Invidious::Videos::Transcript.convert_transcripts_to_vtt(initial_data, caption.language_code) transcript = Invidious::Videos::Transcript.from_raw(
YoutubeAPI.get_transcript(params),
caption.language_code,
caption.auto_generated
)
webvtt = transcript.to_vtt
else else
# Timedtext API handling # Timedtext API handling
url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target
@ -136,7 +141,11 @@ module Invidious::Routes::API::V1::Videos
end end
end end
else else
webvtt = YT_POOL.client &.get("#{url}&fmt=vtt").body uri = URI.parse(url)
query_params = uri.query_params
query_params["fmt"] = "vtt"
uri.query_params = query_params
webvtt = YT_POOL.client &.get(uri.request_target).body
if webvtt.starts_with?("<?xml") if webvtt.starts_with?("<?xml")
webvtt = caption.timedtext_to_vtt(webvtt) webvtt = caption.timedtext_to_vtt(webvtt)
@ -168,10 +177,10 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "application/json" env.response.content_type = "application/json"
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
begin begin
video = get_video(id, region: region) video = Video.get(id, region: region)
rescue ex : NotFoundException rescue ex : NotFoundException
haltf env, 404 haltf env, 404
rescue ex rescue ex
@ -297,7 +306,7 @@ module Invidious::Routes::API::V1::Videos
def self.comments(env) def self.comments(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
env.response.content_type = "application/json" env.response.content_type = "application/json"

View File

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

View File

@ -81,13 +81,12 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}" return env.redirect "/channel/#{channel.ucid}"
end end
# TODO: support sort option for livestreams sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
sort_by = "" sort_options = {"newest", "oldest", "popular"}
sort_options = [] of String
# Fetch items and continuation token # Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_60_livestreams( items, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation channel, continuation: continuation, sort_by: sort_by
) )
selected_tab = Frontend::ChannelPage::TabsAvailable::Streams selected_tab = Frontend::ChannelPage::TabsAvailable::Streams

View File

@ -130,7 +130,7 @@ module Invidious::Routes::Embed
subscriptions ||= [] of String subscriptions ||= [] of String
begin begin
video = get_video(id, region: params.region) video = Video.get(id, region: params.region)
rescue ex : NotFoundException rescue ex : NotFoundException
return error_template(404, ex) return error_template(404, ex)
rescue ex rescue ex

View File

@ -48,7 +48,7 @@ module Invidious::Routes::Feeds
trending_type = env.params.query["type"]? trending_type = env.params.query["type"]?
trending_type ||= "Default" trending_type ||= "Default"
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
region ||= env.get("preferences").as(Preferences).region region ||= env.get("preferences").as(Preferences).region
begin begin
@ -420,7 +420,7 @@ module Invidious::Routes::Feeds
updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content)
begin begin
video = get_video(id, force_refresh: true) video = Video.get(id, force_refresh: true)
rescue rescue
next # skip this video since it raised an exception (e.g. it is a scheduled live event) next # skip this video since it raised an exception (e.g. it is a scheduled live event)
end end

View File

@ -228,7 +228,7 @@ module Invidious::Routes::Playlists
prefs = env.get("preferences").as(Preferences) prefs = env.get("preferences").as(Preferences)
locale = prefs.locale locale = prefs.locale
region = env.params.query["region"]? || prefs.region region = find_region(env.params.query["region"]?) || prefs.region
user = env.get? "user" user = env.get? "user"
sid = env.get? "sid" sid = env.get? "sid"
@ -352,7 +352,7 @@ module Invidious::Routes::Playlists
video_id = env.params.query["video_id"] video_id = env.params.query["video_id"]
begin begin
video = get_video(video_id) video = Video.get(video_id)
rescue ex : NotFoundException rescue ex : NotFoundException
return error_json(404, ex) return error_json(404, ex)
rescue ex rescue ex

View File

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

View File

@ -40,7 +40,7 @@ module Invidious::Routes::Search
prefs = env.get("preferences").as(Preferences) prefs = env.get("preferences").as(Preferences)
locale = prefs.locale locale = prefs.locale
region = env.params.query["region"]? || prefs.region region = find_region(env.params.query["region"]?) || prefs.region
query = Invidious::Search::Query.new(env.params.query, :regular, region) query = Invidious::Search::Query.new(env.params.query, :regular, region)

View File

@ -9,7 +9,7 @@ module Invidious::Routes::VideoPlayback
mns ||= [] of String mns ||= [] of String
if query_params["region"]? if query_params["region"]?
region = query_params["region"] region = find_region(query_params["region"])
query_params.delete("region") query_params.delete("region")
end end
@ -265,7 +265,7 @@ module Invidious::Routes::VideoPlayback
return error_template(400, "Invalid itag") return error_template(400, "Invalid itag")
end end
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
local = (env.params.query["local"]? == "true") local = (env.params.query["local"]? == "true")
title = env.params.query["title"]? title = env.params.query["title"]?
@ -275,7 +275,7 @@ module Invidious::Routes::VideoPlayback
end end
begin begin
video = get_video(id, region: region) video = Video.get(id, region: region)
rescue ex : NotFoundException rescue ex : NotFoundException
return error_template(404, ex) return error_template(404, ex)
rescue ex rescue ex

View File

@ -3,7 +3,7 @@
module Invidious::Routes::Watch module Invidious::Routes::Watch
def self.handle(env) def self.handle(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]? region = find_region(env.params.query["region"]?)
if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+") if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+")
url = "/watch?" + env.params.query.to_s.gsub("%20", "").delete("+") url = "/watch?" + env.params.query.to_s.gsub("%20", "").delete("+")
@ -52,7 +52,7 @@ module Invidious::Routes::Watch
env.params.query.delete_all("listen") env.params.query.delete_all("listen")
begin begin
video = get_video(id, region: params.region) video = Video.get(id, region: params.region)
rescue ex : NotFoundException rescue ex : NotFoundException
LOGGER.error("get_video not found: #{id} : #{ex.message}") LOGGER.error("get_video not found: #{id} : #{ex.message}")
return error_template(404, ex) return error_template(404, ex)

View File

@ -59,7 +59,7 @@ struct Invidious::User
next if video_id == "Video Id" next if video_id == "Video Id"
begin begin
video = get_video(video_id) video = Video.get(video_id)
rescue ex rescue ex
next next
end end
@ -133,7 +133,7 @@ struct Invidious::User
next if !video_id next if !video_id
begin begin
video = get_video(video_id, false) video = Video.get(video_id)
rescue ex rescue ex
next next
end end

View File

@ -1,6 +1,5 @@
struct Preferences struct Preferences
include JSON::Serializable include JSON::Serializable
include YAML::Serializable
property annotations : Bool = CONFIG.default_user_preferences.annotations property annotations : Bool = CONFIG.default_user_preferences.annotations
property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed
@ -8,17 +7,14 @@ struct Preferences
property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect
@[JSON::Field(converter: Preferences::StringToArray)] @[JSON::Field(converter: Preferences::StringToArray)]
@[YAML::Field(converter: Preferences::StringToArray)]
property captions : Array(String) = CONFIG.default_user_preferences.captions property captions : Array(String) = CONFIG.default_user_preferences.captions
@[JSON::Field(converter: Preferences::StringToArray)] @[JSON::Field(converter: Preferences::StringToArray)]
@[YAML::Field(converter: Preferences::StringToArray)]
property comments : Array(String) = CONFIG.default_user_preferences.comments property comments : Array(String) = CONFIG.default_user_preferences.comments
property continue : Bool = CONFIG.default_user_preferences.continue property continue : Bool = CONFIG.default_user_preferences.continue
property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay
@[JSON::Field(converter: Preferences::BoolToString)] @[JSON::Field(converter: Preferences::BoolToString)]
@[YAML::Field(converter: Preferences::BoolToString)]
property dark_mode : String = CONFIG.default_user_preferences.dark_mode property dark_mode : String = CONFIG.default_user_preferences.dark_mode
property latest_only : Bool = CONFIG.default_user_preferences.latest_only property latest_only : Bool = CONFIG.default_user_preferences.latest_only
property listen : Bool = CONFIG.default_user_preferences.listen property listen : Bool = CONFIG.default_user_preferences.listen
@ -78,27 +74,6 @@ struct Preferences
end end
end end
end end
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
yaml.scalar value
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
unless node.is_a?(YAML::Nodes::Scalar)
node.raise "Expected scalar, not #{node.class}"
end
case node.value
when "true"
"dark"
when "false"
"light"
when ""
CONFIG.default_user_preferences.dark_mode
else
node.value
end
end
end end
module ClampInt module ClampInt
@ -109,58 +84,6 @@ struct Preferences
def self.from_json(value : JSON::PullParser) : Int32 def self.from_json(value : JSON::PullParser) : Int32
value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32 value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32
end end
def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder)
yaml.scalar value
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32
node.value.clamp(0, MAX_ITEMS_PER_PAGE)
end
end
module FamilyConverter
def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder)
case value
when Socket::Family::UNSPEC
yaml.scalar nil
when Socket::Family::INET
yaml.scalar "ipv4"
when Socket::Family::INET6
yaml.scalar "ipv6"
when Socket::Family::UNIX
raise "Invalid socket family #{value}"
end
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family
if node.is_a?(YAML::Nodes::Scalar)
case node.value.downcase
when "ipv4"
Socket::Family::INET
when "ipv6"
Socket::Family::INET6
else
Socket::Family::UNSPEC
end
else
node.raise "Expected scalar, not #{node.class}"
end
end
end
module URIConverter
def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder)
yaml.scalar value.normalize!
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI
if node.is_a?(YAML::Nodes::Scalar)
URI.parse node.value
else
node.raise "Expected scalar, not #{node.class}"
end
end
end end
module ProcessString module ProcessString
@ -171,14 +94,6 @@ struct Preferences
def self.from_json(value : JSON::PullParser) : String def self.from_json(value : JSON::PullParser) : String
HTML.escape(value.read_string[0, 100]) HTML.escape(value.read_string[0, 100])
end end
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
yaml.scalar value
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
HTML.escape(node.value[0, 100])
end
end end
module StringToArray module StringToArray
@ -202,73 +117,5 @@ struct Preferences
result result
end end
def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder)
yaml.sequence do
value.each do |element|
yaml.scalar element
end
end
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String)
begin
unless node.is_a?(YAML::Nodes::Sequence)
node.raise "Expected sequence, not #{node.class}"
end
result = [] of String
node.nodes.each do |item|
unless item.is_a?(YAML::Nodes::Scalar)
node.raise "Expected scalar, not #{item.class}"
end
result << HTML.escape(item.value[0, 100])
end
rescue ex
if node.is_a?(YAML::Nodes::Scalar)
result = [HTML.escape(node.value[0, 100]), ""]
else
result = ["", ""]
end
end
result
end
end
module StringToCookies
def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder)
(value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml)
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies
unless node.is_a?(YAML::Nodes::Scalar)
node.raise "Expected scalar, not #{node.class}"
end
cookies = HTTP::Cookies.new
node.value.split(";").each do |cookie|
next if cookie.strip.empty?
name, value = cookie.split("=", 2)
cookies << HTTP::Cookie.new(name.strip, value.strip)
end
cookies
end
end
module TimeSpanConverter
def self.to_yaml(value : Time::Span, yaml : YAML::Nodes::Builder)
return yaml.scalar value.total_minutes.to_i32
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Span
if node.is_a?(YAML::Nodes::Scalar)
return decode_interval(node.value)
else
node.raise "Expected scalar, not #{node.class}"
end
end
end end
end end

View File

@ -5,8 +5,6 @@ enum VideoType
end end
struct Video struct Video
include DB::Serializable
# Version of the JSON structure # Version of the JSON structure
# It prevents us from loading an incompatible version from cache # It prevents us from loading an incompatible version from cache
# (either newer or older, if instances with different versions run # (either newer or older, if instances with different versions run
@ -16,23 +14,16 @@ struct Video
# the `params` structure in videos/parser.cr!!! # the `params` structure in videos/parser.cr!!!
# #
SCHEMA_VERSION = 2 SCHEMA_VERSION = 2
CACHE_KEY = "video_v#{SCHEMA_VERSION}"
property id : String property id : String
@[DB::Field(converter: Video::JSONConverter)]
property info : Hash(String, JSON::Any) property info : Hash(String, JSON::Any)
property updated : Time
@[DB::Field(ignore: true)]
@captions = [] of Invidious::Videos::Captions::Metadata @captions = [] of Invidious::Videos::Captions::Metadata
@[DB::Field(ignore: true)]
property adaptive_fmts : Array(Hash(String, JSON::Any))? property adaptive_fmts : Array(Hash(String, JSON::Any))?
@[DB::Field(ignore: true)]
property fmt_stream : Array(Hash(String, JSON::Any))? property fmt_stream : Array(Hash(String, JSON::Any))?
@[DB::Field(ignore: true)]
property description : String? property description : String?
module JSONConverter module JSONConverter
@ -41,6 +32,45 @@ struct Video
end end
end end
# Create new object from cache (JSON)
def initialize(@id, @info)
end
def self.get(id : String, *, force_refresh = false, region = nil)
key = "#{CACHE_KEY}:#{id}"
key += ":#{region}" if !region.nil?
# Fetch video from cache, unles a force refresh is requested
info = force_refresh ? nil : IV::Cache::INSTANCE.fetch(key)
updated = false
# Fetch video from youtube, if needed
if info.nil?
video = Video.new(id, fetch_video(id, region))
updated = true
else
video = Video.new(id, JSON.parse(info).as_h)
# If the video has premiered or the live has started, refresh the data.
if (video.live_now && video.published < Time.utc)
video = Video.new(id, fetch_video(id, region))
updated = true
end
end
# Store updated entry in cache
# TODO: finer cache control based on video type & publication date
if updated
if video.live_now || video.published < Time.utc
IV::Cache::INSTANCE.store(key, info.to_json, 10.minutes)
else
IV::Cache::INSTANCE.store(key, info.to_json, 2.hours)
end
end
return Video.new(id, info)
end
# Methods for API v1 JSON # Methods for API v1 JSON
def to_json(locale : String?, json : JSON::Builder) def to_json(locale : String?, json : JSON::Builder)
@ -250,7 +280,7 @@ struct Video
end end
def genre_url : String? def genre_url : String?
info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil
end end
def is_vr : Bool? def is_vr : Bool?
@ -362,35 +392,6 @@ struct Video
getset_bool isUpcoming getset_bool isUpcoming
end end
def get_video(id, refresh = true, region = nil, force_refresh = false)
if (video = Invidious::Database::Videos.select(id)) && !region
# If record was last updated over 10 minutes ago, or video has since premiered,
# refresh (expire param in response lasts for 6 hours)
if (refresh &&
(Time.utc - video.updated > 10.minutes) ||
(video.premiere_timestamp.try &.< Time.utc)) ||
force_refresh ||
video.schema_version != Video::SCHEMA_VERSION # cache control
begin
video = fetch_video(id, region)
Invidious::Database::Videos.update(video)
rescue ex
Invidious::Database::Videos.delete(id)
raise ex
end
end
else
video = fetch_video(id, region)
Invidious::Database::Videos.insert(video) if !region
end
return video
rescue DB::Error
# Avoid common `DB::PoolRetryAttemptsExceeded` error and friends
# Note: All DB errors inherit from `DB::Error`
return fetch_video(id, region)
end
def fetch_video(id, region) def fetch_video(id, region)
info = extract_video_info(video_id: id) info = extract_video_info(video_id: id)
@ -408,13 +409,7 @@ def fetch_video(id, region)
end end
end end
video = Video.new({ return info
id: id,
info: info,
updated: Time.utc,
})
return video
end end
def process_continuation(query, plid, id) def process_continuation(query, plid, id)

View File

@ -424,7 +424,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
# Video metadata # Video metadata
"genre" => JSON::Any.new(genre.try &.as_s || ""), "genre" => JSON::Any.new(genre.try &.as_s || ""),
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?),
"license" => JSON::Any.new(license.try &.as_s || ""), "license" => JSON::Any.new(license.try &.as_s || ""),
# Music section # Music section
"music" => JSON.parse(music_list.to_json), "music" => JSON.parse(music_list.to_json),

View File

@ -25,3 +25,13 @@ REGIONS = {
"TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI",
"VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW",
} }
# Utility function that searches in the array above for a given input.
def find_region(reg : String?) : String?
return nil if reg.nil?
# Normalize input
region = (reg || "").upcase[0..1]
return REGIONS.find(&.== region)
end

View File

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

View File

@ -38,7 +38,9 @@ def process_video_params(query, preferences)
preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase) preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase)
quality = query["quality"]? quality = query["quality"]?
quality_dash = query["quality_dash"]? quality_dash = query["quality_dash"]?
region = query["region"]?
region = find_region(query["region"]?)
related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe } related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
speed = query["speed"]?.try &.rchop("x").to_f? speed = query["speed"]?.try &.rchop("x").to_f?
video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe } video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe }

View File

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

View File

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

View File

@ -10,7 +10,7 @@
<meta property="og:site_name" content="<%= author %> | Invidious"> <meta property="og:site_name" content="<%= author %> | Invidious">
<meta property="og:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>"> <meta property="og:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>">
<meta property="og:title" content="<%= title %>"> <meta property="og:title" content="<%= title %>">
<meta property="og:image" content="/vi/<%= video.id %>/maxres.jpg"> <meta property="og:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg">
<meta property="og:description" content="<%= HTML.escape(video.short_description) %>"> <meta property="og:description" content="<%= HTML.escape(video.short_description) %>">
<meta property="og:type" content="video.other"> <meta property="og:type" content="video.other">
<meta property="og:video:url" content="<%= HOST_URL %>/embed/<%= video.id %>"> <meta property="og:video:url" content="<%= HOST_URL %>/embed/<%= video.id %>">

View File

@ -5,9 +5,6 @@
module YoutubeAPI module YoutubeAPI
extend self extend self
private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
private ANDROID_API_KEY = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w"
# For Android versions, see https://en.wikipedia.org/wiki/Android_version_history # For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
private ANDROID_APP_VERSION = "19.14.42" private ANDROID_APP_VERSION = "19.14.42"
private ANDROID_USER_AGENT = "com.google.android.youtube/19.14.42 (Linux; U; Android 12; US) gzip" private ANDROID_USER_AGENT = "com.google.android.youtube/19.14.42 (Linux; U; Android 12; US) gzip"
@ -52,7 +49,6 @@ module YoutubeAPI
name: "WEB", name: "WEB",
name_proto: "1", name_proto: "1",
version: "2.20240304.00.00", version: "2.20240304.00.00",
api_key: DEFAULT_API_KEY,
screen: "WATCH_FULL_SCREEN", screen: "WATCH_FULL_SCREEN",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
@ -62,7 +58,6 @@ module YoutubeAPI
name: "WEB_EMBEDDED_PLAYER", name: "WEB_EMBEDDED_PLAYER",
name_proto: "56", name_proto: "56",
version: "1.20240303.00.00", version: "1.20240303.00.00",
api_key: DEFAULT_API_KEY,
screen: "EMBED", screen: "EMBED",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
@ -72,7 +67,6 @@ module YoutubeAPI
name: "MWEB", name: "MWEB",
name_proto: "2", name_proto: "2",
version: "2.20240304.08.00", version: "2.20240304.08.00",
api_key: DEFAULT_API_KEY,
os_name: "Android", os_name: "Android",
os_version: ANDROID_VERSION, os_version: ANDROID_VERSION,
platform: "MOBILE", platform: "MOBILE",
@ -81,7 +75,6 @@ module YoutubeAPI
name: "WEB", name: "WEB",
name_proto: "1", name_proto: "1",
version: "2.20240304.00.00", version: "2.20240304.00.00",
api_key: DEFAULT_API_KEY,
screen: "EMBED", screen: "EMBED",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
@ -94,7 +87,6 @@ module YoutubeAPI
name: "ANDROID", name: "ANDROID",
name_proto: "3", name_proto: "3",
version: ANDROID_APP_VERSION, version: ANDROID_APP_VERSION,
api_key: ANDROID_API_KEY,
android_sdk_version: ANDROID_SDK_VERSION, android_sdk_version: ANDROID_SDK_VERSION,
user_agent: ANDROID_USER_AGENT, user_agent: ANDROID_USER_AGENT,
os_name: "Android", os_name: "Android",
@ -105,13 +97,11 @@ module YoutubeAPI
name: "ANDROID_EMBEDDED_PLAYER", name: "ANDROID_EMBEDDED_PLAYER",
name_proto: "55", name_proto: "55",
version: ANDROID_APP_VERSION, version: ANDROID_APP_VERSION,
api_key: "AIzaSyCjc_pVEDi4qsv5MtC2dMXzpIaDoRFLsxw",
}, },
ClientType::AndroidScreenEmbed => { ClientType::AndroidScreenEmbed => {
name: "ANDROID", name: "ANDROID",
name_proto: "3", name_proto: "3",
version: ANDROID_APP_VERSION, version: ANDROID_APP_VERSION,
api_key: DEFAULT_API_KEY,
screen: "EMBED", screen: "EMBED",
android_sdk_version: ANDROID_SDK_VERSION, android_sdk_version: ANDROID_SDK_VERSION,
user_agent: ANDROID_USER_AGENT, user_agent: ANDROID_USER_AGENT,
@ -123,7 +113,6 @@ module YoutubeAPI
name: "ANDROID_TESTSUITE", name: "ANDROID_TESTSUITE",
name_proto: "30", name_proto: "30",
version: ANDROID_TS_APP_VERSION, version: ANDROID_TS_APP_VERSION,
api_key: ANDROID_API_KEY,
android_sdk_version: ANDROID_SDK_VERSION, android_sdk_version: ANDROID_SDK_VERSION,
user_agent: ANDROID_TS_USER_AGENT, user_agent: ANDROID_TS_USER_AGENT,
os_name: "Android", os_name: "Android",
@ -137,7 +126,6 @@ module YoutubeAPI
name: "IOS", name: "IOS",
name_proto: "5", name_proto: "5",
version: IOS_APP_VERSION, version: IOS_APP_VERSION,
api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc",
user_agent: IOS_USER_AGENT, user_agent: IOS_USER_AGENT,
device_make: "Apple", device_make: "Apple",
device_model: "iPhone14,5", device_model: "iPhone14,5",
@ -149,7 +137,6 @@ module YoutubeAPI
name: "IOS_MESSAGES_EXTENSION", name: "IOS_MESSAGES_EXTENSION",
name_proto: "66", name_proto: "66",
version: IOS_APP_VERSION, version: IOS_APP_VERSION,
api_key: DEFAULT_API_KEY,
user_agent: IOS_USER_AGENT, user_agent: IOS_USER_AGENT,
device_make: "Apple", device_make: "Apple",
device_model: "iPhone14,5", device_model: "iPhone14,5",
@ -161,7 +148,6 @@ module YoutubeAPI
name: "IOS_MUSIC", name: "IOS_MUSIC",
name_proto: "26", name_proto: "26",
version: "6.42", version: "6.42",
api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s",
user_agent: "com.google.ios.youtubemusic/6.42 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)", user_agent: "com.google.ios.youtubemusic/6.42 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)",
device_make: "Apple", device_make: "Apple",
device_model: "iPhone14,5", device_model: "iPhone14,5",
@ -176,13 +162,11 @@ module YoutubeAPI
name: "TVHTML5", name: "TVHTML5",
name_proto: "7", name_proto: "7",
version: "7.20240304.10.00", version: "7.20240304.10.00",
api_key: DEFAULT_API_KEY,
}, },
ClientType::TvHtml5ScreenEmbed => { ClientType::TvHtml5ScreenEmbed => {
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
name_proto: "85", name_proto: "85",
version: "2.0", version: "2.0",
api_key: DEFAULT_API_KEY,
screen: "EMBED", screen: "EMBED",
}, },
} }
@ -237,11 +221,6 @@ module YoutubeAPI
HARDCODED_CLIENTS[@client_type][:version] HARDCODED_CLIENTS[@client_type][:version]
end end
# :ditto:
def api_key : String
HARDCODED_CLIENTS[@client_type][:api_key]
end
# :ditto: # :ditto:
def screen : String def screen : String
HARDCODED_CLIENTS[@client_type][:screen]? || "" HARDCODED_CLIENTS[@client_type][:screen]? || ""
@ -606,7 +585,7 @@ module YoutubeAPI
client_config ||= DEFAULT_CLIENT_CONFIG client_config ||= DEFAULT_CLIENT_CONFIG
# Query parameters # Query parameters
url = "#{endpoint}?key=#{client_config.api_key}&prettyPrint=false" url = "#{endpoint}?prettyPrint=false"
headers = HTTP::Headers{ headers = HTTP::Headers{
"Content-Type" => "application/json; charset=UTF-8", "Content-Type" => "application/json; charset=UTF-8",