diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a510da8b989fb92930f409f719890c61bb17fdcb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +FROM golang:bookworm as builder +WORKDIR /go/src +EXPOSE 8080 + +# Install git first as it is required for go mod tidy +RUN apt-get update && apt-get install -y git build-essential + +# Copy everything first +COPY go.mod go.sum ./ +COPY warp.go server.go ./ + +# Resolution ambiguity fix: Explicitly fetch the main module and tidy +RUN go mod download && go mod tidy + +# Build binaries +RUN CGO_ENABLED=0 GOOS=linux \ + go build -a -installsuffix cgo -ldflags '-s' -o warp warp.go && \ + go build -a -installsuffix cgo -ldflags '-s' -o server server.go + +FROM ubuntu:22.04 + +# Copy binaries +COPY --from=builder /go/src/warp /usr/local/bin/ +COPY --from=builder /go/src/server /usr/local/bin/ +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +COPY entrypoint.sh /usr/local/bin/ + +# Install dependencies and Deno +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + curl \ + ca-certificates \ + unzip \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL https://deno.land/x/install/install.sh | sh \ + && mv /root/.deno/bin/deno /usr/local/bin/deno \ + && chmod +x /usr/local/bin/entrypoint.sh + +# Copy Deno App +WORKDIR /app +COPY deno.json deno.lock compile.env grafana_dashboard.json ./ +COPY config ./config +COPY src ./src + +ENV DAEMON_MODE false +ENV PROXY_UP "" +ENV PROXY_PORT "8080" +ENV PROXY_USER "" +ENV PROXY_PASS "" +ENV WIREGUARD_UP "" +ENV WIREGUARD_CONFIG "" +ENV WIREGUARD_INTERFACE_PRIVATE_KEY "" +ENV WIREGUARD_INTERFACE_DNS "1.1.1.1" +ENV WIREGUARD_INTERFACE_ADDRESS "" +ENV WIREGUARD_PEER_PUBLIC_KEY "" +ENV WIREGUARD_PEER_ALLOWED_IPS "0.0.0.0/0" +ENV WIREGUARD_PEER_ENDPOINT "" + +ENTRYPOINT [ "entrypoint.sh" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..1ce875873d862b9c6b8583c6be32a864921ba551 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/compile.env b/compile.env new file mode 100644 index 0000000000000000000000000000000000000000..816eabc33734629fa9fd596174406fb5d5914855 --- /dev/null +++ b/compile.env @@ -0,0 +1 @@ +DENO_COMPILED=true diff --git a/config/config.example.toml b/config/config.example.toml new file mode 100644 index 0000000000000000000000000000000000000000..4bf2c36a0ee80a294346f49bf8cb250dc9903faa --- /dev/null +++ b/config/config.example.toml @@ -0,0 +1,80 @@ +##### +# The configuration options listed below are able to be enabled as needed. +# The values in this example are the defaults. Some values can alternatively +# be set using an environment variable. +# +# In order to enable an option, make sure you uncomment both the option +# and the block header for the section it belongs to. Any other commented +# options will continue to use default values. +# See https://toml.io/en/ for details on the configuration format. +##### + +# [server] +# port = 8282 # env variable: PORT +# host = "127.0.0.1" # env variable: HOST +# # Listens to a unix socket on instead of a TCP socket +# use_unix_socket = false # env variable: SERVER_USE_UNIX_SOCKET +# unix_socket_path = "/tmp/invidious-companion.sock" # env variable: SERVER_UNIX_SOCKET_PATH +# # Base path Invidious companion will serve from +# base_path = "/companion" # env variable: SERVER_BASE_PATH +# # secret key needs to be exactly 16 characters long +# secret_key = "CHANGE_ME" # env variable: SERVER_SECRET_KEY +# verify_requests = false # env variable: SERVER_VERIFY_REQUESTS +# encrypt_query_params = false # env variable: SERVER_ENCRYPT_QUERY_PARAMS +# enable_metrics = false # env variable: SERVER_ENABLE_METRICS + +# [cache] +# enabled = true # env variable: CACHE_ENABLED +# # will get cached in /var/tmp/youtubei.js if you specify /var/tmp +# # you need to change the --allow-write from deno run too +# directory = "/var/tmp" # env variable: CACHE_DIRECTORY + +# [networking] +## Proxy type supported: https://docs.deno.com/api/deno/~/Deno.Proxy +# #proxy = "" # env variable: PROXY +# # Enable automatic proxy fetching from antpeak.com (free proxies, auto-rotates when failed) +# # When enabled, ignores the `proxy` setting above and fetches proxies automatically +# auto_proxy = false # env variable: NETWORKING_AUTO_PROXY +# # IPv6 rotation settings - allows sending requests with unique IPv6 addresses +# # 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 +# # Randomizes all bits after the block prefix (e.g., /32 randomizes bits 33-128) +# #ipv6_block = "2001:db8::/32" # env variable: NETWORKING_IPV6_BLOCK + +# [networking.videoplayback] +# # Enable YouTube new video format UMP +# ump = false # env variable: NETWORKING_VIDEOPLAYBACK_UMP +# # size of chunks to request from google servers for rate limiting reductions +# video_fetch_chunk_size_mb = 5 # env variable: NETWORKING_VIDEOPLAYBACK_VIDEO_FETCH_CHUNK_SIZE_MB + +### +# Network call timeouts when talking to YouTube. +# Needed in order to ensure Deno closes hanging connections +### +# [networking.fetch] +# timeout_ms = 30000 # env variable: NETWORKING_FETCH_TIMEOUT_MS + +### +# Network call retries when talking to YouTube, using +# https://docs.deno.com/examples/exponential_backoff/ +### +# [networking.fetch.retry] +# # enable retries on calls to YouTube +# enabled = false # env variable: NETWORKING_FETCH_RETRY_ENABLED +# # max number of times to retry +# times = 1 # env variable: NETWORKING_FETCH_RETRY_TIMES +# # minimum wait after first call (ms) +# initial_debounce = 0 # env variable: NETWORKING_FETCH_RETRY_INITIAL_DEBOUNCE +# # how much to back off after each retry (multiplier of initial_debounce) +# debounce_multiplier = 0 # env variable: NETWORKING_FETCH_RETRY_DEBOUNCE_MULTIPLIER + +# [jobs] + +# [jobs.youtube_session] +# # whether to generate PO tokens +# po_token_enabled = true # env variable: JOBS_YOUTUBE_SESSION_PO_TOKEN_ENABLED +# # frequency of PO token refresh in cron format +# frequency = "*/5 * * * *" # env variable: JOBS_YOUTUBE_SESSION_FREQUENCY + +# [youtube_session] +# oauth_enabled = false # env variable: YOUTUBE_SESSION_OAUTH_ENABLED +# cookies = "" # env variable: YOUTUBE_SESSION_COOKIES diff --git a/config/config.toml b/config/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..0e83eddd1de92aeb7380f13713c0450d345013f6 --- /dev/null +++ b/config/config.toml @@ -0,0 +1,60 @@ +##### +# Invidious Companion Configuration +# +# See https://toml.io/en/ for details on the configuration format. +##### + +[server] +port = 7860 # env: PORT +host = "0.0.0.0" # env: HOST +secret_key = "0123456789abcdef" # env: SERVER_SECRET_KEY + +# Optional Server Settings +# use_unix_socket = false # env: SERVER_USE_UNIX_SOCKET +# unix_socket_path = "/tmp/invidious-companion.sock" +base_path = "/" # env: SERVER_BASE_PATH +# verify_requests = false # env: SERVER_VERIFY_REQUESTS +# encrypt_query_params = false # env: SERVER_ENCRYPT_QUERY_PARAMS +enable_metrics = true # env: SERVER_ENABLE_METRICS + +[cache] +enabled = false # env: CACHE_ENABLED +# directory = "/var/tmp" # env: CACHE_DIRECTORY + +[networking] +# Auto Proxy Settings +# auto_proxy: enable automatic proxy fetching (rotates on failure) +auto_proxy = false # env: NETWORKING_AUTO_PROXY + +# VPN Source: Which service to use for auto_proxy +# 1 = AntPeak (Default) +# 2 = Urban VPN +# 3 = Custom Proxy API (self hosted) +vpn_source = 2 # env: NETWORKING_VPN_SOURCE + +# Manual Proxy (overrides auto_proxy if set) + proxy = "http://127.0.0.1:8080" + +# IPv6 Rotation +# ipv6_block = "2001:db8::/32" # env: NETWORKING_IPV6_BLOCK + +[networking.videoplayback] +ump = false # env: NETWORKING_VIDEOPLAYBACK_UMP +video_fetch_chunk_size_mb = 5 # env: NETWORKING_VIDEOPLAYBACK_VIDEO_FETCH_CHUNK_SIZE_MB + +[networking.fetch] +# timeout_ms = 30000 # env: NETWORKING_FETCH_TIMEOUT_MS + +[networking.fetch.retry] +# enabled = false # env: NETWORKING_FETCH_RETRY_ENABLED +# times = 1 # env: NETWORKING_FETCH_RETRY_TIMES +# initial_debounce = 0 +# debounce_multiplier = 0 + +[jobs.youtube_session] +po_token_enabled = true # env: JOBS_YOUTUBE_SESSION_PO_TOKEN_ENABLED +frequency = "*/5 * * * *" # env: JOBS_YOUTUBE_SESSION_FREQUENCY + +[youtube_session] +# oauth_enabled = true # env: YOUTUBE_SESSION_OAUTH_ENABLED +# cookies = "" # env: YOUTUBE_SESSION_COOKIES diff --git a/deno.json b/deno.json new file mode 100644 index 0000000000000000000000000000000000000000..f3da7e7f2526a24cc6ceb06b9a942661557b8645 --- /dev/null +++ b/deno.json @@ -0,0 +1,47 @@ +{ + "tasks": { + "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", + "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)\"", + "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", + "format": "deno fmt src/**", + "check": "deno check src/**", + "lint": "deno lint src/**" + }, + "imports": { + "@std/cli": "jsr:@std/cli@^1.0.17", + "hono": "jsr:@hono/hono@4.7.4", + "@std/toml": "jsr:@std/toml@1.0.2", + "prom-client": "https://esm.sh/prom-client@15.1.3?pin=v135", + "youtubei.js": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno.ts", + "youtubei.js/Utils": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/Utils.ts", + "youtubei.js/NavigationEndpoint": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/NavigationEndpoint.ts", + "youtubei.js/PlayerCaptionsTracklist": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerCaptionsTracklist.ts", + "youtubei.js/TabbedFeed": "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/mixins/TabbedFeed.ts", + "jsdom": "npm:jsdom@26.1.0", + "bgutils": "https://esm.sh/bgutils-js@3.2.0", + "estree": "https://esm.sh/@types/estree@1.0.6", + "youtubePlayerReq": "./src/lib/helpers/youtubePlayerReq.ts", + "getFetchClient": "./src/lib/helpers/getFetchClient.ts", + "googlevideo": "jsr:@luanrt/googlevideo@2.0.0", + "meriyah": "npm:meriyah@6.1.4", + "crypto/": "https://deno.land/x/crypto@v0.11.0/", + "@std/encoding/base64": "jsr:@std/encoding@1.0.7/base64", + "@std/async": "jsr:@std/async@1.0.11", + "@std/fs": "jsr:@std/fs@1.0.14", + "@std/path": "jsr:@std/path@1.0.8", + "brotli": "https://deno.land/x/brotli@0.1.7/mod.ts", + "zod": "https://deno.land/x/zod@v3.24.2/mod.ts", + "canvas": "./src/lib/extra/emptyExport.ts", + "bufferutil": "./src/lib/extra/emptyExport.ts", + "utf-8-validate": "./src/lib/extra/emptyExport.ts" + }, + "unstable": [ + "cron", + "kv", + "http", + "temporal" + ], + "fmt": { + "indentWidth": 4 + } +} \ No newline at end of file diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000000000000000000000000000000000000..43b9594237e7fa775f6f1bbcb1da33d7c2039fda --- /dev/null +++ b/deno.lock @@ -0,0 +1,1208 @@ +{ + "version": "5", + "specifiers": { + "jsr:@hono/hono@4.7.4": "4.7.4", + "jsr:@std/assert@1.0.12": "1.0.12", + "jsr:@std/async@1.0.11": "1.0.11", + "jsr:@std/cli@^1.0.17": "1.0.24", + "jsr:@std/collections@^1.0.9": "1.1.3", + "jsr:@std/encoding@1.0.7": "1.0.7", + "jsr:@std/fs@1.0.14": "1.0.14", + "jsr:@std/internal@^1.0.6": "1.0.12", + "jsr:@std/toml@1.0.2": "1.0.2", + "npm:jsdom@26.1.0": "26.1.0", + "npm:meriyah@6.1.4": "6.1.4" + }, + "jsr": { + "@hono/hono@4.7.4": { + "integrity": "c03c9cbe0fbfc4e51f3fee6502a7903aa4f9ef7c2c98635607b15eee14258825" + }, + "@std/assert@1.0.12": { + "integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/async@1.0.11": { + "integrity": "eee0d3405275506638a9c8efaa849cf0d35873120c69b7caa1309c9a9e5b6f85" + }, + "@std/cli@1.0.24": { + "integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e" + }, + "@std/collections@1.1.3": { + "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" + }, + "@std/encoding@1.0.7": { + "integrity": "f631247c1698fef289f2de9e2a33d571e46133b38d042905e3eac3715030a82d" + }, + "@std/fs@1.0.14": { + "integrity": "1e84bf0b95fe08f41f1f4aea9717bbf29f45408a56ce073b0114474ce1c9fccf" + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/toml@1.0.2": { + "integrity": "5892ba489c5b512265a384238a8fe8dddbbb9498b4b210ef1b9f0336a423a39b", + "dependencies": [ + "jsr:@std/collections" + ] + } + }, + "npm": { + "@asamuzakjp/css-color@3.2.0_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": { + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dependencies": [ + "@csstools/css-calc", + "@csstools/css-color-parser", + "@csstools/css-parser-algorithms", + "@csstools/css-tokenizer", + "lru-cache" + ] + }, + "@csstools/color-helpers@5.1.0": { + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==" + }, + "@csstools/css-calc@2.1.4_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": { + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dependencies": [ + "@csstools/css-parser-algorithms", + "@csstools/css-tokenizer" + ] + }, + "@csstools/css-color-parser@3.1.0_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": { + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dependencies": [ + "@csstools/color-helpers", + "@csstools/css-calc", + "@csstools/css-parser-algorithms", + "@csstools/css-tokenizer" + ] + }, + "@csstools/css-parser-algorithms@3.0.5_@csstools+css-tokenizer@3.0.4": { + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dependencies": [ + "@csstools/css-tokenizer" + ] + }, + "@csstools/css-tokenizer@3.0.4": { + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==" + }, + "agent-base@7.1.4": { + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "cssstyle@4.6.0": { + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dependencies": [ + "@asamuzakjp/css-color", + "rrweb-cssom" + ] + }, + "data-urls@5.0.0": { + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dependencies": [ + "whatwg-mimetype", + "whatwg-url" + ] + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "decimal.js@10.6.0": { + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==" + }, + "entities@6.0.1": { + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" + }, + "html-encoding-sniffer@4.0.0": { + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dependencies": [ + "whatwg-encoding" + ] + }, + "http-proxy-agent@7.0.2": { + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "https-proxy-agent@7.0.6": { + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "iconv-lite@0.6.3": { + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": [ + "safer-buffer" + ] + }, + "is-potential-custom-element-name@1.0.1": { + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, + "jsdom@26.1.0": { + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dependencies": [ + "cssstyle", + "data-urls", + "decimal.js", + "html-encoding-sniffer", + "http-proxy-agent", + "https-proxy-agent", + "is-potential-custom-element-name", + "nwsapi", + "parse5", + "rrweb-cssom", + "saxes", + "symbol-tree", + "tough-cookie", + "w3c-xmlserializer", + "webidl-conversions", + "whatwg-encoding", + "whatwg-mimetype", + "whatwg-url", + "ws", + "xml-name-validator" + ] + }, + "lru-cache@10.4.3": { + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "meriyah@6.1.4": { + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==" + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nwsapi@2.2.22": { + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==" + }, + "parse5@7.3.0": { + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dependencies": [ + "entities" + ] + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "rrweb-cssom@0.8.0": { + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==" + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "saxes@6.0.0": { + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": [ + "xmlchars" + ] + }, + "symbol-tree@3.2.4": { + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, + "tldts-core@6.1.86": { + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==" + }, + "tldts@6.1.86": { + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dependencies": [ + "tldts-core" + ], + "bin": true + }, + "tough-cookie@5.1.2": { + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dependencies": [ + "tldts" + ] + }, + "tr46@5.1.1": { + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": [ + "punycode" + ] + }, + "w3c-xmlserializer@5.0.0": { + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dependencies": [ + "xml-name-validator" + ] + }, + "webidl-conversions@7.0.0": { + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "whatwg-encoding@3.1.1": { + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dependencies": [ + "iconv-lite" + ] + }, + "whatwg-mimetype@4.0.0": { + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" + }, + "whatwg-url@14.2.0": { + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "ws@8.18.3": { + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" + }, + "xml-name-validator@5.0.0": { + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==" + }, + "xmlchars@2.2.0": { + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + } + }, + "redirects": { + "https://esm.sh/@asamuzakjp/css-color@^3.2.0?target=denonext": "https://esm.sh/@asamuzakjp/css-color@3.2.0?target=denonext", + "https://esm.sh/@csstools/color-helpers@^5.1.0?target=denonext": "https://esm.sh/@csstools/color-helpers@5.1.0?target=denonext", + "https://esm.sh/@csstools/css-calc@^2.1.3?target=denonext": "https://esm.sh/@csstools/css-calc@2.1.4?target=denonext", + "https://esm.sh/@csstools/css-calc@^2.1.4?target=denonext": "https://esm.sh/@csstools/css-calc@2.1.4?target=denonext", + "https://esm.sh/@csstools/css-color-parser@^3.0.9?target=denonext": "https://esm.sh/@csstools/css-color-parser@3.1.0?target=denonext", + "https://esm.sh/@csstools/css-parser-algorithms@^3.0.4?target=denonext": "https://esm.sh/@csstools/css-parser-algorithms@3.0.5?target=denonext", + "https://esm.sh/@csstools/css-parser-algorithms@^3.0.5?target=denonext": "https://esm.sh/@csstools/css-parser-algorithms@3.0.5?target=denonext", + "https://esm.sh/@csstools/css-tokenizer@^3.0.3?target=denonext": "https://esm.sh/@csstools/css-tokenizer@3.0.4?target=denonext", + "https://esm.sh/@csstools/css-tokenizer@^3.0.4?target=denonext": "https://esm.sh/@csstools/css-tokenizer@3.0.4?target=denonext", + "https://esm.sh/@opentelemetry/api@^1.4.0?target=denonext": "https://esm.sh/@opentelemetry/api@1.9.0?target=denonext", + "https://esm.sh/agent-base@^7.1.0?target=denonext": "https://esm.sh/agent-base@7.1.4?target=denonext", + "https://esm.sh/agent-base@^7.1.2?target=denonext": "https://esm.sh/agent-base@7.1.4?target=denonext", + "https://esm.sh/asynckit@^0.4.0?target=denonext": "https://esm.sh/asynckit@0.4.0?target=denonext", + "https://esm.sh/combined-stream@^1.0.8?target=denonext": "https://esm.sh/combined-stream@1.0.8?target=denonext", + "https://esm.sh/cssstyle@^4.2.1?target=denonext": "https://esm.sh/cssstyle@4.6.0?target=denonext", + "https://esm.sh/data-urls@^5.0.0?target=denonext": "https://esm.sh/data-urls@5.0.0?target=denonext", + "https://esm.sh/debug@4?target=denonext": "https://esm.sh/debug@4.4.3?target=denonext", + "https://esm.sh/debug@^4.3.4?target=denonext": "https://esm.sh/debug@4.4.3?target=denonext", + "https://esm.sh/decimal.js@^10.4.3?target=denonext": "https://esm.sh/decimal.js@10.6.0?target=denonext", + "https://esm.sh/decimal.js@^10.5.0?target=denonext": "https://esm.sh/decimal.js@10.6.0?target=denonext", + "https://esm.sh/delayed-stream@~1.0.0?target=denonext": "https://esm.sh/delayed-stream@1.0.0?target=denonext", + "https://esm.sh/entities@^6.0.0/decode?target=denonext": "https://esm.sh/entities@6.0.1/decode?target=denonext", + "https://esm.sh/entities@^6.0.0/escape?target=denonext": "https://esm.sh/entities@6.0.1/escape?target=denonext", + "https://esm.sh/form-data@^4.0.1?target=denonext": "https://esm.sh/form-data@4.0.5?target=denonext", + "https://esm.sh/html-encoding-sniffer@^4.0.0?target=denonext": "https://esm.sh/html-encoding-sniffer@4.0.0?target=denonext", + "https://esm.sh/http-proxy-agent@^7.0.2?target=denonext": "https://esm.sh/http-proxy-agent@7.0.2?target=denonext", + "https://esm.sh/https-proxy-agent@^7.0.6?target=denonext": "https://esm.sh/https-proxy-agent@7.0.6?target=denonext", + "https://esm.sh/is-potential-custom-element-name@^1.0.1?target=denonext": "https://esm.sh/is-potential-custom-element-name@1.0.1?target=denonext", + "https://esm.sh/lru-cache@^10.4.3?target=denonext": "https://esm.sh/lru-cache@10.4.3?target=denonext", + "https://esm.sh/mime-types@^2.1.12?target=denonext": "https://esm.sh/mime-types@2.1.35?target=denonext", + "https://esm.sh/ms@^2.1.3?target=denonext": "https://esm.sh/ms@2.1.3?target=denonext", + "https://esm.sh/nwsapi@^2.2.16?target=denonext": "https://esm.sh/nwsapi@2.2.22?target=denonext", + "https://esm.sh/parse5@^7.2.1?target=denonext": "https://esm.sh/parse5@7.3.0?target=denonext", + "https://esm.sh/punycode@^2.3.1?target=denonext": "https://esm.sh/punycode@2.3.1?target=denonext", + "https://esm.sh/rrweb-cssom@^0.8.0?target=denonext": "https://esm.sh/rrweb-cssom@0.8.0?target=denonext", + "https://esm.sh/safer-buffer@^2.1.2?target=denonext": "https://esm.sh/safer-buffer@2.1.2?target=denonext", + "https://esm.sh/saxes@^6.0.0?target=denonext": "https://esm.sh/saxes@6.0.0?target=denonext", + "https://esm.sh/supports-color?target=denonext": "https://esm.sh/supports-color@10.2.2?target=denonext", + "https://esm.sh/symbol-tree@^3.2.4?target=denonext": "https://esm.sh/symbol-tree@3.2.4?target=denonext", + "https://esm.sh/tdigest@^0.1.1?target=denonext": "https://esm.sh/tdigest@0.1.2?target=denonext", + "https://esm.sh/tldts-core@^6.1.86?target=denonext": "https://esm.sh/tldts-core@6.1.86?target=denonext", + "https://esm.sh/tldts@^6.1.32?target=denonext": "https://esm.sh/tldts@6.1.86?target=denonext", + "https://esm.sh/tough-cookie@^5.0.0?target=denonext": "https://esm.sh/tough-cookie@5.1.2?target=denonext", + "https://esm.sh/tough-cookie@^5.1.1?target=denonext": "https://esm.sh/tough-cookie@5.1.2?target=denonext", + "https://esm.sh/tr46@^5.1.0?target=denonext": "https://esm.sh/tr46@5.1.1?target=denonext", + "https://esm.sh/w3c-xmlserializer@^5.0.0?target=denonext": "https://esm.sh/w3c-xmlserializer@5.0.0?target=denonext", + "https://esm.sh/webidl-conversions@^7.0.0?target=denonext": "https://esm.sh/webidl-conversions@7.0.0?target=denonext", + "https://esm.sh/whatwg-encoding@^3.1.1?target=denonext": "https://esm.sh/whatwg-encoding@3.1.1?target=denonext", + "https://esm.sh/whatwg-mimetype@^4.0.0?target=denonext": "https://esm.sh/whatwg-mimetype@4.0.0?target=denonext", + "https://esm.sh/whatwg-url@^14.0.0?target=denonext": "https://esm.sh/whatwg-url@14.2.0?target=denonext", + "https://esm.sh/whatwg-url@^14.1.0/webidl2js-wrapper?target=denonext": "https://esm.sh/whatwg-url@14.2.0/webidl2js-wrapper?target=denonext", + "https://esm.sh/whatwg-url@^14.1.0?target=denonext": "https://esm.sh/whatwg-url@14.2.0?target=denonext", + "https://esm.sh/whatwg-url@^14.1.1/webidl2js-wrapper?target=denonext": "https://esm.sh/whatwg-url@14.2.0/webidl2js-wrapper?target=denonext", + "https://esm.sh/whatwg-url@^14.1.1?target=denonext": "https://esm.sh/whatwg-url@14.2.0?target=denonext", + "https://esm.sh/ws@^8.18.0?external=bufferutil,utf-8-validate&target=denonext": "https://esm.sh/ws@8.18.3?external=bufferutil,utf-8-validate&target=denonext", + "https://esm.sh/xml-name-validator@^5.0.0?target=denonext": "https://esm.sh/xml-name-validator@5.0.0?target=denonext", + "https://esm.sh/xmlchars@^2.2.0/xml/1.0/ed5?target=denonext": "https://esm.sh/xmlchars@2.2.0/xml/1.0/ed5?target=denonext", + "https://esm.sh/xmlchars@^2.2.0/xml/1.1/ed2?target=denonext": "https://esm.sh/xmlchars@2.2.0/xml/1.1/ed2?target=denonext", + "https://esm.sh/xmlchars@^2.2.0/xmlns/1.0/ed3?target=denonext": "https://esm.sh/xmlchars@2.2.0/xmlns/1.0/ed3?target=denonext" + }, + "remote": { + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno.ts": "f913bfdb3c66b2330fa8b531bd1e291437b836c9fbf002b9ae044bf14e1f397c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/package.json": "37a3751c6d6b7bacf9eee43a5e2f42e003a950b23f30927f27033a375650fd12", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/misc/common.ts": "c71cd3a460e035b46422cce349a9b23cff83bed357eec94fba50a99ba292957c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/misc/params.ts": "4bd8bf67bda1f606ef94ded2fe5f755b55067c5c67bef3c77cb388d2a5867641", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/youtube/api/pfiinnertube/attestation_response_data.ts": "0f4f2580e4995117155455877d192aadfeda85e4012dbd4b184d0fba35a5c068", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/youtube/api/pfiinnertube/capability_info.ts": "cdc7a1921f93e019e6e7ab67da5a3af57cc8f75c1a52e5321eeee79155f2f5dd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/youtube/api/pfiinnertube/client_info.ts": "76d8d517701ac987d32f6c8405bd0fa828d5dbd449761c94afc340918fa107b8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/youtube/api/pfiinnertube/innertube_context.ts": "cb207febdc98a7a9a6dd32a721cbf404673516cbcfd4b0de450fe0bdb1a49f78", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/youtube/api/pfiinnertube/metadata_update_request.ts": "c8c13395883bedae170a06e86623d2c63c96eee2a2e55710d9e211307b0131e5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/youtube/api/pfiinnertube/request_info.ts": "4f6ce95cb60398755450a74b7c5aad7014d680f679762b823582a59ed9acd650", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/youtube/api/pfiinnertube/third_party_info.ts": "7f61b26270ea5740d1aee22ee2498e31046d18d7a4d285fbc1ca0470b39c55f3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/protos/generated/youtube/api/pfiinnertube/user_info.ts": "5833c2998d1f0611581621a20d487ccb74ebb09eb170efbffb90a5b0a1faab41", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/Innertube.ts": "b9b370f2ee04025edfaa3c6693b3b94bb6059c6a25e7799ee62911b1f22d873e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/Actions.ts": "e7be1d70d26a2a43ef3440cbbaf4e4842298a78c25b3b3a7eae00913567f3629", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/OAuth2.ts": "6f622a6cd6628908a333a5a59e7563e03784ebbce04f58d2e13df26a14a92061", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/Player.ts": "fdaa46834890d2e3c2f54f542b90eb32b229e45ed64e39f0fcf00cbe25fdbd25", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/Session.ts": "f98ebe5db92cb1fc3b7a7335c631f4ca5c504988f7b2838796ca16ea981cf333", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/clients/Kids.ts": "33dabfcb41bbe51ef1507a50b4e0fe1269d0e2f681e60d5defa96932af10d320", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/clients/Music.ts": "98347d692141561ff5a87999b6f6b65c06c830f40a733706898552463630aa88", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/clients/Studio.ts": "5cd99ce2024c625c4b21f2aca0a2e45c945e2cd2f2f955647725aa8b5a738806", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/clients/index.ts": "b2606d625fea9aabe4747c4f01de63e3b599374a8160997623bd7efa9c3eff76", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/index.ts": "d96c02d9789a16157680cc7f1adfc7387ffc8743db6c0f4f0bd343b6af4ecf64", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/managers/AccountManager.ts": "05c569396982fb67d2a77308031039120e69974b58d7cd7520c5a9663ede7c40", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/managers/InteractionManager.ts": "ffb7c8c575b5dc4889c1af110f49a8ed479f13d7596875faca23828488598fb4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/managers/PlaylistManager.ts": "d1d1b4582e0f6e0c6e0ec23592bd148605058ae1e17ef4472ea021f37c3225e1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/managers/index.ts": "5beecdce8cda61ea8c9ae837d44ccc0f0be51cad4301c6cec10e7f74aa07410d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/mixins/Feed.ts": "f2f844c2ae4fba8377b08f4662299bf7722cb28cc167edb284709280004a89ea", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/mixins/FilterableFeed.ts": "4719386e06883717daf2a679ee6ffd0ef9a6e66bed6f12e31e50a5214647d1d7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/mixins/MediaInfo.ts": "f9825dde9dd5bc37048bb09e50e0f97feead3915f4c681e421cf7b630396fdad", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/mixins/TabbedFeed.ts": "8fa623bb087ba50de395d76e77ee73eefa6afeba15b43834a13f064fd198a53f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/core/mixins/index.ts": "fe6111ac42722b605f58435c6c23d6ad95fd5a977808f7d47c3a77a0e4bb219e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AboutChannel.ts": "b7098bf0ce19c25025f8ea9296c850078e422ffed01e66233fbd521415be5ea4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AboutChannelView.ts": "f9a033c58c1a72e202bd8812828ee18818afed88d46c67fcc427651f91f4f966", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AccountChannel.ts": "42d25e5d0b3b33dbbd44d5e42333ad8af2180a908d60e165999a0d22ae1f698d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AccountItem.ts": "b8ae58db28eeed79c637b9dc628ff86af2a4431804a7cac241fc694e5d4abced", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AccountItemSection.ts": "9397913f6f7dedf4ca6df0c1388558e97822fc3306a51ec0fb3f2166d56e0728", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AccountItemSectionHeader.ts": "6653606bb157013a896a931d5afae0c04111f476fc8156e0726b04bd985cff19", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AccountSectionList.ts": "278b0c7c1562e0c133a001a0c9aeb77fc6ac0a1325e773c241becff42f212b33", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ActiveAccountHeader.ts": "d97c132a1cf4d50af67437592b712c4e5d13ee3c0fdb0ba0e1af07dc158657b2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AddToPlaylist.ts": "0442b49349483d03246ef476d5814039e8b120770ea7f4e9a8d677bae04881bb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Alert.ts": "951317e55a6824da1ec614792b833c2c101341d3a8bec7378c37bc41a50507c0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AlertWithButton.ts": "5052f31ccc17ae1867a6c581d89bf2a2b2010d262b2bb585408dd4bfb4ce53d2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AnimatedThumbnailOverlayView.ts": "5db12d3e7de1d401f3a010296da60c1bd5ae526680936e1d07f609745128365a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AttributionView.ts": "02a1e4a9f71f64945c2f392220f5cc87fa2ba11d6fb21d803746c90572273f74", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AudioOnlyPlayability.ts": "4bceb72fd82087d6607fe19e497769b8aec58037848ca136a759abd12ba8203c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AutomixPreviewVideo.ts": "324f69168e2fd3878f86c4c2b6cca1babd199607c8e63858e7e2f6e9dcc582d8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AvatarStackView.ts": "1bafc5095d5b6bd45766c64669346136530b58ae144ff1e9e6cb012cc3a37c71", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/AvatarView.ts": "8f2d0c5bcd46969be7f09ab60c2d4ef1867c9b68b1606e57d9c66116cdb73e83", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/BackgroundPromo.ts": "6e63fbfd84689656bf0fff071cc4ba085312e8d7c4c212527b6adac635b087d4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/BackstageImage.ts": "70e8d61a8288658a3b85e541e80515be8543ba7f888e0ebd57d20dfc4686afbe", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/BackstagePost.ts": "17b84179958182a23245d115b6078e8422aae02aecb63a2755850bfef0090037", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/BackstagePostThread.ts": "8c1060ae74df090b896be6675e2862bbdac34b88bc0f2e8902ea77e3bc0c94b2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/BadgeView.ts": "1b8519ecb191026b7b4e89ca5eaaad9f6eae924d9b81c41cd370aaf9932a4489", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/BrowseFeedActions.ts": "31cdbc4eed104c1404b66db108449c6f2dcae2d99441116f0e5c698778f897ea", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/BrowserMediaSession.ts": "8acf89acb338340f6fda58cc93cba64324d7e3c7c475a292848e0e559714a792", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Button.ts": "bde5b357993e6b964ea5c3d66ebfb8675d908c189db3d6c5bfb2db1bb750ba98", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ButtonCardView.ts": "4141e92c1d5287a8ea68c6af185e60b3fd1979348d9d9c9ce5ddbd82c76cf0f8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ButtonView.ts": "f7e0cd870e764368a9d5342e8153faba4a152dc0981a9cf1cb1f755585fa6956", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/C4TabbedHeader.ts": "f0931752407327d5394ba50671ca551ad30729dae2027b9073b8cbc4549d975c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CallToActionButton.ts": "6ba73d975c67478b30a7f15a3c2b40c475324faaf70f6988cf0cea62ce996f55", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Card.ts": "0bb00ec52f69c90035cf02a26d70a225ec18e35a7e5a289afb10d17f9e64efe3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CardCollection.ts": "0f02c3bd5be6229fda3e351173feb842d699df39da8dacd39c906f4f45a199f6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CarouselHeader.ts": "7aac27476062e708fa75a8022b95414ed896407d2b1a24f25be1390683418b39", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CarouselItem.ts": "a0d5da435a191a94b07ed156c65b80c51ddef3d02e582e4643cd3bd931fdf690", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CarouselItemView.ts": "477349bfea82b81929c676c1cd8b587c3c489711522e4f7a492389b0e2080149", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CarouselLockup.ts": "7d2d2cc744d7fe4c6728be720539ca77623ce7f045fbd32e4ceb0a7ddeb0f8a1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CarouselTitleView.ts": "1c37c201d6a62638c2a5902f153ff8150c4251051e673a6a7b264dc99240a619", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Channel.ts": "aa770ee9464546899aa6e87f23c60ff5fddb0456b3978dc348a69e7ac6888852", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelAboutFullMetadata.ts": "f417bf2920a7b21362ce7e92f3bc2caa63df92de66ce320b6b80d870bee5dc72", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelAgeGate.ts": "2981c34ea2b790380e4241072c32a5ae8f28699f6b65d54afb3fe03cd23e8ed1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelExternalLinkView.ts": "0464d1afaca0ceb5bfefc9eee5f8b284434c766aab043d2429839a92f6a17680", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelFeaturedContent.ts": "7855e3eaab1d674715bd4d71240c31cd398d0beeb7c4cfba29413f45667ca6dd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelHeaderLinks.ts": "c1f2470548d874c99325e843a41db9a8360ccb4041f76cfcdc0149e7ba6de609", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelHeaderLinksView.ts": "11800e5d680fcb33f8952dad1a880e4056bd1a28936e6c1bae16a90214ce8101", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelMetadata.ts": "fd1050bb5d5159b6c7f9a6e92a9a04adfc32bf504066d32bc691e617c63fd420", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelMobileHeader.ts": "0b521d7cfbe3eb096638ca916af4a4b4b865d6b5c30910c5627b21d0b51f8089", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelOptions.ts": "7fb3f9589d5ca8c708c56d429373cfc266858c60f338b91a18f03c2fe24cd414", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelOwnerEmptyState.ts": "b747b29b2eca1d26f3b5cdcef458bdae6275945aa59c5a549c7173d0723cd673", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelSubMenu.ts": "bb6640f3d1f27079d0e57b4351e0a16c3163bf61a4300666e4cd886dadf74f3e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelSwitcherHeader.ts": "352660758c5515ca1621a98ba8b6c91eb739e757473240d86993b42d3b43c070", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelSwitcherPage.ts": "f937bc413eb3da1f8b31b1ab65934021fe63baf8993ecb251d73289b886df7b5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelTagline.ts": "8479718dc0eeec61848387a7129af35dc69ce6a3cb1505ba101b736a819e24ff", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelThumbnailWithLink.ts": "e335f0a5890ce9a5c01be37d82c1d4ead14b763cf11156e48cf142aaa7056d94", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChannelVideoPlayer.ts": "c0fca466db8fe642014564969d59153750f7ac59238338ec7878e6847aa41160", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Chapter.ts": "e51baa5d63b638aae64483a57e4780bc3804fb528b0322fffac5941257c36168", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChildVideo.ts": "1dac31a72cf3acb8ff98b756a595827b3cb368df0390c39427042137641c8bfb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChipBarView.ts": "a9d004a98369853836c82ec322199a0ea76c7cb1e339766bff26b1c25c20b9af", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChipCloud.ts": "23653511a7f6fd1f4afe8a7819fbcf7357ae8c485ea654b627b2c01cd02faa76", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChipCloudChip.ts": "523050ad614b0cbda3bf965549d4a15f3f5097b0c3003eb5c5b0a3d71d7f09af", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ChipView.ts": "4be3ae041ec848208c9526939cb079565263e7e81e49367e7f49a49da120a24c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ClientSideToggleMenuItem.ts": "505b0fa14d1e287dfda459d1e42a0277ae56b00fb1170af0dd3cae74d93329b6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ClipAdState.ts": "12a62df6a13800bef4722c5b19c15dd6879563575fb5c6421f8ce395ad2a8716", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ClipCreation.ts": "5649fbabd461e635b10316bd04016f21a933b5c9ed4ab7465a33c2963c57f994", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ClipCreationScrubber.ts": "c19b58a3fc18ef835e95ee9ec3236135e5291d7f03fffbf5f0b2eae6f59acfa0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ClipCreationTextInput.ts": "5dc8b6d33b273e48607fbc7bb628c8dbce186dacf215465e95249940f0fe7a6b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ClipSection.ts": "2dcdadb4c69cabe2592d549b8010b917b24c1ad092ba4b9f5524c45d451fd753", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CollaboratorInfoCardContent.ts": "216b4b6c033cabad5ca335c61e7a44d8f1ded772eb6b7fb4f0f2301ad00dc910", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CollageHeroImage.ts": "37c580d4eada8dda7171703dab6a7e23aa42a44a32b5a65ffc28c7cec5dae71c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CollectionThumbnailView.ts": "724bdfe69e5be7f7e90ca3d1b1bb0a7ff97762622dc7538203baafc4c84bfa01", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CompactChannel.ts": "0d4eec78fad9ae30658b5ad8ef45322aca0285493372e6a1c650f3301be48b27", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CompactLink.ts": "94b196062427e1fcf0806eecb9b37e63206571794427990ddf1c22ce49b72ecf", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CompactMix.ts": "2821cf9ebec785f8ea40734b068f879604ec29ec8e0aa8ca4120c606a41c7f32", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CompactMovie.ts": "749f8a5901cca3508bc2bc3681535695455150b949885eaae80b787faa2b24ba", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CompactPlaylist.ts": "6cf130b855ec292a3947d51b303d225f22828271212a0e24d5716f80f9e8aafa", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CompactStation.ts": "bbad99d1c357248ee6f2ca67fe12923c91278c5fe6055104d38abbe929c434e8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CompactVideo.ts": "c79343ee77d6ab4a29c40a1f199b88ac4c2767ae06abbec9def9aedd60a4ebfa", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CompositeVideoPrimaryInfo.ts": "9568102d033a1d42b3eb8196f80b64d631cb856b7c49a11d7fa2e61b81490151", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ConfirmDialog.ts": "6294c06165cfc99cf54b7e8e979d9b5159e6663fed2649c590ffb5e07a3abf0c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ContentMetadataView.ts": "91d3f66082fd507a9e79f8d0fc21530fc27e8522ad649fc24c35742927efc671", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ContentPreviewImageView.ts": "e4da54a4fea4525900d28eb7080fa436e0a2baaddd606102c6b2e2a2ffd45a25", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ContinuationItem.ts": "f6f2a06ada2632dbde7c68cb20278c4117a9215041427f713728a22491ae23f6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ConversationBar.ts": "2e56f0d3579a980a886a9a41ea14e3bb30e08a33291ed9f3a15721f4a2366008", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CopyLink.ts": "8107ad4c58f932083e535836776eefd9d013713fa2eb7ab371e5226dec5aa77e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CreatePlaylistDialog.ts": "701377af34472057d5a186a3e5a81ebb05a4302dc2be505802dc9891afc3c4ea", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/CreatePlaylistDialogFormView.ts": "8f2a2ad363bd0ed7b8d7ddf3c642f646db0c16cc789df73ec0cf76f25fc63184", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DecoratedAvatarView.ts": "bb7e384855ffb53fc32db5de9f9205353a865398be3f1b7896122397a29b81e3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DecoratedPlayerBar.ts": "35e2d82bb8f963186555ef7f3ca1af6f91f0516486f85aa57d0a196abf43c6bc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DefaultPromoPanel.ts": "9f8634c0a96780f41ef1f9a3dd3c759bc4259689f6db17e0250b2f781eea7b6c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DescriptionPreviewView.ts": "15c7dfdba75962a0ce3ce25b9f0756e63d7378a4939426f0d1c0c06e1189b108", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DialogHeaderView.ts": "e9b837eba94f9be4607127c3f1c61e60421a2554849d19f625646c842c90ac10", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DialogView.ts": "a6b3a835bb0b2040c009e4eaef99166356ac7c79d51b6d69ae0b2e808754b141", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DidYouMean.ts": "5308ce98e58b582a3c66dcf126a4768560f37392461f2af4d9fed7c234ec4293", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DislikeButtonView.ts": "2442308846dbea9951ba058a3a0d94f35e5b82ab72427cbb0588460de35f3bb0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DismissableDialog.ts": "c2dfe0ba877cf77c8450856097b6f41da96a80bb270c1882c51639ec5d02c2a4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DismissableDialogContentSection.ts": "52a40f67d199cf09579b548662cce9bb64aa8dab60118a359229e086787ce9f4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DownloadButton.ts": "626215d98c883ff413f360bc5a73886bd82448f12112bb7d93d4a0d97e5fe5f2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Dropdown.ts": "629b2c3fa8be96687da953600f716ea2c43e496ec4e3eac43823b9820725131b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DropdownItem.ts": "e8a2e1271585e9ff92d786805d0a5f2a04df61a3ed79305f4d671837cfcc3fbf", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DropdownView.ts": "de15b2da121fbe20d5a1ab8d6cdea98df8d3f43702452215d86f1e5af3dc6fb6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/DynamicTextView.ts": "d4b17d7f4a9177dcee184175f9efb2ff0fa5ec2d4fdd6a6aae8036fee54614af", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Element.ts": "9d70e34c3eac0d0ed56d606af2b36ca753cfbc8773907a2531d096797e2cd5c5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EmergencyOnebox.ts": "ccbdca9bde02258204d31cb2d42fbb41c681a9eab333c50ac4d32ee5e941ac4c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EmojiPickerCategory.ts": "1e8bf2dd4efa0f31ee48856fd89345739eb467b37e3b5427338a489e9a61f051", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EmojiPickerCategoryButton.ts": "5ac0f99885ea9271ef697ca7f473f92e6c3cb62708237cd63a8beb1fb8ee1b45", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EmojiPickerUpsellCategory.ts": "c8733c75bdb0b4b882cd75cb3d8761914a467ce22204b2b201a9d010db36aed8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EndScreenPlaylist.ts": "03d94d438a12217d9825b3b701190429d6d55517600681db70bb2756a7cc70ae", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EndScreenVideo.ts": "825d14928f8b8fb2a3f50129d2b09c2017389d10f87252ef2f825f3fe87b52c1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Endscreen.ts": "eb032970230d659bd7a9dc5f67ce22634e3eacfcebab77532b87dc16208caad8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EndscreenElement.ts": "7f818d9bf05f71e9146eab5a920ed7d0b5289d9340d5f18f826d9a9c3d049dfd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EngagementPanelSectionList.ts": "1a427eda3d67c7d8a92e76de23623c0a7279be84b52c578d5efd4867afbfbed3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EngagementPanelTitleHeader.ts": "bf926834cd5b88cba79563e1bc91281c12c5480d1a02d76102dc77c0b0768ff7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/EomSettingsDisclaimer.ts": "382c4c3636bf0ddc3d21418c2e4258fa42c57f3a6163b8a6b58fe5d2737554bc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ExpandableMetadata.ts": "4694e05eee544657f82373c333a31380ecaa565878cad267cab88eeba50cce03", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ExpandableTab.ts": "0a5c20c83ba4caafb94143bbd642aa15e54dd6901b4a9697bb0b89349cbe22f8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ExpandableVideoDescriptionBody.ts": "7f4319878bac8cfe124b37501a478dd291fdf9c1fa5aedf56c6d532261cc5faf", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ExpandedShelfContents.ts": "70158f2bbeccde3c9391c1775eaa72bda650e612aac483e7ac378ff789fd5f40", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Factoid.ts": "10e575dec4ee77d1bbbb1773f884eff18345b19f2d37c461d4bddfbbb006904b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/FancyDismissibleDialog.ts": "532d40d8a4eab04e71fe039ac5266c473b6dce82b2bd051fb1afe8fbe3fe1d19", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/FeedFilterChipBar.ts": "9b1e137d49d921e47e03510b983df85bc41cd8fc70ef9ff6262676b75061c9ad", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/FeedNudge.ts": "d403105fc57b192c86286ea12e70d9534dd7673e43401f690be8c2af3c6f8afd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/FeedTabbedHeader.ts": "94883a1832a3ecf8bb2694da1485d428fcae844075df71c7e6876bfded2b2fae", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/FlexibleActionsView.ts": "c9387583935e5e83eeae15b45ae833d4ad1839ba38e0ae11455a34088452f52e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Form.ts": "13495b764dfea51be19e67f859beeca9251409b920da650f47bd1678381332af", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/FormFooterView.ts": "4d4191dbed740fec50ddf99cf82c7191e3eca701d4879339328fad02927cddda", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/FormPopup.ts": "0acaff45c42727ddafb1bdcec73c37576eea675ee895425156cd1b117dc36fcc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GameCard.ts": "f26e1b085a3ffba41649242299ec399b159d693e78c8323dea9c1d4b9e820b07", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GameDetails.ts": "4fe76f47c0f703a54b134fa53d8172b12222c18519011ac74c790c4e4ca2ea90", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Grid.ts": "3079a3b9979f91396fefe3d60de1bd5531fb1976f128fb3fd8636b89f5a1581e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GridChannel.ts": "087979fac219af5c1fa8311551f9e185274caf413442a269287848410536ff9e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GridHeader.ts": "cde855d3a46427c9f25a7a862e6d1e17e41f65a8edd8e345cd13c79e739a3779", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GridMix.ts": "2784966f9213937254159e2e2dabbd4382d3ed5b2ac9c8de16c1a202700d3cea", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GridMovie.ts": "4c26f9e0aafdb97531ec5241c601a0b20cc8f95197b4709d9b5fcc9c633416b1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GridPlaylist.ts": "268ab90959525f6226a4a7c33293202169af947f670a68006780687acfa34d51", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GridShelfView.ts": "47ba089207569e936d63d156fc75f097b8c11575b9d5c405d7ec7f8b52ed46b7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GridShow.ts": "6200fe2a21403ab1ed5554fb9af21ff258ada2318f29cb7ddb897ac4ce588971", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GridVideo.ts": "05a43570137ccc6a9c83ca6753e883971ffbb7b5c8eaec74ab4828fac3ca2cfd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GuideCollapsibleEntry.ts": "6898c1aa7dee2b9403dc818e0af4cbd5f11a97d7493931c1673e8afdd4a41e49", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GuideCollapsibleSectionEntry.ts": "a71b258a5f7479b3c41494f1b02e8bcab804146c5048028d9775873afab4380e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GuideDownloadsEntry.ts": "1b53dd6b41ca9d78788c74c896ed1375ea9b55c24d366e6a371a81bd5137ac22", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GuideEntry.ts": "a0d8caecf5941882a20e4ea792e3483b97ad59b17c7f7b9e9b0d304c6ea58a6b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GuideSection.ts": "cdf4f493e16c64365c7272f34c3122c99e3cf782adfc19396119002c142143af", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/GuideSubscriptionsSection.ts": "796eb3a997dcde33e0321bbe593cfe4069ca411cc0e4e31d15beecac518240ea", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HashtagHeader.ts": "d0044124e6532d0fc0d11f77041639b33a3166096abea360f347382bf2bb2bc5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HashtagTile.ts": "9b45df9a3d1659dfca9afd3ebeeec33384d51f143f4b42fec18c588bf4abac32", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HeatMarker.ts": "fe898140319d4ff66f6581306312c2fa7c913173651f89057054f63b9d9f37fe", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Heatmap.ts": "ab08f9ad9f85d8dc464ec2edae32e4a53ceb40c2b8a94bddd9d96f59389cf759", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HeroPlaylistThumbnail.ts": "9b9420bd31f5585a46f0cb1fc50c252dd5e8806236df9fbb3788323fb00c9146", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HighlightsCarousel.ts": "422a74b2661f37cd135162a23e12eac8366af21192bf2435878f3809faa10c92", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HistorySuggestion.ts": "58167e2cd26403a3262e3a800217f593a5dc134d22ba56ed5e2869e4e172fd0d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HorizontalCardList.ts": "1f46e177d6dceea2d76c7718bc6e95767e5265a204bc33031b321a2f42ebc044", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HorizontalList.ts": "0cdf416e04ee0d1e2f5b47281ebee779d40db7a7834769dc4c4a001a52e5a722", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HorizontalMovieList.ts": "d665e1b8e581a35eb468b6bb7da29e2f6be8dfcd8ad0e94f479be2b5c95410d0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HowThisWasMadeSectionView.ts": "b3755a41ba6842667b781f47f70b3bb8baf010381fc41005f77bcb38a07d7a7b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/HypePointsFactoid.ts": "816bc2c452e42e7a3eb08af447f5609be4b786793184c0c0b5e24a6abbf1d0b1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/IconLink.ts": "cd28bc7d49261a1a32cb487cdcb7793cde54d0def660f854ae2f1dbf90a02142", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ImageBannerView.ts": "b0e207b2af2cea727d0a7e6e585d8b2f21fc6c1ea0c57c86d1369ef64681d494", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/IncludingResultsFor.ts": "c87f357d7893309940f9464cd957560c6b8f10bc786a786e582d5d6912a15e9a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/InfoPanelContainer.ts": "e7f4ce71b83ca597bc11d271e6c2d660c93be1635ea4d558930c8451b2b240bd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/InfoPanelContent.ts": "20de88f6e063c8a46026843b6fc4c3e2cfaf6665d2e06a8516f5af0488f73a03", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/InfoRow.ts": "ff209cf7f39b77977c2c6533b6251159a49b5f611e0c9cc6f78820991635ba1c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/InteractiveTabbedHeader.ts": "f0172b719a2fd78723f4b20138086e3a55a07eb8b191b43a605ac2e0d4c3a6c6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ItemSection.ts": "5825fb36d6891f71ba5febc3fb74fcdc95e98a3906bc591952cae834d3d7dbf9", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ItemSectionHeader.ts": "cdf8ceab39376cae9cde4c4bc22799e6111dd0b3bb6c87f0ae546db824a3bb6b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ItemSectionTab.ts": "f86071543a11f2732a7d9d28689818445f5326fc176e674f252d75a927496c13", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ItemSectionTabbedHeader.ts": "8b497bc42bb577ba2b30617f862ac7f373cca8cca93eb5650d9fd30595182676", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LikeButton.ts": "8ce35e04195d331f0cfd91a9802ef67faa402b82f3e7cb58e7fb80a6ee486ce7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LikeButtonView.ts": "6925a9427f918f4119e28f43cff021b248693c37ed2690709d18c169d4f3ffec", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ListItemView.ts": "e7c677a4d61ec20abb53c5396652b0efe11502092e9bff355640b2ecb69617b6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ListView.ts": "54fc990768a589fe43918fadf8caab9a9706c4f24a9a350b6b147bdb0d08b345", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LiveChat.ts": "be9e95b7113097bfac20f3b679d3fca2fccbdfdab79f0fd640e270bc760148be", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LiveChatAuthorBadge.ts": "b92b6966f5c0358ff583460b6f41e1402be89f1f1c2088681dec71172227d331", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LiveChatDialog.ts": "24cf510e6187f071d597b8d0503aea4ebb18146e0f6c7653acf4f3eae1a554e4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LiveChatHeader.ts": "f8d28a0301f5e14ba60e33cfd57e58426236c50ba7cb44992314dc693202dddd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LiveChatItemList.ts": "99ea5412e20273114580528722f9f599a1ac76e6b4a1376d3c9c91a72b81cc53", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LiveChatMessageInput.ts": "1c197ed35d386dc6341550022f22bcd268374032ff578f59cfb02d209aa53001", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LiveChatParticipant.ts": "b199264fa36cbe09e9e914c58b3fb6ae6fb73272dfaa15284eef4cc672d07bca", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LiveChatParticipantsList.ts": "945d064cfc1f1243a284fdbbcd425686918b1c9df6554d865a4f67bbcd344971", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LockupMetadataView.ts": "09d67c16cfc53a73c3107a01ad591de410f6fb1a6b4c1726f5e139b0838e5bf8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/LockupView.ts": "ebaf40d191102aaec5056735ebb783c98127b78f40dade548a0cfeddfb87d4cb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MacroMarkersInfoItem.ts": "0989b7359972f8ea89f5c189b229465839dc226f692c4d2f0d1c47eea5f9e9b4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MacroMarkersList.ts": "f3d69fd2eaa6daa0a4dc1179f1ed174c02f3dfdbf270758a52d23cf9a25d1f70", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MacroMarkersListEntity.ts": "29ecc650ededb5f284d6fc6b7bd4aca55f5caed4fb9756794cb09e975981c802", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MacroMarkersListItem.ts": "c7e06e91ad0daf880d269cf3118ae2513cea32d96837f5f34e9f4627f5edaf6e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MenuTitle.ts": "5b658366326734a0add1369a7352363b402ca3d5b9e00b7c9ea26105d3feb4c6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MerchandiseItem.ts": "24e98fa453ed1a9d2a645c70ee0483c2f049813146e91033726362de06963909", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MerchandiseShelf.ts": "bbe89ca0442e37a173f010cc519e2aae79a9430b84ea742f0ec039537d265f5e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Message.ts": "ae02e16b3fe5d9db83aba15ac81a5b217179f4cf6d68ef0910f811f25e2c8530", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MetadataBadge.ts": "1264beec3c7c90fc58bada6c567860465d651936df7d115127e32dc10e111832", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MetadataRow.ts": "c141bea7d47205bfad60ec28dbaf2f1865b2798661fab4365f34502fc721f864", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MetadataRowContainer.ts": "6f64eed25739ed2eaf6c76086d398d966783bdd705f55c7254a6f4be739735b6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MetadataRowHeader.ts": "74f3d921b8bb5371be4e0a8b06060273c6b738c7cb4fdcb642f1032ba1f394d0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MetadataScreen.ts": "e19dd2a4f189c14fe0c0cb0460f883726aba2ba074ace5ce82618570def249f1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MicroformatData.ts": "d83da608b5080fade38279253eb663c9129adbd93bac2ca992aaf358b6f227b5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Mix.ts": "a4f006e68ce012c165690274743d07cf7c6886a7d225416c02689ae95c327004", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ModalWithTitleAndButton.ts": "94740289356c3897998e82001fa5c9673536cf12c34ad30e99fb53f5d154ce6f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Movie.ts": "ece3a9432550f36c2313491861fbad29a975ee4d43604b7726a6aaf96df82804", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MovingThumbnail.ts": "4ad2c4b947e637a73fdca56e903155c7fa191de2eb9fca30d94098d14be17c71", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MultiMarkersPlayerBar.ts": "aa70c654ef101bbbc72a0a856a0a4fba974611eef2c64b659f8e32c7b2f4717b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicCardShelf.ts": "de4dcfa7b2ca8ef91586c0b3aaa7fdb2e87735135156c0689d4df56f995a67f4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicCardShelfHeaderBasic.ts": "2b19859ce41700af922d36343be6a42420308a9e4d25149e23cb52e8507b5278", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicCarouselShelf.ts": "65ff30d0933095f36e515378e9ad0e8485e9a718990e2ddb0843b952b2f35c64", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicCarouselShelfBasicHeader.ts": "bf51fda17ad4fc2e8a234df181f9931318d47b4fbd01f747f4340779e812de07", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicDescriptionShelf.ts": "734f385b0a89b4e3cc65305588db2560dbbbdc67f9b6c23374dc3b8b8f16d3ec", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicDetailHeader.ts": "ba66f0f987fd5dbe159434f78a2a713b821d3c3ace1d6d9b8aefd79bf8bedb44", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicDownloadStateBadge.ts": "db5f732e96805652b8add14e228e649e74264540bb166c87ed6c97081a23ee38", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicEditablePlaylistDetailHeader.ts": "fd73a48db32308d12c1822018b6f6ceecbba47a3a82e3b36c4ef23ec095fbc7a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicElementHeader.ts": "8cd29f81d41e1890916da8bb23db6a2f0fb83fb5174661450bf30b1653921936", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicHeader.ts": "51e8255ee23f683b24b8f33785b6f86e063e23d29debb52a9174b256454b5f22", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicImmersiveHeader.ts": "edd9961b8e0d54f54e891f1d4123411f5c6588e8e296b16da98867c4004e6de8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicInlineBadge.ts": "9d3c9c9e279a5a40010090398d7bf0a3fb202c10ad2eac1c36030a742b37b5c5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicItemThumbnailOverlay.ts": "a025507930943b74ac2182ba76db1452a6cf6f181994dd70c985e93a75a86cde", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicLargeCardItemCarousel.ts": "ef325d7134b17b21bb79e5bd11532fe1de902bd594012c04a7231ef7f2ed0c5b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicMultiRowListItem.ts": "c990dd31b7f61148bedb6bc8a4704eeb3faeaba222e1b274eac185989cc7acb6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicNavigationButton.ts": "9574afabcf79b980c606ef117bdaf673fd1dc22b2e0ab5b2459c786b96844445", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicPlayButton.ts": "0c6cbd8c78dacacee70f94622a40fcc266242858a1594cba31ec1ce21b29850f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicPlaylistEditHeader.ts": "4d7a324b4d38ae30dca3f1d4915222271e307d191a98eb16587b2d6b9d80c75a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicPlaylistShelf.ts": "c019df8605f630ba50382b42ac426b04eda45b15f72a8e40c9d63c600202f7dd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicQueue.ts": "12f1178a9df779b4d754294be22ed5fa8795ed872fd254bacebcdd6acc017c62", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicResponsiveHeader.ts": "8e3da0cff688397c3613301a3ff69070f643e7565ec9eb18299c74be207c5efa", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicResponsiveListItem.ts": "8b61d03770d722f3be9676dcbc142d2cca5831690dda866c8cd6459ed869630d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicResponsiveListItemFixedColumn.ts": "efc38b777ff0c2f4013cedf2873d20dea843ca58f5057a9146952a06cc288a8a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicResponsiveListItemFlexColumn.ts": "b76ede69a9241020468b738e28f59dbff193aab6fd5c86486d8d1c62651d9463", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicShelf.ts": "e05e2bb56da1e9ac0b826f608fc1289dfb28e4dad5eda72b43e3297835547958", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicSideAlignedItem.ts": "9afb8fbc0879edc886398a622ee7bc39e49c646c1f9cadb95dd625eef979e86e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicSortFilterButton.ts": "03a65c739950919813b5e59aedb5d6114287c069bad7388b749d6d54e3187e44", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicTastebuilderShelf.ts": "58a17ad8a95df12317504c55ac73781f2ccfcb0187dbc45dc927661eeb6fb5b3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicTastebuilderShelfThumbnail.ts": "10dbaa0f9d5fe1867fde28e04d99f6191b836627ad28fc10ff2c4b2e4f47a7cd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicThumbnail.ts": "41f8672b0537454e12f54cc13505dd1e6cf41208784fe0773f78ed7cd540b936", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicTwoRowItem.ts": "a4febe5f1d10eea35a6770b7450d53d9b93cd7dc68367d77c6c3381bb2ed28dc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/MusicVisualHeader.ts": "a723f84db6a7c067ec743525411954711e6f10327738086ed5f7bf69ff1e8a42", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/NavigationEndpoint.ts": "6f98ea6b532091b8260581dc460674cad707335dc74342a50c3e0a00d0aa90b2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Notification.ts": "1d707d5e4f885710cfe42f8d1367dabc9015e8eac9789e42bb8677927510ca36", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/NotificationAction.ts": "9786dc1516ac960217de4648278e0c1546d0d575339632d2b17428c7ec26f731", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/OpenOnePickAddVideoModalCommand.ts": "ef7742a48b137f2b0bde3ccd9910fa756259c2f99076dfb38244cc0292cb2e14", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PageHeader.ts": "f74cb411d58e4150cf1b3f4f041d2373088bd09b2d3f4538e87bfe601a1d9d20", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PageHeaderView.ts": "0cf04c56c32c908e301cef5f384e232b4461f473f8805b02e5ae30781deea140", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PageIntroduction.ts": "3f14d6da7dda25c1d2d25ed183e2fdbb2bffbae150dc3e6a410a0bb6b47ac5cf", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PanelFooterView.ts": "fa6e769ccaf0b548fb1ef8b36775baa968797d385286971c151cb9b7c97fced7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PivotButton.ts": "ce77f9a6c86c46a00aff9cedf16499a928e33d5dd6e072f289e48c25bb52e788", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerAnnotationsExpanded.ts": "26726ef10a00ed8a1b1b149facd85448d971c7fc6264bfd663a024ebb6dccf1f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerCaptionsTracklist.ts": "7eb93326a2f63726301566647bb146eed72c1d1da81d5898a5de484175a3f711", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerControlsOverlay.ts": "2ae7c6fe92fc803ea278aaabadd16f77875292d4e23d0f449d803cd3fe2ec30b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerErrorMessage.ts": "bb310ec338fba8d1b04ec5c71589b38858bc91696846b116008bb83426ee03cd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerLegacyDesktopYpcOffer.ts": "a65345864fc6c01b824127f652bc81da89a8e1538c92fcbc48ecdfbb895b541f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerLegacyDesktopYpcTrailer.ts": "113fef458818143c3451e464278410e979671d2e94731dc75f9c95beb739d2fb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerLiveStoryboardSpec.ts": "148eaa29dee8371121a55380aa58b17ac5cadbca31f43dabc7d67fd851919e02", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerMicroformat.ts": "f01c5d396fd6c1c3dcefa9d5bdc68ad80453ac0a5d44ddec87ffa54d9484e393", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerOverflow.ts": "80bf205e5c1350247b8bd373dd80b45aee66905106b3bdcdde7ae68a20eca9b1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerOverlay.ts": "fd71782dd25fcda78fac5a54cbea5191024feb98f927100a8f9afb5ac6f1d3cd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerOverlayAutoplay.ts": "b0d7a84f016fdc01d439806901e44cf5857d9a26901090fcbad31f4ec8181224", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerOverlayVideoDetails.ts": "76fbc275da6ef8b33a66804e0ef9f274c818c694c12124737ef3606942e94837", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlayerStoryboardSpec.ts": "22b756b9e0f5da75f5664d5bdb051bd8dad3480d1156f4f024bbe80fdf5d34c2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Playlist.ts": "102b1bcb02ddc70dbd1d248813e4a67c070df16d24a9972b92bfdc0e471cc60e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistAddToOption.ts": "110bba848733a13ce259098e8f5e8a2c6058f5ef9df7723d5e38bf0a12f15502", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistCustomThumbnail.ts": "0a899be5e805f4284daa22e9b1a5080029ceb10935091a8bc42e32985a52450c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistHeader.ts": "25efbb26a756c9b394f84f4fe8d8362792fbde37c386d368e80794b116c58d2b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistInfoCardContent.ts": "8a0df917c39970e63ce8cb35c0749096be09e8a6d52028b1eb5479bfccd93425", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistMetadata.ts": "4e17156fab0ad3dc9187b7f31fe633d2adf921f63d717de8b290fa7649d0197a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistPanel.ts": "c13dd7993ed1a85487ec4ec3fd998a6b81c7c2a83105a26e5d3d3c2ad21c37cc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistPanelVideo.ts": "73d8901e1a9a1fe6391b3bc1c26371c3c36b31bfdf0c8d06ddcc201357e8d116", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistPanelVideoWrapper.ts": "d20370aee1c0c30b1303c8169cc452d94b50367b9822935d18bfc7d1644924cf", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistSidebar.ts": "1f101354305065f7aeeebbe0b4c31b6db16caebeb4d83bf2e4d013a6c6a9abd8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistSidebarPrimaryInfo.ts": "6a7bff8ca434514c8c83e400930f3c86212877175a1931d1ef278aaf81bebaf4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistSidebarSecondaryInfo.ts": "80f7b2bcce12d5ff94650a376ab94055ef45b39580a5b29bff6e283108f608b5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistThumbnailOverlay.ts": "014b95c46c1ffbafb714413f879c3ad71c679941db7c948ddcd4cfcbc6551527", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistVideo.ts": "9a7d42c52075807a6c5a81c7d943c5742d6bf85613933ce0de162f5bcc93946a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistVideoList.ts": "6866ed2ed241b399e5948f0109b0c068fca10b138284e075e4790146aced9971", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PlaylistVideoThumbnail.ts": "b31d50a0301cf49033b8b28858d787bbbb46cce517a0738e7772692c8e71ee1e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Poll.ts": "2f799883f6439367b14b5c83b7f773a6b0501a9b2e00220c58d0d145f2970fc5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Post.ts": "9879703fe56af733aaafb25c708f5d294c65bad75a2f7b30f34c21463868821e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PostMultiImage.ts": "a7d537fc18346de30f0aebd9f8f49c9ebc793d4376ea11683aeef62eb7388707", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/PremiereTrailerBadge.ts": "41aee386197738594181d4866d64ef31a03ecd719b0bc4456441a8f9287da5e0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ProductList.ts": "6224bbad1d145c4a0a20817881d0e06ebf1be0c240815971ef88ed2013eecd88", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ProductListHeader.ts": "0ccb73d9ccb7f71cfd46dbf9992ff3a8c2498b3cc16e0e452fabe65d3096bd01", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ProductListItem.ts": "2316148fcfc380b9714371bbd9e814180964fefbac282d3df9b70985b0b228e7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ProfileColumn.ts": "1b88dd2b802298e68f115a4e3fdb7b5036be844da2116aab8d9d33152fb54a1f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ProfileColumnStats.ts": "08984b8e624f4ba9379b25cc8ceb67b37f62a27e88c0cba7e8ff1f89f670270b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ProfileColumnStatsEntry.ts": "eccc0b4115232b24b1e5cdb9563118acd5f0c450095f8286b29988756449c46b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ProfileColumnUserInfo.ts": "e8366b0a51984818befd7b5827d10695aa66bcc6e5d80cec79637fa1b102cda6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Quiz.ts": "0e7828c8646c1112f94a0a2285c0e8662c6abb99c83b43d9c15bda51a699e7d8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/RecognitionShelf.ts": "b9f8a431d304dddaba5a3485268bb0a283341849aa679056e1eeca6597bdfc20", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ReelItem.ts": "8ccb6a4bdefcb09a9994723d72b0c612e40e276e8f2dcd00fef426e021c4e828", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ReelPlayerHeader.ts": "ecf5d651cad256231c89032279d245ab29e8354442fa2042c9d4603ff7efc85b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ReelPlayerOverlay.ts": "ce0540053bf25d9a511bc64b1c72675d345b13a18b6ccf9d172153d2808a4f4d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ReelShelf.ts": "2faeb7548db719a32b3edd36601ac76dca6390ca75ef81db67f54694735da8dc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/RelatedChipCloud.ts": "c8cafb03cc4d06fdc785160aa5427595c035efd2232290c36330736461390e99", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/RichGrid.ts": "819ac833d324eee14e36fea27adfb06a65d8b0ea2472329a84d4ef36cb6967e7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/RichItem.ts": "602f53042e99a2ab0bcf28e329e0bf0494c01ebaafbafcf4df29c0ab1d1b04eb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/RichListHeader.ts": "8e4e1cf7544588703c88b8a4d60142032282833b52e4fe9c4f3a4b4167bb8f03", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/RichMetadata.ts": "3bb69be43c5c598f495b1b67196eaca9e14c5d0854e910dd2c59f014322fc915", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/RichMetadataRow.ts": "542b096aee5c002af9d737044c4cfd5e707ae6672c4e9a221653e79b53517846", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/RichSection.ts": "b39ceb1692ece839697b66cc3159e55338b2b5572ef5aefa7aa4fdbdee83a177", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/RichShelf.ts": "16ad1f3074f4478e522ef4d1368ea67cf54db4de8404c1ed48ffe371c9ccd857", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SearchBox.ts": "9f802ee06bd0069c7523191463eed271a57dd5a3b8c968993642b4b1fa2aad41", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SearchFilter.ts": "addddc02fe7cfad3b44169c8ac27ae994dc279473336afe0f077a4c3b25563af", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SearchFilterGroup.ts": "54dc3fd71a207157426f664e8393ef0b26af6c2686617f499169af989fe9850d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SearchFilterOptionsDialog.ts": "614e27d107f52ecc3a5e8254e7797e33be4561898dfd0e8a7517c16571370024", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SearchHeader.ts": "8e87372f2e4523ed42d9493979ac30df5145e4ca3e19e1501160e87f621d95db", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SearchRefinementCard.ts": "a882c437b84fb1ca9b353742e67bff73f1598b7120dad9f286573d43c7eff756", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SearchSubMenu.ts": "713e7c7b59e10f236eec28aacc64a77cf01597e601797ce55d4bb718a2ecfed5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SearchSuggestion.ts": "b825338fdc840b17d4d7c2d9134af32226740f659f9281a2f619eef7e8ba3179", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SearchSuggestionsSection.ts": "44ef7b0526cb8d95855506d60ad2b73a8039eff520383fe0c4617dcad6cff901", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SecondarySearchContainer.ts": "06c78d2f73b8321c616a8e1e619f522bf70a830c11a5a8ca77dd9254ca763e6e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SectionHeaderView.ts": "7d12f8253924f69849bfd8b072f5354732a275a092fd2e7d3d71756413d0aa31", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SectionList.ts": "e8b0875ddcfd5ac1899afac88b86b362c75ff551812f55dc4c7209680b64f8de", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SegmentedLikeDislikeButton.ts": "5ced84a3b23db6e723f4a782548c2515d87d850d5e74289296a09a6856571f77", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SegmentedLikeDislikeButtonView.ts": "32d1a88d4b7b0c938293d097ad337698a94fdc98a2f465a335abbc5d9c4ebc78", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SettingBoolean.ts": "0b671916bd6d7610bffc4a0ba58f1f5e0181504cca289b6b2218e174a61dbaa3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SettingsCheckbox.ts": "03618719909cf23884311ff249dc4527a90577e73cde96a5290d2109ea4d4d05", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SettingsOptions.ts": "2f624036599c0ab498ccd09b72effe1043564c693a43c6b25b058d7cd04b1fc2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SettingsSidebar.ts": "d86be1b7350d64d9a983c239cd62778b261dae9f0fc97caef5928065a7bba60f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SettingsSwitch.ts": "e5fcc8c4d7432a684b10df3c7c06a5086e6a611f50178c3cfd8a3e4cb305131a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SharePanelHeader.ts": "679ef7782a5a4d8bc98d73547de414b0d89d492fec5066c07ee3ae0450fbc58d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SharePanelTitleV15.ts": "1e90e0497bad34b6aef419a5cc632a635f103d99fab9e3f2ca79374bcdf8da1d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ShareTarget.ts": "1231470ec4c92ee7e3d1e2160b3af99c33a6d56982def0ad9642251fbc87a71c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SharedPost.ts": "1224d7529b48e02a654e4bbe8f87ef1727a8e253faecc1abcfb2db4e771c44b0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Shelf.ts": "7acb0f92ea8ef7e08fa4d0569e3994ea452e289067c021efb9ae45fb6f928da9", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ShortsLockupView.ts": "10deeefa7b778c8c248842f1ad7150df4c45b887ac05994ecd2a808e74ff43c3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ShowCustomThumbnail.ts": "db8dc3df7968f4972a7f36821777f24d28275ba1e0286815f77f68556e09588e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ShowingResultsFor.ts": "35d508ab827041eec917747d4ee311b890b82c79ce938397babf9788b7b53129", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SimpleCardContent.ts": "af567487943796a1aed6ff33217412a9cac294b52c94573c224766e1f924b379", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SimpleCardTeaser.ts": "7c147b803e88db19b6c4636195b20d42547078501134ec38d2da2752b26c131f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SimpleTextSection.ts": "1325d00021520830517d00137531e2d82a00ceda28b8bd14804b63edf982d020", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SingleActionEmergencySupport.ts": "57875038e8a642a260d767c0ffc744b4d71cf987041561a42a66417a978a1bfe", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SingleColumnBrowseResults.ts": "bade331e15a27389e1d28d5d6b45ba4ac862a1b7f7a31340e76481c1a504d8a9", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SingleColumnMusicWatchNextResults.ts": "3e685b373eb3a3815464e1dd3c37a0ca12abd27b20b79a613f489be433bfa800", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SingleHeroImage.ts": "c26cb722b9d1e38aa48801e1ecb320aea4018facb1358ae45412d10a1cad9a44", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SlimOwner.ts": "c50622add68c5ae4c8a5f068a8f95ba6fc04af385e6c797fc75bff9df4061716", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SlimVideoMetadata.ts": "7844310fa41a71293ba68d75eaf005eb8b63e555eb9c11808ede2eb1a964fe54", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SortFilterHeader.ts": "65e5b9a0a08d91b00c5a0b00eee5390fe4fef47936474a9d32daa202dc3c8113", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SortFilterSubMenu.ts": "701c6e5643968c508a41986e5361cc703375cb4c27076c4c414d8b73fdf7e7a0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/StartAt.ts": "4332ca51758bf0d69274c8cdbee3d26c9a38f53c35ba84ba6d9c339434bc6222", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/StructuredDescriptionContent.ts": "ec691ca20cd81774e42aa34570b9420a12b65fa4451752200b369732173b6459", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/StructuredDescriptionPlaylistLockup.ts": "6448040a252952a1982f7049d362ea039401369ad3954d6970bed87943038f49", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SubFeedOption.ts": "ca0bcedcc1ade07498babe61ee95c818fb594427113535ee4ab39e6bffde8162", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SubFeedSelector.ts": "4d83771c0c25e9c9900045ad10ab73db347c08cbede0a2b9d0b90be39113fd7b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SubscribeButton.ts": "50cbf0bed71a1b4bf866cbc468066c5e8835dad2f9403c5f813e5881c8a8ec77", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SubscribeButtonView.ts": "82d58331b66bb2ba88ca2244811cc947a44337df56f0c439fdd2a660676319fd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/SubscriptionNotificationToggleButton.ts": "686981908107cb2f09fe1a06e2dc68b037c5424e569c9d1b2920c9e37d1fa687", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Tab.ts": "556f323e76af4f9671c4412e793b768414df02acf11150f4b10adba2ac1897c9", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Tabbed.ts": "6a6c9bb20e05ff2f808fdfbf2492b9daad39afeacc537654edb1f68c920bcaa7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TabbedSearchResults.ts": "1804aece810964a759f1b6c4451a16065017d2c68a71a71b5f6d2aef6823dc5d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TextCarouselItemView.ts": "c67d71008bc1978902a61da0037abed8f1b208442fa88f6e9adb660a9fcd707d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TextFieldView.ts": "4277a3cb272278926d44d7d4b87b02ff23d7dfdc2c0786697f7847572623a387", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TextHeader.ts": "fe7e755d2cbb8b3d67e2979910d9d8b5135d155066d0051f59d7f2c8ac595d0f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThirdPartyShareTargetSection.ts": "f0d8249bcdac213b300365bd8d7bbe06684948a6ee38a3e78712a28e83e107aa", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailBadgeView.ts": "4e3340bb09a13e684d9e9ba71d7c9a720cf77ab2e775771e9a1771c8e0c74b61", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailBottomOverlayView.ts": "abf252051036aa2ec25a29b8aefa1334401342c64728218e7569c65f759424dc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailHoverOverlayToggleActionsView.ts": "f2088804de700c02b6a91908af3ae7c17e164b5afebf1cce207aad0bc7745988", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailHoverOverlayView.ts": "fc26c6fc7b80291ca77a1e86be50f6657b02daff38c2913960a9f41bfda7a5b8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailLandscapePortrait.ts": "b0f052857e53d3d73c9d29627dfb5b40828db0dc0aff574e5012cb95af2ace5f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayBadgeView.ts": "b14dc047f695a769553ac93355257796fb575697c0a263b284cc33a23ea41e66", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayBottomPanel.ts": "539ad58535c9d7ff641c3db289a9b6755ef6e5a1c89dd460b83ff2d75df8520a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayEndorsement.ts": "5151e5a6b29fc62117fc5cba9232f59796ddfcc96728b82c0765ada5ba206fda", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayHoverText.ts": "80aa2ce46a7589766cd38d1b145368cc0867fe8527f0b299eb1d03f68e827147", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayInlineUnplayable.ts": "d530bc3aff19d452ba5ff375c1a5bf5e6e9ce97f813ecf9194ae3807cb28e566", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayLoadingPreview.ts": "b157a91f111365e83e0750c62015670b7016e6e8ff47393d384a56defad0829e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayNowPlaying.ts": "8d249d8702bead9c7ef3ff8c7f1e894c55b2af91bd7a8c650f24b021c611552a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayPinking.ts": "938e503857c9e1880dfce837be592cc52f7ea6c01fa84e20a105fcac1fa3d2e0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayPlaybackStatus.ts": "8407bfb4ac49c67c23c9bc0d5eb098a760c88d1f0db24fa1d30dd97044592ccb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayProgressBarView.ts": "93c5c7b1aa26b8e5f7ca097b265d9a096999a181c51bc21ba00c4a3bd9a755e0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayResumePlayback.ts": "a4b38f21b02f12a9a60c57e08b87fbf6edee0849ed5383a5ddcfeb7a061fd5d2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlaySidePanel.ts": "6d9dae530d73aec0f519115533ac40a803513f461694c851950a80c5a9fd5e60", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayTimeStatus.ts": "e25f43bcf06b78ff91e112883d5046b1ae4887c4fc5383e6674761d925e88a40", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailOverlayToggleButton.ts": "f4564c9145832a9ceeef31935efbb78c142b601f26b04e04f6098464e1019ec0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ThumbnailView.ts": "24f152c361f485a7bea715adcc128391c858ce4aaeaae12e1223c22d2fff13ce", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TimedMarkerDecoration.ts": "2d05c75a747e74ee64880cc206e8320abe8949fa5b7d1f3ce00036c90ba78a8d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TitleAndButtonListHeader.ts": "db79cb8d2503c764bee22131ce77c6e8c4430750d87f7587d7c4dcf55fe93e08", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ToggleButton.ts": "4fe48adf0e705bd2124e06fe3649dead377e7a5f943dca9f6593c02150e5bc9e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ToggleButtonView.ts": "94ef857c1ad26cf05f8aa74095f46d3849d3803b2ba60255207bb1b407974b3f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ToggleFormField.ts": "ae9de70c83fd8e39388806206cb0d89c526c336cb88ca154f848e0cff2d0d6e7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ToggleMenuServiceItem.ts": "0bf6f577e10beeb2942223f0388fd371112d4deef44f147577d06bf0e396a239", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Tooltip.ts": "6d31852c1e4bc57eb81210db674f432877bcdd2c9c8cb3523a852c60a71c3c83", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TopicChannelDetails.ts": "74b3326f7a728e730d1a9ce7d597493fd6a4f161609c93489e3207d794b983f8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Transcript.ts": "c87a21ed189a98eaeb2b19e7d2ded01d00884eb9ce6f36f890072b688ede2974", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TranscriptFooter.ts": "0814c8bf0fe224a0a8d8305679c543de6023ecf325a022b9ee07d2f8b6e046d7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TranscriptSearchBox.ts": "563776e71c7e29b2f9258660c76cd079e0caea30d4bad4dd8478d7d49d9ac0bd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TranscriptSearchPanel.ts": "18215fca5f72b1d42a174e2af7e0009efbddf7637a8df16e7e6f620c848e504b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TranscriptSectionHeader.ts": "391d4f8df3f3e37a585526a6ba79808a05bf0929245e13e16119ff4ece847894", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TranscriptSegment.ts": "af8011075f81a0215ded33504dcdee03beaa9403819995bedcb0c930fba0b3dc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TranscriptSegmentList.ts": "606406dea172f1971dabc9df0b2866c0890b899fef2a2917bd79477fad0142e9", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TwoColumnBrowseResults.ts": "27e497843a39729cd850f47a838c1854a446ff166ccf517ad2a5207fd182707f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TwoColumnSearchResults.ts": "4dc5cc2d5281a29d3f8ca8c017577f1e60df14f434412207d45c925be022ff37", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/TwoColumnWatchNextResults.ts": "93a659711d38dc2f36b6cff98141c8bea9718384614a8dc398803fde490cc291", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/UnifiedSharePanel.ts": "64d74360ddb169c00f629ebcfd0e69bcc85884d0290f080883bd5ef9ba50a2f3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/UniversalWatchCard.ts": "b858b19c695c79be31ab16f27f7832cde27b7c53015c775f5c6a147df25c9c6a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/UploadTimeFactoid.ts": "083e2a6bd0257aa18cdd996041a82b90ac68fee358af395632d914293549becc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/UpsellDialog.ts": "fa053fc521c4efdd44d9b2c44890364d0e936db2c9a72cd2edcebaf818f53362", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VerticalList.ts": "a8278ee8516275eb050fa388406834deeca6a009048a4ac8b44988b3ed0a948a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VerticalWatchCardList.ts": "c7035c371f3cfb48a3fa5d6d7eea488424e04411a08fcfe288d735bb42929c11", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/Video.ts": "2862e9e8702ed773feb37cbe919bfb001c7215518ed76571a853d01a8ecf33dd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoAttributeView.ts": "2b6a3f0bb9f7a9607cb2485aef631f515d2db7d11dd6f003a50fa99cc8d4172d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoAttributesSectionView.ts": "0226ab7b38282d46087af36f9ed3e89eb10d5e465352f6b4c7b904e52b083025", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoCard.ts": "d2429818d1964c56ec3b4d37661ff70e635b75a342c3e1a77e5b4c379162f153", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoDescriptionCourseSection.ts": "bd6f03d1d88737efe40ea5fcde325fd13c188a440ab641643c0379580b55509a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoDescriptionHeader.ts": "a63af0f6248f1da8e888aabb169271a3ec3191de6f5f04de0f6885717861418d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoDescriptionInfocardsSection.ts": "fe9b60f3d32352eb161f14b2bec1a4030a3f0452e097865cf23f151022785a3e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoDescriptionMusicSection.ts": "2a4f1e13a2799e9db773daa0a90ea34c686ed490770ea3e81ba179c69d0c5cea", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoDescriptionTranscriptSection.ts": "7eafec16826ae3eb27ee99f2af3f21288d69ca3a3a8164664e466b949a355bd0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoInfoCardContent.ts": "8187a3ace9b55a1384571f4a5a9e747f95c50b943eeb350d725827ba3ca7f821", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoMetadataCarouselView.ts": "e8d41a65766755a8cb87afa33eefb452766aa10844df505b49f17c160a9fd093", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoOwner.ts": "15faf8888dac8884ba39e0d8e755a9c0fc62172f0a4f04816410a35649c10ada", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoPrimaryInfo.ts": "73909badd54fbb19a7f6c5d070b6350e60cab5d6a7fe016f6be7ef0b8af69b1a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoSecondaryInfo.ts": "0097a7cb7aed11f2b31f5d947e65c408b9a3207deb02108997f2d934280872fa", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/VideoViewCount.ts": "255d32597d633365006a9d99a804f784795403f2a55a93d625f7e604dd62735e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ViewCountFactoid.ts": "4e360108fe81bbb32d133d6654f022968d6cbbc843b71677da43a5e4f59de73a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/WatchCardCompactVideo.ts": "7c3e3f941af6819cc653365e4b557403fed5f1753839f5df19a78bd07e43ca79", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/WatchCardHeroVideo.ts": "fde492a3e7f4a6d298cae64c2a89e924bd3d8c41b75bb788ee0085ed22ced439", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/WatchCardRichHeader.ts": "39499548a3c1eddd47e8e5a507bd9639126ce7e26f803e1e2cce244188522246", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/WatchCardSectionSequence.ts": "bbfe86c3b8d62d713670bc1d7de9aee14420937038ac7f06349ee6d65539ffe5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/WatchNextEndScreen.ts": "86e02a5465d2e857edd606c55d8053ee21fd5c7411e41413609c8da8b5e3a76e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/WatchNextTabbedResults.ts": "54df6302318c0e0c7d284255fd17c7e076f359a4c617e91ac9a916cad8cf1472", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/YpcTrailer.ts": "90b8b98bbeb39434ecd06feb9d09283372354721cbc7e996debd2c9887341d23", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/actions/AppendContinuationItemsAction.ts": "d9405b3511b9e82817fb0540467f3c646e920877f7fc0e2631b2d809df7bc9b1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/actions/ChangeEngagementPanelVisibilityAction.ts": "ff805436e0fdc6fa110383a5da893fa5745a0652affe87012786cd2fbd050039", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/actions/GetMultiPageMenuAction.ts": "77c2ff18dca51a2e382d06e6bf7f40376b65296718be4aac44426ca3b16891b9", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/actions/OpenPopupAction.ts": "77d326bfdc00af7fc5630f9706e86ad9c46070b6638efe45396435d8d7a5a4a0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/actions/SendFeedbackAction.ts": "84f991c88c8060ff4841d9a3ae6f32c7b8aaa0e70323c1231c8fc842552de1c3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/actions/SignalAction.ts": "b7ab573629c1a3d9920dc12090fda82a3f7d321d8ac5d9a4a47e53c6766a853e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/actions/UpdateChannelSwitcherPageAction.ts": "40ff717963ac2459fc8a588c1177627a4367549bad3007e4fee1ac0f556fd42b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/actions/UpdateEngagementPanelAction.ts": "533dc7c75f4e0be5858003292d3c5cd0c17c00dfb0c9978d33a60fb510a8e282", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/actions/UpdateSubscribeButtonAction.ts": "8c2b55d5784f221d2b58417c7f0bffc0f193303e0fde33b92b20b7dc42759504", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/commands/AddToPlaylistCommand.ts": "20f0922289d5626e436c957e9bc8e33a9fb308fead1de786f0928bb23c29a4ae", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/commands/CommandExecutorCommand.ts": "2486e9b105950eb781ceaa52d9dace5a50d301e84ffdb7abc72d76714102bbbc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/commands/ContinuationCommand.ts": "4357ef6b73bbca64129f3ad5ecb7f4112e837d73e27f7870c11f15ff368f28dd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/commands/GetKidsBlocklistPickerCommand.ts": "770b544b9bac44e75285936ffe00d3cab4e02874fc6c8f55aabe4b12d26b4502", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/commands/RunAttestationCommand.ts": "80e1fc4a7a370ca0c73d983f7b09e21dd970b8f1a77cfd7ff4bae396bc673104", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/commands/ShowDialogCommand.ts": "3bb9a155d01b9daa05f7e4ad2dfc8dbbaabdee93d4d0d6295d42caf71fccb542", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/commands/UpdateEngagementPanelContentCommand.ts": "6d18d475d87256aa5a588cdee990565eb918fcd4507a7c7382f473eb9ea0e525", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/AuthorCommentBadge.ts": "b88cf1d08b08fdb60e74f6fa957539819a4bfed08f60d5c9c30f9e7cdf21fb50", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentActionButtons.ts": "a9b4a1d5888800da46ce1d48c0c28dd7f5785f0e08e8394b1e3969731e2d76ac", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentDialog.ts": "788ce01ca4b1d7f194983b7b467c21135d34338497506df24bbe34b3d1a4a71d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentReplies.ts": "bb7c2820f95323f1e9795123e24884da7d8d697bcba6bffc4025816deb9576e9", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentReplyDialog.ts": "69257c8893529d741a29e53b288b6b84b9353e2aeb7c92c259709138d1b3c6ca", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentSimplebox.ts": "780b9a4fbc877476f6c2a9408d8ffc820abc548d2572328d992c1053133aecb0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentThread.ts": "6af0425fc3ceee7b735486ef53246f025ea940147df5bf75ec939d98318dd708", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentView.ts": "04c42ab9eeae9b0c97db65b37810abfd361f9810392065ee1a4121fd2bb1c55e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentsEntryPointHeader.ts": "2d7ac9785cb347fe61c23651084ae5e81614ce678ec8f5c4c03bce369f1cb609", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentsEntryPointTeaser.ts": "ea1991b78ad61527edb4d731155fe8e5031b10f1873ddccb546fca36c91c3014", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentsHeader.ts": "1d4dfcda82f9fa5871c4b60fb07a36604fdbb69dc6536a59c1b421bc6c4fe439", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CommentsSimplebox.ts": "375ddc189375a9c4da5008fa2161b818b6c33f13e8a723dd779c3965ef9b9736", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/CreatorHeart.ts": "0f55fc680430299139f7ed360e53839adc11fccd8ac7c424785427caea9b8bef", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/EmojiPicker.ts": "6917164836bea89bb8684ab22b98cc9e92546b27cb72949cb19c0f60afbe73cb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/PdgCommentChip.ts": "ff40a83544c5350d0b8066c2a31e377c9d5a4e74ec9afb3019fc4c59eb8b5c90", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/SponsorCommentBadge.ts": "407dd678d0161eedc60d15f54d8704b789a61876b219c399e085d73c25ab4081", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/comments/VoiceReplyContainerView.ts": "ceea0628cfb0735f75173e2d4b37da2d1d05d355cf14513163fb288a0d06a701", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/AddToPlaylistEndpoint.ts": "3d0b8a806846af972f0aa60c4d7fa8eaddad770c4ed70f69ac93958074fbaab5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/AddToPlaylistServiceEndpoint.ts": "17ee777df7def24f45e89abbf85afbdc72da887ed6f701c185ee2c4098e39ecf", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/BrowseEndpoint.ts": "ebd5db127b359cbca1e7adedcd8109eab6a8339741b7b88dd71306fda911f461", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/CreateCommentEndpoint.ts": "42f84fc98b019ae3ddbbaee4deeb524851bf00688e85883c5bf259e78b1ae835", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/CreatePlaylistServiceEndpoint.ts": "e3bc39693fb255df8dc84337c6d7646991952369724a908d574d5f0c7a79525e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/DeletePlaylistEndpoint.ts": "3497388121f6a70386ff823489a53fa9848f7fdb5169117398fa5b35f431055d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/FeedbackEndpoint.ts": "31a990df64848e42f4d656b29f56a26c6b197c5972a840ea6d5e60a045c36a14", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/GetAccountsListInnertubeEndpoint.ts": "87d2feadc29703235a08a0144ce1686195ce5ec9fd24013dae9ee1b80b3be60f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/HideEngagementPanelEndpoint.ts": "50ddda789eba9b5bc6b81f9aaf9ad0499e8e8eab1bc0f5c9c8945094cb2e1aeb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/LikeEndpoint.ts": "378e73b82a9a71ad315bbda44a9c36440aad9620719aa738ef24fee2dee6eda1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/LiveChatItemContextMenuEndpoint.ts": "5f839665fdaa37c490ba44899ffa127383d647214fb3ea65b31390fa782f72d0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/ModifyChannelNotificationPreferenceEndpoint.ts": "aef724837eddf006eda405f2de06727606a73cbc68863c4b7f141b6be4bf8274", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/PerformCommentActionEndpoint.ts": "f21ff87222cf2afc911e8ecad88882e51c5ddae987fc7f1a159217c7bfcfbb47", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/PlaylistEditEndpoint.ts": "6930f019e74b1ea1754267497fc65ab688cab8b43a2789249666fadf87a2c034", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/PrefetchWatchCommand.ts": "a609ee27d718c44b48aeec38c0a56a61b5cfc1f231a42e1d6175172b8ff46979", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/ReelWatchEndpoint.ts": "5cbbf7e0371f5cc103d9ac4fd23a52fac9ec6ea48eb42aede0da0293eef4fee1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/SearchEndpoint.ts": "13b7281c4698bcb71134b9a49355fc530ec39ccbb8157b46332a3277d30cbc23", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/ShareEndpoint.ts": "bda5f3a0ff03c9833174b2c517ce41560ca2b6e28de8df7562e8d47e6f81099e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/ShareEntityEndpoint.ts": "bec7c5ed3d04d03808db70df1a9026580dda73a296252da60ed8698e9d0ce811", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/ShareEntityServiceEndpoint.ts": "95fe67c01cf6971cd407cdaa7534ae44eebba676e398473939c96d4db25ab2a7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/ShowEngagementPanelEndpoint.ts": "01919799954a742f76a8b39d224284306dc924cb495e458ed5df78f7c58e2c0e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/SignalServiceEndpoint.ts": "9eb7edd67edf636bac08afc201bd6b4cc615ebe06729bf2f4baa31c44b036174", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/SubscribeEndpoint.ts": "10b727b8f826229eefefa1a198d8e4926e00d907534f7cabd3b8c389ba235b0f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/UnsubscribeEndpoint.ts": "e0961c962e430d6abe1d8139ff15c0a96d4ee45f575bda773f40b8d8fde83bc3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/WatchEndpoint.ts": "47cbd56cd87cc3e21d73179e4f2368e23c46fae548ce400b68541b6ce3b7b5f2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/endpoints/WatchNextEndpoint.ts": "dc44cfcc14a61c4d40fb789c806cd1fd081979cda933f4abe531dde3f6308263", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/AddBannerToLiveChatCommand.ts": "ccba1a407ef920fdc243f8d23505358fbcb6b1e2ba7ac37f41b913a950154ac3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/AddChatItemAction.ts": "84224a2091a48b4afc904b7f82816073fabb0d489c3830ea46d0849c701d8a79", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/AddLiveChatTickerItemAction.ts": "d04afb895b7258ebbdb3889ea9c853fe3038ad6af0b2a2d1389a12dec240f09d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/DimChatItemAction.ts": "d03feacf293be8e422ad31f7f0a8c00fff46d5e1494b0c3b4ae55ba7ac9193be", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/LiveChatActionPanel.ts": "0ee954f1a788713c6fd86f62ac9ec96567a9260354e739991ac11d082ea32666", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/MarkChatItemAsDeletedAction.ts": "427fb72dff5c9930b30d65a5b96a0056aaacb8a7a29df9dfe9d15f220eaf117c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/MarkChatItemsByAuthorAsDeletedAction.ts": "4e4b36a2e177502ba5ae4124eecded7062a980b0e226199ebc308f662da42d02", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/RemoveBannerForLiveChatCommand.ts": "e3d50eb35164ef6946b8d96de837b86513e84f78a369cd0a718a8f482929ca84", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/RemoveChatItemAction.ts": "30b936d73c90115cb7c0261e021ffcc7446ae047e40d5d02cc4ed94a6331b307", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/RemoveChatItemByAuthorAction.ts": "6d75a1901e38dc2575a68a7b2c2f059e8e9e1132e739e6e033beed8f97ba1117", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/ReplaceChatItemAction.ts": "c5e640e28bc875c1f479f2ac0a9571854252ae24732bcd54304edbbf1134106a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/ReplaceLiveChatAction.ts": "977f560d5fcc3adb1e1b8209dc97de6ca1a9ca9b27db9a2c8776e13a88a97c9f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/ReplayChatItemAction.ts": "2a77f7c1c6ef12d8499c711b2a1ce3042a245b700f85dc38dd633b3a38779459", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/ShowLiveChatActionPanelAction.ts": "ef6c01ad6bc548df3bbe6bd4b8c91e535e78efec1630f910be9e61f33186ada1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/ShowLiveChatDialogAction.ts": "bf7abc0f8f5782f286439a12bfdf4a4af15f7a865daee71e3e5dbdaffafb6eac", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/ShowLiveChatTooltipCommand.ts": "c1fd87b9926e144326768a45e2edba6d42032306bacbd3eb58a98c1dbbe88970", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/UpdateDateTextAction.ts": "1c7bcc26f7344cd98751780b258755504a5462b805b38eacfe47bfd7569332dc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/UpdateDescriptionAction.ts": "e59e3b429c00a3db7df47d0b24d2fec4922dffe2d44ca45e23b2177d4c9b7011", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/UpdateLiveChatPollAction.ts": "963ba3a42392f8e4a7b290bf485274a01a38ae94be13861dc29c1150ca4acbcf", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/UpdateTitleAction.ts": "59c8fec38306c15db67f41759866a901e2fc7fc5bd9de370ffcbea7588cc2a0b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/UpdateToggleButtonTextAction.ts": "a03e8f0d7b42cc0e9714644c4c9fbdda9b7e655b1921e95e7596e341564009c5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/UpdateViewershipAction.ts": "17b0ac1c11cf1aeee6b695d31bb110a21f9dc26c83fba968c1c31686174c4f99", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/BumperUserEduContentView.ts": "ce26510164623042d62b04c1f62a34958257ad3ce0f851843e52be19c0aa12cd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/CreatorHeartView.ts": "2e6dcb88836532f8417fda5cb841f9cf82e63ec0b03a9761fd23684e1bf0548f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatAutoModMessage.ts": "33546c3245d6c9d5f057945bfe2fa91d6a73011e29c1497b044930d11aa81a03", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatBanner.ts": "702097ed5efc58b78a2640e82a8b4f6ca02080986f47764b26f492e52e2c98d1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatBannerChatSummary.ts": "b72db2136fb4ea5766e40fea705cba681eab4fab056f2bd9f5262f51edd30c2a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatBannerHeader.ts": "b786b5fd014cc934ecebac5207a3115def70170acf92ddf1b81217e054255706", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatBannerPoll.ts": "58d1bc214cf490126a015f15c9f691252d741f8f991e8440ab9e0a10a699d9e3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatBannerRedirect.ts": "41e54867436b8bc7cdf2bbdc3952d5dc82897bb8b5f2b85fd00b1f06a7c6a872", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatItemBumperView.ts": "f0696a74668b4b151d349f7b4cebb60f73b2be66df4adb471d3642083720a658", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatMembershipItem.ts": "403f563d1a46bfb3f231244de0e62a5bb76ce6201d8dc1423f10b2c51090cade", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatModeChangeMessage.ts": "dd5dcdb58b89850e24d3d921c44861670334ce87a25629f691b858248e3a3741", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatPaidMessage.ts": "07ec2c28d98b621230893161832163c1d505498767cd1dda1a473816373bff1a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatPaidSticker.ts": "5e709b32e69baacb8715ea67ab039592f722ada656a9fcce1d5b3fc6e4352f57", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatPlaceholderItem.ts": "30b308eccb2d6d85d14aee67d3ed0904d92acbcb1570d9f08581040eff4b3090", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatProductItem.ts": "5be6a06e8513a972012a1ea23ba5d8db4f736d94146c6acda3277e59bacd2b77", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatRestrictedParticipation.ts": "6ca106287e383ab1a593aa234268e677ce5515a811c6eeed8c8c970ca1ef0943", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatSponsorshipsGiftPurchaseAnnouncement.ts": "83fb3ee83cb4d9e32e18e00fabe6f8355d24bbc897db383e6bce8492a919e2e7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatSponsorshipsGiftRedemptionAnnouncement.ts": "67a5f6d655fa57fdb67f7e51a6aefb7bad20a3545e5ae541b2e2f6f7388a6459", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatSponsorshipsHeader.ts": "90e0bbccd4a8ca67235650a77e00da332deca32e2ca53c5fb3579a8715ad9b3a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatTextMessage.ts": "2a60bca182c9862d4c0615b3f217e8963cc884a8c8d7ff3907746d1d3132ea67", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatTickerPaidMessageItem.ts": "939546cda0f634e3909ec77b3e6578acb91593e4f0608e0d375c331503420253", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatTickerPaidStickerItem.ts": "e6b8600b998c5c8f942a317dacea2717b963de348df1f75ca0a4e7578e7e037c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatTickerSponsorItem.ts": "6ba17e88ffe48668bcd9602fa076f5ce92f6ef311534dd80c21c21a7212bd0de", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/LiveChatViewerEngagementMessage.ts": "28ecb874780ae56d25b71e22144106d4bebb2b7e85a5ae216b32e28af9b81f11", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/PdgReplyButtonView.ts": "65762dd2c17457ba0e6e355971bb2838249634ca3fb818f2532bdbbeddea26d6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/livechat/items/PollHeader.ts": "1bb80b9bb1c0d963ee3ea3b7f51f8fa88aaa087093bdb720e03c6bdc00766017", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/Menu.ts": "f81a97bf5d792185b9ebce818907da57ff88c790c24021b0e21e53ccfd7a1cf9", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MenuFlexibleItem.ts": "1d43401ea55a940c00a3b11e37b0f1038ba2c90c8aff3d4270c0b1f39c5eeeaa", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MenuNavigationItem.ts": "bd0c949be02af07e7743d33468a3013e50dc7737adad7eb4c44352d93e8305dd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MenuPopup.ts": "bf0e3b6c8c544c3ee668829e821a5acaefe94a58986845b25d8af7385f0bffeb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MenuServiceItem.ts": "cb27d2618896fbe6931ac04a0d1f8f5cf29fedb7459963c564d511bf9088be05", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MenuServiceItemDownload.ts": "e81361dd09e1a2c7001902a3700aaf5c91166b445b713c215c2a07ebdb748d3c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MultiPageMenu.ts": "dbdec611535a9a7f5947f9258de1b99225ca4bff240049f2ff157bb72a736dc8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MultiPageMenuNotificationSection.ts": "8b0eca2127e084af11c44596d0299918468cbf314ed290d7eb9afffbd17606fe", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MusicMenuItemDivider.ts": "2b31892d679eadb4a4d88962956ff6fc6de311f0da6f9eab02ef73f9d8b5c89d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MusicMultiSelectMenu.ts": "e31a1c0db5478c2293086e9c20cfbe6cd5e235aae94637dc25d69c6d8dce09ad", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/MusicMultiSelectMenuItem.ts": "02581cabf95e48e42d0540a144e84abebc89c6a9b5e001ec9bad0d42b85454b4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/menus/SimpleMenuHeader.ts": "3d2144bc62ae800ca8f9c5efcf3f9ab164868ac8bd27841cb16b29115314785d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/AccessibilityContext.ts": "25fcabe96662e2d566091a4411bbf8884c17cb5c9df351dc4512663185ed7321", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/AccessibilityData.ts": "eee97a01e1574a020c615b0b04c959b932d6f0c9bf0cc8eae179fd2de6654c86", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/Author.ts": "b9b94d336efc644f184763567c0d7a65bfedbcac2f3be622cfb2dfaaeb747e53", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/ChildElement.ts": "79f68bdc0d5ec32efc5651b5b85188b724fbf3c25f8a937d31f37ffcc334db37", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/CommandContext.ts": "1b9eade7e5cefca015126c384280cecdb0e38c96ff94368dffaaea29896bfee8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/EmojiRun.ts": "60f5d3aabcaac04630494af587894f08137d2d411d72231e7cd2015d9d7bada2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/Format.ts": "e1fd5f689f12af10eb1d0cb673a0a41cf58bbffb975fe905861352db63d1cffc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/RendererContext.ts": "64099a2a4fc3b8324899de6c3fecea6b9751962a111185485e1b0e78355495c7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/SubscriptionButton.ts": "10b130c9888f033dd7adabc3463018a4b0f8410c1b878577f5ad80f25fb03049", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/Text.ts": "fb21cf31e0772b3fbdeff620eb11fb6a07d41df17c3b3845e8e5c22bdce3f920", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/TextRun.ts": "c2662d3bd764c4ed9ee9dac5c08c77fb342f1ea5c85019bc8f7f88276e79a5c5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/Thumbnail.ts": "6b1110ea2aaa51c3492ed1c42ffcf90dab0b9ca433bcec256bc7be413a014c9f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/misc/VideoDetails.ts": "f5470efe6c0094b060931edfce21be0f45d0af64f0f10388d9975265b007e10e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/mweb/MobileTopbar.ts": "44f7fbfe48514915428c98b104d9b6be755de2167ee2cf260de5712f8086ce6c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/mweb/MultiPageMenuSection.ts": "a2410f43003ef117ba92e6a25312e4ff1027d96899d099e1cfacb5a700cff9b1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/mweb/PivotBar.ts": "58688f48a8e28eb83d509f87567107cd8427d81bddf951e8ddc8ce06ae13437d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/mweb/PivotBarItem.ts": "cb0a5e15747f234c76544f45ebf51598c03f5a59f196a894d1762a12e57e39d6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/mweb/TopbarMenuButton.ts": "f6b6d1b232739d22f87432d432d5b6a4c21a050df2fba065df2a25c9cacd1988", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ytkids/AnchoredSection.ts": "fbd48d39e23788538e1dc96dbb6e06590ffd5c0dd2113535e524ce0729a629d8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ytkids/KidsBlocklistPicker.ts": "42946dc4c361f0e94c399c4ae4ed596db9b1f0c610e082edfe62c20a90cdea2d", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ytkids/KidsBlocklistPickerItem.ts": "987024492384470cdda76695fd930290038e0547176101797ba16b9212801388", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ytkids/KidsCategoriesHeader.ts": "cc93ba2d93382b82724ba82b062012cf37cc465ac6615c896c50cfca9b42a172", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ytkids/KidsCategoryTab.ts": "a8f20f69eed8d5ba306541ed2caa52bcba71a8e91b2adcca915a6f8c1bb4479c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/classes/ytkids/KidsHomeScreen.ts": "58dcbf376422f3ba7ecec749ff2aab6e72f3bb747e669792cfe444f9f1e8a1b2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/continuations.ts": "8f4dc346c25ff8c20864c563e63d3b409d244ed21bf55453a344c79591c242b0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/generator.ts": "aa6aba41a04a06470320f2f246d70fbfbb07ea85053fa0ae2610a450e4028468", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/helpers.ts": "a75b626033557cf63e0a8419a1241e657f6a994cf8c741dfb2290d003250dfa5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/index.ts": "bb5e4232cef64df81417348f464b07193a8cf0f198f809b5744bba760337b040", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/misc.ts": "7f8d468445f5e58ff5fb61f78ba2bf38447e77400b4975b255c69b4c1cc1ad7a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/nodes.ts": "bcf879cc6a4f526cb1c333f68551888446a8e61b85923c7e06996ed764cae9e1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/parser.ts": "2f70a25bd6bd524da94404d482e95e10325a6c47faae3d42624fe18a7d1e2e02", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/types/CommandEndpoints.ts": "4e09e63c0f19959f642045eb178c389e9561667ed5a877040c99b74f4220ffe7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/types/ParsedResponse.ts": "9066b5064a347759b11ee1f596b66acc2523d54b4855b5faf2ae13827021809a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/types/RawResponse.ts": "1b014ddedb55e2229e8b41591065e96bbbd669a26aa3b037b33efbae69266715", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/types/index.ts": "007247905143a2c8fe5c1bb02470f126f9107af5ea2649c1035489587aff97d1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/AccountInfo.ts": "55db92aea54b21ff7d21741e5b76ebbb365bd157e47913200426cf25da7dcf80", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/Channel.ts": "157735bfa0101b3c38ccd4287424151f01d95fe8fc8a53f0d4ce837584035dee", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/Comments.ts": "de5684f3c20bd3890435bd959f7d515ea989356ec6802d04af9b0de68d6a1595", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/Guide.ts": "36f38dc9574207941166b78c7ccef9d5dd59ebc72c0ee569db3452dc59d77f50", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/HashtagFeed.ts": "f3e54bae94e2fc3c2aa0c7aaf709ce1e9a708684eab8b6fce1202e2066764860", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/History.ts": "30c4bbcbffae06536f1b04fe7a478d8a3d2de12dfb201e15b17537566d43428a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/HomeFeed.ts": "bf7606b3c4734c90af713e0d98fc4783c7deca7778a531b39f182d29b577ffa8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/ItemMenu.ts": "ad53265ac6c382e537d81a5f96b874b16bcd195140307da4f79cc7c0451d6153", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/Library.ts": "23a00c894ab996c29cd3b33a765090d2e0cbdbf896e6c6140da7676905b0c206", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/LiveChat.ts": "f385e5b8314ead6eb71200c59b5fd2e7ab32b7168017c18bb14da1397d402ab8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/NotificationsMenu.ts": "782f10807e96199bd3846e66ba8f2d768188e3b3287808d7bfc283db87607f38", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/Playlist.ts": "6a664495ab57f9ee2da58ee508d03438e65d505d83b58c993f0b0d02c1ee8873", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/Search.ts": "1c014ab5fd1bf2c57b373f3d3ddddcd702d3b801ab7262755bba40c0939bbfa5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/Settings.ts": "3318060058505c878f8f4a62ee69207c75fbaecdbfe6114b80d07b78a66e78bd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/SmoothedQueue.ts": "a6bfe6084d76489dbde3f7cff9ef50a2fdf561b33c04ac56fc923ba680b5ff67", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/TranscriptInfo.ts": "b8ecff9130ed588d867a08659a62f71a0113f425953c83288f358f82eec4a266", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/VideoInfo.ts": "e44e535131fd057c3573c80773bf66bb55d6d5602f34319e736a7e0d5356616f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/youtube/index.ts": "43a4bf938c7e99e3ca2fefd0a83dd66c6a19ef3493d878013c612aee9f965c60", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytkids/Channel.ts": "48671b34567963c1802b5c411fc81f79b085d472610d30f45b454d3505210f1a", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytkids/HomeFeed.ts": "8ab641632309a9e06cb25863ea5612156dc00d827a62e4699ed500bc22575ab0", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytkids/Search.ts": "ace048184b9f86b00cbf59200c9b39b5929234651c160eb36a12c653d28ea7aa", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytkids/VideoInfo.ts": "ad3905e776cf6526144eb1e1e82a737c7af5ba78c8a54e11a0b1a3d0fbbbfe35", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytkids/index.ts": "6638e38d05663cde1515bc7e28893ac7f777cfd86783f0c05f7d0288dded7173", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/Album.ts": "44c7261eca6957285b45cf148c053f6ec25e7ae791173721250feac6d1831cdc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/Artist.ts": "d54862c71edd59edef2f58961ce75e56e9664998de493163f436eb066cf0cd50", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/Explore.ts": "8309008ef8b106340df72eb1d4d3cdb2b3ee375d31471ed4bebf6f3b963b69aa", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/HomeFeed.ts": "3c8bc9920e0e6fef613dd14531436fd5a06fe325d075eeae2421735ceb1085fc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/Library.ts": "c0609da173f7ff97ed598f2e348bb7890ceea6f37f84d2104b339033e77413c5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/Playlist.ts": "2f43b31cdb9ea84f442e2948fcb35172f59aecda4575739b8e6cc78e03116ea7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/Recap.ts": "3444981c0ed27e6263c0803ce27618c68f129b2cac580529844ac7e8003ad27b", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/Search.ts": "ff4746ec399dd528e26fbfdbdaf2811e0568ae41440adb597cf3e6246158d6c8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/TrackInfo.ts": "cca58496ddd9064377e66b737a69c57b0c8fb51c66e112c8380009804d7aa7b2", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytmusic/index.ts": "0f4181b7311ffdf368e890c97820fdb1a33a32609be8ba002e7c131054fbb7ef", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytshorts/ShortFormVideoInfo.ts": "411625d6125efda6122ff6ebe4d5b220c0f8f910c67941ffad0197107b409af7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/parser/ytshorts/index.ts": "03d0e82f61eaf3065e0229da89b5798c9bf6602f6c7ae012dc93ba9046ea74c4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/platform/deno.ts": "44f5ce00f11469a4263f9e308eefda7551d4109159f3b9f990649580a171a1a3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/platform/jsruntime/default.ts": "0354bd04f0c24bd659d83ecb929365f53288e45d599e9d47188dea649d9bf236", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/platform/lib.ts": "b1ef65da57cc2acfa278a6468d7a6e045f96f43f8bfebabb2dd683ee23e047dd", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/platform/polyfills/web-crypto.ts": "ae20ed00dea9eafca9ba590f4fa440299cbd57288add788c59cb19f3455ae6d1", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/types/Cache.ts": "06cd238bce7c9657055151587e36ee445e8236d54d27272124ced10ea7be0da4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/types/DashOptions.ts": "cf694c112ab97d778b3df735ddc76fd16fd5ae0d49943e2cc580f1f986f63da6", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/types/FormatUtils.ts": "a877e6d3e84651d5de882a3e6cba7ac82ac4c64d315be64c072776cf2a9eeb72", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/types/GetVideoInfoOptions.ts": "f6bbc11dbf9ee5677e1a55b3064f50d716e9336aa8d4c4a32c164e64576c1beb", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/types/Misc.ts": "971702ea50befc95a46c818595a09af53e38c6d7dd691cec6297c6e06b0002f7", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/types/PlatformShim.ts": "24e8c48ad6754ba64bf95327317dbcc5450c6092e87e7bc06e92f757b22ed4c4", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/types/StreamingInfoOptions.ts": "e709c0f53e0361370ba455f811fc97825ba35739b2b2766b0c13bc32f8cbf264", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/types/index.ts": "16ec980101e06859f4269f8abd1bc3372dd2a9deea200dbc8396d821449738cc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/BinarySerializer.ts": "1f99ec65ec2d52a299fe70b2b8d868bd4ca1d4750d5b360220f81015936180ba", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/Cache.ts": "fd90c88da32e9283adf065475a5cb4e680b5152a6bc06dd8a3dc9349358cab35", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/Constants.ts": "477ec062b9c497fdeb53dc03247ca7a3ffba94df5bd57765db82304bd749e6f8", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/DashManifest.js": "53435f8ee6f35ff3261a88f96824a3f040ff8ef0db6c6dda55f0494f181f2946", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/DashUtils.ts": "05723c6a8dc330d4032d7d0dde416f5cee5caac98a1a5c66e4df86a1ae9fc320", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/EventEmitterLike.ts": "ed053c41100dcb5d8e9ec4120fc8916a0eef1abbe7c215480f59b81d5eb75386", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/FormatUtils.ts": "e4a9dbe5f013ce25ed877db655997e9375a732b1ddca3b35118b18e4d1b10450", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/HTTPClient.ts": "79679cc4883b521078638c68f1388411ea762485848edac828345330c7af1535", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/LZW.ts": "b31ec32b1569c57cea5342e6fbcc8c5ef99580bfc1728870ee0d34db7d632516", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/Log.ts": "a2b5adadd4515d4a5879170e79201254265ba48272f8ab325e8070293aed15fc", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/ProtoUtils.ts": "b81c1a202bbaeffa9a9792ec1d5a13e86ad8ca87ee81b0bdbba094bda4ae3f8f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/StreamingInfo.ts": "f9caf90f08da40734100adb022dd578ade17b1c4be2132e30b881ec8c58558d5", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/Utils.ts": "d791bf3d02a362b53576a935d96847104e7a5624175faf8fd655e164ecb25d6c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/index.ts": "3725d7670be90acbc34e1829d29af0ee9362c00c5ca8f0f16b75bdbedbf71d69", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/javascript/JsAnalyzer.ts": "e275870bea05feaffd1fc93780b811a624c2be42223315d88d14c5fc15dcfcc3", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/javascript/JsExtractor.ts": "39a57dc56beb77972431f305c47980a232d719f2d6f3f71021530f7ee08e6f2c", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/javascript/helpers.ts": "c7676a2a0b5415016114d2dd30a44379ac59d5497a6486ed5720a5ad992e1106", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/javascript/index.ts": "eab40ae620d6b2e7efab257f9d50a3b80e312288a4a0a757db7541182dd2da8f", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/javascript/matchers.ts": "ca3bb7e2099758d43930797fb86f39b15c611780a5d6bb8f80d0dc6db005725e", + "https://cdn.jsdelivr.net/gh/LuanRT/YouTube.js@v16.0.0-deno/deno/src/utils/user-agents.ts": "f17fb5becef1bc6e06c908e038f561be8b2c601ad7effd793da5fadd372abd83", + "https://cdn.jsdelivr.net/npm/jsdom@26.1.0/+esm": "cdc60f484ef171bd966991cfb3fbfff749edf8bda65edc7f3e251ce82ccc3e48", + "https://deno.land/std@0.159.0/encoding/ascii85.ts": "f2b9cb8da1a55b3f120d3de2e78ac993183a4fd00dfa9cb03b51cf3a75bc0baa", + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/x/brotli@0.1.7/mod.ts": "08b913e51488b6e7fa181f2814b9ad087fdb5520041db0368f8156bfa45fd73e", + "https://deno.land/x/brotli@0.1.7/wasm.js": "77771b89e89ec7ff6e3e0939a7fb4f9b166abec3504cec0532ad5c127d6f35d2", + "https://deno.land/x/crypto@v0.11.0/aes.ts": "0f4e5af07514a07d56ec01b2186c0153f13d6e00c26fc4e70692996af6280e48", + "https://deno.land/x/crypto@v0.11.0/block-modes.ts": "6919d89616753727b87308c8829d885257534329e0976cc1940873bb95e25508", + "https://deno.land/x/crypto@v0.11.0/src/aes/consts.ts": "582aeed7afda2fe3deac4a60c4a9f29c60a7fb66f56645f95fa0ddab49bde994", + "https://deno.land/x/crypto@v0.11.0/src/aes/mod.ts": "883d1e48d033dc4491f3a336c07235c5c4cb0371972476b8cdeea5e94ad2efbe", + "https://deno.land/x/crypto@v0.11.0/src/block-modes/base.ts": "9006474c676782602ede9ea16aa49e8d084fd5670f6050641715a3d3085e1ba5", + "https://deno.land/x/crypto@v0.11.0/src/block-modes/cbc.ts": "c12036ea98e694a283396bab8608320ae99250940b28cd25e7a4a263b0611db0", + "https://deno.land/x/crypto@v0.11.0/src/block-modes/cfb.ts": "6668b84874bceea1c33ceffa9a37f378952c3766dc500054d434ce2cfba0efff", + "https://deno.land/x/crypto@v0.11.0/src/block-modes/ctr.ts": "06fd8e338dbda0a6a7fa49718a0fd247830759820e61646a6cf611ad69a9d464", + "https://deno.land/x/crypto@v0.11.0/src/block-modes/ecb.ts": "c346d692f16f8efbcc041c25dd761ca223ef92e03bb05457908953bf261ba325", + "https://deno.land/x/crypto@v0.11.0/src/block-modes/ige.ts": "4ce89fd3995b2b562b9036e857ee8a99d283421c45660b2c0c9f4d33b93d95ec", + "https://deno.land/x/crypto@v0.11.0/src/block-modes/mod.ts": "6a64cfd5dc627f161176bbf97959fe3d2dacd0a35f54184e226bf1ee4714434a", + "https://deno.land/x/crypto@v0.11.0/src/block-modes/ofb.ts": "0f77075505853b4ba1a55b4edecb17323f8a1489456a3d3b74717565cccbf2ef", + "https://deno.land/x/crypto@v0.11.0/src/utils/bytes.ts": "7ccf6cb2d747d9b9de04c9a2494999957c1e86fd4e14bc228aad25283323f5a0", + "https://deno.land/x/crypto@v0.11.0/src/utils/padding.ts": "544c51a471a413b15940bf08b285bc6a5db27796ff3cf240564f42701aba01dc", + "https://deno.land/x/lz4@v0.1.2/mod.ts": "4decfc1a3569d03fd1813bd39128b71c8f082850fe98ecfdde20025772916582", + "https://deno.land/x/lz4@v0.1.2/wasm.js": "b9c65605327ba273f0c76a6dc596ec534d4cda0f0225d7a94ebc606782319e46", + "https://deno.land/x/zod@v3.24.2/ZodError.ts": "27b41119736fcdc69cc72e63838bed1e9e1210c7ce721211f02256e06b443b55", + "https://deno.land/x/zod@v3.24.2/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.24.2/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.24.2/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.24.2/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.24.2/helpers/parseUtil.ts": "c14814d167cc286972b6e094df88d7d982572a08424b7cd50f862036b6fcaa77", + "https://deno.land/x/zod@v3.24.2/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.24.2/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.24.2/helpers/util.ts": "30c273131661ca5dc973f2cfb196fa23caf3a43e224cdde7a683b72e101a31fc", + "https://deno.land/x/zod@v3.24.2/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.24.2/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.24.2/mod.ts": "ec6e2b1255c1a350b80188f97bd0a6bac45801bb46fc48f50b9763aa66046039", + "https://deno.land/x/zod@v3.24.2/standard-schema.ts": "4abb2e7bd784fb95d219584673971bb317e74fb4fd0c74c196b558ba46df4456", + "https://deno.land/x/zod@v3.24.2/types.ts": "91f825106bcf5b2ba08daa108283aadc32c0ac332f09c9a90db3d88b142476a3", + "https://esm.sh/@asamuzakjp/css-color@3.2.0/denonext/css-color.mjs": "5a7c2124bfb224efba4158a1a38e8b618256d6e1fd809e93b83d96daa5a25a96", + "https://esm.sh/@asamuzakjp/css-color@3.2.0?target=denonext": "67bce3fe2d3c1373f13f35065aa7f528f361b398fa2455fc982e265fb4691a46", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/create.mjs": "d9fcaeb3dfeb517c4d59843d78d7b51b3ad91b911e35bc317dc29e97e4ba9e67", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/descriptors.mjs": "7e5dd304539ba1889dea236a3635983d22038ae19bd4952d1d9323f1622a9aaa", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/from-binary.mjs": "8619f1c113211c1bfde08795f2a8f30faeb6b48fefecf044dd12580bdd77e176", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/is-message.mjs": "b6435ef48053c60211391b011012c25056f8527a4b1a1201c128460adb60db29", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/proto-int64.mjs": "43ff71e1af9d45f2a6ad6170f4e4aa6c20f98d47a0a68be3b3f8363764ce1bd8", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/reflect/error.mjs": "e90d2f17fe9bf940b349915978ff389790b267313eafd016f8c80ed3633e2759", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/reflect/guard.mjs": "b7c13bcf7826d6dd26904e33abdc18e83896b2d9fda783d988257f62849e02cb", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/reflect/reflect-check.mjs": "03168260147a043ccd73d45d2e3c5e1adb37aaac3ba4834c5eca63d0f5c2fd1b", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/reflect/reflect.mjs": "a4daf06c0f06e91c1aac94566e7831006e35da21e2e85afe3f17c77458adc3c8", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/reflect/scalar.mjs": "3d4e9550eb227008f020a2b29cabe6dbf863fa17251a5a966163aec135f0314e", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/reflect/unsafe.mjs": "2e5be946c0a5f2fd57aa0631cd0edea8a8b836e6ca3d1f884175029de13612c1", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/to-binary.mjs": "28a330bdbb17e5d18957d164c9511c199615d9b13a9223d6c67260b0f1fdeab4", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/wire/base64-encoding.mjs": "6d5ac314fcfdc2af5529ffa8e319d4fbb5d6f18720a0eed171d012dfce3bb25f", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/wire/binary-encoding.mjs": "125088da118dcbcfde7fa3d11b87d8da30fc30418aa046684c348eea0f842251", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/wire/text-encoding.mjs": "da32b0e15885c8963c118c907927e8ee60fee57d21e3988bfd3aebf44afb1265", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/wire/text-format.mjs": "9c8e39da4c3cbc0618e93219f7def9356774198a005d3da92870aaa41a2eebd3", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/wire/varint.mjs": "148131cfbd4bebfa7aed5c4131a3c76c1a72ee95daaf3acb392ddf755e9a8bf5", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/dist/esm/wkt/wrappers.mjs": "2fdb6acdda91c53c238b51c5ea6ef8def5b4e9871f93c439ab835c0a9f1e6070", + "https://esm.sh/@bufbuild/protobuf@2.0.0/denonext/wire.mjs": "6a6e0ea3c95cdc3b4dd4118c4ef7fa81f677591c2cf8a8fa8e4c991a72f86a16", + "https://esm.sh/@bufbuild/protobuf@2.0.0/wire": "eb1be07dad5823fc419cc9e6f62077a70962ce42facb1a5240b7d5c3674e852f", + "https://esm.sh/@csstools/color-helpers@5.1.0/denonext/color-helpers.mjs": "e41a79a8b8770e970eaec926edbc0f801808da289e078f99a2b3eba2879d52de", + "https://esm.sh/@csstools/color-helpers@5.1.0?target=denonext": "75a3bf1901e7315eb57ce8d57304625cc471384d534fd9c54a61ce6426fa54c8", + "https://esm.sh/@csstools/css-calc@2.1.4/denonext/css-calc.mjs": "5b18ad9a5ecde39080884246c241b5c838bd49d907af09bdae683cc04d624508", + "https://esm.sh/@csstools/css-calc@2.1.4?target=denonext": "e251f57041dd61882defa05a49cf6dcb4917494ae2d4c9673313bfc7d416ba0b", + "https://esm.sh/@csstools/css-color-parser@3.1.0/denonext/css-color-parser.mjs": "80b6757e5f427f44d3a5a1b8834ccc5bd0e3e665745a2457c75d4a0c12002fa3", + "https://esm.sh/@csstools/css-color-parser@3.1.0?target=denonext": "3b18e660d1cf0b8a63867d30a66158839a4c284000298001acde43f099718b69", + "https://esm.sh/@csstools/css-parser-algorithms@3.0.5/denonext/css-parser-algorithms.mjs": "b3ebff1cb4bafa28cf52827611be3b1d40dd6427cdec731840fb4046fc66335d", + "https://esm.sh/@csstools/css-parser-algorithms@3.0.5?target=denonext": "b97bea9487f8a977db6c26c593180ba7b790589543166c758ea34e225108049c", + "https://esm.sh/@csstools/css-tokenizer@3.0.4/denonext/css-tokenizer.mjs": "0d9f924e8593df969ca0e7549e553cdbae5f8bbe5f93048ee5d9d4009e6322dd", + "https://esm.sh/@csstools/css-tokenizer@3.0.4?target=denonext": "76927db5a406c1a5b34e883768ea8719a3754429f95298d6ad2d22e8248336a3", + "https://esm.sh/@opentelemetry/api@1.9.0/denonext/api.mjs": "ecdd000d5db6c4ad6e56d28940d9344e5134ead8898d640ecf5dec3baa9b5c1c", + "https://esm.sh/@opentelemetry/api@1.9.0?target=denonext": "6969eed0eafa68f1c525555a674daaa3143bb7f41f531bf7c1c9967e32419038", + "https://esm.sh/agent-base@7.1.4/denonext/agent-base.mjs": "946861146cac434717ababac2bec4bc54265e65c6836ed13824ea7038156ad0b", + "https://esm.sh/agent-base@7.1.4?target=denonext": "b31574e95ddac2824849158d58de6142f36a95e344660dcf3c75e0596adc9801", + "https://esm.sh/asynckit@0.4.0/denonext/asynckit.mjs": "4ef3be6eb52c104699b90ca5524db55ec15bc76b361432f05c16b6106279ba72", + "https://esm.sh/asynckit@0.4.0?target=denonext": "c6bd8832d6d16b648e22d124a16d33c3a7f7076e92be9444f2e4f6b27545708d", + "https://esm.sh/bgutils-js@3.2.0": "9691c575e7f81d8c2652260f59356b5de97a242fc8b464dbba880a543f6ce075", + "https://esm.sh/bgutils-js@3.2.0/denonext/bgutils-js.mjs": "9acd0267c5bf7273ba122a295f97795cb81d0f6d001db708c92548ac82977f93", + "https://esm.sh/bintrees@1.0.2/denonext/bintrees.mjs": "a60dece304ed0119eeb04d32fcc783ef3adcdce64ef64c7f6d07af54ca078bac", + "https://esm.sh/combined-stream@1.0.8/denonext/combined-stream.mjs": "364b91aa4c33e5f0b4075949d93a3407b21a8695031e7c2be29999d588f9ca2c", + "https://esm.sh/combined-stream@1.0.8?target=denonext": "a0c89b8b29494e966774c7a708e33cc2df16a0bbe2279c841d088e169e7ab3c4", + "https://esm.sh/cssstyle@4.6.0/denonext/cssstyle.mjs": "1b786d2c43d33978d3a56505c299da47acfc08cd6351262110f00c94f244f82f", + "https://esm.sh/cssstyle@4.6.0?target=denonext": "7129256480c55ea444526675680d480e84ab3d22f99c61f98432936d1dcf1d73", + "https://esm.sh/data-urls@5.0.0/denonext/data-urls.mjs": "25b6809e8f13037adaa1426a551c35e41a4edaeb60bf449774f95a6548d7e7cc", + "https://esm.sh/data-urls@5.0.0?target=denonext": "9c793f0b7495861bb9721fe6956a441328c93c70e0159e2aba56fd54385cadf4", + "https://esm.sh/debug@4.4.3/denonext/debug.mjs": "76c71326f78377cc7252a22502b6c058ad13c06d77abda67e670555659f7974e", + "https://esm.sh/debug@4.4.3?target=denonext": "9df8b8806c13297f96185899110f8146b8b0c7944847c8392cbfb4e730a634dd", + "https://esm.sh/decimal.js@10.6.0/denonext/decimal.mjs": "6b826e59480ea004462b2c1bb0d13c606a666fdb2ffe58026ea5c85c390c87ee", + "https://esm.sh/decimal.js@10.6.0?target=denonext": "208d405529a515467f200d440b28dd77bf3f61f1a12ec0b508ec0784afc828ff", + "https://esm.sh/delayed-stream@1.0.0/denonext/delayed-stream.mjs": "051a3501b7b3d3c593b78a2c7305093a8e363c518cd156f1a77117185e312abe", + "https://esm.sh/delayed-stream@1.0.0?target=denonext": "d363b81e01f4c886114df14aa660c1a938bbb4be851ff12132260bed0db6126e", + "https://esm.sh/entities@6.0.1/decode?target=denonext": "3ccc9b5e285ac182223bec6c9e053ff17814f4d27a31b8abafe35d3b684faaa7", + "https://esm.sh/entities@6.0.1/denonext/decode.mjs": "0e11dc867c49cd73eaa3de858276b02727bf6a3e1e5a84be72722cba08697b7d", + "https://esm.sh/entities@6.0.1/denonext/escape.mjs": "f23f7faf0499133a54a93a5dc4276f08793b2bb36b539b1bacddcc3b1e746aca", + "https://esm.sh/entities@6.0.1/escape?target=denonext": "c3df42c65816226666e5a0560e68e3c058a184684488a26b3dedce05eaa329e2", + "https://esm.sh/form-data@4.0.5/denonext/form-data.mjs": "1898b5107b92409a9028d8d8f65213c2de681aba22eeda5b5558958ce2df08cd", + "https://esm.sh/form-data@4.0.5?target=denonext": "0a347e31d651ff1c7ea3e89373c5418bc475f826abfc573209d17632be61844a", + "https://esm.sh/html-encoding-sniffer@4.0.0/denonext/html-encoding-sniffer.mjs": "7ddb6dc2201652cb21f0ef05cf26123e73fada571849ac98bd9c7f005c3f4f31", + "https://esm.sh/html-encoding-sniffer@4.0.0?target=denonext": "d096f813febda0c56a0c54fa5aa1989383fe1923c94150e5b4cdcfa02187f301", + "https://esm.sh/http-proxy-agent@7.0.2/denonext/http-proxy-agent.mjs": "1556b1f1ed4065b42820b9557da226cef428a34529593982ab0dda56b9cee3cf", + "https://esm.sh/http-proxy-agent@7.0.2?target=denonext": "1c3557e656d2df5ab36e25c827cf0ec6f12502fb3273def69c0fde08c84b22d4", + "https://esm.sh/https-proxy-agent@7.0.6/denonext/https-proxy-agent.mjs": "37b0793e160738a4ae695ea567218fb5ad39c458c33ebd5857f06deb3605ffec", + "https://esm.sh/https-proxy-agent@7.0.6?target=denonext": "cd42ba7e40cb2f053a2151f66585c0c338818807c0d7208e23a8302c15659b23", + "https://esm.sh/iconv-lite@0.6.3/denonext/iconv-lite.mjs": "58314c9a1d16db741c1edb519849067c19f931ea0331a2e3ad5c02b1dcf2f82a", + "https://esm.sh/is-potential-custom-element-name@1.0.1/denonext/is-potential-custom-element-name.mjs": "ced6030c333d173179c2a00a27397f71ed68725be65e78be74f2c6c61dea18cd", + "https://esm.sh/is-potential-custom-element-name@1.0.1?target=denonext": "78374c1d74ce1440683f94d22341ba09b796b2a86ece0ab387708b66fbd007ce", + "https://esm.sh/jsdom@26.0.0/X-ZWJ1ZmZlcnV0aWwsY2FudmFzLHV0Zi04LXZhbGlkYXRl/denonext/jsdom.mjs": "dbc8edb38d4b4138c1fa683f716ba7c2ed1e18857767a34b10756a01e861a155", + "https://esm.sh/jsdom@26.0.0?external=canvas,bufferutil,utf-8-validate": "376894d1abef4c5673633645209082c360d7011b55991786a0429e433a6a6941", + "https://esm.sh/jsdom@26.1.0/X-ZWJ1ZmZlcnV0aWwsY2FudmFzLHV0Zi04LXZhbGlkYXRl/denonext/jsdom.mjs": "f0a0b0a40aa1c1e25436d413fac2ee94667b2adc199ef26d046a6d174475a527", + "https://esm.sh/jsdom@26.1.0?external=canvas,bufferutil,utf-8-validate": "d9cfe54cbdd50721a50d6aff792be0adbadeb92f7718ba57a9939326c3dedc22", + "https://esm.sh/lru-cache@10.4.3/denonext/lru-cache.mjs": "ba5ee5ff9067a3a33de530b47c0ce7e81cdff92937e5519258ed422b7e23fbdb", + "https://esm.sh/lru-cache@10.4.3?target=denonext": "e0a3f66d0dbb61c67fbec24fb29ba1b18e21dab43178cfba399481560e076382", + "https://esm.sh/mime-db@1.52.0/denonext/mime-db.mjs": "f93feb3d7150014b71bd0d06c5bd819db56a089b31b8b79a3b0466bb37ef005e", + "https://esm.sh/mime-types@2.1.35/denonext/mime-types.mjs": "704bdb318816fe1360c90a196f7cb3ba6e25fe207707cc2df873f890ad2e5f44", + "https://esm.sh/mime-types@2.1.35?target=denonext": "e4cc9a1aabecc1be22d194375ec3b99cc9d51700cc4629ab689975451c0a8ce5", + "https://esm.sh/ms@2.1.3/denonext/ms.mjs": "9039464da1f4ae1c2042742d335c82556c048bbe49449b5d0cd5198193afa147", + "https://esm.sh/ms@2.1.3?target=denonext": "36f5aa7503ff0ff44ce9e3155a60362d8d3ae5db8db048be5764a3a515b6a263", + "https://esm.sh/nwsapi@2.2.22/denonext/nwsapi.mjs": "a127c469c81e7bd9fa7fac9c76a6d14176d0cd9feceb9a237b0df9fe324f4396", + "https://esm.sh/nwsapi@2.2.22?target=denonext": "3945757b94dd2343180deed4e385a546b78dbf63f1a35442bab6037b062107ce", + "https://esm.sh/parse5@7.3.0/denonext/parse5.mjs": "39564d89f13b5d701ac8f869caea8660ada421fcd19ef2234adfd935cf7cf27e", + "https://esm.sh/parse5@7.3.0?target=denonext": "898a8cf4c02510b1cd0f90c9dffdfe9229f31f2dec425310da4404d472137f2e", + "https://esm.sh/prom-client@15.1.3": "8dfdb71d7bce0684fcdc43210c6ea05a645b0a6c50e142d122aed6f341e1ebf7", + "https://esm.sh/prom-client@15.1.3/denonext/prom-client.mjs": "a01e7faba98754dfc9f0c02cfa27babe7bc199c391c2abb3ffe012833b5884b1", + "https://esm.sh/prom-client@15.1.3?pin=v135": "560dcf5c7d90f8f843c82560b4640fdc66ba7ad7ccecce36503751e2825735a9", + "https://esm.sh/punycode@2.3.1/denonext/punycode.mjs": "5d108000c361561f4ddedc3f201df2a40a0b251c4d94df75ecc959e5ffcc99ac", + "https://esm.sh/punycode@2.3.1?target=denonext": "5b74b6114721e33c740cf6aeafd96838523b5cd9ff3ca71855726bfc8d48d7e3", + "https://esm.sh/rrweb-cssom@0.8.0/denonext/rrweb-cssom.mjs": "f610b63ab54323b433430b132329d9b921cb2c007c0529210349f40d6b07e985", + "https://esm.sh/rrweb-cssom@0.8.0?target=denonext": "d41fd8fa9368efbb84c06d1b955068a2a6dfae71c2c7bbf9cda0523bd64e9fe0", + "https://esm.sh/safer-buffer@2.1.2/denonext/safer-buffer.mjs": "63b601ca3ed03a32349ca04538ac898f2c520dbc6b554246d11c482c31e8a6b8", + "https://esm.sh/safer-buffer@2.1.2?target=denonext": "381ed15e73b07affd71d58c070213d027e4b8951c381f44e5f80589d3ea9957b", + "https://esm.sh/saxes@6.0.0/denonext/saxes.mjs": "a1160d1240a848f68ad3e280147d0c2b4ef2a9f39e4c70142ff40512c2a6d128", + "https://esm.sh/saxes@6.0.0?target=denonext": "49b388620601a8d5fbc00c614d8bb3bf2a59dfdf70df20e67e6dbf51067707ef", + "https://esm.sh/supports-color@10.2.2/denonext/supports-color.mjs": "a263c0229c209e3fed166348cd02e96f08996902c9e8e7c6de4ac50c8ee2a748", + "https://esm.sh/supports-color@10.2.2?target=denonext": "f938c90ae6175cd0ec937dabb90259b115033d9dfde267b69566089f06f92d06", + "https://esm.sh/symbol-tree@3.2.4/denonext/symbol-tree.mjs": "4b03e097480f40b035abeca19562b99968adbd551caeee93672d1a22bb5e0776", + "https://esm.sh/symbol-tree@3.2.4?target=denonext": "22b3ce01dc83f3e9ec2a07ea3a01f56532202e91a2f7a956dc494c7f01f51a09", + "https://esm.sh/tdigest@0.1.2/denonext/tdigest.mjs": "379f502069e3a3efb26dcc3e72a117e2f9c78e4ce3bf6abd6944b4bf8598cb0a", + "https://esm.sh/tdigest@0.1.2?target=denonext": "53806e3c28da0241f722b9d6d921cb4f4fcb628bd5af56d0315acf4865675c6c", + "https://esm.sh/tldts-core@6.1.86/denonext/tldts-core.mjs": "00bc0e31dea41c7122d8f807d5e22ee34069bf2766c8b9414bf7f06b4fc4fd4f", + "https://esm.sh/tldts-core@6.1.86?target=denonext": "3e313ea81d61bcb8e8497f1a16d4a7b50c61ac10ae9419aa3d51e36015f67aa4", + "https://esm.sh/tldts@6.1.86/denonext/tldts.mjs": "62a91e7036004a7b6210619578c1476ac2f752f218899d84aedff757649443c5", + "https://esm.sh/tldts@6.1.86?target=denonext": "e770d646c03003b4042fa1ff58f09057d1f53914e56dfe4644ce1f815ff0c137", + "https://esm.sh/tough-cookie@5.1.2/denonext/tough-cookie.mjs": "920596fcdf61acb18fe894d3d55b580765f7abdc6bce39a13af259653af8f734", + "https://esm.sh/tough-cookie@5.1.2?target=denonext": "0abb332336e4f25ce01badb0fad1522ffa0c460f2a1cbfaeb89e078b864fc2c8", + "https://esm.sh/tr46@5.1.1/denonext/tr46.mjs": "cdad0ad3c6087de1297ea9a9fbea3eb43fdfe19500cfcd87b4b161d9bcb27648", + "https://esm.sh/tr46@5.1.1?target=denonext": "d2450e422fab3f7456bd2e43be411bd44a968603f49d87c0fc343179bd0bf265", + "https://esm.sh/v135/@opentelemetry/api@1.9.0/denonext/api.mjs": "2c4ccab6379206aea31025d84aa2003b0d50e03257cd27d798b3470e5a06673c", + "https://esm.sh/v135/bintrees@1.0.2/denonext/bintrees.mjs": "3e4d167e844bae4a53837b85ad9c7a034cc1978e4f6f29e49cf3ce584fd95f01", + "https://esm.sh/v135/prom-client@15.1.3/denonext/prom-client.mjs": "ce8d0d95a6a654fd68398f6ef31f566e8f33dd9ee301005abd5e9479cdf35bce", + "https://esm.sh/v135/tdigest@0.1.2/denonext/tdigest.mjs": "af68659baf7ee778ce9fabc0fe45b3bba8ac45adae75e7676a56be43ff410bf7", + "https://esm.sh/w3c-xmlserializer@5.0.0/denonext/w3c-xmlserializer.mjs": "122e3e552454f1752e443fdf80f4c8ca36109208fb05943d39fc64a5e85499de", + "https://esm.sh/w3c-xmlserializer@5.0.0?target=denonext": "078e017f6cb607175c9246ebd38041912488fc813100625bf7502cf47dfe7c7f", + "https://esm.sh/webidl-conversions@7.0.0/denonext/webidl-conversions.mjs": "ba51539722c56b6c41341c14ba249f7bf8acd7f132ae1b8d9c0a81cc17360abc", + "https://esm.sh/webidl-conversions@7.0.0?target=denonext": "d4b1e1e5302669677ad8274d9c0f0ba59c50e9ab8c3215a3774f3aa206963bd5", + "https://esm.sh/whatwg-encoding@3.1.1/denonext/whatwg-encoding.mjs": "771c409a89b301144c200fe8520bce25a72b9108068a5a57e21b76670549bccb", + "https://esm.sh/whatwg-encoding@3.1.1?target=denonext": "0e24061fe35afd2b2c93625e4288c643837ae8d491abf572a067b55ecce45c1f", + "https://esm.sh/whatwg-mimetype@4.0.0/denonext/whatwg-mimetype.mjs": "077315581483d77f815ed7e227ea4bf7ffdda61b211b471053c4bca423a7fd54", + "https://esm.sh/whatwg-mimetype@4.0.0?target=denonext": "a0a0b4ee5d006be602d7e01eee5347c950d89662d502d72db5ba6daa60e92a3d", + "https://esm.sh/whatwg-url@14.2.0/denonext/webidl2js-wrapper.mjs": "ce001f19a2ac2752221bc48c77cc3be1f05d4e2044ae1c51b6d1020c9ad5bea3", + "https://esm.sh/whatwg-url@14.2.0/denonext/whatwg-url.mjs": "6842833804c93154dd5fe40fe8e0694b5d4bb594113f24c41a4017263bf60cbc", + "https://esm.sh/whatwg-url@14.2.0/webidl2js-wrapper?target=denonext": "a28f35282ba1cda458d386f8b85190f54c122183cb0b0735725d6e88b85a4864", + "https://esm.sh/whatwg-url@14.2.0?target=denonext": "600345dce237deb981499a6a34248f4db75de14fb73111b52924beff1bb9bd54", + "https://esm.sh/ws@8.18.3/X-ZWJ1ZmZlcnV0aWwsdXRmLTgtdmFsaWRhdGU/denonext/ws.mjs": "0db28a8c1ab57c2360e72e3185d11ba884fc889837f30dbd7ad0a9252f9dcc56", + "https://esm.sh/ws@8.18.3?external=bufferutil,utf-8-validate&target=denonext": "72053510522a7fb83a263e893e30d7e68955bd51f5cf304d546cc656a15b714a", + "https://esm.sh/xml-name-validator@5.0.0/denonext/xml-name-validator.mjs": "d6efa1d81da5e2e5dc725b5c6a7537e4d3729f4b0ba1364c8151be74d7dda168", + "https://esm.sh/xml-name-validator@5.0.0?target=denonext": "5387f3c1042cdac99adc6c11b669da12fd30838e49ec27d63110bf8e370bdfce", + "https://esm.sh/xmlchars@2.2.0/denonext/xml/1.0/ed5.mjs": "7e95e56afc46284fd6e3e43ec430f636d3db5a0840829b188627c160f36b1c7e", + "https://esm.sh/xmlchars@2.2.0/denonext/xml/1.1/ed2.mjs": "bde6c65b73865ce561376fc19316dc52bdf8b0b998fb3c3bdab481d2a4d9de0a", + "https://esm.sh/xmlchars@2.2.0/denonext/xmlns/1.0/ed3.mjs": "ab12a41555aa2468e6f8f42649ae209163e9ab835ca05817cb4f95bac8699ce9", + "https://esm.sh/xmlchars@2.2.0/xml/1.0/ed5?target=denonext": "b1ca831e2cf0954307ebded209a1657cb27223f2a1fd0b347ffcb4e566e997ad", + "https://esm.sh/xmlchars@2.2.0/xml/1.1/ed2?target=denonext": "005781985db506e433617587305e4176c3f3333ac4bb8b0db2a953f0e14bf71c", + "https://esm.sh/xmlchars@2.2.0/xmlns/1.0/ed3?target=denonext": "be6502edd92b286ab2a25e3f5db439700ff79daa964aa3d6e4a0c109af35dd36" + }, + "workspace": { + "dependencies": [ + "jsr:@hono/hono@4.7.4", + "jsr:@luanrt/googlevideo@2.0.0", + "jsr:@std/async@1.0.11", + "jsr:@std/cli@^1.0.17", + "jsr:@std/encoding@1.0.7", + "jsr:@std/fs@1.0.14", + "jsr:@std/path@1.0.8", + "jsr:@std/toml@1.0.2", + "npm:jsdom@26.1.0", + "npm:meriyah@6.1.4" + ] + } +} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..0f406ecd04500d160661dcd5c9d2e9e32eb91c8b --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -ex + +echo "[ENTRYPOINT] Starting WireGuard HTTP Proxy" + +# Check if WireGuard config should be generated +if [[ -z "${WIREGUARD_INTERFACE_PRIVATE_KEY}" ]]; then + echo "[ENTRYPOINT] Generating Cloudflare Warp configuration..." + + # Run warp binary to generate config + WARP_OUTPUT=$(warp) + + # Parse the warp output to extract config values + export WIREGUARD_INTERFACE_PRIVATE_KEY=$(echo "$WARP_OUTPUT" | grep "PrivateKey" | awk '{print $3}') + export WIREGUARD_INTERFACE_ADDRESS=$(echo "$WARP_OUTPUT" | grep "Address" | awk '{print $3}') + export WIREGUARD_PEER_PUBLIC_KEY=$(echo "$WARP_OUTPUT" | grep "PublicKey" | awk '{print $3}') + export WIREGUARD_PEER_ENDPOINT=$(echo "$WARP_OUTPUT" | grep "Endpoint" | awk '{print $3}') + export WIREGUARD_INTERFACE_DNS="${WIREGUARD_INTERFACE_DNS:-1.1.1.1}" + + echo "[ENTRYPOINT] Warp config generated successfully" +else + echo "[ENTRYPOINT] Using provided WireGuard configuration" +fi + +# Start the proxy server in the background +echo "[ENTRYPOINT] Starting HTTP proxy server (internal)..." +server & +SERVER_PID=$! + +# Wait for proxy to start +echo "[ENTRYPOINT] Waiting for proxy to be ready on port 8080..." +while ! curl -v http://127.0.0.1:8080/ 2>&1 | grep "Proxy Running"; do + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "[FATAL] Server process exited unexpectedly!" + wait $SERVER_PID + exit 1 + fi + echo "[ENTRYPOINT] Proxy not ready yet... retrying in 1s" + sleep 1 +done +echo "[ENTRYPOINT] Proxy is ready!" + +# Start Proxy Check +echo "[ENTRYPOINT] Checking proxy connection..." +curl -s -x http://127.0.0.1:8080 https://cloudflare.com/cdn-cgi/trace +echo "" +echo "[ENTRYPOINT] Proxy check complete." + +# Start Streamion +echo "[ENTRYPOINT] Starting Streamion..." +exec deno task dev diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..4cc55e0ca42ee9e5c593177ee70b1f34f3416419 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module streamion + +go 1.20 + +require ( + github.com/caarlos0/env v3.5.0+incompatible + golang.org/x/crypto v0.47.0 + golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb +) + +exclude golang.zx2c4.com/wireguard/tun/netstack v0.0.0-20220703234212-c31a7b1ab478 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/grafana_dashboard.json b/grafana_dashboard.json new file mode 100644 index 0000000000000000000000000000000000000000..fb9649faf15c9aaaaa441e6d0eec84059260e2fa --- /dev/null +++ b/grafana_dashboard.json @@ -0,0 +1,888 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS-REIMU", + "label": "prometheus-reimu", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "12.3.1" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 24, + "panels": [], + "title": "Videoplayback requests", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 1 + }, + "id": 26, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "editorMode": "builder", + "expr": "rate(invidious_companion_videoplayback_forbidden_total[$__rate_interval])", + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + } + ], + "title": "Forbidden Videoplayback (403 from Youtube servers)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 11 + }, + "id": 18, + "panels": [], + "title": "Requests", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(invidious_companion_innertube_successful_request_total[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Successful Requests Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "editorMode": "code", + "expr": "rate(invidious_companion_innertube_failed_request_total[$__rate_interval])", + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + } + ], + "title": "Failed Requests Rate", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 19, + "panels": [], + "title": "Status", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "editorMode": "code", + "expr": "rate(invidious_companion_innertube_error_status_loginRequired_total[$__rate_interval])", + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + } + ], + "title": "\"LOGIN_REQUIRED\" Rate", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 33 + }, + "id": 8, + "panels": [], + "title": "Reasons", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "editorMode": "code", + "expr": "rate(invidious_companion_innertube_error_reason_SignIn_total[$__rate_interval])", + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + } + ], + "title": "\"Sign in to confirm you’re not a bot.\" Rate", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 44 + }, + "id": 9, + "panels": [], + "title": "Subreasons", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 45 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "editorMode": "code", + "expr": "rate(invidious_companion_innertube_error_subreason_ProtectCommunity_total[$__rate_interval])", + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + } + ], + "title": "\"This helps protect our community.\" Rate", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 55 + }, + "id": 20, + "panels": [], + "title": "Jobs", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 56 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS-REIMU}" + }, + "editorMode": "code", + "expr": "invidious_companion_potoken_generation_failure_total", + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + } + ], + "title": "poToken Generation Failure Rate", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "10s", + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "current": {}, + "label": "datasource", + "name": "Datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${Datasource}" + }, + "definition": "label_values(invidious_companion_innertube_successful_request_total,job)", + "description": "", + "label": "Job", + "name": "job", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(invidious_companion_innertube_successful_request_total,job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "", + "type": "query" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Invidious Companion2", + "uid": "1-0-1", + "version": 9, + "weekStart": "", + "id": null +} \ No newline at end of file diff --git a/invidious-companion.service b/invidious-companion.service new file mode 100644 index 0000000000000000000000000000000000000000..4108b9d8bb1230836dd0380613558d48db8f7678 --- /dev/null +++ b/invidious-companion.service @@ -0,0 +1,42 @@ +[Unit] +Description=invidious-companion (companion for Invidious which handles all the video stream retrieval from YouTube servers) +After=syslog.target +After=network.target + +[Service] +RestartSec=2s +Type=simple + +User=invidious +Group=invidious + +# Security hardening - balanced approach for Deno applications +NoNewPrivileges=true +PrivateDevices=true +PrivateTmp=true +ProtectControlGroups=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectSystem=strict +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=true +RestrictSUIDSGID=true +RestrictRealtime=true + +# Filesystem access +BindReadOnlyPaths=/home/invidious/invidious-companion +BindPaths=/home/invidious/tmp +BindPaths=/var/tmp/youtubei.js + +WorkingDirectory=/home/invidious/invidious-companion +ExecStart=/home/invidious/invidious-companion/invidious_companion + +Environment=SERVER_SECRET_KEY=CHANGE_ME +Environment=CACHE_DIRECTORY=/var/tmp/youtubei.js + +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/server.go b/server.go new file mode 100644 index 0000000000000000000000000000000000000000..aab7eb2bbebdad7785848c7b7c83e27f6eaafd06 --- /dev/null +++ b/server.go @@ -0,0 +1,311 @@ +package main + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "log" + "net" + "net/http" + "net/netip" + "strings" + "time" + + "github.com/caarlos0/env" + "golang.zx2c4.com/wireguard/conn" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +type params struct { + User string `env:"PROXY_USER" envDefault:""` + Password string `env:"PROXY_PASS" envDefault:""` + Port string `env:"PORT" envDefault:"8080"` + // WireGuard Params + WgPrivateKey string `env:"WIREGUARD_INTERFACE_PRIVATE_KEY"` + WgAddress string `env:"WIREGUARD_INTERFACE_ADDRESS"` // e.g., 10.0.0.2/32 + WgPeerPublicKey string `env:"WIREGUARD_PEER_PUBLIC_KEY"` + WgPeerEndpoint string `env:"WIREGUARD_PEER_ENDPOINT"` // e.g., 1.2.3.4:51820 + WgDNS string `env:"WIREGUARD_INTERFACE_DNS" envDefault:"1.1.1.1"` +} + +var tnet *netstack.Net + +func handleTunneling(w http.ResponseWriter, r *http.Request) { + dest := r.URL.Host + if dest == "" { + dest = r.Host + } + + // Hijack the connection first to allow custom response writing + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "Hijacking not supported", http.StatusInternalServerError) + return + } + client_conn, _, err := hijacker.Hijack() + if err != nil { + // If hijack fails, we can't do much as headers might be sent or connection broken + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + + var dest_conn net.Conn + + if tnet == nil { + dest_conn, err = net.DialTimeout("tcp", dest, 10*time.Second) + } else { + // Use tnet.Dial to connect through WireGuard + dest_conn, err = tnet.Dial("tcp", dest) + } + + if err != nil { + log.Printf("[ERROR] TUNNEL Dial failed to %s: %v", dest, err) + // Send a 503 to the client through the hijacked connection and close + // Simple HTTP response since we hijacked + client_conn.Write([]byte("HTTP/1.1 503 Service Unavailable\r\n\r\n")) + client_conn.Close() + return + } + + // Write 200 Connection Established to the client + // This signals the client that the tunnel is ready + _, err = client_conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + if err != nil { + log.Printf("[ERROR] TUNNEL Write 200 failed: %v", err) + dest_conn.Close() + client_conn.Close() + return + } + + go transfer(dest_conn, client_conn) + go transfer(client_conn, dest_conn) +} + +func transfer(destination io.WriteCloser, source io.ReadCloser) { + defer destination.Close() + defer source.Close() + io.Copy(destination, source) +} + +func handleHTTP(w http.ResponseWriter, r *http.Request) { + transport := http.DefaultTransport.(*http.Transport).Clone() + + if tnet != nil { + // Use tnet.DialContext for HTTP requests + transport.DialContext = tnet.DialContext + } + + resp, err := transport.RoundTrip(r) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer resp.Body.Close() + copyHeader(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + +func handleDebug(w http.ResponseWriter, r *http.Request) { + if tnet == nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Error: WireGuard not initialized (Direct Mode)")) + return + } + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: tnet.DialContext, + }, + Timeout: 10 * time.Second, + } + + resp, err := client.Get("http://ifconfig.me") + if err != nil { + log.Printf("[DEBUG] VPN Test Failed: %v", err) + http.Error(w, fmt.Sprintf("VPN Connection Failed: %v", err), http.StatusServiceUnavailable) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to read response: %v", err), http.StatusInternalServerError) + return + } + + log.Printf("[DEBUG] VPN Test Success. IP: %s", string(body)) + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf("VPN Connected! Public IP: %s", string(body)))) +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} + +func startWireGuard(cfg params) error { + if cfg.WgPrivateKey == "" || cfg.WgPeerEndpoint == "" { + log.Println("[INFO] WireGuard config missing, running in DIRECT mode (no VPN)") + return nil + } + + log.Println("[INFO] Initializing Userspace WireGuard...") + + localIPs := []netip.Addr{} + if cfg.WgAddress != "" { + // Handle CIDR notation if present (e.g., 10.0.0.2/32) + addrStr := strings.Split(cfg.WgAddress, "/")[0] + addr, err := netip.ParseAddr(addrStr) + if err == nil { + localIPs = append(localIPs, addr) + log.Printf("[INFO] Local VPN IP: %s", addr) + } else { + log.Printf("[WARN] Failed to parse local IP: %v", err) + } + } + + dnsIP, err := netip.ParseAddr(cfg.WgDNS) + if err != nil { + log.Printf("[WARN] Failed to parse DNS IP, using default: %v", err) + dnsIP, _ = netip.ParseAddr("1.1.1.1") + } + log.Printf("[INFO] DNS Server: %s", dnsIP) + + log.Println("[INFO] Creating virtual network interface...") + tunDev, tnetInstance, err := netstack.CreateNetTUN( + localIPs, + []netip.Addr{dnsIP}, + 1420, + ) + if err != nil { + return fmt.Errorf("failed to create TUN: %w", err) + } + tnet = tnetInstance + log.Println("[INFO] Virtual TUN device created successfully") + + log.Println("[INFO] Initializing WireGuard device...") + dev := device.NewDevice(tunDev, conn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, "")) + + log.Printf("[INFO] Configuring peer endpoint: %s", cfg.WgPeerEndpoint) + + // Convert keys from Base64 to Hex + // wireguard-go expects hex keys in UAPI, but inputs are usually Base64 + privateKeyHex, err := base64ToHex(cfg.WgPrivateKey) + if err != nil { + return fmt.Errorf("invalid private key (base64 decode failed): %w", err) + } + + publicKeyHex, err := base64ToHex(cfg.WgPeerPublicKey) + if err != nil { + return fmt.Errorf("invalid peer public key (base64 decode failed): %w", err) + } + + uapi := fmt.Sprintf(`private_key=%s +public_key=%s +endpoint=%s +allowed_ip=0.0.0.0/0 +`, privateKeyHex, publicKeyHex, cfg.WgPeerEndpoint) + + if err := dev.IpcSet(uapi); err != nil { + return fmt.Errorf("failed to configure device: %w", err) + } + log.Println("[INFO] WireGuard peer configured") + + if err := dev.Up(); err != nil { + return fmt.Errorf("failed to bring up device: %w", err) + } + + log.Println("[SUCCESS] WireGuard interface is UP - All traffic will route through VPN") + return nil +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.Println("[STARTUP] Initializing HTTP Proxy with Userspace WireGuard") + + cfg := params{} + if err := env.Parse(&cfg); err != nil { + log.Printf("[WARN] Config parse warning: %+v\n", err) + } + + log.Printf("[CONFIG] Proxy Port: %s", cfg.Port) + if cfg.User != "" { + log.Printf("[CONFIG] Authentication: Enabled (user: %s)", cfg.User) + } else { + log.Println("[CONFIG] Authentication: Disabled") + } + + if err := startWireGuard(cfg); err != nil { + log.Fatalf("[FATAL] Failed to start WireGuard: %v", err) + } + + log.Printf("[STARTUP] Starting HTTP proxy server on port %s\n", cfg.Port) + + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if cfg.User != "" && cfg.Password != "" { + user, pass, ok := r.BasicAuth() + if !ok || user != cfg.User || pass != cfg.Password { + log.Printf("[AUTH] Unauthorized access attempt from %s", r.RemoteAddr) + w.Header().Set("Proxy-Authenticate", `Basic realm="Proxy"`) + http.Error(w, "Unauthorized", http.StatusProxyAuthRequired) + return + } + } + + // Handle CONNECT (HTTPS tunnel) + if r.Method == http.MethodConnect { + log.Printf("[CONNECT] %s -> %s", r.RemoteAddr, r.Host) + handleTunneling(w, r) + return + } + + // Direct requests to the proxy server (Health check & Debug) + // We check r.URL.Host == "" which means it's a direct request, not a proxy request + if r.URL.Host == "" { + if r.URL.Path == "/" { + log.Printf("[HEALTH] Health check from %s", r.RemoteAddr) + w.WriteHeader(http.StatusOK) + if tnet != nil { + w.Write([]byte("Proxy Running via Userspace WireGuard")) + } else { + w.Write([]byte("Proxy Running in Direct Mode (No VPN)")) + } + return + } + + if r.URL.Path == "/debug" { + log.Printf("[DEBUG] Debug check from %s", r.RemoteAddr) + handleDebug(w, r) + return + } + } + + // Proxy HTTP requests + log.Printf("[HTTP] %s %s -> %s", r.Method, r.RemoteAddr, r.URL.String()) + handleHTTP(w, r) + }), + } + + log.Println("[READY] Proxy server is ready to accept connections") + if err := server.ListenAndServe(); err != nil { + log.Fatalf("[FATAL] Server error: %v", err) + } +} + +func base64ToHex(b64 string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", err + } + return hex.EncodeToString(decoded), nil +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..0d8825abb22885494fb1006384f2270827accf73 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..973d1a14f79f189ae4b488b98b77289c70b4f9af --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,11 @@ +// Set to `undefined` if it's no longer in use! +// +// Old Players IDs are usually available for a few more days after Youtube +// rolls out a new Player. This is helpful when Youtube.JS is not able to +// extract the signature decipher algorithm and we need to wait for a fix +// in Youtube.JS. +export const PLAYER_ID = undefined; + +// Error message shown when tokenMinter is not yet ready +export const TOKEN_MINTER_NOT_READY_MESSAGE = + "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"; diff --git a/src/lib/extra/emptyExport.ts b/src/lib/extra/emptyExport.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff8b4c56321a3362fc00224b01800f62466f9a1f --- /dev/null +++ b/src/lib/extra/emptyExport.ts @@ -0,0 +1 @@ +export default {}; diff --git a/src/lib/helpers/config.ts b/src/lib/helpers/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..5eb2875af1fccbdf20468c83a4f87eba7d5e48a4 --- /dev/null +++ b/src/lib/helpers/config.ts @@ -0,0 +1,180 @@ +import { z, ZodError } from "zod"; +import { parse } from "@std/toml"; + +export const ConfigSchema = z.object({ + server: z.object({ + port: z.number().default(Number(Deno.env.get("PORT")) || 8282), + host: z.string().default(Deno.env.get("HOST") || "127.0.0.1"), + use_unix_socket: z.boolean().default( + Deno.env.get("SERVER_USE_UNIX_SOCKET") === "true" || false, + ), + unix_socket_path: z.string().default( + Deno.env.get("SERVER_UNIX_SOCKET_PATH") || + "/tmp/invidious-companion.sock", + ), + base_path: z.string() + .default(Deno.env.get("SERVER_BASE_PATH") || "/companion") + .refine( + (path) => path.startsWith("/"), + { + message: + "SERVER_BASE_PATH must start with a forward slash (/). Example: '/companion'", + }, + ) + .refine( + (path) => !path.endsWith("/") || path === "/", + { + message: + "SERVER_BASE_PATH must not end with a forward slash (/) unless it's the root path. Example: '/companion' not '/companion/'", + }, + ) + .refine( + (path) => !path.includes("//"), + { + message: + "SERVER_BASE_PATH must not contain double slashes (//). Example: '/companion' not '//companion' or '/comp//anion'", + }, + ), + secret_key: z.preprocess( + (val) => + val === undefined + ? Deno.env.get("SERVER_SECRET_KEY") || "" + : val, + z.string().min(1), + ).default(undefined), + verify_requests: z.boolean().default( + Deno.env.get("SERVER_VERIFY_REQUESTS") === "true" || false, + ), + encrypt_query_params: z.boolean().default( + Deno.env.get("SERVER_ENCRYPT_QUERY_PARAMS") === "true" || false, + ), + enable_metrics: z.boolean().default( + Deno.env.get("SERVER_ENABLE_METRICS") === "true" || false, + ), + }).strict().default({}), + cache: z.object({ + enabled: z.boolean().default( + Deno.env.get("CACHE_ENABLED") === "false" ? false : true, + ), + directory: z.string().default( + Deno.env.get("CACHE_DIRECTORY") || "/var/tmp", + ), + }).strict().default({}), + networking: z.object({ + proxy: z.string().nullable().default(Deno.env.get("PROXY") || null), + auto_proxy: z.boolean().default( + Deno.env.get("NETWORKING_AUTO_PROXY") === "true" || false, + ), + vpn_source: z.number().default( + Number(Deno.env.get("NETWORKING_VPN_SOURCE")) || 1, + ), + ipv6_block: z.string().nullable().default( + Deno.env.get("NETWORKING_IPV6_BLOCK") || null, + ), + fetch: z.object({ + timeout_ms: z.number().default( + Number(Deno.env.get("NETWORKING_FETCH_TIMEOUT_MS")) || 30_000, + ), + retry: z.object({ + enabled: z.boolean().default( + Deno.env.get("NETWORKING_FETCH_RETRY_ENABLED") === "true" || + false, + ), + times: z.number().optional().default( + Number(Deno.env.get("NETWORKING_FETCH_RETRY_TIMES")) || 1, + ), + initial_debounce: z.number().optional().default( + Number( + Deno.env.get("NETWORKING_FETCH_RETRY_INITIAL_DEBOUNCE"), + ) || 0, + ), + debounce_multiplier: z.number().optional().default( + Number( + Deno.env.get( + "NETWORKING_FETCH_RETRY_DEBOUNCE_MULTIPLIER", + ), + ) || 0, + ), + }).strict().default({}), + }).strict().default({}), + videoplayback: z.object({ + ump: z.boolean().default( + Deno.env.get("NETWORKING_VIDEOPLAYBACK_UMP") === "true" || + false, + ), + video_fetch_chunk_size_mb: z.number().default( + Number( + Deno.env.get( + "NETWORKING_VIDEOPLAYBACK_VIDEO_FETCH_CHUNK_SIZE_MB", + ), + ) || 5, + ), + }).strict().default({}), + }).strict().default({}), + jobs: z.object({ + youtube_session: z.object({ + po_token_enabled: z.boolean().default( + Deno.env.get("JOBS_YOUTUBE_SESSION_PO_TOKEN_ENABLED") === + "false" + ? false + : true, + ), + frequency: z.string().default( + Deno.env.get("JOBS_YOUTUBE_SESSION_FREQUENCY") || "*/5 * * * *", + ), + }).strict().default({}), + }).strict().default({}), + youtube_session: z.object({ + oauth_enabled: z.boolean().default( + Deno.env.get("YOUTUBE_SESSION_OAUTH_ENABLED") === "true" || false, + ), + cookies: z.string().default( + Deno.env.get("YOUTUBE_SESSION_COOKIES") || "", + ), + }).strict().default({}), +}).strict(); + +export type Config = z.infer; + +export async function parseConfig() { + const configFileName = Deno.env.get("CONFIG_FILE") || "config/config.toml"; + const configFileContents = await Deno.readTextFile(configFileName).catch( + () => null, + ); + if (configFileContents) { + console.log("[INFO] Using custom settings local file"); + } else { + console.log( + "[INFO] No local config file found, using default config", + ); + } + + try { + const rawConfig = configFileContents ? parse(configFileContents) : {}; + const validatedConfig = ConfigSchema.parse(rawConfig); + + console.log("Loaded Configuration", validatedConfig); + + return validatedConfig; + } catch (err) { + let errorMessage = + "There is an error in your configuration, check your environment variables"; + if (configFileContents) { + errorMessage += + ` or in your configuration file located at ${configFileName}`; + } + console.log(errorMessage); + if (err instanceof ZodError) { + console.log(err.issues); + // Include detailed error information in the thrown error for testing + const detailedMessage = err.issues.map((issue) => + `${issue.path.join(".")}: ${issue.message}` + ).join("; "); + throw new Error( + `Failed to parse configuration file: ${detailedMessage}`, + ); + } + // rethrow error if not Zod + throw err; + } +} diff --git a/src/lib/helpers/encodeRFC5987ValueChars.ts b/src/lib/helpers/encodeRFC5987ValueChars.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cfed8cd1bebb9596877b1696738dc8010501dab --- /dev/null +++ b/src/lib/helpers/encodeRFC5987ValueChars.ts @@ -0,0 +1,21 @@ +// Taken from +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_content-disposition_and_link_headers +export function encodeRFC5987ValueChars(str: string) { + return ( + encodeURIComponent(str) + // The following creates the sequences %27 %28 %29 %2A (Note that + // the valid encoding of "*" is %2A, which necessitates calling + // toUpperCase() to properly encode). Although RFC3986 reserves "!", + // RFC5987 does not, so we do not need to escape it. + .replace( + /['()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ) + // The following are not required for percent-encoding per RFC5987, + // so we can allow for a little better readability over the wire: |`^ + .replace( + /%(7C|60|5E)/g, + (_str, hex) => String.fromCharCode(parseInt(hex, 16)), + ) + ); +} diff --git a/src/lib/helpers/encryptQuery.ts b/src/lib/helpers/encryptQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf253adad278bc0c2a735e8287a160bbd909128b --- /dev/null +++ b/src/lib/helpers/encryptQuery.ts @@ -0,0 +1,56 @@ +import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; +import { Aes } from "crypto/aes.ts"; +import { Ecb, Padding } from "crypto/block-modes.ts"; +import type { Config } from "./config.ts"; + +export const encryptQuery = ( + queryParams: string, + config: Config, +): string => { + try { + const cipher = new Ecb( + Aes, + new TextEncoder().encode( + config.server.secret_key, + ), + Padding.PKCS7, + ); + + const encodedData = new TextEncoder().encode( + queryParams, + ); + + const encryptedData = cipher.encrypt(encodedData); + + return encodeBase64(encryptedData); + } catch (err) { + console.error("[ERROR] Failed to encrypt query parameters:", err); + return ""; + } +}; + +export const decryptQuery = ( + queryParams: string, + config: Config, +): string => { + try { + const decipher = new Ecb( + Aes, + new TextEncoder().encode(config.server.secret_key), + Padding.PKCS7, + ); + + const decryptedData = new TextDecoder().decode( + decipher.decrypt( + decodeBase64( + queryParams, + ), + ), + ); + + return decryptedData; + } catch (err) { + console.error("[ERROR] Failed to decrypt query parameters:", err); + return ""; + } +}; diff --git a/src/lib/helpers/getFetchClient.ts b/src/lib/helpers/getFetchClient.ts new file mode 100644 index 0000000000000000000000000000000000000000..acb41457c8ebfc69702b1340886d69afcceb6052 --- /dev/null +++ b/src/lib/helpers/getFetchClient.ts @@ -0,0 +1,107 @@ +import { retry, type RetryOptions } from "@std/async"; +import type { Config } from "./config.ts"; +import { generateRandomIPv6 } from "./ipv6Rotation.ts"; +import { getCurrentProxy } from "./proxyManager.ts"; + +type FetchInputParameter = Parameters[0]; +type FetchInitParameterWithClient = + | RequestInit + | RequestInit & { client: Deno.HttpClient }; +type FetchReturn = ReturnType; + +export const getFetchClient = (config: Config): { + ( + input: FetchInputParameter, + init?: FetchInitParameterWithClient, + ): FetchReturn; +} => { + return async ( + input: FetchInputParameter, + init?: RequestInit, + ) => { + // Use auto-fetched proxy if enabled, otherwise use configured proxy + const proxyAddress = config.networking.auto_proxy + ? getCurrentProxy() + : config.networking.proxy; + const ipv6Block = config.networking.ipv6_block; + + // If proxy or IPv6 rotation is configured, create a custom HTTP client + if (proxyAddress || ipv6Block) { + const clientOptions: Deno.CreateHttpClientOptions = {}; + + if (proxyAddress) { + try { + const proxyUrl = new URL(proxyAddress); + // Extract credentials if present + if (proxyUrl.username && proxyUrl.password) { + clientOptions.proxy = { + url: `${proxyUrl.protocol}//${proxyUrl.host}`, + basicAuth: { + username: decodeURIComponent(proxyUrl.username), + password: decodeURIComponent(proxyUrl.password), + }, + }; + } else { + clientOptions.proxy = { + url: proxyAddress, + }; + } + } catch { + clientOptions.proxy = { + url: proxyAddress, + }; + } + } + + if (ipv6Block) { + clientOptions.localAddress = generateRandomIPv6(ipv6Block); + } + + const client = Deno.createHttpClient(clientOptions); + const fetchRes = await fetchShim(config, input, { + client, + headers: init?.headers, + method: init?.method, + body: init?.body, + }); + client.close(); // Important: close client to avoid leaking resources + return new Response(fetchRes.body, { + status: fetchRes.status, + headers: fetchRes.headers, + }); + } + + return fetchShim(config, input, init); + }; +}; + +function fetchShim( + config: Config, + input: FetchInputParameter, + init?: FetchInitParameterWithClient, +): FetchReturn { + const fetchTimeout = config.networking.fetch?.timeout_ms; + const fetchRetry = config.networking.fetch?.retry?.enabled; + const fetchMaxAttempts = config.networking.fetch?.retry?.times; + const fetchInitialDebounce = config.networking.fetch?.retry + ?.initial_debounce; + const fetchDebounceMultiplier = config.networking.fetch?.retry + ?.debounce_multiplier; + const retryOptions: RetryOptions = { + maxAttempts: fetchMaxAttempts, + minTimeout: fetchInitialDebounce, + multiplier: fetchDebounceMultiplier, + jitter: 0, + }; + + const callFetch = () => + fetch(input, { + // only set the AbortSignal if the timeout is supplied in the config + signal: fetchTimeout + ? AbortSignal.timeout(Number(fetchTimeout)) + : null, + ...(init || {}), + }); + // if retry enabled, call retry with the fetch shim, otherwise pass the fetch shim back directly + return fetchRetry ? retry(callFetch, retryOptions) : callFetch(); +} diff --git a/src/lib/helpers/ipv6Rotation.ts b/src/lib/helpers/ipv6Rotation.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8fba3bb7ede8dd2958401159f5806b88580bd9b --- /dev/null +++ b/src/lib/helpers/ipv6Rotation.ts @@ -0,0 +1,109 @@ +/** + * IPv6 Rotation Utility + * + * This module provides IPv6 address rotation functionality to help avoid + * "Please login" errors from YouTube by sending each request from a unique + * IPv6 address within a configured IPv6 block. + * + * Setup instructions: https://gist.github.com/unixfox/2a9dbcb23d8f69c4582f7c85a849d5cc#linux-setup + */ + +/** + * Generate a random IPv6 address within the specified IPv6 block + * @param ipv6Block - The IPv6 block in CIDR notation (e.g., "2001:db8::/32") + * @returns A random IPv6 address within the specified IPv6 block + */ +export function generateRandomIPv6(ipv6Block: string): string { + // Parse the IPv6 block + const [baseAddress, blockSize] = ipv6Block.split("/"); + const blockBits = parseInt(blockSize, 10); + + if (isNaN(blockBits) || blockBits < 1 || blockBits > 128) { + throw new Error("Invalid IPv6 block size"); + } + + // Expand IPv6 address to full form + const expandedBase = expandIPv6(baseAddress); + + // Convert to binary representation + const baseBytes = ipv6ToBytes(expandedBase); + + // Randomize all bits after the block prefix + for (let i = Math.floor(blockBits / 8); i < 16; i++) { + const bitOffset = Math.max(0, blockBits - i * 8); + if (bitOffset === 0) { + // Fully random byte + baseBytes[i] = Math.floor(Math.random() * 256); + } else if (bitOffset < 8) { + // Partially random byte + const mask = (1 << (8 - bitOffset)) - 1; + const randomPart = Math.floor(Math.random() * (mask + 1)); + baseBytes[i] = (baseBytes[i] & ~mask) | randomPart; + } + // else: keep the original byte (bitOffset >= 8) + } + + // Convert back to IPv6 string + return bytesToIPv6(baseBytes); +} + +/** + * Expand an IPv6 address to its full form + */ +function expandIPv6(address: string): string { + // Handle :: expansion + if (address.includes("::")) { + const parts = address.split("::"); + const leftParts = parts[0] ? parts[0].split(":") : []; + const rightParts = parts[1] ? parts[1].split(":") : []; + const missingParts = 8 - leftParts.length - rightParts.length; + const middle = Array(missingParts).fill("0000"); + const allParts = [...leftParts, ...middle, ...rightParts]; + return allParts.map((p) => p.padStart(4, "0")).join(":"); + } + + // Pad each part to 4 characters + return address.split(":").map((p) => p.padStart(4, "0")).join(":"); +} + +/** + * Convert IPv6 address string to byte array + */ +function ipv6ToBytes(address: string): number[] { + const parts = address.split(":"); + const bytes: number[] = []; + + for (const part of parts) { + const value = parseInt(part, 16); + bytes.push((value >> 8) & 0xFF); + bytes.push(value & 0xFF); + } + + return bytes; +} + +/** + * Convert byte array back to IPv6 address string + */ +function bytesToIPv6(bytes: number[]): string { + const parts: string[] = []; + + for (let i = 0; i < 16; i += 2) { + const value = (bytes[i] << 8) | bytes[i + 1]; + parts.push(value.toString(16)); + } + + // Compress consecutive zeros + let ipv6 = parts.join(":"); + + // Find the longest sequence of consecutive zeros + const zeroSequences = ipv6.match(/(^|:)(0:)+/g); + if (zeroSequences) { + const longestZeroSeq = zeroSequences.reduce((a, b) => + a.length > b.length ? a : b + ); + ipv6 = ipv6.replace(longestZeroSeq, longestZeroSeq[0] + ":"); + } + + return ipv6; +} diff --git a/src/lib/helpers/jsInterpreter.ts b/src/lib/helpers/jsInterpreter.ts new file mode 100644 index 0000000000000000000000000000000000000000..524ac3c39e4182b2d56cb492ea30ee1019e3b198 --- /dev/null +++ b/src/lib/helpers/jsInterpreter.ts @@ -0,0 +1,22 @@ +import { Platform, Types } from "youtubei.js"; + +// https://ytjs.dev/guide/getting-started.html#providing-a-custom-javascript-interpreter +// deno-lint-ignore require-await +export const jsInterpreter = Platform.shim.eval = async ( + data: Types.BuildScriptResult, + env: Record, +) => { + const properties = []; + + if (env.n) { + properties.push(`n: exportedVars.nFunction("${env.n}")`); + } + + if (env.sig) { + properties.push(`sig: exportedVars.sigFunction("${env.sig}")`); + } + + const code = `${data.output}\nreturn { ${properties.join(", ")} }`; + + return new Function(code)(); +}; diff --git a/src/lib/helpers/metrics.ts b/src/lib/helpers/metrics.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca972dfdb8ac5339ff9e0442d191989bdadf915d --- /dev/null +++ b/src/lib/helpers/metrics.ts @@ -0,0 +1,126 @@ +import { IRawResponse } from "youtubei.js"; +import { Counter, Registry } from "prom-client"; + +export class Metrics { + private METRICS_PREFIX = "invidious_companion_"; + public register = new Registry(); + + public createCounter(name: string, help?: string): Counter { + return new Counter({ + name: `${this.METRICS_PREFIX}${name}`, + help: help || "No help has been provided for this metric", + registers: [this.register], + }); + } + + public potokenGenerationFailure = this.createCounter( + "potoken_generation_failure_total", + "Number of times that the PoToken generation job has failed for whatever reason", + ); + + private innertubeErrorStatusLoginRequired = this.createCounter( + "innertube_error_status_loginRequired_total", + 'Number of times that the status "LOGIN_REQUIRED" has been returned by Innertube API', + ); + + private innertubeErrorStatusUnknown = this.createCounter( + "innertube_error_status_unknown_total", + "Number of times that an unknown status has been returned by Innertube API", + ); + + private innertubeErrorReasonSignIn = this.createCounter( + "innertube_error_reason_SignIn_total", + 'Number of times that the message "Sign in to confirm you’re not a bot." has been returned by Innertube API', + ); + + private innertubeErrorSubreasonProtectCommunity = this.createCounter( + "innertube_error_subreason_ProtectCommunity_total", + 'Number of times that the message "This helps protect our community." has been returned by Innertube API', + ); + + private innertubeErrorReasonUnknown = this.createCounter( + "innertube_error_reason_unknown_total", + "Number of times that an unknown reason has been returned by the Innertube API", + ); + + private innertubeErrorSubreasonUnknown = this.createCounter( + "innertube_error_subreason_unknown_total", + "Number of times that an unknown subreason has been returned by the Innertube API", + ); + + public innertubeSuccessfulRequest = this.createCounter( + "innertube_successful_request_total", + "Number successful requests made to the Innertube API", + ); + + private innertubeFailedRequest = this.createCounter( + "innertube_failed_request_total", + "Number failed requests made to the Innertube API for whatever reason", + ); + + private checkStatus(videoData: IRawResponse) { + const status = videoData.playabilityStatus?.status; + + return { + unplayable: status === + "UNPLAYABLE", + contentCheckRequired: status === + "CONTENT_CHECK_REQUIRED", + loginRequired: status === "LOGIN_REQUIRED", + }; + } + + private checkReason(videoData: IRawResponse) { + const reason = videoData.playabilityStatus?.reason; + + return { + signInToConfirmAge: reason?.includes( + "Sign in to confirm your age", + ), + SignInToConfirmBot: reason?.includes( + "Sign in to confirm you’re not a bot", + ), + }; + } + + private checkSubreason(videoData: IRawResponse) { + const subReason = videoData.playabilityStatus?.errorScreen + ?.playerErrorMessageRenderer + ?.subreason?.runs?.[0]?.text; + + return { + thisHelpsProtectCommunity: subReason?.includes( + "This helps protect our community", + ), + }; + } + + public checkInnertubeResponse(videoData: IRawResponse) { + this.innertubeFailedRequest.inc(); + const status = this.checkStatus(videoData); + + if (status.contentCheckRequired || status.unplayable) return; + + if (status.loginRequired) { + this.innertubeErrorStatusLoginRequired.inc(); + const reason = this.checkReason(videoData); + + if (reason.signInToConfirmAge) return; + + if (reason.SignInToConfirmBot) { + this.innertubeErrorReasonSignIn.inc(); + const subReason = this.checkSubreason(videoData); + + if (subReason.thisHelpsProtectCommunity) { + this.innertubeErrorSubreasonProtectCommunity.inc(); + } else { + this.innertubeErrorSubreasonUnknown.inc(); + } + } else { + this.innertubeErrorReasonUnknown.inc(); + } + } else { + this.innertubeErrorStatusUnknown.inc(); + } + } +} diff --git a/src/lib/helpers/proxyManager.ts b/src/lib/helpers/proxyManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..553ffdbd06a387026307290ef637457f9ecd196d --- /dev/null +++ b/src/lib/helpers/proxyManager.ts @@ -0,0 +1,452 @@ +/** + * Automatic Proxy Manager + * Fetches free proxies from antpeak.com API and auto-rotates when they fail. + * Tests proxies against YouTube to ensure they work for the application's needs. + */ + +import { fetchUrbanProxy } from "./urbanProxy.ts"; + +// --- Configuration --- +const API_BASE = "https://antpeak.com"; +const USER_AGENT = + "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"; +const APP_VERSION = "3.7.8"; +const YOUTUBE_TEST_URL = "https://www.youtube.com/watch?v=bzbsJGMVHxQ"; +const CUSTOM_PROXY_URL = "https://ytdlp-api-gbdn.onrender.com/proxies"; + +// --- Types --- +interface DeviceInfo { + udid: string; + appVersion: string; + platform: string; + platformVersion: string; + timeZone: string; + deviceName: string; +} + +interface Location { + id: string; + region: string; + name: string; + countryCode: string; + type: number; + proxyType: number; +} + +interface ProxyServer { + addresses: string[]; + protocol: string; + port: number; + username?: string; + password?: string; +} + +// --- Singleton State --- +let currentProxyUrl: string | null = null; +let accessToken: string | null = null; +let freeLocations: Location[] = []; +let isInitialized = false; +let initializationPromise: Promise | null = null; +let vpnSource = 1; + +// --- Helpers --- + +async function fetchJson( + endpoint: string, + method: string, + body?: unknown, + token?: string, +): Promise { + const url = `${API_BASE}${endpoint}`; + const headers: Record = { + "User-Agent": USER_AGENT, + "Content-Type": "application/json", + "Accept": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`API Error ${response.status}: ${text}`); + } + + return await response.json(); +} + +async function testProxyAgainstYouTube(proxyUrl: string): Promise { + try { + const proxyUrlObj = new URL(proxyUrl); + const clientOptions: Deno.CreateHttpClientOptions = {}; + + if (proxyUrlObj.username && proxyUrlObj.password) { + clientOptions.proxy = { + url: `${proxyUrlObj.protocol}//${proxyUrlObj.host}`, + basicAuth: { + username: decodeURIComponent(proxyUrlObj.username), + password: decodeURIComponent(proxyUrlObj.password), + }, + }; + } else { + clientOptions.proxy = { + url: proxyUrl, + }; + } + + const client = Deno.createHttpClient(clientOptions); + + const response = await fetch(YOUTUBE_TEST_URL, { + client, + signal: AbortSignal.timeout(15000), // 15 second timeout for test + headers: { + "User-Agent": USER_AGENT, + }, + }); + + client.close(); + + // YouTube should return 200 or a redirect (3xx) + if (response.ok || (response.status >= 300 && response.status < 400)) { + return true; + } + return response.status; + } catch (err) { + // console.error("[ProxyManager] Proxy test failed:", err); // Verified by user request to just move to next + return false; + } +} + +async function registerDevice(): Promise { + const deviceInfo: DeviceInfo = { + udid: crypto.randomUUID(), + appVersion: APP_VERSION, + platform: "chrome", + platformVersion: USER_AGENT, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + deviceName: "Chrome 120.0.0.0", + }; + + const launchResponse = await fetchJson( + "/api/launch/", + "POST", + deviceInfo, + ) as { + success: boolean; + data?: { accessToken: string }; + }; + + if (!launchResponse.success || !launchResponse.data?.accessToken) { + throw new Error("Failed to register device with antpeak.com"); + } + + return launchResponse.data.accessToken; +} + +async function fetchLocations(token: string): Promise { + const locationsResponse = await fetchJson( + "/api/location/list/", + "POST", + undefined, + token, + ) as { + success: boolean; + data?: { locations: Location[] }; + }; + + if (!locationsResponse.success || !locationsResponse.data?.locations) { + throw new Error("Failed to fetch locations from antpeak.com"); + } + + // Filter for free locations (proxyType === 0) + return locationsResponse.data.locations.filter((l) => l.proxyType === 0); +} + +async function fetchProxyServer( + token: string, + location: Location, +): Promise { + const serverPayload = { + protocol: "https", + region: location.region, + type: location.type, + }; + + const serverResponse = await fetchJson( + "/api/server/list/", + "POST", + serverPayload, + token, + ) as { + success: boolean; + data?: ProxyServer[]; + }; + + if ( + !serverResponse.success || + !Array.isArray(serverResponse.data) || + serverResponse.data.length === 0 + ) { + return null; + } + + const server = serverResponse.data[0]; + const ip = server.addresses[0]; + const port = server.port; + const username = server.username || ""; + const password = server.password || ""; + + if (!username) { + return `https://${ip}:${port}`; + } else { + return `https://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${ip}:${port}`; + } +} + +// --- Public API --- + +/** + * Initialize the proxy manager. Fetches initial token and locations. + * Safe to call multiple times - will only initialize once. + */ +export async function initProxyManager(source: number = 1): Promise { + vpnSource = source; + if (isInitialized) return; + + if (initializationPromise) { + return initializationPromise; + } + + initializationPromise = (async () => { + console.log("[ProxyManager] Initializing automatic proxy manager..."); + + try { + if (vpnSource === 1) { + accessToken = await registerDevice(); + console.log("[ProxyManager] ✅ Registered with antpeak.com"); + + freeLocations = await fetchLocations(accessToken); + console.log( + `[ProxyManager] ✅ Found ${freeLocations.length} free locations`, + ); + + if (freeLocations.length === 0) { + throw new Error("No free proxy locations available"); + } + } else if (vpnSource === 2) { + console.log("[ProxyManager] Using Urban VPN source"); + } else if (vpnSource === 3) { + console.log("[ProxyManager] Using Custom Proxy API source"); + } + + // Fetch initial proxy + await rotateProxy(); + + isInitialized = true; + console.log("[ProxyManager] ✅ Initialization complete"); + } catch (err) { + console.error("[ProxyManager] ❌ Initialization failed:", err); + throw err; + } + })(); + + return initializationPromise; +} + +/** + * Get the current proxy URL. Returns null if no proxy is available. + */ +export function getCurrentProxy(): string | null { + return currentProxyUrl; +} + +/** + * Rotate to a new proxy. Tests against YouTube before accepting. + * Will try multiple locations until a working proxy is found. + */ +export async function rotateProxy(): Promise { + console.log(`[ProxyManager] Rotation requested. Source: ${vpnSource}`); + + if (vpnSource === 2) { + // Urban VPN Logic + try { + const urbanResult = await fetchUrbanProxy(); + if (urbanResult) { + console.log(`[ProxyManager] Testing Urban proxy against YouTube...`); + const result = await testProxyAgainstYouTube(urbanResult.url); + if (result === true) { + currentProxyUrl = urbanResult.url; + console.log(`[ProxyManager] ✅ New Urban proxy active: ${urbanResult.host}`); + return currentProxyUrl; + } else { + console.log(`[ProxyManager] ❌ Urban proxy failed YouTube test`); + } + } + } catch (err) { + console.error("[ProxyManager] Failed to fetch/test Urban proxy", err); + } + console.error("[ProxyManager] ❌ Could not find a working Urban proxy"); + currentProxyUrl = null; + return null; + } + + if (vpnSource === 3) { + // Custom Proxy Logic + console.log("[ProxyManager] Fetching proxies from custom API..."); + + let attempts = 0; + const maxAttempts = 10; // Increased retry limit as requested + + while (attempts < maxAttempts) { + try { + const response = await fetch(CUSTOM_PROXY_URL); + if (!response.ok) { + throw new Error(`Failed to fetch proxies: ${response.statusText}`); + } + const data = await response.json() as { proxies: string[] }; + + if (!data.proxies || !Array.isArray(data.proxies) || data.proxies.length === 0) { + console.log("[ProxyManager] No proxies returned from API, retrying..."); + attempts++; + continue; + } + + console.log(`[ProxyManager] Got ${data.proxies.length} proxies from API. Testing...`); + + for (const proxy of data.proxies) { + console.log(`[ProxyManager] Testing ${proxy}...`); + const result = await testProxyAgainstYouTube(proxy); + + if (result === true) { + currentProxyUrl = proxy; + console.log(`[ProxyManager] ✅ New custom proxy active: ${proxy}`); + return currentProxyUrl; + } else if (typeof result === 'number') { + console.log(`[ProxyManager] ❌ Proxy returned status ${result}, trying next...`); + } else { + console.log(`[ProxyManager] ❌ Proxy unreachable, trying next...`); + } + } + + console.log("[ProxyManager] All proxies from this batch failed. Refetching..."); + attempts++; + + } catch (err) { + console.error("[ProxyManager] Error fetching custom proxies:", err); + attempts++; + // Wait a bit before retrying if it's a fetch error + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + + console.error("[ProxyManager] ❌ Failed to find a working custom proxy after multiple attempts."); + currentProxyUrl = null; + return null; + } + + if (!accessToken || freeLocations.length === 0) { + console.error( + "[ProxyManager] Not initialized or no locations available", + ); + return null; + } + + // Default AntPeak Logic (vpnSource === 1) + if (!accessToken || freeLocations.length === 0) { + console.error( + "[ProxyManager] Not initialized or no locations available", + ); + return null; + } + + console.log("[ProxyManager] Rotating to new proxy (AntPeak)..."); + + // Shuffle locations to get variety + const shuffledLocations = [...freeLocations].sort(() => + Math.random() - 0.5 + ); + + for (const location of shuffledLocations) { + try { + console.log( + `[ProxyManager] Trying location: ${location.region} (${location.countryCode})`, + ); + + const proxyUrl = await fetchProxyServer(accessToken, location); + if (!proxyUrl) { + console.log( + `[ProxyManager] No server available for ${location.region}`, + ); + continue; + } + + // Test proxy against YouTube + console.log(`[ProxyManager] Testing proxy against YouTube...`); + const result = await testProxyAgainstYouTube(proxyUrl); + + if (result === true) { + currentProxyUrl = proxyUrl; + // Log without credentials for security + const sanitizedUrl = proxyUrl.replace( + /:\/\/[^@]+@/, + "://***:***@", + ); + console.log( + `[ProxyManager] ✅ New proxy active: ${sanitizedUrl}`, + ); + return currentProxyUrl; + } else { + console.log( + `[ProxyManager] ❌ Proxy failed YouTube test, trying next...`, + ); + } + } catch (err) { + console.error( + `[ProxyManager] Error with location ${location.region}:`, + err, + ); + } + } + + console.error("[ProxyManager] ❌ Could not find a working proxy"); + currentProxyUrl = null; + return null; +} + +/** + * Mark the current proxy as failed and rotate to a new one. + * Call this when a request fails due to proxy issues. + */ +export async function markProxyFailed(): Promise { + console.log("[ProxyManager] Current proxy marked as failed, rotating..."); + return await rotateProxy(); +} + +/** + * Check if the proxy manager is initialized and has a working proxy. + */ +export function isProxyManagerReady(): boolean { + return isInitialized && currentProxyUrl !== null; +} + +/** + * Re-register with the API (in case token expires). + */ +export async function refreshRegistration(): Promise { + console.log("[ProxyManager] Refreshing registration..."); + try { + accessToken = await registerDevice(); + freeLocations = await fetchLocations(accessToken); + console.log("[ProxyManager] ✅ Registration refreshed"); + } catch (err) { + console.error("[ProxyManager] ❌ Failed to refresh registration:", err); + throw err; + } +} diff --git a/src/lib/helpers/urbanProxy.ts b/src/lib/helpers/urbanProxy.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa2141efa618d27f79039895a025776a1e699b6f --- /dev/null +++ b/src/lib/helpers/urbanProxy.ts @@ -0,0 +1,233 @@ +const ACCOUNT_API = "https://api-pro.urban-vpn.com/rest/v1"; +const STATS_API = "https://stats.urban-vpn.com/api/rest/v2"; +const CLIENT_APP = "URBAN_VPN_BROWSER_EXTENSION"; +const BROWSER = "CHROME"; + +interface UrbanProxyResult { + url: string; + protocol: string; + host: string; + port: number; + username?: string; + password?: string; +} + +const PREFERRED_COUNTRIES = ["US", "GB", "CA", "DE", "FR", "NL", "ES", "IT", "JP", "KR", "SG", "AU"]; + +export async function fetchUrbanProxy(targetCountryCode = "RANDOM"): Promise { + console.log(`[UrbanVPN] Fetching Urban VPN Proxy (Target: ${targetCountryCode})...`); + + // 1. Register Anonymous + // console.log("[UrbanVPN] 1. Registering Anonymous User..."); + const regUrl = `${ACCOUNT_API}/registrations/clientApps/${CLIENT_APP}/users/anonymous`; + + const regHeaders = { + "content-type": "application/json", + "accept": "application/json, text/plain, */*", + "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" + }; + + const regPayload = { + clientApp: { + name: CLIENT_APP, + browser: BROWSER + } + }; + + let regResp; + try { + regResp = await fetch(regUrl, { + method: "POST", + headers: regHeaders, + body: JSON.stringify(regPayload) + }); + } catch (err) { + console.error("[UrbanVPN] Network error during registration:", err); + return null; + } + + if (!regResp.ok) { + const text = await regResp.text(); + console.error(`[UrbanVPN] Registration failed: ${regResp.status} ${regResp.statusText}`); + console.error(text); + return null; + } + + const regData = await regResp.json(); + const idToken = regData.id_token || regData.idToken || regData.value; + + if (!idToken) { + console.error("[UrbanVPN] No ID token found in registration response."); + return null; + } + + // 2. Get Security Token + // console.log("[UrbanVPN] 2. Getting Security Token..."); + const secUrl = `${ACCOUNT_API}/security/tokens/accs`; + const secHeaders = { + ...regHeaders, + "authorization": `Bearer ${idToken}` + }; + const secPayload = { + type: "accs", + clientApp: { + name: CLIENT_APP + } + }; + + const secResp = await fetch(secUrl, { + method: "POST", + headers: secHeaders, + body: JSON.stringify(secPayload) + }); + + if (!secResp.ok) { + const text = await secResp.text(); + console.error(`[UrbanVPN] Security Token request failed: ${secResp.status}`); + console.error(text); + return null; + } + + const secData = await secResp.json(); + + let tokenString = ""; + let credUsername = ""; + const credPassword = "1"; + + if (secData.token && typeof secData.token === 'object' && secData.token.value) { + tokenString = secData.token.value; + credUsername = secData.token.value; + } else if (typeof secData.token === 'string') { + tokenString = secData.token; + credUsername = secData.token; + + } else if (secData.value) { + tokenString = secData.value; + credUsername = secData.value; + } + + if (!tokenString) { + console.error("[UrbanVPN] No security token found."); + return null; + } + + // 3. Get Countries / Proxies + // console.log("[UrbanVPN] 3. Fetching Proxy List..."); + const countriesUrl = `${STATS_API}/entrypoints/countries`; + const proxyHeaders = { + ...regHeaders, + "authorization": `Bearer ${tokenString}`, + "X-Client-App": CLIENT_APP + }; + + // @ts-ignore: delete operator on string index signature + delete proxyHeaders["content-type"]; + + const countriesResp = await fetch(countriesUrl, { + headers: proxyHeaders + }); + + if (!countriesResp.ok) { + const text = await countriesResp.text(); + console.error(`[UrbanVPN] Failed to fetch countries: ${countriesResp.status}`); + console.error(text); + return null; + } + + const countriesData = await countriesResp.json(); + + if (!countriesData.countries || !countriesData.countries.elements) { + console.error("[UrbanVPN] Invalid countries data format."); + return null; + } + + const countries = countriesData.countries.elements; + + // Pick a country + let selectedCountryCode = targetCountryCode; + if (selectedCountryCode === "RANDOM") { + selectedCountryCode = PREFERRED_COUNTRIES[Math.floor(Math.random() * PREFERRED_COUNTRIES.length)]; + } + + // Find target country proxy + // deno-lint-ignore no-explicit-any + let targetCountry = countries.find((c: any) => c.code.iso2 === selectedCountryCode); + + // Fallback if random choice not found + if (!targetCountry) { + targetCountry = countries[0]; + console.log(`[UrbanVPN] Requested country ${selectedCountryCode} not found, falling back to ${targetCountry.code.iso2}`); + } + + if (targetCountry) { + console.log(`[UrbanVPN] Selected Country: ${targetCountry.title} (${targetCountry.code.iso2})`); + + let proxyHost = null; + let proxyPort = null; + let signature = null; + + if (targetCountry.address && targetCountry.address.primary) { + proxyHost = targetCountry.address.primary.host; + proxyPort = targetCountry.address.primary.port; + } + else if (targetCountry.servers && targetCountry.servers.elements && targetCountry.servers.elements.length > 0) { + // Pick a RANDOM server from the list + const serverIndex = Math.floor(Math.random() * targetCountry.servers.elements.length); + const srv = targetCountry.servers.elements[serverIndex]; + + if (srv.address && srv.address.primary) { + proxyHost = srv.address.primary.host; + proxyPort = srv.address.primary.port || srv.address.primary.port_min; + signature = srv.signature; + } + } + + if (signature) { + // console.log("[UrbanVPN] Found proxy signature, fetching Auth Proxy Token..."); + const proxyTokenUrl = `${ACCOUNT_API}/security/tokens/accs-proxy`; + const proxyTokenPayload = { + type: "accs-proxy", + clientApp: { name: CLIENT_APP }, + signature: signature + }; + + const proxyTokenHeaders = { + ...regHeaders, + "authorization": `Bearer ${tokenString}` + }; + + const ptResp = await fetch(proxyTokenUrl, { + method: "POST", + headers: proxyTokenHeaders, + body: JSON.stringify(proxyTokenPayload) + }); + + if (ptResp.ok) { + const ptData = await ptResp.json(); + if (ptData.value) { + credUsername = ptData.value; + } else if (ptData.token && ptData.token.value) { + credUsername = ptData.token.value; + } + } else { + console.error(`[UrbanVPN] Failed to get Proxy Auth Token: ${ptResp.status}`); + } + } + + if (proxyHost) { + const proxyUrl = `http://${encodeURIComponent(credUsername)}:${encodeURIComponent(credPassword)}@${proxyHost}:${proxyPort}`; + console.log(`[UrbanVPN] Proxy found: ${proxyHost}:${proxyPort}`); + return { + url: proxyUrl, + protocol: 'http', + host: proxyHost, + port: proxyPort, + username: credUsername, + password: credPassword + }; + } + } + + console.error("[UrbanVPN] No proxy server details found."); + return null; +} diff --git a/src/lib/helpers/validateVideoId.ts b/src/lib/helpers/validateVideoId.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfeb135917fcd56e2b15464c9c7c6d8168050600 --- /dev/null +++ b/src/lib/helpers/validateVideoId.ts @@ -0,0 +1,23 @@ +/** + * Validates a YouTube video ID format + * YouTube video IDs are 11 characters long and contain alphanumeric characters, hyphens, and underscores + * Reference: https://webapps.stackexchange.com/questions/54443/format-for-id-of-youtube-video + * + * @param videoId - The video ID to validate + * @returns true if the video ID is valid, false otherwise + */ +export const validateVideoId = (videoId: string): boolean => { + // Handle null, undefined, or non-string values + if (!videoId || typeof videoId !== "string") { + return false; + } + + // YouTube video IDs are exactly 11 characters + if (videoId.length !== 11) { + return false; + } + + // Valid characters: A-Z, a-z, 0-9, -, _ + const validPattern = /^[A-Za-z0-9_-]{11}$/; + return validPattern.test(videoId); +}; diff --git a/src/lib/helpers/verifyRequest.ts b/src/lib/helpers/verifyRequest.ts new file mode 100644 index 0000000000000000000000000000000000000000..0891839e9b26c73a7936544c54718cf00846093a --- /dev/null +++ b/src/lib/helpers/verifyRequest.ts @@ -0,0 +1,39 @@ +import { decodeBase64 } from "@std/encoding/base64"; +import { Aes } from "crypto/aes.ts"; +import { Ecb, Padding } from "crypto/block-modes.ts"; +import type { Config } from "./config.ts"; + +export const verifyRequest = ( + stringToCheck: string, + videoId: string, + config: Config, +): boolean => { + try { + const decipher = new Ecb( + Aes, + new TextEncoder().encode(config.server.secret_key), + Padding.PKCS7, + ); + + const encryptedData = new TextDecoder().decode( + decipher.decrypt( + decodeBase64( + stringToCheck.replace(/-/g, "+").replace(/_/g, "/"), + ), + ), + ); + const [parsedTimestamp, parsedVideoId] = encryptedData.split("|"); + const parsedTimestampInt = parseInt(parsedTimestamp); + const timestampNow = Math.round(+new Date() / 1000); + if (parsedVideoId !== videoId) { + return false; + } + // only allow ID to live for 6 hours + if ((timestampNow + 6 * 60 * 60) - parsedTimestampInt < 0) { + return false; + } + } catch (_) { + return false; + } + return true; +}; diff --git a/src/lib/helpers/youtubePlayerHandling.ts b/src/lib/helpers/youtubePlayerHandling.ts new file mode 100644 index 0000000000000000000000000000000000000000..aeb34546965958962a501ade21870c2009164d71 --- /dev/null +++ b/src/lib/helpers/youtubePlayerHandling.ts @@ -0,0 +1,195 @@ +import { ApiResponse, Innertube, YT } from "youtubei.js"; +import { generateRandomString } from "youtubei.js/Utils"; +import { compress, decompress } from "brotli"; +import type { TokenMinter } from "../jobs/potoken.ts"; +import { Metrics } from "../helpers/metrics.ts"; +let youtubePlayerReqLocation = "youtubePlayerReq"; +if (Deno.env.get("YT_PLAYER_REQ_LOCATION")) { + if (Deno.env.has("DENO_COMPILED")) { + youtubePlayerReqLocation = Deno.mainModule.replace("src/main.ts", "") + + Deno.env.get("YT_PLAYER_REQ_LOCATION"); + } else { + youtubePlayerReqLocation = Deno.env.get( + "YT_PLAYER_REQ_LOCATION", + ) as string; + } +} +const { youtubePlayerReq } = await import(youtubePlayerReqLocation); + +import type { Config } from "./config.ts"; + +const kv = await Deno.openKv(); + +export const youtubePlayerParsing = async ({ + innertubeClient, + videoId, + config, + tokenMinter, + metrics, + overrideCache = false, +}: { + innertubeClient: Innertube; + videoId: string; + config: Config; + tokenMinter: TokenMinter; + metrics: Metrics | undefined; + overrideCache?: boolean; +}): Promise => { + const cacheEnabled = overrideCache ? false : config.cache.enabled; + + const videoCached = (await kv.get(["video_cache", videoId])) + .value as Uint8Array; + + if (videoCached != null && cacheEnabled) { + return JSON.parse(new TextDecoder().decode(decompress(videoCached))); + } else { + const youtubePlayerResponse = await youtubePlayerReq( + innertubeClient, + videoId, + config, + tokenMinter, + ); + const videoData = youtubePlayerResponse.data; + + if (videoData.playabilityStatus.status === "ERROR") { + return videoData; + } + + const video = new YT.VideoInfo( + [youtubePlayerResponse], + innertubeClient.actions, + generateRandomString(16), + ); + + const streamingData = video.streaming_data; + + // Modify the original YouTube response to include deciphered URLs + if (streamingData && videoData && videoData.streamingData) { + const ecatcherServiceTracking = videoData.responseContext + ?.serviceTrackingParams.find((o: { service: string }) => + o.service === "ECATCHER" + ); + const clientNameUsed = ecatcherServiceTracking?.params?.find(( + o: { key: string }, + ) => o.key === "client.name"); + // no need to decipher on IOS nor ANDROID + if ( + !clientNameUsed?.value.includes("IOS") && + !clientNameUsed?.value.includes("ANDROID") + ) { + for (const [index, format] of streamingData.formats.entries()) { + videoData.streamingData.formats[index].url = await format + .decipher( + innertubeClient.session.player, + ); + if ( + videoData.streamingData.formats[index] + .signatureCipher !== + undefined + ) { + delete videoData.streamingData.formats[index] + .signatureCipher; + } + if ( + videoData.streamingData.formats[index].url.includes( + "alr=yes", + ) + ) { + videoData.streamingData.formats[index].url.replace( + "alr=yes", + "alr=no", + ); + } else { + videoData.streamingData.formats[index].url += "&alr=no"; + } + } + for ( + const [index, adaptive_format] of streamingData + .adaptive_formats + .entries() + ) { + videoData.streamingData.adaptiveFormats[index].url = + await adaptive_format + .decipher( + innertubeClient.session.player, + ); + if ( + videoData.streamingData.adaptiveFormats[index] + .signatureCipher !== + undefined + ) { + delete videoData.streamingData.adaptiveFormats[index] + .signatureCipher; + } + if ( + videoData.streamingData.adaptiveFormats[index].url + .includes("alr=yes") + ) { + videoData.streamingData.adaptiveFormats[index].url + .replace("alr=yes", "alr=no"); + } else { + videoData.streamingData.adaptiveFormats[index].url += + "&alr=no"; + } + } + } + } + + const videoOnlyNecessaryInfo = (( + { + captions, + playabilityStatus, + storyboards, + streamingData, + videoDetails, + microformat, + }, + ) => ({ + captions, + playabilityStatus, + storyboards, + streamingData, + videoDetails, + microformat, + }))(videoData); + + if (videoData.playabilityStatus?.status == "OK") { + metrics?.innertubeSuccessfulRequest.inc(); + if (cacheEnabled) { + (async () => { + await kv.set( + ["video_cache", videoId], + compress( + new TextEncoder().encode( + JSON.stringify(videoOnlyNecessaryInfo), + ), + ), + { + expireIn: 1000 * 60 * 60, + }, + ); + })(); + } + } else { + metrics?.checkInnertubeResponse(videoData); + } + + return videoOnlyNecessaryInfo; + } +}; + +export const youtubeVideoInfo = ( + innertubeClient: Innertube, + youtubePlayerResponseJson: object, +): YT.VideoInfo => { + const playerResponse = { + success: true, + status_code: 200, + data: youtubePlayerResponseJson, + } as ApiResponse; + return new YT.VideoInfo( + [playerResponse], + innertubeClient.actions, + "", + ); +}; diff --git a/src/lib/helpers/youtubePlayerReq.ts b/src/lib/helpers/youtubePlayerReq.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a6c413c834261c1bbdf8597e669ed9c5a216fed --- /dev/null +++ b/src/lib/helpers/youtubePlayerReq.ts @@ -0,0 +1,105 @@ +import { ApiResponse, Innertube } from "youtubei.js"; +import NavigationEndpoint from "youtubei.js/NavigationEndpoint"; +import type { TokenMinter } from "../jobs/potoken.ts"; + +import type { Config } from "./config.ts"; + +function callWatchEndpoint( + videoId: string, + innertubeClient: Innertube, + innertubeClientType: string, + contentPoToken: string, +) { + const watch_endpoint = new NavigationEndpoint({ + watchEndpoint: { + videoId: videoId, + // Allow companion to gather sensitive content videos like + // `VuSU7PcEKpU` + racyCheckOk: true, + contentCheckOk: true, + }, + }); + + return watch_endpoint.call( + innertubeClient.actions, + { + playbackContext: { + contentPlaybackContext: { + vis: 0, + splay: false, + lactMilliseconds: "-1", + signatureTimestamp: innertubeClient.session.player + ?.signature_timestamp, + }, + }, + serviceIntegrityDimensions: { + poToken: contentPoToken, + }, + client: innertubeClientType, + }, + ); +} + +export const youtubePlayerReq = async ( + innertubeClient: Innertube, + videoId: string, + config: Config, + tokenMinter: TokenMinter, +): Promise => { + const innertubeClientOauthEnabled = config.youtube_session.oauth_enabled; + + let innertubeClientUsed = "WEB"; + if (innertubeClientOauthEnabled) { + innertubeClientUsed = "TV"; + } + + const contentPoToken = await tokenMinter(videoId); + + const youtubePlayerResponse = await callWatchEndpoint( + videoId, + innertubeClient, + innertubeClientUsed, + contentPoToken, + ); + + // Check if the first adaptive format URL is undefined, if it is then fallback to multiple YT clients + + if ( + !innertubeClientOauthEnabled && + youtubePlayerResponse.data.streamingData && + youtubePlayerResponse.data.streamingData.adaptiveFormats[0].url === + undefined + ) { + console.log( + "[WARNING] No URLs found for adaptive formats. Falling back to other YT clients.", + ); + const innertubeClientsTypeFallback = ["TV_SIMPLY", "MWEB"]; + + for await (const innertubeClientType of innertubeClientsTypeFallback) { + console.log( + `[WARNING] Trying fallback YT client ${innertubeClientType}`, + ); + const youtubePlayerResponseFallback = await callWatchEndpoint( + videoId, + innertubeClient, + innertubeClientType, + contentPoToken, + ); + if ( + youtubePlayerResponseFallback.data.streamingData && ( + youtubePlayerResponseFallback.data.streamingData + .adaptiveFormats[0].url || + youtubePlayerResponseFallback.data.streamingData + .adaptiveFormats[0].signatureCipher + ) + ) { + youtubePlayerResponse.data.streamingData.adaptiveFormats = + youtubePlayerResponseFallback.data.streamingData + .adaptiveFormats; + break; + } + } + } + + return youtubePlayerResponse; +}; diff --git a/src/lib/helpers/youtubeTranscriptsHandling.ts b/src/lib/helpers/youtubeTranscriptsHandling.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2ce2259c954f81a71ef2828593485300664a296 --- /dev/null +++ b/src/lib/helpers/youtubeTranscriptsHandling.ts @@ -0,0 +1,90 @@ +import { Innertube } from "youtubei.js"; +import type { CaptionTrackData } from "youtubei.js/PlayerCaptionsTracklist"; +import { HTTPException } from "hono/http-exception"; + +function createTemporalDuration(milliseconds: number) { + return new Temporal.Duration( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + milliseconds, + ); +} + +const ESCAPE_SUBSTITUTIONS = { + "&": "&", + "<": "<", + ">": ">", + "\u200E": "‎", + "\u200F": "‏", + "\u00A0": " ", +}; + +export async function handleTranscripts( + innertubeClient: Innertube, + videoId: string, + selectedCaption: CaptionTrackData, +) { + const lines: string[] = ["WEBVTT"]; + + const info = await innertubeClient.getInfo(videoId); + const transcriptInfo = await (await info.getTranscript()).selectLanguage( + selectedCaption.name.text || "", + ); + const rawTranscriptLines = transcriptInfo.transcript.content?.body + ?.initial_segments; + + if (rawTranscriptLines == undefined) throw new HTTPException(404); + + rawTranscriptLines.forEach((line) => { + const timestampFormatOptions = { + style: "digital", + minutesDisplay: "always", + fractionalDigits: 3, + }; + + // Temporal.Duration.prototype.toLocaleString() is supposed to delegate to Intl.DurationFormat + // which Deno does not support. However, instead of following specs and having toLocaleString return + // the same toString() it seems to have its own implementation of Intl.DurationFormat, + // with its options parameter type incorrectly restricted to the same as the one for Intl.DateTimeFormatOptions + // even though they do not share the same arguments. + // + // The above matches the options parameter of Intl.DurationFormat, and the resulting output is as expected. + // Until this is fixed typechecking must be disabled for the two use cases below + // + // See + // https://docs.deno.com/api/web/~/Intl.DateTimeFormatOptions + // https://docs.deno.com/api/web/~/Temporal.Duration.prototype.toLocaleString + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration/toLocaleString + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat + + const start_ms = createTemporalDuration(Number(line.start_ms)).round({ + largestUnit: "year", + relativeTo: Temporal.PlainDateTime.from("2022-01-01"), + //@ts-ignore see above + }).toLocaleString("en-US", timestampFormatOptions); + + const end_ms = createTemporalDuration(Number(line.end_ms)).round({ + largestUnit: "year", + relativeTo: Temporal.PlainDateTime.from("2022-01-01"), + //@ts-ignore see above + }).toLocaleString("en-US", timestampFormatOptions); + const timestamp = `${start_ms} --> ${end_ms}`; + + const text = (line.snippet?.text || "").replace( + /[&<>‍‍\u200E\u200F\u00A0]/g, + (match: string) => + ESCAPE_SUBSTITUTIONS[ + match as keyof typeof ESCAPE_SUBSTITUTIONS + ], + ); + + lines.push(`${timestamp}\n${text}`); + }); + + return lines.join("\n\n"); +} diff --git a/src/lib/jobs/potoken.ts b/src/lib/jobs/potoken.ts new file mode 100644 index 0000000000000000000000000000000000000000..caa2deaddc947ffe36925fcec7d579542335804f --- /dev/null +++ b/src/lib/jobs/potoken.ts @@ -0,0 +1,252 @@ +import { Innertube } from "youtubei.js"; +import { + youtubePlayerParsing, + youtubeVideoInfo, +} from "../helpers/youtubePlayerHandling.ts"; +import type { Config } from "../helpers/config.ts"; +import { Metrics } from "../helpers/metrics.ts"; +let getFetchClientLocation = "getFetchClient"; +if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) { + if (Deno.env.has("DENO_COMPILED")) { + getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") + + Deno.env.get("GET_FETCH_CLIENT_LOCATION"); + } else { + getFetchClientLocation = Deno.env.get( + "GET_FETCH_CLIENT_LOCATION", + ) as string; + } +} +const { getFetchClient } = await import(getFetchClientLocation); + +import { InputMessage, OutputMessageSchema } from "./worker.ts"; +import { PLAYER_ID } from "../../constants.ts"; + +interface TokenGeneratorWorker extends Omit { + postMessage(message: InputMessage): void; +} + +const workers: TokenGeneratorWorker[] = []; + +function createMinter(worker: TokenGeneratorWorker) { + return (videoId: string): Promise => { + const { promise, resolve } = Promise.withResolvers(); + // generate a UUID to identify the request as many minter calls + // may be made within a timespan, and this function will be + // informed about all of them until it's got its own + const requestId = crypto.randomUUID(); + const listener = (message: MessageEvent) => { + const parsedMessage = OutputMessageSchema.parse(message.data); + if ( + parsedMessage.type === "content-token" && + parsedMessage.requestId === requestId + ) { + worker.removeEventListener("message", listener); + resolve(parsedMessage.contentToken); + } + }; + worker.addEventListener("message", listener); + worker.postMessage({ + type: "content-token-request", + videoId, + requestId, + }); + + return promise; + }; +} + +export type TokenMinter = ReturnType; + +// Adapted from https://github.com/LuanRT/BgUtils/blob/main/examples/node/index.ts +export const poTokenGenerate = ( + config: Config, + metrics: Metrics | undefined, +): Promise<{ innertubeClient: Innertube; tokenMinter: TokenMinter }> => { + const { promise, resolve, reject } = Promise.withResolvers< + Awaited> + >(); + + const worker: TokenGeneratorWorker = new Worker( + new URL("./worker.ts", import.meta.url).href, + { + type: "module", + name: "PO Token Generator", + }, + ); + // take note of the worker so we can kill it once a new one takes its place + workers.push(worker); + worker.addEventListener("message", async (event) => { + const parsedMessage = OutputMessageSchema.parse(event.data); + + // worker is listening for messages + if (parsedMessage.type === "ready") { + const untypedPostMessage = worker.postMessage.bind(worker); + worker.postMessage = (message: InputMessage) => + untypedPostMessage(message); + worker.postMessage({ type: "initialise", config }); + } + + if (parsedMessage.type === "error") { + console.log({ errorFromWorker: parsedMessage.error }); + worker.terminate(); + reject(parsedMessage.error); + } + + // worker is initialised and has passed back a session token and visitor data + if (parsedMessage.type === "initialised") { + try { + const instantiatedInnertubeClient = await Innertube.create({ + enable_session_cache: false, + po_token: parsedMessage.sessionPoToken, + visitor_data: parsedMessage.visitorData, + fetch: getFetchClient(config), + generate_session_locally: true, + cookie: config.youtube_session.cookies || undefined, + player_id: PLAYER_ID, + }); + const minter = createMinter(worker); + // check token from minter + await checkToken({ + instantiatedInnertubeClient, + config, + integrityTokenBasedMinter: minter, + metrics, + }); + console.log("[INFO] Successfully generated PO token"); + const numberToKill = workers.length - 1; + for (let i = 0; i < numberToKill; i++) { + const workerToKill = workers.shift(); + workerToKill?.terminate(); + } + return resolve({ + innertubeClient: instantiatedInnertubeClient, + tokenMinter: minter, + }); + } catch (err) { + console.log("[WARN] Failed to get valid PO token, will retry", { + err, + }); + worker.terminate(); + reject(err); + } + } + }); + + return promise; +}; + +async function checkToken({ + instantiatedInnertubeClient, + config, + integrityTokenBasedMinter, + metrics, +}: { + instantiatedInnertubeClient: Innertube; + config: Config; + integrityTokenBasedMinter: TokenMinter; + metrics: Metrics | undefined; +}) { + const fetchImpl = getFetchClient(config); + + try { + console.log("[INFO] Searching for videos to validate PO token"); + const searchResults = await instantiatedInnertubeClient.search("news", { + type: "video", + upload_date: "week", + duration: "medium", + }); + + // Get all videos that have an id property and shuffle them randomly + const videos = searchResults.videos + .filter((video) => + video.type === "Video" && "id" in video && video.id + ) + .map((value) => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value); + + if (videos.length === 0) { + throw new Error("No videos with valid IDs found in search results"); + } + + // Try up to 3 random videos to validate the token + const maxAttempts = Math.min(3, videos.length); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const video = videos[attempt]; + + try { + // Type guard to ensure video has an id property + if (!("id" in video) || !video.id) { + console.log( + `[WARN] Video at index ${attempt} has no valid ID, trying next video`, + ); + continue; + } + + console.log( + `[INFO] Validating PO token with video: ${video.id}`, + ); + + const youtubePlayerResponseJson = await youtubePlayerParsing({ + innertubeClient: instantiatedInnertubeClient, + videoId: video.id, + config, + tokenMinter: integrityTokenBasedMinter, + metrics, + overrideCache: true, + }); + + const videoInfo = youtubeVideoInfo( + instantiatedInnertubeClient, + youtubePlayerResponseJson, + ); + + const validFormat = videoInfo.streaming_data + ?.adaptive_formats[0]; + if (!validFormat) { + console.log( + `[WARN] No valid format found for video ${video.id}, trying next video`, + ); + continue; + } + + const result = await fetchImpl(validFormat?.url, { + method: "HEAD", + }); + + if (result.status !== 200) { + console.log( + `[WARN] Got status ${result.status} for video ${video.id}, trying next video`, + ); + continue; + } else { + console.log( + `[INFO] Successfully validated PO token with video: ${video.id}`, + ); + return; // Success + } + } catch (err) { + const videoId = ("id" in video && video.id) + ? video.id + : "unknown"; + console.log( + `[WARN] Failed to validate with video ${videoId}:`, + { err }, + ); + if (attempt === maxAttempts - 1) { + throw new Error( + "Failed to validate PO token with any available videos", + ); + } + continue; + } + } + // If we reach here, all attempts failed without throwing an exception + throw new Error( + "Failed to validate PO token: all validation attempts returned non-200 status codes", + ); + } catch (err) { + console.log("Failed to validate PO token using search method", { err }); + throw err; + } +} diff --git a/src/lib/jobs/worker.ts b/src/lib/jobs/worker.ts new file mode 100644 index 0000000000000000000000000000000000000000..64af093a8e5eda7188630b485852a9a5f95bb9bc --- /dev/null +++ b/src/lib/jobs/worker.ts @@ -0,0 +1,251 @@ +/// + +import { z } from "zod"; +import { Config, ConfigSchema } from "../helpers/config.ts"; +import { BG, buildURL, GOOG_API_KEY, USER_AGENT } from "bgutils"; +import type { WebPoSignalOutput } from "bgutils"; +import { JSDOM } from "jsdom"; +import { Innertube } from "youtubei.js"; +import { PLAYER_ID } from "../../constants.ts"; +let getFetchClientLocation = "getFetchClient"; +if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) { + if (Deno.env.has("DENO_COMPILED")) { + getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") + + Deno.env.get("GET_FETCH_CLIENT_LOCATION"); + } else { + getFetchClientLocation = Deno.env.get( + "GET_FETCH_CLIENT_LOCATION", + ) as string; + } +} + +type FetchFunction = typeof fetch; +const { getFetchClient }: { + getFetchClient: (config: Config) => Promise; +} = await import(getFetchClientLocation); + +// ---- Messages to send to the webworker ---- +const InputInitialiseSchema = z.object({ + type: z.literal("initialise"), + config: ConfigSchema, +}).strict(); + +const InputContentTokenSchema = z.object({ + type: z.literal("content-token-request"), + videoId: z.string(), + requestId: z.string().uuid(), +}).strict(); +export type InputInitialise = z.infer; +export type InputContentToken = z.infer; +const InputMessageSchema = z.union([ + InputInitialiseSchema, + InputContentTokenSchema, +]); +export type InputMessage = z.infer; + +// ---- Messages that the webworker sends to the parent ---- +const OutputReadySchema = z.object({ + type: z.literal("ready"), +}).strict(); + +const OutputInitialiseSchema = z.object({ + type: z.literal("initialised"), + sessionPoToken: z.string(), + visitorData: z.string(), +}).strict(); + +const OutputContentTokenSchema = z.object({ + type: z.literal("content-token"), + contentToken: z.string(), + requestId: InputContentTokenSchema.shape.requestId, +}).strict(); + +const OutputErrorSchema = z.object({ + type: z.literal("error"), + error: z.any(), +}).strict(); +export const OutputMessageSchema = z.union([ + OutputReadySchema, + OutputInitialiseSchema, + OutputContentTokenSchema, + OutputErrorSchema, +]); +type OutputMessage = z.infer; + +const IntegrityTokenResponse = z.tuple([z.string()]).rest(z.any()); + +const isWorker = typeof WorkerGlobalScope !== "undefined" && + self instanceof WorkerGlobalScope; +if (isWorker) { + // helper function to force type-checking + const untypedPostmessage = self.postMessage.bind(self); + const postMessage = (message: OutputMessage) => { + untypedPostmessage(message); + }; + + let minter: BG.WebPoMinter; + + onmessage = async (event) => { + const message = InputMessageSchema.parse(event.data); + if (message.type === "initialise") { + const fetchImpl: typeof fetch = await getFetchClient( + message.config, + ); + try { + const { + sessionPoToken, + visitorData, + generatedMinter, + } = await setup({ + fetchImpl, + innertubeClientCookies: + message.config.youtube_session.cookies, + }); + minter = generatedMinter; + postMessage({ + type: "initialised", + sessionPoToken, + visitorData, + }); + } catch (err) { + postMessage({ type: "error", error: err }); + } + } + // this is called every time a video needs a content token + if (message.type === "content-token-request") { + if (!minter) { + throw new Error( + "Minter not yet ready, must initialise first", + ); + } + const contentToken = await minter.mintAsWebsafeString( + message.videoId, + ); + postMessage({ + type: "content-token", + contentToken, + requestId: message.requestId, + }); + } + }; + + postMessage({ type: "ready" }); +} + +async function setup( + { fetchImpl, innertubeClientCookies }: { + fetchImpl: FetchFunction; + innertubeClientCookies: string; + }, +) { + const innertubeClient = await Innertube.create({ + enable_session_cache: false, + fetch: fetchImpl, + user_agent: USER_AGENT, + retrieve_player: false, + cookie: innertubeClientCookies || undefined, + player_id: PLAYER_ID, + }); + + const visitorData = innertubeClient.session.context.client.visitorData; + + if (!visitorData) { + throw new Error("Could not get visitor data"); + } + + const dom = new JSDOM( + '', + { + url: "https://www.youtube.com/", + referrer: "https://www.youtube.com/", + userAgent: USER_AGENT, + }, + ); + + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + // location: dom.window.location, // --- doesn't seem to be necessary and the Web Worker doesn't like it + origin: dom.window.origin, + }); + + if (!Reflect.has(globalThis, "navigator")) { + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + }); + } + + const challengeResponse = await innertubeClient.getAttestationChallenge( + "ENGAGEMENT_TYPE_UNBOUND", + ); + if (!challengeResponse.bg_challenge) { + throw new Error("Could not get challenge"); + } + + // Mock HTMLCanvasElement.prototype.getContext to silence "Not implemented" error + // and prevent unnecessary noise in logs. + if (dom.window.HTMLCanvasElement) { + dom.window.HTMLCanvasElement.prototype.getContext = (( + _contextId: string, + _options?: any, + ) => { + return new Proxy({}, { + get: (_target, _prop) => { + return () => { }; + }, + }); + }) as any; + dom.window.HTMLCanvasElement.prototype.toDataURL = () => ""; + } + + const interpreterUrl = challengeResponse.bg_challenge.interpreter_url + .private_do_not_access_or_else_trusted_resource_url_wrapped_value; + const bgScriptResponse = await fetchImpl( + `https:${interpreterUrl}`, + ); + const interpreterJavascript = await bgScriptResponse.text(); + + if (interpreterJavascript) { + new Function(interpreterJavascript)(); + } else throw new Error("Could not load VM"); + const botguard = await BG.BotGuardClient.create({ + program: challengeResponse.bg_challenge.program, + globalName: challengeResponse.bg_challenge.global_name, + globalObj: globalThis, + }); + + const webPoSignalOutput: WebPoSignalOutput = []; + const botguardResponse = await botguard.snapshot({ webPoSignalOutput }); + const requestKey = "O43z0dpjhgX20SCx4KAo"; + + const integrityTokenResponse = await fetchImpl( + buildURL("GenerateIT", true), + { + method: "POST", + headers: { + "content-type": "application/json+protobuf", + "x-goog-api-key": GOOG_API_KEY, + "x-user-agent": "grpc-web-javascript/0.1", + "user-agent": USER_AGENT, + }, + body: JSON.stringify([requestKey, botguardResponse]), + }, + ); + const integrityTokenBody = IntegrityTokenResponse.parse( + await integrityTokenResponse.json(), + ); + + const integrityTokenBasedMinter = await BG.WebPoMinter.create({ + integrityToken: integrityTokenBody[0], + }, webPoSignalOutput); + + const sessionPoToken = await integrityTokenBasedMinter.mintAsWebsafeString( + visitorData, + ); + + return { + sessionPoToken, + visitorData, + generatedMinter: integrityTokenBasedMinter, + }; +} diff --git a/src/lib/types/HonoVariables.ts b/src/lib/types/HonoVariables.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1e64d54ca882c3fc6e7836546df6a1950ff0027 --- /dev/null +++ b/src/lib/types/HonoVariables.ts @@ -0,0 +1,11 @@ +import { Innertube } from "youtubei.js"; +import type { TokenMinter } from "../jobs/potoken.ts"; +import type { Config } from "../helpers/config.ts"; +import { Metrics } from "../helpers/metrics.ts"; + +export type HonoVariables = { + innertubeClient: Innertube; + config: Config; + tokenMinter: TokenMinter | undefined; + metrics: Metrics | undefined; +}; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..17fb151f1695e8f5ab2c1da1f51570922bed8c7e --- /dev/null +++ b/src/main.ts @@ -0,0 +1,264 @@ +import { Hono } from "hono"; +import { companionRoutes, miscRoutes } from "./routes/index.ts"; +import { Innertube, Platform } from "youtubei.js"; +import { poTokenGenerate, type TokenMinter } from "./lib/jobs/potoken.ts"; +import { USER_AGENT } from "bgutils"; +import { retry } from "@std/async"; +import type { HonoVariables } from "./lib/types/HonoVariables.ts"; +import { parseArgs } from "@std/cli/parse-args"; +import { existsSync } from "@std/fs/exists"; + +import { parseConfig } from "./lib/helpers/config.ts"; +const config = await parseConfig(); +import { Metrics } from "./lib/helpers/metrics.ts"; +import { PLAYER_ID } from "./constants.ts"; +import { jsInterpreter } from "./lib/helpers/jsInterpreter.ts"; +import { + initProxyManager, + markProxyFailed, + isProxyManagerReady, +} from "./lib/helpers/proxyManager.ts"; + +// Initialize auto proxy manager if enabled +if (config.networking.auto_proxy) { + console.log("[INFO] Auto proxy is enabled, initializing proxy manager..."); + try { + await initProxyManager(config.networking.vpn_source); + } catch (err) { + console.error("[ERROR] Failed to initialize proxy manager:", err); + console.log("[WARN] Continuing without auto proxy..."); + } +} + +const args = parseArgs(Deno.args); + +if (args._version_date && args._version_commit) { + console.log( + `[INFO] Using Invidious companion version ${args._version_date}-${args._version_commit}`, + ); +} + +let getFetchClientLocation = "getFetchClient"; +if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) { + if (Deno.env.has("DENO_COMPILED")) { + getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") + + Deno.env.get("GET_FETCH_CLIENT_LOCATION"); + } else { + getFetchClientLocation = Deno.env.get( + "GET_FETCH_CLIENT_LOCATION", + ) as string; + } +} +const { getFetchClient } = await import(getFetchClientLocation); + +declare module "hono" { + interface ContextVariableMap extends HonoVariables { } +} + +const app = new Hono({ + getPath: (req) => new URL(req.url).pathname, +}); +const companionApp = new Hono({ + getPath: (req) => new URL(req.url).pathname, +}).basePath(config.server.base_path); +const metrics = config.server.enable_metrics ? new Metrics() : undefined; + +let tokenMinter: TokenMinter | undefined; +let innertubeClient: Innertube; +let innertubeClientFetchPlayer = true; +const innertubeClientOauthEnabled = config.youtube_session.oauth_enabled; +const innertubeClientJobPoTokenEnabled = + config.jobs.youtube_session.po_token_enabled; +const innertubeClientCookies = config.youtube_session.cookies; + +// Promise that resolves when tokenMinter initialization is complete (for tests) +let tokenMinterReadyResolve: (() => void) | undefined; +export const tokenMinterReady = new Promise((resolve) => { + tokenMinterReadyResolve = resolve; +}); + +if (!innertubeClientOauthEnabled) { + if (innertubeClientJobPoTokenEnabled) { + console.log("[INFO] job po_token is active."); + // Don't fetch fetch player yet for po_token + innertubeClientFetchPlayer = false; + } else if (!innertubeClientJobPoTokenEnabled) { + console.log("[INFO] job po_token is NOT active."); + } +} + +Platform.shim.eval = jsInterpreter; + +innertubeClient = await Innertube.create({ + enable_session_cache: false, + retrieve_player: innertubeClientFetchPlayer, + fetch: getFetchClient(config), + cookie: innertubeClientCookies || undefined, + user_agent: USER_AGENT, + player_id: PLAYER_ID, +}); + +if (!innertubeClientOauthEnabled) { + if (innertubeClientJobPoTokenEnabled) { + // Initialize tokenMinter in background to not block server startup + console.log("[INFO] Starting PO token generation in background..."); + + // Wrapper function that rotates proxy on failure when auto_proxy is enabled + const poTokenGenerateWithProxyRotation = async () => { + try { + return await poTokenGenerate(config, metrics); + } catch (err) { + // If auto_proxy is enabled and PO token generation failed, rotate to a new proxy + if (config.networking.auto_proxy) { + console.log( + "[INFO] PO token generation failed, rotating to new proxy...", + ); + await markProxyFailed(); + } + throw err; // Re-throw to trigger retry + } + }; + + retry( + poTokenGenerateWithProxyRotation, + { minTimeout: 1_000, maxTimeout: 60_000, multiplier: 5, jitter: 0 }, + ).then((result) => { + innertubeClient = result.innertubeClient; + tokenMinter = result.tokenMinter; + tokenMinterReadyResolve?.(); + }).catch((err) => { + console.error("[ERROR] Failed to initialize PO token:", err); + metrics?.potokenGenerationFailure.inc(); + tokenMinterReadyResolve?.(); + }); + } else { + // If PO token is not enabled, resolve immediately + tokenMinterReadyResolve?.(); + } + // Resolve promise for tests + tokenMinterReadyResolve?.(); +} + +const regenerateSession = async () => { + if (innertubeClientJobPoTokenEnabled) { + try { + ({ innertubeClient, tokenMinter } = await poTokenGenerate( + config, + metrics, + )); + } catch (err) { + metrics?.potokenGenerationFailure.inc(); + // If auto_proxy is enabled and PO token generation failed, rotate to a new proxy + if (config.networking.auto_proxy) { + console.log( + "[INFO] Session regeneration failed, rotating to new proxy...", + ); + await markProxyFailed(); + } + // Don't rethrow for cron/manual trigger to avoid crashing the server loop + console.error("[ERROR] Failed to regenerate session:", err); + } + } else { + innertubeClient = await Innertube.create({ + enable_session_cache: false, + fetch: getFetchClient(config), + retrieve_player: innertubeClientFetchPlayer, + user_agent: USER_AGENT, + cookie: innertubeClientCookies || undefined, + player_id: PLAYER_ID, + }); + } +}; + +if (!innertubeClientOauthEnabled) { + Deno.cron( + "regenerate youtube session", + config.jobs.youtube_session.frequency, + { backoffSchedule: [5_000, 15_000, 60_000, 180_000] }, + regenerateSession, + ); +} + +companionApp.use("*", async (c, next) => { + c.set("innertubeClient", innertubeClient); + c.set("tokenMinter", tokenMinter); + c.set("config", config); + c.set("metrics", metrics); + await next(); +}); +companionRoutes(companionApp, config); + +app.use("*", async (c, next) => { + c.set("metrics", metrics); + await next(); +}); +miscRoutes(app, config, regenerateSession); + +app.route("/", companionApp); + +// This cannot be changed since companion restricts the +// files it can access using deno `--allow-write` argument +const udsPath = config.server.unix_socket_path; + +export function run(signal: AbortSignal, port: number, hostname: string) { + if (config.server.use_unix_socket) { + try { + if (existsSync(udsPath)) { + // Delete the unix domain socket manually before starting the server + Deno.removeSync(udsPath); + } + } catch (err) { + console.log( + `[ERROR] Failed to delete unix domain socket '${udsPath}' before starting the server:`, + err, + ); + } + + const srv = Deno.serve( + { + onListen() { + Deno.chmodSync(udsPath, 0o777); + console.log( + `[INFO] Server successfully started at ${udsPath} with permissions set to 777.`, + ); + }, + signal: signal, + path: udsPath, + }, + app.fetch, + ); + + return srv; + } else { + return Deno.serve( + { + onListen() { + console.log( + `[INFO] Server successfully started at http://${config.server.host}:${config.server.port}${config.server.base_path}`, + ); + }, + signal: signal, + port: port, + hostname: hostname, + }, + app.fetch, + ); + } +} +if (import.meta.main) { + const controller = new AbortController(); + const { signal } = controller; + run(signal, config.server.port, config.server.host); + + Deno.addSignalListener("SIGTERM", () => { + console.log("Caught SIGINT, shutting down..."); + controller.abort(); + Deno.exit(0); + }); + + Deno.addSignalListener("SIGINT", () => { + console.log("Caught SIGINT, shutting down..."); + controller.abort(); + Deno.exit(0); + }); +} diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000000000000000000000000000000000000..37ed29d4a888a4ff0da980159b623cf589989b19 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,12 @@ +import { Hono } from "hono"; + +const health = new Hono(); + +health.get("/", () => { + return new Response("OK", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); +}); + +export default health; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..45c540b38cae30d113a9525f1a0c8c3b64aa2769 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,89 @@ +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { bearerAuth } from "hono/bearer-auth"; + +import youtubeApiPlayer from "./youtube_api_routes/player.ts"; +import invidiousRouteLatestVersion from "./invidious_routes/latestVersion.ts"; +import invidiousRouteDashManifest from "./invidious_routes/dashManifest.ts"; +import invidiousCaptionsApi from "./invidious_routes/captions.ts"; +import invidiousVideosApi from "./invidious_routes/videos.ts"; +import invidiousSearchApi from "./invidious_routes/search.ts"; +import invidiousChannelsApi from "./invidious_routes/channels.ts"; +import invidiousPlaylistsApi from "./invidious_routes/playlists.ts"; +import invidiousMixesApi from "./invidious_routes/mixes.ts"; + +import getDownloadHandler from "./invidious_routes/download.ts"; +import videoPlaybackProxy from "./videoPlaybackProxy.ts"; +import type { Config } from "../lib/helpers/config.ts"; +import metrics from "./metrics.ts"; +import health from "./health.ts"; + +export const companionRoutes = ( + app: Hono, + config: Config, +) => { + const loggerUnixSocket = (message: string, ...rest: string[]) => { + message = message.replace("//localhost/", "/"); + console.log(message, ...rest); + }; + + if (config.server.use_unix_socket) { + app.use("*", logger(loggerUnixSocket)); + } else { + app.use("*", logger()); + } + + app.use( + "/youtubei/v1/*", + bearerAuth({ + token: config.server.secret_key, + }), + ); + + app.route("/youtubei/v1", youtubeApiPlayer); + + app.get("/", (c) => { + 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)"); + }); + + app.route("/latest_version", invidiousRouteLatestVersion); + // Needs app for app.request in order to call /latest_version endpoint + app.post("/download", getDownloadHandler(app)); + app.route("/api/manifest/dash/id", invidiousRouteDashManifest); + app.route("/api/v1/captions", invidiousCaptionsApi); + app.route("/api/v1/videos", invidiousVideosApi); + app.route("/api/v1/search", invidiousSearchApi); + app.route("/api/v1/channels", invidiousChannelsApi); + app.route("/api/v1/playlists", invidiousPlaylistsApi); + app.route("/api/v1/mixes", invidiousMixesApi); + + app.route("/videoplayback", videoPlaybackProxy); +}; + +export const miscRoutes = ( + app: Hono, + config: Config, + regenerateSession?: () => Promise, +) => { + app.route("/healthz", health); + if (config.server.enable_metrics) { + app.route("/metrics", metrics); + } + + app.get("/api/set/proxy/:proxy", async (c) => { + let proxy = c.req.param("proxy"); + if (proxy) { + proxy = decodeURIComponent(proxy); + config.networking.proxy = proxy; + console.log(`[INFO] Proxy updated to: ${proxy}`); + + if (regenerateSession) { + console.log("[INFO] Triggering session regeneration..."); + await regenerateSession(); + } + + return c.text(`Proxy updated to: ${proxy}`); + } + return c.text("Invalid proxy", 400); + }); +}; diff --git a/src/routes/invidious_routes/captions.ts b/src/routes/invidious_routes/captions.ts new file mode 100644 index 0000000000000000000000000000000000000000..df0014a1696cdb8312875ef9a7e0590c72192eac --- /dev/null +++ b/src/routes/invidious_routes/captions.ts @@ -0,0 +1,114 @@ +import { Hono } from "hono"; +import type { HonoVariables } from "../../lib/types/HonoVariables.ts"; +import { verifyRequest } from "../../lib/helpers/verifyRequest.ts"; +import { + youtubePlayerParsing, + youtubeVideoInfo, +} from "../../lib/helpers/youtubePlayerHandling.ts"; +import type { CaptionTrackData } from "youtubei.js/PlayerCaptionsTracklist"; +import { handleTranscripts } from "../../lib/helpers/youtubeTranscriptsHandling.ts"; +import { HTTPException } from "hono/http-exception"; +import { validateVideoId } from "../../lib/helpers/validateVideoId.ts"; +import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts"; + +interface AvailableCaption { + label: string; + languageCode: string; + url: string; +} + +const captionsHandler = new Hono<{ Variables: HonoVariables }>(); +captionsHandler.get("/:videoId", async (c) => { + const { videoId } = c.req.param(); + const config = c.get("config"); + const metrics = c.get("metrics"); + const tokenMinter = c.get("tokenMinter"); + + const check = c.req.query("check"); + + if (!validateVideoId(videoId)) { + throw new HTTPException(400, { + res: new Response("Invalid video ID format."), + }); + } + + // Check if tokenMinter is ready (only needed when PO token is enabled) + if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) { + throw new HTTPException(503, { + res: new Response(TOKEN_MINTER_NOT_READY_MESSAGE), + }); + } + + if (config.server.verify_requests && check == undefined) { + throw new HTTPException(400, { + res: new Response("No check ID."), + }); + } else if (config.server.verify_requests && check) { + if (verifyRequest(check, videoId, config) === false) { + throw new HTTPException(400, { + res: new Response("ID incorrect."), + }); + } + } + + const innertubeClient = c.get("innertubeClient"); + + const youtubePlayerResponseJson = await youtubePlayerParsing({ + innertubeClient, + videoId, + config, + metrics, + tokenMinter: tokenMinter!, + }); + + const videoInfo = youtubeVideoInfo( + innertubeClient, + youtubePlayerResponseJson, + ); + + const captionsTrackArray = videoInfo.captions?.caption_tracks; + if (captionsTrackArray == undefined) throw new HTTPException(404); + + const label = c.req.query("label"); + const lang = c.req.query("lang"); + + // Show all available captions when a specific one is not selected + if (label == undefined && lang == undefined) { + const invidiousAvailableCaptionsArr: AvailableCaption[] = []; + + for (const caption_track of captionsTrackArray) { + invidiousAvailableCaptionsArr.push({ + label: caption_track.name.text || "", + languageCode: caption_track.language_code, + url: `${config.server.base_path}/api/v1/captions/${videoId}?label=${ + encodeURIComponent(caption_track.name.text || "") + }`, + }); + } + + return c.json({ captions: invidiousAvailableCaptionsArr }); + } + + // Extract selected caption + let filterSelected: CaptionTrackData[]; + + if (lang) { + filterSelected = captionsTrackArray.filter((c: CaptionTrackData) => + c.language_code === lang + ); + } else { + filterSelected = captionsTrackArray.filter((c: CaptionTrackData) => + c.name.text === label + ); + } + + if (filterSelected.length == 0) throw new HTTPException(404); + + c.header("Content-Type", "text/vtt; charset=UTF-8"); + c.header("Access-Control-Allow-Origin", "*"); + return c.body( + await handleTranscripts(innertubeClient, videoId, filterSelected[0]), + ); +}); + +export default captionsHandler; diff --git a/src/routes/invidious_routes/channels.ts b/src/routes/invidious_routes/channels.ts new file mode 100644 index 0000000000000000000000000000000000000000..8acd0320704dcf4707d4227f63235bb42774ce05 --- /dev/null +++ b/src/routes/invidious_routes/channels.ts @@ -0,0 +1,255 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { Innertube } from "youtubei.js"; + +const channels = new Hono(); + +interface Thumbnail { + url: string; + width: number; + height: number; +} + +interface VideoThumbnail { + quality: string; + url: string; + width: number; + height: number; +} + +interface LatestVideo { + type: string; + title: string; + videoId: string; + author: string; + authorId: string; + authorUrl: string; + authorVerified: boolean; + videoThumbnails: VideoThumbnail[]; + description: string; + descriptionHtml: string; + viewCount: number; + viewCountText: string; + published: number; + publishedText: string; + lengthSeconds: number; + liveNow: boolean; + premium: boolean; + isUpcoming: boolean; + isNew: boolean; + is4k: boolean; + is8k: boolean; + isVr180: boolean; + isVr360: boolean; + is3d: boolean; + hasCaptions: boolean; +} + +interface ChannelResponse { + author: string; + authorId: string; + authorUrl: string; + authorBanners: Thumbnail[]; + authorThumbnails: Thumbnail[]; + subCount: number; + totalViews: number; + joined: number; + autoGenerated: boolean; + ageGated: boolean; + isFamilyFriendly: boolean; + description: string; + descriptionHtml: string; + allowedRegions: string[]; + tabs: string[]; + tags: string[]; + authorVerified: boolean; + latestVideos: LatestVideo[]; + relatedChannels: any[]; +} + +// Helper to parse subscriber count text to number +function parseSubCount(text: string | undefined): number { + if (!text) return 0; + const cleanText = text.replace(/subscribers?/i, "").trim(); + const multipliers: { [key: string]: number } = { + 'K': 1000, + 'M': 1000000, + 'B': 1000000000 + }; + const match = cleanText.match(/([\d.]+)\s*([KMB])?/i); + if (match) { + const num = parseFloat(match[1]); + const suffix = match[2]?.toUpperCase(); + return suffix ? Math.round(num * multipliers[suffix]) : Math.round(num); + } + return parseInt(cleanText.replace(/[,\s]/g, "")) || 0; +} + +// Helper to parse view count text +function parseViewCount(text: string | undefined): number { + if (!text) return 0; + const match = text.match(/([\d,]+)/); + if (match) { + return parseInt(match[1].replace(/,/g, "")) || 0; + } + return 0; +} + +// Helper to generate video thumbnails +function generateVideoThumbnails(videoId: string): VideoThumbnail[] { + const baseUrl = "https://i.ytimg.com"; + return [ + { quality: "maxres", url: `${baseUrl}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 }, + { quality: "sddefault", url: `${baseUrl}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 }, + { quality: "high", url: `${baseUrl}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 }, + { quality: "medium", url: `${baseUrl}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 }, + { quality: "default", url: `${baseUrl}/vi/${videoId}/default.jpg`, width: 120, height: 90 }, + ]; +} + +// GET /:channelId - Get channel information +channels.get("/:channelId", async (c) => { + const channelId = c.req.param("channelId"); + const innertubeClient = c.get("innertubeClient") as Innertube; + + if (!channelId) { + throw new HTTPException(400, { message: "Channel ID is required" }); + } + + console.log(`[INFO] Fetching channel: ${channelId}`); + + try { + const channel = await innertubeClient.getChannel(channelId); + + // Extract channel metadata + const metadata = channel.metadata as any; + const header = channel.header as any; + const headerContent = header?.content; + + // Parse banners from header.content.banner.image + const authorBanners: Thumbnail[] = []; + if (headerContent?.banner?.image && Array.isArray(headerContent.banner.image)) { + for (const img of headerContent.banner.image) { + authorBanners.push({ + url: img.url, + width: img.width || 0, + height: img.height || 0 + }); + } + } + + // Parse thumbnails from header.content.image.avatar.image + const authorThumbnails: Thumbnail[] = []; + if (headerContent?.image?.avatar?.image && Array.isArray(headerContent.image.avatar.image)) { + for (const img of headerContent.image.avatar.image) { + authorThumbnails.push({ + url: img.url, + width: img.width || 0, + height: img.height || 0 + }); + } + } + // Fallback: try metadata.thumbnail + if (authorThumbnails.length === 0 && metadata?.thumbnail && Array.isArray(metadata.thumbnail)) { + for (const img of metadata.thumbnail) { + authorThumbnails.push({ + url: img.url, + width: img.width || 0, + height: img.height || 0 + }); + } + } + + // Parse subscriber count from header.content.metadata.metadata_rows + let subscriberText = ""; + if (headerContent?.metadata?.metadata_rows) { + for (const row of headerContent.metadata.metadata_rows) { + if (row.metadata_parts) { + for (const part of row.metadata_parts) { + const text = part.text?.text || ""; + if (text.includes("subscriber")) { + subscriberText = text; + break; + } + } + } + if (subscriberText) break; + } + } + + // Get latest videos + const latestVideos: LatestVideo[] = []; + const videosTab = channel.has_videos ? await channel.getVideos() : null; + if (videosTab?.videos) { + for (const video of videosTab.videos.slice(0, 20)) { // Limit to 20 videos + const v = video as any; + latestVideos.push({ + type: "video", + title: v.title?.text || "", + videoId: v.id || "", + author: metadata?.title || "", + authorId: channelId, + authorUrl: `/channel/${channelId}`, + authorVerified: metadata?.is_verified || false, + videoThumbnails: generateVideoThumbnails(v.id || ""), + description: v.description_snippet?.text || "", + descriptionHtml: v.description_snippet?.text || "", + viewCount: parseViewCount(v.view_count?.text), + viewCountText: v.view_count?.text || "", + published: 0, // Timestamp not easily available + publishedText: v.published?.text || "", + lengthSeconds: v.duration?.seconds || 0, + liveNow: v.is_live || false, + premium: false, + isUpcoming: v.is_upcoming || false, + isNew: false, + is4k: false, + is8k: false, + isVr180: false, + isVr360: false, + is3d: false, + hasCaptions: false + }); + } + } + + // Get tabs + const tabs: string[] = []; + const channelAny = channel as any; + if (channelAny.has_home) tabs.push("home"); + if (channelAny.has_videos) tabs.push("videos"); + if (channelAny.has_shorts) tabs.push("shorts"); + if (channelAny.has_live) tabs.push("live"); + if (channelAny.has_playlists) tabs.push("playlists"); + if (channelAny.has_community) tabs.push("community"); + + const response: ChannelResponse = { + author: metadata?.title || "", + authorId: channelId, + authorUrl: `/channel/${channelId}`, + authorBanners: authorBanners, + authorThumbnails: authorThumbnails, + subCount: parseSubCount(subscriberText), + totalViews: 0, // Not easily available + joined: 0, // Not easily available + autoGenerated: false, + ageGated: false, + isFamilyFriendly: true, + description: metadata?.description || "", + descriptionHtml: metadata?.description || "", + allowedRegions: [], + tabs: tabs, + tags: metadata?.keywords || [], + authorVerified: metadata?.is_verified || false, + latestVideos: latestVideos, + relatedChannels: [] + }; + + return c.json(response); + } catch (error) { + console.error(`[ERROR] Failed to fetch channel ${channelId}:`, error); + throw new HTTPException(500, { message: `Failed to fetch channel: ${error}` }); + } +}); + +export default channels; diff --git a/src/routes/invidious_routes/dashManifest.ts b/src/routes/invidious_routes/dashManifest.ts new file mode 100644 index 0000000000000000000000000000000000000000..3075b8e3c442af575e8553f81c10c6f46aab0d14 --- /dev/null +++ b/src/routes/invidious_routes/dashManifest.ts @@ -0,0 +1,132 @@ +import { Hono } from "hono"; +import { FormatUtils } from "youtubei.js"; +import { + youtubePlayerParsing, + youtubeVideoInfo, +} from "../../lib/helpers/youtubePlayerHandling.ts"; +import { verifyRequest } from "../../lib/helpers/verifyRequest.ts"; +import { HTTPException } from "hono/http-exception"; +import { encryptQuery } from "../../lib/helpers/encryptQuery.ts"; +import { validateVideoId } from "../../lib/helpers/validateVideoId.ts"; +import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts"; + +const dashManifest = new Hono(); + +dashManifest.get("/:videoId", async (c) => { + const { videoId } = c.req.param(); + const { check, local } = c.req.query(); + c.header("access-control-allow-origin", "*"); + + const innertubeClient = c.get("innertubeClient"); + const config = c.get("config"); + const metrics = c.get("metrics"); + const tokenMinter = c.get("tokenMinter"); + + // Check if tokenMinter is ready (only needed when PO token is enabled) + if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) { + throw new HTTPException(503, { + res: new Response(TOKEN_MINTER_NOT_READY_MESSAGE), + }); + } + + if (!validateVideoId(videoId)) { + throw new HTTPException(400, { + res: new Response("Invalid video ID format."), + }); + } + + if (config.server.verify_requests && check == undefined) { + throw new HTTPException(400, { + res: new Response("No check ID."), + }); + } else if (config.server.verify_requests && check) { + if (verifyRequest(check, videoId, config) === false) { + throw new HTTPException(400, { + res: new Response("ID incorrect."), + }); + } + } + + const youtubePlayerResponseJson = await youtubePlayerParsing({ + innertubeClient, + videoId, + config, + tokenMinter: tokenMinter!, + metrics, + }); + const videoInfo = youtubeVideoInfo( + innertubeClient, + youtubePlayerResponseJson, + ); + + if (videoInfo.playability_status?.status !== "OK") { + throw ("The video can't be played: " + videoId + " due to reason: " + + videoInfo.playability_status?.reason); + } + + c.header("content-type", "application/dash+xml"); + + if (videoInfo.streaming_data) { + // video.js only support MP4 not WEBM + videoInfo.streaming_data.adaptive_formats = videoInfo + .streaming_data.adaptive_formats + .filter((i) => + i.mime_type.includes("mp4") + ); + + const player_response = videoInfo.page[0]; + // TODO: fix include storyboards in DASH manifest file + //const storyboards = player_response.storyboards; + const captions = player_response.captions?.caption_tracks; + + const dashFile = await FormatUtils.toDash( + videoInfo.streaming_data, + videoInfo.page[0].video_details?.is_post_live_dvr, + (url: URL) => { + let dashUrl = url; + let queryParams = new URLSearchParams(dashUrl.search); + // Can't create URL type without host part + queryParams.set("host", dashUrl.host); + + if (local) { + if (config.networking.videoplayback.ump) { + queryParams.set("ump", "yes"); + } + if ( + config.server.encrypt_query_params + ) { + const publicParams = [...queryParams].filter(([key]) => + ["pot", "ip"].includes(key) === false + ); + const privateParams = [...queryParams].filter(([key]) => + ["pot", "ip"].includes(key) === true + ); + const encryptedParams = encryptQuery( + JSON.stringify(privateParams), + config, + ); + queryParams = new URLSearchParams(publicParams); + queryParams.set("enc", "true"); + queryParams.set("data", encryptedParams); + } + dashUrl = + (config.server.base_path + dashUrl.pathname + "?" + + queryParams.toString()) as unknown as URL; + return dashUrl; + } else { + return dashUrl; + } + }, + undefined, + videoInfo.cpn, + undefined, + innertubeClient.actions, + undefined, + captions, + undefined, + ); + return c.body(dashFile); + } +}); + +export default dashManifest; diff --git a/src/routes/invidious_routes/download.ts b/src/routes/invidious_routes/download.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d61a0fc5015ce041db7d053b2bf1fbeb4f90961 --- /dev/null +++ b/src/routes/invidious_routes/download.ts @@ -0,0 +1,100 @@ +import type { Context, Hono } from "hono"; +import { z } from "zod"; +import { HTTPException } from "hono/http-exception"; +import { verifyRequest } from "../../lib/helpers/verifyRequest.ts"; +import { validateVideoId } from "../../lib/helpers/validateVideoId.ts"; + +const DownloadWidgetSchema = z.union([ + z.object({ label: z.string(), ext: z.string() }).strict(), + z.object({ itag: z.number(), ext: z.string() }).strict(), +]); + +type DownloadWidget = z.infer; + +export default function getDownloadHandler(app: Hono) { + async function handler(c: Context) { + const body = await c.req.formData(); + + const videoId = body.get("id")?.toString(); + if (videoId == undefined) { + throw new HTTPException(400, { + res: new Response("Please specify the video ID"), + }); + } + + if (!validateVideoId(videoId)) { + throw new HTTPException(400, { + res: new Response("Invalid video ID format."), + }); + } + + const config = c.get("config"); + + const check = c.req.query("check"); + + if (config.server.verify_requests && check == undefined) { + throw new HTTPException(400, { + res: new Response("No check ID."), + }); + } else if (config.server.verify_requests && check) { + if (verifyRequest(check, videoId, config) === false) { + throw new HTTPException(400, { + res: new Response("ID incorrect."), + }); + } + } + + const title = body.get("title"); + + let downloadWidgetData: DownloadWidget; + + try { + downloadWidgetData = JSON.parse( + body.get("download_widget")?.toString() || "", + ); + } catch { + throw new HTTPException(400, { + res: new Response("Invalid download_widget json"), + }); + } + + if ( + !(title && videoId && + DownloadWidgetSchema.safeParse(downloadWidgetData).success) + ) { + throw new HTTPException(400, { + res: new Response("Invalid form data required for download"), + }); + } + + if ("label" in downloadWidgetData) { + return await app.request( + `${config.server.base_path}/api/v1/captions/${videoId}?label=${ + encodeURIComponent(downloadWidgetData.label) + }`, + ); + } else { + const itag = downloadWidgetData.itag; + const ext = downloadWidgetData.ext; + const filename = `${title}-${videoId}.${ext}`; + + const urlQueriesForLatestVersion = new URLSearchParams(); + urlQueriesForLatestVersion.set("id", videoId); + urlQueriesForLatestVersion.set("check", check || ""); + urlQueriesForLatestVersion.set("itag", itag.toString()); + // "title" for compatibility with how Invidious sets the content disposition header + // in /videoplayback and /latest_version + urlQueriesForLatestVersion.set( + "title", + filename, + ); + urlQueriesForLatestVersion.set("local", "true"); + + return await app.request( + `${config.server.base_path}/latest_version?${urlQueriesForLatestVersion.toString()}`, + ); + } + } + + return handler; +} diff --git a/src/routes/invidious_routes/latestVersion.ts b/src/routes/invidious_routes/latestVersion.ts new file mode 100644 index 0000000000000000000000000000000000000000..37d82647f753ee9df1e57d479f63630c8b49640e --- /dev/null +++ b/src/routes/invidious_routes/latestVersion.ts @@ -0,0 +1,119 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { + youtubePlayerParsing, + youtubeVideoInfo, +} from "../../lib/helpers/youtubePlayerHandling.ts"; +import { verifyRequest } from "../../lib/helpers/verifyRequest.ts"; +import { encryptQuery } from "../../lib/helpers/encryptQuery.ts"; +import { validateVideoId } from "../../lib/helpers/validateVideoId.ts"; +import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts"; + +const latestVersion = new Hono(); + +latestVersion.get("/", async (c) => { + const { check, itag, id, local, title } = c.req.query(); + c.header("access-control-allow-origin", "*"); + + if (!id || !itag) { + throw new HTTPException(400, { + res: new Response("Please specify the itag and video ID."), + }); + } + + if (!validateVideoId(id)) { + throw new HTTPException(400, { + res: new Response("Invalid video ID format."), + }); + } + + const innertubeClient = c.get("innertubeClient"); + const config = c.get("config"); + const metrics = c.get("metrics"); + const tokenMinter = c.get("tokenMinter"); + + // Check if tokenMinter is ready (only needed when PO token is enabled) + if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) { + throw new HTTPException(503, { + res: new Response(TOKEN_MINTER_NOT_READY_MESSAGE), + }); + } + + if (config.server.verify_requests && check == undefined) { + throw new HTTPException(400, { + res: new Response("No check ID."), + }); + } else if (config.server.verify_requests && check) { + if (verifyRequest(check, id, config) === false) { + throw new HTTPException(400, { + res: new Response("ID incorrect."), + }); + } + } + + const youtubePlayerResponseJson = await youtubePlayerParsing({ + innertubeClient, + videoId: id, + config, + tokenMinter: tokenMinter!, + metrics, + }); + const videoInfo = youtubeVideoInfo( + innertubeClient, + youtubePlayerResponseJson, + ); + + if (videoInfo.playability_status?.status !== "OK") { + throw ("The video can't be played: " + id + " due to reason: " + + videoInfo.playability_status?.reason); + } + const streamingData = videoInfo.streaming_data; + const availableFormats = streamingData?.formats.concat( + streamingData.adaptive_formats, + ); + const selectedItagFormat = availableFormats?.filter((i) => + i.itag == Number(itag) + ); + if (selectedItagFormat?.length === 0) { + throw new HTTPException(400, { + res: new Response("No itag found."), + }); + } else if (selectedItagFormat) { + // Always offer original audio if possible + // This may be changed due to https://github.com/iv-org/invidious/issues/5501 + const itagUrl = selectedItagFormat.find((itag) => + itag.is_original + )?.url as string || selectedItagFormat[0].url as string; + const itagUrlParsed = new URL(itagUrl); + let queryParams = new URLSearchParams(itagUrlParsed.search); + let urlToRedirect = itagUrlParsed.toString(); + + if (local) { + queryParams.set("host", itagUrlParsed.host); + if (config.server.encrypt_query_params) { + const publicParams = [...queryParams].filter(([key]) => + ["pot", "ip"].includes(key) === false + ); + const privateParams = [...queryParams].filter(([key]) => + ["pot", "ip"].includes(key) === true + ); + const encryptedParams = encryptQuery( + JSON.stringify(privateParams), + config, + ); + queryParams = new URLSearchParams(publicParams); + queryParams.set("enc", "true"); + queryParams.set("data", encryptedParams); + } + urlToRedirect = config.server.base_path + itagUrlParsed.pathname + + "?" + + queryParams.toString(); + } + + if (title) urlToRedirect += `&title=${encodeURIComponent(title)}`; + + return c.redirect(urlToRedirect); + } +}); + +export default latestVersion; diff --git a/src/routes/invidious_routes/mixes.ts b/src/routes/invidious_routes/mixes.ts new file mode 100644 index 0000000000000000000000000000000000000000..5341166e310625fa0e5bbaa9716941733972eccf --- /dev/null +++ b/src/routes/invidious_routes/mixes.ts @@ -0,0 +1,91 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { Innertube } from "youtubei.js"; + +const mixes = new Hono(); + +interface VideoThumbnail { + quality: string; + url: string; + width: number; + height: number; +} + +interface MixVideo { + title: string; + videoId: string; + author: string; + authorId: string; + authorUrl: string; + videoThumbnails: VideoThumbnail[]; + index: number; + lengthSeconds: number; +} + +interface MixResponse { + title: string; + mixId: string; + videos: MixVideo[]; +} + +// Helper to generate video thumbnails +function generateVideoThumbnails(videoId: string): VideoThumbnail[] { + const baseUrl = "https://i.ytimg.com"; + return [ + { quality: "maxres", url: `${baseUrl}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 }, + { quality: "sddefault", url: `${baseUrl}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 }, + { quality: "high", url: `${baseUrl}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 }, + { quality: "medium", url: `${baseUrl}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 }, + { quality: "default", url: `${baseUrl}/vi/${videoId}/default.jpg`, width: 120, height: 90 }, + ]; +} + +// GET /:mixId - Get mix information +mixes.get("/:mixId", async (c) => { + const mixId = c.req.param("mixId"); + const innertubeClient = c.get("innertubeClient") as Innertube; + + if (!mixId) { + throw new HTTPException(400, { message: "Mix ID is required" }); + } + + console.log(`[INFO] Fetching mix: ${mixId}`); + + try { + // Mixes are fetched like playlists + const playlist = await innertubeClient.getPlaylist(mixId); + const info = playlist.info as any; + + // Get videos + const videos: MixVideo[] = []; + let index = 0; + for (const item of playlist.items) { + const v = item as any; + if (v.type === "PlaylistVideo" || v.id) { + videos.push({ + title: v.title?.text || "", + videoId: v.id || "", + author: v.author?.name || "", + authorId: v.author?.id || "", + authorUrl: v.author?.id ? `/channel/${v.author.id}` : "", + videoThumbnails: generateVideoThumbnails(v.id || ""), + index: index++, + lengthSeconds: v.duration?.seconds || 0 + }); + } + } + + const response: MixResponse = { + title: info?.title || "", + mixId: mixId, + videos: videos + }; + + return c.json(response); + } catch (error) { + console.error(`[ERROR] Failed to fetch mix ${mixId}:`, error); + throw new HTTPException(500, { message: `Failed to fetch mix: ${error}` }); + } +}); + +export default mixes; diff --git a/src/routes/invidious_routes/playlists.ts b/src/routes/invidious_routes/playlists.ts new file mode 100644 index 0000000000000000000000000000000000000000..9adc06ee7bd95aa28ea40d62122f033b5486343e --- /dev/null +++ b/src/routes/invidious_routes/playlists.ts @@ -0,0 +1,165 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { Innertube } from "youtubei.js"; + +const playlists = new Hono(); + +interface Thumbnail { + url: string; + width: number; + height: number; +} + +interface VideoThumbnail { + quality: string; + url: string; + width: number; + height: number; +} + +interface PlaylistVideo { + type: string; + title: string; + videoId: string; + author: string; + authorId: string; + authorUrl: string; + videoThumbnails: VideoThumbnail[]; + index: number; + lengthSeconds: number; + liveNow: boolean; +} + +interface PlaylistResponse { + type: string; + title: string; + playlistId: string; + playlistThumbnail: string; + author: string; + authorId: string; + authorUrl: string; + subtitle: object | null; + authorThumbnails: Thumbnail[]; + description: string; + descriptionHtml: string; + videoCount: number; + viewCount: number; + updated: number; + isListed: boolean; + videos: PlaylistVideo[]; +} + +// Helper to generate video thumbnails +function generateVideoThumbnails(videoId: string): VideoThumbnail[] { + const baseUrl = "https://i.ytimg.com"; + return [ + { quality: "maxres", url: `${baseUrl}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 }, + { quality: "sddefault", url: `${baseUrl}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 }, + { quality: "high", url: `${baseUrl}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 }, + { quality: "medium", url: `${baseUrl}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 }, + { quality: "default", url: `${baseUrl}/vi/${videoId}/default.jpg`, width: 120, height: 90 }, + ]; +} + +// Helper to parse view count +function parseViewCount(text: string | undefined): number { + if (!text) return 0; + const match = text.match(/([\d,]+)/); + if (match) { + return parseInt(match[1].replace(/,/g, "")) || 0; + } + return 0; +} + +// GET /:playlistId - Get playlist information +playlists.get("/:playlistId", async (c) => { + const playlistId = c.req.param("playlistId"); + const innertubeClient = c.get("innertubeClient") as Innertube; + + if (!playlistId) { + throw new HTTPException(400, { message: "Playlist ID is required" }); + } + + console.log(`[INFO] Fetching playlist: ${playlistId}`); + + try { + const playlist = await innertubeClient.getPlaylist(playlistId); + const info = playlist.info as any; + + // Extract author info + let author = ""; + let authorId = ""; + let authorUrl = ""; + const authorThumbnails: Thumbnail[] = []; + + if (info?.author) { + author = info.author.name || ""; + authorId = info.author.id || ""; + authorUrl = authorId ? `/channel/${authorId}` : ""; + } + + // Get author thumbnails + if (info?.author?.thumbnails && Array.isArray(info.author.thumbnails)) { + for (const thumb of info.author.thumbnails) { + authorThumbnails.push({ + url: thumb.url, + width: thumb.width || 0, + height: thumb.height || 0 + }); + } + } + + // Get playlist thumbnail + let playlistThumbnail = ""; + if (info?.thumbnails && info.thumbnails.length > 0) { + playlistThumbnail = info.thumbnails[0].url || ""; + } + + // Get videos + const videos: PlaylistVideo[] = []; + let index = 0; + for (const item of playlist.items) { + const v = item as any; + if (v.type === "PlaylistVideo" || v.id) { + videos.push({ + type: "video", + title: v.title?.text || "", + videoId: v.id || "", + author: v.author?.name || author, + authorId: v.author?.id || authorId, + authorUrl: v.author?.id ? `/channel/${v.author.id}` : authorUrl, + videoThumbnails: generateVideoThumbnails(v.id || ""), + index: index++, + lengthSeconds: v.duration?.seconds || 0, + liveNow: v.is_live || false + }); + } + } + + const response: PlaylistResponse = { + type: "playlist", + title: info?.title || "", + playlistId: playlistId, + playlistThumbnail: playlistThumbnail, + author: author, + authorId: authorId, + authorUrl: authorUrl, + subtitle: null, + authorThumbnails: authorThumbnails, + description: info?.description || "", + descriptionHtml: info?.description || "", + videoCount: parseViewCount(String(info?.total_items || "")) || videos.length, + viewCount: parseViewCount(info?.views?.text), + updated: 0, // Not easily available + isListed: true, + videos: videos + }; + + return c.json(response); + } catch (error) { + console.error(`[ERROR] Failed to fetch playlist ${playlistId}:`, error); + throw new HTTPException(500, { message: `Failed to fetch playlist: ${error}` }); + } +}); + +export default playlists; diff --git a/src/routes/invidious_routes/search.ts b/src/routes/invidious_routes/search.ts new file mode 100644 index 0000000000000000000000000000000000000000..963c7317265723a2c20f3d702f5299eec76ee1ef --- /dev/null +++ b/src/routes/invidious_routes/search.ts @@ -0,0 +1,300 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { Innertube } from "youtubei.js"; + +const search = new Hono(); + +// Helper to convert duration string to seconds (e.g., "12:34" -> 754) +function parseDuration(durationVal: string | number | undefined): number { + if (typeof durationVal === 'number') return durationVal; + if (!durationVal) return 0; + + // Check if it's just seconds as a string + if (!durationVal.includes(':')) { + return parseInt(durationVal, 10) || 0; + } + + const parts = durationVal.split(':').map(Number); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } + return 0; +} + +// Helper to parse relative time to seconds (approximate) +function parseRelativeTime(text: string | undefined): number { + if (!text) return 0; + const now = Date.now(); + // This is a very rough approximation as we don't have the exact date + // You might want to use a library or more complex logic here if needed + // For now, we'll just return date.now / 1000 to be safe or 0 + // A better approach would be to parse "2 years ago" etc. + // But Invidious often returns the timestamp of the upload. + // If not available, 0 is often used as a fallback. + return Math.floor(now / 1000); +} + + +search.get("/", async (c) => { + const q = c.req.query("q"); + const page = parseInt(c.req.query("page") || "1"); + const type = c.req.query("type") || "all"; + + c.header("access-control-allow-origin", "*"); + c.header("content-type", "application/json"); + + if (!q) { + throw new HTTPException(400, { + res: new Response(JSON.stringify({ error: "Query parameter 'q' is required" })), + }); + } + + const innertubeClient = c.get("innertubeClient") as Innertube; + + try { + // Map 'type' to YouTubei filters + let filters: any = {}; + if (type === "video") filters.type = "video"; + else if (type === "channel") filters.type = "channel"; + else if (type === "playlist") filters.type = "playlist"; + // 'all' uses default, no filter needed usually, or we can fetch without filters + + const searchResults = await innertubeClient.search(q, filters); + + // Note: Pagination support in InnerTube search is via 'getContinuation', + // but existing stateless endpoints often just fetch the first batch. + // Fully implementing page 2+ requires handling continuation tokens, + // usually passed back to the client. The 'page' param in Invidious + // is often abstraction. For now, we return the initial results. + // If the user needs deep pagination, we'd need to store/pass continuation tokens. + + const items = searchResults.results || []; + + // Mapped results + const response: any[] = []; + + for (const item of items) { + if (item.type === "Video" || item.type === "CompactVideo") { + const video = item as any; // Cast to any to access properties safely + response.push({ + type: "video", + title: video.title?.text || "", + videoId: video.id, + author: video.author?.name || "", + authorId: video.author?.id || "", + authorUrl: video.author?.url || (video.author?.id ? `/channel/${video.author?.id}` : ""), + authorVerified: video.author?.is_verified || false, + authorThumbnails: video.author?.thumbnails || [], + videoThumbnails: video.thumbnails || [], + description: video.description?.text || "", + descriptionHtml: video.description?.text || "", // Basic text for now + viewCount: video.view_count?.text ? parseInt(video.view_count.text.replace(/[^0-9]/g, '')) : 0, + viewCountText: video.view_count?.text || "0 views", + published: parseRelativeTime(video.published?.text), + publishedText: video.published?.text || "", + lengthSeconds: parseDuration(video.duration?.text), + liveNow: video.is_live || false, + premium: false, // Not easily available + isUpcoming: video.upcoming || false, + isNew: false, // Logic needed + is4k: false, // Check badges? + is8k: false, + isVr180: false, + isVr360: false, + is3d: false, + hasCaptions: false // Check badges? + }); + } else if (item.type === "Channel") { + const channel = item as any; + response.push({ + type: "channel", + author: channel.author?.name || channel.title?.text || "", + authorId: channel.id, + authorUrl: `/channel/${channel.id}`, + authorVerified: channel.author?.is_verified || false, + authorThumbnails: channel.thumbnails || [], + autoGenerated: false, + subCount: channel.subscriber_count?.text ? parseInt(channel.subscriber_count.text.replace(/[^0-9]/g, '')) : 0, + videoCount: 0, // Often not in search snippets + channelHandle: "", // Might be in url + description: channel.description_snippet?.text || "", + descriptionHtml: channel.description_snippet?.text || "" + }); + } else if (item.type === "Playlist") { + const playlist = item as any; + response.push({ + type: "playlist", + title: playlist.title?.text || "", + playlistId: playlist.id, + playlistThumbnail: playlist.thumbnails?.[0]?.url || "", + author: playlist.author?.name || "", + authorId: playlist.author?.id || "", + authorUrl: `/channel/${playlist.author?.id || ""}`, + authorVerified: playlist.author?.is_verified || false, + videoCount: parseInt(playlist.video_count?.replace(/[^0-9]/g, '') || "0"), + videos: [] // Search result usually doesn't have video list inside + }); + } else if (item.type === "LockupView") { + const lockup = item as any; + const contentId = lockup.content_id; + + if (contentId?.startsWith("PL")) { + // Playlist + let author = ""; + let authorId = ""; + let authorUrl = ""; + let videoCount = 0; + let thumbnail = ""; + + // Extract thumbnail + const primaryThumbnail = lockup.content_image?.primary_thumbnail; + if (primaryThumbnail?.image && primaryThumbnail.image.length > 0) { + thumbnail = primaryThumbnail.image[0].url; + } + + // Extract video count from overlays + if (primaryThumbnail?.overlays) { + for (const overlay of primaryThumbnail.overlays) { + if (overlay.badges) { + for (const badge of overlay.badges) { + if (badge.text && badge.text.includes("videos")) { + const match = badge.text.match(/([\d,]+)\s+videos/); + if (match) { + videoCount = parseInt(match[1].replace(/,/g, "")); + } + } + } + } + } + } + + // Extract author, authorId, and authorUrl from metadata rows + if (lockup.metadata?.metadata?.metadata_rows) { + for (const row of lockup.metadata.metadata.metadata_rows) { + if (row.metadata_parts) { + for (const part of row.metadata_parts) { + if (part.text?.runs) { + for (const run of part.text.runs) { + if (run.endpoint?.metadata?.page_type === "WEB_PAGE_TYPE_CHANNEL") { + author = run.text; + authorId = run.endpoint?.payload?.browseId || ""; + authorUrl = run.endpoint?.payload?.canonicalBaseUrl || (authorId ? `/channel/${authorId}` : ""); + break; + } + } + } + if (author) break; + } + } + if (author) break; + } + } + + // Fallback for author + if (!author && lockup.metadata?.metadata?.metadata_rows?.[0]?.metadata_parts?.[0]?.text?.text) { + author = lockup.metadata.metadata.metadata_rows[0].metadata_parts[0].text.text; + } + + response.push({ + type: "playlist", + title: lockup.metadata?.title?.text || "Unknown Playlist", + playlistId: contentId, + playlistThumbnail: thumbnail, + author: author, + authorId: authorId, + authorUrl: authorUrl, + authorVerified: false, + videoCount: videoCount, + videos: [] + }); + } else if (contentId?.startsWith("UC")) { + // Channel + response.push({ + type: "channel", + author: lockup.metadata?.title?.text || "Unknown Channel", + authorId: contentId, + authorUrl: `/channel/${contentId}`, + authorVerified: false, + authorThumbnails: lockup.content_image?.primary_thumbnail?.thumbnails || [], + autoGenerated: false, + subCount: 0, + videoCount: 0, + channelHandle: "", + description: "", + descriptionHtml: "" + }); + } else { + // Assume Video + response.push({ + type: "video", + title: lockup.metadata?.title?.text || "Unknown Video", + videoId: contentId, + author: "", // Parsing from metadata lines is complex + authorId: "", + authorUrl: "", + authorVerified: false, + authorThumbnails: [], // Often missing in LockupView + videoThumbnails: lockup.content_image?.primary_thumbnail?.thumbnails || [], + description: "", + descriptionHtml: "", + viewCount: 0, + viewCountText: "", + published: 0, + publishedText: "", + lengthSeconds: 0, // Duration might be in metadata + liveNow: false, + premium: false, + isUpcoming: false, + isNew: false, + is4k: false, + is8k: false, + isVr180: false, + isVr360: false, + is3d: false, + hasCaptions: false + }); + } + } + } + + return c.json(response); + + } catch (error) { + console.error("[ERROR] Failed to fetch search results:", error); + throw new HTTPException(500, { + res: new Response(JSON.stringify({ error: "Failed to fetch search results" })), + }); + } +}); + +search.get("/suggestions", async (c) => { + const q = c.req.query("q"); + c.header("access-control-allow-origin", "*"); + c.header("content-type", "application/json"); + + if (!q) { + throw new HTTPException(400, { + res: new Response(JSON.stringify({ error: "Query parameter 'q' is required" })), + }); + } + + const innertubeClient = c.get("innertubeClient") as Innertube; + + try { + const suggestions = await innertubeClient.getSearchSuggestions(q); + return c.json({ + query: q, + suggestions: suggestions, + }); + } catch (error) { + console.error("[ERROR] Failed to fetch search suggestions:", error); + throw new HTTPException(500, { + res: new Response(JSON.stringify({ error: "Failed to fetch search suggestions" })), + }); + } +}); + +export default search; diff --git a/src/routes/invidious_routes/videos.ts b/src/routes/invidious_routes/videos.ts new file mode 100644 index 0000000000000000000000000000000000000000..57e43c17058848984dec881f1845872a559e488b --- /dev/null +++ b/src/routes/invidious_routes/videos.ts @@ -0,0 +1,622 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { + youtubePlayerParsing, + youtubeVideoInfo, +} from "../../lib/helpers/youtubePlayerHandling.ts"; +import { validateVideoId } from "../../lib/helpers/validateVideoId.ts"; +import { encryptQuery } from "../../lib/helpers/encryptQuery.ts"; +import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts"; + +const videos = new Hono(); + +interface Thumbnail { + quality: string; + url: string; + width: number; + height: number; +} + +interface AuthorThumbnail { + url: string; + width: number; + height: number; +} + +interface Storyboard { + url: string; + templateUrl: string; + width: number; + height: number; + count: number; + interval: number; + storyboardWidth: number; + storyboardHeight: number; + storyboardCount: number; +} + +interface AdaptiveFormat { + init?: string; + index?: string; + bitrate: string; + url: string; + itag: string; + type: string; + clen?: string; + lmt?: string; + projectionType: string; + fps?: number; + size?: string; + resolution?: string; + qualityLabel?: string; + container?: string; + encoding?: string; + audioQuality?: string; + audioSampleRate?: number; + audioChannels?: number; + colorInfo?: object; +} + +interface FormatStream { + url: string; + itag: string; + type: string; + quality: string; + bitrate: string; + fps?: number; + size?: string; + resolution?: string; + qualityLabel?: string; + container?: string; + encoding?: string; +} + +interface Caption { + label: string; + language_code: string; + url: string; +} + +interface RecommendedVideo { + videoId: string; + title: string; + videoThumbnails: Thumbnail[]; + author: string; + authorUrl: string; + authorId: string; + authorVerified: boolean; + lengthSeconds: number; + viewCountText: string; + published?: string; + publishedText?: string; +} + +// Generate thumbnail URLs for a video +function generateThumbnails(videoId: string, baseUrl: string): Thumbnail[] { + return [ + { quality: "maxres", url: `${baseUrl}/vi/${videoId}/maxres.jpg`, width: 1280, height: 720 }, + { quality: "maxresdefault", url: `${baseUrl}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 }, + { quality: "sddefault", url: `${baseUrl}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 }, + { quality: "high", url: `${baseUrl}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 }, + { quality: "medium", url: `${baseUrl}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 }, + { quality: "default", url: `${baseUrl}/vi/${videoId}/default.jpg`, width: 120, height: 90 }, + { quality: "start", url: `${baseUrl}/vi/${videoId}/1.jpg`, width: 120, height: 90 }, + { quality: "middle", url: `${baseUrl}/vi/${videoId}/2.jpg`, width: 120, height: 90 }, + { quality: "end", url: `${baseUrl}/vi/${videoId}/3.jpg`, width: 120, height: 90 }, + ]; +} + +// Parse storyboards from YouTube response +function parseStoryboards(storyboards: any, videoId: string): Storyboard[] { + const result: Storyboard[] = []; + if (!storyboards) return result; + + // Handle PlayerStoryboardSpec format + if (storyboards.type === "PlayerStoryboardSpec" && storyboards.boards) { + for (const board of storyboards.boards) { + if (!board.template_url) continue; + result.push({ + url: `/api/v1/storyboards/${videoId}?width=${board.thumbnail_width}&height=${board.thumbnail_height}`, + templateUrl: board.template_url, + width: board.thumbnail_width || 0, + height: board.thumbnail_height || 0, + count: board.thumbnail_count || 0, + interval: board.interval || 0, + storyboardWidth: board.columns || 0, + storyboardHeight: board.rows || 0, + storyboardCount: board.storyboard_count || 1, + }); + } + } + + return result; +} + +// Convert YouTube format to Invidious adaptive format +function convertAdaptiveFormat(format: any): AdaptiveFormat { + const result: AdaptiveFormat = { + bitrate: String(format.bitrate || "0"), + url: format.url || "", + itag: String(format.itag || "0"), + type: format.mime_type || "", + projectionType: format.projection_type || "RECTANGULAR", + }; + + if (format.init_range) { + result.init = `${format.init_range.start}-${format.init_range.end}`; + } + if (format.index_range) { + result.index = `${format.index_range.start}-${format.index_range.end}`; + } + if (format.content_length) result.clen = String(format.content_length); + if (format.last_modified) result.lmt = String(format.last_modified); + if (format.fps) result.fps = format.fps; + if (format.width && format.height) result.size = `${format.width}x${format.height}`; + if (format.quality_label) { + result.qualityLabel = format.quality_label; + result.resolution = format.quality_label; + } + + // Parse container and encoding from mime type + const mimeMatch = format.mime_type?.match(/^(video|audio)\/(\w+)/); + if (mimeMatch) { + result.container = mimeMatch[2]; + } + + const codecMatch = format.mime_type?.match(/codecs="([^"]+)"/); + if (codecMatch) { + result.encoding = codecMatch[1].split(",")[0].trim(); + } + + if (format.audio_quality) result.audioQuality = format.audio_quality; + if (format.audio_sample_rate) result.audioSampleRate = parseInt(format.audio_sample_rate); + if (format.audio_channels) result.audioChannels = format.audio_channels; + if (format.color_info) result.colorInfo = format.color_info; + + return result; +} + +// Convert YouTube format to Invidious format stream (combined video+audio) +function convertFormatStream(format: any): FormatStream { + const result: FormatStream = { + url: format.url || "", + itag: String(format.itag || "0"), + type: format.mime_type || "", + quality: format.quality || "medium", + bitrate: String(format.bitrate || "0"), + }; + + if (format.fps) result.fps = format.fps; + if (format.width && format.height) result.size = `${format.width}x${format.height}`; + if (format.quality_label) { + result.qualityLabel = format.quality_label; + result.resolution = format.quality_label; + } + + const mimeMatch = format.mime_type?.match(/^video\/(\w+)/); + if (mimeMatch) { + result.container = mimeMatch[1]; + } + + const codecMatch = format.mime_type?.match(/codecs="([^"]+)"/); + if (codecMatch) { + result.encoding = codecMatch[1].split(",")[0].trim(); + } + + return result; +} + +// Convert description to HTML with links +function descriptionToHtml(description: string): string { + if (!description) return ""; + + // Escape HTML entities + let html = description + .replace(/&/g, "&") + .replace(//g, ">"); + + // Convert URLs to links + html = html.replace( + /(https?:\/\/[^\s]+)/g, + (url) => { + const displayUrl = url.replace(/^https?:\/\//, ""); + return `${displayUrl}`; + } + ); + + // Convert hashtags to links + html = html.replace( + /#(\w+)/g, + '#$1' + ); + + return html; +} + +// Calculate relative time string +function getRelativeTimeString(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffYears > 0) return `${diffYears} year${diffYears > 1 ? "s" : ""} ago`; + if (diffMonths > 0) return `${diffMonths} month${diffMonths > 1 ? "s" : ""} ago`; + if (diffWeeks > 0) return `${diffWeeks} week${diffWeeks > 1 ? "s" : ""} ago`; + if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`; + if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`; + if (diffMinutes > 0) return `${diffMinutes} minute${diffMinutes > 1 ? "s" : ""} ago`; + return "just now"; +} + +// Localize URL to route through local server +function localizeUrl(url: string, config: any): string { + if (!url) return url; + try { + const urlParsed = new URL(url); + let queryParams = new URLSearchParams(urlParsed.search); + queryParams.set("host", urlParsed.host); + + if (config.server.encrypt_query_params) { + const publicParams = [...queryParams].filter(([key]) => + ["pot", "ip"].includes(key) === false + ); + const privateParams = [...queryParams].filter(([key]) => + ["pot", "ip"].includes(key) === true + ); + const encryptedParams = encryptQuery( + JSON.stringify(privateParams), + config, + ); + queryParams = new URLSearchParams(publicParams); + queryParams.set("enc", "true"); + queryParams.set("data", encryptedParams); + } + + return config.server.base_path + urlParsed.pathname + "?" + queryParams.toString(); + } catch { + return url; + } +} + +videos.get("/:videoId", async (c) => { + const videoId = c.req.param("videoId"); + const { local } = c.req.query(); + c.header("access-control-allow-origin", "*"); + c.header("content-type", "application/json"); + + if (!videoId) { + throw new HTTPException(400, { + res: new Response(JSON.stringify({ error: "Video ID is required" })), + }); + } + + if (!validateVideoId(videoId)) { + throw new HTTPException(400, { + res: new Response(JSON.stringify({ error: "Invalid video ID format" })), + }); + } + + const innertubeClient = c.get("innertubeClient"); + const config = c.get("config"); + const metrics = c.get("metrics"); + const tokenMinter = c.get("tokenMinter"); + + // Check if tokenMinter is ready (only needed when PO token is enabled) + if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) { + throw new HTTPException(503, { + res: new Response(JSON.stringify({ error: TOKEN_MINTER_NOT_READY_MESSAGE })), + }); + } + + const youtubePlayerResponseJson = await youtubePlayerParsing({ + innertubeClient, + videoId, + config, + tokenMinter: tokenMinter!, + metrics, + }) as any; + + const videoInfo = youtubeVideoInfo(innertubeClient, youtubePlayerResponseJson); + + if (videoInfo.playability_status?.status !== "OK") { + throw new HTTPException(400, { + res: new Response(JSON.stringify({ + error: "Video unavailable", + reason: videoInfo.playability_status?.reason, + })), + }); + } + + // Get the request origin for thumbnail URLs + const origin = new URL(c.req.url).origin; + const thumbnailBaseUrl = origin; + + // Build video details + const details = videoInfo.basic_info; + const streamingData = videoInfo.streaming_data; + + // Parse publish date + let publishedTimestamp = 0; + let publishedText = ""; + if (youtubePlayerResponseJson.microformat?.playerMicroformatRenderer?.publishDate) { + const publishDate = new Date(youtubePlayerResponseJson.microformat.playerMicroformatRenderer.publishDate); + publishedTimestamp = Math.floor(publishDate.getTime() / 1000); + publishedText = getRelativeTimeString(publishDate); + } + + // Build adaptive formats + const adaptiveFormats: AdaptiveFormat[] = []; + if (streamingData?.adaptive_formats) { + for (const format of streamingData.adaptive_formats) { + const converted = convertAdaptiveFormat(format); + if (local) { + converted.url = localizeUrl(converted.url, config); + } + adaptiveFormats.push(converted); + } + } + + // Build format streams (combined video+audio) + const formatStreams: FormatStream[] = []; + if (streamingData?.formats) { + for (const format of streamingData.formats) { + const converted = convertFormatStream(format); + if (local) { + converted.url = localizeUrl(converted.url, config); + } + formatStreams.push(converted); + } + } + + // Build captions + const captions: Caption[] = []; + if (videoInfo.captions?.caption_tracks) { + for (const track of videoInfo.captions.caption_tracks) { + captions.push({ + label: track.name?.text || track.language_code || "Unknown", + language_code: track.language_code || "en", + url: `/api/v1/captions/${videoId}?label=${encodeURIComponent(track.name?.text || track.language_code || "")}`, + }); + } + } + + // Build recommended videos + const recommendedVideos: RecommendedVideo[] = []; + // Note: Related videos require a separate API call to /next endpoint + // For now, we return an empty array - this can be enhanced later + + // Build author thumbnails from raw YouTube response + const authorThumbnails: AuthorThumbnail[] = []; + const channelThumbnails = youtubePlayerResponseJson.videoDetails?.author?.thumbnail?.thumbnails || + youtubePlayerResponseJson.microformat?.playerMicroformatRenderer?.ownerProfileUrl ? [] : []; + + // Generate standard author thumbnail sizes if we have the channel ID + if (details.channel_id) { + const sizes = [32, 48, 76, 100, 176, 512]; + for (const size of sizes) { + authorThumbnails.push({ + url: `https://yt3.ggpht.com/a/default-user=s${size}-c-k-c0x00ffffff-no-rj`, + width: size, + height: size, + }); + } + } + + // Get raw YouTube response data + const videoDetails = (youtubePlayerResponseJson as any).videoDetails || {}; + const microformat = (youtubePlayerResponseJson as any).microformat?.playerMicroformatRenderer || {}; + const playabilityStatus = (youtubePlayerResponseJson as any).playabilityStatus || {}; + const streamingDataRaw = (youtubePlayerResponseJson as any).streamingData || {}; + const captionsRaw = (youtubePlayerResponseJson as any).captions || {}; + const storyboardsRaw = (youtubePlayerResponseJson as any).storyboards || {}; + + // Map thumbnails directly from videoDetails + const thumbnailArray = []; + if (videoDetails.thumbnail?.thumbnails) { + for (const thumb of videoDetails.thumbnail.thumbnails) { + thumbnailArray.push({ + url: thumb.url, + width: thumb.width, + height: thumb.height, + }); + } + } + + // Map storyboards directly from API response + const storyboardsArray = []; + if (storyboardsRaw.playerStoryboardSpecRenderer?.spec) { + const spec = storyboardsRaw.playerStoryboardSpecRenderer.spec; + const specParts = spec.split('|'); + + for (let i = 3; i < specParts.length; i++) { + const parts = specParts[i].split('#'); + if (parts.length >= 8) { + const baseUrl = specParts[0]; + const [width, height, count, columns, rows, interval, name, sigh] = parts; + const storyboardCount = Math.ceil(parseInt(count) / (parseInt(columns) * parseInt(rows))); + + const urls = []; + for (let j = 0; j < storyboardCount; j++) { + let url = baseUrl.replace('$L', i - 3).replace('$N', name) + j; + if (sigh) url += '&sigh=' + sigh; + urls.push(url); + } + + storyboardsArray.push({ + width: width, + height: height, + thumbsCount: count, + columns: columns, + rows: rows, + interval: interval, + storyboardCount: storyboardCount, + url: urls, + }); + } + } + } + + // Map captions directly from API response + const captionTracks = []; + if (captionsRaw.playerCaptionsTracklistRenderer?.captionTracks) { + for (const track of captionsRaw.playerCaptionsTracklistRenderer.captionTracks) { + captionTracks.push({ + baseUrl: track.baseUrl, + name: track.name?.simpleText || track.languageCode, + vssId: track.vssId || "", + languageCode: track.languageCode, + isTranslatable: track.isTranslatable ?? true, + }); + } + } + + // Map audioTracks directly from API response + const audioTracks = []; + if (captionsRaw.playerCaptionsTracklistRenderer?.audioTracks) { + for (const track of captionsRaw.playerCaptionsTracklistRenderer.audioTracks) { + audioTracks.push({ + languageName: track.displayName || track.id, + languageCode: track.id, + }); + } + } else if (captionsRaw.playerCaptionsTracklistRenderer?.captionTracks) { + // Fallback: extract unique languages from caption tracks + const uniqueLangs = new Set(); + for (const track of captionsRaw.playerCaptionsTracklistRenderer.captionTracks) { + const langCode = track.languageCode; + if (!uniqueLangs.has(langCode)) { + uniqueLangs.add(langCode); + audioTracks.push({ + languageName: track.name?.simpleText || langCode, + languageCode: langCode, + }); + } + } + } + + // Map formats directly from streamingData + const formatsArray = []; + if (streamingDataRaw.formats) { + for (const format of streamingDataRaw.formats) { + const formatObj: any = { + itag: format.itag, + url: format.url, + mimeType: format.mimeType, + bitrate: format.bitrate, + width: format.width || 0, + height: format.height || 0, + lastModified: format.lastModified, + contentLength: format.contentLength, + quality: format.quality, + fps: format.fps, + qualityLabel: format.qualityLabel, + projectionType: format.projectionType || "RECTANGULAR", + averageBitrate: format.averageBitrate, + approxDurationMs: format.approxDurationMs, + }; + + if (format.audioQuality) formatObj.audioQuality = format.audioQuality; + if (format.audioSampleRate) formatObj.audioSampleRate = format.audioSampleRate; + if (format.audioChannels) formatObj.audioChannels = format.audioChannels; + if (format.qualityLabel) { + formatObj.qualityOrdinal = "QUALITY_ORDINAL_" + format.qualityLabel.replace(/\d+/, "").replace('p', 'P'); + } + + formatsArray.push(formatObj); + } + } + + // Map adaptiveFormats directly from streamingData + const adaptiveFormatsArray = []; + if (streamingDataRaw.adaptiveFormats) { + for (const format of streamingDataRaw.adaptiveFormats) { + const adaptiveFormat: any = { + itag: format.itag, + url: format.url, + mimeType: format.mimeType, + bitrate: format.bitrate, + width: format.width || 0, + height: format.height || 0, + lastModified: format.lastModified, + contentLength: format.contentLength, + quality: format.quality, + fps: format.fps, + qualityLabel: format.qualityLabel, + projectionType: format.projectionType || "RECTANGULAR", + averageBitrate: format.averageBitrate, + approxDurationMs: format.approxDurationMs, + }; + + if (format.initRange) { + adaptiveFormat.initRange = { + start: format.initRange.start, + end: format.initRange.end, + }; + } + if (format.indexRange) { + adaptiveFormat.indexRange = { + start: format.indexRange.start, + end: format.indexRange.end, + }; + } + + if (format.audioQuality) adaptiveFormat.audioQuality = format.audioQuality; + if (format.audioSampleRate) adaptiveFormat.audioSampleRate = format.audioSampleRate; + if (format.audioChannels) adaptiveFormat.audioChannels = format.audioChannels; + if (format.colorInfo) adaptiveFormat.colorInfo = format.colorInfo; + if (format.highReplication) adaptiveFormat.highReplication = format.highReplication; + if (format.loudnessDb !== undefined) adaptiveFormat.loudnessDb = format.loudnessDb; + + if (format.qualityLabel) { + adaptiveFormat.qualityOrdinal = "QUALITY_ORDINAL_" + format.qualityLabel.replace(/\d+/, "").replace('p', 'P'); + } else { + adaptiveFormat.qualityOrdinal = "QUALITY_ORDINAL_UNKNOWN"; + } + + adaptiveFormatsArray.push(adaptiveFormat); + } + } + + const currentTimestamp = Math.floor(Date.now() / 1000); + + const response = { + status: playabilityStatus.status || "OK", + id: videoDetails.videoId || videoId, + title: videoDetails.title || "", + lengthSeconds: videoDetails.lengthSeconds || "0", + keywords: videoDetails.keywords || [], + channelTitle: videoDetails.author || "", + channelId: videoDetails.channelId || "", + description: videoDetails.shortDescription || "", + thumbnail: thumbnailArray, + allowRatings: videoDetails.allowRatings ?? true, + viewCount: videoDetails.viewCount || "0", + isPrivate: videoDetails.isPrivate || false, + isUnpluggedCorpus: videoDetails.isUnpluggedCorpus || false, + isLiveContent: videoDetails.isLiveContent || false, + storyboards: storyboardsArray, + captions: { + captionTracks: captionTracks, + }, + audioTracks: audioTracks, + defaultVideoLanguage: microformat.defaultLanguage || "English", + defaultVideoLanguageCode: microformat.defaultLanguage || "en", + fetchedTS: currentTimestamp, + expiresInSeconds: streamingDataRaw.expiresInSeconds || "21540", + formats: formatsArray, + isGCR: false, + adaptiveFormats: adaptiveFormatsArray, + availableAt: currentTimestamp, + }; + + return c.json(response); +}); + +export default videos; diff --git a/src/routes/metrics.ts b/src/routes/metrics.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e0eea87af0c2de0f1df9a076429446385337e09 --- /dev/null +++ b/src/routes/metrics.ts @@ -0,0 +1,11 @@ +import { Hono } from "hono"; + +const metrics = new Hono(); + +metrics.get("/", async (c) => { + return new Response(await c.get("metrics")?.register.metrics(), { + headers: { "Content-Type": "text/plain" }, + }); +}); + +export default metrics; diff --git a/src/routes/videoPlaybackProxy.ts b/src/routes/videoPlaybackProxy.ts new file mode 100644 index 0000000000000000000000000000000000000000..542bfaab062011b30c107d54459692bea1ea2eb6 --- /dev/null +++ b/src/routes/videoPlaybackProxy.ts @@ -0,0 +1,240 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { encodeRFC5987ValueChars } from "../lib/helpers/encodeRFC5987ValueChars.ts"; +import { decryptQuery } from "../lib/helpers/encryptQuery.ts"; +import { StreamingApi } from "hono/utils/stream"; + +let getFetchClientLocation = "getFetchClient"; +if (Deno.env.get("GET_FETCH_CLIENT_LOCATION")) { + if (Deno.env.has("DENO_COMPILED")) { + getFetchClientLocation = Deno.mainModule.replace("src/main.ts", "") + + Deno.env.get("GET_FETCH_CLIENT_LOCATION"); + } else { + getFetchClientLocation = Deno.env.get( + "GET_FETCH_CLIENT_LOCATION", + ) as string; + } +} +const { getFetchClient } = await import(getFetchClientLocation); + +const videoPlaybackProxy = new Hono(); + +videoPlaybackProxy.options("/", () => { + const headersForResponse: Record = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, OPTIONS", + "access-control-allow-headers": "Content-Type, Range", + }; + return new Response("OK", { + status: 200, + headers: headersForResponse, + }); +}); + +videoPlaybackProxy.get("/", async (c) => { + const { c: client, expire, title } = c.req.query(); + const urlReq = new URL(c.req.url); + const config = c.get("config"); + const queryParams = new URLSearchParams(urlReq.search); + + if (c.req.query("enc") === "true") { + const { data: encryptedQuery } = c.req.query(); + const decryptedQueryParams = decryptQuery(encryptedQuery, config); + const parsedDecryptedQueryParams = new URLSearchParams( + JSON.parse(decryptedQueryParams), + ); + queryParams.delete("enc"); + queryParams.delete("data"); + queryParams.set("pot", parsedDecryptedQueryParams.get("pot") as string); + queryParams.set("ip", parsedDecryptedQueryParams.get("ip") as string); + } + + + + if ( + expire == undefined || + Number(expire) < Number(Date.now().toString().slice(0, -3)) + ) { + throw new HTTPException(400, { + res: new Response( + "Expire query string undefined or videoplayback URL has expired.", + ), + }); + } + + if (client == undefined) { + throw new HTTPException(400, { + res: new Response("'c' query string undefined."), + }); + } + + + queryParams.delete("title"); + + const rangeHeader = c.req.header("range"); + const requestBytes = rangeHeader ? rangeHeader.split("=")[1] : null; + const [firstByte, lastByte] = requestBytes?.split("-") || []; + if (requestBytes) { + queryParams.append( + "range", + requestBytes, + ); + } + + const headersToSend: HeadersInit = { + "accept": "*/*", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "en-us,en;q=0.5", + "origin": "https://www.youtube.com", + "referer": "https://www.youtube.com", + }; + + if (client == "ANDROID") { + headersToSend["user-agent"] = + "com.google.android.youtube/1537338816 (Linux; U; Android 13; en_US; ; Build/TQ2A.230505.002; Cronet/113.0.5672.24)"; + } else if (client == "IOS") { + headersToSend["user-agent"] = + "com.google.ios.youtube/19.32.8 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)"; + } else { + headersToSend["user-agent"] = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + } + + const fetchClient = await getFetchClient(config); + + let headResponse: Response | undefined; + let location = `https://redirector.googlevideo.com/videoplayback?${queryParams.toString()}`; + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-p2-semantics-17#section-7.3 + // A maximum of 5 redirections is defined in the note of the section 7.3 + // of this RFC, that's why `i < 5` + for (let i = 0; i < 5; i++) { + const googlevideoResponse: Response = await fetchClient(location, { + method: "HEAD", + headers: headersToSend, + redirect: "manual", + }); + if (googlevideoResponse.status == 403) { + return new Response(googlevideoResponse.body, { + status: googlevideoResponse.status, + statusText: googlevideoResponse.statusText, + }); + } + if (googlevideoResponse.headers.has("Location")) { + location = googlevideoResponse.headers.get("Location") as string; + continue; + } else { + headResponse = googlevideoResponse; + break; + } + } + if (headResponse === undefined) { + throw new HTTPException(502, { + res: new Response( + "Google headResponse redirected too many times", + ), + }); + } + + // =================== REQUEST CHUNKING ======================= + // if the requested response is larger than the chunkSize, break up the response + // into chunks and stream the response back to the client to avoid rate limiting + const { readable, writable } = new TransformStream(); + const stream = new StreamingApi(writable, readable); + const googleVideoUrl = new URL(location); + const getChunk = async (start: number, end: number) => { + googleVideoUrl.searchParams.set( + "range", + `${start}-${end}`, + ); + const postResponse = await fetchClient(googleVideoUrl, { + method: "POST", + body: new Uint8Array([0x78, 0]), // protobuf: { 15: 0 } (no idea what it means but this is what YouTube uses), + headers: headersToSend, + }); + if (postResponse.status !== 200) { + throw new Error("Non-200 response from google servers"); + } + await stream.pipe(postResponse.body); + }; + + const chunkSize = + config.networking.videoplayback.video_fetch_chunk_size_mb * 1_000_000; + const totalBytes = Number( + headResponse.headers.get("Content-Length") || "0", + ); + + // if no range sent, the client wants thw whole file, i.e. for downloads + const wholeRequestStartByte = Number(firstByte || "0"); + const wholeRequestEndByte = wholeRequestStartByte + Number(totalBytes) - 1; + + let chunk = Promise.resolve(); + for ( + let startByte = wholeRequestStartByte; + startByte < wholeRequestEndByte; + startByte += chunkSize + ) { + // i.e. + // 0 - 4_999_999, then + // 5_000_000 - 9_999_999, then + // 10_000_000 - 14_999_999 + let endByte = startByte + chunkSize - 1; + if (endByte > wholeRequestEndByte) { + endByte = wholeRequestEndByte; + } + chunk = chunk.then(() => getChunk(startByte, endByte)); + } + chunk.catch(() => { + stream.abort(); + }); + // =================== REQUEST CHUNKING ======================= + + const headersForResponse: Record = { + "content-length": headResponse.headers.get("content-length") || "", + "access-control-allow-origin": "*", + "accept-ranges": headResponse.headers.get("accept-ranges") || "", + "content-type": headResponse.headers.get("content-type") || "", + "expires": headResponse.headers.get("expires") || "", + "last-modified": headResponse.headers.get("last-modified") || "", + }; + + if (title) { + headersForResponse["content-disposition"] = `attachment; filename="${encodeURIComponent(title) + }"; filename*=UTF-8''${encodeRFC5987ValueChars(title)}`; + } + + let responseStatus = headResponse.status; + if (requestBytes && responseStatus == 200) { + // check for range headers in the forms: + // "bytes=0-" get full length from start + // "bytes=500-" get full length from 500 bytes in + // "bytes=500-1000" get 500 bytes starting from 500 + if (lastByte) { + responseStatus = 206; + headersForResponse["content-range"] = `bytes ${requestBytes}/${queryParams.get("clen") || "*" + }`; + } else { + // i.e. "bytes=0-", "bytes=600-" + // full size of content is able to be calculated, so a full Content-Range header can be constructed + const bytesReceived = headersForResponse["content-length"]; + // last byte should always be one less than the length + const totalContentLength = Number(firstByte) + + Number(bytesReceived); + const lastByte = totalContentLength - 1; + if (firstByte !== "0") { + // only part of the total content returned, 206 + responseStatus = 206; + } + headersForResponse["content-range"] = + `bytes ${firstByte}-${lastByte}/${totalContentLength}`; + } + } + + return new Response(stream.responseReadable, { + status: responseStatus, + statusText: headResponse.statusText, + headers: headersForResponse, + }); +}); + +export default videoPlaybackProxy; diff --git a/src/routes/youtube_api_routes/player.ts b/src/routes/youtube_api_routes/player.ts new file mode 100644 index 0000000000000000000000000000000000000000..f416fa6d1021b64c629243d4f2c2f427a109e9d8 --- /dev/null +++ b/src/routes/youtube_api_routes/player.ts @@ -0,0 +1,54 @@ +import { Hono } from "hono"; +import { youtubePlayerParsing } from "../../lib/helpers/youtubePlayerHandling.ts"; +import { HTTPException } from "hono/http-exception"; +import { validateVideoId } from "../../lib/helpers/validateVideoId.ts"; +import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts"; + +const player = new Hono(); + +player.post("/player", async (c) => { + const jsonReq = await c.req.json(); + const innertubeClient = c.get("innertubeClient"); + const config = c.get("config"); + const metrics = c.get("metrics"); + const tokenMinter = c.get("tokenMinter"); + + // Check if tokenMinter is ready (only needed when PO token is enabled) + if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) { + return c.json({ + playabilityStatus: { + status: "ERROR", + reason: TOKEN_MINTER_NOT_READY_MESSAGE, + errorScreen: { + playerErrorMessageRenderer: { + reason: { + simpleText: TOKEN_MINTER_NOT_READY_MESSAGE, + }, + subreason: { + simpleText: TOKEN_MINTER_NOT_READY_MESSAGE, + }, + }, + }, + }, + }); + } + + if (jsonReq.videoId) { + if (!validateVideoId(jsonReq.videoId)) { + throw new HTTPException(400, { + res: new Response("Invalid video ID format."), + }); + } + return c.json( + await youtubePlayerParsing({ + innertubeClient, + videoId: jsonReq.videoId, + config, + tokenMinter: tokenMinter!, + metrics, + }), + ); + } +}); + +export default player; diff --git a/src/tests/dashManifest.ts b/src/tests/dashManifest.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ce72a1a2572524ae62bce7b88842314d255934e --- /dev/null +++ b/src/tests/dashManifest.ts @@ -0,0 +1,13 @@ +import { assertEquals } from "./deps.ts"; + +export async function dashManifest(baseUrl: string) { + const resp = await fetch( + `${baseUrl}/api/manifest/dash/id/jNQXAC9IVRw?local=true&unique_res=1`, + { + method: "GET", + }, + ); + + await resp.body?.cancel(); + assertEquals(resp.status, 200, "response status code is not 200"); +} diff --git a/src/tests/deps.ts b/src/tests/deps.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5a599d6d4b7899daa4c0c09a58bcb13e2cb84ae --- /dev/null +++ b/src/tests/deps.ts @@ -0,0 +1,6 @@ +export { + assert, + assertEquals, + assertRejects, + assertThrows, +} from "jsr:@std/assert@1.0.12"; diff --git a/src/tests/ipv6Rotation_test.ts b/src/tests/ipv6Rotation_test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c31a7cb1bcfaacc47a1ba8acf1660f81f1e9b04c --- /dev/null +++ b/src/tests/ipv6Rotation_test.ts @@ -0,0 +1,70 @@ +import { assertEquals, assertThrows } from "./deps.ts"; +import { generateRandomIPv6 } from "../lib/helpers/ipv6Rotation.ts"; + +Deno.test("generateRandomIPv6 - generates valid IPv6 addresses", () => { + const ipv6Block = "2001:db8::/32"; + + // Generate multiple addresses to ensure randomness + const addresses = new Set(); + for (let i = 0; i < 100; i++) { + const addr = generateRandomIPv6(ipv6Block); + addresses.add(addr); + + // Verify the address starts with the correct prefix + // For /32 block, the first 32 bits should match + // 2001:db8 = first 32 bits + const parts = addr.split(":"); + assertEquals(parts[0], "2001"); + assertEquals(parts[1], "db8"); + } + + // Ensure we got different addresses (high probability with randomization) + // At least 50 unique addresses out of 100 should be generated + assertEquals(addresses.size > 50, true); +}); + +Deno.test("generateRandomIPv6 - handles different block sizes", () => { + // Test with /32 block + const addr32 = generateRandomIPv6("2001:db8::/32"); + const parts32 = addr32.split(":"); + assertEquals(parts32[0], "2001"); + assertEquals(parts32[1], "db8"); + + // Test with /48 block + const addr48 = generateRandomIPv6("2001:db8:1234::/48"); + const parts48 = addr48.split(":"); + assertEquals(parts48[0], "2001"); + assertEquals(parts48[1], "db8"); + assertEquals(parts48[2], "1234"); + + // Test with /64 block + const addr64 = generateRandomIPv6("2001:db8::/64"); + const parts64 = addr64.split(":"); + assertEquals(parts64[0], "2001"); + assertEquals(parts64[1], "db8"); +}); + +Deno.test("generateRandomIPv6 - throws error for invalid block size", () => { + assertThrows( + () => generateRandomIPv6("2001:db8::/129"), + Error, + "Invalid IPv6 block size", + ); + + assertThrows( + () => generateRandomIPv6("2001:db8::/0"), + Error, + "Invalid IPv6 block size", + ); +}); + +Deno.test("generateRandomIPv6 - handles compressed IPv6 notation", () => { + const ipv6Block = "2001:db8::/32"; + const addr = generateRandomIPv6(ipv6Block); + + // Address should be valid IPv6 + const parts = addr.split(":"); + assertEquals(parts.length >= 3, true); // At least some parts should be present + assertEquals(parts[0], "2001"); + assertEquals(parts[1], "db8"); +}); diff --git a/src/tests/latestVersion.ts b/src/tests/latestVersion.ts new file mode 100644 index 0000000000000000000000000000000000000000..411647d0988cf5cbe7a964c7397dfdb52b32dc2d --- /dev/null +++ b/src/tests/latestVersion.ts @@ -0,0 +1,14 @@ +import { assertEquals } from "./deps.ts"; + +export async function latestVersion(baseUrl: string) { + const resp = await fetch( + `${baseUrl}/latest_version?id=jNQXAC9IVRw&itag=18&local=true`, + { + method: "GET", + redirect: "manual", + }, + ); + + await resp.body?.cancel(); + assertEquals(resp.status, 302); +} diff --git a/src/tests/main_test.ts b/src/tests/main_test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d455719cfd13cb6356933febcd79535193a4a91 --- /dev/null +++ b/src/tests/main_test.ts @@ -0,0 +1,47 @@ +Deno.env.set("SERVER_SECRET_KEY", "aaaaaaaaaaaaaaaa"); +const { run, tokenMinterReady } = await import("../main.ts"); + +const { parseConfig } = await import("../lib/helpers/config.ts"); +const config = await parseConfig(); + +import { dashManifest } from "./dashManifest.ts"; +import { youtubePlayer } from "./youtubePlayer.ts"; +import { latestVersion } from "./latestVersion.ts"; + +Deno.test({ + name: "Checking if Invidious companion works", + async fn(t) { + const controller = new AbortController(); + const baseUrl = + `http://${config.server.host}:${config.server.port.toString()}${config.server.base_path}`; + const headers = { Authorization: "Bearer aaaaaaaaaaaaaaaa" }; + + await run( + controller.signal, + config.server.port, + config.server.host, + ); + + // Wait for tokenMinter to be ready before running tests + await tokenMinterReady; + + await t.step( + "Check if it can get an OK playabilityStatus on /youtubei/v1/player", + youtubePlayer.bind(null, baseUrl, headers), + ); + + await t.step( + "Check if it can generate a DASH manifest", + dashManifest.bind(null, baseUrl), + ); + + await t.step( + "Check if it can generate a valid URL for latest_version", + latestVersion.bind(null, baseUrl), + ); + + await controller.abort(); + }, + // need to disable leaks test for now because we are leaking resources when using HTTPClient using a proxy + sanitizeResources: false, +}); diff --git a/src/tests/secret_key_validation_test.ts b/src/tests/secret_key_validation_test.ts new file mode 100644 index 0000000000000000000000000000000000000000..24f9fa29f1bc2fad620cd6c9cfd7d9703ec70278 --- /dev/null +++ b/src/tests/secret_key_validation_test.ts @@ -0,0 +1,237 @@ +/** + * Test for secret key validation in the actual Invidious companion configuration + * This test verifies that SERVER_SECRET_KEY validation properly rejects special characters + * when the actual config is parsed + */ +import { assert, assertEquals } from "./deps.ts"; +import { parseConfig } from "../lib/helpers/config.ts"; + +Deno.test("Secret key validation in Invidious companion config", async (t) => { + // Clean up any existing environment variables that might interfere + Deno.env.delete("SERVER_SECRET_KEY"); + + await t.step("accepts valid alphanumeric keys", async () => { + const validKeys = [ + "aaaaaaaaaaaaaaaa", // all lowercase + "AAAAAAAAAAAAAAAA", // all uppercase + "1234567890123456", // all numbers + "Aa1Bb2Cc3Dd4Ee5F", // mixed case + "ABC123DEF456789A", // mixed letters and numbers + ]; + + for (const key of validKeys) { + // Set the environment variable for each test + Deno.env.set("SERVER_SECRET_KEY", key); + + try { + const config = await parseConfig(); + assertEquals( + config.server.secret_key, + key, + `Key "${key}" should be accepted and stored correctly`, + ); + } catch (error) { + assert( + false, + `Key "${key}" should be valid but config parsing failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + }); + + await t.step("rejects keys with special characters", async () => { + const invalidKeys = [ + "my#key!123456789", // Contains # and ! + "test@key12345678", // Contains @ (fixed length) + "key-with-dashes1", // Contains - + "key_with_under_s", // Contains _ + "key with spaces1", // Contains spaces (fixed length to 16) + "key$with$dollar$", // Contains $ + "key+with+plus+12", // Contains + + "key=with=equals=", // Contains = + "key(with)parens1", // Contains () + "key[with]bracket", // Contains [] + ]; + + for (const key of invalidKeys) { + // Set the environment variable for each test + Deno.env.set("SERVER_SECRET_KEY", key); + + try { + await parseConfig(); + assert( + false, + `Key "${key}" should be invalid but config parsing succeeded`, + ); + } catch (error) { + // Verify it's a config parsing error with the right message + assert( + error instanceof Error && + error.message.includes("Failed to parse configuration"), + `Should get config parsing error, got: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + // Check that the error contains expected validation message content + const errorStr = error instanceof Error + ? error.toString() + : String(error); + assert( + errorStr.includes( + "SERVER_SECRET_KEY contains invalid characters", + ) || + errorStr.includes("alphanumeric characters"), + `Error should mention invalid characters or alphanumeric, got: ${errorStr}`, + ); + } + } + }); + + await t.step("rejects keys with wrong length", async () => { + const wrongLengthKeys = [ + "short", // Too short + "thiskeyistoolongtobevalid", // Too long + "", // Empty + "a", // Single character + "exactly15chars", // 15 chars + "exactly17charss", // 17 chars + ]; + + for (const key of wrongLengthKeys) { + // Set the environment variable for each test + Deno.env.set("SERVER_SECRET_KEY", key); + + try { + await parseConfig(); + assert( + false, + `Key "${key}" (length ${key.length}) should be invalid but config parsing succeeded`, + ); + } catch (error) { + // Verify it's a config parsing error + assert( + error instanceof Error && + error.message.includes("Failed to parse configuration"), + `Should get config parsing error, got: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + // Check that the error mentions length requirement + const errorStr = error instanceof Error + ? error.toString() + : String(error); + assert( + errorStr.includes("exactly 16 character") || + errorStr.includes( + "String must contain exactly 16 character", + ), + `Error should mention 16 characters, got: ${errorStr}`, + ); + } + } + }); + + await t.step("validates error message content", async () => { + // Test that special character validation provides the right error + Deno.env.set("SERVER_SECRET_KEY", "my#key!123456789"); + + try { + await parseConfig(); + assert(false, "Should have failed with special character key"); + } catch (error) { + const errorStr = error instanceof Error + ? error.toString() + : String(error); + + // Check that the error message contains validation details + assert( + errorStr.includes( + "SERVER_SECRET_KEY contains invalid characters", + ) || + errorStr.includes("alphanumeric characters"), + "Should mention SERVER_SECRET_KEY and character validation", + ); + } + + // Test that length validation still works and provides clear message + Deno.env.set("SERVER_SECRET_KEY", "short"); + + try { + await parseConfig(); + assert(false, "Should have failed with short key"); + } catch (error) { + const errorStr = error instanceof Error + ? error.toString() + : String(error); + assert( + errorStr.includes("exactly 16 character") || + errorStr.includes( + "String must contain exactly 16 character", + ), + `Should mention 16 characters: ${errorStr}`, + ); + } + }); + + await t.step( + "validates precedence - length vs character validation", + async () => { + // When both length and character validation fail, length should be checked first + // This is the default Zod behavior + Deno.env.set("SERVER_SECRET_KEY", "bad#"); + + try { + await parseConfig(); + assert( + false, + "Should have failed with short key containing special chars", + ); + } catch (error) { + const errorStr = error instanceof Error + ? error.toString() + : String(error); + // Should get length error since it's checked first + assert( + errorStr.includes("exactly 16 character") || + errorStr.includes( + "String must contain exactly 16 character", + ), + `Should get length error first: ${errorStr}`, + ); + } + }, + ); + + // Clean up environment variable after tests + await t.step("validates missing SERVER_SECRET_KEY fails", async () => { + // Test with no SERVER_SECRET_KEY set (uses default empty string) + Deno.env.delete("SERVER_SECRET_KEY"); + + try { + await parseConfig(); + assert( + false, + "Should have failed with missing/empty SERVER_SECRET_KEY", + ); + } catch (error) { + const errorStr = error instanceof Error + ? error.toString() + : String(error); + assert( + errorStr.includes("exactly 16 character") || + errorStr.includes( + "String must contain exactly 16 character", + ), + `Should get length error for empty key: ${errorStr}`, + ); + } + }); + + await t.step("cleanup", () => { + Deno.env.delete("SERVER_SECRET_KEY"); + }); +}); diff --git a/src/tests/validateVideoId_test.ts b/src/tests/validateVideoId_test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d176887edbfaf5e9877ead8c7233040ba14421a5 --- /dev/null +++ b/src/tests/validateVideoId_test.ts @@ -0,0 +1,97 @@ +import { assertEquals } from "./deps.ts"; +import { validateVideoId } from "../lib/helpers/validateVideoId.ts"; + +Deno.test("Video ID validation", async (t) => { + await t.step("accepts valid YouTube video IDs", () => { + const validIds = [ + "jNQXAC9IVRw", // Standard video ID from tests + "dQw4w9WgXcQ", // Rick Roll video + "aqz-KE-bpKQ", // Video with hyphens + "A_B_C_D_E_1", // Video with underscores + "0123456789a", // Numbers and letters + "ABCDEFGHIJK", // All uppercase + "abcdefghijk", // All lowercase + "-_-_-_-_-_-", // Hyphens and underscores + ]; + + for (const id of validIds) { + assertEquals( + validateVideoId(id), + true, + `Video ID "${id}" should be valid`, + ); + } + }); + + await t.step("rejects invalid video IDs", () => { + const invalidIds = [ + "", // Empty string + "short", // Too short + "thisistoolongtobeavalidvideoid", // Too long + "exactly10c", // 10 characters (too short) + "exactly12chr", // 12 characters (too long) + "jNQXAC9IVR", // 10 characters + "jNQXAC9IVRwX", // 12 characters + "jNQX AC9IVRw", // Contains space + "jNQX@AC9IVRw", // Contains @ + "jNQX#AC9IVRw", // Contains # + "jNQX!AC9IVRw", // Contains ! + "jNQX$AC9IVRw", // Contains $ + "jNQX%AC9IVRw", // Contains % + "jNQX&AC9IVRw", // Contains & + "jNQX*AC9IVRw", // Contains * + "jNQX(AC9IVRw", // Contains ( + "jNQX)AC9IVRw", // Contains ) + "jNQX=AC9IVRw", // Contains = + "jNQX+AC9IVRw", // Contains + + "jNQX[AC9IVRw", // Contains [ + "jNQX]AC9IVRw", // Contains ] + "jNQX{AC9IVRw", // Contains { + "jNQX}AC9IVRw", // Contains } + "jNQX|AC9IVRw", // Contains | + "jNQX\\AC9IVRw", // Contains \ + "jNQX/AC9IVRw", // Contains / + "jNQX:AC9IVRw", // Contains : + "jNQX;AC9IVRw", // Contains ; + "jNQX'AC9IVRw", // Contains ' + 'jNQX"AC9IVRw', // Contains " + "jNQXAC9IVRw", // Contains > + "jNQX,AC9IVRw", // Contains , + "jNQX.AC9IVRw", // Contains . + "jNQX?AC9IVRw", // Contains ? + "../../../etc", // Path traversal attempt + "'; DROP TABLE", // SQL injection attempt + "