Compare commits

..
Author SHA1 Message Date
Emilien Devos 47ce8a726b limit feeds and delete materialized views 2026-06-09 08:42:53 +00:00
40 changed files with 253 additions and 1180 deletions
+44
View File
@@ -0,0 +1,44 @@
---
name: Bug report
about: Create a bug report to help us improve Invidious
title: '[Bug] '
labels: bug
assignees: ''
---
<!--
BEFORE TRYING TO REPORT A BUG:
* Read the FAQ: https://docs.invidious.io/faq/!
* Use the search function to check if there is already an issue open for your problem: https://github.com/search?q=repo%3Aiv-org%2Finvidious+replace+me+with+your+bug&type=issues!
MAKE SURE TO FOLLOW THE TWO STEPS ABOVE BEFORE REPORTING A BUG. A BUG THAT ALREADY EXIST WILL IMMEDIATELY CLOSED.
If you want to suggest a new feature please use "Feature request" instead
If you want to suggest an enhancement to an existing feature please use "Enhancement" instead
-->
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
**Steps to Reproduce**
<!-- Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
-->
**Logs**
<!-- If applicable, copy the log that appear in the browser page where the error is reported. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Additional context**
<!-- Add any other context about the problem here.
- Browser (if applicable):
- OS (if applicable):
-->
-103
View File
@@ -1,103 +0,0 @@
name: Bug report
description: Create a bug report to help us improve Invidious
title: "[Bug] "
labels: ["bug"]
assignees: []
body:
- type: markdown
attributes:
value: |
BEFORE TRYING TO REPORT A BUG:
* Read the FAQ: https://docs.invidious.io/faq/
* Use the search function to check if there is already an issue open for your problem: https://github.com/search?q=repo%3Aiv-org%2Finvidious+replace+me+with+your+bug&type=issues
MAKE SURE TO FOLLOW THE TWO STEPS ABOVE BEFORE REPORTING A BUG. A BUG THAT ALREADY EXISTS WILL BE IMMEDIATELY CLOSED.
If you want to suggest a new feature, please use "Feature request" instead
If you want to suggest an enhancement to an existing feature, please use "Enhancement" instead
- type: textarea
id: describe-bug
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: If applicable, copy the logs that appear on the page where the error is reported.
render: shell
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.
placeholder: |
- Browser (if applicable):
- OS (if applicable):
validations:
required: false
- type: checkboxes
id: ai-policy
attributes:
label: AI Policy
options:
- label: I have read the [AI Policy](https://github.com/iv-org/invidious/blob/master/AI_POLICY.md) and understand the disclosure requirements
required: true
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Did you use AI to help create this issue?
options:
- AI was not used to create this issue
- AI was used to fully create this issue
- AI was used to partially create this issue (explain how)
validations:
required: true
- type: input
id: ai-models
attributes:
label: Model(s) used
description: If you used AI, list the EXACT model(s) used (and thinking/reasoning level if relevant). Leave empty if you did not use AI.
placeholder: "e.g. GLM-5.2, MiniMax-M3, DeepSeek V4 Pro, Kimi K2.7 Code..."
validations:
required: false
- type: input
id: ai-tools
attributes:
label: Tool(s) used
description: If you used AI, list the tool(s) used to interact with it. Leave empty if you did not use AI.
placeholder: "e.g. OpenCode, OpenChamber, Cline, Pi, Open WebUI..."
validations:
required: false
- type: textarea
id: ai-explanation
attributes:
label: How was AI used?
description: If you selected "AI was used to partially create this issue (explain how)" above, explain how AI was used here. Leave empty otherwise.
validations:
required: false
+24
View File
@@ -0,0 +1,24 @@
---
name: Enhancement
about: Suggest an enhancement for an existing feature
title: '[Enhancement] '
labels: enhancement
assignees: ''
---
<!-- Please use the search function to check if the desired function has already been requested by someone else -->
<!-- If you want to suggest a new feature please use "Feature request" instead -->
<!-- If you want to report a bug, please use "Bug report" instead -->
**Is your enhancement request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
**Additional context**
<!-- Add any other context or screenshots about the enhancement here. -->
-81
View File
@@ -1,81 +0,0 @@
name: Enhancement
description: Suggest an enhancement for an existing feature
title: "[Enhancement] "
labels: ["enhancement"]
assignees: []
body:
- type: markdown
attributes:
value: |
Please use the search function to check if the desired function has already been requested by someone else
If you want to suggest a new feature, please use "Feature request" instead
If you want to report a bug, please use "Bug report" instead
- type: textarea
id: problem
attributes:
label: Is your enhancement request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context or screenshots about the enhancement here.
validations:
required: false
- type: checkboxes
id: ai-policy
attributes:
label: AI Policy
options:
- label: I have read the [AI Policy](https://github.com/iv-org/invidious/blob/master/AI_POLICY.md) and understand the disclosure requirements
required: true
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Did you use AI to help create this issue?
options:
- AI was not used to create this issue
- AI was used to fully create this issue
- AI was used to partially create this issue (explain how)
validations:
required: true
- type: input
id: ai-models
attributes:
label: Model(s) used
description: If you used AI, list the EXACT model(s) used (and thinking/reasoning level if relevant). Leave empty if you did not use AI.
placeholder: "e.g. GLM-5.2, MiniMax-M3, DeepSeek V4 Pro, Kimi K2.7 Code..."
validations:
required: false
- type: input
id: ai-tools
attributes:
label: Tool(s) used
description: If you used AI, list the tool(s) used to interact with it. Leave empty if you did not use AI.
placeholder: "e.g. OpenCode, OpenChamber, Cline, Pi, Open WebUI..."
validations:
required: false
- type: textarea
id: ai-explanation
attributes:
label: How was AI used?
description: If you selected "AI was used to partially create this issue (explain how)" above, explain how AI was used here. Leave empty otherwise.
validations:
required: false
+24
View File
@@ -0,0 +1,24 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[Feature request] '
labels: feature-request
assignees: ''
---
<!-- Please use the search function to check if the desired function has already been requested by someone else -->
<!-- If you want to suggest an enhancement to an existing feature please use "Enhancement" instead -->
<!-- If you want to report a bug, please use "Bug report" instead -->
**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->
@@ -1,81 +0,0 @@
name: Feature request
description: Suggest an idea for this project
title: "[Feature request] "
labels: ["feature-request"]
assignees: []
body:
- type: markdown
attributes:
value: |
Please use the search function to check if the desired function has already been requested by someone else
If you want to suggest an enhancement to an existing feature, please use "Enhancement" instead
If you want to report a bug, please use "Bug report" instead
- type: textarea
id: problem
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: checkboxes
id: ai-policy
attributes:
label: AI Policy
options:
- label: I have read the [AI Policy](https://github.com/iv-org/invidious/blob/master/AI_POLICY.md) and understand the disclosure requirements
required: true
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Did you use AI to help create this issue?
options:
- AI was not used to create this issue
- AI was used to fully create this issue
- AI was used to partially create this issue (explain how)
validations:
required: true
- type: input
id: ai-models
attributes:
label: Model(s) used
description: If you used AI, list the EXACT model(s) used (and thinking/reasoning level if relevant). Leave empty if you did not use AI.
placeholder: "e.g. GLM-5.2, MiniMax-M3, DeepSeek V4 Pro, Kimi K2.7 Code..."
validations:
required: false
- type: input
id: ai-tools
attributes:
label: Tool(s) used
description: If you used AI, list the tool(s) used to interact with it. Leave empty if you did not use AI.
placeholder: "e.g. OpenCode, OpenChamber, Cline, Pi, Open WebUI..."
validations:
required: false
- type: textarea
id: ai-explanation
attributes:
label: How was AI used?
description: If you selected "AI was used to partially create this issue (explain how)" above, explain how AI was used here. Leave empty otherwise.
validations:
required: false
-27
View File
@@ -1,27 +0,0 @@
## Checklist
- [ ] I have read the [AI Policy](https://github.com/iv-org/invidious/blob/master/AI_POLICY.md) and understand the disclosure requirements
## AI Disclosure
<!-- Tick exactly one -->
- [ ] AI was not used to create this pull request
- [ ] AI was used to fully create this pull request
- [ ] AI was used to partially create this pull request
<!-- Leave the following section blank if you didn't use AI -->
**Model(s) used (and thinking/reasoning level if relevant):**
<!-- e.g. GLM-5.2, MiniMax-M3, DeepSeek V4 Pro, Kimi K2.7 Code... -->
**Tool(s) used:**
<!-- e.g. OpenCode, OpenChamber, Cline, Pi, Open WebUI... -->
**How was AI used?**
<!-- If you selected "AI was used to partially create this pull request" above, explain how AI was used here. Leave empty otherwise. -->
---
## Pull request description
@@ -86,7 +86,7 @@ jobs:
# https://github.com/marketplace/actions/docker-manifest-create-action
- name: Create and push manifest
uses: int128/docker-manifest-create-action@v2.25.0
uses: int128/docker-manifest-create-action@v2.21.0
with:
push: true
tags: quay.io/invidious/invidious:master
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
# https://github.com/marketplace/actions/docker-manifest-create-action
- name: Create and push manifest
uses: int128/docker-manifest-create-action@v2.25.0
uses: int128/docker-manifest-create-action@v2.21.0
with:
push: true
tags: quay.io/invidious/invidious:latest
+2 -2
View File
@@ -65,7 +65,7 @@ jobs:
crystal: ${{ matrix.crystal }}
- name: Cache Shards
uses: actions/cache@v6
uses: actions/cache@v5
with:
path: |
./lib
@@ -137,7 +137,7 @@ jobs:
crystal: latest
- name: Cache Shards
uses: actions/cache@v6
uses: actions/cache@v5
with:
path: |
./lib
-60
View File
@@ -1,60 +0,0 @@
name: Create new release
# Manually-triggered workflow that prepares a new Invidious release by running
# scripts/create-release.py. All the logic lives in that script (which can also
# be run by hand); this workflow only provides checkout, Python and secrets.
#
# The script computes the version, collects every merged PR since the last
# release, generates the changelog prose via an OpenAI-compatible API (any
# endpoint via OPENAI_BASE_URL; OpenRouter by default), bumps shard.yml +
# CHANGELOG.md, and opens a release PR. Tagging and publishing stay manual, as
# the release documentation requires maintainer review.
on:
workflow_dispatch:
inputs:
major:
description: "Major version. Empty = keep current. Bump only for breaking changes."
required: false
default: ""
patch:
description: "Patch number (increment for a 2nd release on the same UTC day)."
required: false
default: "0"
model:
description: "Model used to write the changelog."
required: false
default: "deepseek/deepseek-v4-pro"
base_url:
description: "OpenAI-compatible API base URL (default: OpenRouter)."
required: false
default: "https://openrouter.ai/api/v1"
permissions:
contents: write
pull-requests: write
jobs:
prepare-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: master
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Prepare release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ inputs.base_url }}
run: |
python3 scripts/create-release.py \
${{ inputs.major && format('--major {0}', inputs.major) || '' }} \
--patch "${{ inputs.patch }}" \
--model "${{ inputs.model }}"
-38
View File
@@ -1,38 +0,0 @@
Invidious is and has always been made by human first and foremost. However, things have changed recently with the rise of AI.
This document is going to explain everything that you need to know if you ever contribute in any way to Invidious using any kind of AI.
This document has been fully written, from scratch, by a Human.
# Motivation
Invidious is written in an obscure language: Crystal.
Because it is obscure the number of people knowing it is really low.
Because the amount of people knowing it is really low, the amount of contributors to Invidious is really low: the vast majority of contributions are done by the Invidious maintainers themselves.
Invidious is the biggest Crystal project that exists, bigger than Crystal itself [(yes, seriously)](https://shards.info/).
The problem of being the biggest software in an obscure language is that you're often effectively the first project to encounter a problem and because it's an obscure language, not a lot of libraries exist to make it easier for you, meaning, you usually have to make everything you need yourself.
This makes it so working on Invidious far harder than working on most open source projects because you are effectively not benefiting and not using any external libraries for the vast majority of things. Almost any time you need anything, you have to make it yourself, which overcomplicates everything.
We are aware that some people wont like this change and we might even end up on one of the "bad people" list, but we try to be reasonable. We ask that you, please, do not fork the project out of spite because of this new policy - let's not split the thin list of people able to contribute even thinner. Contributions are welcome and highly preferred to anything made by AI.
This policy comes from a place of *need* not from a place of *choice*.
# Policy
Now that AIs exists and have become *reasonably good*, we will tolerate people using them with reasons and knowledge, as long those rules are respected:
- **Any one using AI to report bugs or submit code MUST properly disclose it, this includes mentioning the name of the EXACT model used and the tools used to interact with it.**
- The Human using AI MUST properly check the output manually in addition to any automated check that may exist or may have been created, **this includes BOTH codes AND bug reports**.
- Any code submitted by a Human, written even partially by AI, is the responsibility of this Human - If it's malicious, broken, destructive or anything bad, the Human is the sole responsible.
- Any new code touching any of the actual functions of Invidious MUST BE thoroughly tested by the Human MANUALLY.
- Team members using AIs are strongly encouraged to wait for the review of another Human before merging anything.
- At any point [Human-in-the-loop](https://en.wikipedia.org/wiki/Human-in-the-loop) applies.
-139
View File
@@ -2,145 +2,6 @@
## vX.Y.0 (future)
## v2.20260723.0
### Wrap-up
This release hardens security, adds an instance option to disable API endpoints, and fixes YouTube backend changes that broke video metadata and playback. A cross-user playlist deletion vulnerability was closed, and videos are no longer incorrectly marked as upcoming.
Instance owners gain a new configuration flag to disable API endpoints that are easy to abuse, a reduced backoff in the channel refresh job when no errors occur, and an updated User-Agent header for YouTube requests. The OCI image bumps OpenSSL to 3.6.3, and the Docker image moves to Crystal 1.20.3.
Developer experience improves with the ability to release directly from GitHub Actions, enhanced issue/PR templates with an AI policy compliance field, and a new `AI_POLICY.md` document. Dependency bumps keep CI current with `int128/docker-manifest-create-action` 2.25.0 and `actions/cache` 6.
### New features & important changes
#### For Users
- No user-facing changes in this release.
#### For instance owners
- A new configuration option allows disabling API endpoints that are easy to abuse (#5630)
- The `RefreshChannelsJob` backoff is reduced when no errors occur, improving channel refresh efficiency (#5759)
- The User-Agent header for YouTube requests was updated to maintain compatibility (#5794)
- OpenSSL was updated to 3.6.3 in the OCI image (#5808)
- The Docker image now uses Crystal 1.20.3 (#5797)
#### For developers
- Releases can now be triggered directly from a GitHub Action workflow (#5823)
- Issue and PR templates were reworded and enhanced, and a field for AI policy compliance was added (#5804, #5803)
- An `AI_POLICY.md` document was created to clarify the project's stance on AI-generated contributions (#5791)
- CI dependencies were bumped: `int128/docker-manifest-create-action` to 2.25.0 and `actions/cache` to 6 (#5807, #5793)
### Bugs fixed
#### User-side
- A security issue that allowed playlist deletion across different users was fixed (#5790)
- Videos are no longer incorrectly labelled as `isUpcoming = true` due to a YouTube backend change (#5800)
- YouTube backend API changes that caused playback and metadata issues were addressed (#5818, #5819)
### Full list of pull requests merged since the last release (newest first)
* feat: add ability to release from github action (https://github.com/iv-org/invidious/pull/5823, by @unixfox)
* youtube backend api fix (https://github.com/iv-org/invidious/pull/5818, by @gabe4278)
* Hotfix - Fix YouTube change (https://github.com/iv-org/invidious/pull/5819, by @bunchy7ss)
* chore: update openssl to 3.6.3 in OCI (https://github.com/iv-org/invidious/pull/5808, by @Fijxu)
* chore(deps): bump int128/docker-manifest-create-action from 2.22.0 to 2.25.0 (https://github.com/iv-org/invidious/pull/5807, by @dependabot[bot])
* Various enhancements and rewording to the issues/PRs templates (https://github.com/iv-org/invidious/pull/5804, by @TheFrenchGhosty)
* Add a field for AI policy compliance in the issues/PRs templates (https://github.com/iv-org/invidious/pull/5803, by @TheFrenchGhosty)
* Fix videos being incorrectly labelled as isUpcoming = true (https://github.com/iv-org/invidious/pull/5800, by @absidue)
* chore(deps): bump 84codes/crystal from 1.20.2-alpine to 1.20.3-alpine in /docker (https://github.com/iv-org/invidious/pull/5797, by @dependabot[bot])
* Create AI_POLICY.md (https://github.com/iv-org/invidious/pull/5791, by @TheFrenchGhosty)
* Add option to disable easy to abuse API endpoints (https://github.com/iv-org/invidious/pull/5630, by @Fijxu)
* [RefreshChannelsJob] Reduce backoff if no errors occur (https://github.com/iv-org/invidious/pull/5759, by @unrealtournament)
* fix: security issue playlist deletion cross user (https://github.com/iv-org/invidious/pull/5790, by @unixfox)
* chore: update User-Agent header for Youtube requests (https://github.com/iv-org/invidious/pull/5794, by @Fijxu)
* chore(deps): bump actions/cache from 5 to 6 (https://github.com/iv-org/invidious/pull/5793, by @dependabot[bot])
## v2.20260626.0
### Wrap-up
This release hardens playlists, channels, and search, adds a privacy option for searches, and modernizes the packaging and CI pipeline.
Searches can now be submitted via `POST` so queries do not leak into server logs or browser history, Invidious cookies work across alternative domains, and "Watch on YouTube" / embed redirects use the correct timestamp and host. Playlist and channel parsing issues got fixed: outdated playlist parsing that hid all videos, paid course videos breaking imports, RSS feeds exposing private playlists without auth, broken author verification badges, and channel videos/playlists not loading from search. Thumbnail paths `/pl_c` / `/tvfilm_banner` are now supported, YouTube comments that were written in Japanese, Chinese, Korean and probably other languages do not longer swallows the last character when an emoji is present in the comment, and the search filters dropped the deprecated "sort by rating/date" options.
Packaging moves Docker builds to the 84codes Crystal compiler image, updates OpenSSL to 3.6.2 and Crystal to 1.20.x in OCI, bumps Alpine to 3.24, and unifies the ARM64 and AMD64 Dockerfiles. Developers benefit from continued encapsulation of constants/helpers/translation/video-parser logic into dedicated modules, an `api/v1/channels.cr` lint pass, trailing-whitespace cleanup, and a sweep of dependency and GitHub Actions bumps.
### New features & important changes
#### For Users
- Searches can be submitted through `POST` requests so queries stay out of URLs, server logs and browser history (#5551)
- Invidious cookies are honoured across alternative configured domains (#5647)
- Embed and "Watch on YouTube" redirects use the correct `t`/`start` parameter and the `www.youtube.com` host consistently (#5660, #5768)
- The `referrerpolicy`/`noreferrer` handling was corrected now that YouTube requires referrers on embeds (#5642)
- The listen button on the title updates its elapsed time, and the deprecated "sort by rating/date" search filter options were removed (#5625, #5629)
#### For instance owners
- Docker builds switched to the 84codes Crystal compiler container image, and OCI images were updated to Crystal 1.20.x with OpenSSL 3.6.2 (#5473, #5692)
- Alpine was bumped to 3.24 in the Docker image (#5778)
- ARM64 and AMD64 Dockerfiles were unified into a single workflow (#5700)
#### For developers
- Constants and functions were encapsulated into dedicated `I18n`, `Helpers`, `Invidious::Videos::Parser` and `Invidious::Videos::Clip` modules (#5637, #5639, #5745)
- `api/v1/channels.cr` received a lint pass and trailing whitespaces were removed from the codebase (#5693, #5634)
- CI bumped the Crystal version matrix and displayed compile progress/stats, and the `crystal-lang/install-crystal` action was updated (#5691, #5696, #5703, #5686)
### Bugs fixed
#### User-side
- Playlists showed no videos because of outdated playlist parsing; this is fixed along with paid course videos breaking the importer (#5774, #5207)
- Private Invidious playlists were reachable through RSS feeds without authentication (#5776)
- Channel videos and playlists failed to load from search, and channel author verification was broken (#5736, #5751)
- A missing `collectionThumbnailViewModel` hash key crashed channel browsing (#5725)
- The `quality=medium` query parameter was appended to videos about to premiere (#5755)
- YouTube/Invidious links did not rewind their timestamp when playback position was rewound (#5601)
- The last character of a comment was lost when the comment contained emoji (#5587)
- Playlist RSS `watch` URLs only joined params with `&` when params were present, and thumbnail paths `/pl_c` and `/tvfilm_banner` are now supported (#5646, #5742)
#### For instance owners
- Docker/OCI builds keep current with Crystal 1.20.1, OpenSSL 3.6.2, Alpine 3.24 and the unified multi-arch Dockerfile (#5703, #5701, #5778, #5700)
#### For developers
- Dependency and GitHub Actions bumps kept CI current: `docker/login-action`, `build-push-action`, `metadata-action`, `setup-buildx-action`, `int128/docker-manifest-create-action` and `crystal-lang/install-crystal` (#5705, #5766, #5721, #5686, #5661, #5662, #5663, #5664)
### Full list of pull requests merged since the last release (newest first)
* fix: fix playlists not showing any videos due to outdated playlist parsing (https://github.com/iv-org/invidious/pull/5774, by @Fijxu)
* chore(deps): bump alpine from 3.23 to 3.24 in /docker (https://github.com/iv-org/invidious/pull/5778, by @dependabot[bot])
* fix: fix private invidious playlists on rss feeds from being fetched without authentication (https://github.com/iv-org/invidious/pull/5776, by @Fijxu)
* Use "www.youtube.com" consistently (https://github.com/iv-org/invidious/pull/5768, by @janmoesen)
* chore(deps): bump int128/docker-manifest-create-action from 2.21.0 to 2.22.0 (https://github.com/iv-org/invidious/pull/5766, by @dependabot[bot])
* Add support for alternative domains for Invidious cookies (https://github.com/iv-org/invidious/pull/5647, by @Fijxu)
* Only include '&' if params are present in `watch` urls for playlist RSS (https://github.com/iv-org/invidious/pull/5646, by @Fijxu)
* Dockerfile: Switch to 84codes crystal compiler container image (https://github.com/iv-org/invidious/pull/5473, by @Fijxu)
* fix: Do not append query params `quality=medium` to videos that are about to premiere (https://github.com/iv-org/invidious/pull/5755, by @Fijxu)
* Fix Youtube and Invidious links not rewinding their time when video playback position is rewound (https://github.com/iv-org/invidious/pull/5601, by @Fijxu)
* feat: Add support for POST requests on searches for privacy (https://github.com/iv-org/invidious/pull/5551, by @Fijxu)
* Fix last character disappearance if emoji are in comment (https://github.com/iv-org/invidious/pull/5587, by @shiny-comic)
* Encapsulate videos parser and clip functions inside it's own `Invidious::Videos::Parser` and `Invidious::Videos::Clip` module (https://github.com/iv-org/invidious/pull/5745, by @Fijxu)
* fix: fix author verification in channels (https://github.com/iv-org/invidious/pull/5751, by @Fijxu)
* Add support for `/pl_c` and `/tvfilm_banner` paths (thumbnails used in some playlists) (https://github.com/iv-org/invidious/pull/5742, by @Fijxu)
* fix: fix channel videos and playlists on searches (https://github.com/iv-org/invidious/pull/5736, by @Fijxu)
* fix: fix `Missing hash key: "collectionThumbnailViewModel"` (https://github.com/iv-org/invidious/pull/5725, by @Fijxu)
* chore(deps): bump int128/docker-manifest-create-action from 2.20.0 to 2.21.0 (https://github.com/iv-org/invidious/pull/5721, by @dependabot[bot])
* chore: update openssl to 3.6.2 in OCI (https://github.com/iv-org/invidious/pull/5701, by @Fijxu)
* Bump int128/docker-manifest-create-action from 2.19.0 to 2.20.0 (https://github.com/iv-org/invidious/pull/5705, by @dependabot[bot])
* CI: Unify ARM64 and AMD64 Dockerfiles (https://github.com/iv-org/invidious/pull/5700, by @Fijxu)
* CI: update Crystal 1.20.0 to 1.20.1 in ci.yml matrix (https://github.com/iv-org/invidious/pull/5703, by @Fijxu)
* CI: display progress and stats when compiling Invidious in ci.yml matrix (https://github.com/iv-org/invidious/pull/5696, by @Fijxu)
* CI: Bump Crystal version matrix (https://github.com/iv-org/invidious/pull/5691, by @Fijxu)
* chore: update Crystal to 1.20.0 in OCI (https://github.com/iv-org/invidious/pull/5692, by @Fijxu)
* player: Use correct time parameter for YouTube embed redirects (https://github.com/iv-org/invidious/pull/5660, by @radmorecameron)
* chore: lint api/v1/channels.cr (https://github.com/iv-org/invidious/pull/5693, by @Fijxu)
* Encapsulate helpers constants and functions inside it's own `Helpers` module (https://github.com/iv-org/invidious/pull/5639, by @Fijxu)
* Encapsulate translation constants and functions inside it's own `I18n` module (https://github.com/iv-org/invidious/pull/5637, by @Fijxu)
* Bump crystal-lang/install-crystal from 1.9.1 to 1.9.2 (https://github.com/iv-org/invidious/pull/5686, by @dependabot[bot])
* Playlists: fix parsing error when some videos are paid for in a course (https://github.com/iv-org/invidious/pull/5207, by @ChunkyProgrammer)
* Bump docker/login-action from 3 to 4 (https://github.com/iv-org/invidious/pull/5661, by @dependabot[bot])
* Bump docker/build-push-action from 6 to 7 (https://github.com/iv-org/invidious/pull/5662, by @dependabot[bot])
* Bump docker/metadata-action from 5 to 6 (https://github.com/iv-org/invidious/pull/5663, by @dependabot[bot])
* Bump docker/setup-buildx-action from 3 to 4 (https://github.com/iv-org/invidious/pull/5664, by @dependabot[bot])
* Remove noreferrer since youtube now requires referrers on embeds (https://github.com/iv-org/invidious/pull/5642, by @ashleyirispuppy143)
* Remove trailing whitespaces from codebase (https://github.com/iv-org/invidious/pull/5634, by @Fijxu)
* Add title listen button time updates (https://github.com/iv-org/invidious/pull/5625, by @JeroenBoersma)
* Remove sort by rating and date in video search filters (https://github.com/iv-org/invidious/pull/5629, by @Fijxu)
## v2.20260207.0
### Wrap-up
+13 -15
View File
@@ -205,6 +205,7 @@ https_only: false
# path: /tmp/invidious.sock
# permissions: 777
# -----------------------------
# Network (outbound)
# -----------------------------
@@ -227,6 +228,7 @@ https_only: false
##
#pool_size: 100
##
## Additional cookies to be sent when requesting the youtube API.
##
@@ -261,6 +263,7 @@ https_only: false
# host:
# port:
##
## Use Innertube's transcripts API instead of timedtext for closed captions
##
@@ -341,6 +344,7 @@ https_only: false
##
#statistics_enabled: false
# -----------------------------
# Users and accounts
# -----------------------------
@@ -452,25 +456,12 @@ full_refresh: false
##
feed_threads: 1
##
## Setting to disable easy to abuse API endpoints that can
## be spammed and therefore blocking your Invidious instance.
##
## Useful for public instance maintainers.
##
## Notes: The following API endpoints will be disabled:
## - /api/v1/videos
## - /api/v1/clips
## - /api/v1/transcripts
##
## Accepted values: true, false
## Default: false
##
disable_abusable_api: false
jobs:
## Options for the database cleaning job
clear_expired_items:
## Enable/Disable job
##
## Accepted values: true, false
@@ -480,6 +471,7 @@ jobs:
## Options for the channels updater job
refresh_channels:
## Enable/Disable job
##
## Accepted values: true, false
@@ -489,6 +481,7 @@ jobs:
## Options for the RSS feeds updater job
refresh_feeds:
## Enable/Disable job
##
## Accepted values: true, false
@@ -496,6 +489,7 @@ jobs:
##
enable: true
# -----------------------------
# Miscellaneous
# -----------------------------
@@ -694,6 +688,7 @@ default_user_preferences:
##
#captions: ["", "", ""]
# -----------------------------
# Interface
# -----------------------------
@@ -795,6 +790,7 @@ default_user_preferences:
##
#related_videos: true
# -----------------------------
# Video player behavior
# -----------------------------
@@ -858,6 +854,7 @@ default_user_preferences:
##
#video_loop: false
# -----------------------------
# Video playback settings
# -----------------------------
@@ -969,6 +966,7 @@ default_user_preferences:
##
#sort: published
# -----------------------------
# Miscellaneous
# -----------------------------
@@ -0,0 +1,6 @@
CREATE INDEX channel_videos_ucid_published_idx
ON public.channel_videos
USING btree
(ucid COLLATE pg_catalog."default", published);
DROP INDEX channel_videos_ucid_idx;
+4 -4
View File
@@ -19,12 +19,12 @@ CREATE TABLE IF NOT EXISTS public.channel_videos
GRANT ALL ON TABLE public.channel_videos TO current_user;
-- Index: public.channel_videos_ucid_idx
-- Index: public.channel_videos_ucid_published_idx
-- DROP INDEX public.channel_videos_ucid_idx;
-- DROP INDEX public.channel_videos_ucid_published_idx;
CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx
CREATE INDEX IF NOT EXISTS channel_videos_ucid_published_idx
ON public.channel_videos
USING btree
(ucid COLLATE pg_catalog."default");
(ucid COLLATE pg_catalog."default", published);
+5 -5
View File
@@ -1,8 +1,8 @@
# https://github.com/openssl/openssl/releases/tag/openssl-3.6.3
ARG OPENSSL_VERSION='3.6.3'
ARG OPENSSL_SHA256='243a86649cf6f23eeb6a2ff2456e09e5d77dd9018a54d3d96b0c6bdd6ba6c7f1'
# https://github.com/openssl/openssl/releases/tag/openssl-3.6.2
ARG OPENSSL_VERSION='3.6.2'
ARG OPENSSL_SHA256='aaf51a1fe064384f811daeaeb4ec4dce7340ec8bd893027eee676af31e83a04f'
FROM 84codes/crystal:1.20.3-alpine AS dependabot-crystal
FROM 84codes/crystal:1.20.2-alpine AS dependabot-crystal
# We compile openssl ourselves due to a memory leak in how crystal interacts
# with openssl
@@ -61,7 +61,7 @@ RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ;
--link-flags "-lxml2 -llzma"; \
fi
FROM alpine:3.24
FROM alpine:3.23
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \
+60
View File
@@ -0,0 +1,60 @@
name: invidious
image:
repository: quay.io/invidious/invidious
tag: latest
pullPolicy: Always
replicaCount: 1
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 16
targetCPUUtilizationPercentage: 50
service:
type: ClusterIP
port: 3000
#loadBalancerIP:
resources: {}
#requests:
# cpu: 100m
# memory: 64Mi
#limits:
# cpu: 800m
# memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
# See https://github.com/bitnami/charts/tree/master/bitnami/postgresql
postgresql:
image:
tag: 13
auth:
username: kemal
password: kemal
database: invidious
primary:
initdb:
username: kemal
password: kemal
scriptsConfigMap: invidious-postgresql-init
# Adapted from ../config/config.yml
config:
channel_threads: 1
db:
user: kemal
password: kemal
host: invidious-postgresql
port: 5432
dbname: invidious
full_refresh: false
https_only: false
domain:
-397
View File
@@ -1,397 +0,0 @@
#!/usr/bin/env python3
"""Prepare a new Invidious release.
This automates the release procedure documented at
https://github.com/iv-org/documentation/blob/master/docs/create-new-invidious-release.md
What it does (all of it, so the GitHub workflow only has to run this script):
1. Computes the next version `<MAJOR>.<YYYYMMDD>.<PATCH>` from shard.yml.
2. Collects EVERY pull request merged since the previous release, via `gh`.
3. Asks an LLM (OpenRouter API) to write only the changelog *prose*
(wrap-up + categorised sections), using recent CHANGELOG.md as a style
guide. The exhaustive PR list is generated deterministically so no PR is
ever missed.
4. Bumps shard.yml and prepends the new section to CHANGELOG.md.
5. Creates the `release-vX.Y.Z` branch, commits, pushes, and opens a PR to
master with the post-merge checklist (tagging + publishing stay manual,
as the docs require maintainer review and forbid squash-merges/retagging).
The LLM is called through any OpenAI-compatible Chat Completions endpoint,
configured with the standard `OPENAI_API_KEY` / `OPENAI_BASE_URL` environment
variables (as used by the official OpenAI SDK). It defaults to OpenRouter, but
point it at OpenAI, a local server, etc. by setting `OPENAI_BASE_URL`.
Manual usage:
export OPENAI_API_KEY=sk-or-... # OpenRouter key by default
./scripts/create-release.py # full run: edits, commit, push, PR
./scripts/create-release.py --dry-run # only edit files locally
./scripts/create-release.py --major 3 # bump major (breaking changes)
./scripts/create-release.py --patch 1 # 2nd release on the same day
./scripts/create-release.py --model x/y # override the model
# Use vanilla OpenAI instead of OpenRouter:
OPENAI_BASE_URL=https://api.openai.com/v1 \
./scripts/create-release.py --model gpt-4o
Requirements: python3, git, and the GitHub CLI (`gh`) authenticated (or
GH_TOKEN set). Only OPENAI_API_KEY is mandatory (skippable with --dry-run
combined with --pr-json for offline testing).
"""
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
DEFAULT_MODEL = "deepseek/deepseek-v4-pro"
REPO_URL = "https://github.com/iv-org/invidious"
CHANGELOG_FILE = "CHANGELOG.md"
SHARD_FILE = "shard.yml"
def die(msg):
prefix = "::error::" if os.environ.get("GITHUB_ACTIONS") else "error: "
print(f"{prefix}{msg}", file=sys.stderr)
sys.exit(1)
def run(cmd, capture=False, check=True):
"""Run a subprocess command (list of args)."""
print(f"$ {' '.join(cmd)}", file=sys.stderr)
result = subprocess.run(
cmd, check=False, text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
)
if check and result.returncode != 0:
detail = (result.stderr or "").strip() if capture else ""
die(f"command failed ({result.returncode}): {' '.join(cmd)}\n{detail}")
return (result.stdout or "").strip() if capture else ""
# --------------------------------------------------------------------------- #
# Version + git helpers
# --------------------------------------------------------------------------- #
def read_current_version():
with open(SHARD_FILE, encoding="utf-8") as fh:
for line in fh:
m = re.match(r"^version:\s*(\S+)", line)
if m:
return m.group(1)
die(f"no 'version:' line found in {SHARD_FILE}")
def compute_version(args):
current = read_current_version() # e.g. 2.20260626.0-dev
print(f"Current shard.yml version: {current}", file=sys.stderr)
major = str(args.major) if args.major is not None else current.split(".", 1)[0]
date = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d")
version = f"{major}.{date}.{args.patch}"
tag = f"v{version}"
existing = run(["git", "tag", "--list", tag], capture=True)
if existing:
die(f"tag {tag} already exists; pass --patch {args.patch + 1}")
return version, tag
def previous_tag():
tags = run(["git", "tag", "--list", "v*", "--sort=-creatordate"],
capture=True)
return tags.splitlines()[0].strip() if tags else ""
def tag_date(tag):
return run(["git", "log", "-1", "--format=%cI", tag], capture=True)
# --------------------------------------------------------------------------- #
# Pull request collection
# --------------------------------------------------------------------------- #
def collect_prs(repo, prev_tag, pr_json_path):
if pr_json_path:
with open(pr_json_path, encoding="utf-8") as fh:
prs = json.load(fh)
else:
cmd = ["gh", "pr", "list", "--repo", repo, "--base", "master",
"--state", "merged", "--limit", "1000",
"--json", "number,title,url,author,mergedAt"]
if prev_tag:
since = tag_date(prev_tag)
print(f"Listing PRs merged after {since} ({prev_tag})",
file=sys.stderr)
cmd += ["--jq", f'[.[] | select(.mergedAt > "{since}")]']
else:
print("No previous tag; listing all merged PRs.", file=sys.stderr)
out = run(cmd, capture=True)
prs = json.loads(out) if out else []
prs.sort(key=lambda p: p.get("mergedAt") or "", reverse=True)
print(f"Collected {len(prs)} merged PR(s).", file=sys.stderr)
return prs
def format_author(author):
"""Match the CHANGELOG convention (`gh` returns bots as `app/dependabot`,
but the changelog uses `dependabot[bot]`)."""
login = (author or {}).get("login") or "ghost"
if login.startswith("app/"):
return f"{login[len('app/'):]}[bot]"
return login
def format_pr_list(prs):
lines = []
for pr in prs:
author = format_author(pr.get("author"))
title = (pr.get("title") or "").strip()
lines.append(f"* {title} ({REPO_URL}/pull/{pr.get('number')}, "
f"by @{author})")
return "\n".join(lines)
# --------------------------------------------------------------------------- #
# Changelog generation
# --------------------------------------------------------------------------- #
def extract_style_reference(changelog, max_chars=6000):
matches = list(re.finditer(r"^## v\S+.*$", changelog, flags=re.MULTILINE))
for i, m in enumerate(matches):
if "(future)" in m.group(0):
continue
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(changelog)
section = changelog[start:end].strip()
section = re.split(r"^### Full list of pull requests", section,
flags=re.MULTILINE)[0].strip()
return section[:max_chars]
return ""
def build_prompt(prs, style_ref, version):
pr_bullets = "\n".join(
f"- #{p.get('number')}: {(p.get('title') or '').strip()}" for p in prs)
return f"""You are writing the changelog prose for the Invidious release v{version}.
Below is an example of a PAST release entry from CHANGELOG.md. Match its \
Markdown structure, tone and heading style EXACTLY (a "### Wrap-up" narrative \
of 2-3 short paragraphs, then "### New features & important changes" with \
"#### For Users", "#### For instance owners" and "#### For developers" \
sub-sections, then a "### Bugs fixed" section when relevant).
--- STYLE EXAMPLE START ---
{style_ref}
--- STYLE EXAMPLE END ---
Here is the complete list of pull requests merged since the last release, \
which you must summarise (reference PRs inline as "(#1234)"). Sort content by \
user impact, with new features and breaking changes first:
{pr_bullets}
Rules:
- Output ONLY the Markdown body starting at "### Wrap-up". Do NOT include the \
"## v{version}" header.
- Do NOT include the exhaustive "### Full list of pull requests merged since \
the last release" section; it is appended separately.
- Do NOT invent PRs or changes that are not in the list above.
- Keep it concise and factual."""
def call_llm(base_url, api_key, model, prompt):
url = base_url.rstrip("/") + "/chat/completions"
payload = {
"model": model,
"messages": [
{"role": "system", "content": "You are a meticulous release "
"manager for the Invidious project who writes clear, accurate "
"changelogs in Markdown."},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
}
req = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
# OpenRouter-specific attribution headers; ignored by other APIs.
"HTTP-Referer": REPO_URL,
"X-Title": "Invidious release changelog",
}, method="POST")
try:
with urllib.request.urlopen(req, timeout=180) as resp:
body = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
die(f"LLM API HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')}")
except urllib.error.URLError as exc:
die(f"LLM API request failed: {exc}")
try:
content = body["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError):
die(f"unexpected LLM API response: {json.dumps(body)[:1000]}")
if not content:
die("LLM API returned empty content")
return content
def generate_prose(prs, version, base_url, api_key, model):
if not prs:
return ("### Wrap-up\n\nNo pull requests were merged since the "
"previous release.")
with open(CHANGELOG_FILE, encoding="utf-8") as fh:
changelog = fh.read()
prompt = build_prompt(prs, extract_style_reference(changelog), version)
return call_llm(base_url, api_key, model, prompt)
# --------------------------------------------------------------------------- #
# File edits
# --------------------------------------------------------------------------- #
def update_changelog(version, entry_body):
with open(CHANGELOG_FILE, encoding="utf-8") as fh:
content = fh.read()
section = f"## v{version}\n\n{entry_body}\n"
future = re.search(r"^## v\S*\s*\(future\)\s*$", content, flags=re.MULTILINE)
if future:
at = future.end()
rest = content[at:].lstrip("\n")
content = content[:at] + "\n\n" + section + "\n" + rest
else:
title = re.search(r"^# CHANGELOG\s*$", content, flags=re.MULTILINE)
at = title.end() if title else 0
content = (content[:at] + "\n\n" + section + "\n"
+ content[at:].lstrip("\n"))
with open(CHANGELOG_FILE, "w", encoding="utf-8") as fh:
fh.write(content)
def update_shard(version):
with open(SHARD_FILE, encoding="utf-8") as fh:
content = fh.read()
content, n = re.subn(r"^version:\s*.*$", f"version: {version}", content,
count=1, flags=re.MULTILINE)
if n != 1:
die(f"could not update 'version:' in {SHARD_FILE}")
with open(SHARD_FILE, "w", encoding="utf-8") as fh:
fh.write(content)
# --------------------------------------------------------------------------- #
# Git / GitHub
# --------------------------------------------------------------------------- #
def detect_repo(explicit):
if explicit:
return explicit
env = os.environ.get("GITHUB_REPOSITORY")
if env:
return env
url = run(["git", "remote", "get-url", "origin"], capture=True, check=False)
m = re.search(r"github\.com[:/](.+?)(?:\.git)?$", url)
return m.group(1) if m else "iv-org/invidious"
def create_pull_request(repo, tag, branch, model, summary):
if run(["git", "config", "user.name"], capture=True, check=False) == "":
run(["git", "config", "user.name", "github-actions[bot]"])
run(["git", "config", "user.email",
"41898282+github-actions[bot]@users.noreply.github.com"])
run(["git", "checkout", "-b", branch])
run(["git", "add", CHANGELOG_FILE, SHARD_FILE])
run(["git", "commit", "-m", f"Release {tag}"])
run(["git", "push", "-u", "origin", branch])
body = f"""Automated release preparation for **{tag}**.
Bumps `{SHARD_FILE}` and updates `{CHANGELOG_FILE}` (prose generated by the \
`{model}` model; the PR list is generated deterministically so every merged \
PR is included).
## Before merging
- [ ] Review the generated changelog for accuracy
- [ ] Confirm the version number and major bump (if any)
## After merging (maintainer, per the release docs)
- Do **not** squash-merge; keep the release commit distinct.
- Tag the merge commit: `git tag -as {tag}` using the changelog summary as \
the annotation, then `git push origin {tag}`.
- Run the "Prepare for next release" step (append `-dev` in {SHARD_FILE}, add \
a new `(future)` changelog header).
- Create the GitHub release from the tag with the changelog summary + PR list.
Refer to the documentation for more details:
https://github.com/iv-org/documentation/blob/master/docs/create-new-invidious-release.md
### Proposed tag annotation (changelog summary)
```markdown
{summary}
```
"""
run(["gh", "pr", "create", "--repo", repo, "--base", "master",
"--head", branch, "--title", f"Release {tag}", "--body", body])
# --------------------------------------------------------------------------- #
def main():
parser = argparse.ArgumentParser(description="Prepare an Invidious release.")
parser.add_argument("--major", type=int, default=None,
help="major version (default: keep current)")
parser.add_argument("--patch", type=int, default=0,
help="patch number (default: 0)")
parser.add_argument("--model", default=os.environ.get(
"LLM_MODEL", DEFAULT_MODEL),
help=f"model name (default: {DEFAULT_MODEL})")
parser.add_argument("--base-url", default=os.environ.get(
"OPENAI_BASE_URL", DEFAULT_BASE_URL),
help=f"OpenAI-compatible API base URL (default: {DEFAULT_BASE_URL})")
parser.add_argument("--repo", default=None,
help="owner/repo (default: autodetect)")
parser.add_argument("--pr-json", default=None,
help="read PR list from a JSON file instead of gh")
parser.add_argument("--dry-run", action="store_true",
help="edit files only; no branch/commit/push/PR")
args = parser.parse_args()
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
die("OPENAI_API_KEY is not set")
repo = detect_repo(args.repo)
version, tag = compute_version(args)
branch = f"release-{tag}"
prev = previous_tag()
print(f"Preparing {tag} (previous: {prev or '<none>'}) on {repo}",
file=sys.stderr)
prs = collect_prs(repo, prev, args.pr_json)
prose = generate_prose(prs, version, args.base_url, api_key, args.model)
entry_body = prose
pr_list = format_pr_list(prs)
if pr_list:
entry_body += ("\n\n### Full list of pull requests merged since the "
"last release (newest first)\n\n" + pr_list)
update_changelog(version, entry_body)
update_shard(version)
print(f"Updated {CHANGELOG_FILE} and {SHARD_FILE} for {tag}.",
file=sys.stderr)
if args.dry_run:
print(f"\n--- Dry run complete. Review the diff, then:\n"
f" git checkout -b {branch}\n"
f" git add {CHANGELOG_FILE} {SHARD_FILE}\n"
f" git commit -m 'Release {tag}'\n"
f" git push -u origin {branch}\n"
f" gh pr create --base master --head {branch} "
f"--title 'Release {tag}'", file=sys.stderr)
return
create_pull_request(repo, tag, branch, args.model, prose)
print(f"\nOpened release PR for {tag}.", file=sys.stderr)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,5 +1,5 @@
name: invidious
version: 2.20260723.0
version: 2.20260207.0-dev
authors:
- Invidious team <contact@invidious.io>
-13
View File
@@ -107,14 +107,6 @@ Kemal.config.extra_options do |parser|
exit
end
end
parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{CONFIG.feed_threads})") do |number|
begin
CONFIG.feed_threads = number.to_i
rescue ex
puts "THREADS must be integer"
exit
end
end
parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: #{CONFIG.output})") do |output|
CONFIG.output = output
end
@@ -166,10 +158,6 @@ if CONFIG.channel_threads > 0
Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB)
end
if CONFIG.feed_threads > 0
Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB)
end
if CONFIG.statistics_enabled
Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE)
end
@@ -217,7 +205,6 @@ end
Kemal.config.powered_by_header = false
add_handler FilteredCompressHandler.new
add_handler APIHandler.new
add_handler DisableAbusableAPIHandler.new
add_handler AuthHandler.new
add_handler DenyFrame.new
-5
View File
@@ -95,8 +95,6 @@ class Config
# Time interval between two executions of the job that crawls channel videos (subscriptions update).
@[YAML::Field(converter: Preferences::TimeSpanConverter)]
property channel_refresh_interval : Time::Span = 30.minutes
# Number of threads to use for updating feeds
property feed_threads : Int32 = 1
# Log file path or STDOUT
property output : String = "STDOUT"
# Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
@@ -183,9 +181,6 @@ class Config
# Playlist length limit
property playlist_length_limit : Int32 = 500
# Disable easy to abuse API endpoints
property disable_abusable_api : Bool = false
def disabled?(option)
case disabled = CONFIG.disable_proxy
when Bool
+3 -3
View File
@@ -194,13 +194,13 @@ module Invidious::Database::PlaylistVideos
PG_DB.exec(request, args: video_array)
end
def delete(index, plid : String)
def delete(index)
request = <<-SQL
DELETE FROM playlist_videos *
WHERE index = $1 AND plid = $2
WHERE index = $1
SQL
PG_DB.exec(request, index, plid)
PG_DB.exec(request, index)
end
def delete_by_playlist(plid : String)
+1 -1
View File
@@ -201,7 +201,7 @@ def error_redirect_helper(env : HTTP::Server::Context)
<a href="/redirect?referer=#{env.get("current_page")}">#{switch_instance}</a>
</li>
<li>
<a rel="noreferrer noopener" href="https://www.youtube.com#{env.request.resource}">#{go_to_youtube}</a>
<a rel="noreferrer noopener" href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
</li>
</ul>
END_HTML
-20
View File
@@ -133,26 +133,6 @@ class APIHandler < Kemal::Handler
end
end
class DisableAbusableAPIHandler < Kemal::Handler
{% for method in %w(GET HEAD) %}
# This endpoints make a video request to Invidious companion.
{% for endpoint in %w(videos clips transcripts) %}
only ["/api/v1/{{ endpoint.id }}/:id"], {{ method }}
{% end %}
{% end %}
def call(env)
return call_next env unless only_match?(env) && CONFIG.disable_abusable_api
env.response.content_type = "application/json"
env.response.status_code = 403
message = {"error" => "This API endpoint has been disabled by the administrator."}.to_json
env.response.print message
env.response.close
return
end
end
class DenyFrame < Kemal::Handler
exclude ["/embed/*"]
+1 -1
View File
@@ -148,7 +148,7 @@ struct SearchVideo
end
def upcoming?
premiere_timestamp.try { |t| t.to_unix > 0 } ? true : false
premiere_timestamp ? true : false
end
end
@@ -36,11 +36,6 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
LOGGER.trace("RefreshChannelsJob: #{id} fiber : Updating DB")
Invidious::Database::Channels.update_author(id, channel.author)
if backoff > 2.minutes
backoff /= 2
LOGGER.debug("RefreshChannelsJob: #{id} fiber : decreasing backoff to #{backoff}s")
end
rescue ex
LOGGER.error("RefreshChannelsJob: #{id} : #{ex.message}")
if ex.message == "Deleted or invalid channel"
-75
View File
@@ -1,75 +0,0 @@
class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
private getter db : DB::Database
def initialize(@db)
end
def begin
max_fibers = CONFIG.feed_threads
active_fibers = 0
active_channel = ::Channel(Bool).new
loop do
db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs|
rs.each do
email = rs.read(String)
view_name = "subscriptions_#{sha256(email)}"
if active_fibers >= max_fibers
if active_channel.receive
active_fibers -= 1
end
end
active_fibers += 1
spawn do
begin
# Drop outdated views
column_array = Invidious::Database.get_column_array(db, view_name)
ChannelVideo.type_array.each_with_index do |name, i|
if name != column_array[i]?
LOGGER.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}")
db.exec("DROP MATERIALIZED VIEW #{view_name}")
raise "view does not exist"
end
end
if !db.query_one("SELECT pg_get_viewdef('#{view_name}')", as: String).includes? "WHERE ((cv.ucid = ANY (u.subscriptions))"
LOGGER.info("RefreshFeedsJob: Materialized view #{view_name} is out-of-date, recreating...")
db.exec("DROP MATERIALIZED VIEW #{view_name}")
end
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
rescue ex
# Rename old views
begin
legacy_view_name = "subscriptions_#{sha256(email)[0..7]}"
db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0")
LOGGER.info("RefreshFeedsJob: RENAME MATERIALIZED VIEW #{legacy_view_name}")
db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}")
rescue ex
begin
# While iterating through, we may have an email stored from a deleted account
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
LOGGER.info("RefreshFeedsJob: CREATE #{view_name}")
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
end
rescue ex
LOGGER.error("RefreshFeedJobs: REFRESH #{email} : #{ex.message}")
end
end
end
active_channel.send(true)
end
end
end
sleep 5.seconds
Fiber.yield
end
end
end
+15 -42
View File
@@ -454,10 +454,6 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32,
end
end
# TODO (2026-06-24): Migrate this function to use parsers instead, as it uses,
# the same LockupViewModel used in Channel videos and Youtube playlists that
# appears on searches (Invidious /search endpoint).
# Related to https://github.com/iv-org/invidious/pull/5736
def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JSON::Any))
videos = [] of PlaylistVideo | ProblematicTimelineItem
@@ -471,7 +467,8 @@ def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JS
tabs_contents = tabs_renderer.["contents"]? || tabs_renderer.["content"]
list_renderer = tabs_contents.["sectionListRenderer"]["contents"][0]
contents = list_renderer.["itemSectionRenderer"]["contents"].as_a
item_renderer = list_renderer.["itemSectionRenderer"]["contents"][0]
contents = item_renderer.["playlistVideoListRenderer"]["contents"].as_a
else
# Continuation data
contents = initial_data["onResponseReceivedActions"][0]?
@@ -482,39 +479,15 @@ def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JS
end
contents.try &.each do |item|
if i = item["lockupViewModel"]?
thumbnail_view_model = i.dig?(
"contentImage", "thumbnailViewModel"
)
watch_endpoint = i.dig?("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
video_id = watch_endpoint.try &.["videoId"]?.try &.as_s
plid = watch_endpoint.try &.["playlistId"]?.try &.as_s || playlist_id
index = watch_endpoint.try &.["index"]?.try &.as_i64
metadata = i["metadata"]?
lockup_metadata_view_model = metadata.try &.dig?("lockupMetadataViewModel")
title = lockup_metadata_view_model.try &.dig?("title", "content").try &.as_s
lockup_metadata = lockup_metadata_view_model.try &.dig?("metadata")
metadata_rows = lockup_metadata.try &.dig?("contentMetadataViewModel", "metadataRows").try &.as_a
# Find the metadataParts with commandRuns inside, which contains author
# information.
metadata_parts = metadata_rows.try &.find { |row|
parts = row["metadataParts"]?.try &.as_a
parts && parts.any? { |item2| item2.dig?("text", "commandRuns").try &.as_a }
}.try &.["metadataParts"].as_a
if author_info = metadata_parts.try &.find(&.dig?("text", "commandRuns"))
.try &.["text"]
author = author_info["content"].as_s
ucid = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId")
.try &.as_s
end
length = thumbnail_view_model.try &.dig?("overlays", 0, "thumbnailBottomOverlayViewModel", "badges", 0, "thumbnailBadgeViewModel", "text").try &.as_s
length_seconds = decode_length_seconds(length) if length
if i = item["playlistVideoRenderer"]?
video_id = i.dig?("navigationEndpoint", "watchEndpoint", "videoId").try &.as_s || i.dig("videoId").as_s
plid = i.dig?("navigationEndpoint", "watchEndpoint", "playlistId").try &.as_s || playlist_id
index = i.dig?("navigationEndpoint", "watchEndpoint", "index").try &.as_i64 || i.dig("index", "simpleText").as_s.to_i64
title = i["title"].try { |t| t["simpleText"]? || t["runs"]?.try &.[0]["text"]? }.try &.as_s || ""
author = i["shortBylineText"]?.try &.["runs"][0]["text"].as_s || ""
ucid = i["shortBylineText"]?.try &.["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s || ""
length_seconds = i["lengthSeconds"]?.try &.as_s.to_i
live = false
if !length_seconds
@@ -523,15 +496,15 @@ def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JS
end
videos << PlaylistVideo.new({
title: title || "",
id: video_id || "",
author: author || "",
ucid: ucid || "",
title: title,
id: video_id,
author: author,
ucid: ucid,
length_seconds: length_seconds,
published: Time.utc,
plid: plid,
live_now: live,
index: index || -1_i64,
index: index,
})
end
rescue ex
-2
View File
@@ -123,10 +123,8 @@ module Invidious::Routes::Account
return error_template(400, ex)
end
view_name = "subscriptions_#{sha256(user.email)}"
Invidious::Database::Users.delete(user)
Invidious::Database::SessionIDs.delete(email: user.email)
PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
env.request.cookies.each do |cookie|
cookie.expires = Time.utc(1990, 1, 1)
+1 -1
View File
@@ -364,7 +364,7 @@ module Invidious::Routes::API::V1::Authenticated
return error_json(404, "Playlist does not contain index")
end
Invidious::Database::PlaylistVideos.delete(index, plid)
Invidious::Database::PlaylistVideos.delete(index)
Invidious::Database::Playlists.update_video_removed(plid, index)
env.response.status_code = 204
+1 -1
View File
@@ -351,7 +351,7 @@ module Invidious::Routes::Channels
invidious_url_params.delete_all("user")
begin
resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
rescue ex : InfoException | KeyError
return error_template(404, I18n.translate(locale, "This channel does not exist."))
+1 -1
View File
@@ -8,7 +8,7 @@ module Invidious::Routes::ErrorRoutes
if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/)
item = md["id"]
# Check if item is branding URL e.g. https://www.youtube.com/gaming
# Check if item is branding URL e.g. https://youtube.com/gaming
response = YT_POOL.client &.get("/#{item}")
if response.status_code == 301
-5
View File
@@ -283,11 +283,6 @@ module Invidious::Routes::Feeds
if playlist = Invidious::Database::Playlists.select(id: plid)
videos = get_playlist_videos(playlist, offset: 0)
user = env.get?("user").try &.as(User)
if !playlist || playlist.privacy.private? && playlist.author != user.try &.email
return error_atom(404, "Playlist does not exist.")
end
return XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
"xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom",
-3
View File
@@ -125,9 +125,6 @@ module Invidious::Routes::Login
Invidious::Database::Users.insert(user)
Invidious::Database::SessionIDs.insert(sid, email)
view_name = "subscriptions_#{sha256(user.email)}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
if alt = CONFIG.alternative_domains.index(host)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
else
+2 -6
View File
@@ -357,12 +357,8 @@ module Invidious::Routes::Playlists
Invidious::Database::PlaylistVideos.insert(playlist_video)
Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index)
when "remove_video"
index = env.params.query["set_video_id"].to_i64?
if index.nil? || !playlist.index.includes? index
return error_json(404, "Playlist does not contain index")
end
Invidious::Database::PlaylistVideos.delete(index, playlist_id)
index = env.params.query["set_video_id"]
Invidious::Database::PlaylistVideos.delete(index)
Invidious::Database::Playlists.update_video_removed(playlist_id, index)
when "move_video_before"
# TODO: Playlist stub
+9 -9
View File
@@ -37,18 +37,18 @@ module Invidious::Search
# Search inside of user subscriptions
def subscriptions(query : Query, user : Invidious::User) : Array(ChannelVideo)
view_name = "subscriptions_#{sha256(user.email)}"
return PG_DB.query_all("
SELECT id,title,published,updated,ucid,author,length_seconds
FROM (
SELECT *,
to_tsvector(#{view_name}.title) ||
to_tsvector(#{view_name}.author)
as document
FROM #{view_name}
) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;",
query.text, (query.page - 1) * 20,
SELECT cv.*,
to_tsvector(cv.title) ||
to_tsvector(cv.author) AS document
FROM channel_videos cv
JOIN users ON cv.ucid = any(users.subscriptions)
WHERE users.email = $1 AND published > now() - interval '1 month'
ORDER BY published
) v_search WHERE v_search.document @@ plainto_tsquery($2) LIMIT 20 OFFSET $3;",
user.email, query.text, (query.page - 1) * 20,
as: ChannelVideo
)
end
+22 -17
View File
@@ -27,7 +27,6 @@ def get_subscription_feed(user, max_results = 40, page = 1)
offset = (page - 1) * limit
notifications = Invidious::Database::Users.select_notifications(user)
view_name = "subscriptions_#{sha256(user.email)}"
if user.preferences.notifications_only && !notifications.empty?
# Only show notifications
@@ -53,33 +52,39 @@ def get_subscription_feed(user, max_results = 40, page = 1)
# Show latest video from a channel that a user hasn't watched
# "unseen_only" isn't really correct here, more accurate would be "unwatched_only"
if user.watched.empty?
values = "'{}'"
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo)
# "SELECT cv.* FROM channel_videos cv JOIN users ON cv.ucid = any(users.subscriptions) WHERE users.email = $1 AND published > now() - interval '1 month' ORDER BY published DESC"
# "SELECT DISTINCT ON (cv.ucid) cv.* FROM channel_videos cv JOIN users ON cv.ucid = any(users.subscriptions) WHERE users.email = ? AND NOT cv.id = any(users.watched) AND published > now() - interval '1 month' ORDER BY ucid, published DESC"
videos = PG_DB.query_all("SELECT DISTINCT ON (cv.ucid) cv.* " \
"FROM channel_videos cv " \
"JOIN users ON cv.ucid = any(users.subscriptions) " \
"WHERE users.email = $1 AND NOT cv.id = any(users.watched) AND published > now() - interval '1 month' " \
"ORDER BY ucid, published DESC", user.email, as: ChannelVideo)
else
# Show latest video from each channel
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo)
videos = PG_DB.query_all("SELECT DISTINCT ON (cv.ucid) cv.* " \
"FROM channel_videos cv " \
"JOIN users ON cv.ucid = any(users.subscriptions) " \
"WHERE users.email = $1 AND published > now() - interval '1 month' " \
"ORDER BY ucid, published DESC", user.email, as: ChannelVideo)
end
videos.sort_by!(&.published).reverse!
else
if user.preferences.unseen_only
# Only show unwatched
if user.watched.empty?
values = "'{}'"
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
videos = PG_DB.query_all("SELECT cv.* " \
"FROM channel_videos cv " \
"JOIN users ON cv.ucid = any(users.subscriptions) " \
"WHERE users.email = $1 AND NOT cv.id = any(users.watched) AND published > now() - interval '1 month' " \
"ORDER BY published DESC LIMIT $2 OFFSET $3", user.email, limit, offset, as: ChannelVideo)
else
# Sort subscriptions as normal
videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
videos = PG_DB.query_all("SELECT cv.* " \
"FROM channel_videos cv " \
"JOIN users ON cv.ucid = any(users.subscriptions) " \
"WHERE users.email = $1 AND published > now() - interval '1 month' " \
"ORDER BY published DESC LIMIT $2 OFFSET $3", user.email, limit, offset, as: ChannelVideo)
end
end
+1 -1
View File
@@ -106,7 +106,7 @@ end
def add_yt_headers(request)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
+11 -14
View File
@@ -6,13 +6,13 @@ module YoutubeAPI
extend self
# For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
private ANDROID_APP_VERSION = "21.29.366"
private ANDROID_VERSION = "16"
private ANDROID_APP_VERSION = "19.35.36"
private ANDROID_VERSION = "13"
private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; en_US; SM-S908E Build/TP1A.220624.014) gzip"
private ANDROID_SDK_VERSION = 33_i64
private ANDROID_TS_APP_VERSION = "1.9"
private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 16; US) gzip"
private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip"
# For Apple device names, see https://gist.github.com/adamawolf/3048717
# For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases,
@@ -50,8 +50,7 @@ module YoutubeAPI
ClientType::Web => {
name: "WEB",
name_proto: "1",
version: "2.20260722.01.00",
version: "2.20250222.10.00",
screen: "WATCH_FULL_SCREEN",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@@ -60,7 +59,7 @@ module YoutubeAPI
ClientType::WebEmbeddedPlayer => {
name: "WEB_EMBEDDED_PLAYER",
name_proto: "56",
version: "2.20260722.01.00",
version: "1.20250219.01.00",
screen: "EMBED",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@@ -69,7 +68,7 @@ module YoutubeAPI
ClientType::WebMobile => {
name: "MWEB",
name_proto: "2",
version: "2.20260722.01.00",
version: "2.20250224.01.00",
os_name: "Android",
os_version: ANDROID_VERSION,
platform: "MOBILE",
@@ -77,9 +76,7 @@ module YoutubeAPI
ClientType::WebScreenEmbed => {
name: "WEB",
name_proto: "1",
version: "2.20260722.01.00",
version: "2.20250222.10.00",
screen: "EMBED",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@@ -88,7 +85,7 @@ module YoutubeAPI
ClientType::WebCreator => {
name: "WEB_CREATOR",
name_proto: "62",
version: "2.20260722.01.00",
version: "1.20241203.01.00",
os_name: "Windows",
os_version: WINDOWS_VERSION,
platform: "DESKTOP",
@@ -174,7 +171,7 @@ module YoutubeAPI
ClientType::TvHtml5 => {
name: "TVHTML5",
name_proto: "7",
version: "2.20260722.01.00",
version: "7.20250219.14.00",
},
ClientType::TvHtml5ScreenEmbed => {
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
@@ -483,7 +480,7 @@ module YoutubeAPI
#
# ```
# # Valid channel "brand URL" gives the related UCID and browse ID
# channel_a = YoutubeAPI.resolve_url("https://www.youtube.com/c/google")
# channel_a = YoutubeAPI.resolve_url("https://youtube.com/c/google")
# channel_a # => {
# "endpoint": {
# "browseEndpoint": {
@@ -495,7 +492,7 @@ module YoutubeAPI
# }
#
# # Invalid URL returns throws an InfoException
# channel_b = YoutubeAPI.resolve_url("https://www.youtube.com/c/invalid")
# channel_b = YoutubeAPI.resolve_url("https://youtube.com/c/invalid")
# ```
#
def resolve_url(url : String, client_config : ClientConfig | Nil = nil)