Upload 57 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- Dockerfile +61 -0
- LICENSE +661 -0
- compile.env +1 -0
- config/config.example.toml +80 -0
- config/config.toml +60 -0
- deno.json +47 -0
- deno.lock +0 -0
- entrypoint.sh +51 -0
- go.mod +11 -0
- go.sum +0 -0
- grafana_dashboard.json +888 -0
- invidious-companion.service +42 -0
- server.go +311 -0
- src/.DS_Store +0 -0
- src/constants.ts +11 -0
- src/lib/extra/emptyExport.ts +1 -0
- src/lib/helpers/config.ts +180 -0
- src/lib/helpers/encodeRFC5987ValueChars.ts +21 -0
- src/lib/helpers/encryptQuery.ts +56 -0
- src/lib/helpers/getFetchClient.ts +107 -0
- src/lib/helpers/ipv6Rotation.ts +109 -0
- src/lib/helpers/jsInterpreter.ts +22 -0
- src/lib/helpers/metrics.ts +126 -0
- src/lib/helpers/proxyManager.ts +452 -0
- src/lib/helpers/urbanProxy.ts +233 -0
- src/lib/helpers/validateVideoId.ts +23 -0
- src/lib/helpers/verifyRequest.ts +39 -0
- src/lib/helpers/youtubePlayerHandling.ts +195 -0
- src/lib/helpers/youtubePlayerReq.ts +105 -0
- src/lib/helpers/youtubeTranscriptsHandling.ts +90 -0
- src/lib/jobs/potoken.ts +252 -0
- src/lib/jobs/worker.ts +251 -0
- src/lib/types/HonoVariables.ts +11 -0
- src/main.ts +264 -0
- src/routes/health.ts +12 -0
- src/routes/index.ts +89 -0
- src/routes/invidious_routes/captions.ts +114 -0
- src/routes/invidious_routes/channels.ts +255 -0
- src/routes/invidious_routes/dashManifest.ts +132 -0
- src/routes/invidious_routes/download.ts +100 -0
- src/routes/invidious_routes/latestVersion.ts +119 -0
- src/routes/invidious_routes/mixes.ts +91 -0
- src/routes/invidious_routes/playlists.ts +165 -0
- src/routes/invidious_routes/search.ts +300 -0
- src/routes/invidious_routes/videos.ts +622 -0
- src/routes/metrics.ts +11 -0
- src/routes/videoPlaybackProxy.ts +240 -0
- src/routes/youtube_api_routes/player.ts +54 -0
- src/tests/dashManifest.ts +13 -0
- src/tests/deps.ts +6 -0
Dockerfile
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM golang:bookworm as builder
|
| 2 |
+
WORKDIR /go/src
|
| 3 |
+
EXPOSE 8080
|
| 4 |
+
|
| 5 |
+
# Install git first as it is required for go mod tidy
|
| 6 |
+
RUN apt-get update && apt-get install -y git build-essential
|
| 7 |
+
|
| 8 |
+
# Copy everything first
|
| 9 |
+
COPY go.mod go.sum ./
|
| 10 |
+
COPY warp.go server.go ./
|
| 11 |
+
|
| 12 |
+
# Resolution ambiguity fix: Explicitly fetch the main module and tidy
|
| 13 |
+
RUN go mod download && go mod tidy
|
| 14 |
+
|
| 15 |
+
# Build binaries
|
| 16 |
+
RUN CGO_ENABLED=0 GOOS=linux \
|
| 17 |
+
go build -a -installsuffix cgo -ldflags '-s' -o warp warp.go && \
|
| 18 |
+
go build -a -installsuffix cgo -ldflags '-s' -o server server.go
|
| 19 |
+
|
| 20 |
+
FROM ubuntu:22.04
|
| 21 |
+
|
| 22 |
+
# Copy binaries
|
| 23 |
+
COPY --from=builder /go/src/warp /usr/local/bin/
|
| 24 |
+
COPY --from=builder /go/src/server /usr/local/bin/
|
| 25 |
+
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
| 26 |
+
|
| 27 |
+
COPY entrypoint.sh /usr/local/bin/
|
| 28 |
+
|
| 29 |
+
# Install dependencies and Deno
|
| 30 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 31 |
+
bash \
|
| 32 |
+
curl \
|
| 33 |
+
ca-certificates \
|
| 34 |
+
unzip \
|
| 35 |
+
ffmpeg \
|
| 36 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 37 |
+
&& curl -fsSL https://deno.land/x/install/install.sh | sh \
|
| 38 |
+
&& mv /root/.deno/bin/deno /usr/local/bin/deno \
|
| 39 |
+
&& chmod +x /usr/local/bin/entrypoint.sh
|
| 40 |
+
|
| 41 |
+
# Copy Deno App
|
| 42 |
+
WORKDIR /app
|
| 43 |
+
COPY deno.json deno.lock compile.env grafana_dashboard.json ./
|
| 44 |
+
COPY config ./config
|
| 45 |
+
COPY src ./src
|
| 46 |
+
|
| 47 |
+
ENV DAEMON_MODE false
|
| 48 |
+
ENV PROXY_UP ""
|
| 49 |
+
ENV PROXY_PORT "8080"
|
| 50 |
+
ENV PROXY_USER ""
|
| 51 |
+
ENV PROXY_PASS ""
|
| 52 |
+
ENV WIREGUARD_UP ""
|
| 53 |
+
ENV WIREGUARD_CONFIG ""
|
| 54 |
+
ENV WIREGUARD_INTERFACE_PRIVATE_KEY ""
|
| 55 |
+
ENV WIREGUARD_INTERFACE_DNS "1.1.1.1"
|
| 56 |
+
ENV WIREGUARD_INTERFACE_ADDRESS ""
|
| 57 |
+
ENV WIREGUARD_PEER_PUBLIC_KEY ""
|
| 58 |
+
ENV WIREGUARD_PEER_ALLOWED_IPS "0.0.0.0/0"
|
| 59 |
+
ENV WIREGUARD_PEER_ENDPOINT ""
|
| 60 |
+
|
| 61 |
+
ENTRYPOINT [ "entrypoint.sh" ]
|
LICENSE
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
| 2 |
+
Version 3, 19 November 2007
|
| 3 |
+
|
| 4 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
| 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 |
+
<one line to give the program's name and a brief idea of what it does.>
|
| 633 |
+
Copyright (C) <year> <name of author>
|
| 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 <http://www.gnu.org/licenses/>.
|
| 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 |
+
<http://www.gnu.org/licenses/>.
|
compile.env
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
DENO_COMPILED=true
|
config/config.example.toml
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# # Listens to a unix socket on instead of a TCP socket
|
| 16 |
+
# use_unix_socket = false # env variable: SERVER_USE_UNIX_SOCKET
|
| 17 |
+
# unix_socket_path = "/tmp/invidious-companion.sock" # env variable: SERVER_UNIX_SOCKET_PATH
|
| 18 |
+
# # Base path Invidious companion will serve from
|
| 19 |
+
# base_path = "/companion" # env variable: SERVER_BASE_PATH
|
| 20 |
+
# # secret key needs to be exactly 16 characters long
|
| 21 |
+
# secret_key = "CHANGE_ME" # env variable: SERVER_SECRET_KEY
|
| 22 |
+
# verify_requests = false # env variable: SERVER_VERIFY_REQUESTS
|
| 23 |
+
# encrypt_query_params = false # env variable: SERVER_ENCRYPT_QUERY_PARAMS
|
| 24 |
+
# enable_metrics = false # env variable: SERVER_ENABLE_METRICS
|
| 25 |
+
|
| 26 |
+
# [cache]
|
| 27 |
+
# enabled = true # env variable: CACHE_ENABLED
|
| 28 |
+
# # will get cached in /var/tmp/youtubei.js if you specify /var/tmp
|
| 29 |
+
# # you need to change the --allow-write from deno run too
|
| 30 |
+
# directory = "/var/tmp" # env variable: CACHE_DIRECTORY
|
| 31 |
+
|
| 32 |
+
# [networking]
|
| 33 |
+
## Proxy type supported: https://docs.deno.com/api/deno/~/Deno.Proxy
|
| 34 |
+
# #proxy = "" # env variable: PROXY
|
| 35 |
+
# # Enable automatic proxy fetching from antpeak.com (free proxies, auto-rotates when failed)
|
| 36 |
+
# # When enabled, ignores the `proxy` setting above and fetches proxies automatically
|
| 37 |
+
# auto_proxy = false # env variable: NETWORKING_AUTO_PROXY
|
| 38 |
+
# # IPv6 rotation settings - allows sending requests with unique IPv6 addresses
|
| 39 |
+
# # This requires IPv6 setup: https://github.com/iv-org/invidious-companion/wiki/How-to-send-IPv6-requests-with-a-new-IPv6-address-for-each-request-on-a-server-with-a-whole-IPv6-range
|
| 40 |
+
# # Randomizes all bits after the block prefix (e.g., /32 randomizes bits 33-128)
|
| 41 |
+
# #ipv6_block = "2001:db8::/32" # env variable: NETWORKING_IPV6_BLOCK
|
| 42 |
+
|
| 43 |
+
# [networking.videoplayback]
|
| 44 |
+
# # Enable YouTube new video format UMP
|
| 45 |
+
# ump = false # env variable: NETWORKING_VIDEOPLAYBACK_UMP
|
| 46 |
+
# # size of chunks to request from google servers for rate limiting reductions
|
| 47 |
+
# video_fetch_chunk_size_mb = 5 # env variable: NETWORKING_VIDEOPLAYBACK_VIDEO_FETCH_CHUNK_SIZE_MB
|
| 48 |
+
|
| 49 |
+
###
|
| 50 |
+
# Network call timeouts when talking to YouTube.
|
| 51 |
+
# Needed in order to ensure Deno closes hanging connections
|
| 52 |
+
###
|
| 53 |
+
# [networking.fetch]
|
| 54 |
+
# timeout_ms = 30000 # env variable: NETWORKING_FETCH_TIMEOUT_MS
|
| 55 |
+
|
| 56 |
+
###
|
| 57 |
+
# Network call retries when talking to YouTube, using
|
| 58 |
+
# https://docs.deno.com/examples/exponential_backoff/
|
| 59 |
+
###
|
| 60 |
+
# [networking.fetch.retry]
|
| 61 |
+
# # enable retries on calls to YouTube
|
| 62 |
+
# enabled = false # env variable: NETWORKING_FETCH_RETRY_ENABLED
|
| 63 |
+
# # max number of times to retry
|
| 64 |
+
# times = 1 # env variable: NETWORKING_FETCH_RETRY_TIMES
|
| 65 |
+
# # minimum wait after first call (ms)
|
| 66 |
+
# initial_debounce = 0 # env variable: NETWORKING_FETCH_RETRY_INITIAL_DEBOUNCE
|
| 67 |
+
# # how much to back off after each retry (multiplier of initial_debounce)
|
| 68 |
+
# debounce_multiplier = 0 # env variable: NETWORKING_FETCH_RETRY_DEBOUNCE_MULTIPLIER
|
| 69 |
+
|
| 70 |
+
# [jobs]
|
| 71 |
+
|
| 72 |
+
# [jobs.youtube_session]
|
| 73 |
+
# # whether to generate PO tokens
|
| 74 |
+
# po_token_enabled = true # env variable: JOBS_YOUTUBE_SESSION_PO_TOKEN_ENABLED
|
| 75 |
+
# # frequency of PO token refresh in cron format
|
| 76 |
+
# frequency = "*/5 * * * *" # env variable: JOBS_YOUTUBE_SESSION_FREQUENCY
|
| 77 |
+
|
| 78 |
+
# [youtube_session]
|
| 79 |
+
# oauth_enabled = false # env variable: YOUTUBE_SESSION_OAUTH_ENABLED
|
| 80 |
+
# cookies = "" # env variable: YOUTUBE_SESSION_COOKIES
|
config/config.toml
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#####
|
| 2 |
+
# Invidious Companion Configuration
|
| 3 |
+
#
|
| 4 |
+
# See https://toml.io/en/ for details on the configuration format.
|
| 5 |
+
#####
|
| 6 |
+
|
| 7 |
+
[server]
|
| 8 |
+
port = 7860 # env: PORT
|
| 9 |
+
host = "0.0.0.0" # env: HOST
|
| 10 |
+
secret_key = "0123456789abcdef" # env: SERVER_SECRET_KEY
|
| 11 |
+
|
| 12 |
+
# Optional Server Settings
|
| 13 |
+
# use_unix_socket = false # env: SERVER_USE_UNIX_SOCKET
|
| 14 |
+
# unix_socket_path = "/tmp/invidious-companion.sock"
|
| 15 |
+
base_path = "/" # env: SERVER_BASE_PATH
|
| 16 |
+
# verify_requests = false # env: SERVER_VERIFY_REQUESTS
|
| 17 |
+
# encrypt_query_params = false # env: SERVER_ENCRYPT_QUERY_PARAMS
|
| 18 |
+
enable_metrics = true # env: SERVER_ENABLE_METRICS
|
| 19 |
+
|
| 20 |
+
[cache]
|
| 21 |
+
enabled = false # env: CACHE_ENABLED
|
| 22 |
+
# directory = "/var/tmp" # env: CACHE_DIRECTORY
|
| 23 |
+
|
| 24 |
+
[networking]
|
| 25 |
+
# Auto Proxy Settings
|
| 26 |
+
# auto_proxy: enable automatic proxy fetching (rotates on failure)
|
| 27 |
+
auto_proxy = false # env: NETWORKING_AUTO_PROXY
|
| 28 |
+
|
| 29 |
+
# VPN Source: Which service to use for auto_proxy
|
| 30 |
+
# 1 = AntPeak (Default)
|
| 31 |
+
# 2 = Urban VPN
|
| 32 |
+
# 3 = Custom Proxy API (self hosted)
|
| 33 |
+
vpn_source = 2 # env: NETWORKING_VPN_SOURCE
|
| 34 |
+
|
| 35 |
+
# Manual Proxy (overrides auto_proxy if set)
|
| 36 |
+
proxy = "http://127.0.0.1:8080"
|
| 37 |
+
|
| 38 |
+
# IPv6 Rotation
|
| 39 |
+
# ipv6_block = "2001:db8::/32" # env: NETWORKING_IPV6_BLOCK
|
| 40 |
+
|
| 41 |
+
[networking.videoplayback]
|
| 42 |
+
ump = false # env: NETWORKING_VIDEOPLAYBACK_UMP
|
| 43 |
+
video_fetch_chunk_size_mb = 5 # env: NETWORKING_VIDEOPLAYBACK_VIDEO_FETCH_CHUNK_SIZE_MB
|
| 44 |
+
|
| 45 |
+
[networking.fetch]
|
| 46 |
+
# timeout_ms = 30000 # env: NETWORKING_FETCH_TIMEOUT_MS
|
| 47 |
+
|
| 48 |
+
[networking.fetch.retry]
|
| 49 |
+
# enabled = false # env: NETWORKING_FETCH_RETRY_ENABLED
|
| 50 |
+
# times = 1 # env: NETWORKING_FETCH_RETRY_TIMES
|
| 51 |
+
# initial_debounce = 0
|
| 52 |
+
# debounce_multiplier = 0
|
| 53 |
+
|
| 54 |
+
[jobs.youtube_session]
|
| 55 |
+
po_token_enabled = true # env: JOBS_YOUTUBE_SESSION_PO_TOKEN_ENABLED
|
| 56 |
+
frequency = "*/5 * * * *" # env: JOBS_YOUTUBE_SESSION_FREQUENCY
|
| 57 |
+
|
| 58 |
+
[youtube_session]
|
| 59 |
+
# oauth_enabled = true # env: YOUTUBE_SESSION_OAUTH_ENABLED
|
| 60 |
+
# cookies = "" # env: YOUTUBE_SESSION_COOKIES
|
deno.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=.,/var/tmp/youtubei.js,/tmp/invidious-companion.sock,/tmp/mp3-downloads --allow-write=/var/tmp/youtubei.js,/tmp/invidious-companion.sock,/tmp/mp3-downloads --allow-run=ffmpeg --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,/tmp/invidious-companion.sock,/tmp/mp3-downloads --allow-run=ffmpeg 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=.,/var/tmp/youtubei.js,/tmp/invidious-companion.sock --allow-write=/var/tmp/youtubei.js",
|
| 6 |
+
"format": "deno fmt src/**",
|
| 7 |
+
"check": "deno check src/**",
|
| 8 |
+
"lint": "deno lint src/**"
|
| 9 |
+
},
|
| 10 |
+
"imports": {
|
| 11 |
+
"@std/cli": "jsr:@std/cli@^1.0.17",
|
| 12 |
+
"hono": "jsr:@hono/hono@4.7.4",
|
| 13 |
+
"@std/toml": "jsr:@std/toml@1.0.2",
|
| 14 |
+
"prom-client": "https://esm.sh/prom-client@15.1.3?pin=v135",
|
| 15 |
+
"youtubei.js": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno.ts",
|
| 16 |
+
"youtubei.js/Utils": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/Utils.ts",
|
| 17 |
+
"youtubei.js/NavigationEndpoint": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/NavigationEndpoint.ts",
|
| 18 |
+
"youtubei.js/PlayerCaptionsTracklist": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerCaptionsTracklist.ts",
|
| 19 |
+
"youtubei.js/TabbedFeed": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/mixins/TabbedFeed.ts",
|
| 20 |
+
"jsdom": "npm:jsdom@26.1.0",
|
| 21 |
+
"bgutils": "https://esm.sh/bgutils-js@3.2.0",
|
| 22 |
+
"estree": "https://esm.sh/@types/estree@1.0.6",
|
| 23 |
+
"youtubePlayerReq": "./src/lib/helpers/youtubePlayerReq.ts",
|
| 24 |
+
"getFetchClient": "./src/lib/helpers/getFetchClient.ts",
|
| 25 |
+
"googlevideo": "jsr:@luanrt/googlevideo@2.0.0",
|
| 26 |
+
"meriyah": "npm:meriyah@6.1.4",
|
| 27 |
+
"crypto/": "https://deno.land/x/crypto@v0.11.0/",
|
| 28 |
+
"@std/encoding/base64": "jsr:@std/encoding@1.0.7/base64",
|
| 29 |
+
"@std/async": "jsr:@std/async@1.0.11",
|
| 30 |
+
"@std/fs": "jsr:@std/fs@1.0.14",
|
| 31 |
+
"@std/path": "jsr:@std/path@1.0.8",
|
| 32 |
+
"brotli": "https://deno.land/x/brotli@0.1.7/mod.ts",
|
| 33 |
+
"zod": "https://deno.land/x/zod@v3.24.2/mod.ts",
|
| 34 |
+
"canvas": "./src/lib/extra/emptyExport.ts",
|
| 35 |
+
"bufferutil": "./src/lib/extra/emptyExport.ts",
|
| 36 |
+
"utf-8-validate": "./src/lib/extra/emptyExport.ts"
|
| 37 |
+
},
|
| 38 |
+
"unstable": [
|
| 39 |
+
"cron",
|
| 40 |
+
"kv",
|
| 41 |
+
"http",
|
| 42 |
+
"temporal"
|
| 43 |
+
],
|
| 44 |
+
"fmt": {
|
| 45 |
+
"indentWidth": 4
|
| 46 |
+
}
|
| 47 |
+
}
|
deno.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -ex
|
| 3 |
+
|
| 4 |
+
echo "[ENTRYPOINT] Starting WireGuard HTTP Proxy"
|
| 5 |
+
|
| 6 |
+
# Check if WireGuard config should be generated
|
| 7 |
+
if [[ -z "${WIREGUARD_INTERFACE_PRIVATE_KEY}" ]]; then
|
| 8 |
+
echo "[ENTRYPOINT] Generating Cloudflare Warp configuration..."
|
| 9 |
+
|
| 10 |
+
# Run warp binary to generate config
|
| 11 |
+
WARP_OUTPUT=$(warp)
|
| 12 |
+
|
| 13 |
+
# Parse the warp output to extract config values
|
| 14 |
+
export WIREGUARD_INTERFACE_PRIVATE_KEY=$(echo "$WARP_OUTPUT" | grep "PrivateKey" | awk '{print $3}')
|
| 15 |
+
export WIREGUARD_INTERFACE_ADDRESS=$(echo "$WARP_OUTPUT" | grep "Address" | awk '{print $3}')
|
| 16 |
+
export WIREGUARD_PEER_PUBLIC_KEY=$(echo "$WARP_OUTPUT" | grep "PublicKey" | awk '{print $3}')
|
| 17 |
+
export WIREGUARD_PEER_ENDPOINT=$(echo "$WARP_OUTPUT" | grep "Endpoint" | awk '{print $3}')
|
| 18 |
+
export WIREGUARD_INTERFACE_DNS="${WIREGUARD_INTERFACE_DNS:-1.1.1.1}"
|
| 19 |
+
|
| 20 |
+
echo "[ENTRYPOINT] Warp config generated successfully"
|
| 21 |
+
else
|
| 22 |
+
echo "[ENTRYPOINT] Using provided WireGuard configuration"
|
| 23 |
+
fi
|
| 24 |
+
|
| 25 |
+
# Start the proxy server in the background
|
| 26 |
+
echo "[ENTRYPOINT] Starting HTTP proxy server (internal)..."
|
| 27 |
+
server &
|
| 28 |
+
SERVER_PID=$!
|
| 29 |
+
|
| 30 |
+
# Wait for proxy to start
|
| 31 |
+
echo "[ENTRYPOINT] Waiting for proxy to be ready on port 8080..."
|
| 32 |
+
while ! curl -v http://127.0.0.1:8080/ 2>&1 | grep "Proxy Running"; do
|
| 33 |
+
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
| 34 |
+
echo "[FATAL] Server process exited unexpectedly!"
|
| 35 |
+
wait $SERVER_PID
|
| 36 |
+
exit 1
|
| 37 |
+
fi
|
| 38 |
+
echo "[ENTRYPOINT] Proxy not ready yet... retrying in 1s"
|
| 39 |
+
sleep 1
|
| 40 |
+
done
|
| 41 |
+
echo "[ENTRYPOINT] Proxy is ready!"
|
| 42 |
+
|
| 43 |
+
# Start Proxy Check
|
| 44 |
+
echo "[ENTRYPOINT] Checking proxy connection..."
|
| 45 |
+
curl -s -x http://127.0.0.1:8080 https://cloudflare.com/cdn-cgi/trace
|
| 46 |
+
echo ""
|
| 47 |
+
echo "[ENTRYPOINT] Proxy check complete."
|
| 48 |
+
|
| 49 |
+
# Start Streamion
|
| 50 |
+
echo "[ENTRYPOINT] Starting Streamion..."
|
| 51 |
+
exec deno task dev
|
go.mod
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module streamion
|
| 2 |
+
|
| 3 |
+
go 1.20
|
| 4 |
+
|
| 5 |
+
require (
|
| 6 |
+
github.com/caarlos0/env v3.5.0+incompatible
|
| 7 |
+
golang.org/x/crypto v0.47.0
|
| 8 |
+
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
exclude golang.zx2c4.com/wireguard/tun/netstack v0.0.0-20220703234212-c31a7b1ab478
|
go.sum
ADDED
|
File without changes
|
grafana_dashboard.json
ADDED
|
@@ -0,0 +1,888 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"__inputs": [
|
| 3 |
+
{
|
| 4 |
+
"name": "DS_PROMETHEUS-REIMU",
|
| 5 |
+
"label": "prometheus-reimu",
|
| 6 |
+
"description": "",
|
| 7 |
+
"type": "datasource",
|
| 8 |
+
"pluginId": "prometheus",
|
| 9 |
+
"pluginName": "Prometheus"
|
| 10 |
+
}
|
| 11 |
+
],
|
| 12 |
+
"__elements": {},
|
| 13 |
+
"__requires": [
|
| 14 |
+
{
|
| 15 |
+
"type": "grafana",
|
| 16 |
+
"id": "grafana",
|
| 17 |
+
"name": "Grafana",
|
| 18 |
+
"version": "12.3.1"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"type": "datasource",
|
| 22 |
+
"id": "prometheus",
|
| 23 |
+
"name": "Prometheus",
|
| 24 |
+
"version": "1.0.0"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"type": "panel",
|
| 28 |
+
"id": "timeseries",
|
| 29 |
+
"name": "Time series",
|
| 30 |
+
"version": ""
|
| 31 |
+
}
|
| 32 |
+
],
|
| 33 |
+
"annotations": {
|
| 34 |
+
"list": [
|
| 35 |
+
{
|
| 36 |
+
"builtIn": 1,
|
| 37 |
+
"datasource": {
|
| 38 |
+
"type": "grafana",
|
| 39 |
+
"uid": "-- Grafana --"
|
| 40 |
+
},
|
| 41 |
+
"enable": true,
|
| 42 |
+
"hide": true,
|
| 43 |
+
"iconColor": "rgba(0, 211, 255, 1)",
|
| 44 |
+
"name": "Annotations & Alerts",
|
| 45 |
+
"type": "dashboard"
|
| 46 |
+
}
|
| 47 |
+
]
|
| 48 |
+
},
|
| 49 |
+
"editable": true,
|
| 50 |
+
"fiscalYearStartMonth": 0,
|
| 51 |
+
"graphTooltip": 0,
|
| 52 |
+
"links": [],
|
| 53 |
+
"panels": [
|
| 54 |
+
{
|
| 55 |
+
"collapsed": false,
|
| 56 |
+
"gridPos": {
|
| 57 |
+
"h": 1,
|
| 58 |
+
"w": 24,
|
| 59 |
+
"x": 0,
|
| 60 |
+
"y": 0
|
| 61 |
+
},
|
| 62 |
+
"id": 24,
|
| 63 |
+
"panels": [],
|
| 64 |
+
"title": "Videoplayback requests",
|
| 65 |
+
"type": "row"
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"datasource": {
|
| 69 |
+
"type": "prometheus",
|
| 70 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 71 |
+
},
|
| 72 |
+
"fieldConfig": {
|
| 73 |
+
"defaults": {
|
| 74 |
+
"color": {
|
| 75 |
+
"mode": "palette-classic"
|
| 76 |
+
},
|
| 77 |
+
"custom": {
|
| 78 |
+
"axisBorderShow": false,
|
| 79 |
+
"axisCenteredZero": false,
|
| 80 |
+
"axisColorMode": "text",
|
| 81 |
+
"axisLabel": "",
|
| 82 |
+
"axisPlacement": "auto",
|
| 83 |
+
"barAlignment": 0,
|
| 84 |
+
"barWidthFactor": 0.6,
|
| 85 |
+
"drawStyle": "line",
|
| 86 |
+
"fillOpacity": 0,
|
| 87 |
+
"gradientMode": "none",
|
| 88 |
+
"hideFrom": {
|
| 89 |
+
"legend": false,
|
| 90 |
+
"tooltip": false,
|
| 91 |
+
"viz": false
|
| 92 |
+
},
|
| 93 |
+
"insertNulls": false,
|
| 94 |
+
"lineInterpolation": "linear",
|
| 95 |
+
"lineWidth": 1,
|
| 96 |
+
"pointSize": 5,
|
| 97 |
+
"scaleDistribution": {
|
| 98 |
+
"type": "linear"
|
| 99 |
+
},
|
| 100 |
+
"showPoints": "auto",
|
| 101 |
+
"showValues": false,
|
| 102 |
+
"spanNulls": true,
|
| 103 |
+
"stacking": {
|
| 104 |
+
"group": "A",
|
| 105 |
+
"mode": "none"
|
| 106 |
+
},
|
| 107 |
+
"thresholdsStyle": {
|
| 108 |
+
"mode": "off"
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
"mappings": [],
|
| 112 |
+
"thresholds": {
|
| 113 |
+
"mode": "absolute",
|
| 114 |
+
"steps": [
|
| 115 |
+
{
|
| 116 |
+
"color": "green",
|
| 117 |
+
"value": 0
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"color": "red",
|
| 121 |
+
"value": 80
|
| 122 |
+
}
|
| 123 |
+
]
|
| 124 |
+
}
|
| 125 |
+
},
|
| 126 |
+
"overrides": []
|
| 127 |
+
},
|
| 128 |
+
"gridPos": {
|
| 129 |
+
"h": 10,
|
| 130 |
+
"w": 24,
|
| 131 |
+
"x": 0,
|
| 132 |
+
"y": 1
|
| 133 |
+
},
|
| 134 |
+
"id": 26,
|
| 135 |
+
"options": {
|
| 136 |
+
"legend": {
|
| 137 |
+
"calcs": [],
|
| 138 |
+
"displayMode": "list",
|
| 139 |
+
"placement": "bottom",
|
| 140 |
+
"showLegend": true
|
| 141 |
+
},
|
| 142 |
+
"tooltip": {
|
| 143 |
+
"hideZeros": false,
|
| 144 |
+
"mode": "single",
|
| 145 |
+
"sort": "none"
|
| 146 |
+
}
|
| 147 |
+
},
|
| 148 |
+
"pluginVersion": "12.3.1",
|
| 149 |
+
"targets": [
|
| 150 |
+
{
|
| 151 |
+
"datasource": {
|
| 152 |
+
"type": "prometheus",
|
| 153 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 154 |
+
},
|
| 155 |
+
"editorMode": "builder",
|
| 156 |
+
"expr": "rate(invidious_companion_videoplayback_forbidden_total[$__rate_interval])",
|
| 157 |
+
"instant": false,
|
| 158 |
+
"legendFormat": "{{instance}}",
|
| 159 |
+
"range": true,
|
| 160 |
+
"refId": "A"
|
| 161 |
+
}
|
| 162 |
+
],
|
| 163 |
+
"title": "Forbidden Videoplayback (403 from Youtube servers)",
|
| 164 |
+
"type": "timeseries"
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"collapsed": false,
|
| 168 |
+
"gridPos": {
|
| 169 |
+
"h": 1,
|
| 170 |
+
"w": 24,
|
| 171 |
+
"x": 0,
|
| 172 |
+
"y": 11
|
| 173 |
+
},
|
| 174 |
+
"id": 18,
|
| 175 |
+
"panels": [],
|
| 176 |
+
"title": "Requests",
|
| 177 |
+
"type": "row"
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"datasource": {
|
| 181 |
+
"type": "prometheus",
|
| 182 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 183 |
+
},
|
| 184 |
+
"fieldConfig": {
|
| 185 |
+
"defaults": {
|
| 186 |
+
"color": {
|
| 187 |
+
"mode": "palette-classic"
|
| 188 |
+
},
|
| 189 |
+
"custom": {
|
| 190 |
+
"axisBorderShow": false,
|
| 191 |
+
"axisCenteredZero": false,
|
| 192 |
+
"axisColorMode": "text",
|
| 193 |
+
"axisLabel": "",
|
| 194 |
+
"axisPlacement": "auto",
|
| 195 |
+
"barAlignment": 0,
|
| 196 |
+
"barWidthFactor": 0.6,
|
| 197 |
+
"drawStyle": "line",
|
| 198 |
+
"fillOpacity": 0,
|
| 199 |
+
"gradientMode": "none",
|
| 200 |
+
"hideFrom": {
|
| 201 |
+
"legend": false,
|
| 202 |
+
"tooltip": false,
|
| 203 |
+
"viz": false
|
| 204 |
+
},
|
| 205 |
+
"insertNulls": false,
|
| 206 |
+
"lineInterpolation": "linear",
|
| 207 |
+
"lineWidth": 1,
|
| 208 |
+
"pointSize": 5,
|
| 209 |
+
"scaleDistribution": {
|
| 210 |
+
"type": "linear"
|
| 211 |
+
},
|
| 212 |
+
"showPoints": "auto",
|
| 213 |
+
"showValues": false,
|
| 214 |
+
"spanNulls": true,
|
| 215 |
+
"stacking": {
|
| 216 |
+
"group": "A",
|
| 217 |
+
"mode": "none"
|
| 218 |
+
},
|
| 219 |
+
"thresholdsStyle": {
|
| 220 |
+
"mode": "off"
|
| 221 |
+
}
|
| 222 |
+
},
|
| 223 |
+
"mappings": [],
|
| 224 |
+
"thresholds": {
|
| 225 |
+
"mode": "absolute",
|
| 226 |
+
"steps": [
|
| 227 |
+
{
|
| 228 |
+
"color": "green",
|
| 229 |
+
"value": 0
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"color": "red",
|
| 233 |
+
"value": 80
|
| 234 |
+
}
|
| 235 |
+
]
|
| 236 |
+
}
|
| 237 |
+
},
|
| 238 |
+
"overrides": []
|
| 239 |
+
},
|
| 240 |
+
"gridPos": {
|
| 241 |
+
"h": 10,
|
| 242 |
+
"w": 12,
|
| 243 |
+
"x": 0,
|
| 244 |
+
"y": 12
|
| 245 |
+
},
|
| 246 |
+
"id": 12,
|
| 247 |
+
"options": {
|
| 248 |
+
"legend": {
|
| 249 |
+
"calcs": [],
|
| 250 |
+
"displayMode": "list",
|
| 251 |
+
"placement": "bottom",
|
| 252 |
+
"showLegend": true
|
| 253 |
+
},
|
| 254 |
+
"tooltip": {
|
| 255 |
+
"hideZeros": false,
|
| 256 |
+
"mode": "single",
|
| 257 |
+
"sort": "none"
|
| 258 |
+
}
|
| 259 |
+
},
|
| 260 |
+
"pluginVersion": "12.3.1",
|
| 261 |
+
"targets": [
|
| 262 |
+
{
|
| 263 |
+
"datasource": {
|
| 264 |
+
"type": "prometheus",
|
| 265 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 266 |
+
},
|
| 267 |
+
"disableTextWrap": false,
|
| 268 |
+
"editorMode": "builder",
|
| 269 |
+
"expr": "rate(invidious_companion_innertube_successful_request_total[$__rate_interval])",
|
| 270 |
+
"fullMetaSearch": false,
|
| 271 |
+
"includeNullMetadata": false,
|
| 272 |
+
"instant": false,
|
| 273 |
+
"legendFormat": "{{instance}}",
|
| 274 |
+
"range": true,
|
| 275 |
+
"refId": "A",
|
| 276 |
+
"useBackend": false
|
| 277 |
+
}
|
| 278 |
+
],
|
| 279 |
+
"title": "Successful Requests Rate",
|
| 280 |
+
"type": "timeseries"
|
| 281 |
+
},
|
| 282 |
+
{
|
| 283 |
+
"datasource": {
|
| 284 |
+
"type": "prometheus",
|
| 285 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 286 |
+
},
|
| 287 |
+
"fieldConfig": {
|
| 288 |
+
"defaults": {
|
| 289 |
+
"color": {
|
| 290 |
+
"mode": "palette-classic"
|
| 291 |
+
},
|
| 292 |
+
"custom": {
|
| 293 |
+
"axisBorderShow": false,
|
| 294 |
+
"axisCenteredZero": false,
|
| 295 |
+
"axisColorMode": "text",
|
| 296 |
+
"axisLabel": "",
|
| 297 |
+
"axisPlacement": "auto",
|
| 298 |
+
"barAlignment": 0,
|
| 299 |
+
"barWidthFactor": 0.6,
|
| 300 |
+
"drawStyle": "line",
|
| 301 |
+
"fillOpacity": 0,
|
| 302 |
+
"gradientMode": "none",
|
| 303 |
+
"hideFrom": {
|
| 304 |
+
"legend": false,
|
| 305 |
+
"tooltip": false,
|
| 306 |
+
"viz": false
|
| 307 |
+
},
|
| 308 |
+
"insertNulls": false,
|
| 309 |
+
"lineInterpolation": "linear",
|
| 310 |
+
"lineWidth": 1,
|
| 311 |
+
"pointSize": 5,
|
| 312 |
+
"scaleDistribution": {
|
| 313 |
+
"type": "linear"
|
| 314 |
+
},
|
| 315 |
+
"showPoints": "auto",
|
| 316 |
+
"showValues": false,
|
| 317 |
+
"spanNulls": true,
|
| 318 |
+
"stacking": {
|
| 319 |
+
"group": "A",
|
| 320 |
+
"mode": "none"
|
| 321 |
+
},
|
| 322 |
+
"thresholdsStyle": {
|
| 323 |
+
"mode": "off"
|
| 324 |
+
}
|
| 325 |
+
},
|
| 326 |
+
"mappings": [],
|
| 327 |
+
"thresholds": {
|
| 328 |
+
"mode": "absolute",
|
| 329 |
+
"steps": [
|
| 330 |
+
{
|
| 331 |
+
"color": "green",
|
| 332 |
+
"value": 0
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"color": "red",
|
| 336 |
+
"value": 80
|
| 337 |
+
}
|
| 338 |
+
]
|
| 339 |
+
}
|
| 340 |
+
},
|
| 341 |
+
"overrides": []
|
| 342 |
+
},
|
| 343 |
+
"gridPos": {
|
| 344 |
+
"h": 10,
|
| 345 |
+
"w": 12,
|
| 346 |
+
"x": 12,
|
| 347 |
+
"y": 12
|
| 348 |
+
},
|
| 349 |
+
"id": 13,
|
| 350 |
+
"options": {
|
| 351 |
+
"legend": {
|
| 352 |
+
"calcs": [],
|
| 353 |
+
"displayMode": "list",
|
| 354 |
+
"placement": "bottom",
|
| 355 |
+
"showLegend": true
|
| 356 |
+
},
|
| 357 |
+
"tooltip": {
|
| 358 |
+
"hideZeros": false,
|
| 359 |
+
"mode": "single",
|
| 360 |
+
"sort": "none"
|
| 361 |
+
}
|
| 362 |
+
},
|
| 363 |
+
"pluginVersion": "12.3.1",
|
| 364 |
+
"targets": [
|
| 365 |
+
{
|
| 366 |
+
"datasource": {
|
| 367 |
+
"type": "prometheus",
|
| 368 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 369 |
+
},
|
| 370 |
+
"editorMode": "code",
|
| 371 |
+
"expr": "rate(invidious_companion_innertube_failed_request_total[$__rate_interval])",
|
| 372 |
+
"instant": false,
|
| 373 |
+
"legendFormat": "{{instance}}",
|
| 374 |
+
"range": true,
|
| 375 |
+
"refId": "A"
|
| 376 |
+
}
|
| 377 |
+
],
|
| 378 |
+
"title": "Failed Requests Rate",
|
| 379 |
+
"type": "timeseries"
|
| 380 |
+
},
|
| 381 |
+
{
|
| 382 |
+
"collapsed": false,
|
| 383 |
+
"gridPos": {
|
| 384 |
+
"h": 1,
|
| 385 |
+
"w": 24,
|
| 386 |
+
"x": 0,
|
| 387 |
+
"y": 22
|
| 388 |
+
},
|
| 389 |
+
"id": 19,
|
| 390 |
+
"panels": [],
|
| 391 |
+
"title": "Status",
|
| 392 |
+
"type": "row"
|
| 393 |
+
},
|
| 394 |
+
{
|
| 395 |
+
"datasource": {
|
| 396 |
+
"type": "prometheus",
|
| 397 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 398 |
+
},
|
| 399 |
+
"fieldConfig": {
|
| 400 |
+
"defaults": {
|
| 401 |
+
"color": {
|
| 402 |
+
"mode": "palette-classic"
|
| 403 |
+
},
|
| 404 |
+
"custom": {
|
| 405 |
+
"axisBorderShow": false,
|
| 406 |
+
"axisCenteredZero": false,
|
| 407 |
+
"axisColorMode": "text",
|
| 408 |
+
"axisLabel": "",
|
| 409 |
+
"axisPlacement": "auto",
|
| 410 |
+
"barAlignment": 0,
|
| 411 |
+
"barWidthFactor": 0.6,
|
| 412 |
+
"drawStyle": "line",
|
| 413 |
+
"fillOpacity": 0,
|
| 414 |
+
"gradientMode": "none",
|
| 415 |
+
"hideFrom": {
|
| 416 |
+
"legend": false,
|
| 417 |
+
"tooltip": false,
|
| 418 |
+
"viz": false
|
| 419 |
+
},
|
| 420 |
+
"insertNulls": false,
|
| 421 |
+
"lineInterpolation": "linear",
|
| 422 |
+
"lineStyle": {
|
| 423 |
+
"fill": "solid"
|
| 424 |
+
},
|
| 425 |
+
"lineWidth": 1,
|
| 426 |
+
"pointSize": 5,
|
| 427 |
+
"scaleDistribution": {
|
| 428 |
+
"type": "linear"
|
| 429 |
+
},
|
| 430 |
+
"showPoints": "auto",
|
| 431 |
+
"showValues": false,
|
| 432 |
+
"spanNulls": true,
|
| 433 |
+
"stacking": {
|
| 434 |
+
"group": "A",
|
| 435 |
+
"mode": "none"
|
| 436 |
+
},
|
| 437 |
+
"thresholdsStyle": {
|
| 438 |
+
"mode": "off"
|
| 439 |
+
}
|
| 440 |
+
},
|
| 441 |
+
"mappings": [],
|
| 442 |
+
"thresholds": {
|
| 443 |
+
"mode": "absolute",
|
| 444 |
+
"steps": [
|
| 445 |
+
{
|
| 446 |
+
"color": "green",
|
| 447 |
+
"value": 0
|
| 448 |
+
},
|
| 449 |
+
{
|
| 450 |
+
"color": "red",
|
| 451 |
+
"value": 80
|
| 452 |
+
}
|
| 453 |
+
]
|
| 454 |
+
}
|
| 455 |
+
},
|
| 456 |
+
"overrides": []
|
| 457 |
+
},
|
| 458 |
+
"gridPos": {
|
| 459 |
+
"h": 10,
|
| 460 |
+
"w": 24,
|
| 461 |
+
"x": 0,
|
| 462 |
+
"y": 23
|
| 463 |
+
},
|
| 464 |
+
"id": 22,
|
| 465 |
+
"options": {
|
| 466 |
+
"legend": {
|
| 467 |
+
"calcs": [],
|
| 468 |
+
"displayMode": "list",
|
| 469 |
+
"placement": "bottom",
|
| 470 |
+
"showLegend": true
|
| 471 |
+
},
|
| 472 |
+
"tooltip": {
|
| 473 |
+
"hideZeros": false,
|
| 474 |
+
"mode": "single",
|
| 475 |
+
"sort": "none"
|
| 476 |
+
}
|
| 477 |
+
},
|
| 478 |
+
"pluginVersion": "12.3.1",
|
| 479 |
+
"targets": [
|
| 480 |
+
{
|
| 481 |
+
"datasource": {
|
| 482 |
+
"type": "prometheus",
|
| 483 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 484 |
+
},
|
| 485 |
+
"editorMode": "code",
|
| 486 |
+
"expr": "rate(invidious_companion_innertube_error_status_loginRequired_total[$__rate_interval])",
|
| 487 |
+
"instant": false,
|
| 488 |
+
"legendFormat": "{{instance}}",
|
| 489 |
+
"range": true,
|
| 490 |
+
"refId": "A"
|
| 491 |
+
}
|
| 492 |
+
],
|
| 493 |
+
"title": "\"LOGIN_REQUIRED\" Rate",
|
| 494 |
+
"type": "timeseries"
|
| 495 |
+
},
|
| 496 |
+
{
|
| 497 |
+
"collapsed": false,
|
| 498 |
+
"gridPos": {
|
| 499 |
+
"h": 1,
|
| 500 |
+
"w": 24,
|
| 501 |
+
"x": 0,
|
| 502 |
+
"y": 33
|
| 503 |
+
},
|
| 504 |
+
"id": 8,
|
| 505 |
+
"panels": [],
|
| 506 |
+
"title": "Reasons",
|
| 507 |
+
"type": "row"
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"datasource": {
|
| 511 |
+
"type": "prometheus",
|
| 512 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 513 |
+
},
|
| 514 |
+
"fieldConfig": {
|
| 515 |
+
"defaults": {
|
| 516 |
+
"color": {
|
| 517 |
+
"mode": "palette-classic"
|
| 518 |
+
},
|
| 519 |
+
"custom": {
|
| 520 |
+
"axisBorderShow": false,
|
| 521 |
+
"axisCenteredZero": false,
|
| 522 |
+
"axisColorMode": "text",
|
| 523 |
+
"axisLabel": "",
|
| 524 |
+
"axisPlacement": "auto",
|
| 525 |
+
"barAlignment": 0,
|
| 526 |
+
"barWidthFactor": 0.6,
|
| 527 |
+
"drawStyle": "line",
|
| 528 |
+
"fillOpacity": 0,
|
| 529 |
+
"gradientMode": "none",
|
| 530 |
+
"hideFrom": {
|
| 531 |
+
"legend": false,
|
| 532 |
+
"tooltip": false,
|
| 533 |
+
"viz": false
|
| 534 |
+
},
|
| 535 |
+
"insertNulls": false,
|
| 536 |
+
"lineInterpolation": "linear",
|
| 537 |
+
"lineStyle": {
|
| 538 |
+
"fill": "solid"
|
| 539 |
+
},
|
| 540 |
+
"lineWidth": 1,
|
| 541 |
+
"pointSize": 5,
|
| 542 |
+
"scaleDistribution": {
|
| 543 |
+
"type": "linear"
|
| 544 |
+
},
|
| 545 |
+
"showPoints": "auto",
|
| 546 |
+
"showValues": false,
|
| 547 |
+
"spanNulls": true,
|
| 548 |
+
"stacking": {
|
| 549 |
+
"group": "A",
|
| 550 |
+
"mode": "none"
|
| 551 |
+
},
|
| 552 |
+
"thresholdsStyle": {
|
| 553 |
+
"mode": "off"
|
| 554 |
+
}
|
| 555 |
+
},
|
| 556 |
+
"mappings": [],
|
| 557 |
+
"thresholds": {
|
| 558 |
+
"mode": "absolute",
|
| 559 |
+
"steps": [
|
| 560 |
+
{
|
| 561 |
+
"color": "green",
|
| 562 |
+
"value": 0
|
| 563 |
+
},
|
| 564 |
+
{
|
| 565 |
+
"color": "red",
|
| 566 |
+
"value": 80
|
| 567 |
+
}
|
| 568 |
+
]
|
| 569 |
+
}
|
| 570 |
+
},
|
| 571 |
+
"overrides": []
|
| 572 |
+
},
|
| 573 |
+
"gridPos": {
|
| 574 |
+
"h": 10,
|
| 575 |
+
"w": 24,
|
| 576 |
+
"x": 0,
|
| 577 |
+
"y": 34
|
| 578 |
+
},
|
| 579 |
+
"id": 4,
|
| 580 |
+
"options": {
|
| 581 |
+
"legend": {
|
| 582 |
+
"calcs": [],
|
| 583 |
+
"displayMode": "list",
|
| 584 |
+
"placement": "bottom",
|
| 585 |
+
"showLegend": true
|
| 586 |
+
},
|
| 587 |
+
"tooltip": {
|
| 588 |
+
"hideZeros": false,
|
| 589 |
+
"mode": "single",
|
| 590 |
+
"sort": "none"
|
| 591 |
+
}
|
| 592 |
+
},
|
| 593 |
+
"pluginVersion": "12.3.1",
|
| 594 |
+
"targets": [
|
| 595 |
+
{
|
| 596 |
+
"datasource": {
|
| 597 |
+
"type": "prometheus",
|
| 598 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 599 |
+
},
|
| 600 |
+
"editorMode": "code",
|
| 601 |
+
"expr": "rate(invidious_companion_innertube_error_reason_SignIn_total[$__rate_interval])",
|
| 602 |
+
"instant": false,
|
| 603 |
+
"legendFormat": "{{instance}}",
|
| 604 |
+
"range": true,
|
| 605 |
+
"refId": "A"
|
| 606 |
+
}
|
| 607 |
+
],
|
| 608 |
+
"title": "\"Sign in to confirm you’re not a bot.\" Rate",
|
| 609 |
+
"type": "timeseries"
|
| 610 |
+
},
|
| 611 |
+
{
|
| 612 |
+
"collapsed": false,
|
| 613 |
+
"gridPos": {
|
| 614 |
+
"h": 1,
|
| 615 |
+
"w": 24,
|
| 616 |
+
"x": 0,
|
| 617 |
+
"y": 44
|
| 618 |
+
},
|
| 619 |
+
"id": 9,
|
| 620 |
+
"panels": [],
|
| 621 |
+
"title": "Subreasons",
|
| 622 |
+
"type": "row"
|
| 623 |
+
},
|
| 624 |
+
{
|
| 625 |
+
"datasource": {
|
| 626 |
+
"type": "prometheus",
|
| 627 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 628 |
+
},
|
| 629 |
+
"fieldConfig": {
|
| 630 |
+
"defaults": {
|
| 631 |
+
"color": {
|
| 632 |
+
"mode": "palette-classic"
|
| 633 |
+
},
|
| 634 |
+
"custom": {
|
| 635 |
+
"axisBorderShow": false,
|
| 636 |
+
"axisCenteredZero": false,
|
| 637 |
+
"axisColorMode": "text",
|
| 638 |
+
"axisLabel": "",
|
| 639 |
+
"axisPlacement": "auto",
|
| 640 |
+
"barAlignment": 0,
|
| 641 |
+
"barWidthFactor": 0.6,
|
| 642 |
+
"drawStyle": "line",
|
| 643 |
+
"fillOpacity": 0,
|
| 644 |
+
"gradientMode": "none",
|
| 645 |
+
"hideFrom": {
|
| 646 |
+
"legend": false,
|
| 647 |
+
"tooltip": false,
|
| 648 |
+
"viz": false
|
| 649 |
+
},
|
| 650 |
+
"insertNulls": false,
|
| 651 |
+
"lineInterpolation": "linear",
|
| 652 |
+
"lineWidth": 1,
|
| 653 |
+
"pointSize": 5,
|
| 654 |
+
"scaleDistribution": {
|
| 655 |
+
"type": "linear"
|
| 656 |
+
},
|
| 657 |
+
"showPoints": "auto",
|
| 658 |
+
"showValues": false,
|
| 659 |
+
"spanNulls": true,
|
| 660 |
+
"stacking": {
|
| 661 |
+
"group": "A",
|
| 662 |
+
"mode": "none"
|
| 663 |
+
},
|
| 664 |
+
"thresholdsStyle": {
|
| 665 |
+
"mode": "off"
|
| 666 |
+
}
|
| 667 |
+
},
|
| 668 |
+
"mappings": [],
|
| 669 |
+
"thresholds": {
|
| 670 |
+
"mode": "absolute",
|
| 671 |
+
"steps": [
|
| 672 |
+
{
|
| 673 |
+
"color": "green",
|
| 674 |
+
"value": 0
|
| 675 |
+
},
|
| 676 |
+
{
|
| 677 |
+
"color": "red",
|
| 678 |
+
"value": 80
|
| 679 |
+
}
|
| 680 |
+
]
|
| 681 |
+
}
|
| 682 |
+
},
|
| 683 |
+
"overrides": []
|
| 684 |
+
},
|
| 685 |
+
"gridPos": {
|
| 686 |
+
"h": 10,
|
| 687 |
+
"w": 24,
|
| 688 |
+
"x": 0,
|
| 689 |
+
"y": 45
|
| 690 |
+
},
|
| 691 |
+
"id": 6,
|
| 692 |
+
"options": {
|
| 693 |
+
"legend": {
|
| 694 |
+
"calcs": [],
|
| 695 |
+
"displayMode": "list",
|
| 696 |
+
"placement": "bottom",
|
| 697 |
+
"showLegend": true
|
| 698 |
+
},
|
| 699 |
+
"tooltip": {
|
| 700 |
+
"hideZeros": false,
|
| 701 |
+
"mode": "single",
|
| 702 |
+
"sort": "none"
|
| 703 |
+
}
|
| 704 |
+
},
|
| 705 |
+
"pluginVersion": "12.3.1",
|
| 706 |
+
"targets": [
|
| 707 |
+
{
|
| 708 |
+
"datasource": {
|
| 709 |
+
"type": "prometheus",
|
| 710 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 711 |
+
},
|
| 712 |
+
"editorMode": "code",
|
| 713 |
+
"expr": "rate(invidious_companion_innertube_error_subreason_ProtectCommunity_total[$__rate_interval])",
|
| 714 |
+
"instant": false,
|
| 715 |
+
"legendFormat": "{{instance}}",
|
| 716 |
+
"range": true,
|
| 717 |
+
"refId": "A"
|
| 718 |
+
}
|
| 719 |
+
],
|
| 720 |
+
"title": "\"This helps protect our community.\" Rate",
|
| 721 |
+
"type": "timeseries"
|
| 722 |
+
},
|
| 723 |
+
{
|
| 724 |
+
"collapsed": false,
|
| 725 |
+
"gridPos": {
|
| 726 |
+
"h": 1,
|
| 727 |
+
"w": 24,
|
| 728 |
+
"x": 0,
|
| 729 |
+
"y": 55
|
| 730 |
+
},
|
| 731 |
+
"id": 20,
|
| 732 |
+
"panels": [],
|
| 733 |
+
"title": "Jobs",
|
| 734 |
+
"type": "row"
|
| 735 |
+
},
|
| 736 |
+
{
|
| 737 |
+
"datasource": {
|
| 738 |
+
"type": "prometheus",
|
| 739 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 740 |
+
},
|
| 741 |
+
"fieldConfig": {
|
| 742 |
+
"defaults": {
|
| 743 |
+
"color": {
|
| 744 |
+
"mode": "palette-classic"
|
| 745 |
+
},
|
| 746 |
+
"custom": {
|
| 747 |
+
"axisBorderShow": false,
|
| 748 |
+
"axisCenteredZero": false,
|
| 749 |
+
"axisColorMode": "text",
|
| 750 |
+
"axisLabel": "",
|
| 751 |
+
"axisPlacement": "auto",
|
| 752 |
+
"barAlignment": 0,
|
| 753 |
+
"barWidthFactor": 0.6,
|
| 754 |
+
"drawStyle": "line",
|
| 755 |
+
"fillOpacity": 0,
|
| 756 |
+
"gradientMode": "none",
|
| 757 |
+
"hideFrom": {
|
| 758 |
+
"legend": false,
|
| 759 |
+
"tooltip": false,
|
| 760 |
+
"viz": false
|
| 761 |
+
},
|
| 762 |
+
"insertNulls": false,
|
| 763 |
+
"lineInterpolation": "linear",
|
| 764 |
+
"lineStyle": {
|
| 765 |
+
"fill": "solid"
|
| 766 |
+
},
|
| 767 |
+
"lineWidth": 1,
|
| 768 |
+
"pointSize": 5,
|
| 769 |
+
"scaleDistribution": {
|
| 770 |
+
"type": "linear"
|
| 771 |
+
},
|
| 772 |
+
"showPoints": "auto",
|
| 773 |
+
"showValues": false,
|
| 774 |
+
"spanNulls": true,
|
| 775 |
+
"stacking": {
|
| 776 |
+
"group": "A",
|
| 777 |
+
"mode": "none"
|
| 778 |
+
},
|
| 779 |
+
"thresholdsStyle": {
|
| 780 |
+
"mode": "off"
|
| 781 |
+
}
|
| 782 |
+
},
|
| 783 |
+
"mappings": [],
|
| 784 |
+
"thresholds": {
|
| 785 |
+
"mode": "absolute",
|
| 786 |
+
"steps": [
|
| 787 |
+
{
|
| 788 |
+
"color": "green",
|
| 789 |
+
"value": 0
|
| 790 |
+
},
|
| 791 |
+
{
|
| 792 |
+
"color": "red",
|
| 793 |
+
"value": 80
|
| 794 |
+
}
|
| 795 |
+
]
|
| 796 |
+
}
|
| 797 |
+
},
|
| 798 |
+
"overrides": []
|
| 799 |
+
},
|
| 800 |
+
"gridPos": {
|
| 801 |
+
"h": 9,
|
| 802 |
+
"w": 24,
|
| 803 |
+
"x": 0,
|
| 804 |
+
"y": 56
|
| 805 |
+
},
|
| 806 |
+
"id": 16,
|
| 807 |
+
"options": {
|
| 808 |
+
"legend": {
|
| 809 |
+
"calcs": [],
|
| 810 |
+
"displayMode": "list",
|
| 811 |
+
"placement": "bottom",
|
| 812 |
+
"showLegend": true
|
| 813 |
+
},
|
| 814 |
+
"tooltip": {
|
| 815 |
+
"hideZeros": false,
|
| 816 |
+
"mode": "single",
|
| 817 |
+
"sort": "none"
|
| 818 |
+
}
|
| 819 |
+
},
|
| 820 |
+
"pluginVersion": "12.3.1",
|
| 821 |
+
"targets": [
|
| 822 |
+
{
|
| 823 |
+
"datasource": {
|
| 824 |
+
"type": "prometheus",
|
| 825 |
+
"uid": "${DS_PROMETHEUS-REIMU}"
|
| 826 |
+
},
|
| 827 |
+
"editorMode": "code",
|
| 828 |
+
"expr": "invidious_companion_potoken_generation_failure_total",
|
| 829 |
+
"instant": false,
|
| 830 |
+
"legendFormat": "{{instance}}",
|
| 831 |
+
"range": true,
|
| 832 |
+
"refId": "A"
|
| 833 |
+
}
|
| 834 |
+
],
|
| 835 |
+
"title": "poToken Generation Failure Rate",
|
| 836 |
+
"type": "timeseries"
|
| 837 |
+
}
|
| 838 |
+
],
|
| 839 |
+
"preload": false,
|
| 840 |
+
"refresh": "10s",
|
| 841 |
+
"schemaVersion": 42,
|
| 842 |
+
"tags": [],
|
| 843 |
+
"templating": {
|
| 844 |
+
"list": [
|
| 845 |
+
{
|
| 846 |
+
"current": {},
|
| 847 |
+
"label": "datasource",
|
| 848 |
+
"name": "Datasource",
|
| 849 |
+
"options": [],
|
| 850 |
+
"query": "prometheus",
|
| 851 |
+
"refresh": 1,
|
| 852 |
+
"regex": "",
|
| 853 |
+
"type": "datasource"
|
| 854 |
+
},
|
| 855 |
+
{
|
| 856 |
+
"current": {},
|
| 857 |
+
"datasource": {
|
| 858 |
+
"type": "prometheus",
|
| 859 |
+
"uid": "${Datasource}"
|
| 860 |
+
},
|
| 861 |
+
"definition": "label_values(invidious_companion_innertube_successful_request_total,job)",
|
| 862 |
+
"description": "",
|
| 863 |
+
"label": "Job",
|
| 864 |
+
"name": "job",
|
| 865 |
+
"options": [],
|
| 866 |
+
"query": {
|
| 867 |
+
"qryType": 1,
|
| 868 |
+
"query": "label_values(invidious_companion_innertube_successful_request_total,job)",
|
| 869 |
+
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
| 870 |
+
},
|
| 871 |
+
"refresh": 2,
|
| 872 |
+
"regex": "",
|
| 873 |
+
"type": "query"
|
| 874 |
+
}
|
| 875 |
+
]
|
| 876 |
+
},
|
| 877 |
+
"time": {
|
| 878 |
+
"from": "now-1h",
|
| 879 |
+
"to": "now"
|
| 880 |
+
},
|
| 881 |
+
"timepicker": {},
|
| 882 |
+
"timezone": "browser",
|
| 883 |
+
"title": "Invidious Companion2",
|
| 884 |
+
"uid": "1-0-1",
|
| 885 |
+
"version": 9,
|
| 886 |
+
"weekStart": "",
|
| 887 |
+
"id": null
|
| 888 |
+
}
|
invidious-companion.service
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[Unit]
|
| 2 |
+
Description=invidious-companion (companion for Invidious which handles all the video stream retrieval from YouTube servers)
|
| 3 |
+
After=syslog.target
|
| 4 |
+
After=network.target
|
| 5 |
+
|
| 6 |
+
[Service]
|
| 7 |
+
RestartSec=2s
|
| 8 |
+
Type=simple
|
| 9 |
+
|
| 10 |
+
User=invidious
|
| 11 |
+
Group=invidious
|
| 12 |
+
|
| 13 |
+
# Security hardening - balanced approach for Deno applications
|
| 14 |
+
NoNewPrivileges=true
|
| 15 |
+
PrivateDevices=true
|
| 16 |
+
PrivateTmp=true
|
| 17 |
+
ProtectControlGroups=true
|
| 18 |
+
ProtectHostname=true
|
| 19 |
+
ProtectKernelLogs=true
|
| 20 |
+
ProtectKernelModules=true
|
| 21 |
+
ProtectKernelTunables=true
|
| 22 |
+
ProtectSystem=strict
|
| 23 |
+
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
| 24 |
+
RestrictNamespaces=true
|
| 25 |
+
RestrictSUIDSGID=true
|
| 26 |
+
RestrictRealtime=true
|
| 27 |
+
|
| 28 |
+
# Filesystem access
|
| 29 |
+
BindReadOnlyPaths=/home/invidious/invidious-companion
|
| 30 |
+
BindPaths=/home/invidious/tmp
|
| 31 |
+
BindPaths=/var/tmp/youtubei.js
|
| 32 |
+
|
| 33 |
+
WorkingDirectory=/home/invidious/invidious-companion
|
| 34 |
+
ExecStart=/home/invidious/invidious-companion/invidious_companion
|
| 35 |
+
|
| 36 |
+
Environment=SERVER_SECRET_KEY=CHANGE_ME
|
| 37 |
+
Environment=CACHE_DIRECTORY=/var/tmp/youtubei.js
|
| 38 |
+
|
| 39 |
+
Restart=always
|
| 40 |
+
|
| 41 |
+
[Install]
|
| 42 |
+
WantedBy=multi-user.target
|
server.go
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/base64"
|
| 5 |
+
"encoding/hex"
|
| 6 |
+
"fmt"
|
| 7 |
+
"io"
|
| 8 |
+
"log"
|
| 9 |
+
"net"
|
| 10 |
+
"net/http"
|
| 11 |
+
"net/netip"
|
| 12 |
+
"strings"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
"github.com/caarlos0/env"
|
| 16 |
+
"golang.zx2c4.com/wireguard/conn"
|
| 17 |
+
"golang.zx2c4.com/wireguard/device"
|
| 18 |
+
"golang.zx2c4.com/wireguard/tun/netstack"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
type params struct {
|
| 22 |
+
User string `env:"PROXY_USER" envDefault:""`
|
| 23 |
+
Password string `env:"PROXY_PASS" envDefault:""`
|
| 24 |
+
Port string `env:"PORT" envDefault:"8080"`
|
| 25 |
+
// WireGuard Params
|
| 26 |
+
WgPrivateKey string `env:"WIREGUARD_INTERFACE_PRIVATE_KEY"`
|
| 27 |
+
WgAddress string `env:"WIREGUARD_INTERFACE_ADDRESS"` // e.g., 10.0.0.2/32
|
| 28 |
+
WgPeerPublicKey string `env:"WIREGUARD_PEER_PUBLIC_KEY"`
|
| 29 |
+
WgPeerEndpoint string `env:"WIREGUARD_PEER_ENDPOINT"` // e.g., 1.2.3.4:51820
|
| 30 |
+
WgDNS string `env:"WIREGUARD_INTERFACE_DNS" envDefault:"1.1.1.1"`
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
var tnet *netstack.Net
|
| 34 |
+
|
| 35 |
+
func handleTunneling(w http.ResponseWriter, r *http.Request) {
|
| 36 |
+
dest := r.URL.Host
|
| 37 |
+
if dest == "" {
|
| 38 |
+
dest = r.Host
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Hijack the connection first to allow custom response writing
|
| 42 |
+
hijacker, ok := w.(http.Hijacker)
|
| 43 |
+
if !ok {
|
| 44 |
+
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
|
| 45 |
+
return
|
| 46 |
+
}
|
| 47 |
+
client_conn, _, err := hijacker.Hijack()
|
| 48 |
+
if err != nil {
|
| 49 |
+
// If hijack fails, we can't do much as headers might be sent or connection broken
|
| 50 |
+
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
| 51 |
+
return
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
var dest_conn net.Conn
|
| 55 |
+
|
| 56 |
+
if tnet == nil {
|
| 57 |
+
dest_conn, err = net.DialTimeout("tcp", dest, 10*time.Second)
|
| 58 |
+
} else {
|
| 59 |
+
// Use tnet.Dial to connect through WireGuard
|
| 60 |
+
dest_conn, err = tnet.Dial("tcp", dest)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if err != nil {
|
| 64 |
+
log.Printf("[ERROR] TUNNEL Dial failed to %s: %v", dest, err)
|
| 65 |
+
// Send a 503 to the client through the hijacked connection and close
|
| 66 |
+
// Simple HTTP response since we hijacked
|
| 67 |
+
client_conn.Write([]byte("HTTP/1.1 503 Service Unavailable\r\n\r\n"))
|
| 68 |
+
client_conn.Close()
|
| 69 |
+
return
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Write 200 Connection Established to the client
|
| 73 |
+
// This signals the client that the tunnel is ready
|
| 74 |
+
_, err = client_conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
|
| 75 |
+
if err != nil {
|
| 76 |
+
log.Printf("[ERROR] TUNNEL Write 200 failed: %v", err)
|
| 77 |
+
dest_conn.Close()
|
| 78 |
+
client_conn.Close()
|
| 79 |
+
return
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
go transfer(dest_conn, client_conn)
|
| 83 |
+
go transfer(client_conn, dest_conn)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
func transfer(destination io.WriteCloser, source io.ReadCloser) {
|
| 87 |
+
defer destination.Close()
|
| 88 |
+
defer source.Close()
|
| 89 |
+
io.Copy(destination, source)
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
func handleHTTP(w http.ResponseWriter, r *http.Request) {
|
| 93 |
+
transport := http.DefaultTransport.(*http.Transport).Clone()
|
| 94 |
+
|
| 95 |
+
if tnet != nil {
|
| 96 |
+
// Use tnet.DialContext for HTTP requests
|
| 97 |
+
transport.DialContext = tnet.DialContext
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
resp, err := transport.RoundTrip(r)
|
| 101 |
+
if err != nil {
|
| 102 |
+
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
| 103 |
+
return
|
| 104 |
+
}
|
| 105 |
+
defer resp.Body.Close()
|
| 106 |
+
copyHeader(w.Header(), resp.Header)
|
| 107 |
+
w.WriteHeader(resp.StatusCode)
|
| 108 |
+
io.Copy(w, resp.Body)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
func handleDebug(w http.ResponseWriter, r *http.Request) {
|
| 112 |
+
if tnet == nil {
|
| 113 |
+
w.Header().Set("Content-Type", "text/plain")
|
| 114 |
+
w.WriteHeader(http.StatusOK)
|
| 115 |
+
w.Write([]byte("Error: WireGuard not initialized (Direct Mode)"))
|
| 116 |
+
return
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
client := &http.Client{
|
| 120 |
+
Transport: &http.Transport{
|
| 121 |
+
DialContext: tnet.DialContext,
|
| 122 |
+
},
|
| 123 |
+
Timeout: 10 * time.Second,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
resp, err := client.Get("http://ifconfig.me")
|
| 127 |
+
if err != nil {
|
| 128 |
+
log.Printf("[DEBUG] VPN Test Failed: %v", err)
|
| 129 |
+
http.Error(w, fmt.Sprintf("VPN Connection Failed: %v", err), http.StatusServiceUnavailable)
|
| 130 |
+
return
|
| 131 |
+
}
|
| 132 |
+
defer resp.Body.Close()
|
| 133 |
+
|
| 134 |
+
body, err := io.ReadAll(resp.Body)
|
| 135 |
+
if err != nil {
|
| 136 |
+
http.Error(w, fmt.Sprintf("Failed to read response: %v", err), http.StatusInternalServerError)
|
| 137 |
+
return
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
log.Printf("[DEBUG] VPN Test Success. IP: %s", string(body))
|
| 141 |
+
w.Header().Set("Content-Type", "text/plain")
|
| 142 |
+
w.WriteHeader(http.StatusOK)
|
| 143 |
+
w.Write([]byte(fmt.Sprintf("VPN Connected! Public IP: %s", string(body))))
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
func copyHeader(dst, src http.Header) {
|
| 147 |
+
for k, vv := range src {
|
| 148 |
+
for _, v := range vv {
|
| 149 |
+
dst.Add(k, v)
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
func startWireGuard(cfg params) error {
|
| 155 |
+
if cfg.WgPrivateKey == "" || cfg.WgPeerEndpoint == "" {
|
| 156 |
+
log.Println("[INFO] WireGuard config missing, running in DIRECT mode (no VPN)")
|
| 157 |
+
return nil
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
log.Println("[INFO] Initializing Userspace WireGuard...")
|
| 161 |
+
|
| 162 |
+
localIPs := []netip.Addr{}
|
| 163 |
+
if cfg.WgAddress != "" {
|
| 164 |
+
// Handle CIDR notation if present (e.g., 10.0.0.2/32)
|
| 165 |
+
addrStr := strings.Split(cfg.WgAddress, "/")[0]
|
| 166 |
+
addr, err := netip.ParseAddr(addrStr)
|
| 167 |
+
if err == nil {
|
| 168 |
+
localIPs = append(localIPs, addr)
|
| 169 |
+
log.Printf("[INFO] Local VPN IP: %s", addr)
|
| 170 |
+
} else {
|
| 171 |
+
log.Printf("[WARN] Failed to parse local IP: %v", err)
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
dnsIP, err := netip.ParseAddr(cfg.WgDNS)
|
| 176 |
+
if err != nil {
|
| 177 |
+
log.Printf("[WARN] Failed to parse DNS IP, using default: %v", err)
|
| 178 |
+
dnsIP, _ = netip.ParseAddr("1.1.1.1")
|
| 179 |
+
}
|
| 180 |
+
log.Printf("[INFO] DNS Server: %s", dnsIP)
|
| 181 |
+
|
| 182 |
+
log.Println("[INFO] Creating virtual network interface...")
|
| 183 |
+
tunDev, tnetInstance, err := netstack.CreateNetTUN(
|
| 184 |
+
localIPs,
|
| 185 |
+
[]netip.Addr{dnsIP},
|
| 186 |
+
1420,
|
| 187 |
+
)
|
| 188 |
+
if err != nil {
|
| 189 |
+
return fmt.Errorf("failed to create TUN: %w", err)
|
| 190 |
+
}
|
| 191 |
+
tnet = tnetInstance
|
| 192 |
+
log.Println("[INFO] Virtual TUN device created successfully")
|
| 193 |
+
|
| 194 |
+
log.Println("[INFO] Initializing WireGuard device...")
|
| 195 |
+
dev := device.NewDevice(tunDev, conn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
|
| 196 |
+
|
| 197 |
+
log.Printf("[INFO] Configuring peer endpoint: %s", cfg.WgPeerEndpoint)
|
| 198 |
+
|
| 199 |
+
// Convert keys from Base64 to Hex
|
| 200 |
+
// wireguard-go expects hex keys in UAPI, but inputs are usually Base64
|
| 201 |
+
privateKeyHex, err := base64ToHex(cfg.WgPrivateKey)
|
| 202 |
+
if err != nil {
|
| 203 |
+
return fmt.Errorf("invalid private key (base64 decode failed): %w", err)
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
publicKeyHex, err := base64ToHex(cfg.WgPeerPublicKey)
|
| 207 |
+
if err != nil {
|
| 208 |
+
return fmt.Errorf("invalid peer public key (base64 decode failed): %w", err)
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
uapi := fmt.Sprintf(`private_key=%s
|
| 212 |
+
public_key=%s
|
| 213 |
+
endpoint=%s
|
| 214 |
+
allowed_ip=0.0.0.0/0
|
| 215 |
+
`, privateKeyHex, publicKeyHex, cfg.WgPeerEndpoint)
|
| 216 |
+
|
| 217 |
+
if err := dev.IpcSet(uapi); err != nil {
|
| 218 |
+
return fmt.Errorf("failed to configure device: %w", err)
|
| 219 |
+
}
|
| 220 |
+
log.Println("[INFO] WireGuard peer configured")
|
| 221 |
+
|
| 222 |
+
if err := dev.Up(); err != nil {
|
| 223 |
+
return fmt.Errorf("failed to bring up device: %w", err)
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
log.Println("[SUCCESS] WireGuard interface is UP - All traffic will route through VPN")
|
| 227 |
+
return nil
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
func main() {
|
| 231 |
+
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
| 232 |
+
log.Println("[STARTUP] Initializing HTTP Proxy with Userspace WireGuard")
|
| 233 |
+
|
| 234 |
+
cfg := params{}
|
| 235 |
+
if err := env.Parse(&cfg); err != nil {
|
| 236 |
+
log.Printf("[WARN] Config parse warning: %+v\n", err)
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
log.Printf("[CONFIG] Proxy Port: %s", cfg.Port)
|
| 240 |
+
if cfg.User != "" {
|
| 241 |
+
log.Printf("[CONFIG] Authentication: Enabled (user: %s)", cfg.User)
|
| 242 |
+
} else {
|
| 243 |
+
log.Println("[CONFIG] Authentication: Disabled")
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
if err := startWireGuard(cfg); err != nil {
|
| 247 |
+
log.Fatalf("[FATAL] Failed to start WireGuard: %v", err)
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
log.Printf("[STARTUP] Starting HTTP proxy server on port %s\n", cfg.Port)
|
| 251 |
+
|
| 252 |
+
server := &http.Server{
|
| 253 |
+
Addr: ":" + cfg.Port,
|
| 254 |
+
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 255 |
+
if cfg.User != "" && cfg.Password != "" {
|
| 256 |
+
user, pass, ok := r.BasicAuth()
|
| 257 |
+
if !ok || user != cfg.User || pass != cfg.Password {
|
| 258 |
+
log.Printf("[AUTH] Unauthorized access attempt from %s", r.RemoteAddr)
|
| 259 |
+
w.Header().Set("Proxy-Authenticate", `Basic realm="Proxy"`)
|
| 260 |
+
http.Error(w, "Unauthorized", http.StatusProxyAuthRequired)
|
| 261 |
+
return
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// Handle CONNECT (HTTPS tunnel)
|
| 266 |
+
if r.Method == http.MethodConnect {
|
| 267 |
+
log.Printf("[CONNECT] %s -> %s", r.RemoteAddr, r.Host)
|
| 268 |
+
handleTunneling(w, r)
|
| 269 |
+
return
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// Direct requests to the proxy server (Health check & Debug)
|
| 273 |
+
// We check r.URL.Host == "" which means it's a direct request, not a proxy request
|
| 274 |
+
if r.URL.Host == "" {
|
| 275 |
+
if r.URL.Path == "/" {
|
| 276 |
+
log.Printf("[HEALTH] Health check from %s", r.RemoteAddr)
|
| 277 |
+
w.WriteHeader(http.StatusOK)
|
| 278 |
+
if tnet != nil {
|
| 279 |
+
w.Write([]byte("Proxy Running via Userspace WireGuard"))
|
| 280 |
+
} else {
|
| 281 |
+
w.Write([]byte("Proxy Running in Direct Mode (No VPN)"))
|
| 282 |
+
}
|
| 283 |
+
return
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
if r.URL.Path == "/debug" {
|
| 287 |
+
log.Printf("[DEBUG] Debug check from %s", r.RemoteAddr)
|
| 288 |
+
handleDebug(w, r)
|
| 289 |
+
return
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
// Proxy HTTP requests
|
| 294 |
+
log.Printf("[HTTP] %s %s -> %s", r.Method, r.RemoteAddr, r.URL.String())
|
| 295 |
+
handleHTTP(w, r)
|
| 296 |
+
}),
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
log.Println("[READY] Proxy server is ready to accept connections")
|
| 300 |
+
if err := server.ListenAndServe(); err != nil {
|
| 301 |
+
log.Fatalf("[FATAL] Server error: %v", err)
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
func base64ToHex(b64 string) (string, error) {
|
| 306 |
+
decoded, err := base64.StdEncoding.DecodeString(b64)
|
| 307 |
+
if err != nil {
|
| 308 |
+
return "", err
|
| 309 |
+
}
|
| 310 |
+
return hex.EncodeToString(decoded), nil
|
| 311 |
+
}
|
src/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
src/constants.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Set to `undefined` if it's no longer in use!
|
| 2 |
+
//
|
| 3 |
+
// Old Players IDs are usually available for a few more days after Youtube
|
| 4 |
+
// rolls out a new Player. This is helpful when Youtube.JS is not able to
|
| 5 |
+
// extract the signature decipher algorithm and we need to wait for a fix
|
| 6 |
+
// in Youtube.JS.
|
| 7 |
+
export const PLAYER_ID = undefined;
|
| 8 |
+
|
| 9 |
+
// Error message shown when tokenMinter is not yet ready
|
| 10 |
+
export const TOKEN_MINTER_NOT_READY_MESSAGE =
|
| 11 |
+
"Companion is starting. Please wait until a valid potoken is found. If this process takes too long, please consult: https://docs.invidious.io/youtube-errors-explained/#po-token-initialization-taking-too-much-time-to-complete";
|
src/lib/extra/emptyExport.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export default {};
|
src/lib/helpers/config.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
use_unix_socket: z.boolean().default(
|
| 9 |
+
Deno.env.get("SERVER_USE_UNIX_SOCKET") === "true" || false,
|
| 10 |
+
),
|
| 11 |
+
unix_socket_path: z.string().default(
|
| 12 |
+
Deno.env.get("SERVER_UNIX_SOCKET_PATH") ||
|
| 13 |
+
"/tmp/invidious-companion.sock",
|
| 14 |
+
),
|
| 15 |
+
base_path: z.string()
|
| 16 |
+
.default(Deno.env.get("SERVER_BASE_PATH") || "/companion")
|
| 17 |
+
.refine(
|
| 18 |
+
(path) => path.startsWith("/"),
|
| 19 |
+
{
|
| 20 |
+
message:
|
| 21 |
+
"SERVER_BASE_PATH must start with a forward slash (/). Example: '/companion'",
|
| 22 |
+
},
|
| 23 |
+
)
|
| 24 |
+
.refine(
|
| 25 |
+
(path) => !path.endsWith("/") || path === "/",
|
| 26 |
+
{
|
| 27 |
+
message:
|
| 28 |
+
"SERVER_BASE_PATH must not end with a forward slash (/) unless it's the root path. Example: '/companion' not '/companion/'",
|
| 29 |
+
},
|
| 30 |
+
)
|
| 31 |
+
.refine(
|
| 32 |
+
(path) => !path.includes("//"),
|
| 33 |
+
{
|
| 34 |
+
message:
|
| 35 |
+
"SERVER_BASE_PATH must not contain double slashes (//). Example: '/companion' not '//companion' or '/comp//anion'",
|
| 36 |
+
},
|
| 37 |
+
),
|
| 38 |
+
secret_key: z.preprocess(
|
| 39 |
+
(val) =>
|
| 40 |
+
val === undefined
|
| 41 |
+
? Deno.env.get("SERVER_SECRET_KEY") || ""
|
| 42 |
+
: val,
|
| 43 |
+
z.string().min(1),
|
| 44 |
+
).default(undefined),
|
| 45 |
+
verify_requests: z.boolean().default(
|
| 46 |
+
Deno.env.get("SERVER_VERIFY_REQUESTS") === "true" || false,
|
| 47 |
+
),
|
| 48 |
+
encrypt_query_params: z.boolean().default(
|
| 49 |
+
Deno.env.get("SERVER_ENCRYPT_QUERY_PARAMS") === "true" || false,
|
| 50 |
+
),
|
| 51 |
+
enable_metrics: z.boolean().default(
|
| 52 |
+
Deno.env.get("SERVER_ENABLE_METRICS") === "true" || false,
|
| 53 |
+
),
|
| 54 |
+
}).strict().default({}),
|
| 55 |
+
cache: z.object({
|
| 56 |
+
enabled: z.boolean().default(
|
| 57 |
+
Deno.env.get("CACHE_ENABLED") === "false" ? false : true,
|
| 58 |
+
),
|
| 59 |
+
directory: z.string().default(
|
| 60 |
+
Deno.env.get("CACHE_DIRECTORY") || "/var/tmp",
|
| 61 |
+
),
|
| 62 |
+
}).strict().default({}),
|
| 63 |
+
networking: z.object({
|
| 64 |
+
proxy: z.string().nullable().default(Deno.env.get("PROXY") || null),
|
| 65 |
+
auto_proxy: z.boolean().default(
|
| 66 |
+
Deno.env.get("NETWORKING_AUTO_PROXY") === "true" || false,
|
| 67 |
+
),
|
| 68 |
+
vpn_source: z.number().default(
|
| 69 |
+
Number(Deno.env.get("NETWORKING_VPN_SOURCE")) || 1,
|
| 70 |
+
),
|
| 71 |
+
ipv6_block: z.string().nullable().default(
|
| 72 |
+
Deno.env.get("NETWORKING_IPV6_BLOCK") || null,
|
| 73 |
+
),
|
| 74 |
+
fetch: z.object({
|
| 75 |
+
timeout_ms: z.number().default(
|
| 76 |
+
Number(Deno.env.get("NETWORKING_FETCH_TIMEOUT_MS")) || 30_000,
|
| 77 |
+
),
|
| 78 |
+
retry: z.object({
|
| 79 |
+
enabled: z.boolean().default(
|
| 80 |
+
Deno.env.get("NETWORKING_FETCH_RETRY_ENABLED") === "true" ||
|
| 81 |
+
false,
|
| 82 |
+
),
|
| 83 |
+
times: z.number().optional().default(
|
| 84 |
+
Number(Deno.env.get("NETWORKING_FETCH_RETRY_TIMES")) || 1,
|
| 85 |
+
),
|
| 86 |
+
initial_debounce: z.number().optional().default(
|
| 87 |
+
Number(
|
| 88 |
+
Deno.env.get("NETWORKING_FETCH_RETRY_INITIAL_DEBOUNCE"),
|
| 89 |
+
) || 0,
|
| 90 |
+
),
|
| 91 |
+
debounce_multiplier: z.number().optional().default(
|
| 92 |
+
Number(
|
| 93 |
+
Deno.env.get(
|
| 94 |
+
"NETWORKING_FETCH_RETRY_DEBOUNCE_MULTIPLIER",
|
| 95 |
+
),
|
| 96 |
+
) || 0,
|
| 97 |
+
),
|
| 98 |
+
}).strict().default({}),
|
| 99 |
+
}).strict().default({}),
|
| 100 |
+
videoplayback: z.object({
|
| 101 |
+
ump: z.boolean().default(
|
| 102 |
+
Deno.env.get("NETWORKING_VIDEOPLAYBACK_UMP") === "true" ||
|
| 103 |
+
false,
|
| 104 |
+
),
|
| 105 |
+
video_fetch_chunk_size_mb: z.number().default(
|
| 106 |
+
Number(
|
| 107 |
+
Deno.env.get(
|
| 108 |
+
"NETWORKING_VIDEOPLAYBACK_VIDEO_FETCH_CHUNK_SIZE_MB",
|
| 109 |
+
),
|
| 110 |
+
) || 5,
|
| 111 |
+
),
|
| 112 |
+
}).strict().default({}),
|
| 113 |
+
}).strict().default({}),
|
| 114 |
+
jobs: z.object({
|
| 115 |
+
youtube_session: z.object({
|
| 116 |
+
po_token_enabled: z.boolean().default(
|
| 117 |
+
Deno.env.get("JOBS_YOUTUBE_SESSION_PO_TOKEN_ENABLED") ===
|
| 118 |
+
"false"
|
| 119 |
+
? false
|
| 120 |
+
: true,
|
| 121 |
+
),
|
| 122 |
+
frequency: z.string().default(
|
| 123 |
+
Deno.env.get("JOBS_YOUTUBE_SESSION_FREQUENCY") || "*/5 * * * *",
|
| 124 |
+
),
|
| 125 |
+
}).strict().default({}),
|
| 126 |
+
}).strict().default({}),
|
| 127 |
+
youtube_session: z.object({
|
| 128 |
+
oauth_enabled: z.boolean().default(
|
| 129 |
+
Deno.env.get("YOUTUBE_SESSION_OAUTH_ENABLED") === "true" || false,
|
| 130 |
+
),
|
| 131 |
+
cookies: z.string().default(
|
| 132 |
+
Deno.env.get("YOUTUBE_SESSION_COOKIES") || "",
|
| 133 |
+
),
|
| 134 |
+
}).strict().default({}),
|
| 135 |
+
}).strict();
|
| 136 |
+
|
| 137 |
+
export type Config = z.infer<typeof ConfigSchema>;
|
| 138 |
+
|
| 139 |
+
export async function parseConfig() {
|
| 140 |
+
const configFileName = Deno.env.get("CONFIG_FILE") || "config/config.toml";
|
| 141 |
+
const configFileContents = await Deno.readTextFile(configFileName).catch(
|
| 142 |
+
() => null,
|
| 143 |
+
);
|
| 144 |
+
if (configFileContents) {
|
| 145 |
+
console.log("[INFO] Using custom settings local file");
|
| 146 |
+
} else {
|
| 147 |
+
console.log(
|
| 148 |
+
"[INFO] No local config file found, using default config",
|
| 149 |
+
);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
try {
|
| 153 |
+
const rawConfig = configFileContents ? parse(configFileContents) : {};
|
| 154 |
+
const validatedConfig = ConfigSchema.parse(rawConfig);
|
| 155 |
+
|
| 156 |
+
console.log("Loaded Configuration", validatedConfig);
|
| 157 |
+
|
| 158 |
+
return validatedConfig;
|
| 159 |
+
} catch (err) {
|
| 160 |
+
let errorMessage =
|
| 161 |
+
"There is an error in your configuration, check your environment variables";
|
| 162 |
+
if (configFileContents) {
|
| 163 |
+
errorMessage +=
|
| 164 |
+
` or in your configuration file located at ${configFileName}`;
|
| 165 |
+
}
|
| 166 |
+
console.log(errorMessage);
|
| 167 |
+
if (err instanceof ZodError) {
|
| 168 |
+
console.log(err.issues);
|
| 169 |
+
// Include detailed error information in the thrown error for testing
|
| 170 |
+
const detailedMessage = err.issues.map((issue) =>
|
| 171 |
+
`${issue.path.join(".")}: ${issue.message}`
|
| 172 |
+
).join("; ");
|
| 173 |
+
throw new Error(
|
| 174 |
+
`Failed to parse configuration file: ${detailedMessage}`,
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
// rethrow error if not Zod
|
| 178 |
+
throw err;
|
| 179 |
+
}
|
| 180 |
+
}
|
src/lib/helpers/encodeRFC5987ValueChars.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|
src/lib/helpers/encryptQuery.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
};
|
src/lib/helpers/getFetchClient.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { retry, type RetryOptions } from "@std/async";
|
| 2 |
+
import type { Config } from "./config.ts";
|
| 3 |
+
import { generateRandomIPv6 } from "./ipv6Rotation.ts";
|
| 4 |
+
import { getCurrentProxy } from "./proxyManager.ts";
|
| 5 |
+
|
| 6 |
+
type FetchInputParameter = Parameters<typeof fetch>[0];
|
| 7 |
+
type FetchInitParameterWithClient =
|
| 8 |
+
| RequestInit
|
| 9 |
+
| RequestInit & { client: Deno.HttpClient };
|
| 10 |
+
type FetchReturn = ReturnType<typeof fetch>;
|
| 11 |
+
|
| 12 |
+
export const getFetchClient = (config: Config): {
|
| 13 |
+
(
|
| 14 |
+
input: FetchInputParameter,
|
| 15 |
+
init?: FetchInitParameterWithClient,
|
| 16 |
+
): FetchReturn;
|
| 17 |
+
} => {
|
| 18 |
+
return async (
|
| 19 |
+
input: FetchInputParameter,
|
| 20 |
+
init?: RequestInit,
|
| 21 |
+
) => {
|
| 22 |
+
// Use auto-fetched proxy if enabled, otherwise use configured proxy
|
| 23 |
+
const proxyAddress = config.networking.auto_proxy
|
| 24 |
+
? getCurrentProxy()
|
| 25 |
+
: config.networking.proxy;
|
| 26 |
+
const ipv6Block = config.networking.ipv6_block;
|
| 27 |
+
|
| 28 |
+
// If proxy or IPv6 rotation is configured, create a custom HTTP client
|
| 29 |
+
if (proxyAddress || ipv6Block) {
|
| 30 |
+
const clientOptions: Deno.CreateHttpClientOptions = {};
|
| 31 |
+
|
| 32 |
+
if (proxyAddress) {
|
| 33 |
+
try {
|
| 34 |
+
const proxyUrl = new URL(proxyAddress);
|
| 35 |
+
// Extract credentials if present
|
| 36 |
+
if (proxyUrl.username && proxyUrl.password) {
|
| 37 |
+
clientOptions.proxy = {
|
| 38 |
+
url: `${proxyUrl.protocol}//${proxyUrl.host}`,
|
| 39 |
+
basicAuth: {
|
| 40 |
+
username: decodeURIComponent(proxyUrl.username),
|
| 41 |
+
password: decodeURIComponent(proxyUrl.password),
|
| 42 |
+
},
|
| 43 |
+
};
|
| 44 |
+
} else {
|
| 45 |
+
clientOptions.proxy = {
|
| 46 |
+
url: proxyAddress,
|
| 47 |
+
};
|
| 48 |
+
}
|
| 49 |
+
} catch {
|
| 50 |
+
clientOptions.proxy = {
|
| 51 |
+
url: proxyAddress,
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
if (ipv6Block) {
|
| 57 |
+
clientOptions.localAddress = generateRandomIPv6(ipv6Block);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const client = Deno.createHttpClient(clientOptions);
|
| 61 |
+
const fetchRes = await fetchShim(config, input, {
|
| 62 |
+
client,
|
| 63 |
+
headers: init?.headers,
|
| 64 |
+
method: init?.method,
|
| 65 |
+
body: init?.body,
|
| 66 |
+
});
|
| 67 |
+
client.close(); // Important: close client to avoid leaking resources
|
| 68 |
+
return new Response(fetchRes.body, {
|
| 69 |
+
status: fetchRes.status,
|
| 70 |
+
headers: fetchRes.headers,
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return fetchShim(config, input, init);
|
| 75 |
+
};
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
function fetchShim(
|
| 79 |
+
config: Config,
|
| 80 |
+
input: FetchInputParameter,
|
| 81 |
+
init?: FetchInitParameterWithClient,
|
| 82 |
+
): FetchReturn {
|
| 83 |
+
const fetchTimeout = config.networking.fetch?.timeout_ms;
|
| 84 |
+
const fetchRetry = config.networking.fetch?.retry?.enabled;
|
| 85 |
+
const fetchMaxAttempts = config.networking.fetch?.retry?.times;
|
| 86 |
+
const fetchInitialDebounce = config.networking.fetch?.retry
|
| 87 |
+
?.initial_debounce;
|
| 88 |
+
const fetchDebounceMultiplier = config.networking.fetch?.retry
|
| 89 |
+
?.debounce_multiplier;
|
| 90 |
+
const retryOptions: RetryOptions = {
|
| 91 |
+
maxAttempts: fetchMaxAttempts,
|
| 92 |
+
minTimeout: fetchInitialDebounce,
|
| 93 |
+
multiplier: fetchDebounceMultiplier,
|
| 94 |
+
jitter: 0,
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
const callFetch = () =>
|
| 98 |
+
fetch(input, {
|
| 99 |
+
// only set the AbortSignal if the timeout is supplied in the config
|
| 100 |
+
signal: fetchTimeout
|
| 101 |
+
? AbortSignal.timeout(Number(fetchTimeout))
|
| 102 |
+
: null,
|
| 103 |
+
...(init || {}),
|
| 104 |
+
});
|
| 105 |
+
// if retry enabled, call retry with the fetch shim, otherwise pass the fetch shim back directly
|
| 106 |
+
return fetchRetry ? retry(callFetch, retryOptions) : callFetch();
|
| 107 |
+
}
|
src/lib/helpers/ipv6Rotation.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* IPv6 Rotation Utility
|
| 3 |
+
*
|
| 4 |
+
* This module provides IPv6 address rotation functionality to help avoid
|
| 5 |
+
* "Please login" errors from YouTube by sending each request from a unique
|
| 6 |
+
* IPv6 address within a configured IPv6 block.
|
| 7 |
+
*
|
| 8 |
+
* Setup instructions: https://gist.github.com/unixfox/2a9dbcb23d8f69c4582f7c85a849d5cc#linux-setup
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Generate a random IPv6 address within the specified IPv6 block
|
| 13 |
+
* @param ipv6Block - The IPv6 block in CIDR notation (e.g., "2001:db8::/32")
|
| 14 |
+
* @returns A random IPv6 address within the specified IPv6 block
|
| 15 |
+
*/
|
| 16 |
+
export function generateRandomIPv6(ipv6Block: string): string {
|
| 17 |
+
// Parse the IPv6 block
|
| 18 |
+
const [baseAddress, blockSize] = ipv6Block.split("/");
|
| 19 |
+
const blockBits = parseInt(blockSize, 10);
|
| 20 |
+
|
| 21 |
+
if (isNaN(blockBits) || blockBits < 1 || blockBits > 128) {
|
| 22 |
+
throw new Error("Invalid IPv6 block size");
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Expand IPv6 address to full form
|
| 26 |
+
const expandedBase = expandIPv6(baseAddress);
|
| 27 |
+
|
| 28 |
+
// Convert to binary representation
|
| 29 |
+
const baseBytes = ipv6ToBytes(expandedBase);
|
| 30 |
+
|
| 31 |
+
// Randomize all bits after the block prefix
|
| 32 |
+
for (let i = Math.floor(blockBits / 8); i < 16; i++) {
|
| 33 |
+
const bitOffset = Math.max(0, blockBits - i * 8);
|
| 34 |
+
if (bitOffset === 0) {
|
| 35 |
+
// Fully random byte
|
| 36 |
+
baseBytes[i] = Math.floor(Math.random() * 256);
|
| 37 |
+
} else if (bitOffset < 8) {
|
| 38 |
+
// Partially random byte
|
| 39 |
+
const mask = (1 << (8 - bitOffset)) - 1;
|
| 40 |
+
const randomPart = Math.floor(Math.random() * (mask + 1));
|
| 41 |
+
baseBytes[i] = (baseBytes[i] & ~mask) | randomPart;
|
| 42 |
+
}
|
| 43 |
+
// else: keep the original byte (bitOffset >= 8)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Convert back to IPv6 string
|
| 47 |
+
return bytesToIPv6(baseBytes);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Expand an IPv6 address to its full form
|
| 52 |
+
*/
|
| 53 |
+
function expandIPv6(address: string): string {
|
| 54 |
+
// Handle :: expansion
|
| 55 |
+
if (address.includes("::")) {
|
| 56 |
+
const parts = address.split("::");
|
| 57 |
+
const leftParts = parts[0] ? parts[0].split(":") : [];
|
| 58 |
+
const rightParts = parts[1] ? parts[1].split(":") : [];
|
| 59 |
+
const missingParts = 8 - leftParts.length - rightParts.length;
|
| 60 |
+
const middle = Array(missingParts).fill("0000");
|
| 61 |
+
const allParts = [...leftParts, ...middle, ...rightParts];
|
| 62 |
+
return allParts.map((p) => p.padStart(4, "0")).join(":");
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Pad each part to 4 characters
|
| 66 |
+
return address.split(":").map((p) => p.padStart(4, "0")).join(":");
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Convert IPv6 address string to byte array
|
| 71 |
+
*/
|
| 72 |
+
function ipv6ToBytes(address: string): number[] {
|
| 73 |
+
const parts = address.split(":");
|
| 74 |
+
const bytes: number[] = [];
|
| 75 |
+
|
| 76 |
+
for (const part of parts) {
|
| 77 |
+
const value = parseInt(part, 16);
|
| 78 |
+
bytes.push((value >> 8) & 0xFF);
|
| 79 |
+
bytes.push(value & 0xFF);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return bytes;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Convert byte array back to IPv6 address string
|
| 87 |
+
*/
|
| 88 |
+
function bytesToIPv6(bytes: number[]): string {
|
| 89 |
+
const parts: string[] = [];
|
| 90 |
+
|
| 91 |
+
for (let i = 0; i < 16; i += 2) {
|
| 92 |
+
const value = (bytes[i] << 8) | bytes[i + 1];
|
| 93 |
+
parts.push(value.toString(16));
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Compress consecutive zeros
|
| 97 |
+
let ipv6 = parts.join(":");
|
| 98 |
+
|
| 99 |
+
// Find the longest sequence of consecutive zeros
|
| 100 |
+
const zeroSequences = ipv6.match(/(^|:)(0:)+/g);
|
| 101 |
+
if (zeroSequences) {
|
| 102 |
+
const longestZeroSeq = zeroSequences.reduce((a, b) =>
|
| 103 |
+
a.length > b.length ? a : b
|
| 104 |
+
);
|
| 105 |
+
ipv6 = ipv6.replace(longestZeroSeq, longestZeroSeq[0] + ":");
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
return ipv6;
|
| 109 |
+
}
|
src/lib/helpers/jsInterpreter.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Platform, Types } from "youtubei.js";
|
| 2 |
+
|
| 3 |
+
// https://ytjs.dev/guide/getting-started.html#providing-a-custom-javascript-interpreter
|
| 4 |
+
// deno-lint-ignore require-await
|
| 5 |
+
export const jsInterpreter = Platform.shim.eval = async (
|
| 6 |
+
data: Types.BuildScriptResult,
|
| 7 |
+
env: Record<string, Types.VMPrimative>,
|
| 8 |
+
) => {
|
| 9 |
+
const properties = [];
|
| 10 |
+
|
| 11 |
+
if (env.n) {
|
| 12 |
+
properties.push(`n: exportedVars.nFunction("${env.n}")`);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
if (env.sig) {
|
| 16 |
+
properties.push(`sig: exportedVars.sigFunction("${env.sig}")`);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const code = `${data.output}\nreturn { ${properties.join(", ")} }`;
|
| 20 |
+
|
| 21 |
+
return new Function(code)();
|
| 22 |
+
};
|
src/lib/helpers/metrics.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|
src/lib/helpers/proxyManager.ts
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Automatic Proxy Manager
|
| 3 |
+
* Fetches free proxies from antpeak.com API and auto-rotates when they fail.
|
| 4 |
+
* Tests proxies against YouTube to ensure they work for the application's needs.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { fetchUrbanProxy } from "./urbanProxy.ts";
|
| 8 |
+
|
| 9 |
+
// --- Configuration ---
|
| 10 |
+
const API_BASE = "https://antpeak.com";
|
| 11 |
+
const USER_AGENT =
|
| 12 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
| 13 |
+
const APP_VERSION = "3.7.8";
|
| 14 |
+
const YOUTUBE_TEST_URL = "https://www.youtube.com/watch?v=bzbsJGMVHxQ";
|
| 15 |
+
const CUSTOM_PROXY_URL = "https://ytdlp-api-gbdn.onrender.com/proxies";
|
| 16 |
+
|
| 17 |
+
// --- Types ---
|
| 18 |
+
interface DeviceInfo {
|
| 19 |
+
udid: string;
|
| 20 |
+
appVersion: string;
|
| 21 |
+
platform: string;
|
| 22 |
+
platformVersion: string;
|
| 23 |
+
timeZone: string;
|
| 24 |
+
deviceName: string;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
interface Location {
|
| 28 |
+
id: string;
|
| 29 |
+
region: string;
|
| 30 |
+
name: string;
|
| 31 |
+
countryCode: string;
|
| 32 |
+
type: number;
|
| 33 |
+
proxyType: number;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
interface ProxyServer {
|
| 37 |
+
addresses: string[];
|
| 38 |
+
protocol: string;
|
| 39 |
+
port: number;
|
| 40 |
+
username?: string;
|
| 41 |
+
password?: string;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// --- Singleton State ---
|
| 45 |
+
let currentProxyUrl: string | null = null;
|
| 46 |
+
let accessToken: string | null = null;
|
| 47 |
+
let freeLocations: Location[] = [];
|
| 48 |
+
let isInitialized = false;
|
| 49 |
+
let initializationPromise: Promise<void> | null = null;
|
| 50 |
+
let vpnSource = 1;
|
| 51 |
+
|
| 52 |
+
// --- Helpers ---
|
| 53 |
+
|
| 54 |
+
async function fetchJson(
|
| 55 |
+
endpoint: string,
|
| 56 |
+
method: string,
|
| 57 |
+
body?: unknown,
|
| 58 |
+
token?: string,
|
| 59 |
+
): Promise<unknown> {
|
| 60 |
+
const url = `${API_BASE}${endpoint}`;
|
| 61 |
+
const headers: Record<string, string> = {
|
| 62 |
+
"User-Agent": USER_AGENT,
|
| 63 |
+
"Content-Type": "application/json",
|
| 64 |
+
"Accept": "application/json",
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
if (token) {
|
| 68 |
+
headers["Authorization"] = `Bearer ${token}`;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const response = await fetch(url, {
|
| 72 |
+
method,
|
| 73 |
+
headers,
|
| 74 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
if (!response.ok) {
|
| 78 |
+
const text = await response.text();
|
| 79 |
+
throw new Error(`API Error ${response.status}: ${text}`);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return await response.json();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async function testProxyAgainstYouTube(proxyUrl: string): Promise<number | boolean> {
|
| 86 |
+
try {
|
| 87 |
+
const proxyUrlObj = new URL(proxyUrl);
|
| 88 |
+
const clientOptions: Deno.CreateHttpClientOptions = {};
|
| 89 |
+
|
| 90 |
+
if (proxyUrlObj.username && proxyUrlObj.password) {
|
| 91 |
+
clientOptions.proxy = {
|
| 92 |
+
url: `${proxyUrlObj.protocol}//${proxyUrlObj.host}`,
|
| 93 |
+
basicAuth: {
|
| 94 |
+
username: decodeURIComponent(proxyUrlObj.username),
|
| 95 |
+
password: decodeURIComponent(proxyUrlObj.password),
|
| 96 |
+
},
|
| 97 |
+
};
|
| 98 |
+
} else {
|
| 99 |
+
clientOptions.proxy = {
|
| 100 |
+
url: proxyUrl,
|
| 101 |
+
};
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const client = Deno.createHttpClient(clientOptions);
|
| 105 |
+
|
| 106 |
+
const response = await fetch(YOUTUBE_TEST_URL, {
|
| 107 |
+
client,
|
| 108 |
+
signal: AbortSignal.timeout(15000), // 15 second timeout for test
|
| 109 |
+
headers: {
|
| 110 |
+
"User-Agent": USER_AGENT,
|
| 111 |
+
},
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
client.close();
|
| 115 |
+
|
| 116 |
+
// YouTube should return 200 or a redirect (3xx)
|
| 117 |
+
if (response.ok || (response.status >= 300 && response.status < 400)) {
|
| 118 |
+
return true;
|
| 119 |
+
}
|
| 120 |
+
return response.status;
|
| 121 |
+
} catch (err) {
|
| 122 |
+
// console.error("[ProxyManager] Proxy test failed:", err); // Verified by user request to just move to next
|
| 123 |
+
return false;
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
async function registerDevice(): Promise<string> {
|
| 128 |
+
const deviceInfo: DeviceInfo = {
|
| 129 |
+
udid: crypto.randomUUID(),
|
| 130 |
+
appVersion: APP_VERSION,
|
| 131 |
+
platform: "chrome",
|
| 132 |
+
platformVersion: USER_AGENT,
|
| 133 |
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
| 134 |
+
deviceName: "Chrome 120.0.0.0",
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
const launchResponse = await fetchJson(
|
| 138 |
+
"/api/launch/",
|
| 139 |
+
"POST",
|
| 140 |
+
deviceInfo,
|
| 141 |
+
) as {
|
| 142 |
+
success: boolean;
|
| 143 |
+
data?: { accessToken: string };
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
if (!launchResponse.success || !launchResponse.data?.accessToken) {
|
| 147 |
+
throw new Error("Failed to register device with antpeak.com");
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return launchResponse.data.accessToken;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
async function fetchLocations(token: string): Promise<Location[]> {
|
| 154 |
+
const locationsResponse = await fetchJson(
|
| 155 |
+
"/api/location/list/",
|
| 156 |
+
"POST",
|
| 157 |
+
undefined,
|
| 158 |
+
token,
|
| 159 |
+
) as {
|
| 160 |
+
success: boolean;
|
| 161 |
+
data?: { locations: Location[] };
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
if (!locationsResponse.success || !locationsResponse.data?.locations) {
|
| 165 |
+
throw new Error("Failed to fetch locations from antpeak.com");
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// Filter for free locations (proxyType === 0)
|
| 169 |
+
return locationsResponse.data.locations.filter((l) => l.proxyType === 0);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
async function fetchProxyServer(
|
| 173 |
+
token: string,
|
| 174 |
+
location: Location,
|
| 175 |
+
): Promise<string | null> {
|
| 176 |
+
const serverPayload = {
|
| 177 |
+
protocol: "https",
|
| 178 |
+
region: location.region,
|
| 179 |
+
type: location.type,
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
const serverResponse = await fetchJson(
|
| 183 |
+
"/api/server/list/",
|
| 184 |
+
"POST",
|
| 185 |
+
serverPayload,
|
| 186 |
+
token,
|
| 187 |
+
) as {
|
| 188 |
+
success: boolean;
|
| 189 |
+
data?: ProxyServer[];
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
if (
|
| 193 |
+
!serverResponse.success ||
|
| 194 |
+
!Array.isArray(serverResponse.data) ||
|
| 195 |
+
serverResponse.data.length === 0
|
| 196 |
+
) {
|
| 197 |
+
return null;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
const server = serverResponse.data[0];
|
| 201 |
+
const ip = server.addresses[0];
|
| 202 |
+
const port = server.port;
|
| 203 |
+
const username = server.username || "";
|
| 204 |
+
const password = server.password || "";
|
| 205 |
+
|
| 206 |
+
if (!username) {
|
| 207 |
+
return `https://${ip}:${port}`;
|
| 208 |
+
} else {
|
| 209 |
+
return `https://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${ip}:${port}`;
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// --- Public API ---
|
| 214 |
+
|
| 215 |
+
/**
|
| 216 |
+
* Initialize the proxy manager. Fetches initial token and locations.
|
| 217 |
+
* Safe to call multiple times - will only initialize once.
|
| 218 |
+
*/
|
| 219 |
+
export async function initProxyManager(source: number = 1): Promise<void> {
|
| 220 |
+
vpnSource = source;
|
| 221 |
+
if (isInitialized) return;
|
| 222 |
+
|
| 223 |
+
if (initializationPromise) {
|
| 224 |
+
return initializationPromise;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
initializationPromise = (async () => {
|
| 228 |
+
console.log("[ProxyManager] Initializing automatic proxy manager...");
|
| 229 |
+
|
| 230 |
+
try {
|
| 231 |
+
if (vpnSource === 1) {
|
| 232 |
+
accessToken = await registerDevice();
|
| 233 |
+
console.log("[ProxyManager] ✅ Registered with antpeak.com");
|
| 234 |
+
|
| 235 |
+
freeLocations = await fetchLocations(accessToken);
|
| 236 |
+
console.log(
|
| 237 |
+
`[ProxyManager] ✅ Found ${freeLocations.length} free locations`,
|
| 238 |
+
);
|
| 239 |
+
|
| 240 |
+
if (freeLocations.length === 0) {
|
| 241 |
+
throw new Error("No free proxy locations available");
|
| 242 |
+
}
|
| 243 |
+
} else if (vpnSource === 2) {
|
| 244 |
+
console.log("[ProxyManager] Using Urban VPN source");
|
| 245 |
+
} else if (vpnSource === 3) {
|
| 246 |
+
console.log("[ProxyManager] Using Custom Proxy API source");
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// Fetch initial proxy
|
| 250 |
+
await rotateProxy();
|
| 251 |
+
|
| 252 |
+
isInitialized = true;
|
| 253 |
+
console.log("[ProxyManager] ✅ Initialization complete");
|
| 254 |
+
} catch (err) {
|
| 255 |
+
console.error("[ProxyManager] ❌ Initialization failed:", err);
|
| 256 |
+
throw err;
|
| 257 |
+
}
|
| 258 |
+
})();
|
| 259 |
+
|
| 260 |
+
return initializationPromise;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
/**
|
| 264 |
+
* Get the current proxy URL. Returns null if no proxy is available.
|
| 265 |
+
*/
|
| 266 |
+
export function getCurrentProxy(): string | null {
|
| 267 |
+
return currentProxyUrl;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/**
|
| 271 |
+
* Rotate to a new proxy. Tests against YouTube before accepting.
|
| 272 |
+
* Will try multiple locations until a working proxy is found.
|
| 273 |
+
*/
|
| 274 |
+
export async function rotateProxy(): Promise<string | null> {
|
| 275 |
+
console.log(`[ProxyManager] Rotation requested. Source: ${vpnSource}`);
|
| 276 |
+
|
| 277 |
+
if (vpnSource === 2) {
|
| 278 |
+
// Urban VPN Logic
|
| 279 |
+
try {
|
| 280 |
+
const urbanResult = await fetchUrbanProxy();
|
| 281 |
+
if (urbanResult) {
|
| 282 |
+
console.log(`[ProxyManager] Testing Urban proxy against YouTube...`);
|
| 283 |
+
const result = await testProxyAgainstYouTube(urbanResult.url);
|
| 284 |
+
if (result === true) {
|
| 285 |
+
currentProxyUrl = urbanResult.url;
|
| 286 |
+
console.log(`[ProxyManager] ✅ New Urban proxy active: ${urbanResult.host}`);
|
| 287 |
+
return currentProxyUrl;
|
| 288 |
+
} else {
|
| 289 |
+
console.log(`[ProxyManager] ❌ Urban proxy failed YouTube test`);
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
} catch (err) {
|
| 293 |
+
console.error("[ProxyManager] Failed to fetch/test Urban proxy", err);
|
| 294 |
+
}
|
| 295 |
+
console.error("[ProxyManager] ❌ Could not find a working Urban proxy");
|
| 296 |
+
currentProxyUrl = null;
|
| 297 |
+
return null;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
if (vpnSource === 3) {
|
| 301 |
+
// Custom Proxy Logic
|
| 302 |
+
console.log("[ProxyManager] Fetching proxies from custom API...");
|
| 303 |
+
|
| 304 |
+
let attempts = 0;
|
| 305 |
+
const maxAttempts = 10; // Increased retry limit as requested
|
| 306 |
+
|
| 307 |
+
while (attempts < maxAttempts) {
|
| 308 |
+
try {
|
| 309 |
+
const response = await fetch(CUSTOM_PROXY_URL);
|
| 310 |
+
if (!response.ok) {
|
| 311 |
+
throw new Error(`Failed to fetch proxies: ${response.statusText}`);
|
| 312 |
+
}
|
| 313 |
+
const data = await response.json() as { proxies: string[] };
|
| 314 |
+
|
| 315 |
+
if (!data.proxies || !Array.isArray(data.proxies) || data.proxies.length === 0) {
|
| 316 |
+
console.log("[ProxyManager] No proxies returned from API, retrying...");
|
| 317 |
+
attempts++;
|
| 318 |
+
continue;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
console.log(`[ProxyManager] Got ${data.proxies.length} proxies from API. Testing...`);
|
| 322 |
+
|
| 323 |
+
for (const proxy of data.proxies) {
|
| 324 |
+
console.log(`[ProxyManager] Testing ${proxy}...`);
|
| 325 |
+
const result = await testProxyAgainstYouTube(proxy);
|
| 326 |
+
|
| 327 |
+
if (result === true) {
|
| 328 |
+
currentProxyUrl = proxy;
|
| 329 |
+
console.log(`[ProxyManager] ✅ New custom proxy active: ${proxy}`);
|
| 330 |
+
return currentProxyUrl;
|
| 331 |
+
} else if (typeof result === 'number') {
|
| 332 |
+
console.log(`[ProxyManager] ❌ Proxy returned status ${result}, trying next...`);
|
| 333 |
+
} else {
|
| 334 |
+
console.log(`[ProxyManager] ❌ Proxy unreachable, trying next...`);
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
console.log("[ProxyManager] All proxies from this batch failed. Refetching...");
|
| 339 |
+
attempts++;
|
| 340 |
+
|
| 341 |
+
} catch (err) {
|
| 342 |
+
console.error("[ProxyManager] Error fetching custom proxies:", err);
|
| 343 |
+
attempts++;
|
| 344 |
+
// Wait a bit before retrying if it's a fetch error
|
| 345 |
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
console.error("[ProxyManager] ❌ Failed to find a working custom proxy after multiple attempts.");
|
| 350 |
+
currentProxyUrl = null;
|
| 351 |
+
return null;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
if (!accessToken || freeLocations.length === 0) {
|
| 355 |
+
console.error(
|
| 356 |
+
"[ProxyManager] Not initialized or no locations available",
|
| 357 |
+
);
|
| 358 |
+
return null;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// Default AntPeak Logic (vpnSource === 1)
|
| 362 |
+
if (!accessToken || freeLocations.length === 0) {
|
| 363 |
+
console.error(
|
| 364 |
+
"[ProxyManager] Not initialized or no locations available",
|
| 365 |
+
);
|
| 366 |
+
return null;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
console.log("[ProxyManager] Rotating to new proxy (AntPeak)...");
|
| 370 |
+
|
| 371 |
+
// Shuffle locations to get variety
|
| 372 |
+
const shuffledLocations = [...freeLocations].sort(() =>
|
| 373 |
+
Math.random() - 0.5
|
| 374 |
+
);
|
| 375 |
+
|
| 376 |
+
for (const location of shuffledLocations) {
|
| 377 |
+
try {
|
| 378 |
+
console.log(
|
| 379 |
+
`[ProxyManager] Trying location: ${location.region} (${location.countryCode})`,
|
| 380 |
+
);
|
| 381 |
+
|
| 382 |
+
const proxyUrl = await fetchProxyServer(accessToken, location);
|
| 383 |
+
if (!proxyUrl) {
|
| 384 |
+
console.log(
|
| 385 |
+
`[ProxyManager] No server available for ${location.region}`,
|
| 386 |
+
);
|
| 387 |
+
continue;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
// Test proxy against YouTube
|
| 391 |
+
console.log(`[ProxyManager] Testing proxy against YouTube...`);
|
| 392 |
+
const result = await testProxyAgainstYouTube(proxyUrl);
|
| 393 |
+
|
| 394 |
+
if (result === true) {
|
| 395 |
+
currentProxyUrl = proxyUrl;
|
| 396 |
+
// Log without credentials for security
|
| 397 |
+
const sanitizedUrl = proxyUrl.replace(
|
| 398 |
+
/:\/\/[^@]+@/,
|
| 399 |
+
"://***:***@",
|
| 400 |
+
);
|
| 401 |
+
console.log(
|
| 402 |
+
`[ProxyManager] ✅ New proxy active: ${sanitizedUrl}`,
|
| 403 |
+
);
|
| 404 |
+
return currentProxyUrl;
|
| 405 |
+
} else {
|
| 406 |
+
console.log(
|
| 407 |
+
`[ProxyManager] ❌ Proxy failed YouTube test, trying next...`,
|
| 408 |
+
);
|
| 409 |
+
}
|
| 410 |
+
} catch (err) {
|
| 411 |
+
console.error(
|
| 412 |
+
`[ProxyManager] Error with location ${location.region}:`,
|
| 413 |
+
err,
|
| 414 |
+
);
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
console.error("[ProxyManager] ❌ Could not find a working proxy");
|
| 419 |
+
currentProxyUrl = null;
|
| 420 |
+
return null;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
/**
|
| 424 |
+
* Mark the current proxy as failed and rotate to a new one.
|
| 425 |
+
* Call this when a request fails due to proxy issues.
|
| 426 |
+
*/
|
| 427 |
+
export async function markProxyFailed(): Promise<string | null> {
|
| 428 |
+
console.log("[ProxyManager] Current proxy marked as failed, rotating...");
|
| 429 |
+
return await rotateProxy();
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
/**
|
| 433 |
+
* Check if the proxy manager is initialized and has a working proxy.
|
| 434 |
+
*/
|
| 435 |
+
export function isProxyManagerReady(): boolean {
|
| 436 |
+
return isInitialized && currentProxyUrl !== null;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
/**
|
| 440 |
+
* Re-register with the API (in case token expires).
|
| 441 |
+
*/
|
| 442 |
+
export async function refreshRegistration(): Promise<void> {
|
| 443 |
+
console.log("[ProxyManager] Refreshing registration...");
|
| 444 |
+
try {
|
| 445 |
+
accessToken = await registerDevice();
|
| 446 |
+
freeLocations = await fetchLocations(accessToken);
|
| 447 |
+
console.log("[ProxyManager] ✅ Registration refreshed");
|
| 448 |
+
} catch (err) {
|
| 449 |
+
console.error("[ProxyManager] ❌ Failed to refresh registration:", err);
|
| 450 |
+
throw err;
|
| 451 |
+
}
|
| 452 |
+
}
|
src/lib/helpers/urbanProxy.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const ACCOUNT_API = "https://api-pro.urban-vpn.com/rest/v1";
|
| 2 |
+
const STATS_API = "https://stats.urban-vpn.com/api/rest/v2";
|
| 3 |
+
const CLIENT_APP = "URBAN_VPN_BROWSER_EXTENSION";
|
| 4 |
+
const BROWSER = "CHROME";
|
| 5 |
+
|
| 6 |
+
interface UrbanProxyResult {
|
| 7 |
+
url: string;
|
| 8 |
+
protocol: string;
|
| 9 |
+
host: string;
|
| 10 |
+
port: number;
|
| 11 |
+
username?: string;
|
| 12 |
+
password?: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const PREFERRED_COUNTRIES = ["US", "GB", "CA", "DE", "FR", "NL", "ES", "IT", "JP", "KR", "SG", "AU"];
|
| 16 |
+
|
| 17 |
+
export async function fetchUrbanProxy(targetCountryCode = "RANDOM"): Promise<UrbanProxyResult | null> {
|
| 18 |
+
console.log(`[UrbanVPN] Fetching Urban VPN Proxy (Target: ${targetCountryCode})...`);
|
| 19 |
+
|
| 20 |
+
// 1. Register Anonymous
|
| 21 |
+
// console.log("[UrbanVPN] 1. Registering Anonymous User...");
|
| 22 |
+
const regUrl = `${ACCOUNT_API}/registrations/clientApps/${CLIENT_APP}/users/anonymous`;
|
| 23 |
+
|
| 24 |
+
const regHeaders = {
|
| 25 |
+
"content-type": "application/json",
|
| 26 |
+
"accept": "application/json, text/plain, */*",
|
| 27 |
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const regPayload = {
|
| 31 |
+
clientApp: {
|
| 32 |
+
name: CLIENT_APP,
|
| 33 |
+
browser: BROWSER
|
| 34 |
+
}
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
let regResp;
|
| 38 |
+
try {
|
| 39 |
+
regResp = await fetch(regUrl, {
|
| 40 |
+
method: "POST",
|
| 41 |
+
headers: regHeaders,
|
| 42 |
+
body: JSON.stringify(regPayload)
|
| 43 |
+
});
|
| 44 |
+
} catch (err) {
|
| 45 |
+
console.error("[UrbanVPN] Network error during registration:", err);
|
| 46 |
+
return null;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (!regResp.ok) {
|
| 50 |
+
const text = await regResp.text();
|
| 51 |
+
console.error(`[UrbanVPN] Registration failed: ${regResp.status} ${regResp.statusText}`);
|
| 52 |
+
console.error(text);
|
| 53 |
+
return null;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const regData = await regResp.json();
|
| 57 |
+
const idToken = regData.id_token || regData.idToken || regData.value;
|
| 58 |
+
|
| 59 |
+
if (!idToken) {
|
| 60 |
+
console.error("[UrbanVPN] No ID token found in registration response.");
|
| 61 |
+
return null;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// 2. Get Security Token
|
| 65 |
+
// console.log("[UrbanVPN] 2. Getting Security Token...");
|
| 66 |
+
const secUrl = `${ACCOUNT_API}/security/tokens/accs`;
|
| 67 |
+
const secHeaders = {
|
| 68 |
+
...regHeaders,
|
| 69 |
+
"authorization": `Bearer ${idToken}`
|
| 70 |
+
};
|
| 71 |
+
const secPayload = {
|
| 72 |
+
type: "accs",
|
| 73 |
+
clientApp: {
|
| 74 |
+
name: CLIENT_APP
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const secResp = await fetch(secUrl, {
|
| 79 |
+
method: "POST",
|
| 80 |
+
headers: secHeaders,
|
| 81 |
+
body: JSON.stringify(secPayload)
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
if (!secResp.ok) {
|
| 85 |
+
const text = await secResp.text();
|
| 86 |
+
console.error(`[UrbanVPN] Security Token request failed: ${secResp.status}`);
|
| 87 |
+
console.error(text);
|
| 88 |
+
return null;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const secData = await secResp.json();
|
| 92 |
+
|
| 93 |
+
let tokenString = "";
|
| 94 |
+
let credUsername = "";
|
| 95 |
+
const credPassword = "1";
|
| 96 |
+
|
| 97 |
+
if (secData.token && typeof secData.token === 'object' && secData.token.value) {
|
| 98 |
+
tokenString = secData.token.value;
|
| 99 |
+
credUsername = secData.token.value;
|
| 100 |
+
} else if (typeof secData.token === 'string') {
|
| 101 |
+
tokenString = secData.token;
|
| 102 |
+
credUsername = secData.token;
|
| 103 |
+
|
| 104 |
+
} else if (secData.value) {
|
| 105 |
+
tokenString = secData.value;
|
| 106 |
+
credUsername = secData.value;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
if (!tokenString) {
|
| 110 |
+
console.error("[UrbanVPN] No security token found.");
|
| 111 |
+
return null;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// 3. Get Countries / Proxies
|
| 115 |
+
// console.log("[UrbanVPN] 3. Fetching Proxy List...");
|
| 116 |
+
const countriesUrl = `${STATS_API}/entrypoints/countries`;
|
| 117 |
+
const proxyHeaders = {
|
| 118 |
+
...regHeaders,
|
| 119 |
+
"authorization": `Bearer ${tokenString}`,
|
| 120 |
+
"X-Client-App": CLIENT_APP
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
// @ts-ignore: delete operator on string index signature
|
| 124 |
+
delete proxyHeaders["content-type"];
|
| 125 |
+
|
| 126 |
+
const countriesResp = await fetch(countriesUrl, {
|
| 127 |
+
headers: proxyHeaders
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
if (!countriesResp.ok) {
|
| 131 |
+
const text = await countriesResp.text();
|
| 132 |
+
console.error(`[UrbanVPN] Failed to fetch countries: ${countriesResp.status}`);
|
| 133 |
+
console.error(text);
|
| 134 |
+
return null;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const countriesData = await countriesResp.json();
|
| 138 |
+
|
| 139 |
+
if (!countriesData.countries || !countriesData.countries.elements) {
|
| 140 |
+
console.error("[UrbanVPN] Invalid countries data format.");
|
| 141 |
+
return null;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const countries = countriesData.countries.elements;
|
| 145 |
+
|
| 146 |
+
// Pick a country
|
| 147 |
+
let selectedCountryCode = targetCountryCode;
|
| 148 |
+
if (selectedCountryCode === "RANDOM") {
|
| 149 |
+
selectedCountryCode = PREFERRED_COUNTRIES[Math.floor(Math.random() * PREFERRED_COUNTRIES.length)];
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// Find target country proxy
|
| 153 |
+
// deno-lint-ignore no-explicit-any
|
| 154 |
+
let targetCountry = countries.find((c: any) => c.code.iso2 === selectedCountryCode);
|
| 155 |
+
|
| 156 |
+
// Fallback if random choice not found
|
| 157 |
+
if (!targetCountry) {
|
| 158 |
+
targetCountry = countries[0];
|
| 159 |
+
console.log(`[UrbanVPN] Requested country ${selectedCountryCode} not found, falling back to ${targetCountry.code.iso2}`);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
if (targetCountry) {
|
| 163 |
+
console.log(`[UrbanVPN] Selected Country: ${targetCountry.title} (${targetCountry.code.iso2})`);
|
| 164 |
+
|
| 165 |
+
let proxyHost = null;
|
| 166 |
+
let proxyPort = null;
|
| 167 |
+
let signature = null;
|
| 168 |
+
|
| 169 |
+
if (targetCountry.address && targetCountry.address.primary) {
|
| 170 |
+
proxyHost = targetCountry.address.primary.host;
|
| 171 |
+
proxyPort = targetCountry.address.primary.port;
|
| 172 |
+
}
|
| 173 |
+
else if (targetCountry.servers && targetCountry.servers.elements && targetCountry.servers.elements.length > 0) {
|
| 174 |
+
// Pick a RANDOM server from the list
|
| 175 |
+
const serverIndex = Math.floor(Math.random() * targetCountry.servers.elements.length);
|
| 176 |
+
const srv = targetCountry.servers.elements[serverIndex];
|
| 177 |
+
|
| 178 |
+
if (srv.address && srv.address.primary) {
|
| 179 |
+
proxyHost = srv.address.primary.host;
|
| 180 |
+
proxyPort = srv.address.primary.port || srv.address.primary.port_min;
|
| 181 |
+
signature = srv.signature;
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if (signature) {
|
| 186 |
+
// console.log("[UrbanVPN] Found proxy signature, fetching Auth Proxy Token...");
|
| 187 |
+
const proxyTokenUrl = `${ACCOUNT_API}/security/tokens/accs-proxy`;
|
| 188 |
+
const proxyTokenPayload = {
|
| 189 |
+
type: "accs-proxy",
|
| 190 |
+
clientApp: { name: CLIENT_APP },
|
| 191 |
+
signature: signature
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
const proxyTokenHeaders = {
|
| 195 |
+
...regHeaders,
|
| 196 |
+
"authorization": `Bearer ${tokenString}`
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
const ptResp = await fetch(proxyTokenUrl, {
|
| 200 |
+
method: "POST",
|
| 201 |
+
headers: proxyTokenHeaders,
|
| 202 |
+
body: JSON.stringify(proxyTokenPayload)
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
if (ptResp.ok) {
|
| 206 |
+
const ptData = await ptResp.json();
|
| 207 |
+
if (ptData.value) {
|
| 208 |
+
credUsername = ptData.value;
|
| 209 |
+
} else if (ptData.token && ptData.token.value) {
|
| 210 |
+
credUsername = ptData.token.value;
|
| 211 |
+
}
|
| 212 |
+
} else {
|
| 213 |
+
console.error(`[UrbanVPN] Failed to get Proxy Auth Token: ${ptResp.status}`);
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
if (proxyHost) {
|
| 218 |
+
const proxyUrl = `http://${encodeURIComponent(credUsername)}:${encodeURIComponent(credPassword)}@${proxyHost}:${proxyPort}`;
|
| 219 |
+
console.log(`[UrbanVPN] Proxy found: ${proxyHost}:${proxyPort}`);
|
| 220 |
+
return {
|
| 221 |
+
url: proxyUrl,
|
| 222 |
+
protocol: 'http',
|
| 223 |
+
host: proxyHost,
|
| 224 |
+
port: proxyPort,
|
| 225 |
+
username: credUsername,
|
| 226 |
+
password: credPassword
|
| 227 |
+
};
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
console.error("[UrbanVPN] No proxy server details found.");
|
| 232 |
+
return null;
|
| 233 |
+
}
|
src/lib/helpers/validateVideoId.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Validates a YouTube video ID format
|
| 3 |
+
* YouTube video IDs are 11 characters long and contain alphanumeric characters, hyphens, and underscores
|
| 4 |
+
* Reference: https://webapps.stackexchange.com/questions/54443/format-for-id-of-youtube-video
|
| 5 |
+
*
|
| 6 |
+
* @param videoId - The video ID to validate
|
| 7 |
+
* @returns true if the video ID is valid, false otherwise
|
| 8 |
+
*/
|
| 9 |
+
export const validateVideoId = (videoId: string): boolean => {
|
| 10 |
+
// Handle null, undefined, or non-string values
|
| 11 |
+
if (!videoId || typeof videoId !== "string") {
|
| 12 |
+
return false;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// YouTube video IDs are exactly 11 characters
|
| 16 |
+
if (videoId.length !== 11) {
|
| 17 |
+
return false;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// Valid characters: A-Z, a-z, 0-9, -, _
|
| 21 |
+
const validPattern = /^[A-Za-z0-9_-]{11}$/;
|
| 22 |
+
return validPattern.test(videoId);
|
| 23 |
+
};
|
src/lib/helpers/verifyRequest.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
};
|
src/lib/helpers/youtubePlayerHandling.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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<object> => {
|
| 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 = await 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 |
+
await 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 |
+
};
|
src/lib/helpers/youtubePlayerReq.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 32 |
+
?.signature_timestamp,
|
| 33 |
+
},
|
| 34 |
+
},
|
| 35 |
+
serviceIntegrityDimensions: {
|
| 36 |
+
poToken: contentPoToken,
|
| 37 |
+
},
|
| 38 |
+
client: innertubeClientType,
|
| 39 |
+
},
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export const youtubePlayerReq = async (
|
| 44 |
+
innertubeClient: Innertube,
|
| 45 |
+
videoId: string,
|
| 46 |
+
config: Config,
|
| 47 |
+
tokenMinter: TokenMinter,
|
| 48 |
+
): Promise<ApiResponse> => {
|
| 49 |
+
const innertubeClientOauthEnabled = config.youtube_session.oauth_enabled;
|
| 50 |
+
|
| 51 |
+
let innertubeClientUsed = "WEB";
|
| 52 |
+
if (innertubeClientOauthEnabled) {
|
| 53 |
+
innertubeClientUsed = "TV";
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const contentPoToken = await tokenMinter(videoId);
|
| 57 |
+
|
| 58 |
+
const youtubePlayerResponse = await callWatchEndpoint(
|
| 59 |
+
videoId,
|
| 60 |
+
innertubeClient,
|
| 61 |
+
innertubeClientUsed,
|
| 62 |
+
contentPoToken,
|
| 63 |
+
);
|
| 64 |
+
|
| 65 |
+
// Check if the first adaptive format URL is undefined, if it is then fallback to multiple YT clients
|
| 66 |
+
|
| 67 |
+
if (
|
| 68 |
+
!innertubeClientOauthEnabled &&
|
| 69 |
+
youtubePlayerResponse.data.streamingData &&
|
| 70 |
+
youtubePlayerResponse.data.streamingData.adaptiveFormats[0].url ===
|
| 71 |
+
undefined
|
| 72 |
+
) {
|
| 73 |
+
console.log(
|
| 74 |
+
"[WARNING] No URLs found for adaptive formats. Falling back to other YT clients.",
|
| 75 |
+
);
|
| 76 |
+
const innertubeClientsTypeFallback = ["TV_SIMPLY", "MWEB"];
|
| 77 |
+
|
| 78 |
+
for await (const innertubeClientType of innertubeClientsTypeFallback) {
|
| 79 |
+
console.log(
|
| 80 |
+
`[WARNING] Trying fallback YT client ${innertubeClientType}`,
|
| 81 |
+
);
|
| 82 |
+
const youtubePlayerResponseFallback = await callWatchEndpoint(
|
| 83 |
+
videoId,
|
| 84 |
+
innertubeClient,
|
| 85 |
+
innertubeClientType,
|
| 86 |
+
contentPoToken,
|
| 87 |
+
);
|
| 88 |
+
if (
|
| 89 |
+
youtubePlayerResponseFallback.data.streamingData && (
|
| 90 |
+
youtubePlayerResponseFallback.data.streamingData
|
| 91 |
+
.adaptiveFormats[0].url ||
|
| 92 |
+
youtubePlayerResponseFallback.data.streamingData
|
| 93 |
+
.adaptiveFormats[0].signatureCipher
|
| 94 |
+
)
|
| 95 |
+
) {
|
| 96 |
+
youtubePlayerResponse.data.streamingData.adaptiveFormats =
|
| 97 |
+
youtubePlayerResponseFallback.data.streamingData
|
| 98 |
+
.adaptiveFormats;
|
| 99 |
+
break;
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
return youtubePlayerResponse;
|
| 105 |
+
};
|
src/lib/helpers/youtubeTranscriptsHandling.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
relativeTo: Temporal.PlainDateTime.from("2022-01-01"),
|
| 68 |
+
//@ts-ignore see above
|
| 69 |
+
}).toLocaleString("en-US", timestampFormatOptions);
|
| 70 |
+
|
| 71 |
+
const end_ms = createTemporalDuration(Number(line.end_ms)).round({
|
| 72 |
+
largestUnit: "year",
|
| 73 |
+
relativeTo: Temporal.PlainDateTime.from("2022-01-01"),
|
| 74 |
+
//@ts-ignore see above
|
| 75 |
+
}).toLocaleString("en-US", timestampFormatOptions);
|
| 76 |
+
const timestamp = `${start_ms} --> ${end_ms}`;
|
| 77 |
+
|
| 78 |
+
const text = (line.snippet?.text || "").replace(
|
| 79 |
+
/[&<>\u200E\u200F\u00A0]/g,
|
| 80 |
+
(match: string) =>
|
| 81 |
+
ESCAPE_SUBSTITUTIONS[
|
| 82 |
+
match as keyof typeof ESCAPE_SUBSTITUTIONS
|
| 83 |
+
],
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
lines.push(`${timestamp}\n${text}`);
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
return lines.join("\n\n");
|
| 90 |
+
}
|
src/lib/jobs/potoken.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
import { PLAYER_ID } from "../../constants.ts";
|
| 23 |
+
|
| 24 |
+
interface TokenGeneratorWorker extends Omit<Worker, "postMessage"> {
|
| 25 |
+
postMessage(message: InputMessage): void;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const workers: TokenGeneratorWorker[] = [];
|
| 29 |
+
|
| 30 |
+
function createMinter(worker: TokenGeneratorWorker) {
|
| 31 |
+
return (videoId: string): Promise<string> => {
|
| 32 |
+
const { promise, resolve } = Promise.withResolvers<string>();
|
| 33 |
+
// generate a UUID to identify the request as many minter calls
|
| 34 |
+
// may be made within a timespan, and this function will be
|
| 35 |
+
// informed about all of them until it's got its own
|
| 36 |
+
const requestId = crypto.randomUUID();
|
| 37 |
+
const listener = (message: MessageEvent) => {
|
| 38 |
+
const parsedMessage = OutputMessageSchema.parse(message.data);
|
| 39 |
+
if (
|
| 40 |
+
parsedMessage.type === "content-token" &&
|
| 41 |
+
parsedMessage.requestId === requestId
|
| 42 |
+
) {
|
| 43 |
+
worker.removeEventListener("message", listener);
|
| 44 |
+
resolve(parsedMessage.contentToken);
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
worker.addEventListener("message", listener);
|
| 48 |
+
worker.postMessage({
|
| 49 |
+
type: "content-token-request",
|
| 50 |
+
videoId,
|
| 51 |
+
requestId,
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
return promise;
|
| 55 |
+
};
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export type TokenMinter = ReturnType<typeof createMinter>;
|
| 59 |
+
|
| 60 |
+
// Adapted from https://github.com/LuanRT/BgUtils/blob/main/examples/node/index.ts
|
| 61 |
+
export const poTokenGenerate = (
|
| 62 |
+
config: Config,
|
| 63 |
+
metrics: Metrics | undefined,
|
| 64 |
+
): Promise<{ innertubeClient: Innertube; tokenMinter: TokenMinter }> => {
|
| 65 |
+
const { promise, resolve, reject } = Promise.withResolvers<
|
| 66 |
+
Awaited<ReturnType<typeof poTokenGenerate>>
|
| 67 |
+
>();
|
| 68 |
+
|
| 69 |
+
const worker: TokenGeneratorWorker = new Worker(
|
| 70 |
+
new URL("./worker.ts", import.meta.url).href,
|
| 71 |
+
{
|
| 72 |
+
type: "module",
|
| 73 |
+
name: "PO Token Generator",
|
| 74 |
+
},
|
| 75 |
+
);
|
| 76 |
+
// take note of the worker so we can kill it once a new one takes its place
|
| 77 |
+
workers.push(worker);
|
| 78 |
+
worker.addEventListener("message", async (event) => {
|
| 79 |
+
const parsedMessage = OutputMessageSchema.parse(event.data);
|
| 80 |
+
|
| 81 |
+
// worker is listening for messages
|
| 82 |
+
if (parsedMessage.type === "ready") {
|
| 83 |
+
const untypedPostMessage = worker.postMessage.bind(worker);
|
| 84 |
+
worker.postMessage = (message: InputMessage) =>
|
| 85 |
+
untypedPostMessage(message);
|
| 86 |
+
worker.postMessage({ type: "initialise", config });
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if (parsedMessage.type === "error") {
|
| 90 |
+
console.log({ errorFromWorker: parsedMessage.error });
|
| 91 |
+
worker.terminate();
|
| 92 |
+
reject(parsedMessage.error);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// worker is initialised and has passed back a session token and visitor data
|
| 96 |
+
if (parsedMessage.type === "initialised") {
|
| 97 |
+
try {
|
| 98 |
+
const instantiatedInnertubeClient = await Innertube.create({
|
| 99 |
+
enable_session_cache: false,
|
| 100 |
+
po_token: parsedMessage.sessionPoToken,
|
| 101 |
+
visitor_data: parsedMessage.visitorData,
|
| 102 |
+
fetch: getFetchClient(config),
|
| 103 |
+
generate_session_locally: true,
|
| 104 |
+
cookie: config.youtube_session.cookies || undefined,
|
| 105 |
+
player_id: PLAYER_ID,
|
| 106 |
+
});
|
| 107 |
+
const minter = createMinter(worker);
|
| 108 |
+
// check token from minter
|
| 109 |
+
await checkToken({
|
| 110 |
+
instantiatedInnertubeClient,
|
| 111 |
+
config,
|
| 112 |
+
integrityTokenBasedMinter: minter,
|
| 113 |
+
metrics,
|
| 114 |
+
});
|
| 115 |
+
console.log("[INFO] Successfully generated PO token");
|
| 116 |
+
const numberToKill = workers.length - 1;
|
| 117 |
+
for (let i = 0; i < numberToKill; i++) {
|
| 118 |
+
const workerToKill = workers.shift();
|
| 119 |
+
workerToKill?.terminate();
|
| 120 |
+
}
|
| 121 |
+
return resolve({
|
| 122 |
+
innertubeClient: instantiatedInnertubeClient,
|
| 123 |
+
tokenMinter: minter,
|
| 124 |
+
});
|
| 125 |
+
} catch (err) {
|
| 126 |
+
console.log("[WARN] Failed to get valid PO token, will retry", {
|
| 127 |
+
err,
|
| 128 |
+
});
|
| 129 |
+
worker.terminate();
|
| 130 |
+
reject(err);
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
return promise;
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
async function checkToken({
|
| 139 |
+
instantiatedInnertubeClient,
|
| 140 |
+
config,
|
| 141 |
+
integrityTokenBasedMinter,
|
| 142 |
+
metrics,
|
| 143 |
+
}: {
|
| 144 |
+
instantiatedInnertubeClient: Innertube;
|
| 145 |
+
config: Config;
|
| 146 |
+
integrityTokenBasedMinter: TokenMinter;
|
| 147 |
+
metrics: Metrics | undefined;
|
| 148 |
+
}) {
|
| 149 |
+
const fetchImpl = getFetchClient(config);
|
| 150 |
+
|
| 151 |
+
try {
|
| 152 |
+
console.log("[INFO] Searching for videos to validate PO token");
|
| 153 |
+
const searchResults = await instantiatedInnertubeClient.search("news", {
|
| 154 |
+
type: "video",
|
| 155 |
+
upload_date: "week",
|
| 156 |
+
duration: "medium",
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
// Get all videos that have an id property and shuffle them randomly
|
| 160 |
+
const videos = searchResults.videos
|
| 161 |
+
.filter((video) =>
|
| 162 |
+
video.type === "Video" && "id" in video && video.id
|
| 163 |
+
)
|
| 164 |
+
.map((value) => ({ value, sort: Math.random() }))
|
| 165 |
+
.sort((a, b) => a.sort - b.sort)
|
| 166 |
+
.map(({ value }) => value);
|
| 167 |
+
|
| 168 |
+
if (videos.length === 0) {
|
| 169 |
+
throw new Error("No videos with valid IDs found in search results");
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// Try up to 3 random videos to validate the token
|
| 173 |
+
const maxAttempts = Math.min(3, videos.length);
|
| 174 |
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
| 175 |
+
const video = videos[attempt];
|
| 176 |
+
|
| 177 |
+
try {
|
| 178 |
+
// Type guard to ensure video has an id property
|
| 179 |
+
if (!("id" in video) || !video.id) {
|
| 180 |
+
console.log(
|
| 181 |
+
`[WARN] Video at index ${attempt} has no valid ID, trying next video`,
|
| 182 |
+
);
|
| 183 |
+
continue;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
console.log(
|
| 187 |
+
`[INFO] Validating PO token with video: ${video.id}`,
|
| 188 |
+
);
|
| 189 |
+
|
| 190 |
+
const youtubePlayerResponseJson = await youtubePlayerParsing({
|
| 191 |
+
innertubeClient: instantiatedInnertubeClient,
|
| 192 |
+
videoId: video.id,
|
| 193 |
+
config,
|
| 194 |
+
tokenMinter: integrityTokenBasedMinter,
|
| 195 |
+
metrics,
|
| 196 |
+
overrideCache: true,
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
const videoInfo = youtubeVideoInfo(
|
| 200 |
+
instantiatedInnertubeClient,
|
| 201 |
+
youtubePlayerResponseJson,
|
| 202 |
+
);
|
| 203 |
+
|
| 204 |
+
const validFormat = videoInfo.streaming_data
|
| 205 |
+
?.adaptive_formats[0];
|
| 206 |
+
if (!validFormat) {
|
| 207 |
+
console.log(
|
| 208 |
+
`[WARN] No valid format found for video ${video.id}, trying next video`,
|
| 209 |
+
);
|
| 210 |
+
continue;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const result = await fetchImpl(validFormat?.url, {
|
| 214 |
+
method: "HEAD",
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
if (result.status !== 200) {
|
| 218 |
+
console.log(
|
| 219 |
+
`[WARN] Got status ${result.status} for video ${video.id}, trying next video`,
|
| 220 |
+
);
|
| 221 |
+
continue;
|
| 222 |
+
} else {
|
| 223 |
+
console.log(
|
| 224 |
+
`[INFO] Successfully validated PO token with video: ${video.id}`,
|
| 225 |
+
);
|
| 226 |
+
return; // Success
|
| 227 |
+
}
|
| 228 |
+
} catch (err) {
|
| 229 |
+
const videoId = ("id" in video && video.id)
|
| 230 |
+
? video.id
|
| 231 |
+
: "unknown";
|
| 232 |
+
console.log(
|
| 233 |
+
`[WARN] Failed to validate with video ${videoId}:`,
|
| 234 |
+
{ err },
|
| 235 |
+
);
|
| 236 |
+
if (attempt === maxAttempts - 1) {
|
| 237 |
+
throw new Error(
|
| 238 |
+
"Failed to validate PO token with any available videos",
|
| 239 |
+
);
|
| 240 |
+
}
|
| 241 |
+
continue;
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
// If we reach here, all attempts failed without throwing an exception
|
| 245 |
+
throw new Error(
|
| 246 |
+
"Failed to validate PO token: all validation attempts returned non-200 status codes",
|
| 247 |
+
);
|
| 248 |
+
} catch (err) {
|
| 249 |
+
console.log("Failed to validate PO token using search method", { err });
|
| 250 |
+
throw err;
|
| 251 |
+
}
|
| 252 |
+
}
|
src/lib/jobs/worker.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference lib="webworker" />
|
| 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 |
+
import { PLAYER_ID } from "../../constants.ts";
|
| 10 |
+
let getFetchClientLocation = "getFetchClient";
|
| 11 |
+
if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) {
|
| 12 |
+
if (Deno.env.has("DENO_COMPILED")) {
|
| 13 |
+
getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") +
|
| 14 |
+
Deno.env.get("GET_FETCH_CLIENT_LOCATION");
|
| 15 |
+
} else {
|
| 16 |
+
getFetchClientLocation = Deno.env.get(
|
| 17 |
+
"GET_FETCH_CLIENT_LOCATION",
|
| 18 |
+
) as string;
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
type FetchFunction = typeof fetch;
|
| 23 |
+
const { getFetchClient }: {
|
| 24 |
+
getFetchClient: (config: Config) => Promise<FetchFunction>;
|
| 25 |
+
} = await import(getFetchClientLocation);
|
| 26 |
+
|
| 27 |
+
// ---- Messages to send to the webworker ----
|
| 28 |
+
const InputInitialiseSchema = z.object({
|
| 29 |
+
type: z.literal("initialise"),
|
| 30 |
+
config: ConfigSchema,
|
| 31 |
+
}).strict();
|
| 32 |
+
|
| 33 |
+
const InputContentTokenSchema = z.object({
|
| 34 |
+
type: z.literal("content-token-request"),
|
| 35 |
+
videoId: z.string(),
|
| 36 |
+
requestId: z.string().uuid(),
|
| 37 |
+
}).strict();
|
| 38 |
+
export type InputInitialise = z.infer<typeof InputInitialiseSchema>;
|
| 39 |
+
export type InputContentToken = z.infer<typeof InputContentTokenSchema>;
|
| 40 |
+
const InputMessageSchema = z.union([
|
| 41 |
+
InputInitialiseSchema,
|
| 42 |
+
InputContentTokenSchema,
|
| 43 |
+
]);
|
| 44 |
+
export type InputMessage = z.infer<typeof InputMessageSchema>;
|
| 45 |
+
|
| 46 |
+
// ---- Messages that the webworker sends to the parent ----
|
| 47 |
+
const OutputReadySchema = z.object({
|
| 48 |
+
type: z.literal("ready"),
|
| 49 |
+
}).strict();
|
| 50 |
+
|
| 51 |
+
const OutputInitialiseSchema = z.object({
|
| 52 |
+
type: z.literal("initialised"),
|
| 53 |
+
sessionPoToken: z.string(),
|
| 54 |
+
visitorData: z.string(),
|
| 55 |
+
}).strict();
|
| 56 |
+
|
| 57 |
+
const OutputContentTokenSchema = z.object({
|
| 58 |
+
type: z.literal("content-token"),
|
| 59 |
+
contentToken: z.string(),
|
| 60 |
+
requestId: InputContentTokenSchema.shape.requestId,
|
| 61 |
+
}).strict();
|
| 62 |
+
|
| 63 |
+
const OutputErrorSchema = z.object({
|
| 64 |
+
type: z.literal("error"),
|
| 65 |
+
error: z.any(),
|
| 66 |
+
}).strict();
|
| 67 |
+
export const OutputMessageSchema = z.union([
|
| 68 |
+
OutputReadySchema,
|
| 69 |
+
OutputInitialiseSchema,
|
| 70 |
+
OutputContentTokenSchema,
|
| 71 |
+
OutputErrorSchema,
|
| 72 |
+
]);
|
| 73 |
+
type OutputMessage = z.infer<typeof OutputMessageSchema>;
|
| 74 |
+
|
| 75 |
+
const IntegrityTokenResponse = z.tuple([z.string()]).rest(z.any());
|
| 76 |
+
|
| 77 |
+
const isWorker = typeof WorkerGlobalScope !== "undefined" &&
|
| 78 |
+
self instanceof WorkerGlobalScope;
|
| 79 |
+
if (isWorker) {
|
| 80 |
+
// helper function to force type-checking
|
| 81 |
+
const untypedPostmessage = self.postMessage.bind(self);
|
| 82 |
+
const postMessage = (message: OutputMessage) => {
|
| 83 |
+
untypedPostmessage(message);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
let minter: BG.WebPoMinter;
|
| 87 |
+
|
| 88 |
+
onmessage = async (event) => {
|
| 89 |
+
const message = InputMessageSchema.parse(event.data);
|
| 90 |
+
if (message.type === "initialise") {
|
| 91 |
+
const fetchImpl: typeof fetch = await getFetchClient(
|
| 92 |
+
message.config,
|
| 93 |
+
);
|
| 94 |
+
try {
|
| 95 |
+
const {
|
| 96 |
+
sessionPoToken,
|
| 97 |
+
visitorData,
|
| 98 |
+
generatedMinter,
|
| 99 |
+
} = await setup({
|
| 100 |
+
fetchImpl,
|
| 101 |
+
innertubeClientCookies:
|
| 102 |
+
message.config.youtube_session.cookies,
|
| 103 |
+
});
|
| 104 |
+
minter = generatedMinter;
|
| 105 |
+
postMessage({
|
| 106 |
+
type: "initialised",
|
| 107 |
+
sessionPoToken,
|
| 108 |
+
visitorData,
|
| 109 |
+
});
|
| 110 |
+
} catch (err) {
|
| 111 |
+
postMessage({ type: "error", error: err });
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
// this is called every time a video needs a content token
|
| 115 |
+
if (message.type === "content-token-request") {
|
| 116 |
+
if (!minter) {
|
| 117 |
+
throw new Error(
|
| 118 |
+
"Minter not yet ready, must initialise first",
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
const contentToken = await minter.mintAsWebsafeString(
|
| 122 |
+
message.videoId,
|
| 123 |
+
);
|
| 124 |
+
postMessage({
|
| 125 |
+
type: "content-token",
|
| 126 |
+
contentToken,
|
| 127 |
+
requestId: message.requestId,
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
postMessage({ type: "ready" });
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
async function setup(
|
| 136 |
+
{ fetchImpl, innertubeClientCookies }: {
|
| 137 |
+
fetchImpl: FetchFunction;
|
| 138 |
+
innertubeClientCookies: string;
|
| 139 |
+
},
|
| 140 |
+
) {
|
| 141 |
+
const innertubeClient = await Innertube.create({
|
| 142 |
+
enable_session_cache: false,
|
| 143 |
+
fetch: fetchImpl,
|
| 144 |
+
user_agent: USER_AGENT,
|
| 145 |
+
retrieve_player: false,
|
| 146 |
+
cookie: innertubeClientCookies || undefined,
|
| 147 |
+
player_id: PLAYER_ID,
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
const visitorData = innertubeClient.session.context.client.visitorData;
|
| 151 |
+
|
| 152 |
+
if (!visitorData) {
|
| 153 |
+
throw new Error("Could not get visitor data");
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const dom = new JSDOM(
|
| 157 |
+
'<!DOCTYPE html><html lang="en"><head><title></title></head><body></body></html>',
|
| 158 |
+
{
|
| 159 |
+
url: "https://www.youtube.com/",
|
| 160 |
+
referrer: "https://www.youtube.com/",
|
| 161 |
+
userAgent: USER_AGENT,
|
| 162 |
+
},
|
| 163 |
+
);
|
| 164 |
+
|
| 165 |
+
Object.assign(globalThis, {
|
| 166 |
+
window: dom.window,
|
| 167 |
+
document: dom.window.document,
|
| 168 |
+
// location: dom.window.location, // --- doesn't seem to be necessary and the Web Worker doesn't like it
|
| 169 |
+
origin: dom.window.origin,
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
if (!Reflect.has(globalThis, "navigator")) {
|
| 173 |
+
Object.defineProperty(globalThis, "navigator", {
|
| 174 |
+
value: dom.window.navigator,
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const challengeResponse = await innertubeClient.getAttestationChallenge(
|
| 179 |
+
"ENGAGEMENT_TYPE_UNBOUND",
|
| 180 |
+
);
|
| 181 |
+
if (!challengeResponse.bg_challenge) {
|
| 182 |
+
throw new Error("Could not get challenge");
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// Mock HTMLCanvasElement.prototype.getContext to silence "Not implemented" error
|
| 186 |
+
// and prevent unnecessary noise in logs.
|
| 187 |
+
if (dom.window.HTMLCanvasElement) {
|
| 188 |
+
dom.window.HTMLCanvasElement.prototype.getContext = ((
|
| 189 |
+
_contextId: string,
|
| 190 |
+
_options?: any,
|
| 191 |
+
) => {
|
| 192 |
+
return new Proxy({}, {
|
| 193 |
+
get: (_target, _prop) => {
|
| 194 |
+
return () => { };
|
| 195 |
+
},
|
| 196 |
+
});
|
| 197 |
+
}) as any;
|
| 198 |
+
dom.window.HTMLCanvasElement.prototype.toDataURL = () => "";
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
const interpreterUrl = challengeResponse.bg_challenge.interpreter_url
|
| 202 |
+
.private_do_not_access_or_else_trusted_resource_url_wrapped_value;
|
| 203 |
+
const bgScriptResponse = await fetchImpl(
|
| 204 |
+
`https:${interpreterUrl}`,
|
| 205 |
+
);
|
| 206 |
+
const interpreterJavascript = await bgScriptResponse.text();
|
| 207 |
+
|
| 208 |
+
if (interpreterJavascript) {
|
| 209 |
+
new Function(interpreterJavascript)();
|
| 210 |
+
} else throw new Error("Could not load VM");
|
| 211 |
+
const botguard = await BG.BotGuardClient.create({
|
| 212 |
+
program: challengeResponse.bg_challenge.program,
|
| 213 |
+
globalName: challengeResponse.bg_challenge.global_name,
|
| 214 |
+
globalObj: globalThis,
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
const webPoSignalOutput: WebPoSignalOutput = [];
|
| 218 |
+
const botguardResponse = await botguard.snapshot({ webPoSignalOutput });
|
| 219 |
+
const requestKey = "O43z0dpjhgX20SCx4KAo";
|
| 220 |
+
|
| 221 |
+
const integrityTokenResponse = await fetchImpl(
|
| 222 |
+
buildURL("GenerateIT", true),
|
| 223 |
+
{
|
| 224 |
+
method: "POST",
|
| 225 |
+
headers: {
|
| 226 |
+
"content-type": "application/json+protobuf",
|
| 227 |
+
"x-goog-api-key": GOOG_API_KEY,
|
| 228 |
+
"x-user-agent": "grpc-web-javascript/0.1",
|
| 229 |
+
"user-agent": USER_AGENT,
|
| 230 |
+
},
|
| 231 |
+
body: JSON.stringify([requestKey, botguardResponse]),
|
| 232 |
+
},
|
| 233 |
+
);
|
| 234 |
+
const integrityTokenBody = IntegrityTokenResponse.parse(
|
| 235 |
+
await integrityTokenResponse.json(),
|
| 236 |
+
);
|
| 237 |
+
|
| 238 |
+
const integrityTokenBasedMinter = await BG.WebPoMinter.create({
|
| 239 |
+
integrityToken: integrityTokenBody[0],
|
| 240 |
+
}, webPoSignalOutput);
|
| 241 |
+
|
| 242 |
+
const sessionPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(
|
| 243 |
+
visitorData,
|
| 244 |
+
);
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
sessionPoToken,
|
| 248 |
+
visitorData,
|
| 249 |
+
generatedMinter: integrityTokenBasedMinter,
|
| 250 |
+
};
|
| 251 |
+
}
|
src/lib/types/HonoVariables.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 | undefined;
|
| 10 |
+
metrics: Metrics | undefined;
|
| 11 |
+
};
|
src/main.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Hono } from "hono";
|
| 2 |
+
import { companionRoutes, miscRoutes } from "./routes/index.ts";
|
| 3 |
+
import { Innertube, Platform } 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 |
+
import { existsSync } from "@std/fs/exists";
|
| 10 |
+
|
| 11 |
+
import { parseConfig } from "./lib/helpers/config.ts";
|
| 12 |
+
const config = await parseConfig();
|
| 13 |
+
import { Metrics } from "./lib/helpers/metrics.ts";
|
| 14 |
+
import { PLAYER_ID } from "./constants.ts";
|
| 15 |
+
import { jsInterpreter } from "./lib/helpers/jsInterpreter.ts";
|
| 16 |
+
import {
|
| 17 |
+
initProxyManager,
|
| 18 |
+
markProxyFailed,
|
| 19 |
+
isProxyManagerReady,
|
| 20 |
+
} from "./lib/helpers/proxyManager.ts";
|
| 21 |
+
|
| 22 |
+
// Initialize auto proxy manager if enabled
|
| 23 |
+
if (config.networking.auto_proxy) {
|
| 24 |
+
console.log("[INFO] Auto proxy is enabled, initializing proxy manager...");
|
| 25 |
+
try {
|
| 26 |
+
await initProxyManager(config.networking.vpn_source);
|
| 27 |
+
} catch (err) {
|
| 28 |
+
console.error("[ERROR] Failed to initialize proxy manager:", err);
|
| 29 |
+
console.log("[WARN] Continuing without auto proxy...");
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const args = parseArgs(Deno.args);
|
| 34 |
+
|
| 35 |
+
if (args._version_date && args._version_commit) {
|
| 36 |
+
console.log(
|
| 37 |
+
`[INFO] Using Invidious companion version ${args._version_date}-${args._version_commit}`,
|
| 38 |
+
);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
let getFetchClientLocation = "getFetchClient";
|
| 42 |
+
if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) {
|
| 43 |
+
if (Deno.env.has("DENO_COMPILED")) {
|
| 44 |
+
getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") +
|
| 45 |
+
Deno.env.get("GET_FETCH_CLIENT_LOCATION");
|
| 46 |
+
} else {
|
| 47 |
+
getFetchClientLocation = Deno.env.get(
|
| 48 |
+
"GET_FETCH_CLIENT_LOCATION",
|
| 49 |
+
) as string;
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
const { getFetchClient } = await import(getFetchClientLocation);
|
| 53 |
+
|
| 54 |
+
declare module "hono" {
|
| 55 |
+
interface ContextVariableMap extends HonoVariables { }
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const app = new Hono({
|
| 59 |
+
getPath: (req) => new URL(req.url).pathname,
|
| 60 |
+
});
|
| 61 |
+
const companionApp = new Hono({
|
| 62 |
+
getPath: (req) => new URL(req.url).pathname,
|
| 63 |
+
}).basePath(config.server.base_path);
|
| 64 |
+
const metrics = config.server.enable_metrics ? new Metrics() : undefined;
|
| 65 |
+
|
| 66 |
+
let tokenMinter: TokenMinter | undefined;
|
| 67 |
+
let innertubeClient: Innertube;
|
| 68 |
+
let innertubeClientFetchPlayer = true;
|
| 69 |
+
const innertubeClientOauthEnabled = config.youtube_session.oauth_enabled;
|
| 70 |
+
const innertubeClientJobPoTokenEnabled =
|
| 71 |
+
config.jobs.youtube_session.po_token_enabled;
|
| 72 |
+
const innertubeClientCookies = config.youtube_session.cookies;
|
| 73 |
+
|
| 74 |
+
// Promise that resolves when tokenMinter initialization is complete (for tests)
|
| 75 |
+
let tokenMinterReadyResolve: (() => void) | undefined;
|
| 76 |
+
export const tokenMinterReady = new Promise<void>((resolve) => {
|
| 77 |
+
tokenMinterReadyResolve = resolve;
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
if (!innertubeClientOauthEnabled) {
|
| 81 |
+
if (innertubeClientJobPoTokenEnabled) {
|
| 82 |
+
console.log("[INFO] job po_token is active.");
|
| 83 |
+
// Don't fetch fetch player yet for po_token
|
| 84 |
+
innertubeClientFetchPlayer = false;
|
| 85 |
+
} else if (!innertubeClientJobPoTokenEnabled) {
|
| 86 |
+
console.log("[INFO] job po_token is NOT active.");
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
Platform.shim.eval = jsInterpreter;
|
| 91 |
+
|
| 92 |
+
innertubeClient = await Innertube.create({
|
| 93 |
+
enable_session_cache: false,
|
| 94 |
+
retrieve_player: innertubeClientFetchPlayer,
|
| 95 |
+
fetch: getFetchClient(config),
|
| 96 |
+
cookie: innertubeClientCookies || undefined,
|
| 97 |
+
user_agent: USER_AGENT,
|
| 98 |
+
player_id: PLAYER_ID,
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
if (!innertubeClientOauthEnabled) {
|
| 102 |
+
if (innertubeClientJobPoTokenEnabled) {
|
| 103 |
+
// Initialize tokenMinter in background to not block server startup
|
| 104 |
+
console.log("[INFO] Starting PO token generation in background...");
|
| 105 |
+
|
| 106 |
+
// Wrapper function that rotates proxy on failure when auto_proxy is enabled
|
| 107 |
+
const poTokenGenerateWithProxyRotation = async () => {
|
| 108 |
+
try {
|
| 109 |
+
return await poTokenGenerate(config, metrics);
|
| 110 |
+
} catch (err) {
|
| 111 |
+
// If auto_proxy is enabled and PO token generation failed, rotate to a new proxy
|
| 112 |
+
if (config.networking.auto_proxy) {
|
| 113 |
+
console.log(
|
| 114 |
+
"[INFO] PO token generation failed, rotating to new proxy...",
|
| 115 |
+
);
|
| 116 |
+
await markProxyFailed();
|
| 117 |
+
}
|
| 118 |
+
throw err; // Re-throw to trigger retry
|
| 119 |
+
}
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
retry(
|
| 123 |
+
poTokenGenerateWithProxyRotation,
|
| 124 |
+
{ minTimeout: 1_000, maxTimeout: 60_000, multiplier: 5, jitter: 0 },
|
| 125 |
+
).then((result) => {
|
| 126 |
+
innertubeClient = result.innertubeClient;
|
| 127 |
+
tokenMinter = result.tokenMinter;
|
| 128 |
+
tokenMinterReadyResolve?.();
|
| 129 |
+
}).catch((err) => {
|
| 130 |
+
console.error("[ERROR] Failed to initialize PO token:", err);
|
| 131 |
+
metrics?.potokenGenerationFailure.inc();
|
| 132 |
+
tokenMinterReadyResolve?.();
|
| 133 |
+
});
|
| 134 |
+
} else {
|
| 135 |
+
// If PO token is not enabled, resolve immediately
|
| 136 |
+
tokenMinterReadyResolve?.();
|
| 137 |
+
}
|
| 138 |
+
// Resolve promise for tests
|
| 139 |
+
tokenMinterReadyResolve?.();
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const regenerateSession = async () => {
|
| 143 |
+
if (innertubeClientJobPoTokenEnabled) {
|
| 144 |
+
try {
|
| 145 |
+
({ innertubeClient, tokenMinter } = await poTokenGenerate(
|
| 146 |
+
config,
|
| 147 |
+
metrics,
|
| 148 |
+
));
|
| 149 |
+
} catch (err) {
|
| 150 |
+
metrics?.potokenGenerationFailure.inc();
|
| 151 |
+
// If auto_proxy is enabled and PO token generation failed, rotate to a new proxy
|
| 152 |
+
if (config.networking.auto_proxy) {
|
| 153 |
+
console.log(
|
| 154 |
+
"[INFO] Session regeneration failed, rotating to new proxy...",
|
| 155 |
+
);
|
| 156 |
+
await markProxyFailed();
|
| 157 |
+
}
|
| 158 |
+
// Don't rethrow for cron/manual trigger to avoid crashing the server loop
|
| 159 |
+
console.error("[ERROR] Failed to regenerate session:", err);
|
| 160 |
+
}
|
| 161 |
+
} else {
|
| 162 |
+
innertubeClient = await Innertube.create({
|
| 163 |
+
enable_session_cache: false,
|
| 164 |
+
fetch: getFetchClient(config),
|
| 165 |
+
retrieve_player: innertubeClientFetchPlayer,
|
| 166 |
+
user_agent: USER_AGENT,
|
| 167 |
+
cookie: innertubeClientCookies || undefined,
|
| 168 |
+
player_id: PLAYER_ID,
|
| 169 |
+
});
|
| 170 |
+
}
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
if (!innertubeClientOauthEnabled) {
|
| 174 |
+
Deno.cron(
|
| 175 |
+
"regenerate youtube session",
|
| 176 |
+
config.jobs.youtube_session.frequency,
|
| 177 |
+
{ backoffSchedule: [5_000, 15_000, 60_000, 180_000] },
|
| 178 |
+
regenerateSession,
|
| 179 |
+
);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
companionApp.use("*", async (c, next) => {
|
| 183 |
+
c.set("innertubeClient", innertubeClient);
|
| 184 |
+
c.set("tokenMinter", tokenMinter);
|
| 185 |
+
c.set("config", config);
|
| 186 |
+
c.set("metrics", metrics);
|
| 187 |
+
await next();
|
| 188 |
+
});
|
| 189 |
+
companionRoutes(companionApp, config);
|
| 190 |
+
|
| 191 |
+
app.use("*", async (c, next) => {
|
| 192 |
+
c.set("metrics", metrics);
|
| 193 |
+
await next();
|
| 194 |
+
});
|
| 195 |
+
miscRoutes(app, config, regenerateSession);
|
| 196 |
+
|
| 197 |
+
app.route("/", companionApp);
|
| 198 |
+
|
| 199 |
+
// This cannot be changed since companion restricts the
|
| 200 |
+
// files it can access using deno `--allow-write` argument
|
| 201 |
+
const udsPath = config.server.unix_socket_path;
|
| 202 |
+
|
| 203 |
+
export function run(signal: AbortSignal, port: number, hostname: string) {
|
| 204 |
+
if (config.server.use_unix_socket) {
|
| 205 |
+
try {
|
| 206 |
+
if (existsSync(udsPath)) {
|
| 207 |
+
// Delete the unix domain socket manually before starting the server
|
| 208 |
+
Deno.removeSync(udsPath);
|
| 209 |
+
}
|
| 210 |
+
} catch (err) {
|
| 211 |
+
console.log(
|
| 212 |
+
`[ERROR] Failed to delete unix domain socket '${udsPath}' before starting the server:`,
|
| 213 |
+
err,
|
| 214 |
+
);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const srv = Deno.serve(
|
| 218 |
+
{
|
| 219 |
+
onListen() {
|
| 220 |
+
Deno.chmodSync(udsPath, 0o777);
|
| 221 |
+
console.log(
|
| 222 |
+
`[INFO] Server successfully started at ${udsPath} with permissions set to 777.`,
|
| 223 |
+
);
|
| 224 |
+
},
|
| 225 |
+
signal: signal,
|
| 226 |
+
path: udsPath,
|
| 227 |
+
},
|
| 228 |
+
app.fetch,
|
| 229 |
+
);
|
| 230 |
+
|
| 231 |
+
return srv;
|
| 232 |
+
} else {
|
| 233 |
+
return Deno.serve(
|
| 234 |
+
{
|
| 235 |
+
onListen() {
|
| 236 |
+
console.log(
|
| 237 |
+
`[INFO] Server successfully started at http://${config.server.host}:${config.server.port}${config.server.base_path}`,
|
| 238 |
+
);
|
| 239 |
+
},
|
| 240 |
+
signal: signal,
|
| 241 |
+
port: port,
|
| 242 |
+
hostname: hostname,
|
| 243 |
+
},
|
| 244 |
+
app.fetch,
|
| 245 |
+
);
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
if (import.meta.main) {
|
| 249 |
+
const controller = new AbortController();
|
| 250 |
+
const { signal } = controller;
|
| 251 |
+
run(signal, config.server.port, config.server.host);
|
| 252 |
+
|
| 253 |
+
Deno.addSignalListener("SIGTERM", () => {
|
| 254 |
+
console.log("Caught SIGINT, shutting down...");
|
| 255 |
+
controller.abort();
|
| 256 |
+
Deno.exit(0);
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
Deno.addSignalListener("SIGINT", () => {
|
| 260 |
+
console.log("Caught SIGINT, shutting down...");
|
| 261 |
+
controller.abort();
|
| 262 |
+
Deno.exit(0);
|
| 263 |
+
});
|
| 264 |
+
}
|
src/routes/health.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
src/routes/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 invidiousVideosApi from "./invidious_routes/videos.ts";
|
| 10 |
+
import invidiousSearchApi from "./invidious_routes/search.ts";
|
| 11 |
+
import invidiousChannelsApi from "./invidious_routes/channels.ts";
|
| 12 |
+
import invidiousPlaylistsApi from "./invidious_routes/playlists.ts";
|
| 13 |
+
import invidiousMixesApi from "./invidious_routes/mixes.ts";
|
| 14 |
+
|
| 15 |
+
import getDownloadHandler from "./invidious_routes/download.ts";
|
| 16 |
+
import videoPlaybackProxy from "./videoPlaybackProxy.ts";
|
| 17 |
+
import type { Config } from "../lib/helpers/config.ts";
|
| 18 |
+
import metrics from "./metrics.ts";
|
| 19 |
+
import health from "./health.ts";
|
| 20 |
+
|
| 21 |
+
export const companionRoutes = (
|
| 22 |
+
app: Hono,
|
| 23 |
+
config: Config,
|
| 24 |
+
) => {
|
| 25 |
+
const loggerUnixSocket = (message: string, ...rest: string[]) => {
|
| 26 |
+
message = message.replace("//localhost/", "/");
|
| 27 |
+
console.log(message, ...rest);
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
if (config.server.use_unix_socket) {
|
| 31 |
+
app.use("*", logger(loggerUnixSocket));
|
| 32 |
+
} else {
|
| 33 |
+
app.use("*", logger());
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
app.use(
|
| 37 |
+
"/youtubei/v1/*",
|
| 38 |
+
bearerAuth({
|
| 39 |
+
token: config.server.secret_key,
|
| 40 |
+
}),
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
app.route("/youtubei/v1", youtubeApiPlayer);
|
| 44 |
+
|
| 45 |
+
app.get("/", (c) => {
|
| 46 |
+
return c.text("(this is not actual invidious its just designed to be used in place of it a custom invidious based on invidious-companion)");
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
app.route("/latest_version", invidiousRouteLatestVersion);
|
| 50 |
+
// Needs app for app.request in order to call /latest_version endpoint
|
| 51 |
+
app.post("/download", getDownloadHandler(app));
|
| 52 |
+
app.route("/api/manifest/dash/id", invidiousRouteDashManifest);
|
| 53 |
+
app.route("/api/v1/captions", invidiousCaptionsApi);
|
| 54 |
+
app.route("/api/v1/videos", invidiousVideosApi);
|
| 55 |
+
app.route("/api/v1/search", invidiousSearchApi);
|
| 56 |
+
app.route("/api/v1/channels", invidiousChannelsApi);
|
| 57 |
+
app.route("/api/v1/playlists", invidiousPlaylistsApi);
|
| 58 |
+
app.route("/api/v1/mixes", invidiousMixesApi);
|
| 59 |
+
|
| 60 |
+
app.route("/videoplayback", videoPlaybackProxy);
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
export const miscRoutes = (
|
| 64 |
+
app: Hono,
|
| 65 |
+
config: Config,
|
| 66 |
+
regenerateSession?: () => Promise<void>,
|
| 67 |
+
) => {
|
| 68 |
+
app.route("/healthz", health);
|
| 69 |
+
if (config.server.enable_metrics) {
|
| 70 |
+
app.route("/metrics", metrics);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
app.get("/api/set/proxy/:proxy", async (c) => {
|
| 74 |
+
let proxy = c.req.param("proxy");
|
| 75 |
+
if (proxy) {
|
| 76 |
+
proxy = decodeURIComponent(proxy);
|
| 77 |
+
config.networking.proxy = proxy;
|
| 78 |
+
console.log(`[INFO] Proxy updated to: ${proxy}`);
|
| 79 |
+
|
| 80 |
+
if (regenerateSession) {
|
| 81 |
+
console.log("[INFO] Triggering session regeneration...");
|
| 82 |
+
await regenerateSession();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
return c.text(`Proxy updated to: ${proxy}`);
|
| 86 |
+
}
|
| 87 |
+
return c.text("Invalid proxy", 400);
|
| 88 |
+
});
|
| 89 |
+
};
|
src/routes/invidious_routes/captions.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
import { validateVideoId } from "../../lib/helpers/validateVideoId.ts";
|
| 12 |
+
import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts";
|
| 13 |
+
|
| 14 |
+
interface AvailableCaption {
|
| 15 |
+
label: string;
|
| 16 |
+
languageCode: string;
|
| 17 |
+
url: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const captionsHandler = new Hono<{ Variables: HonoVariables }>();
|
| 21 |
+
captionsHandler.get("/:videoId", async (c) => {
|
| 22 |
+
const { videoId } = c.req.param();
|
| 23 |
+
const config = c.get("config");
|
| 24 |
+
const metrics = c.get("metrics");
|
| 25 |
+
const tokenMinter = c.get("tokenMinter");
|
| 26 |
+
|
| 27 |
+
const check = c.req.query("check");
|
| 28 |
+
|
| 29 |
+
if (!validateVideoId(videoId)) {
|
| 30 |
+
throw new HTTPException(400, {
|
| 31 |
+
res: new Response("Invalid video ID format."),
|
| 32 |
+
});
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Check if tokenMinter is ready (only needed when PO token is enabled)
|
| 36 |
+
if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) {
|
| 37 |
+
throw new HTTPException(503, {
|
| 38 |
+
res: new Response(TOKEN_MINTER_NOT_READY_MESSAGE),
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (config.server.verify_requests && check == undefined) {
|
| 43 |
+
throw new HTTPException(400, {
|
| 44 |
+
res: new Response("No check ID."),
|
| 45 |
+
});
|
| 46 |
+
} else if (config.server.verify_requests && check) {
|
| 47 |
+
if (verifyRequest(check, videoId, config) === false) {
|
| 48 |
+
throw new HTTPException(400, {
|
| 49 |
+
res: new Response("ID incorrect."),
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const innertubeClient = c.get("innertubeClient");
|
| 55 |
+
|
| 56 |
+
const youtubePlayerResponseJson = await youtubePlayerParsing({
|
| 57 |
+
innertubeClient,
|
| 58 |
+
videoId,
|
| 59 |
+
config,
|
| 60 |
+
metrics,
|
| 61 |
+
tokenMinter: tokenMinter!,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
const videoInfo = youtubeVideoInfo(
|
| 65 |
+
innertubeClient,
|
| 66 |
+
youtubePlayerResponseJson,
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
const captionsTrackArray = videoInfo.captions?.caption_tracks;
|
| 70 |
+
if (captionsTrackArray == undefined) throw new HTTPException(404);
|
| 71 |
+
|
| 72 |
+
const label = c.req.query("label");
|
| 73 |
+
const lang = c.req.query("lang");
|
| 74 |
+
|
| 75 |
+
// Show all available captions when a specific one is not selected
|
| 76 |
+
if (label == undefined && lang == undefined) {
|
| 77 |
+
const invidiousAvailableCaptionsArr: AvailableCaption[] = [];
|
| 78 |
+
|
| 79 |
+
for (const caption_track of captionsTrackArray) {
|
| 80 |
+
invidiousAvailableCaptionsArr.push({
|
| 81 |
+
label: caption_track.name.text || "",
|
| 82 |
+
languageCode: caption_track.language_code,
|
| 83 |
+
url: `${config.server.base_path}/api/v1/captions/${videoId}?label=${
|
| 84 |
+
encodeURIComponent(caption_track.name.text || "")
|
| 85 |
+
}`,
|
| 86 |
+
});
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
return c.json({ captions: invidiousAvailableCaptionsArr });
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Extract selected caption
|
| 93 |
+
let filterSelected: CaptionTrackData[];
|
| 94 |
+
|
| 95 |
+
if (lang) {
|
| 96 |
+
filterSelected = captionsTrackArray.filter((c: CaptionTrackData) =>
|
| 97 |
+
c.language_code === lang
|
| 98 |
+
);
|
| 99 |
+
} else {
|
| 100 |
+
filterSelected = captionsTrackArray.filter((c: CaptionTrackData) =>
|
| 101 |
+
c.name.text === label
|
| 102 |
+
);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
if (filterSelected.length == 0) throw new HTTPException(404);
|
| 106 |
+
|
| 107 |
+
c.header("Content-Type", "text/vtt; charset=UTF-8");
|
| 108 |
+
c.header("Access-Control-Allow-Origin", "*");
|
| 109 |
+
return c.body(
|
| 110 |
+
await handleTranscripts(innertubeClient, videoId, filterSelected[0]),
|
| 111 |
+
);
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
export default captionsHandler;
|
src/routes/invidious_routes/channels.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Hono } from "hono";
|
| 2 |
+
import { HTTPException } from "hono/http-exception";
|
| 3 |
+
import type { Innertube } from "youtubei.js";
|
| 4 |
+
|
| 5 |
+
const channels = new Hono();
|
| 6 |
+
|
| 7 |
+
interface Thumbnail {
|
| 8 |
+
url: string;
|
| 9 |
+
width: number;
|
| 10 |
+
height: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface VideoThumbnail {
|
| 14 |
+
quality: string;
|
| 15 |
+
url: string;
|
| 16 |
+
width: number;
|
| 17 |
+
height: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface LatestVideo {
|
| 21 |
+
type: string;
|
| 22 |
+
title: string;
|
| 23 |
+
videoId: string;
|
| 24 |
+
author: string;
|
| 25 |
+
authorId: string;
|
| 26 |
+
authorUrl: string;
|
| 27 |
+
authorVerified: boolean;
|
| 28 |
+
videoThumbnails: VideoThumbnail[];
|
| 29 |
+
description: string;
|
| 30 |
+
descriptionHtml: string;
|
| 31 |
+
viewCount: number;
|
| 32 |
+
viewCountText: string;
|
| 33 |
+
published: number;
|
| 34 |
+
publishedText: string;
|
| 35 |
+
lengthSeconds: number;
|
| 36 |
+
liveNow: boolean;
|
| 37 |
+
premium: boolean;
|
| 38 |
+
isUpcoming: boolean;
|
| 39 |
+
isNew: boolean;
|
| 40 |
+
is4k: boolean;
|
| 41 |
+
is8k: boolean;
|
| 42 |
+
isVr180: boolean;
|
| 43 |
+
isVr360: boolean;
|
| 44 |
+
is3d: boolean;
|
| 45 |
+
hasCaptions: boolean;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
interface ChannelResponse {
|
| 49 |
+
author: string;
|
| 50 |
+
authorId: string;
|
| 51 |
+
authorUrl: string;
|
| 52 |
+
authorBanners: Thumbnail[];
|
| 53 |
+
authorThumbnails: Thumbnail[];
|
| 54 |
+
subCount: number;
|
| 55 |
+
totalViews: number;
|
| 56 |
+
joined: number;
|
| 57 |
+
autoGenerated: boolean;
|
| 58 |
+
ageGated: boolean;
|
| 59 |
+
isFamilyFriendly: boolean;
|
| 60 |
+
description: string;
|
| 61 |
+
descriptionHtml: string;
|
| 62 |
+
allowedRegions: string[];
|
| 63 |
+
tabs: string[];
|
| 64 |
+
tags: string[];
|
| 65 |
+
authorVerified: boolean;
|
| 66 |
+
latestVideos: LatestVideo[];
|
| 67 |
+
relatedChannels: any[];
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// Helper to parse subscriber count text to number
|
| 71 |
+
function parseSubCount(text: string | undefined): number {
|
| 72 |
+
if (!text) return 0;
|
| 73 |
+
const cleanText = text.replace(/subscribers?/i, "").trim();
|
| 74 |
+
const multipliers: { [key: string]: number } = {
|
| 75 |
+
'K': 1000,
|
| 76 |
+
'M': 1000000,
|
| 77 |
+
'B': 1000000000
|
| 78 |
+
};
|
| 79 |
+
const match = cleanText.match(/([\d.]+)\s*([KMB])?/i);
|
| 80 |
+
if (match) {
|
| 81 |
+
const num = parseFloat(match[1]);
|
| 82 |
+
const suffix = match[2]?.toUpperCase();
|
| 83 |
+
return suffix ? Math.round(num * multipliers[suffix]) : Math.round(num);
|
| 84 |
+
}
|
| 85 |
+
return parseInt(cleanText.replace(/[,\s]/g, "")) || 0;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Helper to parse view count text
|
| 89 |
+
function parseViewCount(text: string | undefined): number {
|
| 90 |
+
if (!text) return 0;
|
| 91 |
+
const match = text.match(/([\d,]+)/);
|
| 92 |
+
if (match) {
|
| 93 |
+
return parseInt(match[1].replace(/,/g, "")) || 0;
|
| 94 |
+
}
|
| 95 |
+
return 0;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Helper to generate video thumbnails
|
| 99 |
+
function generateVideoThumbnails(videoId: string): VideoThumbnail[] {
|
| 100 |
+
const baseUrl = "https://i.ytimg.com";
|
| 101 |
+
return [
|
| 102 |
+
{ quality: "maxres", url: `${baseUrl}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 },
|
| 103 |
+
{ quality: "sddefault", url: `${baseUrl}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 },
|
| 104 |
+
{ quality: "high", url: `${baseUrl}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 },
|
| 105 |
+
{ quality: "medium", url: `${baseUrl}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 },
|
| 106 |
+
{ quality: "default", url: `${baseUrl}/vi/${videoId}/default.jpg`, width: 120, height: 90 },
|
| 107 |
+
];
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// GET /:channelId - Get channel information
|
| 111 |
+
channels.get("/:channelId", async (c) => {
|
| 112 |
+
const channelId = c.req.param("channelId");
|
| 113 |
+
const innertubeClient = c.get("innertubeClient") as Innertube;
|
| 114 |
+
|
| 115 |
+
if (!channelId) {
|
| 116 |
+
throw new HTTPException(400, { message: "Channel ID is required" });
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
console.log(`[INFO] Fetching channel: ${channelId}`);
|
| 120 |
+
|
| 121 |
+
try {
|
| 122 |
+
const channel = await innertubeClient.getChannel(channelId);
|
| 123 |
+
|
| 124 |
+
// Extract channel metadata
|
| 125 |
+
const metadata = channel.metadata as any;
|
| 126 |
+
const header = channel.header as any;
|
| 127 |
+
const headerContent = header?.content;
|
| 128 |
+
|
| 129 |
+
// Parse banners from header.content.banner.image
|
| 130 |
+
const authorBanners: Thumbnail[] = [];
|
| 131 |
+
if (headerContent?.banner?.image && Array.isArray(headerContent.banner.image)) {
|
| 132 |
+
for (const img of headerContent.banner.image) {
|
| 133 |
+
authorBanners.push({
|
| 134 |
+
url: img.url,
|
| 135 |
+
width: img.width || 0,
|
| 136 |
+
height: img.height || 0
|
| 137 |
+
});
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// Parse thumbnails from header.content.image.avatar.image
|
| 142 |
+
const authorThumbnails: Thumbnail[] = [];
|
| 143 |
+
if (headerContent?.image?.avatar?.image && Array.isArray(headerContent.image.avatar.image)) {
|
| 144 |
+
for (const img of headerContent.image.avatar.image) {
|
| 145 |
+
authorThumbnails.push({
|
| 146 |
+
url: img.url,
|
| 147 |
+
width: img.width || 0,
|
| 148 |
+
height: img.height || 0
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
// Fallback: try metadata.thumbnail
|
| 153 |
+
if (authorThumbnails.length === 0 && metadata?.thumbnail && Array.isArray(metadata.thumbnail)) {
|
| 154 |
+
for (const img of metadata.thumbnail) {
|
| 155 |
+
authorThumbnails.push({
|
| 156 |
+
url: img.url,
|
| 157 |
+
width: img.width || 0,
|
| 158 |
+
height: img.height || 0
|
| 159 |
+
});
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// Parse subscriber count from header.content.metadata.metadata_rows
|
| 164 |
+
let subscriberText = "";
|
| 165 |
+
if (headerContent?.metadata?.metadata_rows) {
|
| 166 |
+
for (const row of headerContent.metadata.metadata_rows) {
|
| 167 |
+
if (row.metadata_parts) {
|
| 168 |
+
for (const part of row.metadata_parts) {
|
| 169 |
+
const text = part.text?.text || "";
|
| 170 |
+
if (text.includes("subscriber")) {
|
| 171 |
+
subscriberText = text;
|
| 172 |
+
break;
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
if (subscriberText) break;
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// Get latest videos
|
| 181 |
+
const latestVideos: LatestVideo[] = [];
|
| 182 |
+
const videosTab = channel.has_videos ? await channel.getVideos() : null;
|
| 183 |
+
if (videosTab?.videos) {
|
| 184 |
+
for (const video of videosTab.videos.slice(0, 20)) { // Limit to 20 videos
|
| 185 |
+
const v = video as any;
|
| 186 |
+
latestVideos.push({
|
| 187 |
+
type: "video",
|
| 188 |
+
title: v.title?.text || "",
|
| 189 |
+
videoId: v.id || "",
|
| 190 |
+
author: metadata?.title || "",
|
| 191 |
+
authorId: channelId,
|
| 192 |
+
authorUrl: `/channel/${channelId}`,
|
| 193 |
+
authorVerified: metadata?.is_verified || false,
|
| 194 |
+
videoThumbnails: generateVideoThumbnails(v.id || ""),
|
| 195 |
+
description: v.description_snippet?.text || "",
|
| 196 |
+
descriptionHtml: v.description_snippet?.text || "",
|
| 197 |
+
viewCount: parseViewCount(v.view_count?.text),
|
| 198 |
+
viewCountText: v.view_count?.text || "",
|
| 199 |
+
published: 0, // Timestamp not easily available
|
| 200 |
+
publishedText: v.published?.text || "",
|
| 201 |
+
lengthSeconds: v.duration?.seconds || 0,
|
| 202 |
+
liveNow: v.is_live || false,
|
| 203 |
+
premium: false,
|
| 204 |
+
isUpcoming: v.is_upcoming || false,
|
| 205 |
+
isNew: false,
|
| 206 |
+
is4k: false,
|
| 207 |
+
is8k: false,
|
| 208 |
+
isVr180: false,
|
| 209 |
+
isVr360: false,
|
| 210 |
+
is3d: false,
|
| 211 |
+
hasCaptions: false
|
| 212 |
+
});
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// Get tabs
|
| 217 |
+
const tabs: string[] = [];
|
| 218 |
+
const channelAny = channel as any;
|
| 219 |
+
if (channelAny.has_home) tabs.push("home");
|
| 220 |
+
if (channelAny.has_videos) tabs.push("videos");
|
| 221 |
+
if (channelAny.has_shorts) tabs.push("shorts");
|
| 222 |
+
if (channelAny.has_live) tabs.push("live");
|
| 223 |
+
if (channelAny.has_playlists) tabs.push("playlists");
|
| 224 |
+
if (channelAny.has_community) tabs.push("community");
|
| 225 |
+
|
| 226 |
+
const response: ChannelResponse = {
|
| 227 |
+
author: metadata?.title || "",
|
| 228 |
+
authorId: channelId,
|
| 229 |
+
authorUrl: `/channel/${channelId}`,
|
| 230 |
+
authorBanners: authorBanners,
|
| 231 |
+
authorThumbnails: authorThumbnails,
|
| 232 |
+
subCount: parseSubCount(subscriberText),
|
| 233 |
+
totalViews: 0, // Not easily available
|
| 234 |
+
joined: 0, // Not easily available
|
| 235 |
+
autoGenerated: false,
|
| 236 |
+
ageGated: false,
|
| 237 |
+
isFamilyFriendly: true,
|
| 238 |
+
description: metadata?.description || "",
|
| 239 |
+
descriptionHtml: metadata?.description || "",
|
| 240 |
+
allowedRegions: [],
|
| 241 |
+
tabs: tabs,
|
| 242 |
+
tags: metadata?.keywords || [],
|
| 243 |
+
authorVerified: metadata?.is_verified || false,
|
| 244 |
+
latestVideos: latestVideos,
|
| 245 |
+
relatedChannels: []
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
return c.json(response);
|
| 249 |
+
} catch (error) {
|
| 250 |
+
console.error(`[ERROR] Failed to fetch channel ${channelId}:`, error);
|
| 251 |
+
throw new HTTPException(500, { message: `Failed to fetch channel: ${error}` });
|
| 252 |
+
}
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
export default channels;
|
src/routes/invidious_routes/dashManifest.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
import { validateVideoId } from "../../lib/helpers/validateVideoId.ts";
|
| 11 |
+
import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts";
|
| 12 |
+
|
| 13 |
+
const dashManifest = new Hono();
|
| 14 |
+
|
| 15 |
+
dashManifest.get("/:videoId", async (c) => {
|
| 16 |
+
const { videoId } = c.req.param();
|
| 17 |
+
const { check, local } = c.req.query();
|
| 18 |
+
c.header("access-control-allow-origin", "*");
|
| 19 |
+
|
| 20 |
+
const innertubeClient = c.get("innertubeClient");
|
| 21 |
+
const config = c.get("config");
|
| 22 |
+
const metrics = c.get("metrics");
|
| 23 |
+
const tokenMinter = c.get("tokenMinter");
|
| 24 |
+
|
| 25 |
+
// Check if tokenMinter is ready (only needed when PO token is enabled)
|
| 26 |
+
if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) {
|
| 27 |
+
throw new HTTPException(503, {
|
| 28 |
+
res: new Response(TOKEN_MINTER_NOT_READY_MESSAGE),
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
if (!validateVideoId(videoId)) {
|
| 33 |
+
throw new HTTPException(400, {
|
| 34 |
+
res: new Response("Invalid video ID format."),
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (config.server.verify_requests && check == undefined) {
|
| 39 |
+
throw new HTTPException(400, {
|
| 40 |
+
res: new Response("No check ID."),
|
| 41 |
+
});
|
| 42 |
+
} else if (config.server.verify_requests && check) {
|
| 43 |
+
if (verifyRequest(check, videoId, config) === false) {
|
| 44 |
+
throw new HTTPException(400, {
|
| 45 |
+
res: new Response("ID incorrect."),
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const youtubePlayerResponseJson = await youtubePlayerParsing({
|
| 51 |
+
innertubeClient,
|
| 52 |
+
videoId,
|
| 53 |
+
config,
|
| 54 |
+
tokenMinter: tokenMinter!,
|
| 55 |
+
metrics,
|
| 56 |
+
});
|
| 57 |
+
const videoInfo = youtubeVideoInfo(
|
| 58 |
+
innertubeClient,
|
| 59 |
+
youtubePlayerResponseJson,
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
if (videoInfo.playability_status?.status !== "OK") {
|
| 63 |
+
throw ("The video can't be played: " + videoId + " due to reason: " +
|
| 64 |
+
videoInfo.playability_status?.reason);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
c.header("content-type", "application/dash+xml");
|
| 68 |
+
|
| 69 |
+
if (videoInfo.streaming_data) {
|
| 70 |
+
// video.js only support MP4 not WEBM
|
| 71 |
+
videoInfo.streaming_data.adaptive_formats = videoInfo
|
| 72 |
+
.streaming_data.adaptive_formats
|
| 73 |
+
.filter((i) =>
|
| 74 |
+
i.mime_type.includes("mp4")
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
const player_response = videoInfo.page[0];
|
| 78 |
+
// TODO: fix include storyboards in DASH manifest file
|
| 79 |
+
//const storyboards = player_response.storyboards;
|
| 80 |
+
const captions = player_response.captions?.caption_tracks;
|
| 81 |
+
|
| 82 |
+
const dashFile = await FormatUtils.toDash(
|
| 83 |
+
videoInfo.streaming_data,
|
| 84 |
+
videoInfo.page[0].video_details?.is_post_live_dvr,
|
| 85 |
+
(url: URL) => {
|
| 86 |
+
let dashUrl = url;
|
| 87 |
+
let queryParams = new URLSearchParams(dashUrl.search);
|
| 88 |
+
// Can't create URL type without host part
|
| 89 |
+
queryParams.set("host", dashUrl.host);
|
| 90 |
+
|
| 91 |
+
if (local) {
|
| 92 |
+
if (config.networking.videoplayback.ump) {
|
| 93 |
+
queryParams.set("ump", "yes");
|
| 94 |
+
}
|
| 95 |
+
if (
|
| 96 |
+
config.server.encrypt_query_params
|
| 97 |
+
) {
|
| 98 |
+
const publicParams = [...queryParams].filter(([key]) =>
|
| 99 |
+
["pot", "ip"].includes(key) === false
|
| 100 |
+
);
|
| 101 |
+
const privateParams = [...queryParams].filter(([key]) =>
|
| 102 |
+
["pot", "ip"].includes(key) === true
|
| 103 |
+
);
|
| 104 |
+
const encryptedParams = encryptQuery(
|
| 105 |
+
JSON.stringify(privateParams),
|
| 106 |
+
config,
|
| 107 |
+
);
|
| 108 |
+
queryParams = new URLSearchParams(publicParams);
|
| 109 |
+
queryParams.set("enc", "true");
|
| 110 |
+
queryParams.set("data", encryptedParams);
|
| 111 |
+
}
|
| 112 |
+
dashUrl =
|
| 113 |
+
(config.server.base_path + dashUrl.pathname + "?" +
|
| 114 |
+
queryParams.toString()) as unknown as URL;
|
| 115 |
+
return dashUrl;
|
| 116 |
+
} else {
|
| 117 |
+
return dashUrl;
|
| 118 |
+
}
|
| 119 |
+
},
|
| 120 |
+
undefined,
|
| 121 |
+
videoInfo.cpn,
|
| 122 |
+
undefined,
|
| 123 |
+
innertubeClient.actions,
|
| 124 |
+
undefined,
|
| 125 |
+
captions,
|
| 126 |
+
undefined,
|
| 127 |
+
);
|
| 128 |
+
return c.body(dashFile);
|
| 129 |
+
}
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
export default dashManifest;
|
src/routes/invidious_routes/download.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
import { validateVideoId } from "../../lib/helpers/validateVideoId.ts";
|
| 6 |
+
|
| 7 |
+
const DownloadWidgetSchema = z.union([
|
| 8 |
+
z.object({ label: z.string(), ext: z.string() }).strict(),
|
| 9 |
+
z.object({ itag: z.number(), ext: z.string() }).strict(),
|
| 10 |
+
]);
|
| 11 |
+
|
| 12 |
+
type DownloadWidget = z.infer<typeof DownloadWidgetSchema>;
|
| 13 |
+
|
| 14 |
+
export default function getDownloadHandler(app: Hono) {
|
| 15 |
+
async function handler(c: Context) {
|
| 16 |
+
const body = await c.req.formData();
|
| 17 |
+
|
| 18 |
+
const videoId = body.get("id")?.toString();
|
| 19 |
+
if (videoId == undefined) {
|
| 20 |
+
throw new HTTPException(400, {
|
| 21 |
+
res: new Response("Please specify the video ID"),
|
| 22 |
+
});
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
if (!validateVideoId(videoId)) {
|
| 26 |
+
throw new HTTPException(400, {
|
| 27 |
+
res: new Response("Invalid video ID format."),
|
| 28 |
+
});
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const config = c.get("config");
|
| 32 |
+
|
| 33 |
+
const check = c.req.query("check");
|
| 34 |
+
|
| 35 |
+
if (config.server.verify_requests && check == undefined) {
|
| 36 |
+
throw new HTTPException(400, {
|
| 37 |
+
res: new Response("No check ID."),
|
| 38 |
+
});
|
| 39 |
+
} else if (config.server.verify_requests && check) {
|
| 40 |
+
if (verifyRequest(check, videoId, config) === false) {
|
| 41 |
+
throw new HTTPException(400, {
|
| 42 |
+
res: new Response("ID incorrect."),
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const title = body.get("title");
|
| 48 |
+
|
| 49 |
+
let downloadWidgetData: DownloadWidget;
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
downloadWidgetData = JSON.parse(
|
| 53 |
+
body.get("download_widget")?.toString() || "",
|
| 54 |
+
);
|
| 55 |
+
} catch {
|
| 56 |
+
throw new HTTPException(400, {
|
| 57 |
+
res: new Response("Invalid download_widget json"),
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (
|
| 62 |
+
!(title && videoId &&
|
| 63 |
+
DownloadWidgetSchema.safeParse(downloadWidgetData).success)
|
| 64 |
+
) {
|
| 65 |
+
throw new HTTPException(400, {
|
| 66 |
+
res: new Response("Invalid form data required for download"),
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if ("label" in downloadWidgetData) {
|
| 71 |
+
return await app.request(
|
| 72 |
+
`${config.server.base_path}/api/v1/captions/${videoId}?label=${
|
| 73 |
+
encodeURIComponent(downloadWidgetData.label)
|
| 74 |
+
}`,
|
| 75 |
+
);
|
| 76 |
+
} else {
|
| 77 |
+
const itag = downloadWidgetData.itag;
|
| 78 |
+
const ext = downloadWidgetData.ext;
|
| 79 |
+
const filename = `${title}-${videoId}.${ext}`;
|
| 80 |
+
|
| 81 |
+
const urlQueriesForLatestVersion = new URLSearchParams();
|
| 82 |
+
urlQueriesForLatestVersion.set("id", videoId);
|
| 83 |
+
urlQueriesForLatestVersion.set("check", check || "");
|
| 84 |
+
urlQueriesForLatestVersion.set("itag", itag.toString());
|
| 85 |
+
// "title" for compatibility with how Invidious sets the content disposition header
|
| 86 |
+
// in /videoplayback and /latest_version
|
| 87 |
+
urlQueriesForLatestVersion.set(
|
| 88 |
+
"title",
|
| 89 |
+
filename,
|
| 90 |
+
);
|
| 91 |
+
urlQueriesForLatestVersion.set("local", "true");
|
| 92 |
+
|
| 93 |
+
return await app.request(
|
| 94 |
+
`${config.server.base_path}/latest_version?${urlQueriesForLatestVersion.toString()}`,
|
| 95 |
+
);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return handler;
|
| 100 |
+
}
|
src/routes/invidious_routes/latestVersion.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
import { validateVideoId } from "../../lib/helpers/validateVideoId.ts";
|
| 10 |
+
import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts";
|
| 11 |
+
|
| 12 |
+
const latestVersion = new Hono();
|
| 13 |
+
|
| 14 |
+
latestVersion.get("/", async (c) => {
|
| 15 |
+
const { check, itag, id, local, title } = c.req.query();
|
| 16 |
+
c.header("access-control-allow-origin", "*");
|
| 17 |
+
|
| 18 |
+
if (!id || !itag) {
|
| 19 |
+
throw new HTTPException(400, {
|
| 20 |
+
res: new Response("Please specify the itag and video ID."),
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
if (!validateVideoId(id)) {
|
| 25 |
+
throw new HTTPException(400, {
|
| 26 |
+
res: new Response("Invalid video ID format."),
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const innertubeClient = c.get("innertubeClient");
|
| 31 |
+
const config = c.get("config");
|
| 32 |
+
const metrics = c.get("metrics");
|
| 33 |
+
const tokenMinter = c.get("tokenMinter");
|
| 34 |
+
|
| 35 |
+
// Check if tokenMinter is ready (only needed when PO token is enabled)
|
| 36 |
+
if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) {
|
| 37 |
+
throw new HTTPException(503, {
|
| 38 |
+
res: new Response(TOKEN_MINTER_NOT_READY_MESSAGE),
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (config.server.verify_requests && check == undefined) {
|
| 43 |
+
throw new HTTPException(400, {
|
| 44 |
+
res: new Response("No check ID."),
|
| 45 |
+
});
|
| 46 |
+
} else if (config.server.verify_requests && check) {
|
| 47 |
+
if (verifyRequest(check, id, config) === false) {
|
| 48 |
+
throw new HTTPException(400, {
|
| 49 |
+
res: new Response("ID incorrect."),
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const youtubePlayerResponseJson = await youtubePlayerParsing({
|
| 55 |
+
innertubeClient,
|
| 56 |
+
videoId: id,
|
| 57 |
+
config,
|
| 58 |
+
tokenMinter: tokenMinter!,
|
| 59 |
+
metrics,
|
| 60 |
+
});
|
| 61 |
+
const videoInfo = youtubeVideoInfo(
|
| 62 |
+
innertubeClient,
|
| 63 |
+
youtubePlayerResponseJson,
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
if (videoInfo.playability_status?.status !== "OK") {
|
| 67 |
+
throw ("The video can't be played: " + id + " due to reason: " +
|
| 68 |
+
videoInfo.playability_status?.reason);
|
| 69 |
+
}
|
| 70 |
+
const streamingData = videoInfo.streaming_data;
|
| 71 |
+
const availableFormats = streamingData?.formats.concat(
|
| 72 |
+
streamingData.adaptive_formats,
|
| 73 |
+
);
|
| 74 |
+
const selectedItagFormat = availableFormats?.filter((i) =>
|
| 75 |
+
i.itag == Number(itag)
|
| 76 |
+
);
|
| 77 |
+
if (selectedItagFormat?.length === 0) {
|
| 78 |
+
throw new HTTPException(400, {
|
| 79 |
+
res: new Response("No itag found."),
|
| 80 |
+
});
|
| 81 |
+
} else if (selectedItagFormat) {
|
| 82 |
+
// Always offer original audio if possible
|
| 83 |
+
// This may be changed due to https://github.com/iv-org/invidious/issues/5501
|
| 84 |
+
const itagUrl = selectedItagFormat.find((itag) =>
|
| 85 |
+
itag.is_original
|
| 86 |
+
)?.url as string || selectedItagFormat[0].url as string;
|
| 87 |
+
const itagUrlParsed = new URL(itagUrl);
|
| 88 |
+
let queryParams = new URLSearchParams(itagUrlParsed.search);
|
| 89 |
+
let urlToRedirect = itagUrlParsed.toString();
|
| 90 |
+
|
| 91 |
+
if (local) {
|
| 92 |
+
queryParams.set("host", itagUrlParsed.host);
|
| 93 |
+
if (config.server.encrypt_query_params) {
|
| 94 |
+
const publicParams = [...queryParams].filter(([key]) =>
|
| 95 |
+
["pot", "ip"].includes(key) === false
|
| 96 |
+
);
|
| 97 |
+
const privateParams = [...queryParams].filter(([key]) =>
|
| 98 |
+
["pot", "ip"].includes(key) === true
|
| 99 |
+
);
|
| 100 |
+
const encryptedParams = encryptQuery(
|
| 101 |
+
JSON.stringify(privateParams),
|
| 102 |
+
config,
|
| 103 |
+
);
|
| 104 |
+
queryParams = new URLSearchParams(publicParams);
|
| 105 |
+
queryParams.set("enc", "true");
|
| 106 |
+
queryParams.set("data", encryptedParams);
|
| 107 |
+
}
|
| 108 |
+
urlToRedirect = config.server.base_path + itagUrlParsed.pathname +
|
| 109 |
+
"?" +
|
| 110 |
+
queryParams.toString();
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
if (title) urlToRedirect += `&title=${encodeURIComponent(title)}`;
|
| 114 |
+
|
| 115 |
+
return c.redirect(urlToRedirect);
|
| 116 |
+
}
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
export default latestVersion;
|
src/routes/invidious_routes/mixes.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Hono } from "hono";
|
| 2 |
+
import { HTTPException } from "hono/http-exception";
|
| 3 |
+
import type { Innertube } from "youtubei.js";
|
| 4 |
+
|
| 5 |
+
const mixes = new Hono();
|
| 6 |
+
|
| 7 |
+
interface VideoThumbnail {
|
| 8 |
+
quality: string;
|
| 9 |
+
url: string;
|
| 10 |
+
width: number;
|
| 11 |
+
height: number;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface MixVideo {
|
| 15 |
+
title: string;
|
| 16 |
+
videoId: string;
|
| 17 |
+
author: string;
|
| 18 |
+
authorId: string;
|
| 19 |
+
authorUrl: string;
|
| 20 |
+
videoThumbnails: VideoThumbnail[];
|
| 21 |
+
index: number;
|
| 22 |
+
lengthSeconds: number;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
interface MixResponse {
|
| 26 |
+
title: string;
|
| 27 |
+
mixId: string;
|
| 28 |
+
videos: MixVideo[];
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// Helper to generate video thumbnails
|
| 32 |
+
function generateVideoThumbnails(videoId: string): VideoThumbnail[] {
|
| 33 |
+
const baseUrl = "https://i.ytimg.com";
|
| 34 |
+
return [
|
| 35 |
+
{ quality: "maxres", url: `${baseUrl}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 },
|
| 36 |
+
{ quality: "sddefault", url: `${baseUrl}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 },
|
| 37 |
+
{ quality: "high", url: `${baseUrl}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 },
|
| 38 |
+
{ quality: "medium", url: `${baseUrl}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 },
|
| 39 |
+
{ quality: "default", url: `${baseUrl}/vi/${videoId}/default.jpg`, width: 120, height: 90 },
|
| 40 |
+
];
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// GET /:mixId - Get mix information
|
| 44 |
+
mixes.get("/:mixId", async (c) => {
|
| 45 |
+
const mixId = c.req.param("mixId");
|
| 46 |
+
const innertubeClient = c.get("innertubeClient") as Innertube;
|
| 47 |
+
|
| 48 |
+
if (!mixId) {
|
| 49 |
+
throw new HTTPException(400, { message: "Mix ID is required" });
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
console.log(`[INFO] Fetching mix: ${mixId}`);
|
| 53 |
+
|
| 54 |
+
try {
|
| 55 |
+
// Mixes are fetched like playlists
|
| 56 |
+
const playlist = await innertubeClient.getPlaylist(mixId);
|
| 57 |
+
const info = playlist.info as any;
|
| 58 |
+
|
| 59 |
+
// Get videos
|
| 60 |
+
const videos: MixVideo[] = [];
|
| 61 |
+
let index = 0;
|
| 62 |
+
for (const item of playlist.items) {
|
| 63 |
+
const v = item as any;
|
| 64 |
+
if (v.type === "PlaylistVideo" || v.id) {
|
| 65 |
+
videos.push({
|
| 66 |
+
title: v.title?.text || "",
|
| 67 |
+
videoId: v.id || "",
|
| 68 |
+
author: v.author?.name || "",
|
| 69 |
+
authorId: v.author?.id || "",
|
| 70 |
+
authorUrl: v.author?.id ? `/channel/${v.author.id}` : "",
|
| 71 |
+
videoThumbnails: generateVideoThumbnails(v.id || ""),
|
| 72 |
+
index: index++,
|
| 73 |
+
lengthSeconds: v.duration?.seconds || 0
|
| 74 |
+
});
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const response: MixResponse = {
|
| 79 |
+
title: info?.title || "",
|
| 80 |
+
mixId: mixId,
|
| 81 |
+
videos: videos
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
return c.json(response);
|
| 85 |
+
} catch (error) {
|
| 86 |
+
console.error(`[ERROR] Failed to fetch mix ${mixId}:`, error);
|
| 87 |
+
throw new HTTPException(500, { message: `Failed to fetch mix: ${error}` });
|
| 88 |
+
}
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
export default mixes;
|
src/routes/invidious_routes/playlists.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Hono } from "hono";
|
| 2 |
+
import { HTTPException } from "hono/http-exception";
|
| 3 |
+
import type { Innertube } from "youtubei.js";
|
| 4 |
+
|
| 5 |
+
const playlists = new Hono();
|
| 6 |
+
|
| 7 |
+
interface Thumbnail {
|
| 8 |
+
url: string;
|
| 9 |
+
width: number;
|
| 10 |
+
height: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface VideoThumbnail {
|
| 14 |
+
quality: string;
|
| 15 |
+
url: string;
|
| 16 |
+
width: number;
|
| 17 |
+
height: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface PlaylistVideo {
|
| 21 |
+
type: string;
|
| 22 |
+
title: string;
|
| 23 |
+
videoId: string;
|
| 24 |
+
author: string;
|
| 25 |
+
authorId: string;
|
| 26 |
+
authorUrl: string;
|
| 27 |
+
videoThumbnails: VideoThumbnail[];
|
| 28 |
+
index: number;
|
| 29 |
+
lengthSeconds: number;
|
| 30 |
+
liveNow: boolean;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface PlaylistResponse {
|
| 34 |
+
type: string;
|
| 35 |
+
title: string;
|
| 36 |
+
playlistId: string;
|
| 37 |
+
playlistThumbnail: string;
|
| 38 |
+
author: string;
|
| 39 |
+
authorId: string;
|
| 40 |
+
authorUrl: string;
|
| 41 |
+
subtitle: object | null;
|
| 42 |
+
authorThumbnails: Thumbnail[];
|
| 43 |
+
description: string;
|
| 44 |
+
descriptionHtml: string;
|
| 45 |
+
videoCount: number;
|
| 46 |
+
viewCount: number;
|
| 47 |
+
updated: number;
|
| 48 |
+
isListed: boolean;
|
| 49 |
+
videos: PlaylistVideo[];
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Helper to generate video thumbnails
|
| 53 |
+
function generateVideoThumbnails(videoId: string): VideoThumbnail[] {
|
| 54 |
+
const baseUrl = "https://i.ytimg.com";
|
| 55 |
+
return [
|
| 56 |
+
{ quality: "maxres", url: `${baseUrl}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 },
|
| 57 |
+
{ quality: "sddefault", url: `${baseUrl}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 },
|
| 58 |
+
{ quality: "high", url: `${baseUrl}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 },
|
| 59 |
+
{ quality: "medium", url: `${baseUrl}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 },
|
| 60 |
+
{ quality: "default", url: `${baseUrl}/vi/${videoId}/default.jpg`, width: 120, height: 90 },
|
| 61 |
+
];
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// Helper to parse view count
|
| 65 |
+
function parseViewCount(text: string | undefined): number {
|
| 66 |
+
if (!text) return 0;
|
| 67 |
+
const match = text.match(/([\d,]+)/);
|
| 68 |
+
if (match) {
|
| 69 |
+
return parseInt(match[1].replace(/,/g, "")) || 0;
|
| 70 |
+
}
|
| 71 |
+
return 0;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// GET /:playlistId - Get playlist information
|
| 75 |
+
playlists.get("/:playlistId", async (c) => {
|
| 76 |
+
const playlistId = c.req.param("playlistId");
|
| 77 |
+
const innertubeClient = c.get("innertubeClient") as Innertube;
|
| 78 |
+
|
| 79 |
+
if (!playlistId) {
|
| 80 |
+
throw new HTTPException(400, { message: "Playlist ID is required" });
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
console.log(`[INFO] Fetching playlist: ${playlistId}`);
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
const playlist = await innertubeClient.getPlaylist(playlistId);
|
| 87 |
+
const info = playlist.info as any;
|
| 88 |
+
|
| 89 |
+
// Extract author info
|
| 90 |
+
let author = "";
|
| 91 |
+
let authorId = "";
|
| 92 |
+
let authorUrl = "";
|
| 93 |
+
const authorThumbnails: Thumbnail[] = [];
|
| 94 |
+
|
| 95 |
+
if (info?.author) {
|
| 96 |
+
author = info.author.name || "";
|
| 97 |
+
authorId = info.author.id || "";
|
| 98 |
+
authorUrl = authorId ? `/channel/${authorId}` : "";
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// Get author thumbnails
|
| 102 |
+
if (info?.author?.thumbnails && Array.isArray(info.author.thumbnails)) {
|
| 103 |
+
for (const thumb of info.author.thumbnails) {
|
| 104 |
+
authorThumbnails.push({
|
| 105 |
+
url: thumb.url,
|
| 106 |
+
width: thumb.width || 0,
|
| 107 |
+
height: thumb.height || 0
|
| 108 |
+
});
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Get playlist thumbnail
|
| 113 |
+
let playlistThumbnail = "";
|
| 114 |
+
if (info?.thumbnails && info.thumbnails.length > 0) {
|
| 115 |
+
playlistThumbnail = info.thumbnails[0].url || "";
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Get videos
|
| 119 |
+
const videos: PlaylistVideo[] = [];
|
| 120 |
+
let index = 0;
|
| 121 |
+
for (const item of playlist.items) {
|
| 122 |
+
const v = item as any;
|
| 123 |
+
if (v.type === "PlaylistVideo" || v.id) {
|
| 124 |
+
videos.push({
|
| 125 |
+
type: "video",
|
| 126 |
+
title: v.title?.text || "",
|
| 127 |
+
videoId: v.id || "",
|
| 128 |
+
author: v.author?.name || author,
|
| 129 |
+
authorId: v.author?.id || authorId,
|
| 130 |
+
authorUrl: v.author?.id ? `/channel/${v.author.id}` : authorUrl,
|
| 131 |
+
videoThumbnails: generateVideoThumbnails(v.id || ""),
|
| 132 |
+
index: index++,
|
| 133 |
+
lengthSeconds: v.duration?.seconds || 0,
|
| 134 |
+
liveNow: v.is_live || false
|
| 135 |
+
});
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const response: PlaylistResponse = {
|
| 140 |
+
type: "playlist",
|
| 141 |
+
title: info?.title || "",
|
| 142 |
+
playlistId: playlistId,
|
| 143 |
+
playlistThumbnail: playlistThumbnail,
|
| 144 |
+
author: author,
|
| 145 |
+
authorId: authorId,
|
| 146 |
+
authorUrl: authorUrl,
|
| 147 |
+
subtitle: null,
|
| 148 |
+
authorThumbnails: authorThumbnails,
|
| 149 |
+
description: info?.description || "",
|
| 150 |
+
descriptionHtml: info?.description || "",
|
| 151 |
+
videoCount: parseViewCount(String(info?.total_items || "")) || videos.length,
|
| 152 |
+
viewCount: parseViewCount(info?.views?.text),
|
| 153 |
+
updated: 0, // Not easily available
|
| 154 |
+
isListed: true,
|
| 155 |
+
videos: videos
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
return c.json(response);
|
| 159 |
+
} catch (error) {
|
| 160 |
+
console.error(`[ERROR] Failed to fetch playlist ${playlistId}:`, error);
|
| 161 |
+
throw new HTTPException(500, { message: `Failed to fetch playlist: ${error}` });
|
| 162 |
+
}
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
export default playlists;
|
src/routes/invidious_routes/search.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Hono } from "hono";
|
| 2 |
+
import { HTTPException } from "hono/http-exception";
|
| 3 |
+
import type { Innertube } from "youtubei.js";
|
| 4 |
+
|
| 5 |
+
const search = new Hono();
|
| 6 |
+
|
| 7 |
+
// Helper to convert duration string to seconds (e.g., "12:34" -> 754)
|
| 8 |
+
function parseDuration(durationVal: string | number | undefined): number {
|
| 9 |
+
if (typeof durationVal === 'number') return durationVal;
|
| 10 |
+
if (!durationVal) return 0;
|
| 11 |
+
|
| 12 |
+
// Check if it's just seconds as a string
|
| 13 |
+
if (!durationVal.includes(':')) {
|
| 14 |
+
return parseInt(durationVal, 10) || 0;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const parts = durationVal.split(':').map(Number);
|
| 18 |
+
if (parts.length === 3) {
|
| 19 |
+
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
| 20 |
+
}
|
| 21 |
+
if (parts.length === 2) {
|
| 22 |
+
return parts[0] * 60 + parts[1];
|
| 23 |
+
}
|
| 24 |
+
return 0;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Helper to parse relative time to seconds (approximate)
|
| 28 |
+
function parseRelativeTime(text: string | undefined): number {
|
| 29 |
+
if (!text) return 0;
|
| 30 |
+
const now = Date.now();
|
| 31 |
+
// This is a very rough approximation as we don't have the exact date
|
| 32 |
+
// You might want to use a library or more complex logic here if needed
|
| 33 |
+
// For now, we'll just return date.now / 1000 to be safe or 0
|
| 34 |
+
// A better approach would be to parse "2 years ago" etc.
|
| 35 |
+
// But Invidious often returns the timestamp of the upload.
|
| 36 |
+
// If not available, 0 is often used as a fallback.
|
| 37 |
+
return Math.floor(now / 1000);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
search.get("/", async (c) => {
|
| 42 |
+
const q = c.req.query("q");
|
| 43 |
+
const page = parseInt(c.req.query("page") || "1");
|
| 44 |
+
const type = c.req.query("type") || "all";
|
| 45 |
+
|
| 46 |
+
c.header("access-control-allow-origin", "*");
|
| 47 |
+
c.header("content-type", "application/json");
|
| 48 |
+
|
| 49 |
+
if (!q) {
|
| 50 |
+
throw new HTTPException(400, {
|
| 51 |
+
res: new Response(JSON.stringify({ error: "Query parameter 'q' is required" })),
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const innertubeClient = c.get("innertubeClient") as Innertube;
|
| 56 |
+
|
| 57 |
+
try {
|
| 58 |
+
// Map 'type' to YouTubei filters
|
| 59 |
+
let filters: any = {};
|
| 60 |
+
if (type === "video") filters.type = "video";
|
| 61 |
+
else if (type === "channel") filters.type = "channel";
|
| 62 |
+
else if (type === "playlist") filters.type = "playlist";
|
| 63 |
+
// 'all' uses default, no filter needed usually, or we can fetch without filters
|
| 64 |
+
|
| 65 |
+
const searchResults = await innertubeClient.search(q, filters);
|
| 66 |
+
|
| 67 |
+
// Note: Pagination support in InnerTube search is via 'getContinuation',
|
| 68 |
+
// but existing stateless endpoints often just fetch the first batch.
|
| 69 |
+
// Fully implementing page 2+ requires handling continuation tokens,
|
| 70 |
+
// usually passed back to the client. The 'page' param in Invidious
|
| 71 |
+
// is often abstraction. For now, we return the initial results.
|
| 72 |
+
// If the user needs deep pagination, we'd need to store/pass continuation tokens.
|
| 73 |
+
|
| 74 |
+
const items = searchResults.results || [];
|
| 75 |
+
|
| 76 |
+
// Mapped results
|
| 77 |
+
const response: any[] = [];
|
| 78 |
+
|
| 79 |
+
for (const item of items) {
|
| 80 |
+
if (item.type === "Video" || item.type === "CompactVideo") {
|
| 81 |
+
const video = item as any; // Cast to any to access properties safely
|
| 82 |
+
response.push({
|
| 83 |
+
type: "video",
|
| 84 |
+
title: video.title?.text || "",
|
| 85 |
+
videoId: video.id,
|
| 86 |
+
author: video.author?.name || "",
|
| 87 |
+
authorId: video.author?.id || "",
|
| 88 |
+
authorUrl: video.author?.url || (video.author?.id ? `/channel/${video.author?.id}` : ""),
|
| 89 |
+
authorVerified: video.author?.is_verified || false,
|
| 90 |
+
authorThumbnails: video.author?.thumbnails || [],
|
| 91 |
+
videoThumbnails: video.thumbnails || [],
|
| 92 |
+
description: video.description?.text || "",
|
| 93 |
+
descriptionHtml: video.description?.text || "", // Basic text for now
|
| 94 |
+
viewCount: video.view_count?.text ? parseInt(video.view_count.text.replace(/[^0-9]/g, '')) : 0,
|
| 95 |
+
viewCountText: video.view_count?.text || "0 views",
|
| 96 |
+
published: parseRelativeTime(video.published?.text),
|
| 97 |
+
publishedText: video.published?.text || "",
|
| 98 |
+
lengthSeconds: parseDuration(video.duration?.text),
|
| 99 |
+
liveNow: video.is_live || false,
|
| 100 |
+
premium: false, // Not easily available
|
| 101 |
+
isUpcoming: video.upcoming || false,
|
| 102 |
+
isNew: false, // Logic needed
|
| 103 |
+
is4k: false, // Check badges?
|
| 104 |
+
is8k: false,
|
| 105 |
+
isVr180: false,
|
| 106 |
+
isVr360: false,
|
| 107 |
+
is3d: false,
|
| 108 |
+
hasCaptions: false // Check badges?
|
| 109 |
+
});
|
| 110 |
+
} else if (item.type === "Channel") {
|
| 111 |
+
const channel = item as any;
|
| 112 |
+
response.push({
|
| 113 |
+
type: "channel",
|
| 114 |
+
author: channel.author?.name || channel.title?.text || "",
|
| 115 |
+
authorId: channel.id,
|
| 116 |
+
authorUrl: `/channel/${channel.id}`,
|
| 117 |
+
authorVerified: channel.author?.is_verified || false,
|
| 118 |
+
authorThumbnails: channel.thumbnails || [],
|
| 119 |
+
autoGenerated: false,
|
| 120 |
+
subCount: channel.subscriber_count?.text ? parseInt(channel.subscriber_count.text.replace(/[^0-9]/g, '')) : 0,
|
| 121 |
+
videoCount: 0, // Often not in search snippets
|
| 122 |
+
channelHandle: "", // Might be in url
|
| 123 |
+
description: channel.description_snippet?.text || "",
|
| 124 |
+
descriptionHtml: channel.description_snippet?.text || ""
|
| 125 |
+
});
|
| 126 |
+
} else if (item.type === "Playlist") {
|
| 127 |
+
const playlist = item as any;
|
| 128 |
+
response.push({
|
| 129 |
+
type: "playlist",
|
| 130 |
+
title: playlist.title?.text || "",
|
| 131 |
+
playlistId: playlist.id,
|
| 132 |
+
playlistThumbnail: playlist.thumbnails?.[0]?.url || "",
|
| 133 |
+
author: playlist.author?.name || "",
|
| 134 |
+
authorId: playlist.author?.id || "",
|
| 135 |
+
authorUrl: `/channel/${playlist.author?.id || ""}`,
|
| 136 |
+
authorVerified: playlist.author?.is_verified || false,
|
| 137 |
+
videoCount: parseInt(playlist.video_count?.replace(/[^0-9]/g, '') || "0"),
|
| 138 |
+
videos: [] // Search result usually doesn't have video list inside
|
| 139 |
+
});
|
| 140 |
+
} else if (item.type === "LockupView") {
|
| 141 |
+
const lockup = item as any;
|
| 142 |
+
const contentId = lockup.content_id;
|
| 143 |
+
|
| 144 |
+
if (contentId?.startsWith("PL")) {
|
| 145 |
+
// Playlist
|
| 146 |
+
let author = "";
|
| 147 |
+
let authorId = "";
|
| 148 |
+
let authorUrl = "";
|
| 149 |
+
let videoCount = 0;
|
| 150 |
+
let thumbnail = "";
|
| 151 |
+
|
| 152 |
+
// Extract thumbnail
|
| 153 |
+
const primaryThumbnail = lockup.content_image?.primary_thumbnail;
|
| 154 |
+
if (primaryThumbnail?.image && primaryThumbnail.image.length > 0) {
|
| 155 |
+
thumbnail = primaryThumbnail.image[0].url;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// Extract video count from overlays
|
| 159 |
+
if (primaryThumbnail?.overlays) {
|
| 160 |
+
for (const overlay of primaryThumbnail.overlays) {
|
| 161 |
+
if (overlay.badges) {
|
| 162 |
+
for (const badge of overlay.badges) {
|
| 163 |
+
if (badge.text && badge.text.includes("videos")) {
|
| 164 |
+
const match = badge.text.match(/([\d,]+)\s+videos/);
|
| 165 |
+
if (match) {
|
| 166 |
+
videoCount = parseInt(match[1].replace(/,/g, ""));
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// Extract author, authorId, and authorUrl from metadata rows
|
| 175 |
+
if (lockup.metadata?.metadata?.metadata_rows) {
|
| 176 |
+
for (const row of lockup.metadata.metadata.metadata_rows) {
|
| 177 |
+
if (row.metadata_parts) {
|
| 178 |
+
for (const part of row.metadata_parts) {
|
| 179 |
+
if (part.text?.runs) {
|
| 180 |
+
for (const run of part.text.runs) {
|
| 181 |
+
if (run.endpoint?.metadata?.page_type === "WEB_PAGE_TYPE_CHANNEL") {
|
| 182 |
+
author = run.text;
|
| 183 |
+
authorId = run.endpoint?.payload?.browseId || "";
|
| 184 |
+
authorUrl = run.endpoint?.payload?.canonicalBaseUrl || (authorId ? `/channel/${authorId}` : "");
|
| 185 |
+
break;
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
if (author) break;
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
if (author) break;
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// Fallback for author
|
| 197 |
+
if (!author && lockup.metadata?.metadata?.metadata_rows?.[0]?.metadata_parts?.[0]?.text?.text) {
|
| 198 |
+
author = lockup.metadata.metadata.metadata_rows[0].metadata_parts[0].text.text;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
response.push({
|
| 202 |
+
type: "playlist",
|
| 203 |
+
title: lockup.metadata?.title?.text || "Unknown Playlist",
|
| 204 |
+
playlistId: contentId,
|
| 205 |
+
playlistThumbnail: thumbnail,
|
| 206 |
+
author: author,
|
| 207 |
+
authorId: authorId,
|
| 208 |
+
authorUrl: authorUrl,
|
| 209 |
+
authorVerified: false,
|
| 210 |
+
videoCount: videoCount,
|
| 211 |
+
videos: []
|
| 212 |
+
});
|
| 213 |
+
} else if (contentId?.startsWith("UC")) {
|
| 214 |
+
// Channel
|
| 215 |
+
response.push({
|
| 216 |
+
type: "channel",
|
| 217 |
+
author: lockup.metadata?.title?.text || "Unknown Channel",
|
| 218 |
+
authorId: contentId,
|
| 219 |
+
authorUrl: `/channel/${contentId}`,
|
| 220 |
+
authorVerified: false,
|
| 221 |
+
authorThumbnails: lockup.content_image?.primary_thumbnail?.thumbnails || [],
|
| 222 |
+
autoGenerated: false,
|
| 223 |
+
subCount: 0,
|
| 224 |
+
videoCount: 0,
|
| 225 |
+
channelHandle: "",
|
| 226 |
+
description: "",
|
| 227 |
+
descriptionHtml: ""
|
| 228 |
+
});
|
| 229 |
+
} else {
|
| 230 |
+
// Assume Video
|
| 231 |
+
response.push({
|
| 232 |
+
type: "video",
|
| 233 |
+
title: lockup.metadata?.title?.text || "Unknown Video",
|
| 234 |
+
videoId: contentId,
|
| 235 |
+
author: "", // Parsing from metadata lines is complex
|
| 236 |
+
authorId: "",
|
| 237 |
+
authorUrl: "",
|
| 238 |
+
authorVerified: false,
|
| 239 |
+
authorThumbnails: [], // Often missing in LockupView
|
| 240 |
+
videoThumbnails: lockup.content_image?.primary_thumbnail?.thumbnails || [],
|
| 241 |
+
description: "",
|
| 242 |
+
descriptionHtml: "",
|
| 243 |
+
viewCount: 0,
|
| 244 |
+
viewCountText: "",
|
| 245 |
+
published: 0,
|
| 246 |
+
publishedText: "",
|
| 247 |
+
lengthSeconds: 0, // Duration might be in metadata
|
| 248 |
+
liveNow: false,
|
| 249 |
+
premium: false,
|
| 250 |
+
isUpcoming: false,
|
| 251 |
+
isNew: false,
|
| 252 |
+
is4k: false,
|
| 253 |
+
is8k: false,
|
| 254 |
+
isVr180: false,
|
| 255 |
+
isVr360: false,
|
| 256 |
+
is3d: false,
|
| 257 |
+
hasCaptions: false
|
| 258 |
+
});
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
return c.json(response);
|
| 264 |
+
|
| 265 |
+
} catch (error) {
|
| 266 |
+
console.error("[ERROR] Failed to fetch search results:", error);
|
| 267 |
+
throw new HTTPException(500, {
|
| 268 |
+
res: new Response(JSON.stringify({ error: "Failed to fetch search results" })),
|
| 269 |
+
});
|
| 270 |
+
}
|
| 271 |
+
});
|
| 272 |
+
|
| 273 |
+
search.get("/suggestions", async (c) => {
|
| 274 |
+
const q = c.req.query("q");
|
| 275 |
+
c.header("access-control-allow-origin", "*");
|
| 276 |
+
c.header("content-type", "application/json");
|
| 277 |
+
|
| 278 |
+
if (!q) {
|
| 279 |
+
throw new HTTPException(400, {
|
| 280 |
+
res: new Response(JSON.stringify({ error: "Query parameter 'q' is required" })),
|
| 281 |
+
});
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
const innertubeClient = c.get("innertubeClient") as Innertube;
|
| 285 |
+
|
| 286 |
+
try {
|
| 287 |
+
const suggestions = await innertubeClient.getSearchSuggestions(q);
|
| 288 |
+
return c.json({
|
| 289 |
+
query: q,
|
| 290 |
+
suggestions: suggestions,
|
| 291 |
+
});
|
| 292 |
+
} catch (error) {
|
| 293 |
+
console.error("[ERROR] Failed to fetch search suggestions:", error);
|
| 294 |
+
throw new HTTPException(500, {
|
| 295 |
+
res: new Response(JSON.stringify({ error: "Failed to fetch search suggestions" })),
|
| 296 |
+
});
|
| 297 |
+
}
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
export default search;
|
src/routes/invidious_routes/videos.ts
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 { validateVideoId } from "../../lib/helpers/validateVideoId.ts";
|
| 8 |
+
import { encryptQuery } from "../../lib/helpers/encryptQuery.ts";
|
| 9 |
+
import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts";
|
| 10 |
+
|
| 11 |
+
const videos = new Hono();
|
| 12 |
+
|
| 13 |
+
interface Thumbnail {
|
| 14 |
+
quality: string;
|
| 15 |
+
url: string;
|
| 16 |
+
width: number;
|
| 17 |
+
height: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface AuthorThumbnail {
|
| 21 |
+
url: string;
|
| 22 |
+
width: number;
|
| 23 |
+
height: number;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
interface Storyboard {
|
| 27 |
+
url: string;
|
| 28 |
+
templateUrl: string;
|
| 29 |
+
width: number;
|
| 30 |
+
height: number;
|
| 31 |
+
count: number;
|
| 32 |
+
interval: number;
|
| 33 |
+
storyboardWidth: number;
|
| 34 |
+
storyboardHeight: number;
|
| 35 |
+
storyboardCount: number;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface AdaptiveFormat {
|
| 39 |
+
init?: string;
|
| 40 |
+
index?: string;
|
| 41 |
+
bitrate: string;
|
| 42 |
+
url: string;
|
| 43 |
+
itag: string;
|
| 44 |
+
type: string;
|
| 45 |
+
clen?: string;
|
| 46 |
+
lmt?: string;
|
| 47 |
+
projectionType: string;
|
| 48 |
+
fps?: number;
|
| 49 |
+
size?: string;
|
| 50 |
+
resolution?: string;
|
| 51 |
+
qualityLabel?: string;
|
| 52 |
+
container?: string;
|
| 53 |
+
encoding?: string;
|
| 54 |
+
audioQuality?: string;
|
| 55 |
+
audioSampleRate?: number;
|
| 56 |
+
audioChannels?: number;
|
| 57 |
+
colorInfo?: object;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
interface FormatStream {
|
| 61 |
+
url: string;
|
| 62 |
+
itag: string;
|
| 63 |
+
type: string;
|
| 64 |
+
quality: string;
|
| 65 |
+
bitrate: string;
|
| 66 |
+
fps?: number;
|
| 67 |
+
size?: string;
|
| 68 |
+
resolution?: string;
|
| 69 |
+
qualityLabel?: string;
|
| 70 |
+
container?: string;
|
| 71 |
+
encoding?: string;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
interface Caption {
|
| 75 |
+
label: string;
|
| 76 |
+
language_code: string;
|
| 77 |
+
url: string;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
interface RecommendedVideo {
|
| 81 |
+
videoId: string;
|
| 82 |
+
title: string;
|
| 83 |
+
videoThumbnails: Thumbnail[];
|
| 84 |
+
author: string;
|
| 85 |
+
authorUrl: string;
|
| 86 |
+
authorId: string;
|
| 87 |
+
authorVerified: boolean;
|
| 88 |
+
lengthSeconds: number;
|
| 89 |
+
viewCountText: string;
|
| 90 |
+
published?: string;
|
| 91 |
+
publishedText?: string;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Generate thumbnail URLs for a video
|
| 95 |
+
function generateThumbnails(videoId: string, baseUrl: string): Thumbnail[] {
|
| 96 |
+
return [
|
| 97 |
+
{ quality: "maxres", url: `${baseUrl}/vi/${videoId}/maxres.jpg`, width: 1280, height: 720 },
|
| 98 |
+
{ quality: "maxresdefault", url: `${baseUrl}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 },
|
| 99 |
+
{ quality: "sddefault", url: `${baseUrl}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 },
|
| 100 |
+
{ quality: "high", url: `${baseUrl}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 },
|
| 101 |
+
{ quality: "medium", url: `${baseUrl}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 },
|
| 102 |
+
{ quality: "default", url: `${baseUrl}/vi/${videoId}/default.jpg`, width: 120, height: 90 },
|
| 103 |
+
{ quality: "start", url: `${baseUrl}/vi/${videoId}/1.jpg`, width: 120, height: 90 },
|
| 104 |
+
{ quality: "middle", url: `${baseUrl}/vi/${videoId}/2.jpg`, width: 120, height: 90 },
|
| 105 |
+
{ quality: "end", url: `${baseUrl}/vi/${videoId}/3.jpg`, width: 120, height: 90 },
|
| 106 |
+
];
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Parse storyboards from YouTube response
|
| 110 |
+
function parseStoryboards(storyboards: any, videoId: string): Storyboard[] {
|
| 111 |
+
const result: Storyboard[] = [];
|
| 112 |
+
if (!storyboards) return result;
|
| 113 |
+
|
| 114 |
+
// Handle PlayerStoryboardSpec format
|
| 115 |
+
if (storyboards.type === "PlayerStoryboardSpec" && storyboards.boards) {
|
| 116 |
+
for (const board of storyboards.boards) {
|
| 117 |
+
if (!board.template_url) continue;
|
| 118 |
+
result.push({
|
| 119 |
+
url: `/api/v1/storyboards/${videoId}?width=${board.thumbnail_width}&height=${board.thumbnail_height}`,
|
| 120 |
+
templateUrl: board.template_url,
|
| 121 |
+
width: board.thumbnail_width || 0,
|
| 122 |
+
height: board.thumbnail_height || 0,
|
| 123 |
+
count: board.thumbnail_count || 0,
|
| 124 |
+
interval: board.interval || 0,
|
| 125 |
+
storyboardWidth: board.columns || 0,
|
| 126 |
+
storyboardHeight: board.rows || 0,
|
| 127 |
+
storyboardCount: board.storyboard_count || 1,
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return result;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// Convert YouTube format to Invidious adaptive format
|
| 136 |
+
function convertAdaptiveFormat(format: any): AdaptiveFormat {
|
| 137 |
+
const result: AdaptiveFormat = {
|
| 138 |
+
bitrate: String(format.bitrate || "0"),
|
| 139 |
+
url: format.url || "",
|
| 140 |
+
itag: String(format.itag || "0"),
|
| 141 |
+
type: format.mime_type || "",
|
| 142 |
+
projectionType: format.projection_type || "RECTANGULAR",
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
if (format.init_range) {
|
| 146 |
+
result.init = `${format.init_range.start}-${format.init_range.end}`;
|
| 147 |
+
}
|
| 148 |
+
if (format.index_range) {
|
| 149 |
+
result.index = `${format.index_range.start}-${format.index_range.end}`;
|
| 150 |
+
}
|
| 151 |
+
if (format.content_length) result.clen = String(format.content_length);
|
| 152 |
+
if (format.last_modified) result.lmt = String(format.last_modified);
|
| 153 |
+
if (format.fps) result.fps = format.fps;
|
| 154 |
+
if (format.width && format.height) result.size = `${format.width}x${format.height}`;
|
| 155 |
+
if (format.quality_label) {
|
| 156 |
+
result.qualityLabel = format.quality_label;
|
| 157 |
+
result.resolution = format.quality_label;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Parse container and encoding from mime type
|
| 161 |
+
const mimeMatch = format.mime_type?.match(/^(video|audio)\/(\w+)/);
|
| 162 |
+
if (mimeMatch) {
|
| 163 |
+
result.container = mimeMatch[2];
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
const codecMatch = format.mime_type?.match(/codecs="([^"]+)"/);
|
| 167 |
+
if (codecMatch) {
|
| 168 |
+
result.encoding = codecMatch[1].split(",")[0].trim();
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
if (format.audio_quality) result.audioQuality = format.audio_quality;
|
| 172 |
+
if (format.audio_sample_rate) result.audioSampleRate = parseInt(format.audio_sample_rate);
|
| 173 |
+
if (format.audio_channels) result.audioChannels = format.audio_channels;
|
| 174 |
+
if (format.color_info) result.colorInfo = format.color_info;
|
| 175 |
+
|
| 176 |
+
return result;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// Convert YouTube format to Invidious format stream (combined video+audio)
|
| 180 |
+
function convertFormatStream(format: any): FormatStream {
|
| 181 |
+
const result: FormatStream = {
|
| 182 |
+
url: format.url || "",
|
| 183 |
+
itag: String(format.itag || "0"),
|
| 184 |
+
type: format.mime_type || "",
|
| 185 |
+
quality: format.quality || "medium",
|
| 186 |
+
bitrate: String(format.bitrate || "0"),
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
if (format.fps) result.fps = format.fps;
|
| 190 |
+
if (format.width && format.height) result.size = `${format.width}x${format.height}`;
|
| 191 |
+
if (format.quality_label) {
|
| 192 |
+
result.qualityLabel = format.quality_label;
|
| 193 |
+
result.resolution = format.quality_label;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const mimeMatch = format.mime_type?.match(/^video\/(\w+)/);
|
| 197 |
+
if (mimeMatch) {
|
| 198 |
+
result.container = mimeMatch[1];
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
const codecMatch = format.mime_type?.match(/codecs="([^"]+)"/);
|
| 202 |
+
if (codecMatch) {
|
| 203 |
+
result.encoding = codecMatch[1].split(",")[0].trim();
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
return result;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Convert description to HTML with links
|
| 210 |
+
function descriptionToHtml(description: string): string {
|
| 211 |
+
if (!description) return "";
|
| 212 |
+
|
| 213 |
+
// Escape HTML entities
|
| 214 |
+
let html = description
|
| 215 |
+
.replace(/&/g, "&")
|
| 216 |
+
.replace(/</g, "<")
|
| 217 |
+
.replace(/>/g, ">");
|
| 218 |
+
|
| 219 |
+
// Convert URLs to links
|
| 220 |
+
html = html.replace(
|
| 221 |
+
/(https?:\/\/[^\s]+)/g,
|
| 222 |
+
(url) => {
|
| 223 |
+
const displayUrl = url.replace(/^https?:\/\//, "");
|
| 224 |
+
return `<a href="${url}">${displayUrl}</a>`;
|
| 225 |
+
}
|
| 226 |
+
);
|
| 227 |
+
|
| 228 |
+
// Convert hashtags to links
|
| 229 |
+
html = html.replace(
|
| 230 |
+
/#(\w+)/g,
|
| 231 |
+
'<a href="/hashtag/$1">#$1</a>'
|
| 232 |
+
);
|
| 233 |
+
|
| 234 |
+
return html;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// Calculate relative time string
|
| 238 |
+
function getRelativeTimeString(date: Date): string {
|
| 239 |
+
const now = new Date();
|
| 240 |
+
const diffMs = now.getTime() - date.getTime();
|
| 241 |
+
const diffSeconds = Math.floor(diffMs / 1000);
|
| 242 |
+
const diffMinutes = Math.floor(diffSeconds / 60);
|
| 243 |
+
const diffHours = Math.floor(diffMinutes / 60);
|
| 244 |
+
const diffDays = Math.floor(diffHours / 24);
|
| 245 |
+
const diffWeeks = Math.floor(diffDays / 7);
|
| 246 |
+
const diffMonths = Math.floor(diffDays / 30);
|
| 247 |
+
const diffYears = Math.floor(diffDays / 365);
|
| 248 |
+
|
| 249 |
+
if (diffYears > 0) return `${diffYears} year${diffYears > 1 ? "s" : ""} ago`;
|
| 250 |
+
if (diffMonths > 0) return `${diffMonths} month${diffMonths > 1 ? "s" : ""} ago`;
|
| 251 |
+
if (diffWeeks > 0) return `${diffWeeks} week${diffWeeks > 1 ? "s" : ""} ago`;
|
| 252 |
+
if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
|
| 253 |
+
if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
|
| 254 |
+
if (diffMinutes > 0) return `${diffMinutes} minute${diffMinutes > 1 ? "s" : ""} ago`;
|
| 255 |
+
return "just now";
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// Localize URL to route through local server
|
| 259 |
+
function localizeUrl(url: string, config: any): string {
|
| 260 |
+
if (!url) return url;
|
| 261 |
+
try {
|
| 262 |
+
const urlParsed = new URL(url);
|
| 263 |
+
let queryParams = new URLSearchParams(urlParsed.search);
|
| 264 |
+
queryParams.set("host", urlParsed.host);
|
| 265 |
+
|
| 266 |
+
if (config.server.encrypt_query_params) {
|
| 267 |
+
const publicParams = [...queryParams].filter(([key]) =>
|
| 268 |
+
["pot", "ip"].includes(key) === false
|
| 269 |
+
);
|
| 270 |
+
const privateParams = [...queryParams].filter(([key]) =>
|
| 271 |
+
["pot", "ip"].includes(key) === true
|
| 272 |
+
);
|
| 273 |
+
const encryptedParams = encryptQuery(
|
| 274 |
+
JSON.stringify(privateParams),
|
| 275 |
+
config,
|
| 276 |
+
);
|
| 277 |
+
queryParams = new URLSearchParams(publicParams);
|
| 278 |
+
queryParams.set("enc", "true");
|
| 279 |
+
queryParams.set("data", encryptedParams);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
return config.server.base_path + urlParsed.pathname + "?" + queryParams.toString();
|
| 283 |
+
} catch {
|
| 284 |
+
return url;
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
videos.get("/:videoId", async (c) => {
|
| 289 |
+
const videoId = c.req.param("videoId");
|
| 290 |
+
const { local } = c.req.query();
|
| 291 |
+
c.header("access-control-allow-origin", "*");
|
| 292 |
+
c.header("content-type", "application/json");
|
| 293 |
+
|
| 294 |
+
if (!videoId) {
|
| 295 |
+
throw new HTTPException(400, {
|
| 296 |
+
res: new Response(JSON.stringify({ error: "Video ID is required" })),
|
| 297 |
+
});
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
if (!validateVideoId(videoId)) {
|
| 301 |
+
throw new HTTPException(400, {
|
| 302 |
+
res: new Response(JSON.stringify({ error: "Invalid video ID format" })),
|
| 303 |
+
});
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
const innertubeClient = c.get("innertubeClient");
|
| 307 |
+
const config = c.get("config");
|
| 308 |
+
const metrics = c.get("metrics");
|
| 309 |
+
const tokenMinter = c.get("tokenMinter");
|
| 310 |
+
|
| 311 |
+
// Check if tokenMinter is ready (only needed when PO token is enabled)
|
| 312 |
+
if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) {
|
| 313 |
+
throw new HTTPException(503, {
|
| 314 |
+
res: new Response(JSON.stringify({ error: TOKEN_MINTER_NOT_READY_MESSAGE })),
|
| 315 |
+
});
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
const youtubePlayerResponseJson = await youtubePlayerParsing({
|
| 319 |
+
innertubeClient,
|
| 320 |
+
videoId,
|
| 321 |
+
config,
|
| 322 |
+
tokenMinter: tokenMinter!,
|
| 323 |
+
metrics,
|
| 324 |
+
}) as any;
|
| 325 |
+
|
| 326 |
+
const videoInfo = youtubeVideoInfo(innertubeClient, youtubePlayerResponseJson);
|
| 327 |
+
|
| 328 |
+
if (videoInfo.playability_status?.status !== "OK") {
|
| 329 |
+
throw new HTTPException(400, {
|
| 330 |
+
res: new Response(JSON.stringify({
|
| 331 |
+
error: "Video unavailable",
|
| 332 |
+
reason: videoInfo.playability_status?.reason,
|
| 333 |
+
})),
|
| 334 |
+
});
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// Get the request origin for thumbnail URLs
|
| 338 |
+
const origin = new URL(c.req.url).origin;
|
| 339 |
+
const thumbnailBaseUrl = origin;
|
| 340 |
+
|
| 341 |
+
// Build video details
|
| 342 |
+
const details = videoInfo.basic_info;
|
| 343 |
+
const streamingData = videoInfo.streaming_data;
|
| 344 |
+
|
| 345 |
+
// Parse publish date
|
| 346 |
+
let publishedTimestamp = 0;
|
| 347 |
+
let publishedText = "";
|
| 348 |
+
if (youtubePlayerResponseJson.microformat?.playerMicroformatRenderer?.publishDate) {
|
| 349 |
+
const publishDate = new Date(youtubePlayerResponseJson.microformat.playerMicroformatRenderer.publishDate);
|
| 350 |
+
publishedTimestamp = Math.floor(publishDate.getTime() / 1000);
|
| 351 |
+
publishedText = getRelativeTimeString(publishDate);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// Build adaptive formats
|
| 355 |
+
const adaptiveFormats: AdaptiveFormat[] = [];
|
| 356 |
+
if (streamingData?.adaptive_formats) {
|
| 357 |
+
for (const format of streamingData.adaptive_formats) {
|
| 358 |
+
const converted = convertAdaptiveFormat(format);
|
| 359 |
+
if (local) {
|
| 360 |
+
converted.url = localizeUrl(converted.url, config);
|
| 361 |
+
}
|
| 362 |
+
adaptiveFormats.push(converted);
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
// Build format streams (combined video+audio)
|
| 367 |
+
const formatStreams: FormatStream[] = [];
|
| 368 |
+
if (streamingData?.formats) {
|
| 369 |
+
for (const format of streamingData.formats) {
|
| 370 |
+
const converted = convertFormatStream(format);
|
| 371 |
+
if (local) {
|
| 372 |
+
converted.url = localizeUrl(converted.url, config);
|
| 373 |
+
}
|
| 374 |
+
formatStreams.push(converted);
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
// Build captions
|
| 379 |
+
const captions: Caption[] = [];
|
| 380 |
+
if (videoInfo.captions?.caption_tracks) {
|
| 381 |
+
for (const track of videoInfo.captions.caption_tracks) {
|
| 382 |
+
captions.push({
|
| 383 |
+
label: track.name?.text || track.language_code || "Unknown",
|
| 384 |
+
language_code: track.language_code || "en",
|
| 385 |
+
url: `/api/v1/captions/${videoId}?label=${encodeURIComponent(track.name?.text || track.language_code || "")}`,
|
| 386 |
+
});
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
// Build recommended videos
|
| 391 |
+
const recommendedVideos: RecommendedVideo[] = [];
|
| 392 |
+
// Note: Related videos require a separate API call to /next endpoint
|
| 393 |
+
// For now, we return an empty array - this can be enhanced later
|
| 394 |
+
|
| 395 |
+
// Build author thumbnails from raw YouTube response
|
| 396 |
+
const authorThumbnails: AuthorThumbnail[] = [];
|
| 397 |
+
const channelThumbnails = youtubePlayerResponseJson.videoDetails?.author?.thumbnail?.thumbnails ||
|
| 398 |
+
youtubePlayerResponseJson.microformat?.playerMicroformatRenderer?.ownerProfileUrl ? [] : [];
|
| 399 |
+
|
| 400 |
+
// Generate standard author thumbnail sizes if we have the channel ID
|
| 401 |
+
if (details.channel_id) {
|
| 402 |
+
const sizes = [32, 48, 76, 100, 176, 512];
|
| 403 |
+
for (const size of sizes) {
|
| 404 |
+
authorThumbnails.push({
|
| 405 |
+
url: `https://yt3.ggpht.com/a/default-user=s${size}-c-k-c0x00ffffff-no-rj`,
|
| 406 |
+
width: size,
|
| 407 |
+
height: size,
|
| 408 |
+
});
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
// Get raw YouTube response data
|
| 413 |
+
const videoDetails = (youtubePlayerResponseJson as any).videoDetails || {};
|
| 414 |
+
const microformat = (youtubePlayerResponseJson as any).microformat?.playerMicroformatRenderer || {};
|
| 415 |
+
const playabilityStatus = (youtubePlayerResponseJson as any).playabilityStatus || {};
|
| 416 |
+
const streamingDataRaw = (youtubePlayerResponseJson as any).streamingData || {};
|
| 417 |
+
const captionsRaw = (youtubePlayerResponseJson as any).captions || {};
|
| 418 |
+
const storyboardsRaw = (youtubePlayerResponseJson as any).storyboards || {};
|
| 419 |
+
|
| 420 |
+
// Map thumbnails directly from videoDetails
|
| 421 |
+
const thumbnailArray = [];
|
| 422 |
+
if (videoDetails.thumbnail?.thumbnails) {
|
| 423 |
+
for (const thumb of videoDetails.thumbnail.thumbnails) {
|
| 424 |
+
thumbnailArray.push({
|
| 425 |
+
url: thumb.url,
|
| 426 |
+
width: thumb.width,
|
| 427 |
+
height: thumb.height,
|
| 428 |
+
});
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
// Map storyboards directly from API response
|
| 433 |
+
const storyboardsArray = [];
|
| 434 |
+
if (storyboardsRaw.playerStoryboardSpecRenderer?.spec) {
|
| 435 |
+
const spec = storyboardsRaw.playerStoryboardSpecRenderer.spec;
|
| 436 |
+
const specParts = spec.split('|');
|
| 437 |
+
|
| 438 |
+
for (let i = 3; i < specParts.length; i++) {
|
| 439 |
+
const parts = specParts[i].split('#');
|
| 440 |
+
if (parts.length >= 8) {
|
| 441 |
+
const baseUrl = specParts[0];
|
| 442 |
+
const [width, height, count, columns, rows, interval, name, sigh] = parts;
|
| 443 |
+
const storyboardCount = Math.ceil(parseInt(count) / (parseInt(columns) * parseInt(rows)));
|
| 444 |
+
|
| 445 |
+
const urls = [];
|
| 446 |
+
for (let j = 0; j < storyboardCount; j++) {
|
| 447 |
+
let url = baseUrl.replace('$L', i - 3).replace('$N', name) + j;
|
| 448 |
+
if (sigh) url += '&sigh=' + sigh;
|
| 449 |
+
urls.push(url);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
storyboardsArray.push({
|
| 453 |
+
width: width,
|
| 454 |
+
height: height,
|
| 455 |
+
thumbsCount: count,
|
| 456 |
+
columns: columns,
|
| 457 |
+
rows: rows,
|
| 458 |
+
interval: interval,
|
| 459 |
+
storyboardCount: storyboardCount,
|
| 460 |
+
url: urls,
|
| 461 |
+
});
|
| 462 |
+
}
|
| 463 |
+
}
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
// Map captions directly from API response
|
| 467 |
+
const captionTracks = [];
|
| 468 |
+
if (captionsRaw.playerCaptionsTracklistRenderer?.captionTracks) {
|
| 469 |
+
for (const track of captionsRaw.playerCaptionsTracklistRenderer.captionTracks) {
|
| 470 |
+
captionTracks.push({
|
| 471 |
+
baseUrl: track.baseUrl,
|
| 472 |
+
name: track.name?.simpleText || track.languageCode,
|
| 473 |
+
vssId: track.vssId || "",
|
| 474 |
+
languageCode: track.languageCode,
|
| 475 |
+
isTranslatable: track.isTranslatable ?? true,
|
| 476 |
+
});
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
// Map audioTracks directly from API response
|
| 481 |
+
const audioTracks = [];
|
| 482 |
+
if (captionsRaw.playerCaptionsTracklistRenderer?.audioTracks) {
|
| 483 |
+
for (const track of captionsRaw.playerCaptionsTracklistRenderer.audioTracks) {
|
| 484 |
+
audioTracks.push({
|
| 485 |
+
languageName: track.displayName || track.id,
|
| 486 |
+
languageCode: track.id,
|
| 487 |
+
});
|
| 488 |
+
}
|
| 489 |
+
} else if (captionsRaw.playerCaptionsTracklistRenderer?.captionTracks) {
|
| 490 |
+
// Fallback: extract unique languages from caption tracks
|
| 491 |
+
const uniqueLangs = new Set();
|
| 492 |
+
for (const track of captionsRaw.playerCaptionsTracklistRenderer.captionTracks) {
|
| 493 |
+
const langCode = track.languageCode;
|
| 494 |
+
if (!uniqueLangs.has(langCode)) {
|
| 495 |
+
uniqueLangs.add(langCode);
|
| 496 |
+
audioTracks.push({
|
| 497 |
+
languageName: track.name?.simpleText || langCode,
|
| 498 |
+
languageCode: langCode,
|
| 499 |
+
});
|
| 500 |
+
}
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// Map formats directly from streamingData
|
| 505 |
+
const formatsArray = [];
|
| 506 |
+
if (streamingDataRaw.formats) {
|
| 507 |
+
for (const format of streamingDataRaw.formats) {
|
| 508 |
+
const formatObj: any = {
|
| 509 |
+
itag: format.itag,
|
| 510 |
+
url: format.url,
|
| 511 |
+
mimeType: format.mimeType,
|
| 512 |
+
bitrate: format.bitrate,
|
| 513 |
+
width: format.width || 0,
|
| 514 |
+
height: format.height || 0,
|
| 515 |
+
lastModified: format.lastModified,
|
| 516 |
+
contentLength: format.contentLength,
|
| 517 |
+
quality: format.quality,
|
| 518 |
+
fps: format.fps,
|
| 519 |
+
qualityLabel: format.qualityLabel,
|
| 520 |
+
projectionType: format.projectionType || "RECTANGULAR",
|
| 521 |
+
averageBitrate: format.averageBitrate,
|
| 522 |
+
approxDurationMs: format.approxDurationMs,
|
| 523 |
+
};
|
| 524 |
+
|
| 525 |
+
if (format.audioQuality) formatObj.audioQuality = format.audioQuality;
|
| 526 |
+
if (format.audioSampleRate) formatObj.audioSampleRate = format.audioSampleRate;
|
| 527 |
+
if (format.audioChannels) formatObj.audioChannels = format.audioChannels;
|
| 528 |
+
if (format.qualityLabel) {
|
| 529 |
+
formatObj.qualityOrdinal = "QUALITY_ORDINAL_" + format.qualityLabel.replace(/\d+/, "").replace('p', 'P');
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
formatsArray.push(formatObj);
|
| 533 |
+
}
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
// Map adaptiveFormats directly from streamingData
|
| 537 |
+
const adaptiveFormatsArray = [];
|
| 538 |
+
if (streamingDataRaw.adaptiveFormats) {
|
| 539 |
+
for (const format of streamingDataRaw.adaptiveFormats) {
|
| 540 |
+
const adaptiveFormat: any = {
|
| 541 |
+
itag: format.itag,
|
| 542 |
+
url: format.url,
|
| 543 |
+
mimeType: format.mimeType,
|
| 544 |
+
bitrate: format.bitrate,
|
| 545 |
+
width: format.width || 0,
|
| 546 |
+
height: format.height || 0,
|
| 547 |
+
lastModified: format.lastModified,
|
| 548 |
+
contentLength: format.contentLength,
|
| 549 |
+
quality: format.quality,
|
| 550 |
+
fps: format.fps,
|
| 551 |
+
qualityLabel: format.qualityLabel,
|
| 552 |
+
projectionType: format.projectionType || "RECTANGULAR",
|
| 553 |
+
averageBitrate: format.averageBitrate,
|
| 554 |
+
approxDurationMs: format.approxDurationMs,
|
| 555 |
+
};
|
| 556 |
+
|
| 557 |
+
if (format.initRange) {
|
| 558 |
+
adaptiveFormat.initRange = {
|
| 559 |
+
start: format.initRange.start,
|
| 560 |
+
end: format.initRange.end,
|
| 561 |
+
};
|
| 562 |
+
}
|
| 563 |
+
if (format.indexRange) {
|
| 564 |
+
adaptiveFormat.indexRange = {
|
| 565 |
+
start: format.indexRange.start,
|
| 566 |
+
end: format.indexRange.end,
|
| 567 |
+
};
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
if (format.audioQuality) adaptiveFormat.audioQuality = format.audioQuality;
|
| 571 |
+
if (format.audioSampleRate) adaptiveFormat.audioSampleRate = format.audioSampleRate;
|
| 572 |
+
if (format.audioChannels) adaptiveFormat.audioChannels = format.audioChannels;
|
| 573 |
+
if (format.colorInfo) adaptiveFormat.colorInfo = format.colorInfo;
|
| 574 |
+
if (format.highReplication) adaptiveFormat.highReplication = format.highReplication;
|
| 575 |
+
if (format.loudnessDb !== undefined) adaptiveFormat.loudnessDb = format.loudnessDb;
|
| 576 |
+
|
| 577 |
+
if (format.qualityLabel) {
|
| 578 |
+
adaptiveFormat.qualityOrdinal = "QUALITY_ORDINAL_" + format.qualityLabel.replace(/\d+/, "").replace('p', 'P');
|
| 579 |
+
} else {
|
| 580 |
+
adaptiveFormat.qualityOrdinal = "QUALITY_ORDINAL_UNKNOWN";
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
adaptiveFormatsArray.push(adaptiveFormat);
|
| 584 |
+
}
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
const currentTimestamp = Math.floor(Date.now() / 1000);
|
| 588 |
+
|
| 589 |
+
const response = {
|
| 590 |
+
status: playabilityStatus.status || "OK",
|
| 591 |
+
id: videoDetails.videoId || videoId,
|
| 592 |
+
title: videoDetails.title || "",
|
| 593 |
+
lengthSeconds: videoDetails.lengthSeconds || "0",
|
| 594 |
+
keywords: videoDetails.keywords || [],
|
| 595 |
+
channelTitle: videoDetails.author || "",
|
| 596 |
+
channelId: videoDetails.channelId || "",
|
| 597 |
+
description: videoDetails.shortDescription || "",
|
| 598 |
+
thumbnail: thumbnailArray,
|
| 599 |
+
allowRatings: videoDetails.allowRatings ?? true,
|
| 600 |
+
viewCount: videoDetails.viewCount || "0",
|
| 601 |
+
isPrivate: videoDetails.isPrivate || false,
|
| 602 |
+
isUnpluggedCorpus: videoDetails.isUnpluggedCorpus || false,
|
| 603 |
+
isLiveContent: videoDetails.isLiveContent || false,
|
| 604 |
+
storyboards: storyboardsArray,
|
| 605 |
+
captions: {
|
| 606 |
+
captionTracks: captionTracks,
|
| 607 |
+
},
|
| 608 |
+
audioTracks: audioTracks,
|
| 609 |
+
defaultVideoLanguage: microformat.defaultLanguage || "English",
|
| 610 |
+
defaultVideoLanguageCode: microformat.defaultLanguage || "en",
|
| 611 |
+
fetchedTS: currentTimestamp,
|
| 612 |
+
expiresInSeconds: streamingDataRaw.expiresInSeconds || "21540",
|
| 613 |
+
formats: formatsArray,
|
| 614 |
+
isGCR: false,
|
| 615 |
+
adaptiveFormats: adaptiveFormatsArray,
|
| 616 |
+
availableAt: currentTimestamp,
|
| 617 |
+
};
|
| 618 |
+
|
| 619 |
+
return c.json(response);
|
| 620 |
+
});
|
| 621 |
+
|
| 622 |
+
export default videos;
|
src/routes/metrics.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
src/routes/videoPlaybackProxy.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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<string, string> = {
|
| 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 { 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 |
+
|
| 53 |
+
|
| 54 |
+
if (
|
| 55 |
+
expire == undefined ||
|
| 56 |
+
Number(expire) < Number(Date.now().toString().slice(0, -3))
|
| 57 |
+
) {
|
| 58 |
+
throw new HTTPException(400, {
|
| 59 |
+
res: new Response(
|
| 60 |
+
"Expire query string undefined or videoplayback URL has expired.",
|
| 61 |
+
),
|
| 62 |
+
});
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
if (client == undefined) {
|
| 66 |
+
throw new HTTPException(400, {
|
| 67 |
+
res: new Response("'c' query string undefined."),
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
queryParams.delete("title");
|
| 73 |
+
|
| 74 |
+
const rangeHeader = c.req.header("range");
|
| 75 |
+
const requestBytes = rangeHeader ? rangeHeader.split("=")[1] : null;
|
| 76 |
+
const [firstByte, lastByte] = requestBytes?.split("-") || [];
|
| 77 |
+
if (requestBytes) {
|
| 78 |
+
queryParams.append(
|
| 79 |
+
"range",
|
| 80 |
+
requestBytes,
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const headersToSend: HeadersInit = {
|
| 85 |
+
"accept": "*/*",
|
| 86 |
+
"accept-encoding": "gzip, deflate, br, zstd",
|
| 87 |
+
"accept-language": "en-us,en;q=0.5",
|
| 88 |
+
"origin": "https://www.youtube.com",
|
| 89 |
+
"referer": "https://www.youtube.com",
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
if (client == "ANDROID") {
|
| 93 |
+
headersToSend["user-agent"] =
|
| 94 |
+
"com.google.android.youtube/1537338816 (Linux; U; Android 13; en_US; ; Build/TQ2A.230505.002; Cronet/113.0.5672.24)";
|
| 95 |
+
} else if (client == "IOS") {
|
| 96 |
+
headersToSend["user-agent"] =
|
| 97 |
+
"com.google.ios.youtube/19.32.8 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)";
|
| 98 |
+
} else {
|
| 99 |
+
headersToSend["user-agent"] =
|
| 100 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const fetchClient = await getFetchClient(config);
|
| 104 |
+
|
| 105 |
+
let headResponse: Response | undefined;
|
| 106 |
+
let location = `https://redirector.googlevideo.com/videoplayback?${queryParams.toString()}`;
|
| 107 |
+
|
| 108 |
+
// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-p2-semantics-17#section-7.3
|
| 109 |
+
// A maximum of 5 redirections is defined in the note of the section 7.3
|
| 110 |
+
// of this RFC, that's why `i < 5`
|
| 111 |
+
for (let i = 0; i < 5; i++) {
|
| 112 |
+
const googlevideoResponse: Response = await fetchClient(location, {
|
| 113 |
+
method: "HEAD",
|
| 114 |
+
headers: headersToSend,
|
| 115 |
+
redirect: "manual",
|
| 116 |
+
});
|
| 117 |
+
if (googlevideoResponse.status == 403) {
|
| 118 |
+
return new Response(googlevideoResponse.body, {
|
| 119 |
+
status: googlevideoResponse.status,
|
| 120 |
+
statusText: googlevideoResponse.statusText,
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
if (googlevideoResponse.headers.has("Location")) {
|
| 124 |
+
location = googlevideoResponse.headers.get("Location") as string;
|
| 125 |
+
continue;
|
| 126 |
+
} else {
|
| 127 |
+
headResponse = googlevideoResponse;
|
| 128 |
+
break;
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
if (headResponse === undefined) {
|
| 132 |
+
throw new HTTPException(502, {
|
| 133 |
+
res: new Response(
|
| 134 |
+
"Google headResponse redirected too many times",
|
| 135 |
+
),
|
| 136 |
+
});
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// =================== REQUEST CHUNKING =======================
|
| 140 |
+
// if the requested response is larger than the chunkSize, break up the response
|
| 141 |
+
// into chunks and stream the response back to the client to avoid rate limiting
|
| 142 |
+
const { readable, writable } = new TransformStream();
|
| 143 |
+
const stream = new StreamingApi(writable, readable);
|
| 144 |
+
const googleVideoUrl = new URL(location);
|
| 145 |
+
const getChunk = async (start: number, end: number) => {
|
| 146 |
+
googleVideoUrl.searchParams.set(
|
| 147 |
+
"range",
|
| 148 |
+
`${start}-${end}`,
|
| 149 |
+
);
|
| 150 |
+
const postResponse = await fetchClient(googleVideoUrl, {
|
| 151 |
+
method: "POST",
|
| 152 |
+
body: new Uint8Array([0x78, 0]), // protobuf: { 15: 0 } (no idea what it means but this is what YouTube uses),
|
| 153 |
+
headers: headersToSend,
|
| 154 |
+
});
|
| 155 |
+
if (postResponse.status !== 200) {
|
| 156 |
+
throw new Error("Non-200 response from google servers");
|
| 157 |
+
}
|
| 158 |
+
await stream.pipe(postResponse.body);
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
const chunkSize =
|
| 162 |
+
config.networking.videoplayback.video_fetch_chunk_size_mb * 1_000_000;
|
| 163 |
+
const totalBytes = Number(
|
| 164 |
+
headResponse.headers.get("Content-Length") || "0",
|
| 165 |
+
);
|
| 166 |
+
|
| 167 |
+
// if no range sent, the client wants thw whole file, i.e. for downloads
|
| 168 |
+
const wholeRequestStartByte = Number(firstByte || "0");
|
| 169 |
+
const wholeRequestEndByte = wholeRequestStartByte + Number(totalBytes) - 1;
|
| 170 |
+
|
| 171 |
+
let chunk = Promise.resolve();
|
| 172 |
+
for (
|
| 173 |
+
let startByte = wholeRequestStartByte;
|
| 174 |
+
startByte < wholeRequestEndByte;
|
| 175 |
+
startByte += chunkSize
|
| 176 |
+
) {
|
| 177 |
+
// i.e.
|
| 178 |
+
// 0 - 4_999_999, then
|
| 179 |
+
// 5_000_000 - 9_999_999, then
|
| 180 |
+
// 10_000_000 - 14_999_999
|
| 181 |
+
let endByte = startByte + chunkSize - 1;
|
| 182 |
+
if (endByte > wholeRequestEndByte) {
|
| 183 |
+
endByte = wholeRequestEndByte;
|
| 184 |
+
}
|
| 185 |
+
chunk = chunk.then(() => getChunk(startByte, endByte));
|
| 186 |
+
}
|
| 187 |
+
chunk.catch(() => {
|
| 188 |
+
stream.abort();
|
| 189 |
+
});
|
| 190 |
+
// =================== REQUEST CHUNKING =======================
|
| 191 |
+
|
| 192 |
+
const headersForResponse: Record<string, string> = {
|
| 193 |
+
"content-length": headResponse.headers.get("content-length") || "",
|
| 194 |
+
"access-control-allow-origin": "*",
|
| 195 |
+
"accept-ranges": headResponse.headers.get("accept-ranges") || "",
|
| 196 |
+
"content-type": headResponse.headers.get("content-type") || "",
|
| 197 |
+
"expires": headResponse.headers.get("expires") || "",
|
| 198 |
+
"last-modified": headResponse.headers.get("last-modified") || "",
|
| 199 |
+
};
|
| 200 |
+
|
| 201 |
+
if (title) {
|
| 202 |
+
headersForResponse["content-disposition"] = `attachment; filename="${encodeURIComponent(title)
|
| 203 |
+
}"; filename*=UTF-8''${encodeRFC5987ValueChars(title)}`;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
let responseStatus = headResponse.status;
|
| 207 |
+
if (requestBytes && responseStatus == 200) {
|
| 208 |
+
// check for range headers in the forms:
|
| 209 |
+
// "bytes=0-" get full length from start
|
| 210 |
+
// "bytes=500-" get full length from 500 bytes in
|
| 211 |
+
// "bytes=500-1000" get 500 bytes starting from 500
|
| 212 |
+
if (lastByte) {
|
| 213 |
+
responseStatus = 206;
|
| 214 |
+
headersForResponse["content-range"] = `bytes ${requestBytes}/${queryParams.get("clen") || "*"
|
| 215 |
+
}`;
|
| 216 |
+
} else {
|
| 217 |
+
// i.e. "bytes=0-", "bytes=600-"
|
| 218 |
+
// full size of content is able to be calculated, so a full Content-Range header can be constructed
|
| 219 |
+
const bytesReceived = headersForResponse["content-length"];
|
| 220 |
+
// last byte should always be one less than the length
|
| 221 |
+
const totalContentLength = Number(firstByte) +
|
| 222 |
+
Number(bytesReceived);
|
| 223 |
+
const lastByte = totalContentLength - 1;
|
| 224 |
+
if (firstByte !== "0") {
|
| 225 |
+
// only part of the total content returned, 206
|
| 226 |
+
responseStatus = 206;
|
| 227 |
+
}
|
| 228 |
+
headersForResponse["content-range"] =
|
| 229 |
+
`bytes ${firstByte}-${lastByte}/${totalContentLength}`;
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
return new Response(stream.responseReadable, {
|
| 234 |
+
status: responseStatus,
|
| 235 |
+
statusText: headResponse.statusText,
|
| 236 |
+
headers: headersForResponse,
|
| 237 |
+
});
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
export default videoPlaybackProxy;
|
src/routes/youtube_api_routes/player.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Hono } from "hono";
|
| 2 |
+
import { youtubePlayerParsing } from "../../lib/helpers/youtubePlayerHandling.ts";
|
| 3 |
+
import { HTTPException } from "hono/http-exception";
|
| 4 |
+
import { validateVideoId } from "../../lib/helpers/validateVideoId.ts";
|
| 5 |
+
import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts";
|
| 6 |
+
|
| 7 |
+
const player = new Hono();
|
| 8 |
+
|
| 9 |
+
player.post("/player", async (c) => {
|
| 10 |
+
const jsonReq = await c.req.json();
|
| 11 |
+
const innertubeClient = c.get("innertubeClient");
|
| 12 |
+
const config = c.get("config");
|
| 13 |
+
const metrics = c.get("metrics");
|
| 14 |
+
const tokenMinter = c.get("tokenMinter");
|
| 15 |
+
|
| 16 |
+
// Check if tokenMinter is ready (only needed when PO token is enabled)
|
| 17 |
+
if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) {
|
| 18 |
+
return c.json({
|
| 19 |
+
playabilityStatus: {
|
| 20 |
+
status: "ERROR",
|
| 21 |
+
reason: TOKEN_MINTER_NOT_READY_MESSAGE,
|
| 22 |
+
errorScreen: {
|
| 23 |
+
playerErrorMessageRenderer: {
|
| 24 |
+
reason: {
|
| 25 |
+
simpleText: TOKEN_MINTER_NOT_READY_MESSAGE,
|
| 26 |
+
},
|
| 27 |
+
subreason: {
|
| 28 |
+
simpleText: TOKEN_MINTER_NOT_READY_MESSAGE,
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
},
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (jsonReq.videoId) {
|
| 37 |
+
if (!validateVideoId(jsonReq.videoId)) {
|
| 38 |
+
throw new HTTPException(400, {
|
| 39 |
+
res: new Response("Invalid video ID format."),
|
| 40 |
+
});
|
| 41 |
+
}
|
| 42 |
+
return c.json(
|
| 43 |
+
await youtubePlayerParsing({
|
| 44 |
+
innertubeClient,
|
| 45 |
+
videoId: jsonReq.videoId,
|
| 46 |
+
config,
|
| 47 |
+
tokenMinter: tokenMinter!,
|
| 48 |
+
metrics,
|
| 49 |
+
}),
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
export default player;
|
src/tests/dashManifest.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|
src/tests/deps.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export {
|
| 2 |
+
assert,
|
| 3 |
+
assertEquals,
|
| 4 |
+
assertRejects,
|
| 5 |
+
assertThrows,
|
| 6 |
+
} from "jsr:@std/assert@1.0.12";
|