├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── deno-check.yaml │ └── docker-build-push.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── compile.env ├── config └── config.example.toml ├── deno.json ├── deno.lock ├── docker-compose.yaml ├── grafana_dashboard.json └── src ├── lib ├── helpers │ ├── config.ts │ ├── encodeRFC5987ValueChars.ts │ ├── encryptQuery.ts │ ├── getFetchClient.ts │ ├── metrics.ts │ ├── verifyRequest.ts │ ├── youtubePlayerHandling.ts │ ├── youtubePlayerReq.ts │ └── youtubeTranscriptsHandling.ts ├── jobs │ ├── potoken.ts │ └── worker.ts └── types │ └── HonoVariables.ts ├── main.ts ├── routes ├── health.ts ├── index.ts ├── invidious_routes │ ├── captions.ts │ ├── dashManifest.ts │ ├── download.ts │ └── latestVersion.ts ├── metrics.ts ├── videoPlaybackProxy.ts └── youtube_api_routes │ └── player.ts └── tests ├── dashManifest.ts ├── deps.ts ├── latestVersion.ts ├── main_test.ts └── youtubePlayer.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | invidious_companion 2 | test_things/ 3 | config/local.toml -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "docker" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/deno-check.yaml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | testing: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Setup repo 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Deno 16 | uses: denoland/setup-deno@v2 17 | with: 18 | deno-version: v2.x 19 | 20 | - name: Verify formatting 21 | run: deno fmt --check src/** 22 | 23 | - name: Verify typing 24 | run: deno check src/** 25 | 26 | - name: Run linter 27 | run: deno lint 28 | 29 | - name: Install and run tor 30 | uses: tor-actions/setup-tor@main 31 | with: 32 | daemon: true 33 | port: 9150 34 | 35 | - name: Run tests x4 times (IP may be banned) 36 | uses: nick-fields/retry@v3 37 | with: 38 | timeout_minutes: 5 39 | max_attempts: 4 40 | command: | 41 | pkill -HUP tor 42 | curl -s --socks5 127.0.0.1:9150 https://check.torproject.org/api/ip; echo 43 | rm -rf /var/tmp/youtubei.js 44 | PROXY=socks5://127.0.0.1:9150 deno task test 45 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-push.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | # Define when this workflow will run 4 | on: 5 | push: 6 | branches: 7 | - master # Trigger on pushes to master branch 8 | tags: 9 | - '[0-9]+.[0-9]+.[0-9]+' # Trigger on semantic version tags 10 | paths-ignore: 11 | - '.gitignore' 12 | - 'LICENSE' 13 | - 'README.md' 14 | - 'docker-compose.yml' 15 | workflow_dispatch: # Allow manual triggering of the workflow 16 | 17 | # Define environment variables used throughout the workflow 18 | env: 19 | REGISTRY: quay.io 20 | IMAGE_NAME: invidious/invidious-companion 21 | 22 | jobs: 23 | build-and-push: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | # Step 1: Check out the repository code 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | # Step 2: Set up QEMU for multi-architecture builds 32 | - name: Set up QEMU 33 | uses: docker/setup-qemu-action@v3 34 | 35 | # Step 3: Set up Docker Buildx for enhanced build capabilities 36 | - name: Set up Docker Buildx 37 | uses: docker/setup-buildx-action@v3 38 | 39 | # Step 4: Authenticate with Quay.io registry 40 | - name: Log in to Quay.io 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ${{ env.REGISTRY }} 44 | username: ${{ secrets.QUAY_USERNAME }} 45 | password: ${{ secrets.QUAY_PASSWORD }} 46 | 47 | # Step 5: Extract metadata for Docker image tagging and labeling 48 | - name: Extract metadata for Docker 49 | id: meta 50 | uses: docker/metadata-action@v5 51 | with: 52 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 53 | # Define tagging strategy 54 | tags: | 55 | type=semver,pattern={{version}} 56 | type=semver,pattern={{major}}.{{minor}} 57 | type=semver,pattern={{major}} 58 | type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} 59 | type=sha,prefix={{branch}}- 60 | # Define labels 61 | labels: | 62 | quay.expires-after=12w 63 | 64 | # Step 6: Build and push the Docker image 65 | - name: Build and push Docker image 66 | uses: docker/build-push-action@v6 67 | with: 68 | context: . 69 | push: true 70 | platforms: linux/amd64,linux/arm64 # Build for multiple architectures 71 | tags: ${{ steps.meta.outputs.tags }} 72 | labels: ${{ steps.meta.outputs.labels }} 73 | cache-from: type=gha 74 | cache-to: type=gha,mode=max 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.orig 2 | *.pyc 3 | *.swp 4 | .env 5 | 6 | /.cargo_home/ 7 | /.idea/ 8 | /.vs/ 9 | /.vscode/ 10 | gclient_config.py_entries 11 | /target/ 12 | /std/hash/_wasm/target 13 | /tests/wpt/runner/manifest.json 14 | /third_party/ 15 | /tests/napi/node_modules 16 | /tests/napi/build 17 | /tests/napi/third_party_tests/node_modules 18 | 19 | # MacOS generated files 20 | .DS_Store 21 | .DS_Store? 22 | 23 | # Flamegraphs 24 | /flamebench*.svg 25 | /flamegraph*.svg 26 | 27 | # WPT generated cert files 28 | /tests/wpt/runner/certs/index.txt* 29 | /tests/wpt/runner/certs/serial* 30 | 31 | /ext/websocket/autobahn/reports 32 | 33 | # JUnit files produced by deno test --junit 34 | junit.xml 35 | 36 | # Jupyter files 37 | .ipynb_checkpoints/ 38 | Untitled*.ipynb 39 | 40 | invidious_companion 41 | test_things/ 42 | config/config.toml 43 | .cache/ 44 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | # check=error=true 3 | 4 | # Default values for versions 5 | ARG THC_VERSION='0.36.0' \ 6 | TINI_VERSION='0.19.0' 7 | 8 | # Default values for variables that change less often 9 | ARG DENO_DIR='/deno-dir' \ 10 | GH_BASE_URL='https://github.com' \ 11 | HOST='0.0.0.0' \ 12 | PORT='8282' 13 | 14 | 15 | # we can use these aliases and let dependabot remain simple 16 | # inspired by: 17 | # https://github.com/dependabot/dependabot-core/issues/2057#issuecomment-1351660410 18 | FROM alpine:3.22 AS dependabot-alpine 19 | FROM debian:12-slim AS dependabot-debian 20 | 21 | # Retrieve the deno binary from the repository 22 | FROM denoland/deno:bin-2.3.5 AS deno-bin 23 | 24 | 25 | # Stage for creating the non-privileged user 26 | FROM dependabot-alpine AS user-stage 27 | 28 | RUN adduser -u 10001 -S appuser 29 | 30 | # Stage for downloading files using curl from Debian 31 | FROM dependabot-debian AS debian-curl 32 | RUN DEBIAN_FRONTEND='noninteractive' && export DEBIAN_FRONTEND && \ 33 | apt-get update && apt-get install -y curl 34 | 35 | # Download tiny-health-checker from GitHub 36 | FROM debian-curl AS thc-download 37 | ARG GH_BASE_URL THC_VERSION 38 | RUN arch="$(uname -m)" && \ 39 | gh_url() { printf -- "${GH_BASE_URL}/%s/releases/download/%s/%s\n" "$@" ; } && \ 40 | URL="$(gh_url dmikusa/tiny-health-checker v${THC_VERSION} thc-${arch}-unknown-linux-musl)" && \ 41 | curl -fsSL --output /thc "${URL}" && chmod -v 00555 /thc 42 | 43 | # Cache the thc binary as a layer 44 | FROM scratch AS thc-bin 45 | ARG THC_VERSION 46 | ENV THC_VERSION="${THC_VERSION}" 47 | COPY --from=thc-download /thc /thc 48 | 49 | # Download tini from GitHub 50 | FROM debian-curl AS tini-download 51 | ARG GH_BASE_URL TINI_VERSION 52 | RUN arch="$(dpkg --print-architecture)" && \ 53 | gh_url() { printf -- "${GH_BASE_URL}/%s/releases/download/%s/%s\n" "$@" ; } && \ 54 | URL="$(gh_url krallin/tini v${TINI_VERSION} tini-${arch})" && \ 55 | curl -fsSL --output /tini "${URL}" && chmod -v 00555 /tini 56 | 57 | # Cache the tini binary as a layer 58 | FROM scratch AS tini-bin 59 | ARG TINI_VERSION 60 | ENV TINI_VERSION="${TINI_VERSION}" 61 | COPY --from=tini-download /tini /tini 62 | 63 | # Stage for using git from Debian 64 | FROM dependabot-debian AS debian-git 65 | RUN DEBIAN_FRONTEND='noninteractive' && export DEBIAN_FRONTEND && \ 66 | apt-get update && apt-get install -y git 67 | 68 | # Stage for using deno on Debian 69 | FROM debian-git AS debian-deno 70 | 71 | # cache dir for youtube.js library 72 | RUN mkdir -v -p /var/tmp/youtubei.js 73 | 74 | ARG DENO_DIR 75 | RUN useradd --uid 1993 --user-group deno \ 76 | && mkdir -v "${DENO_DIR}" \ 77 | && chown deno:deno "${DENO_DIR}" 78 | 79 | ENV DENO_DIR="${DENO_DIR}" \ 80 | DENO_INSTALL_ROOT='/usr/local' 81 | 82 | COPY --from=deno-bin /deno /usr/bin/deno 83 | 84 | # Create a builder using deno on Debian 85 | FROM debian-deno AS builder 86 | 87 | WORKDIR /app 88 | 89 | COPY deno.lock ./ 90 | COPY deno.json ./ 91 | 92 | COPY ./src/ ./src/ 93 | 94 | # To let the `deno task compile` know the current commit on which 95 | # Invidious companion is being built, similar to how Invidious does it. 96 | # Dependencies are cached in ${DENO_DIR} for our deno builder 97 | RUN --mount=type=bind,rw,source=.git,target=/app/.git \ 98 | --mount=type=cache,target="${DENO_DIR}" \ 99 | deno task compile 100 | 101 | FROM gcr.io/distroless/cc AS app 102 | 103 | # Copy group file for the non-privileged user from the user-stage 104 | COPY --from=user-stage /etc/group /etc/group 105 | 106 | # Copy passwd file for the non-privileged user from the user-stage 107 | COPY --from=user-stage /etc/passwd /etc/passwd 108 | 109 | COPY --from=thc-bin /thc /thc 110 | COPY --from=tini-bin /tini /tini 111 | 112 | # Copy cache directory and set correct permissions 113 | COPY --from=builder --chown=appuser:nogroup /var/tmp/youtubei.js /var/tmp/youtubei.js 114 | 115 | # Set the working directory 116 | WORKDIR /app 117 | 118 | COPY --from=builder /app/invidious_companion ./ 119 | 120 | ARG HOST PORT THC_VERSION TINI_VERSION 121 | EXPOSE "${PORT}/tcp" 122 | ENV HOST="${HOST}" \ 123 | PORT="${PORT}" \ 124 | THC_PORT="${PORT}" \ 125 | THC_PATH='/healthz' \ 126 | THC_VERSION="${THC_VERSION}" \ 127 | TINI_VERSION="${TINI_VERSION}" 128 | 129 | COPY ./config/ ./config/ 130 | 131 | # Switch to non-privileged user 132 | USER appuser 133 | 134 | ENTRYPOINT ["/tini", "--", "/app/invidious_companion"] 135 | 136 | HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=5 CMD ["/thc"] 137 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Invidious companion 2 | 3 | Companion for Invidious which handle all the video stream retrieval from YouTube servers. 4 | 5 | ## Requirements 6 | 7 | - [deno](https://docs.deno.com/runtime/) 8 | 9 | ## Documentation 10 | - Installation guide: https://docs.invidious.io/companion-installation/ 11 | - Extra documentation for Invidious companion: https://github.com/iv-org/invidious-companion/wiki 12 | 13 | ## Run Locally (development) 14 | 15 | ``` 16 | SERVER_SECRET_KEY=CHANGEME deno task dev 17 | ``` 18 | 19 | ## Available tasks using deno 20 | 21 | - `deno task dev`: Launch Invidious companion in debug mode 22 | - `deno task compile`: Compile the project to a single file. 23 | - `deno task test`: Test all the tests for Invidious companion 24 | - `deno task format`: Format all the .ts files in the project. 25 | -------------------------------------------------------------------------------- /compile.env: -------------------------------------------------------------------------------- 1 | DENO_COMPILED=true 2 | -------------------------------------------------------------------------------- /config/config.example.toml: -------------------------------------------------------------------------------- 1 | ##### 2 | # The configuration options listed below are able to be enabled as needed. 3 | # The values in this example are the defaults. Some values can alternatively 4 | # be set using an environment variable. 5 | # 6 | # In order to enable an option, make sure you uncomment both the option 7 | # and the block header for the section it belongs to. Any other commented 8 | # options will continue to use default values. 9 | # See https://toml.io/en/ for details on the configuration format. 10 | ##### 11 | 12 | # [server] 13 | # port = 8282 # env variable: PORT 14 | # host = "127.0.0.1" # env variable: HOST 15 | # # secret key needs to be exactly 16 characters long 16 | # secret_key = "CHANGE_ME" # env variable: SERVER_SECRET_KEY 17 | # verify_requests = false # env variable: SERVER_VERIFY_REQUESTS 18 | # encrypt_query_params = false # env variable: SERVER_ENCRYPT_QUERY_PARAMS 19 | # enable_metrics = false # env variable: SERVER_ENABLE_METRICS 20 | 21 | # [cache] 22 | # enabled = true # env variable: CACHE_ENABLED 23 | # # will get cached in /var/tmp/youtubei.js if you specify /var/tmp 24 | # # you need to change the --allow-write from deno run too 25 | # directory = "/var/tmp" # env variable: CACHE_DIRECTORY 26 | 27 | # [networking] 28 | # #proxy = "" # env variable: PROXY 29 | # # Enable YouTube new video format UMP 30 | 31 | # [networking.videoplayback] 32 | # ump = false # env variable: NETWORKING_VIDEOPLAYBACK_UMP 33 | # # size of chunks to request from google servers for rate limiting reductions 34 | # video_fetch_chunk_size_mb = 5 # env variable: NETWORKING_VIDEOPLAYBACK_VIDEO_FETCH_CHUNK_SIZE_MB 35 | 36 | ### 37 | # Network call timeouts when talking to YouTube. 38 | # Needed in order to ensure Deno closes hanging connections 39 | ### 40 | # [networking.fetch] 41 | # timeout_ms = 30000 # env variable: NETWORKING_FETCH_TIMEOUT_MS 42 | 43 | ### 44 | # Network call retries when talking to YouTube, using 45 | # https://docs.deno.com/examples/exponential_backoff/ 46 | ### 47 | # [networking.fetch.retry] 48 | # # enable retries on calls to YouTube 49 | # enabled = false # env variable: NETWORKING_FETCH_RETRY_ENABLED 50 | # # max number of times to retry 51 | # times = 1 # env variable: NETWORKING_FETCH_RETRY_TIMES 52 | # # minimum wait after first call (ms) 53 | # initial_debounce = 0 # env variable: NETWORKING_FETCH_RETRY_INITIAL_DEBOUNCE 54 | # # how much to back off after each retry (multiplier of initial_debounce) 55 | # debounce_multiplier = 0 # env variable: NETWORKING_FETCH_RETRY_DEBOUNCE_MULTIPLIER 56 | 57 | # [jobs] 58 | 59 | # [jobs.youtube_session] 60 | # # whether to generate PO tokens 61 | # po_token_enabled = true # env variable: JOBS_YOUTUBE_SESSION_PO_TOKEN_ENABLED 62 | # # frequency of PO token refresh in cron format 63 | # frequency = "*/5 * * * *" # env variable: JOBS_YOUTUBE_SESSION_FREQUENCY 64 | 65 | # [youtube_session] 66 | # oauth_enabled = false # env variable: YOUTUBE_SESSION_OAUTH_ENABLED 67 | # cookies = "" # env variable: YOUTUBE_SESSION_COOKIES 68 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "dev": "deno run --allow-import=github.com:443,jsr.io:443,cdn.jsdelivr.net:443,esm.sh:443,deno.land:443 --allow-net --allow-env --allow-sys=hostname --allow-read --allow-write=/var/tmp/youtubei.js --watch src/main.ts", 4 | "compile": "deno compile --include ./src/lib/helpers/youtubePlayerReq.ts --include ./src/lib/helpers/getFetchClient.ts --output invidious_companion --allow-import=github.com:443,jsr.io:443,cdn.jsdelivr.net:443,esm.sh:443,deno.land:443 --allow-net --allow-env --allow-read --allow-sys=hostname --allow-write=/var/tmp/youtubei.js src/main.ts --_version_date=\"$(git log -1 --format=%ci | awk '{print $1}' | sed s/-/./g)\" --_version_commit=\"$(git rev-list HEAD --max-count=1 --abbrev-commit)\"", 5 | "test": "deno test --allow-import=github.com:443,jsr.io:443,cdn.jsdelivr.net:443,esm.sh:443,deno.land:443 --allow-net --allow-env --allow-sys=hostname --allow-read --allow-write=/var/tmp/youtubei.js", 6 | "format": "deno fmt src/**" 7 | }, 8 | "imports": { 9 | "@std/cli": "jsr:@std/cli@^1.0.17", 10 | "hono": "jsr:@hono/hono@4.7.4", 11 | "@std/toml": "jsr:@std/toml@1.0.2", 12 | "prom-client": "https://esm.sh/prom-client@15.1.3", 13 | "youtubei.js": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v13.4.0-deno/deno.ts", 14 | "youtubei.js/Utils": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v13.4.0-deno/deno/src/utils/Utils.ts", 15 | "youtubei.js/NavigationEndpoint": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v13.4.0-deno/deno/src/parser/classes/NavigationEndpoint.ts", 16 | "youtubei.js/PlayerCaptionsTracklist": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v13.4.0-deno/deno/src/parser/classes/PlayerCaptionsTracklist.ts", 17 | "jsdom": "npm:jsdom@26.0.0", 18 | "bgutils": "https://esm.sh/bgutils-js@3.2.0", 19 | "estree": "https://esm.sh/@types/estree@1.0.6", 20 | "youtubePlayerReq": "./src/lib/helpers/youtubePlayerReq.ts", 21 | "getFetchClient": "./src/lib/helpers/getFetchClient.ts", 22 | "googlevideo": "jsr:@luanrt/googlevideo@2.0.0", 23 | "jsr:@luanrt/jintr": "jsr:@luanrt/jintr@3.3.1", 24 | "crypto/": "https://deno.land/x/crypto@v0.11.0/", 25 | "@std/encoding/base64": "jsr:@std/encoding@1.0.7/base64", 26 | "@std/async": "jsr:@std/async@1.0.11", 27 | "@std/fs": "jsr:@std/fs@1.0.14", 28 | "@std/path": "jsr:@std/path@1.0.8", 29 | "brotli": "https://deno.land/x/brotli@0.1.7/mod.ts", 30 | "zod": "https://deno.land/x/zod@v3.24.2/mod.ts" 31 | }, 32 | "unstable": [ 33 | "cron", 34 | "kv", 35 | "http", 36 | "temporal" 37 | ], 38 | "fmt": { 39 | "indentWidth": 4 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | invidious_companion: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | # image: quay.io/invidious/invidious-companion:latest 8 | ports: 9 | - 127.0.0.1:8282:8282 10 | restart: unless-stopped 11 | cap_drop: 12 | - ALL 13 | read_only: true 14 | user: 10001:10001 15 | # cache for youtube library 16 | volumes: 17 | - /var/tmp/youtubei.js:/var/tmp/youtubei.js:rw 18 | security_opt: 19 | - no-new-privileges:true 20 | -------------------------------------------------------------------------------- /grafana_dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "type": "dashboard" 15 | } 16 | ] 17 | }, 18 | "editable": true, 19 | "fiscalYearStartMonth": 0, 20 | "graphTooltip": 0, 21 | "id": 38, 22 | "links": [], 23 | "panels": [ 24 | { 25 | "collapsed": true, 26 | "gridPos": { 27 | "h": 1, 28 | "w": 24, 29 | "x": 0, 30 | "y": 0 31 | }, 32 | "id": 18, 33 | "panels": [ 34 | { 35 | "datasource": { 36 | "type": "prometheus", 37 | "uid": "bdy7ccu1ntm2oa" 38 | }, 39 | "fieldConfig": { 40 | "defaults": { 41 | "color": { 42 | "mode": "palette-classic" 43 | }, 44 | "custom": { 45 | "axisBorderShow": false, 46 | "axisCenteredZero": false, 47 | "axisColorMode": "text", 48 | "axisLabel": "", 49 | "axisPlacement": "auto", 50 | "barAlignment": 0, 51 | "barWidthFactor": 0.6, 52 | "drawStyle": "line", 53 | "fillOpacity": 0, 54 | "gradientMode": "none", 55 | "hideFrom": { 56 | "legend": false, 57 | "tooltip": false, 58 | "viz": false 59 | }, 60 | "insertNulls": false, 61 | "lineInterpolation": "linear", 62 | "lineWidth": 1, 63 | "pointSize": 5, 64 | "scaleDistribution": { 65 | "type": "linear" 66 | }, 67 | "showPoints": "auto", 68 | "spanNulls": true, 69 | "stacking": { 70 | "group": "A", 71 | "mode": "none" 72 | }, 73 | "thresholdsStyle": { 74 | "mode": "off" 75 | } 76 | }, 77 | "mappings": [], 78 | "thresholds": { 79 | "mode": "absolute", 80 | "steps": [ 81 | { 82 | "color": "green", 83 | "value": null 84 | }, 85 | { 86 | "color": "red", 87 | "value": 80 88 | } 89 | ] 90 | } 91 | }, 92 | "overrides": [] 93 | }, 94 | "gridPos": { 95 | "h": 10, 96 | "w": 12, 97 | "x": 0, 98 | "y": 1 99 | }, 100 | "id": 12, 101 | "options": { 102 | "legend": { 103 | "calcs": [], 104 | "displayMode": "list", 105 | "placement": "bottom", 106 | "showLegend": true 107 | }, 108 | "tooltip": { 109 | "hideZeros": false, 110 | "mode": "single", 111 | "sort": "none" 112 | } 113 | }, 114 | "pluginVersion": "11.5.1", 115 | "targets": [ 116 | { 117 | "datasource": { 118 | "type": "prometheus", 119 | "uid": "bdy7ccu1ntm2oa" 120 | }, 121 | "disableTextWrap": false, 122 | "editorMode": "builder", 123 | "expr": "rate(invidious_companion_innertube_successful_request_total[$__rate_interval])", 124 | "fullMetaSearch": false, 125 | "includeNullMetadata": false, 126 | "legendFormat": "{{instance}}", 127 | "range": true, 128 | "refId": "A", 129 | "useBackend": false 130 | } 131 | ], 132 | "title": "Successful Requests Rate", 133 | "type": "timeseries" 134 | }, 135 | { 136 | "datasource": { 137 | "type": "prometheus", 138 | "uid": "bdy7ccu1ntm2oa" 139 | }, 140 | "fieldConfig": { 141 | "defaults": { 142 | "color": { 143 | "mode": "palette-classic" 144 | }, 145 | "custom": { 146 | "axisBorderShow": false, 147 | "axisCenteredZero": false, 148 | "axisColorMode": "text", 149 | "axisLabel": "", 150 | "axisPlacement": "auto", 151 | "barAlignment": 0, 152 | "barWidthFactor": 0.6, 153 | "drawStyle": "line", 154 | "fillOpacity": 0, 155 | "gradientMode": "none", 156 | "hideFrom": { 157 | "legend": false, 158 | "tooltip": false, 159 | "viz": false 160 | }, 161 | "insertNulls": false, 162 | "lineInterpolation": "linear", 163 | "lineWidth": 1, 164 | "pointSize": 5, 165 | "scaleDistribution": { 166 | "type": "linear" 167 | }, 168 | "showPoints": "auto", 169 | "spanNulls": true, 170 | "stacking": { 171 | "group": "A", 172 | "mode": "none" 173 | }, 174 | "thresholdsStyle": { 175 | "mode": "off" 176 | } 177 | }, 178 | "mappings": [], 179 | "thresholds": { 180 | "mode": "absolute", 181 | "steps": [ 182 | { 183 | "color": "green", 184 | "value": null 185 | }, 186 | { 187 | "color": "red", 188 | "value": 80 189 | } 190 | ] 191 | } 192 | }, 193 | "overrides": [] 194 | }, 195 | "gridPos": { 196 | "h": 10, 197 | "w": 12, 198 | "x": 12, 199 | "y": 1 200 | }, 201 | "id": 13, 202 | "options": { 203 | "legend": { 204 | "calcs": [], 205 | "displayMode": "list", 206 | "placement": "bottom", 207 | "showLegend": true 208 | }, 209 | "tooltip": { 210 | "hideZeros": false, 211 | "mode": "single", 212 | "sort": "none" 213 | } 214 | }, 215 | "pluginVersion": "11.5.1", 216 | "targets": [ 217 | { 218 | "datasource": { 219 | "type": "prometheus", 220 | "uid": "bdy7ccu1ntm2oa" 221 | }, 222 | "disableTextWrap": false, 223 | "editorMode": "builder", 224 | "expr": "rate(invidious_companion_innertube_failed_request_total[$__rate_interval])", 225 | "fullMetaSearch": false, 226 | "includeNullMetadata": false, 227 | "legendFormat": "{{instance}}", 228 | "range": true, 229 | "refId": "A", 230 | "useBackend": false 231 | } 232 | ], 233 | "title": "Failed Requests Rate", 234 | "type": "timeseries" 235 | } 236 | ], 237 | "title": "Requests", 238 | "type": "row" 239 | }, 240 | { 241 | "collapsed": true, 242 | "gridPos": { 243 | "h": 1, 244 | "w": 24, 245 | "x": 0, 246 | "y": 1 247 | }, 248 | "id": 19, 249 | "panels": [ 250 | { 251 | "datasource": { 252 | "type": "prometheus", 253 | "uid": "bdy7ccu1ntm2oa" 254 | }, 255 | "fieldConfig": { 256 | "defaults": { 257 | "color": { 258 | "mode": "palette-classic" 259 | }, 260 | "custom": { 261 | "axisBorderShow": false, 262 | "axisCenteredZero": false, 263 | "axisColorMode": "text", 264 | "axisLabel": "", 265 | "axisPlacement": "auto", 266 | "barAlignment": 0, 267 | "barWidthFactor": 0.6, 268 | "drawStyle": "line", 269 | "fillOpacity": 0, 270 | "gradientMode": "none", 271 | "hideFrom": { 272 | "legend": false, 273 | "tooltip": false, 274 | "viz": false 275 | }, 276 | "insertNulls": false, 277 | "lineInterpolation": "linear", 278 | "lineStyle": { 279 | "fill": "solid" 280 | }, 281 | "lineWidth": 1, 282 | "pointSize": 5, 283 | "scaleDistribution": { 284 | "type": "linear" 285 | }, 286 | "showPoints": "auto", 287 | "spanNulls": true, 288 | "stacking": { 289 | "group": "A", 290 | "mode": "none" 291 | }, 292 | "thresholdsStyle": { 293 | "mode": "off" 294 | } 295 | }, 296 | "mappings": [], 297 | "thresholds": { 298 | "mode": "absolute", 299 | "steps": [ 300 | { 301 | "color": "green", 302 | "value": null 303 | }, 304 | { 305 | "color": "red", 306 | "value": 80 307 | } 308 | ] 309 | } 310 | }, 311 | "overrides": [] 312 | }, 313 | "gridPos": { 314 | "h": 10, 315 | "w": 12, 316 | "x": 0, 317 | "y": 2 318 | }, 319 | "id": 15, 320 | "options": { 321 | "legend": { 322 | "calcs": [], 323 | "displayMode": "list", 324 | "placement": "bottom", 325 | "showLegend": true 326 | }, 327 | "tooltip": { 328 | "hideZeros": false, 329 | "mode": "single", 330 | "sort": "none" 331 | } 332 | }, 333 | "pluginVersion": "11.5.1", 334 | "targets": [ 335 | { 336 | "datasource": { 337 | "type": "prometheus", 338 | "uid": "bdy7ccu1ntm2oa" 339 | }, 340 | "disableTextWrap": false, 341 | "editorMode": "builder", 342 | "expr": "rate(invidious_companion_innertube_error_status_unknown_total[$__rate_interval])", 343 | "fullMetaSearch": false, 344 | "includeNullMetadata": false, 345 | "legendFormat": "{{instance}}", 346 | "range": true, 347 | "refId": "A", 348 | "useBackend": false 349 | } 350 | ], 351 | "title": "Unknown Status Rate", 352 | "type": "timeseries" 353 | }, 354 | { 355 | "datasource": { 356 | "type": "prometheus", 357 | "uid": "bdy7ccu1ntm2oa" 358 | }, 359 | "fieldConfig": { 360 | "defaults": { 361 | "color": { 362 | "mode": "palette-classic" 363 | }, 364 | "custom": { 365 | "axisBorderShow": false, 366 | "axisCenteredZero": false, 367 | "axisColorMode": "text", 368 | "axisLabel": "", 369 | "axisPlacement": "auto", 370 | "barAlignment": 0, 371 | "barWidthFactor": 0.6, 372 | "drawStyle": "line", 373 | "fillOpacity": 0, 374 | "gradientMode": "none", 375 | "hideFrom": { 376 | "legend": false, 377 | "tooltip": false, 378 | "viz": false 379 | }, 380 | "insertNulls": false, 381 | "lineInterpolation": "linear", 382 | "lineStyle": { 383 | "fill": "solid" 384 | }, 385 | "lineWidth": 1, 386 | "pointSize": 5, 387 | "scaleDistribution": { 388 | "type": "linear" 389 | }, 390 | "showPoints": "auto", 391 | "spanNulls": true, 392 | "stacking": { 393 | "group": "A", 394 | "mode": "none" 395 | }, 396 | "thresholdsStyle": { 397 | "mode": "off" 398 | } 399 | }, 400 | "mappings": [], 401 | "thresholds": { 402 | "mode": "absolute", 403 | "steps": [ 404 | { 405 | "color": "green", 406 | "value": null 407 | }, 408 | { 409 | "color": "red", 410 | "value": 80 411 | } 412 | ] 413 | } 414 | }, 415 | "overrides": [] 416 | }, 417 | "gridPos": { 418 | "h": 10, 419 | "w": 12, 420 | "x": 12, 421 | "y": 2 422 | }, 423 | "id": 22, 424 | "options": { 425 | "legend": { 426 | "calcs": [], 427 | "displayMode": "list", 428 | "placement": "bottom", 429 | "showLegend": true 430 | }, 431 | "tooltip": { 432 | "hideZeros": false, 433 | "mode": "single", 434 | "sort": "none" 435 | } 436 | }, 437 | "pluginVersion": "11.5.1", 438 | "targets": [ 439 | { 440 | "datasource": { 441 | "type": "prometheus", 442 | "uid": "bdy7ccu1ntm2oa" 443 | }, 444 | "disableTextWrap": false, 445 | "editorMode": "code", 446 | "expr": "rate(invidious_companion_innertube_error_status_loginRequired_total[$__rate_interval])", 447 | "fullMetaSearch": false, 448 | "includeNullMetadata": false, 449 | "legendFormat": "{{instance}}", 450 | "range": true, 451 | "refId": "A", 452 | "useBackend": false 453 | } 454 | ], 455 | "title": "\"LOGIN_REQUIRED\" Rate", 456 | "type": "timeseries" 457 | } 458 | ], 459 | "title": "Status", 460 | "type": "row" 461 | }, 462 | { 463 | "collapsed": true, 464 | "gridPos": { 465 | "h": 1, 466 | "w": 24, 467 | "x": 0, 468 | "y": 2 469 | }, 470 | "id": 8, 471 | "panels": [ 472 | { 473 | "datasource": { 474 | "type": "prometheus", 475 | "uid": "bdy7ccu1ntm2oa" 476 | }, 477 | "fieldConfig": { 478 | "defaults": { 479 | "color": { 480 | "mode": "palette-classic" 481 | }, 482 | "custom": { 483 | "axisBorderShow": false, 484 | "axisCenteredZero": false, 485 | "axisColorMode": "text", 486 | "axisLabel": "", 487 | "axisPlacement": "auto", 488 | "barAlignment": 0, 489 | "barWidthFactor": 0.6, 490 | "drawStyle": "line", 491 | "fillOpacity": 0, 492 | "gradientMode": "none", 493 | "hideFrom": { 494 | "legend": false, 495 | "tooltip": false, 496 | "viz": false 497 | }, 498 | "insertNulls": false, 499 | "lineInterpolation": "linear", 500 | "lineStyle": { 501 | "fill": "solid" 502 | }, 503 | "lineWidth": 1, 504 | "pointSize": 5, 505 | "scaleDistribution": { 506 | "type": "linear" 507 | }, 508 | "showPoints": "auto", 509 | "spanNulls": true, 510 | "stacking": { 511 | "group": "A", 512 | "mode": "none" 513 | }, 514 | "thresholdsStyle": { 515 | "mode": "off" 516 | } 517 | }, 518 | "mappings": [], 519 | "thresholds": { 520 | "mode": "absolute", 521 | "steps": [ 522 | { 523 | "color": "green", 524 | "value": null 525 | }, 526 | { 527 | "color": "red", 528 | "value": 80 529 | } 530 | ] 531 | } 532 | }, 533 | "overrides": [] 534 | }, 535 | "gridPos": { 536 | "h": 10, 537 | "w": 12, 538 | "x": 0, 539 | "y": 23 540 | }, 541 | "id": 14, 542 | "options": { 543 | "legend": { 544 | "calcs": [], 545 | "displayMode": "list", 546 | "placement": "bottom", 547 | "showLegend": true 548 | }, 549 | "tooltip": { 550 | "hideZeros": false, 551 | "mode": "single", 552 | "sort": "none" 553 | } 554 | }, 555 | "pluginVersion": "11.5.1", 556 | "targets": [ 557 | { 558 | "datasource": { 559 | "type": "prometheus", 560 | "uid": "bdy7ccu1ntm2oa" 561 | }, 562 | "disableTextWrap": false, 563 | "editorMode": "builder", 564 | "expr": "rate(invidious_companion_innertube_error_reason_unknown_total[$__rate_interval])", 565 | "fullMetaSearch": false, 566 | "includeNullMetadata": false, 567 | "legendFormat": "{{instance}}", 568 | "range": true, 569 | "refId": "A", 570 | "useBackend": false 571 | } 572 | ], 573 | "title": "Unknown Reason Rate", 574 | "type": "timeseries" 575 | }, 576 | { 577 | "datasource": { 578 | "type": "prometheus", 579 | "uid": "bdy7ccu1ntm2oa" 580 | }, 581 | "fieldConfig": { 582 | "defaults": { 583 | "color": { 584 | "mode": "palette-classic" 585 | }, 586 | "custom": { 587 | "axisBorderShow": false, 588 | "axisCenteredZero": false, 589 | "axisColorMode": "text", 590 | "axisLabel": "", 591 | "axisPlacement": "auto", 592 | "barAlignment": 0, 593 | "barWidthFactor": 0.6, 594 | "drawStyle": "line", 595 | "fillOpacity": 0, 596 | "gradientMode": "none", 597 | "hideFrom": { 598 | "legend": false, 599 | "tooltip": false, 600 | "viz": false 601 | }, 602 | "insertNulls": false, 603 | "lineInterpolation": "linear", 604 | "lineStyle": { 605 | "fill": "solid" 606 | }, 607 | "lineWidth": 1, 608 | "pointSize": 5, 609 | "scaleDistribution": { 610 | "type": "linear" 611 | }, 612 | "showPoints": "auto", 613 | "spanNulls": true, 614 | "stacking": { 615 | "group": "A", 616 | "mode": "none" 617 | }, 618 | "thresholdsStyle": { 619 | "mode": "off" 620 | } 621 | }, 622 | "mappings": [], 623 | "thresholds": { 624 | "mode": "absolute", 625 | "steps": [ 626 | { 627 | "color": "green", 628 | "value": null 629 | }, 630 | { 631 | "color": "red", 632 | "value": 80 633 | } 634 | ] 635 | } 636 | }, 637 | "overrides": [] 638 | }, 639 | "gridPos": { 640 | "h": 10, 641 | "w": 12, 642 | "x": 12, 643 | "y": 23 644 | }, 645 | "id": 4, 646 | "options": { 647 | "legend": { 648 | "calcs": [], 649 | "displayMode": "list", 650 | "placement": "bottom", 651 | "showLegend": true 652 | }, 653 | "tooltip": { 654 | "hideZeros": false, 655 | "mode": "single", 656 | "sort": "none" 657 | } 658 | }, 659 | "pluginVersion": "11.5.1", 660 | "targets": [ 661 | { 662 | "datasource": { 663 | "type": "prometheus", 664 | "uid": "bdy7ccu1ntm2oa" 665 | }, 666 | "disableTextWrap": false, 667 | "editorMode": "builder", 668 | "expr": "rate(invidious_companion_innertube_error_reason_SignIn_total[$__rate_interval])", 669 | "fullMetaSearch": false, 670 | "includeNullMetadata": false, 671 | "legendFormat": "{{instance}}", 672 | "range": true, 673 | "refId": "A", 674 | "useBackend": false 675 | } 676 | ], 677 | "title": "\"Sign in to confirm you’re not a bot.\" Rate", 678 | "type": "timeseries" 679 | } 680 | ], 681 | "title": "Reasons", 682 | "type": "row" 683 | }, 684 | { 685 | "collapsed": true, 686 | "gridPos": { 687 | "h": 1, 688 | "w": 24, 689 | "x": 0, 690 | "y": 3 691 | }, 692 | "id": 9, 693 | "panels": [ 694 | { 695 | "datasource": { 696 | "type": "prometheus", 697 | "uid": "bdy7ccu1ntm2oa" 698 | }, 699 | "fieldConfig": { 700 | "defaults": { 701 | "color": { 702 | "mode": "palette-classic" 703 | }, 704 | "custom": { 705 | "axisBorderShow": false, 706 | "axisCenteredZero": false, 707 | "axisColorMode": "text", 708 | "axisLabel": "", 709 | "axisPlacement": "auto", 710 | "barAlignment": 0, 711 | "barWidthFactor": 0.6, 712 | "drawStyle": "line", 713 | "fillOpacity": 0, 714 | "gradientMode": "none", 715 | "hideFrom": { 716 | "legend": false, 717 | "tooltip": false, 718 | "viz": false 719 | }, 720 | "insertNulls": false, 721 | "lineInterpolation": "linear", 722 | "lineWidth": 1, 723 | "pointSize": 5, 724 | "scaleDistribution": { 725 | "type": "linear" 726 | }, 727 | "showPoints": "auto", 728 | "spanNulls": true, 729 | "stacking": { 730 | "group": "A", 731 | "mode": "none" 732 | }, 733 | "thresholdsStyle": { 734 | "mode": "off" 735 | } 736 | }, 737 | "mappings": [], 738 | "thresholds": { 739 | "mode": "absolute", 740 | "steps": [ 741 | { 742 | "color": "green", 743 | "value": null 744 | }, 745 | { 746 | "color": "red", 747 | "value": 80 748 | } 749 | ] 750 | } 751 | }, 752 | "overrides": [] 753 | }, 754 | "gridPos": { 755 | "h": 10, 756 | "w": 12, 757 | "x": 0, 758 | "y": 34 759 | }, 760 | "id": 21, 761 | "options": { 762 | "legend": { 763 | "calcs": [], 764 | "displayMode": "list", 765 | "placement": "bottom", 766 | "showLegend": true 767 | }, 768 | "tooltip": { 769 | "hideZeros": false, 770 | "mode": "single", 771 | "sort": "none" 772 | } 773 | }, 774 | "pluginVersion": "11.5.1", 775 | "targets": [ 776 | { 777 | "datasource": { 778 | "type": "prometheus", 779 | "uid": "bdy7ccu1ntm2oa" 780 | }, 781 | "disableTextWrap": false, 782 | "editorMode": "code", 783 | "expr": "rate(invidious_companion_innertube_error_subreason_unknown_total[$__rate_interval])", 784 | "fullMetaSearch": false, 785 | "includeNullMetadata": false, 786 | "legendFormat": "{{instance}}", 787 | "range": true, 788 | "refId": "A", 789 | "useBackend": false 790 | } 791 | ], 792 | "title": "Unknown Subreason Rate", 793 | "type": "timeseries" 794 | }, 795 | { 796 | "datasource": { 797 | "type": "prometheus", 798 | "uid": "bdy7ccu1ntm2oa" 799 | }, 800 | "fieldConfig": { 801 | "defaults": { 802 | "color": { 803 | "mode": "palette-classic" 804 | }, 805 | "custom": { 806 | "axisBorderShow": false, 807 | "axisCenteredZero": false, 808 | "axisColorMode": "text", 809 | "axisLabel": "", 810 | "axisPlacement": "auto", 811 | "barAlignment": 0, 812 | "barWidthFactor": 0.6, 813 | "drawStyle": "line", 814 | "fillOpacity": 0, 815 | "gradientMode": "none", 816 | "hideFrom": { 817 | "legend": false, 818 | "tooltip": false, 819 | "viz": false 820 | }, 821 | "insertNulls": false, 822 | "lineInterpolation": "linear", 823 | "lineWidth": 1, 824 | "pointSize": 5, 825 | "scaleDistribution": { 826 | "type": "linear" 827 | }, 828 | "showPoints": "auto", 829 | "spanNulls": true, 830 | "stacking": { 831 | "group": "A", 832 | "mode": "none" 833 | }, 834 | "thresholdsStyle": { 835 | "mode": "off" 836 | } 837 | }, 838 | "mappings": [], 839 | "thresholds": { 840 | "mode": "absolute", 841 | "steps": [ 842 | { 843 | "color": "green", 844 | "value": null 845 | }, 846 | { 847 | "color": "red", 848 | "value": 80 849 | } 850 | ] 851 | } 852 | }, 853 | "overrides": [] 854 | }, 855 | "gridPos": { 856 | "h": 10, 857 | "w": 12, 858 | "x": 12, 859 | "y": 34 860 | }, 861 | "id": 6, 862 | "options": { 863 | "legend": { 864 | "calcs": [], 865 | "displayMode": "list", 866 | "placement": "bottom", 867 | "showLegend": true 868 | }, 869 | "tooltip": { 870 | "hideZeros": false, 871 | "mode": "single", 872 | "sort": "none" 873 | } 874 | }, 875 | "pluginVersion": "11.5.1", 876 | "targets": [ 877 | { 878 | "datasource": { 879 | "type": "prometheus", 880 | "uid": "bdy7ccu1ntm2oa" 881 | }, 882 | "disableTextWrap": false, 883 | "editorMode": "builder", 884 | "expr": "rate(invidious_companion_innertube_error_subreason_ProtectCommunity_total[$__rate_interval])", 885 | "fullMetaSearch": false, 886 | "includeNullMetadata": false, 887 | "legendFormat": "{{instance}}", 888 | "range": true, 889 | "refId": "A", 890 | "useBackend": false 891 | } 892 | ], 893 | "title": "\"This helps protect our community.\" Rate", 894 | "type": "timeseries" 895 | } 896 | ], 897 | "title": "Subreasons", 898 | "type": "row" 899 | }, 900 | { 901 | "collapsed": true, 902 | "gridPos": { 903 | "h": 1, 904 | "w": 24, 905 | "x": 0, 906 | "y": 4 907 | }, 908 | "id": 20, 909 | "panels": [ 910 | { 911 | "datasource": { 912 | "type": "prometheus", 913 | "uid": "bdy7ccu1ntm2oa" 914 | }, 915 | "fieldConfig": { 916 | "defaults": { 917 | "color": { 918 | "mode": "palette-classic" 919 | }, 920 | "custom": { 921 | "axisBorderShow": false, 922 | "axisCenteredZero": false, 923 | "axisColorMode": "text", 924 | "axisLabel": "", 925 | "axisPlacement": "auto", 926 | "barAlignment": 0, 927 | "barWidthFactor": 0.6, 928 | "drawStyle": "line", 929 | "fillOpacity": 0, 930 | "gradientMode": "none", 931 | "hideFrom": { 932 | "legend": false, 933 | "tooltip": false, 934 | "viz": false 935 | }, 936 | "insertNulls": false, 937 | "lineInterpolation": "linear", 938 | "lineStyle": { 939 | "fill": "solid" 940 | }, 941 | "lineWidth": 1, 942 | "pointSize": 5, 943 | "scaleDistribution": { 944 | "type": "linear" 945 | }, 946 | "showPoints": "auto", 947 | "spanNulls": true, 948 | "stacking": { 949 | "group": "A", 950 | "mode": "none" 951 | }, 952 | "thresholdsStyle": { 953 | "mode": "off" 954 | } 955 | }, 956 | "mappings": [], 957 | "thresholds": { 958 | "mode": "absolute", 959 | "steps": [ 960 | { 961 | "color": "green", 962 | "value": null 963 | }, 964 | { 965 | "color": "red", 966 | "value": 80 967 | } 968 | ] 969 | } 970 | }, 971 | "overrides": [] 972 | }, 973 | "gridPos": { 974 | "h": 10, 975 | "w": 12, 976 | "x": 0, 977 | "y": 185 978 | }, 979 | "id": 16, 980 | "options": { 981 | "legend": { 982 | "calcs": [], 983 | "displayMode": "list", 984 | "placement": "bottom", 985 | "showLegend": true 986 | }, 987 | "tooltip": { 988 | "hideZeros": false, 989 | "mode": "single", 990 | "sort": "none" 991 | } 992 | }, 993 | "pluginVersion": "11.5.1", 994 | "targets": [ 995 | { 996 | "datasource": { 997 | "type": "prometheus", 998 | "uid": "bdy7ccu1ntm2oa" 999 | }, 1000 | "disableTextWrap": false, 1001 | "editorMode": "builder", 1002 | "expr": "rate(invidious_companion_potoken_generation_failure_total[$__rate_interval])", 1003 | "fullMetaSearch": false, 1004 | "includeNullMetadata": false, 1005 | "legendFormat": "{{instance}}", 1006 | "range": true, 1007 | "refId": "A", 1008 | "useBackend": false 1009 | } 1010 | ], 1011 | "title": "poToken Generation Failure Rate", 1012 | "type": "timeseries" 1013 | } 1014 | ], 1015 | "title": "Jobs", 1016 | "type": "row" 1017 | } 1018 | ], 1019 | "preload": false, 1020 | "refresh": "5s", 1021 | "schemaVersion": 40, 1022 | "tags": [], 1023 | "templating": { 1024 | "list": [] 1025 | }, 1026 | "time": { 1027 | "from": "now-30m", 1028 | "to": "now" 1029 | }, 1030 | "timepicker": {}, 1031 | "timezone": "browser", 1032 | "title": "Invidious Companion", 1033 | "uid": "1-0-0", 1034 | "version": 20, 1035 | "weekStart": "" 1036 | } -------------------------------------------------------------------------------- /src/lib/helpers/config.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodError } from "zod"; 2 | import { parse } from "@std/toml"; 3 | 4 | export const ConfigSchema = z.object({ 5 | server: z.object({ 6 | port: z.number().default(Number(Deno.env.get("PORT")) || 8282), 7 | host: z.string().default(Deno.env.get("HOST") || "127.0.0.1"), 8 | secret_key: z.string().length(16).default( 9 | Deno.env.get("SERVER_SECRET_KEY") || "", 10 | ), 11 | verify_requests: z.boolean().default( 12 | Deno.env.get("SERVER_VERIFY_REQUESTS") === "true" || false, 13 | ), 14 | encrypt_query_params: z.boolean().default( 15 | Deno.env.get("SERVER_ENCRYPT_QUERY_PARAMS") === "true" || false, 16 | ), 17 | enable_metrics: z.boolean().default( 18 | Deno.env.get("SERVER_ENABLE_METRICS") === "true" || false, 19 | ), 20 | }).strict().default({}), 21 | cache: z.object({ 22 | enabled: z.boolean().default( 23 | Deno.env.get("CACHE_ENABLED") === "false" ? false : true, 24 | ), 25 | directory: z.string().default( 26 | Deno.env.get("CACHE_DIRECTORY") || "/var/tmp", 27 | ), 28 | }).strict().default({}), 29 | networking: z.object({ 30 | proxy: z.string().nullable().default(Deno.env.get("PROXY") || null), 31 | fetch: z.object({ 32 | timeout_ms: z.number().default( 33 | Number(Deno.env.get("NETWORKING_FETCH_TIMEOUT_MS")) || 30_000, 34 | ), 35 | retry: z.object({ 36 | enabled: z.boolean().default( 37 | Deno.env.get("NETWORKING_FETCH_RETRY_ENABLED") === "true" || 38 | false, 39 | ), 40 | times: z.number().optional().default( 41 | Number(Deno.env.get("NETWORKING_FETCH_RETRY_TIMES")) || 1, 42 | ), 43 | initial_debounce: z.number().optional().default( 44 | Number( 45 | Deno.env.get("NETWORKING_FETCH_RETRY_INITIAL_DEBOUNCE"), 46 | ) || 0, 47 | ), 48 | debounce_multiplier: z.number().optional().default( 49 | Number( 50 | Deno.env.get( 51 | "NETWORKING_FETCH_RETRY_DEBOUNCE_MULTIPLIER", 52 | ), 53 | ) || 0, 54 | ), 55 | }).strict().default({}), 56 | }).strict().default({}), 57 | videoplayback: z.object({ 58 | ump: z.boolean().default( 59 | Deno.env.get("NETWORKING_VIDEOPLAYBACK_UMP") === "true" || 60 | false, 61 | ), 62 | video_fetch_chunk_size_mb: z.number().default( 63 | Number( 64 | Deno.env.get( 65 | "NETWORKING_VIDEOPLAYBACK_VIDEO_FETCH_CHUNK_SIZE_MB", 66 | ), 67 | ) || 5, 68 | ), 69 | }).strict().default({}), 70 | }).strict().default({}), 71 | jobs: z.object({ 72 | youtube_session: z.object({ 73 | po_token_enabled: z.boolean().default( 74 | Deno.env.get("JOBS_YOUTUBE_SESSION_PO_TOKEN_ENABLED") === 75 | "false" 76 | ? false 77 | : true, 78 | ), 79 | frequency: z.string().default( 80 | Deno.env.get("JOBS_YOUTUBE_SESSION_FREQUENCY") || "*/5 * * * *", 81 | ), 82 | }).strict().default({}), 83 | }).strict().default({}), 84 | youtube_session: z.object({ 85 | oauth_enabled: z.boolean().default( 86 | Deno.env.get("YOUTUBE_SESSION_OAUTH_ENABLED") === "true" || false, 87 | ), 88 | cookies: z.string().default( 89 | Deno.env.get("YOUTUBE_SESSION_COOKIES") || "", 90 | ), 91 | }).strict().default({}), 92 | }).strict(); 93 | 94 | export type Config = z.infer; 95 | 96 | export async function parseConfig() { 97 | const configFileName = Deno.env.get("CONFIG_FILE") || "config/config.toml"; 98 | const configFileContents = await Deno.readTextFile(configFileName).catch( 99 | () => null, 100 | ); 101 | if (configFileContents) { 102 | console.log("[INFO] Using custom settings local file"); 103 | } else { 104 | console.log( 105 | "[INFO] No local config file found, using default config", 106 | ); 107 | } 108 | 109 | try { 110 | const rawConfig = configFileContents ? parse(configFileContents) : {}; 111 | const validatedConfig = ConfigSchema.parse(rawConfig); 112 | 113 | console.log("Loaded Configuration", validatedConfig); 114 | 115 | return validatedConfig; 116 | } catch (err) { 117 | let errorMessage = 118 | "There is an error in your configuration, check your environment variables"; 119 | if (configFileContents) { 120 | errorMessage += 121 | ` or in your configuration file located at ${configFileName}`; 122 | } 123 | console.log(errorMessage); 124 | if (err instanceof ZodError) { 125 | console.log(err.issues); 126 | throw new Error("Failed to parse configuration file"); 127 | } 128 | // rethrow error if not Zod 129 | throw err; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/lib/helpers/encodeRFC5987ValueChars.ts: -------------------------------------------------------------------------------- 1 | // Taken from 2 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_content-disposition_and_link_headers 3 | export function encodeRFC5987ValueChars(str: string) { 4 | return ( 5 | encodeURIComponent(str) 6 | // The following creates the sequences %27 %28 %29 %2A (Note that 7 | // the valid encoding of "*" is %2A, which necessitates calling 8 | // toUpperCase() to properly encode). Although RFC3986 reserves "!", 9 | // RFC5987 does not, so we do not need to escape it. 10 | .replace( 11 | /['()*]/g, 12 | (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, 13 | ) 14 | // The following are not required for percent-encoding per RFC5987, 15 | // so we can allow for a little better readability over the wire: |`^ 16 | .replace( 17 | /%(7C|60|5E)/g, 18 | (_str, hex) => String.fromCharCode(parseInt(hex, 16)), 19 | ) 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/helpers/encryptQuery.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; 2 | import { Aes } from "crypto/aes.ts"; 3 | import { Ecb, Padding } from "crypto/block-modes.ts"; 4 | import type { Config } from "./config.ts"; 5 | 6 | export const encryptQuery = ( 7 | queryParams: string, 8 | config: Config, 9 | ): string => { 10 | try { 11 | const cipher = new Ecb( 12 | Aes, 13 | new TextEncoder().encode( 14 | config.server.secret_key, 15 | ), 16 | Padding.PKCS7, 17 | ); 18 | 19 | const encodedData = new TextEncoder().encode( 20 | queryParams, 21 | ); 22 | 23 | const encryptedData = cipher.encrypt(encodedData); 24 | 25 | return encodeBase64(encryptedData); 26 | } catch (err) { 27 | console.error("[ERROR] Failed to encrypt query parameters:", err); 28 | return ""; 29 | } 30 | }; 31 | 32 | export const decryptQuery = ( 33 | queryParams: string, 34 | config: Config, 35 | ): string => { 36 | try { 37 | const decipher = new Ecb( 38 | Aes, 39 | new TextEncoder().encode(config.server.secret_key), 40 | Padding.PKCS7, 41 | ); 42 | 43 | const decryptedData = new TextDecoder().decode( 44 | decipher.decrypt( 45 | decodeBase64( 46 | queryParams, 47 | ), 48 | ), 49 | ); 50 | 51 | return decryptedData; 52 | } catch (err) { 53 | console.error("[ERROR] Failed to decrypt query parameters:", err); 54 | return ""; 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /src/lib/helpers/getFetchClient.ts: -------------------------------------------------------------------------------- 1 | import { retry, type RetryOptions } from "@std/async"; 2 | import type { Config } from "./config.ts"; 3 | 4 | type FetchInputParameter = Parameters[0]; 5 | type FetchInitParameterWithClient = 6 | | RequestInit 7 | | RequestInit & { client: Deno.HttpClient }; 8 | type FetchReturn = ReturnType; 9 | 10 | export const getFetchClient = (config: Config): { 11 | ( 12 | input: FetchInputParameter, 13 | init?: FetchInitParameterWithClient, 14 | ): FetchReturn; 15 | } => { 16 | const proxyAddress = config.networking.proxy; 17 | if (proxyAddress) { 18 | return async ( 19 | input: FetchInputParameter, 20 | init?: RequestInit, 21 | ) => { 22 | const client = Deno.createHttpClient({ 23 | proxy: { 24 | url: proxyAddress, 25 | }, 26 | }); 27 | const fetchRes = await fetchShim(config, input, { 28 | client, 29 | headers: init?.headers, 30 | method: init?.method, 31 | body: init?.body, 32 | }); 33 | return new Response(fetchRes.body, { 34 | status: fetchRes.status, 35 | headers: fetchRes.headers, 36 | }); 37 | }; 38 | } 39 | 40 | return (input: FetchInputParameter, init?: FetchInitParameterWithClient) => 41 | fetchShim(config, input, init); 42 | }; 43 | 44 | function fetchShim( 45 | config: Config, 46 | input: FetchInputParameter, 47 | init?: FetchInitParameterWithClient, 48 | ): FetchReturn { 49 | const fetchTimeout = config.networking.fetch?.timeout_ms; 50 | const fetchRetry = config.networking.fetch?.retry?.enabled; 51 | const fetchMaxAttempts = config.networking.fetch?.retry?.times; 52 | const fetchInitialDebounce = config.networking.fetch?.retry 53 | ?.initial_debounce; 54 | const fetchDebounceMultiplier = config.networking.fetch?.retry 55 | ?.debounce_multiplier; 56 | const retryOptions: RetryOptions = { 57 | maxAttempts: fetchMaxAttempts, 58 | minTimeout: fetchInitialDebounce, 59 | multiplier: fetchDebounceMultiplier, 60 | jitter: 0, 61 | }; 62 | 63 | const callFetch = () => 64 | fetch(input, { 65 | // only set the AbortSignal if the timeout is supplied in the config 66 | signal: fetchTimeout 67 | ? AbortSignal.timeout(Number(fetchTimeout)) 68 | : null, 69 | ...(init || {}), 70 | }); 71 | // if retry enabled, call retry with the fetch shim, otherwise pass the fetch shim back directly 72 | return fetchRetry ? retry(callFetch, retryOptions) : callFetch(); 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/helpers/metrics.ts: -------------------------------------------------------------------------------- 1 | import { IRawResponse } from "youtubei.js"; 2 | import { Counter, Registry } from "prom-client"; 3 | 4 | export class Metrics { 5 | private METRICS_PREFIX = "invidious_companion_"; 6 | public register = new Registry(); 7 | 8 | public createCounter(name: string, help?: string): Counter { 9 | return new Counter({ 10 | name: `${this.METRICS_PREFIX}${name}`, 11 | help: help || "No help has been provided for this metric", 12 | registers: [this.register], 13 | }); 14 | } 15 | 16 | public potokenGenerationFailure = this.createCounter( 17 | "potoken_generation_failure_total", 18 | "Number of times that the PoToken generation job has failed for whatever reason", 19 | ); 20 | 21 | private innertubeErrorStatusLoginRequired = this.createCounter( 22 | "innertube_error_status_loginRequired_total", 23 | 'Number of times that the status "LOGIN_REQUIRED" has been returned by Innertube API', 24 | ); 25 | 26 | private innertubeErrorStatusUnknown = this.createCounter( 27 | "innertube_error_status_unknown_total", 28 | "Number of times that an unknown status has been returned by Innertube API", 29 | ); 30 | 31 | private innertubeErrorReasonSignIn = this.createCounter( 32 | "innertube_error_reason_SignIn_total", 33 | 'Number of times that the message "Sign in to confirm you’re not a bot." has been returned by Innertube API', 34 | ); 35 | 36 | private innertubeErrorSubreasonProtectCommunity = this.createCounter( 37 | "innertube_error_subreason_ProtectCommunity_total", 38 | 'Number of times that the message "This helps protect our community." has been returned by Innertube API', 39 | ); 40 | 41 | private innertubeErrorReasonUnknown = this.createCounter( 42 | "innertube_error_reason_unknown_total", 43 | "Number of times that an unknown reason has been returned by the Innertube API", 44 | ); 45 | 46 | private innertubeErrorSubreasonUnknown = this.createCounter( 47 | "innertube_error_subreason_unknown_total", 48 | "Number of times that an unknown subreason has been returned by the Innertube API", 49 | ); 50 | 51 | public innertubeSuccessfulRequest = this.createCounter( 52 | "innertube_successful_request_total", 53 | "Number successful requests made to the Innertube API", 54 | ); 55 | 56 | private innertubeFailedRequest = this.createCounter( 57 | "innertube_failed_request_total", 58 | "Number failed requests made to the Innertube API for whatever reason", 59 | ); 60 | 61 | private checkStatus(videoData: IRawResponse) { 62 | const status = videoData.playabilityStatus?.status; 63 | 64 | return { 65 | unplayable: status === 66 | "UNPLAYABLE", 67 | contentCheckRequired: status === 68 | "CONTENT_CHECK_REQUIRED", 69 | loginRequired: status === "LOGIN_REQUIRED", 70 | }; 71 | } 72 | 73 | private checkReason(videoData: IRawResponse) { 74 | const reason = videoData.playabilityStatus?.reason; 75 | 76 | return { 77 | signInToConfirmAge: reason?.includes( 78 | "Sign in to confirm your age", 79 | ), 80 | SignInToConfirmBot: reason?.includes( 81 | "Sign in to confirm you’re not a bot", 82 | ), 83 | }; 84 | } 85 | 86 | private checkSubreason(videoData: IRawResponse) { 87 | const subReason = videoData.playabilityStatus?.errorScreen 88 | ?.playerErrorMessageRenderer 89 | ?.subreason?.runs?.[0]?.text; 90 | 91 | return { 92 | thisHelpsProtectCommunity: subReason?.includes( 93 | "This helps protect our community", 94 | ), 95 | }; 96 | } 97 | 98 | public checkInnertubeResponse(videoData: IRawResponse) { 99 | this.innertubeFailedRequest.inc(); 100 | const status = this.checkStatus(videoData); 101 | 102 | if (status.contentCheckRequired || status.unplayable) return; 103 | 104 | if (status.loginRequired) { 105 | this.innertubeErrorStatusLoginRequired.inc(); 106 | const reason = this.checkReason(videoData); 107 | 108 | if (reason.signInToConfirmAge) return; 109 | 110 | if (reason.SignInToConfirmBot) { 111 | this.innertubeErrorReasonSignIn.inc(); 112 | const subReason = this.checkSubreason(videoData); 113 | 114 | if (subReason.thisHelpsProtectCommunity) { 115 | this.innertubeErrorSubreasonProtectCommunity.inc(); 116 | } else { 117 | this.innertubeErrorSubreasonUnknown.inc(); 118 | } 119 | } else { 120 | this.innertubeErrorReasonUnknown.inc(); 121 | } 122 | } else { 123 | this.innertubeErrorStatusUnknown.inc(); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/lib/helpers/verifyRequest.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64 } from "@std/encoding/base64"; 2 | import { Aes } from "crypto/aes.ts"; 3 | import { Ecb, Padding } from "crypto/block-modes.ts"; 4 | import type { Config } from "./config.ts"; 5 | 6 | export const verifyRequest = ( 7 | stringToCheck: string, 8 | videoId: string, 9 | config: Config, 10 | ): boolean => { 11 | try { 12 | const decipher = new Ecb( 13 | Aes, 14 | new TextEncoder().encode(config.server.secret_key), 15 | Padding.PKCS7, 16 | ); 17 | 18 | const encryptedData = new TextDecoder().decode( 19 | decipher.decrypt( 20 | decodeBase64( 21 | stringToCheck.replace(/-/g, "+").replace(/_/g, "/"), 22 | ), 23 | ), 24 | ); 25 | const [parsedTimestamp, parsedVideoId] = encryptedData.split("|"); 26 | const parsedTimestampInt = parseInt(parsedTimestamp); 27 | const timestampNow = Math.round(+new Date() / 1000); 28 | if (parsedVideoId !== videoId) { 29 | return false; 30 | } 31 | // only allow ID to live for 6 hours 32 | if ((timestampNow + 6 * 60 * 60) - parsedTimestampInt < 0) { 33 | return false; 34 | } 35 | } catch (_) { 36 | return false; 37 | } 38 | return true; 39 | }; 40 | -------------------------------------------------------------------------------- /src/lib/helpers/youtubePlayerHandling.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse, Innertube, YT } from "youtubei.js"; 2 | import { generateRandomString } from "youtubei.js/Utils"; 3 | import { compress, decompress } from "brotli"; 4 | import type { TokenMinter } from "../jobs/potoken.ts"; 5 | import { Metrics } from "../helpers/metrics.ts"; 6 | let youtubePlayerReqLocation = "youtubePlayerReq"; 7 | if (Deno.env.get("YT_PLAYER_REQ_LOCATION")) { 8 | if (Deno.env.has("DENO_COMPILED")) { 9 | youtubePlayerReqLocation = Deno.mainModule.replace("src/main.ts", "") + 10 | Deno.env.get("YT_PLAYER_REQ_LOCATION"); 11 | } else { 12 | youtubePlayerReqLocation = Deno.env.get( 13 | "YT_PLAYER_REQ_LOCATION", 14 | ) as string; 15 | } 16 | } 17 | const { youtubePlayerReq } = await import(youtubePlayerReqLocation); 18 | 19 | import type { Config } from "./config.ts"; 20 | 21 | const kv = await Deno.openKv(); 22 | 23 | export const youtubePlayerParsing = async ({ 24 | innertubeClient, 25 | videoId, 26 | config, 27 | tokenMinter, 28 | metrics, 29 | overrideCache = false, 30 | }: { 31 | innertubeClient: Innertube; 32 | videoId: string; 33 | config: Config; 34 | tokenMinter: TokenMinter; 35 | metrics: Metrics | undefined; 36 | overrideCache?: boolean; 37 | }): Promise => { 38 | const cacheEnabled = overrideCache ? false : config.cache.enabled; 39 | 40 | const videoCached = (await kv.get(["video_cache", videoId])) 41 | .value as Uint8Array; 42 | 43 | if (videoCached != null && cacheEnabled) { 44 | return JSON.parse(new TextDecoder().decode(decompress(videoCached))); 45 | } else { 46 | const youtubePlayerResponse = await youtubePlayerReq( 47 | innertubeClient, 48 | videoId, 49 | config, 50 | tokenMinter, 51 | ); 52 | const videoData = youtubePlayerResponse.data; 53 | 54 | if (videoData.playabilityStatus.status === "ERROR") { 55 | return videoData; 56 | } 57 | 58 | const video = new YT.VideoInfo( 59 | [youtubePlayerResponse], 60 | innertubeClient.actions, 61 | generateRandomString(16), 62 | ); 63 | 64 | const streamingData = video.streaming_data; 65 | 66 | // Modify the original YouTube response to include deciphered URLs 67 | if (streamingData && videoData && videoData.streamingData) { 68 | const ecatcherServiceTracking = videoData.responseContext 69 | ?.serviceTrackingParams.find((o: { service: string }) => 70 | o.service === "ECATCHER" 71 | ); 72 | const clientNameUsed = ecatcherServiceTracking?.params?.find(( 73 | o: { key: string }, 74 | ) => o.key === "client.name"); 75 | // no need to decipher on IOS nor ANDROID 76 | if ( 77 | !clientNameUsed?.value.includes("IOS") && 78 | !clientNameUsed?.value.includes("ANDROID") 79 | ) { 80 | for (const [index, format] of streamingData.formats.entries()) { 81 | videoData.streamingData.formats[index].url = format 82 | .decipher( 83 | innertubeClient.session.player, 84 | ); 85 | if ( 86 | videoData.streamingData.formats[index] 87 | .signatureCipher !== 88 | undefined 89 | ) { 90 | delete videoData.streamingData.formats[index] 91 | .signatureCipher; 92 | } 93 | if ( 94 | videoData.streamingData.formats[index].url.includes( 95 | "alr=yes", 96 | ) 97 | ) { 98 | videoData.streamingData.formats[index].url.replace( 99 | "alr=yes", 100 | "alr=no", 101 | ); 102 | } else { 103 | videoData.streamingData.formats[index].url += "&alr=no"; 104 | } 105 | } 106 | for ( 107 | const [index, adaptive_format] of streamingData 108 | .adaptive_formats 109 | .entries() 110 | ) { 111 | videoData.streamingData.adaptiveFormats[index].url = 112 | adaptive_format 113 | .decipher( 114 | innertubeClient.session.player, 115 | ); 116 | if ( 117 | videoData.streamingData.adaptiveFormats[index] 118 | .signatureCipher !== 119 | undefined 120 | ) { 121 | delete videoData.streamingData.adaptiveFormats[index] 122 | .signatureCipher; 123 | } 124 | if ( 125 | videoData.streamingData.adaptiveFormats[index].url 126 | .includes("alr=yes") 127 | ) { 128 | videoData.streamingData.adaptiveFormats[index].url 129 | .replace("alr=yes", "alr=no"); 130 | } else { 131 | videoData.streamingData.adaptiveFormats[index].url += 132 | "&alr=no"; 133 | } 134 | } 135 | } 136 | } 137 | 138 | const videoOnlyNecessaryInfo = (( 139 | { 140 | captions, 141 | playabilityStatus, 142 | storyboards, 143 | streamingData, 144 | videoDetails, 145 | microformat, 146 | }, 147 | ) => ({ 148 | captions, 149 | playabilityStatus, 150 | storyboards, 151 | streamingData, 152 | videoDetails, 153 | microformat, 154 | }))(videoData); 155 | 156 | if (videoData.playabilityStatus?.status == "OK") { 157 | metrics?.innertubeSuccessfulRequest.inc(); 158 | if (cacheEnabled) { 159 | (async () => { 160 | await kv.set( 161 | ["video_cache", videoId], 162 | compress( 163 | new TextEncoder().encode( 164 | JSON.stringify(videoOnlyNecessaryInfo), 165 | ), 166 | ), 167 | { 168 | expireIn: 1000 * 60 * 60, 169 | }, 170 | ); 171 | })(); 172 | } 173 | } else { 174 | metrics?.checkInnertubeResponse(videoData); 175 | } 176 | 177 | return videoOnlyNecessaryInfo; 178 | } 179 | }; 180 | 181 | export const youtubeVideoInfo = ( 182 | innertubeClient: Innertube, 183 | youtubePlayerResponseJson: object, 184 | ): YT.VideoInfo => { 185 | const playerResponse = { 186 | success: true, 187 | status_code: 200, 188 | data: youtubePlayerResponseJson, 189 | } as ApiResponse; 190 | return new YT.VideoInfo( 191 | [playerResponse], 192 | innertubeClient.actions, 193 | "", 194 | ); 195 | }; 196 | -------------------------------------------------------------------------------- /src/lib/helpers/youtubePlayerReq.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse, Innertube } from "youtubei.js"; 2 | import NavigationEndpoint from "youtubei.js/NavigationEndpoint"; 3 | import type { TokenMinter } from "../jobs/potoken.ts"; 4 | 5 | import type { Config } from "./config.ts"; 6 | 7 | function callWatchEndpoint( 8 | videoId: string, 9 | innertubeClient: Innertube, 10 | innertubeClientType: string, 11 | contentPoToken: string, 12 | ) { 13 | const watch_endpoint = new NavigationEndpoint({ 14 | watchEndpoint: { 15 | videoId: videoId, 16 | // Allow companion to gather sensitive content videos like 17 | // `VuSU7PcEKpU` 18 | racyCheckOk: true, 19 | contentCheckOk: true, 20 | }, 21 | }); 22 | 23 | return watch_endpoint.call( 24 | innertubeClient.actions, 25 | { 26 | playbackContext: { 27 | contentPlaybackContext: { 28 | vis: 0, 29 | splay: false, 30 | lactMilliseconds: "-1", 31 | signatureTimestamp: innertubeClient.session.player?.sts, 32 | }, 33 | }, 34 | serviceIntegrityDimensions: { 35 | poToken: contentPoToken, 36 | }, 37 | client: innertubeClientType, 38 | }, 39 | ); 40 | } 41 | 42 | export const youtubePlayerReq = async ( 43 | innertubeClient: Innertube, 44 | videoId: string, 45 | config: Config, 46 | tokenMinter: TokenMinter, 47 | ): Promise => { 48 | const innertubeClientOauthEnabled = config.youtube_session.oauth_enabled; 49 | 50 | let innertubeClientUsed = "WEB"; 51 | if (innertubeClientOauthEnabled) { 52 | innertubeClientUsed = "TV"; 53 | } 54 | 55 | const contentPoToken = await tokenMinter(videoId); 56 | 57 | const youtubePlayerResponse = await callWatchEndpoint( 58 | videoId, 59 | innertubeClient, 60 | innertubeClientUsed, 61 | contentPoToken, 62 | ); 63 | 64 | // Check if the first adaptive format URL is undefined, if it is then fallback to multiple YT clients 65 | 66 | if ( 67 | !innertubeClientOauthEnabled && 68 | youtubePlayerResponse.data.streamingData && 69 | youtubePlayerResponse.data.streamingData.adaptiveFormats[0].url === 70 | undefined 71 | ) { 72 | console.log( 73 | "[WARNING] No URLs found for adaptive formats. Falling back to other YT clients.", 74 | ); 75 | const innertubeClientsTypeFallback = ["TV", "MWEB"]; 76 | 77 | for await (const innertubeClientType of innertubeClientsTypeFallback) { 78 | console.log( 79 | `[WARNING] Trying fallback YT client ${innertubeClientType}`, 80 | ); 81 | const youtubePlayerResponseFallback = await callWatchEndpoint( 82 | videoId, 83 | innertubeClient, 84 | innertubeClientType, 85 | contentPoToken, 86 | ); 87 | if ( 88 | youtubePlayerResponseFallback.data.streamingData && 89 | youtubePlayerResponseFallback.data.streamingData 90 | .adaptiveFormats[0].url 91 | ) { 92 | youtubePlayerResponse.data.streamingData.adaptiveFormats = 93 | youtubePlayerResponseFallback.data.streamingData 94 | .adaptiveFormats; 95 | break; 96 | } 97 | } 98 | } 99 | 100 | return youtubePlayerResponse; 101 | }; 102 | -------------------------------------------------------------------------------- /src/lib/helpers/youtubeTranscriptsHandling.ts: -------------------------------------------------------------------------------- 1 | import { Innertube } from "youtubei.js"; 2 | import type { CaptionTrackData } from "youtubei.js/PlayerCaptionsTracklist"; 3 | import { HTTPException } from "hono/http-exception"; 4 | 5 | function createTemporalDuration(milliseconds: number) { 6 | return new Temporal.Duration( 7 | undefined, 8 | undefined, 9 | undefined, 10 | undefined, 11 | undefined, 12 | undefined, 13 | undefined, 14 | milliseconds, 15 | ); 16 | } 17 | 18 | const ESCAPE_SUBSTITUTIONS = { 19 | "&": "&", 20 | "<": "<", 21 | ">": ">", 22 | "\u200E": "‎", 23 | "\u200F": "‏", 24 | "\u00A0": " ", 25 | }; 26 | 27 | export async function handleTranscripts( 28 | innertubeClient: Innertube, 29 | videoId: string, 30 | selectedCaption: CaptionTrackData, 31 | ) { 32 | const lines: string[] = ["WEBVTT"]; 33 | 34 | const info = await innertubeClient.getInfo(videoId); 35 | const transcriptInfo = await (await info.getTranscript()).selectLanguage( 36 | selectedCaption.name.text || "", 37 | ); 38 | const rawTranscriptLines = transcriptInfo.transcript.content?.body 39 | ?.initial_segments; 40 | 41 | if (rawTranscriptLines == undefined) throw new HTTPException(404); 42 | 43 | rawTranscriptLines.forEach((line) => { 44 | const timestampFormatOptions = { 45 | style: "digital", 46 | minutesDisplay: "always", 47 | fractionalDigits: 3, 48 | }; 49 | 50 | // Temporal.Duration.prototype.toLocaleString() is supposed to delegate to Intl.DurationFormat 51 | // which Deno does not support. However, instead of following specs and having toLocaleString return 52 | // the same toString() it seems to have its own implementation of Intl.DurationFormat, 53 | // with its options parameter type incorrectly restricted to the same as the one for Intl.DateTimeFormatOptions 54 | // even though they do not share the same arguments. 55 | // 56 | // The above matches the options parameter of Intl.DurationFormat, and the resulting output is as expected. 57 | // Until this is fixed typechecking must be disabled for the two use cases below 58 | // 59 | // See 60 | // https://docs.deno.com/api/web/~/Intl.DateTimeFormatOptions 61 | // https://docs.deno.com/api/web/~/Temporal.Duration.prototype.toLocaleString 62 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration/toLocaleString 63 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat 64 | 65 | const start_ms = createTemporalDuration(Number(line.start_ms)).round({ 66 | largestUnit: "year", 67 | //@ts-ignore see above 68 | }).toLocaleString("en-US", timestampFormatOptions); 69 | 70 | const end_ms = createTemporalDuration(Number(line.end_ms)).round({ 71 | largestUnit: "year", 72 | //@ts-ignore see above 73 | }).toLocaleString("en-US", timestampFormatOptions); 74 | const timestamp = `${start_ms} --> ${end_ms}`; 75 | 76 | const text = (line.snippet?.text || "").replace( 77 | /[&<>‍‍\u200E\u200F\u00A0]/g, 78 | (match: string) => 79 | ESCAPE_SUBSTITUTIONS[ 80 | match as keyof typeof ESCAPE_SUBSTITUTIONS 81 | ], 82 | ); 83 | 84 | lines.push(`${timestamp}\n${text}`); 85 | }); 86 | 87 | return lines.join("\n\n"); 88 | } 89 | -------------------------------------------------------------------------------- /src/lib/jobs/potoken.ts: -------------------------------------------------------------------------------- 1 | import { Innertube } from "youtubei.js"; 2 | import { 3 | youtubePlayerParsing, 4 | youtubeVideoInfo, 5 | } from "../helpers/youtubePlayerHandling.ts"; 6 | import type { Config } from "../helpers/config.ts"; 7 | import { Metrics } from "../helpers/metrics.ts"; 8 | let getFetchClientLocation = "getFetchClient"; 9 | if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) { 10 | if (Deno.env.has("DENO_COMPILED")) { 11 | getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") + 12 | Deno.env.get("GET_FETCH_CLIENT_LOCATION"); 13 | } else { 14 | getFetchClientLocation = Deno.env.get( 15 | "GET_FETCH_CLIENT_LOCATION", 16 | ) as string; 17 | } 18 | } 19 | const { getFetchClient } = await import(getFetchClientLocation); 20 | 21 | import { InputMessage, OutputMessageSchema } from "./worker.ts"; 22 | 23 | interface TokenGeneratorWorker extends Omit { 24 | postMessage(message: InputMessage): void; 25 | } 26 | 27 | const workers: TokenGeneratorWorker[] = []; 28 | 29 | function createMinter(worker: TokenGeneratorWorker) { 30 | return (videoId: string): Promise => { 31 | const { promise, resolve } = Promise.withResolvers(); 32 | // generate a UUID to identify the request as many minter calls 33 | // may be made within a timespan, and this function will be 34 | // informed about all of them until it's got its own 35 | const requestId = crypto.randomUUID(); 36 | const listener = (message: MessageEvent) => { 37 | const parsedMessage = OutputMessageSchema.parse(message.data); 38 | if ( 39 | parsedMessage.type === "content-token" && 40 | parsedMessage.requestId === requestId 41 | ) { 42 | worker.removeEventListener("message", listener); 43 | resolve(parsedMessage.contentToken); 44 | } 45 | }; 46 | worker.addEventListener("message", listener); 47 | worker.postMessage({ 48 | type: "content-token-request", 49 | videoId, 50 | requestId, 51 | }); 52 | 53 | return promise; 54 | }; 55 | } 56 | 57 | export type TokenMinter = ReturnType; 58 | 59 | // Adapted from https://github.com/LuanRT/BgUtils/blob/main/examples/node/index.ts 60 | export const poTokenGenerate = ( 61 | config: Config, 62 | metrics: Metrics | undefined, 63 | ): Promise<{ innertubeClient: Innertube; tokenMinter: TokenMinter }> => { 64 | const { promise, resolve, reject } = Promise.withResolvers< 65 | Awaited> 66 | >(); 67 | 68 | const worker: TokenGeneratorWorker = new Worker( 69 | new URL("./worker.ts", import.meta.url).href, 70 | { 71 | type: "module", 72 | name: "PO Token Generator", 73 | }, 74 | ); 75 | // take note of the worker so we can kill it once a new one takes its place 76 | workers.push(worker); 77 | worker.addEventListener("message", async (event) => { 78 | const parsedMessage = OutputMessageSchema.parse(event.data); 79 | 80 | // worker is listening for messages 81 | if (parsedMessage.type === "ready") { 82 | const untypedPostMessage = worker.postMessage.bind(worker); 83 | worker.postMessage = (message: InputMessage) => 84 | untypedPostMessage(message); 85 | worker.postMessage({ type: "initialise", config }); 86 | } 87 | 88 | if (parsedMessage.type === "error") { 89 | console.log({ errorFromWorker: parsedMessage.error }); 90 | worker.terminate(); 91 | reject(parsedMessage.error); 92 | } 93 | 94 | // worker is initialised and has passed back a session token and visitor data 95 | if (parsedMessage.type === "initialised") { 96 | try { 97 | const instantiatedInnertubeClient = await Innertube.create({ 98 | enable_session_cache: false, 99 | po_token: parsedMessage.sessionPoToken, 100 | visitor_data: parsedMessage.visitorData, 101 | fetch: getFetchClient(config), 102 | generate_session_locally: true, 103 | cookie: config.youtube_session.cookies || undefined, 104 | }); 105 | const minter = createMinter(worker); 106 | // check token from minter 107 | await checkToken({ 108 | instantiatedInnertubeClient, 109 | config, 110 | integrityTokenBasedMinter: minter, 111 | metrics, 112 | }); 113 | console.log("[INFO] Successfully generated PO token"); 114 | const numberToKill = workers.length - 1; 115 | for (let i = 0; i < numberToKill; i++) { 116 | const workerToKill = workers.shift(); 117 | workerToKill?.terminate(); 118 | } 119 | return resolve({ 120 | innertubeClient: instantiatedInnertubeClient, 121 | tokenMinter: minter, 122 | }); 123 | } catch (err) { 124 | console.log("[WARN] Failed to get valid PO token, will retry", { 125 | err, 126 | }); 127 | worker.terminate(); 128 | reject(err); 129 | } 130 | } 131 | }); 132 | 133 | return promise; 134 | }; 135 | 136 | async function checkToken({ 137 | instantiatedInnertubeClient, 138 | config, 139 | integrityTokenBasedMinter, 140 | metrics, 141 | }: { 142 | instantiatedInnertubeClient: Innertube; 143 | config: Config; 144 | integrityTokenBasedMinter: TokenMinter; 145 | metrics: Metrics | undefined; 146 | }) { 147 | const fetchImpl = getFetchClient(config); 148 | 149 | try { 150 | const feed = await instantiatedInnertubeClient.getTrending(); 151 | // get all videos and shuffle them randomly to avoid using the same trending video over and over 152 | const videos = feed.videos 153 | .filter((video) => video.type === "Video") 154 | .map((value) => ({ value, sort: Math.random() })) 155 | .sort((a, b) => a.sort - b.sort) 156 | .map(({ value }) => value); 157 | 158 | const video = videos.find((video) => "id" in video); 159 | if (!video) { 160 | throw new Error("no videos with id found in trending"); 161 | } 162 | 163 | const youtubePlayerResponseJson = await youtubePlayerParsing({ 164 | innertubeClient: instantiatedInnertubeClient, 165 | videoId: video.id, 166 | config, 167 | tokenMinter: integrityTokenBasedMinter, 168 | metrics, 169 | overrideCache: true, 170 | }); 171 | const videoInfo = youtubeVideoInfo( 172 | instantiatedInnertubeClient, 173 | youtubePlayerResponseJson, 174 | ); 175 | const validFormat = videoInfo.streaming_data?.adaptive_formats[0]; 176 | if (!validFormat) { 177 | throw new Error( 178 | "failed to find valid video with adaptive format to check token against", 179 | ); 180 | } 181 | const result = await fetchImpl(validFormat?.url, { method: "HEAD" }); 182 | if (result.status !== 200) { 183 | throw new Error( 184 | `did not get a 200 when checking video, got ${result.status} instead`, 185 | ); 186 | } 187 | } catch (err) { 188 | console.log("Failed to get valid PO token, will retry", { err }); 189 | throw err; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/lib/jobs/worker.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { z } from "zod"; 4 | import { Config, ConfigSchema } from "../helpers/config.ts"; 5 | import { BG, buildURL, GOOG_API_KEY, USER_AGENT } from "bgutils"; 6 | import type { WebPoSignalOutput } from "bgutils"; 7 | import { JSDOM } from "jsdom"; 8 | import { Innertube } from "youtubei.js"; 9 | let getFetchClientLocation = "getFetchClient"; 10 | if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) { 11 | if (Deno.env.has("DENO_COMPILED")) { 12 | getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") + 13 | Deno.env.get("GET_FETCH_CLIENT_LOCATION"); 14 | } else { 15 | getFetchClientLocation = Deno.env.get( 16 | "GET_FETCH_CLIENT_LOCATION", 17 | ) as string; 18 | } 19 | } 20 | 21 | type FetchFunction = typeof fetch; 22 | const { getFetchClient }: { 23 | getFetchClient: (config: Config) => Promise; 24 | } = await import(getFetchClientLocation); 25 | 26 | // ---- Messages to send to the webworker ---- 27 | const InputInitialiseSchema = z.object({ 28 | type: z.literal("initialise"), 29 | config: ConfigSchema, 30 | }).strict(); 31 | 32 | const InputContentTokenSchema = z.object({ 33 | type: z.literal("content-token-request"), 34 | videoId: z.string(), 35 | requestId: z.string().uuid(), 36 | }).strict(); 37 | export type InputInitialise = z.infer; 38 | export type InputContentToken = z.infer; 39 | const InputMessageSchema = z.union([ 40 | InputInitialiseSchema, 41 | InputContentTokenSchema, 42 | ]); 43 | export type InputMessage = z.infer; 44 | 45 | // ---- Messages that the webworker sends to the parent ---- 46 | const OutputReadySchema = z.object({ 47 | type: z.literal("ready"), 48 | }).strict(); 49 | 50 | const OutputInitialiseSchema = z.object({ 51 | type: z.literal("initialised"), 52 | sessionPoToken: z.string(), 53 | visitorData: z.string(), 54 | }).strict(); 55 | 56 | const OutputContentTokenSchema = z.object({ 57 | type: z.literal("content-token"), 58 | contentToken: z.string(), 59 | requestId: InputContentTokenSchema.shape.requestId, 60 | }).strict(); 61 | 62 | const OutputErrorSchema = z.object({ 63 | type: z.literal("error"), 64 | error: z.any(), 65 | }).strict(); 66 | export const OutputMessageSchema = z.union([ 67 | OutputReadySchema, 68 | OutputInitialiseSchema, 69 | OutputContentTokenSchema, 70 | OutputErrorSchema, 71 | ]); 72 | type OutputMessage = z.infer; 73 | 74 | const IntegrityTokenResponse = z.tuple([z.string()]).rest(z.any()); 75 | 76 | const isWorker = typeof WorkerGlobalScope !== "undefined" && 77 | self instanceof WorkerGlobalScope; 78 | if (isWorker) { 79 | // helper function to force type-checking 80 | const untypedPostmessage = self.postMessage.bind(self); 81 | const postMessage = (message: OutputMessage) => { 82 | untypedPostmessage(message); 83 | }; 84 | 85 | let minter: BG.WebPoMinter; 86 | 87 | onmessage = async (event) => { 88 | const message = InputMessageSchema.parse(event.data); 89 | if (message.type === "initialise") { 90 | const fetchImpl: typeof fetch = await getFetchClient( 91 | message.config, 92 | ); 93 | try { 94 | const { 95 | sessionPoToken, 96 | visitorData, 97 | generatedMinter, 98 | } = await setup({ 99 | fetchImpl, 100 | innertubeClientCookies: 101 | message.config.youtube_session.cookies, 102 | }); 103 | minter = generatedMinter; 104 | postMessage({ 105 | type: "initialised", 106 | sessionPoToken, 107 | visitorData, 108 | }); 109 | } catch (err) { 110 | postMessage({ type: "error", error: err }); 111 | } 112 | } 113 | // this is called every time a video needs a content token 114 | if (message.type === "content-token-request") { 115 | if (!minter) { 116 | throw new Error( 117 | "Minter not yet ready, must initialise first", 118 | ); 119 | } 120 | const contentToken = await minter.mintAsWebsafeString( 121 | message.videoId, 122 | ); 123 | postMessage({ 124 | type: "content-token", 125 | contentToken, 126 | requestId: message.requestId, 127 | }); 128 | } 129 | }; 130 | 131 | postMessage({ type: "ready" }); 132 | } 133 | 134 | async function setup( 135 | { fetchImpl, innertubeClientCookies }: { 136 | fetchImpl: FetchFunction; 137 | innertubeClientCookies: string; 138 | }, 139 | ) { 140 | const innertubeClient = await Innertube.create({ 141 | enable_session_cache: false, 142 | user_agent: USER_AGENT, 143 | retrieve_player: false, 144 | cookie: innertubeClientCookies || undefined, 145 | }); 146 | 147 | const visitorData = innertubeClient.session.context.client.visitorData; 148 | 149 | if (!visitorData) { 150 | throw new Error("Could not get visitor data"); 151 | } 152 | 153 | const dom = new JSDOM( 154 | '', 155 | { 156 | url: "https://www.youtube.com/", 157 | referrer: "https://www.youtube.com/", 158 | userAgent: USER_AGENT, 159 | }, 160 | ); 161 | 162 | Object.assign(globalThis, { 163 | window: dom.window, 164 | document: dom.window.document, 165 | // location: dom.window.location, // --- doesn't seem to be necessary and the Web Worker doesn't like it 166 | origin: dom.window.origin, 167 | }); 168 | 169 | if (!Reflect.has(globalThis, "navigator")) { 170 | Object.defineProperty(globalThis, "navigator", { 171 | value: dom.window.navigator, 172 | }); 173 | } 174 | 175 | const challengeResponse = await innertubeClient.getAttestationChallenge( 176 | "ENGAGEMENT_TYPE_UNBOUND", 177 | ); 178 | if (!challengeResponse.bg_challenge) { 179 | throw new Error("Could not get challenge"); 180 | } 181 | 182 | const interpreterUrl = challengeResponse.bg_challenge.interpreter_url 183 | .private_do_not_access_or_else_trusted_resource_url_wrapped_value; 184 | const bgScriptResponse = await fetchImpl( 185 | `https:${interpreterUrl}`, 186 | ); 187 | const interpreterJavascript = await bgScriptResponse.text(); 188 | 189 | if (interpreterJavascript) { 190 | new Function(interpreterJavascript)(); 191 | } else throw new Error("Could not load VM"); 192 | 193 | // Botguard currently surfaces a "Not implemented" error here, due to the environment 194 | // not having a valid Canvas API in JSDOM. At the time of writing, this doesn't cause 195 | // any issues as the Canvas check doesn't appear to be an enforced element of the checks 196 | console.log( 197 | '[INFO] the "Not implemented: HTMLCanvasElement.prototype.getContext" error is normal. Please do not open a bug report about it.', 198 | ); 199 | const botguard = await BG.BotGuardClient.create({ 200 | program: challengeResponse.bg_challenge.program, 201 | globalName: challengeResponse.bg_challenge.global_name, 202 | globalObj: globalThis, 203 | }); 204 | 205 | const webPoSignalOutput: WebPoSignalOutput = []; 206 | const botguardResponse = await botguard.snapshot({ webPoSignalOutput }); 207 | const requestKey = "O43z0dpjhgX20SCx4KAo"; 208 | 209 | const integrityTokenResponse = await fetchImpl( 210 | buildURL("GenerateIT", true), 211 | { 212 | method: "POST", 213 | headers: { 214 | "content-type": "application/json+protobuf", 215 | "x-goog-api-key": GOOG_API_KEY, 216 | "x-user-agent": "grpc-web-javascript/0.1", 217 | "user-agent": USER_AGENT, 218 | }, 219 | body: JSON.stringify([requestKey, botguardResponse]), 220 | }, 221 | ); 222 | const integrityTokenBody = IntegrityTokenResponse.parse( 223 | await integrityTokenResponse.json(), 224 | ); 225 | 226 | const integrityTokenBasedMinter = await BG.WebPoMinter.create({ 227 | integrityToken: integrityTokenBody[0], 228 | }, webPoSignalOutput); 229 | 230 | const sessionPoToken = await integrityTokenBasedMinter.mintAsWebsafeString( 231 | visitorData, 232 | ); 233 | 234 | return { 235 | sessionPoToken, 236 | visitorData, 237 | generatedMinter: integrityTokenBasedMinter, 238 | }; 239 | } 240 | -------------------------------------------------------------------------------- /src/lib/types/HonoVariables.ts: -------------------------------------------------------------------------------- 1 | import { Innertube } from "youtubei.js"; 2 | import type { TokenMinter } from "../jobs/potoken.ts"; 3 | import type { Config } from "../helpers/config.ts"; 4 | import { Metrics } from "../helpers/metrics.ts"; 5 | 6 | export type HonoVariables = { 7 | innertubeClient: Innertube; 8 | config: Config; 9 | tokenMinter: TokenMinter; 10 | metrics: Metrics | undefined; 11 | }; 12 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { routes } from "./routes/index.ts"; 3 | import { Innertube } from "youtubei.js"; 4 | import { poTokenGenerate, type TokenMinter } from "./lib/jobs/potoken.ts"; 5 | import { USER_AGENT } from "bgutils"; 6 | import { retry } from "@std/async"; 7 | import type { HonoVariables } from "./lib/types/HonoVariables.ts"; 8 | import { parseArgs } from "@std/cli/parse-args"; 9 | 10 | import { parseConfig } from "./lib/helpers/config.ts"; 11 | const config = await parseConfig(); 12 | import { Metrics } from "./lib/helpers/metrics.ts"; 13 | 14 | const args = parseArgs(Deno.args); 15 | 16 | if (args._version_date && args._version_commit) { 17 | console.log( 18 | `[INFO] Using Invidious companion version ${args._version_date}-${args._version_commit}`, 19 | ); 20 | } 21 | 22 | let getFetchClientLocation = "getFetchClient"; 23 | if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) { 24 | if (Deno.env.has("DENO_COMPILED")) { 25 | getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") + 26 | Deno.env.get("GET_FETCH_CLIENT_LOCATION"); 27 | } else { 28 | getFetchClientLocation = Deno.env.get( 29 | "GET_FETCH_CLIENT_LOCATION", 30 | ) as string; 31 | } 32 | } 33 | const { getFetchClient } = await import(getFetchClientLocation); 34 | 35 | declare module "hono" { 36 | interface ContextVariableMap extends HonoVariables {} 37 | } 38 | const app = new Hono(); 39 | const metrics = config.server.enable_metrics ? new Metrics() : undefined; 40 | 41 | let tokenMinter: TokenMinter; 42 | let innertubeClient: Innertube; 43 | let innertubeClientFetchPlayer = true; 44 | const innertubeClientOauthEnabled = config.youtube_session.oauth_enabled; 45 | const innertubeClientJobPoTokenEnabled = 46 | config.jobs.youtube_session.po_token_enabled; 47 | const innertubeClientCookies = config.youtube_session.cookies; 48 | 49 | if (!innertubeClientOauthEnabled) { 50 | if (innertubeClientJobPoTokenEnabled) { 51 | console.log("[INFO] job po_token is active."); 52 | // Don't fetch fetch player yet for po_token 53 | innertubeClientFetchPlayer = false; 54 | } else if (!innertubeClientJobPoTokenEnabled) { 55 | console.log("[INFO] job po_token is NOT active."); 56 | } 57 | } 58 | 59 | innertubeClient = await Innertube.create({ 60 | enable_session_cache: false, 61 | retrieve_player: innertubeClientFetchPlayer, 62 | fetch: getFetchClient(config), 63 | cookie: innertubeClientCookies || undefined, 64 | user_agent: USER_AGENT, 65 | }); 66 | 67 | if (!innertubeClientOauthEnabled) { 68 | if (innertubeClientJobPoTokenEnabled) { 69 | ({ innertubeClient, tokenMinter } = await retry( 70 | poTokenGenerate.bind( 71 | poTokenGenerate, 72 | config, 73 | metrics, 74 | ), 75 | { minTimeout: 1_000, maxTimeout: 60_000, multiplier: 5, jitter: 0 }, 76 | )); 77 | } 78 | Deno.cron( 79 | "regenerate youtube session", 80 | config.jobs.youtube_session.frequency, 81 | { backoffSchedule: [5_000, 15_000, 60_000, 180_000] }, 82 | async () => { 83 | if (innertubeClientJobPoTokenEnabled) { 84 | try { 85 | ({ innertubeClient, tokenMinter } = await poTokenGenerate( 86 | config, 87 | metrics, 88 | )); 89 | } catch (err) { 90 | metrics?.potokenGenerationFailure.inc(); 91 | throw err; 92 | } 93 | } else { 94 | innertubeClient = await Innertube.create({ 95 | enable_session_cache: false, 96 | fetch: getFetchClient(config), 97 | retrieve_player: innertubeClientFetchPlayer, 98 | user_agent: USER_AGENT, 99 | cookie: innertubeClientCookies || undefined, 100 | }); 101 | } 102 | }, 103 | ); 104 | } else if (innertubeClientOauthEnabled) { 105 | // Fired when waiting for the user to authorize the sign in attempt. 106 | innertubeClient.session.on("auth-pending", (data) => { 107 | console.log( 108 | `[INFO] [OAUTH] Go to ${data.verification_url} in your browser and enter code ${data.user_code} to authenticate.`, 109 | ); 110 | }); 111 | // Fired when authentication is successful. 112 | innertubeClient.session.on("auth", () => { 113 | console.log("[INFO] [OAUTH] Sign in successful!"); 114 | }); 115 | // Fired when the access token expires. 116 | innertubeClient.session.on("update-credentials", async () => { 117 | console.log("[INFO] [OAUTH] Credentials updated."); 118 | await innertubeClient.session.oauth.cacheCredentials(); 119 | }); 120 | 121 | // Attempt to sign in and then cache the credentials 122 | await innertubeClient.session.signIn(); 123 | await innertubeClient.session.oauth.cacheCredentials(); 124 | } 125 | 126 | app.use("*", async (c, next) => { 127 | c.set("innertubeClient", innertubeClient); 128 | c.set("tokenMinter", tokenMinter); 129 | c.set("config", config); 130 | c.set("metrics", metrics); 131 | await next(); 132 | }); 133 | 134 | routes(app, config); 135 | 136 | export function run(signal: AbortSignal, port: number, hostname: string) { 137 | return Deno.serve( 138 | { signal: signal, port: port, hostname: hostname }, 139 | app.fetch, 140 | ); 141 | } 142 | 143 | if (import.meta.main) { 144 | const controller = new AbortController(); 145 | const { signal } = controller; 146 | run(signal, config.server.port, config.server.host); 147 | 148 | Deno.addSignalListener("SIGTERM", () => { 149 | console.log("Caught SIGINT, shutting down..."); 150 | controller.abort(); 151 | Deno.exit(0); 152 | }); 153 | 154 | Deno.addSignalListener("SIGINT", () => { 155 | console.log("Caught SIGINT, shutting down..."); 156 | controller.abort(); 157 | Deno.exit(0); 158 | }); 159 | } 160 | -------------------------------------------------------------------------------- /src/routes/health.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | 3 | const health = new Hono(); 4 | 5 | health.get("/", () => { 6 | return new Response("OK", { 7 | status: 200, 8 | headers: { "Content-Type": "text/plain" }, 9 | }); 10 | }); 11 | 12 | export default health; 13 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { logger } from "hono/logger"; 3 | import { bearerAuth } from "hono/bearer-auth"; 4 | 5 | import youtubeApiPlayer from "./youtube_api_routes/player.ts"; 6 | import invidiousRouteLatestVersion from "./invidious_routes/latestVersion.ts"; 7 | import invidiousRouteDashManifest from "./invidious_routes/dashManifest.ts"; 8 | import invidiousCaptionsApi from "./invidious_routes/captions.ts"; 9 | import getDownloadHandler from "./invidious_routes/download.ts"; 10 | import videoPlaybackProxy from "./videoPlaybackProxy.ts"; 11 | import health from "./health.ts"; 12 | import type { Config } from "../lib/helpers/config.ts"; 13 | import metrics from "./metrics.ts"; 14 | 15 | export const routes = ( 16 | app: Hono, 17 | config: Config, 18 | ) => { 19 | app.use("*", logger()); 20 | 21 | app.use( 22 | "/youtubei/v1/*", 23 | bearerAuth({ 24 | token: config.server.secret_key, 25 | }), 26 | ); 27 | 28 | app.route("/youtubei/v1", youtubeApiPlayer); 29 | app.route("/latest_version", invidiousRouteLatestVersion); 30 | // Needs app for app.request in order to call /latest_version endpoint 31 | app.post("/download", getDownloadHandler(app)); 32 | app.route("/api/manifest/dash/id", invidiousRouteDashManifest); 33 | app.route("/api/v1/captions", invidiousCaptionsApi); 34 | app.route("/videoplayback", videoPlaybackProxy); 35 | app.route("/healthz", health); 36 | if (config.server.enable_metrics) { 37 | app.route("/metrics", metrics); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/routes/invidious_routes/captions.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import type { HonoVariables } from "../../lib/types/HonoVariables.ts"; 3 | import { verifyRequest } from "../../lib/helpers/verifyRequest.ts"; 4 | import { 5 | youtubePlayerParsing, 6 | youtubeVideoInfo, 7 | } from "../../lib/helpers/youtubePlayerHandling.ts"; 8 | import type { CaptionTrackData } from "youtubei.js/PlayerCaptionsTracklist"; 9 | import { handleTranscripts } from "../../lib/helpers/youtubeTranscriptsHandling.ts"; 10 | import { HTTPException } from "hono/http-exception"; 11 | 12 | interface AvailableCaption { 13 | label: string; 14 | languageCode: string; 15 | url: string; 16 | } 17 | 18 | const captionsHandler = new Hono<{ Variables: HonoVariables }>(); 19 | captionsHandler.get("/:videoId", async (c) => { 20 | const { videoId } = c.req.param(); 21 | const config = c.get("config"); 22 | const metrics = c.get("metrics"); 23 | 24 | const check = c.req.query("check"); 25 | 26 | if (config.server.verify_requests && check == undefined) { 27 | throw new HTTPException(400, { 28 | res: new Response("No check ID."), 29 | }); 30 | } else if (config.server.verify_requests && check) { 31 | if (verifyRequest(check, videoId, config) === false) { 32 | throw new HTTPException(400, { 33 | res: new Response("ID incorrect."), 34 | }); 35 | } 36 | } 37 | 38 | const innertubeClient = c.get("innertubeClient"); 39 | 40 | const youtubePlayerResponseJson = await youtubePlayerParsing({ 41 | innertubeClient, 42 | videoId, 43 | config, 44 | metrics, 45 | tokenMinter: c.get("tokenMinter"), 46 | }); 47 | 48 | const videoInfo = youtubeVideoInfo( 49 | innertubeClient, 50 | youtubePlayerResponseJson, 51 | ); 52 | 53 | const captionsTrackArray = videoInfo.captions?.caption_tracks; 54 | if (captionsTrackArray == undefined) throw new HTTPException(404); 55 | 56 | const label = c.req.query("label"); 57 | const lang = c.req.query("lang"); 58 | 59 | // Show all available captions when a specific one is not selected 60 | if (label == undefined && lang == undefined) { 61 | const invidiousAvailableCaptionsArr: AvailableCaption[] = []; 62 | 63 | for (const caption_track of captionsTrackArray) { 64 | invidiousAvailableCaptionsArr.push({ 65 | label: caption_track.name.text || "", 66 | languageCode: caption_track.language_code, 67 | url: `/api/v1/captions/${videoId}?label=${ 68 | encodeURIComponent(caption_track.name.text || "") 69 | }`, 70 | }); 71 | } 72 | 73 | return c.json({ captions: invidiousAvailableCaptionsArr }); 74 | } 75 | 76 | // Extract selected caption 77 | let filterSelected: CaptionTrackData[]; 78 | 79 | if (lang) { 80 | filterSelected = captionsTrackArray.filter((c: CaptionTrackData) => 81 | c.language_code === lang 82 | ); 83 | } else { 84 | filterSelected = captionsTrackArray.filter((c: CaptionTrackData) => 85 | c.name.text === label 86 | ); 87 | } 88 | 89 | if (filterSelected.length == 0) throw new HTTPException(404); 90 | 91 | c.header("Content-Type", "text/vtt; charset=UTF-8"); 92 | return c.body( 93 | await handleTranscripts(innertubeClient, videoId, filterSelected[0]), 94 | ); 95 | }); 96 | 97 | export default captionsHandler; 98 | -------------------------------------------------------------------------------- /src/routes/invidious_routes/dashManifest.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { FormatUtils } from "youtubei.js"; 3 | import { 4 | youtubePlayerParsing, 5 | youtubeVideoInfo, 6 | } from "../../lib/helpers/youtubePlayerHandling.ts"; 7 | import { verifyRequest } from "../../lib/helpers/verifyRequest.ts"; 8 | import { HTTPException } from "hono/http-exception"; 9 | import { encryptQuery } from "../../lib/helpers/encryptQuery.ts"; 10 | 11 | const dashManifest = new Hono(); 12 | 13 | dashManifest.get("/:videoId", async (c) => { 14 | const { videoId } = c.req.param(); 15 | const { check, local } = c.req.query(); 16 | c.header("access-control-allow-origin", "*"); 17 | 18 | const innertubeClient = c.get("innertubeClient"); 19 | const config = c.get("config"); 20 | const metrics = c.get("metrics"); 21 | 22 | if (config.server.verify_requests && check == undefined) { 23 | throw new HTTPException(400, { 24 | res: new Response("No check ID."), 25 | }); 26 | } else if (config.server.verify_requests && check) { 27 | if (verifyRequest(check, videoId, config) === false) { 28 | throw new HTTPException(400, { 29 | res: new Response("ID incorrect."), 30 | }); 31 | } 32 | } 33 | 34 | const youtubePlayerResponseJson = await youtubePlayerParsing({ 35 | innertubeClient, 36 | videoId, 37 | config, 38 | tokenMinter: c.get("tokenMinter"), 39 | metrics, 40 | }); 41 | const videoInfo = youtubeVideoInfo( 42 | innertubeClient, 43 | youtubePlayerResponseJson, 44 | ); 45 | 46 | if (videoInfo.playability_status?.status !== "OK") { 47 | throw ("The video can't be played: " + videoId + " due to reason: " + 48 | videoInfo.playability_status?.reason); 49 | } 50 | 51 | c.header("content-type", "application/dash+xml"); 52 | 53 | if (videoInfo.streaming_data) { 54 | // video.js only support MP4 not WEBM 55 | videoInfo.streaming_data.adaptive_formats = videoInfo 56 | .streaming_data.adaptive_formats 57 | .filter((i) => 58 | i.has_video === false || i.mime_type.includes("mp4") 59 | ); 60 | 61 | const player_response = videoInfo.page[0]; 62 | // TODO: fix include storyboards in DASH manifest file 63 | //const storyboards = player_response.storyboards; 64 | const captions = player_response.captions?.caption_tracks; 65 | 66 | const dashFile = await FormatUtils.toDash( 67 | videoInfo.streaming_data, 68 | videoInfo.page[0].video_details?.is_post_live_dvr, 69 | (url: URL) => { 70 | let dashUrl = url; 71 | let queryParams = new URLSearchParams(dashUrl.search); 72 | // Can't create URL type without host part 73 | queryParams.set("host", dashUrl.host); 74 | 75 | if (local) { 76 | if (config.networking.videoplayback.ump) { 77 | queryParams.set("ump", "yes"); 78 | } 79 | if ( 80 | config.server.encrypt_query_params 81 | ) { 82 | const publicParams = [...queryParams].filter(([key]) => 83 | ["pot", "ip"].includes(key) === false 84 | ); 85 | const privateParams = [...queryParams].filter(([key]) => 86 | ["pot", "ip"].includes(key) === true 87 | ); 88 | const encryptedParams = encryptQuery( 89 | JSON.stringify(privateParams), 90 | config, 91 | ); 92 | queryParams = new URLSearchParams(publicParams); 93 | queryParams.set("enc", "true"); 94 | queryParams.set("data", encryptedParams); 95 | } 96 | dashUrl = (dashUrl.pathname + "?" + 97 | queryParams.toString()) as unknown as URL; 98 | return dashUrl; 99 | } else { 100 | return dashUrl; 101 | } 102 | }, 103 | undefined, 104 | videoInfo.cpn, 105 | undefined, 106 | innertubeClient.actions, 107 | undefined, 108 | captions, 109 | undefined, 110 | ); 111 | return c.body(dashFile); 112 | } 113 | }); 114 | 115 | export default dashManifest; 116 | -------------------------------------------------------------------------------- /src/routes/invidious_routes/download.ts: -------------------------------------------------------------------------------- 1 | import type { Context, Hono } from "hono"; 2 | import { z } from "zod"; 3 | import { HTTPException } from "hono/http-exception"; 4 | import { verifyRequest } from "../../lib/helpers/verifyRequest.ts"; 5 | 6 | const DownloadWidgetSchema = z.union([ 7 | z.object({ label: z.string(), ext: z.string() }).strict(), 8 | z.object({ itag: z.number(), ext: z.string() }).strict(), 9 | ]); 10 | 11 | type DownloadWidget = z.infer; 12 | 13 | export default function getDownloadHandler(app: Hono) { 14 | async function handler(c: Context) { 15 | const body = await c.req.formData(); 16 | 17 | const videoId = body.get("id")?.toString(); 18 | if (videoId == undefined) { 19 | throw new HTTPException(400, { 20 | res: new Response("Please specify the video ID"), 21 | }); 22 | } 23 | 24 | const config = c.get("config"); 25 | 26 | const check = c.req.query("check"); 27 | 28 | if (config.server.verify_requests && check == undefined) { 29 | throw new HTTPException(400, { 30 | res: new Response("No check ID."), 31 | }); 32 | } else if (config.server.verify_requests && check) { 33 | if (verifyRequest(check, videoId, config) === false) { 34 | throw new HTTPException(400, { 35 | res: new Response("ID incorrect."), 36 | }); 37 | } 38 | } 39 | 40 | const title = body.get("title"); 41 | 42 | let downloadWidgetData: DownloadWidget; 43 | 44 | try { 45 | downloadWidgetData = JSON.parse( 46 | body.get("download_widget")?.toString() || "", 47 | ); 48 | } catch { 49 | throw new HTTPException(400, { 50 | res: new Response("Invalid download_widget json"), 51 | }); 52 | } 53 | 54 | if ( 55 | !(title && videoId && 56 | DownloadWidgetSchema.safeParse(downloadWidgetData).success) 57 | ) { 58 | throw new HTTPException(400, { 59 | res: new Response("Invalid form data required for download"), 60 | }); 61 | } 62 | 63 | if ("label" in downloadWidgetData) { 64 | return await app.request( 65 | `/api/v1/captions/${videoId}?label=${ 66 | encodeURIComponent(downloadWidgetData.label) 67 | }`, 68 | ); 69 | } else { 70 | const itag = downloadWidgetData.itag; 71 | const ext = downloadWidgetData.ext; 72 | const filename = `${title}-${videoId}.${ext}`; 73 | 74 | const urlQueriesForLatestVersion = new URLSearchParams(); 75 | urlQueriesForLatestVersion.set("id", videoId); 76 | urlQueriesForLatestVersion.set("check", check || ""); 77 | urlQueriesForLatestVersion.set("itag", itag.toString()); 78 | // "title" for compatibility with how Invidious sets the content disposition header 79 | // in /videoplayback and /latest_version 80 | urlQueriesForLatestVersion.set( 81 | "title", 82 | filename, 83 | ); 84 | urlQueriesForLatestVersion.set("local", "true"); 85 | 86 | return await app.request( 87 | `/latest_version?${urlQueriesForLatestVersion.toString()}`, 88 | ); 89 | } 90 | } 91 | 92 | return handler; 93 | } 94 | -------------------------------------------------------------------------------- /src/routes/invidious_routes/latestVersion.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import { 4 | youtubePlayerParsing, 5 | youtubeVideoInfo, 6 | } from "../../lib/helpers/youtubePlayerHandling.ts"; 7 | import { verifyRequest } from "../../lib/helpers/verifyRequest.ts"; 8 | import { encryptQuery } from "../../lib/helpers/encryptQuery.ts"; 9 | 10 | const latestVersion = new Hono(); 11 | 12 | latestVersion.get("/", async (c) => { 13 | const { check, itag, id, local, title } = c.req.query(); 14 | c.header("access-control-allow-origin", "*"); 15 | 16 | if (!id || !itag) { 17 | throw new HTTPException(400, { 18 | res: new Response("Please specify the itag and video ID."), 19 | }); 20 | } 21 | 22 | const innertubeClient = c.get("innertubeClient"); 23 | const config = c.get("config"); 24 | const metrics = c.get("metrics"); 25 | 26 | if (config.server.verify_requests && check == undefined) { 27 | throw new HTTPException(400, { 28 | res: new Response("No check ID."), 29 | }); 30 | } else if (config.server.verify_requests && check) { 31 | if (verifyRequest(check, id, config) === false) { 32 | throw new HTTPException(400, { 33 | res: new Response("ID incorrect."), 34 | }); 35 | } 36 | } 37 | 38 | const youtubePlayerResponseJson = await youtubePlayerParsing({ 39 | innertubeClient, 40 | videoId: id, 41 | config, 42 | tokenMinter: c.get("tokenMinter"), 43 | metrics, 44 | }); 45 | const videoInfo = youtubeVideoInfo( 46 | innertubeClient, 47 | youtubePlayerResponseJson, 48 | ); 49 | 50 | if (videoInfo.playability_status?.status !== "OK") { 51 | throw ("The video can't be played: " + id + " due to reason: " + 52 | videoInfo.playability_status?.reason); 53 | } 54 | const streamingData = videoInfo.streaming_data; 55 | const availableFormats = streamingData?.formats.concat( 56 | streamingData.adaptive_formats, 57 | ); 58 | const selectedItagFormat = availableFormats?.filter((i) => 59 | i.itag == Number(itag) 60 | ); 61 | if (selectedItagFormat?.length === 0) { 62 | throw new HTTPException(400, { 63 | res: new Response("No itag found."), 64 | }); 65 | } else if (selectedItagFormat) { 66 | const itagUrl = selectedItagFormat[0].url as string; 67 | const itagUrlParsed = new URL(itagUrl); 68 | let queryParams = new URLSearchParams(itagUrlParsed.search); 69 | let urlToRedirect = itagUrlParsed.toString(); 70 | 71 | if (local) { 72 | queryParams.set("host", itagUrlParsed.host); 73 | if (config.server.encrypt_query_params) { 74 | const publicParams = [...queryParams].filter(([key]) => 75 | ["pot", "ip"].includes(key) === false 76 | ); 77 | const privateParams = [...queryParams].filter(([key]) => 78 | ["pot", "ip"].includes(key) === true 79 | ); 80 | const encryptedParams = encryptQuery( 81 | JSON.stringify(privateParams), 82 | config, 83 | ); 84 | queryParams = new URLSearchParams(publicParams); 85 | queryParams.set("enc", "true"); 86 | queryParams.set("data", encryptedParams); 87 | } 88 | urlToRedirect = itagUrlParsed.pathname + "?" + 89 | queryParams.toString(); 90 | } 91 | 92 | if (title) urlToRedirect += `&title=${encodeURIComponent(title)}`; 93 | 94 | return c.redirect(urlToRedirect); 95 | } 96 | }); 97 | 98 | export default latestVersion; 99 | -------------------------------------------------------------------------------- /src/routes/metrics.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | 3 | const metrics = new Hono(); 4 | 5 | metrics.get("/", async (c) => { 6 | return new Response(await c.get("metrics")?.register.metrics(), { 7 | headers: { "Content-Type": "text/plain" }, 8 | }); 9 | }); 10 | 11 | export default metrics; 12 | -------------------------------------------------------------------------------- /src/routes/videoPlaybackProxy.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import { encodeRFC5987ValueChars } from "../lib/helpers/encodeRFC5987ValueChars.ts"; 4 | import { decryptQuery } from "../lib/helpers/encryptQuery.ts"; 5 | import { StreamingApi } from "hono/utils/stream"; 6 | 7 | let getFetchClientLocation = "getFetchClient"; 8 | if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) { 9 | if (Deno.env.has("DENO_COMPILED")) { 10 | getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") + 11 | Deno.env.get("GET_FETCH_CLIENT_LOCATION"); 12 | } else { 13 | getFetchClientLocation = Deno.env.get( 14 | "GET_FETCH_CLIENT_LOCATION", 15 | ) as string; 16 | } 17 | } 18 | const { getFetchClient } = await import(getFetchClientLocation); 19 | 20 | const videoPlaybackProxy = new Hono(); 21 | 22 | videoPlaybackProxy.options("/", () => { 23 | const headersForResponse: Record = { 24 | "access-control-allow-origin": "*", 25 | "access-control-allow-methods": "GET, OPTIONS", 26 | "access-control-allow-headers": "Content-Type, Range", 27 | }; 28 | return new Response("OK", { 29 | status: 200, 30 | headers: headersForResponse, 31 | }); 32 | }); 33 | 34 | videoPlaybackProxy.get("/", async (c) => { 35 | const { host, c: client, expire, title } = c.req.query(); 36 | const urlReq = new URL(c.req.url); 37 | const config = c.get("config"); 38 | const queryParams = new URLSearchParams(urlReq.search); 39 | 40 | if (c.req.query("enc") === "true") { 41 | const { data: encryptedQuery } = c.req.query(); 42 | const decryptedQueryParams = decryptQuery(encryptedQuery, config); 43 | const parsedDecryptedQueryParams = new URLSearchParams( 44 | JSON.parse(decryptedQueryParams), 45 | ); 46 | queryParams.delete("enc"); 47 | queryParams.delete("data"); 48 | queryParams.set("pot", parsedDecryptedQueryParams.get("pot") as string); 49 | queryParams.set("ip", parsedDecryptedQueryParams.get("ip") as string); 50 | } 51 | 52 | if (host == undefined || !/[\w-]+.googlevideo.com/.test(host)) { 53 | throw new HTTPException(400, { 54 | res: new Response("Host query string do not match or undefined."), 55 | }); 56 | } 57 | 58 | if ( 59 | expire == undefined || 60 | Number(expire) < Number(Date.now().toString().slice(0, -3)) 61 | ) { 62 | throw new HTTPException(400, { 63 | res: new Response( 64 | "Expire query string undefined or videoplayback URL has expired.", 65 | ), 66 | }); 67 | } 68 | 69 | if (client == undefined) { 70 | throw new HTTPException(400, { 71 | res: new Response("'c' query string undefined."), 72 | }); 73 | } 74 | 75 | queryParams.delete("host"); 76 | queryParams.delete("title"); 77 | 78 | const rangeHeader = c.req.header("range"); 79 | const requestBytes = rangeHeader ? rangeHeader.split("=")[1] : null; 80 | const [firstByte, lastByte] = requestBytes?.split("-") || []; 81 | if (requestBytes) { 82 | queryParams.append( 83 | "range", 84 | requestBytes, 85 | ); 86 | } 87 | 88 | const headersToSend: HeadersInit = { 89 | "accept": "*/*", 90 | "accept-encoding": "gzip, deflate, br, zstd", 91 | "accept-language": "en-us,en;q=0.5", 92 | "origin": "https://www.youtube.com", 93 | "referer": "https://www.youtube.com", 94 | }; 95 | 96 | if (client == "ANDROID") { 97 | headersToSend["user-agent"] = 98 | "com.google.android.youtube/1537338816 (Linux; U; Android 13; en_US; ; Build/TQ2A.230505.002; Cronet/113.0.5672.24)"; 99 | } else if (client == "IOS") { 100 | headersToSend["user-agent"] = 101 | "com.google.ios.youtube/19.32.8 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)"; 102 | } else { 103 | headersToSend["user-agent"] = 104 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; 105 | } 106 | 107 | const fetchClient = await getFetchClient(config); 108 | 109 | let headResponse: Response | undefined; 110 | let location = `https://${host}/videoplayback?${queryParams.toString()}`; 111 | 112 | // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-p2-semantics-17#section-7.3 113 | // A maximum of 5 redirections is defined in the note of the section 7.3 114 | // of this RFC, that's why `i < 5` 115 | for (let i = 0; i < 5; i++) { 116 | const googlevideoResponse: Response = await fetchClient(location, { 117 | method: "HEAD", 118 | headers: headersToSend, 119 | redirect: "manual", 120 | }); 121 | if (googlevideoResponse.status == 403) { 122 | return new Response(googlevideoResponse.body, { 123 | status: googlevideoResponse.status, 124 | statusText: googlevideoResponse.statusText, 125 | }); 126 | } 127 | if (googlevideoResponse.headers.has("Location")) { 128 | location = googlevideoResponse.headers.get("Location") as string; 129 | continue; 130 | } else { 131 | headResponse = googlevideoResponse; 132 | break; 133 | } 134 | } 135 | if (headResponse === undefined) { 136 | throw new HTTPException(502, { 137 | res: new Response( 138 | "Google headResponse redirected too many times", 139 | ), 140 | }); 141 | } 142 | 143 | // =================== REQUEST CHUNKING ======================= 144 | // if the requested response is larger than the chunkSize, break up the response 145 | // into chunks and stream the response back to the client to avoid rate limiting 146 | const { readable, writable } = new TransformStream(); 147 | const stream = new StreamingApi(writable, readable); 148 | const googleVideoUrl = new URL(location); 149 | const getChunk = async (start: number, end: number) => { 150 | googleVideoUrl.searchParams.set( 151 | "range", 152 | `${start}-${end}`, 153 | ); 154 | const postResponse = await fetchClient(googleVideoUrl, { 155 | method: "POST", 156 | body: new Uint8Array([0x78, 0]), // protobuf: { 15: 0 } (no idea what it means but this is what YouTube uses), 157 | headers: headersToSend, 158 | }); 159 | if (postResponse.status !== 200) { 160 | throw new Error("Non-200 response from google servers"); 161 | } 162 | await stream.pipe(postResponse.body); 163 | }; 164 | 165 | const chunkSize = 166 | config.networking.videoplayback.video_fetch_chunk_size_mb * 1_000_000; 167 | const totalBytes = Number( 168 | headResponse.headers.get("Content-Length") || "0", 169 | ); 170 | 171 | // if no range sent, the client wants thw whole file, i.e. for downloads 172 | const wholeRequestStartByte = Number(firstByte || "0"); 173 | const wholeRequestEndByte = wholeRequestStartByte + Number(totalBytes) - 1; 174 | 175 | let chunk = Promise.resolve(); 176 | for ( 177 | let startByte = wholeRequestStartByte; 178 | startByte < wholeRequestEndByte; 179 | startByte += chunkSize 180 | ) { 181 | // i.e. 182 | // 0 - 4_999_999, then 183 | // 5_000_000 - 9_999_999, then 184 | // 10_000_000 - 14_999_999 185 | let endByte = startByte + chunkSize - 1; 186 | if (endByte > wholeRequestEndByte) { 187 | endByte = wholeRequestEndByte; 188 | } 189 | chunk = chunk.then(() => getChunk(startByte, endByte)); 190 | } 191 | chunk.catch(() => { 192 | stream.abort(); 193 | }); 194 | // =================== REQUEST CHUNKING ======================= 195 | 196 | const headersForResponse: Record = { 197 | "content-length": headResponse.headers.get("content-length") || "", 198 | "access-control-allow-origin": "*", 199 | "accept-ranges": headResponse.headers.get("accept-ranges") || "", 200 | "content-type": headResponse.headers.get("content-type") || "", 201 | "expires": headResponse.headers.get("expires") || "", 202 | "last-modified": headResponse.headers.get("last-modified") || "", 203 | }; 204 | 205 | if (title) { 206 | headersForResponse["content-disposition"] = `attachment; filename="${ 207 | encodeURIComponent(title) 208 | }"; filename*=UTF-8''${encodeRFC5987ValueChars(title)}`; 209 | } 210 | 211 | let responseStatus = headResponse.status; 212 | if (requestBytes && responseStatus == 200) { 213 | // check for range headers in the forms: 214 | // "bytes=0-" get full length from start 215 | // "bytes=500-" get full length from 500 bytes in 216 | // "bytes=500-1000" get 500 bytes starting from 500 217 | if (lastByte) { 218 | responseStatus = 206; 219 | headersForResponse["content-range"] = `bytes ${requestBytes}/${ 220 | queryParams.get("clen") || "*" 221 | }`; 222 | } else { 223 | // i.e. "bytes=0-", "bytes=600-" 224 | // full size of content is able to be calculated, so a full Content-Range header can be constructed 225 | const bytesReceived = headersForResponse["content-length"]; 226 | // last byte should always be one less than the length 227 | const totalContentLength = Number(firstByte) + 228 | Number(bytesReceived); 229 | const lastByte = totalContentLength - 1; 230 | if (firstByte !== "0") { 231 | // only part of the total content returned, 206 232 | responseStatus = 206; 233 | } 234 | headersForResponse["content-range"] = 235 | `bytes ${firstByte}-${lastByte}/${totalContentLength}`; 236 | } 237 | } 238 | 239 | return new Response(stream.responseReadable, { 240 | status: responseStatus, 241 | statusText: headResponse.statusText, 242 | headers: headersForResponse, 243 | }); 244 | }); 245 | 246 | export default videoPlaybackProxy; 247 | -------------------------------------------------------------------------------- /src/routes/youtube_api_routes/player.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { youtubePlayerParsing } from "../../lib/helpers/youtubePlayerHandling.ts"; 3 | 4 | const player = new Hono(); 5 | 6 | player.post("/player", async (c) => { 7 | const jsonReq = await c.req.json(); 8 | const innertubeClient = c.get("innertubeClient"); 9 | const config = c.get("config"); 10 | const metrics = c.get("metrics"); 11 | if (jsonReq.videoId) { 12 | return c.json( 13 | await youtubePlayerParsing({ 14 | innertubeClient, 15 | videoId: jsonReq.videoId, 16 | config, 17 | tokenMinter: c.get("tokenMinter"), 18 | metrics, 19 | }), 20 | ); 21 | } 22 | }); 23 | 24 | export default player; 25 | -------------------------------------------------------------------------------- /src/tests/dashManifest.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "./deps.ts"; 2 | 3 | export async function dashManifest(baseUrl: string) { 4 | const resp = await fetch( 5 | `${baseUrl}/api/manifest/dash/id/jNQXAC9IVRw?local=true&unique_res=1`, 6 | { 7 | method: "GET", 8 | }, 9 | ); 10 | 11 | await resp.body?.cancel(); 12 | assertEquals(resp.status, 200, "response status code is not 200"); 13 | } 14 | -------------------------------------------------------------------------------- /src/tests/deps.ts: -------------------------------------------------------------------------------- 1 | export { assert, assertEquals } from "jsr:@std/assert@1.0.12"; 2 | -------------------------------------------------------------------------------- /src/tests/latestVersion.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "./deps.ts"; 2 | 3 | export async function latestVersion(baseUrl: string) { 4 | const resp = await fetch( 5 | `${baseUrl}/latest_version?id=jNQXAC9IVRw&itag=18&local=true`, 6 | { 7 | method: "GET", 8 | redirect: "manual", 9 | }, 10 | ); 11 | 12 | await resp.body?.cancel(); 13 | assertEquals(resp.status, 302); 14 | } 15 | -------------------------------------------------------------------------------- /src/tests/main_test.ts: -------------------------------------------------------------------------------- 1 | Deno.env.set("SERVER_SECRET_KEY", "aaaaaaaaaaaaaaaa"); 2 | const { run } = await import("../main.ts"); 3 | 4 | import { dashManifest } from "./dashManifest.ts"; 5 | import { youtubePlayer } from "./youtubePlayer.ts"; 6 | import { latestVersion } from "./latestVersion.ts"; 7 | 8 | Deno.test({ 9 | name: "Checking if Invidious companion works", 10 | async fn(t) { 11 | const controller = new AbortController(); 12 | const port = 8282; 13 | const baseUrl = `http://localhost:${port.toString()}`; 14 | const headers = { Authorization: "Bearer aaaaaaaaaaaaaaaa" }; 15 | 16 | await run( 17 | controller.signal, 18 | port, 19 | "localhost", 20 | ); 21 | 22 | await t.step( 23 | "Check if it can get an OK playabilityStatus on /youtubei/v1/player", 24 | youtubePlayer.bind(null, baseUrl, headers), 25 | ); 26 | 27 | await t.step( 28 | "Check if it can generate a DASH manifest", 29 | dashManifest.bind(null, baseUrl), 30 | ); 31 | 32 | await t.step( 33 | "Check if it can generate a valid URL for latest_version", 34 | latestVersion.bind(null, baseUrl), 35 | ); 36 | 37 | await controller.abort(); 38 | }, 39 | // need to disable leaks test for now because we are leaking resources when using HTTPClient using a proxy 40 | sanitizeResources: false, 41 | }); 42 | -------------------------------------------------------------------------------- /src/tests/youtubePlayer.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertEquals } from "./deps.ts"; 2 | 3 | export async function youtubePlayer( 4 | baseUrl: string, 5 | headers: { Authorization: string }, 6 | ) { 7 | const resp = await fetch(`${baseUrl}/youtubei/v1/player`, { 8 | method: "POST", 9 | headers, 10 | body: JSON.stringify({ 11 | videoId: "jNQXAC9IVRw", 12 | }), 13 | }); 14 | 15 | assertEquals(resp.status, 200, "response status code is not 200"); 16 | 17 | const youtubeV1Player = await resp.json(); 18 | 19 | assertEquals( 20 | youtubeV1Player.playabilityStatus?.status, 21 | "OK", 22 | "playabilityStatus is not OK", 23 | ); 24 | assertEquals( 25 | youtubeV1Player.videoDetails?.videoId, 26 | "jNQXAC9IVRw", 27 | "videoDetails is not jNQXAC9IVRw", 28 | ); 29 | assert( 30 | youtubeV1Player.streamingData?.adaptiveFormats, 31 | "adaptiveFormats is not present", 32 | ); 33 | assert( 34 | youtubeV1Player.streamingData?.adaptiveFormats.length > 0, 35 | "adaptiveFormats is empty", 36 | ); 37 | } 38 | --------------------------------------------------------------------------------