mirror of
https://github.com/iv-org/invidious.git
synced 2026-07-25 15:53:20 +05:30
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ce8a726b |
@@ -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):
|
||||
-->
|
||||
@@ -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
|
||||
@@ -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. -->
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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 && \
|
||||
|
||||
@@ -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:
|
||||
@@ -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,5 +1,5 @@
|
||||
name: invidious
|
||||
version: 2.20260723.0
|
||||
version: 2.20260207.0-dev
|
||||
|
||||
authors:
|
||||
- Invidious team <contact@invidious.io>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*"]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user