cursorpro commited on
Commit
4d612cb
·
verified ·
1 Parent(s): 2f2ba98

Upload 57 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +61 -0
  2. LICENSE +661 -0
  3. compile.env +1 -0
  4. config/config.example.toml +80 -0
  5. config/config.toml +60 -0
  6. deno.json +47 -0
  7. deno.lock +0 -0
  8. entrypoint.sh +51 -0
  9. go.mod +11 -0
  10. go.sum +0 -0
  11. grafana_dashboard.json +888 -0
  12. invidious-companion.service +42 -0
  13. server.go +311 -0
  14. src/.DS_Store +0 -0
  15. src/constants.ts +11 -0
  16. src/lib/extra/emptyExport.ts +1 -0
  17. src/lib/helpers/config.ts +180 -0
  18. src/lib/helpers/encodeRFC5987ValueChars.ts +21 -0
  19. src/lib/helpers/encryptQuery.ts +56 -0
  20. src/lib/helpers/getFetchClient.ts +107 -0
  21. src/lib/helpers/ipv6Rotation.ts +109 -0
  22. src/lib/helpers/jsInterpreter.ts +22 -0
  23. src/lib/helpers/metrics.ts +126 -0
  24. src/lib/helpers/proxyManager.ts +452 -0
  25. src/lib/helpers/urbanProxy.ts +233 -0
  26. src/lib/helpers/validateVideoId.ts +23 -0
  27. src/lib/helpers/verifyRequest.ts +39 -0
  28. src/lib/helpers/youtubePlayerHandling.ts +195 -0
  29. src/lib/helpers/youtubePlayerReq.ts +105 -0
  30. src/lib/helpers/youtubeTranscriptsHandling.ts +90 -0
  31. src/lib/jobs/potoken.ts +252 -0
  32. src/lib/jobs/worker.ts +251 -0
  33. src/lib/types/HonoVariables.ts +11 -0
  34. src/main.ts +264 -0
  35. src/routes/health.ts +12 -0
  36. src/routes/index.ts +89 -0
  37. src/routes/invidious_routes/captions.ts +114 -0
  38. src/routes/invidious_routes/channels.ts +255 -0
  39. src/routes/invidious_routes/dashManifest.ts +132 -0
  40. src/routes/invidious_routes/download.ts +100 -0
  41. src/routes/invidious_routes/latestVersion.ts +119 -0
  42. src/routes/invidious_routes/mixes.ts +91 -0
  43. src/routes/invidious_routes/playlists.ts +165 -0
  44. src/routes/invidious_routes/search.ts +300 -0
  45. src/routes/invidious_routes/videos.ts +622 -0
  46. src/routes/metrics.ts +11 -0
  47. src/routes/videoPlaybackProxy.ts +240 -0
  48. src/routes/youtube_api_routes/player.ts +54 -0
  49. src/tests/dashManifest.ts +13 -0
  50. 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
+ "&": "&amp;",
20
+ "<": "&lt;",
21
+ ">": "&gt;",
22
+ "\u200E": "&lrm;",
23
+ "\u200F": "&rlm;",
24
+ "\u00A0": "&nbsp;",
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, "&amp;")
216
+ .replace(/</g, "&lt;")
217
+ .replace(/>/g, "&gt;");
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";