diff --git a/.gitignore b/.gitignore index b408b65..4442328 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ cookies.json docker-compose.yml # ide -.vscode +.vscode \ No newline at end of file diff --git a/api/LICENSE b/api/LICENSE index bfef380..7d1945a 100644 --- a/api/LICENSE +++ b/api/LICENSE @@ -1,437 +1,661 @@ -Attribution-NonCommercial-ShareAlike 4.0 International - -======================================================================= - -Creative Commons Corporation ("Creative Commons") is not a law firm and -does not provide legal services or legal advice. Distribution of -Creative Commons public licenses does not create a lawyer-client or -other relationship. Creative Commons makes its licenses and related -information available on an "as-is" basis. Creative Commons gives no -warranties regarding its licenses, any material licensed under their -terms and conditions, or any related information. Creative Commons -disclaims all liability for damages resulting from their use to the -fullest extent possible. - -Using Creative Commons Public Licenses - -Creative Commons public licenses provide a standard set of terms and -conditions that creators and other rights holders may use to share -original works of authorship and other material subject to copyright -and certain other rights specified in the public license below. The -following considerations are for informational purposes only, are not -exhaustive, and do not form part of our licenses. - - Considerations for licensors: Our public licenses are - intended for use by those authorized to give the public - permission to use material in ways otherwise restricted by - copyright and certain other rights. Our licenses are - irrevocable. Licensors should read and understand the terms - and conditions of the license they choose before applying it. - Licensors should also secure all rights necessary before - applying our licenses so that the public can reuse the - material as expected. Licensors should clearly mark any - material not subject to the license. This includes other CC- - licensed material, or material used under an exception or - limitation to copyright. More considerations for licensors: - wiki.creativecommons.org/Considerations_for_licensors - - Considerations for the public: By using one of our public - licenses, a licensor grants the public permission to use the - licensed material under specified terms and conditions. If - the licensor's permission is not necessary for any reason--for - example, because of any applicable exception or limitation to - copyright--then that use is not regulated by the license. Our - licenses grant only permissions under copyright and certain - other rights that a licensor has authority to grant. Use of - the licensed material may still be restricted for other - reasons, including because others have copyright or other - rights in the material. A licensor may make special requests, - such as asking that all changes be marked or described. - Although not required by our licenses, you are encouraged to - respect those requests where reasonable. More considerations - for the public: - wiki.creativecommons.org/Considerations_for_licensees - -======================================================================= - -Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International -Public License - -By exercising the Licensed Rights (defined below), You accept and agree -to be bound by the terms and conditions of this Creative Commons -Attribution-NonCommercial-ShareAlike 4.0 International Public License -("Public License"). To the extent this Public License may be -interpreted as a contract, You are granted the Licensed Rights in -consideration of Your acceptance of these terms and conditions, and the -Licensor grants You such rights in consideration of benefits the -Licensor receives from making the Licensed Material available under -these terms and conditions. - - -Section 1 -- Definitions. - - a. Adapted Material means material subject to Copyright and Similar - Rights that is derived from or based upon the Licensed Material - and in which the Licensed Material is translated, altered, - arranged, transformed, or otherwise modified in a manner requiring - permission under the Copyright and Similar Rights held by the - Licensor. For purposes of this Public License, where the Licensed - Material is a musical work, performance, or sound recording, - Adapted Material is always produced where the Licensed Material is - synched in timed relation with a moving image. - - b. Adapter's License means the license You apply to Your Copyright - and Similar Rights in Your contributions to Adapted Material in - accordance with the terms and conditions of this Public License. - - c. BY-NC-SA Compatible License means a license listed at - creativecommons.org/compatiblelicenses, approved by Creative - Commons as essentially the equivalent of this Public License. - - d. Copyright and Similar Rights means copyright and/or similar rights - closely related to copyright including, without limitation, - performance, broadcast, sound recording, and Sui Generis Database - Rights, without regard to how the rights are labeled or - categorized. For purposes of this Public License, the rights - specified in Section 2(b)(1)-(2) are not Copyright and Similar - Rights. - - e. Effective Technological Measures means those measures that, in the - absence of proper authority, may not be circumvented under laws - fulfilling obligations under Article 11 of the WIPO Copyright - Treaty adopted on December 20, 1996, and/or similar international - agreements. - - f. Exceptions and Limitations means fair use, fair dealing, and/or - any other exception or limitation to Copyright and Similar Rights - that applies to Your use of the Licensed Material. - - g. License Elements means the license attributes listed in the name - of a Creative Commons Public License. The License Elements of this - Public License are Attribution, NonCommercial, and ShareAlike. - - h. Licensed Material means the artistic or literary work, database, - or other material to which the Licensor applied this Public - License. - - i. Licensed Rights means the rights granted to You subject to the - terms and conditions of this Public License, which are limited to - all Copyright and Similar Rights that apply to Your use of the - Licensed Material and that the Licensor has authority to license. - - j. Licensor means the individual(s) or entity(ies) granting rights - under this Public License. - - k. NonCommercial means not primarily intended for or directed towards - commercial advantage or monetary compensation. For purposes of - this Public License, the exchange of the Licensed Material for - other material subject to Copyright and Similar Rights by digital - file-sharing or similar means is NonCommercial provided there is - no payment of monetary compensation in connection with the - exchange. - - l. Share means to provide material to the public by any means or - process that requires permission under the Licensed Rights, such - as reproduction, public display, public performance, distribution, - dissemination, communication, or importation, and to make material - available to the public including in ways that members of the - public may access the material from a place and at a time - individually chosen by them. - - m. Sui Generis Database Rights means rights other than copyright - resulting from Directive 96/9/EC of the European Parliament and of - the Council of 11 March 1996 on the legal protection of databases, - as amended and/or succeeded, as well as other essentially - equivalent rights anywhere in the world. - - n. You means the individual or entity exercising the Licensed Rights - under this Public License. Your has a corresponding meaning. - - -Section 2 -- Scope. - - a. License grant. - - 1. Subject to the terms and conditions of this Public License, - the Licensor hereby grants You a worldwide, royalty-free, - non-sublicensable, non-exclusive, irrevocable license to - exercise the Licensed Rights in the Licensed Material to: - - a. reproduce and Share the Licensed Material, in whole or - in part, for NonCommercial purposes only; and - - b. produce, reproduce, and Share Adapted Material for - NonCommercial purposes only. - - 2. Exceptions and Limitations. For the avoidance of doubt, where - Exceptions and Limitations apply to Your use, this Public - License does not apply, and You do not need to comply with - its terms and conditions. - - 3. Term. The term of this Public License is specified in Section - 6(a). - - 4. Media and formats; technical modifications allowed. The - Licensor authorizes You to exercise the Licensed Rights in - all media and formats whether now known or hereafter created, - and to make technical modifications necessary to do so. The - Licensor waives and/or agrees not to assert any right or - authority to forbid You from making technical modifications - necessary to exercise the Licensed Rights, including - technical modifications necessary to circumvent Effective - Technological Measures. For purposes of this Public License, - simply making modifications authorized by this Section 2(a) - (4) never produces Adapted Material. - - 5. Downstream recipients. - - a. Offer from the Licensor -- Licensed Material. Every - recipient of the Licensed Material automatically - receives an offer from the Licensor to exercise the - Licensed Rights under the terms and conditions of this - Public License. - - b. Additional offer from the Licensor -- Adapted Material. - Every recipient of Adapted Material from You - automatically receives an offer from the Licensor to - exercise the Licensed Rights in the Adapted Material - under the conditions of the Adapter's License You apply. - - c. No downstream restrictions. You may not offer or impose - any additional or different terms or conditions on, or - apply any Effective Technological Measures to, the - Licensed Material if doing so restricts exercise of the - Licensed Rights by any recipient of the Licensed - Material. - - 6. No endorsement. Nothing in this Public License constitutes or - may be construed as permission to assert or imply that You - are, or that Your use of the Licensed Material is, connected - with, or sponsored, endorsed, or granted official status by, - the Licensor or others designated to receive attribution as - provided in Section 3(a)(1)(A)(i). - - b. Other rights. - - 1. Moral rights, such as the right of integrity, are not - licensed under this Public License, nor are publicity, - privacy, and/or other similar personality rights; however, to - the extent possible, the Licensor waives and/or agrees not to - assert any such rights held by the Licensor to the limited - extent necessary to allow You to exercise the Licensed - Rights, but not otherwise. - - 2. Patent and trademark rights are not licensed under this - Public License. - - 3. To the extent possible, the Licensor waives any right to - collect royalties from You for the exercise of the Licensed - Rights, whether directly or through a collecting society - under any voluntary or waivable statutory or compulsory - licensing scheme. In all other cases the Licensor expressly - reserves any right to collect such royalties, including when - the Licensed Material is used other than for NonCommercial - purposes. - - -Section 3 -- License Conditions. - -Your exercise of the Licensed Rights is expressly made subject to the -following conditions. - - a. Attribution. - - 1. If You Share the Licensed Material (including in modified - form), You must: - - a. retain the following if it is supplied by the Licensor - with the Licensed Material: - - i. identification of the creator(s) of the Licensed - Material and any others designated to receive - attribution, in any reasonable manner requested by - the Licensor (including by pseudonym if - designated); - - ii. a copyright notice; - - iii. a notice that refers to this Public License; - - iv. a notice that refers to the disclaimer of - warranties; - - v. a URI or hyperlink to the Licensed Material to the - extent reasonably practicable; - - b. indicate if You modified the Licensed Material and - retain an indication of any previous modifications; and - - c. indicate the Licensed Material is licensed under this - Public License, and include the text of, or the URI or - hyperlink to, this Public License. - - 2. You may satisfy the conditions in Section 3(a)(1) in any - reasonable manner based on the medium, means, and context in - which You Share the Licensed Material. For example, it may be - reasonable to satisfy the conditions by providing a URI or - hyperlink to a resource that includes the required - information. - 3. If requested by the Licensor, You must remove any of the - information required by Section 3(a)(1)(A) to the extent - reasonably practicable. - - b. ShareAlike. - - In addition to the conditions in Section 3(a), if You Share - Adapted Material You produce, the following conditions also apply. - - 1. The Adapter's License You apply must be a Creative Commons - license with the same License Elements, this version or - later, or a BY-NC-SA Compatible License. - - 2. You must include the text of, or the URI or hyperlink to, the - Adapter's License You apply. You may satisfy this condition - in any reasonable manner based on the medium, means, and - context in which You Share Adapted Material. - - 3. You may not offer or impose any additional or different terms - or conditions on, or apply any Effective Technological - Measures to, Adapted Material that restrict exercise of the - rights granted under the Adapter's License You apply. - - -Section 4 -- Sui Generis Database Rights. - -Where the Licensed Rights include Sui Generis Database Rights that -apply to Your use of the Licensed Material: - - a. for the avoidance of doubt, Section 2(a)(1) grants You the right - to extract, reuse, reproduce, and Share all or a substantial - portion of the contents of the database for NonCommercial purposes - only; - - b. if You include all or a substantial portion of the database - contents in a database in which You have Sui Generis Database - Rights, then the database in which You have Sui Generis Database - Rights (but not its individual contents) is Adapted Material, - including for purposes of Section 3(b); and - - c. You must comply with the conditions in Section 3(a) if You Share - all or a substantial portion of the contents of the database. - -For the avoidance of doubt, this Section 4 supplements and does not -replace Your obligations under this Public License where the Licensed -Rights include other Copyright and Similar Rights. - - -Section 5 -- Disclaimer of Warranties and Limitation of Liability. - - a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE - EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS - AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF - ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, - IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, - WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR - PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, - ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT - KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT - ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. - - b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE - TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, - NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, - INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, - COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR - USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN - ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR - DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR - IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. - - c. The disclaimer of warranties and limitation of liability provided - above shall be interpreted in a manner that, to the extent - possible, most closely approximates an absolute disclaimer and - waiver of all liability. - - -Section 6 -- Term and Termination. - - a. This Public License applies for the term of the Copyright and - Similar Rights licensed here. However, if You fail to comply with - this Public License, then Your rights under this Public License - terminate automatically. - - b. Where Your right to use the Licensed Material has terminated under - Section 6(a), it reinstates: - - 1. automatically as of the date the violation is cured, provided - it is cured within 30 days of Your discovery of the - violation; or - - 2. upon express reinstatement by the Licensor. - - For the avoidance of doubt, this Section 6(b) does not affect any - right the Licensor may have to seek remedies for Your violations - of this Public License. - - c. For the avoidance of doubt, the Licensor may also offer the - Licensed Material under separate terms or conditions or stop - distributing the Licensed Material at any time; however, doing so - will not terminate this Public License. - - d. Sections 1, 5, 6, 7, and 8 survive termination of this Public - License. - - -Section 7 -- Other Terms and Conditions. - - a. The Licensor shall not be bound by any additional or different - terms or conditions communicated by You unless expressly agreed. - - b. Any arrangements, understandings, or agreements regarding the - Licensed Material not stated herein are separate from and - independent of the terms and conditions of this Public License. - - -Section 8 -- Interpretation. - - a. For the avoidance of doubt, this Public License does not, and - shall not be interpreted to, reduce, limit, restrict, or impose - conditions on any use of the Licensed Material that could lawfully - be made without permission under this Public License. - - b. To the extent possible, if any provision of this Public License is - deemed unenforceable, it shall be automatically reformed to the - minimum extent necessary to make it enforceable. If the provision - cannot be reformed, it shall be severed from this Public License - without affecting the enforceability of the remaining terms and - conditions. - - c. No term or condition of this Public License will be waived and no - failure to comply consented to unless expressly agreed to by the - Licensor. - - d. Nothing in this Public License constitutes or may be interpreted - as a limitation upon, or waiver of, any privileges and immunities - that apply to the Licensor or You, including from the legal - processes of any jurisdiction or authority. - -======================================================================= - -Creative Commons is not a party to its public -licenses. Notwithstanding, Creative Commons may elect to apply one of -its public licenses to material it publishes and in those instances -will be considered the “Licensor.” The text of the Creative Commons -public licenses is dedicated to the public domain under the CC0 Public -Domain Dedication. Except for the limited purpose of indicating that -material is shared under a Creative Commons public license or as -otherwise permitted by the Creative Commons policies published at -creativecommons.org/policies, Creative Commons does not authorize the -use of the trademark "Creative Commons" or any other trademark or logo -of Creative Commons without its prior written consent including, -without limitation, in connection with any unauthorized modifications -to any of its public licenses or any other arrangements, -understandings, or agreements concerning use of licensed material. For -the avoidance of doubt, this paragraph does not form part of the -public licenses. - -Creative Commons may be contacted at creativecommons.org. \ No newline at end of file + 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. + + save what you love with cobalt. + Copyright (C) 2024 imput + + 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 +. diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..84c534e --- /dev/null +++ b/api/README.md @@ -0,0 +1,106 @@ +# cobalt api +this directory includes the source code for cobalt api. it's made with [express.js](https://www.npmjs.com/package/express) and love! + +## running your own instance +if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md). +we recommend to use docker compose unless you intend to run cobalt for developing/debugging purposes. + +## accessing the api +there is currently no publicly available pre-hosted api. +we recommend [deploying your own instance](/docs/run-an-instance.md) if you wish to use the cobalt api. + +you can read [the api documentation here](/docs/api.md). + +> [!WARNING] +> the v7 public api (/api/json) will be shut down on **november 11th, 2024**. +> you can access documentation for it [here](https://github.com/imputnet/cobalt/blob/7/docs/api.md). + +## supported services +this list is not final and keeps expanding over time. if support for a service you want is missing, create an issue (or a pull request 👀). + +| service | video + audio | only audio | only video | metadata | rich file names | +| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: | +| bilibili | ✅ | ✅ | ✅ | ➖ | ➖ | +| bluesky | ✅ | ✅ | ✅ | ➖ | ➖ | +| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ | +| instagram | ✅ | ✅ | ✅ | ➖ | ➖ | +| facebook | ✅ | ❌ | ✅ | ➖ | ➖ | +| loom | ✅ | ❌ | ✅ | ✅ | ➖ | +| ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ | +| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ | +| reddit | ✅ | ✅ | ✅ | ❌ | ❌ | +| rutube | ✅ | ✅ | ✅ | ✅ | ✅ | +| snapchat | ✅ | ✅ | ✅ | ➖ | ➖ | +| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ | +| streamable | ✅ | ✅ | ✅ | ➖ | ➖ | +| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ | +| tumblr | ✅ | ✅ | ✅ | ➖ | ➖ | +| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ | +| twitter/x | ✅ | ✅ | ✅ | ➖ | ➖ | +| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ | +| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ | +| youtube | ✅ | ✅ | ✅ | ✅ | ✅ | + +| emoji | meaning | +| :-----: | :---------------------- | +| ✅ | supported | +| ➖ | impossible/unreasonable | +| ❌ | not supported | + +### additional notes or features (per service) +| service | notes or features | +| :-------- | :----- | +| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. | +| facebook | supports public accessible videos content only. | +| pinterest | supports photos, gifs, videos and stories. | +| reddit | supports gifs and videos. | +| snapchat | supports spotlights and stories. lets you pick what to save from stories. | +| rutube | supports yappy & private links. | +| soundcloud | supports private links. | +| tiktok | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios. | +| twitter/x | lets you pick what to save from multi-media posts. may not be 100% reliable due to current management. | +| vimeo | audio downloads are only available for dash. | +| youtube | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. | + +## license +cobalt api code is licensed under [AGPL-3.0](LICENSE). + +this license allows you to modify, distribute and use the code for any purpose +as long as you: +- give appropriate credit to the original repo when using or modifying any parts of the code, +- provide a link to the license and indicate if changes to the code were made, and +- release the code under the **same license** + +## acknowledgements +### ffmpeg +cobalt heavily relies on ffmpeg for converting and merging media files. it's an absolutely amazing piece of software offered for anyone for free, yet doesn't receive as much credit as it should. + +you can [support ffmpeg here](https://ffmpeg.org/donations.html)! + +#### ffmpeg-static +we use [ffmpeg-static](https://github.com/eugeneware/ffmpeg-static) to get binaries for ffmpeg depending on the platform. + +you can support the developer via various methods listed on their github page! (linked above) + +### youtube.js +cobalt relies on [youtube.js](https://github.com/LuanRT/YouTube.js) for interacting with the innertube api, it wouldn't have been possible without it. + +you can support the developer via various methods listed on their github page! (linked above) + +### many others +cobalt also depends on: + +- [content-disposition-header](https://www.npmjs.com/package/content-disposition-header) to simplify the provision of `content-disposition` headers. +- [cors](https://www.npmjs.com/package/cors) to manage cross-origin resource sharing within expressjs. +- [dotenv](https://www.npmjs.com/package/dotenv) to load environment variables from the `.env` file. +- [express](https://www.npmjs.com/package/express) as the backbone of cobalt servers. +- [express-rate-limit](https://www.npmjs.com/package/express-rate-limit) to rate limit api endpoints. +- [hls-parser](https://www.npmjs.com/package/hls-parser) to parse `m3u8` playlists for certain services. +- [ipaddr.js](https://www.npmjs.com/package/ipaddr.js) to parse ip addresses (for rate limiting). +- [nanoid](https://www.npmjs.com/package/nanoid) to generate unique (temporary) identifiers for each requested stream. +- [psl](https://www.npmjs.com/package/psl) as the domain name parser. +- [set-cookie-parser](https://www.npmjs.com/package/set-cookie-parser) to parse cookies that cobalt receives from certain services. +- [undici](https://www.npmjs.com/package/undici) for making http requests. +- [url-pattern](https://www.npmjs.com/package/url-pattern) to match provided links with supported patterns. + +...and many other packages that these packages rely on. diff --git a/api/package.json b/api/package.json index 887a3d1..2205e1c 100644 --- a/api/package.json +++ b/api/package.json @@ -1,45 +1,50 @@ { - "name": "teamhydra-videodl-api", - "description": "Team Hydra's video downloader (API)", - "version": "10.0.0", - "author": "Team Hydra", + "name": "@imput/cobalt-api", + "description": "save what you love", + "version": "10.5.2", + "author": "imput", "exports": "./src/cobalt.js", "type": "module", "engines": { "node": ">=18" }, "scripts": { - "start": "node src/thvideodl", - "setup": "node src/util/setup", + "start": "node src/cobalt", "test": "node src/util/test", - "token:youtube": "node src/util/generate-youtube-tokens" + "token:youtube": "node src/util/generate-youtube-tokens", + "token:jwt": "node src/util/generate-jwt-secret" }, "repository": { "type": "git", - "url": "git+https://teamhydra.io/Team-Hydra/Video-Downloader.git" + "url": "git+https://github.com/imputnet/cobalt.git" }, "license": "AGPL-3.0", + "bugs": { + "url": "https://github.com/imputnet/cobalt/issues" + }, + "homepage": "https://github.com/imputnet/cobalt#readme", "dependencies": { + "@datastructures-js/priority-queue": "^6.3.1", + "@imput/psl": "^2.0.4", "@imput/version-info": "workspace:^", "content-disposition-header": "0.6.0", "cors": "^2.8.5", "dotenv": "^16.0.1", - "esbuild": "^0.14.51", - "express": "^4.18.1", - "express-rate-limit": "^6.3.0", + "express": "^4.21.2", + "express-rate-limit": "^7.4.1", "ffmpeg-static": "^5.1.0", "hls-parser": "^0.10.7", - "ipaddr.js": "2.1.0", - "nanoid": "^4.0.2", - "node-cache": "^5.1.2", - "psl": "1.9.0", + "ipaddr.js": "2.2.0", + "nanoid": "^5.0.9", "set-cookie-parser": "2.6.0", "undici": "^5.19.1", "url-pattern": "1.0.3", - "youtubei.js": "^10.3.0", + "youtubei.js": "^12.2.0", "zod": "^3.23.8" }, "optionalDependencies": { - "freebind": "^0.2.2" + "freebind": "^0.2.2", + "rate-limit-redis": "^4.2.0", + "redis": "^4.7.0" } } diff --git a/api/src/cobalt.js b/api/src/cobalt.js new file mode 100644 index 0000000..5cac208 --- /dev/null +++ b/api/src/cobalt.js @@ -0,0 +1,32 @@ +import "dotenv/config"; + +import express from "express"; +import cluster from "node:cluster"; + +import path from "path"; +import { fileURLToPath } from "url"; + +import { env, isCluster } from "./config.js" +import { Red } from "./misc/console-text.js"; +import { initCluster } from "./misc/cluster.js"; + +const app = express(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename).slice(0, -4); + +app.disable("x-powered-by"); + +if (env.apiURL) { + const { runAPI } = await import("./core/api.js"); + + if (isCluster) { + await initCluster(); + } + + runAPI(express, app, __dirname, cluster.isPrimary); +} else { + console.log( + Red("API_URL env variable is missing, cobalt api can't start.") + ) +} diff --git a/api/src/config.js b/api/src/config.js index 5ea07d0..191e844 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -1,20 +1,20 @@ -import { getVersion } from '@imput/version-info'; -import { services } from './processing/service-config.js'; +import { getVersion } from "@imput/version-info"; +import { services } from "./processing/service-config.js"; +import { supportsReusePort } from "./misc/cluster.js"; const version = await getVersion(); const disabledServices = process.env.DISABLED_SERVICES?.split(',') || []; -const enabledServices = new Set( - Object.keys(services).filter((e) => { - if (!disabledServices.includes(e)) { - return e; - } - }), -); +const enabledServices = new Set(Object.keys(services).filter(e => { + if (!disabledServices.includes(e)) { + return e; + } +})); const env = { apiURL: process.env.API_URL || '', apiPort: process.env.API_PORT || 9000, + tunnelPort: process.env.API_PORT || 9000, listenAddress: process.env.API_LISTEN_ADDRESS, freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR, @@ -24,39 +24,58 @@ const env = { cookiePath: process.env.COOKIE_PATH, - rateLimitWindow: - (process.env.RATELIMIT_WINDOW && - parseInt(process.env.RATELIMIT_WINDOW)) || - 60, - rateLimitMax: - (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || - 20, + rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60, + rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20, - durationLimit: - (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || - 10800, - streamLifespan: 90, + durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800, + streamLifespan: (process.env.TUNNEL_LIFESPAN && parseInt(process.env.TUNNEL_LIFESPAN)) || 90, - processingPriority: - process.platform !== 'win32' && - process.env.PROCESSING_PRIORITY && - parseInt(process.env.PROCESSING_PRIORITY), + processingPriority: process.platform !== 'win32' + && process.env.PROCESSING_PRIORITY + && parseInt(process.env.PROCESSING_PRIORITY), externalProxy: process.env.API_EXTERNAL_PROXY, + turnstileSitekey: process.env.TURNSTILE_SITEKEY, turnstileSecret: process.env.TURNSTILE_SECRET, jwtSecret: process.env.JWT_SECRET, jwtLifetime: process.env.JWT_EXPIRY || 120, + sessionEnabled: process.env.TURNSTILE_SITEKEY + && process.env.TURNSTILE_SECRET + && process.env.JWT_SECRET, + + apiKeyURL: process.env.API_KEY_URL && new URL(process.env.API_KEY_URL), + authRequired: process.env.API_AUTH_REQUIRED === '1', + redisURL: process.env.API_REDIS_URL, + instanceCount: (process.env.API_INSTANCE_COUNT && parseInt(process.env.API_INSTANCE_COUNT)) || 1, + keyReloadInterval: 900, + enabledServices, -}; +} + +const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"; +const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`; + +export const setTunnelPort = (port) => env.tunnelPort = port; +export const isCluster = env.instanceCount > 1; + +if (env.sessionEnabled && env.jwtSecret.length < 16) { + throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)"); +} -const genericUserAgent = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'; -const teamHydraUserAgent = `teamhydravideodl/${version} (support@hep.gg)`; +if (env.instanceCount > 1 && !env.redisURL) { + throw new Error("API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2"); +} else if (env.instanceCount > 1 && !await supportsReusePort()) { + console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js'); + console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux'); + console.error('(or other OS that supports it). for more info, see `reusePort` option on'); + console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback'); + throw new Error('SO_REUSEPORT is not supported'); +} export { env, genericUserAgent, - teamHydraUserAgent as cobaltUserAgent, // lol -}; + cobaltUserAgent, +} diff --git a/api/src/core/api.js b/api/src/core/api.js index 1ed8ed4..153f2ca 100644 --- a/api/src/core/api.js +++ b/api/src/core/api.js @@ -1,168 +1,196 @@ -import cors from 'cors'; -import rateLimit from 'express-rate-limit'; -import { setGlobalDispatcher, ProxyAgent } from 'undici'; -import { - getCommit, - getBranch, - getRemote, - getVersion, -} from '@imput/version-info'; - -import jwt from '../security/jwt.js'; -import stream from '../stream/stream.js'; -import match from '../processing/match.js'; - -import { env } from '../config.js'; -import { extract } from '../processing/url.js'; -import { languageCode } from '../misc/utils.js'; -import { Bright, Cyan } from '../misc/console-text.js'; -import { generateHmac, generateSalt } from '../misc/crypto.js'; -import { randomizeCiphers } from '../misc/randomize-ciphers.js'; -import { verifyTurnstileToken } from '../security/turnstile.js'; -import { friendlyServiceName } from '../processing/service-alias.js'; -import { verifyStream, getInternalStream } from '../stream/manage.js'; -import { - createResponse, - normalizeRequest, - getIP, -} from '../processing/request.js'; +import cors from "cors"; +import http from "node:http"; +import rateLimit from "express-rate-limit"; +import { setGlobalDispatcher, ProxyAgent } from "undici"; +import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info"; + +import jwt from "../security/jwt.js"; +import stream from "../stream/stream.js"; +import match from "../processing/match.js"; + +import { env, isCluster, setTunnelPort } from "../config.js"; +import { extract } from "../processing/url.js"; +import { Green, Bright, Cyan } from "../misc/console-text.js"; +import { hashHmac } from "../security/secrets.js"; +import { createStore } from "../store/redis-ratelimit.js"; +import { randomizeCiphers } from "../misc/randomize-ciphers.js"; +import { verifyTurnstileToken } from "../security/turnstile.js"; +import { friendlyServiceName } from "../processing/service-alias.js"; +import { verifyStream, getInternalStream } from "../stream/manage.js"; +import { createResponse, normalizeRequest, getIP } from "../processing/request.js"; +import * as APIKeys from "../security/api-keys.js"; +import * as Cookies from "../processing/cookie/manager.js"; const git = { branch: await getBranch(), commit: await getCommit(), remote: await getRemote(), -}; +} const version = await getVersion(); const acceptRegex = /^application\/json(; charset=utf-8)?$/; -const ipSalt = generateSalt(); -const corsConfig = env.corsWildcard - ? {} - : { - origin: env.corsURL, - optionsSuccessStatus: 200, - }; +const corsConfig = env.corsWildcard ? {} : { + origin: env.corsURL, + optionsSuccessStatus: 200 +} const fail = (res, code, context) => { - const { status, body } = createResponse('error', { code, context }); + const { status, body } = createResponse("error", { code, context }); res.status(status).json(body); -}; +} -export const runAPI = (express, app, __dirname) => { +export const runAPI = async (express, app, __dirname, isPrimary = true) => { const startTime = new Date(); const startTimestamp = startTime.getTime(); const serverInfo = JSON.stringify({ - videodl: { + cobalt: { version: version, url: env.apiURL, startTime: `${startTimestamp}`, durationLimit: env.durationLimit, - services: [...env.enabledServices].map((e) => { + turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined, + services: [...env.enabledServices].map(e => { return friendlyServiceName(e); }), }, git, + }) + + const handleRateExceeded = (_, res) => { + const { status, body } = createResponse("error", { + code: "error.api.rate_exceeded", + context: { + limit: env.rateLimitWindow + } + }); + return res.status(status).json(body); + }; + + const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url'); + + const sessionLimiter = rateLimit({ + windowMs: 60000, + limit: 10, + standardHeaders: 'draft-6', + legacyHeaders: false, + keyGenerator, + store: await createStore('session'), + handler: handleRateExceeded }); const apiLimiter = rateLimit({ windowMs: env.rateLimitWindow * 1000, - max: env.rateLimitMax, - standardHeaders: true, + limit: (req) => req.rateLimitMax || env.rateLimitMax, + standardHeaders: 'draft-6', legacyHeaders: false, - keyGenerator: (req) => { - if (req.authorized) { - return generateHmac(req.header('Authorization'), ipSalt); - } - return generateHmac(getIP(req), ipSalt); - }, - handler: (req, res) => { - const { status, body } = createResponse('error', { - code: 'error.api.rate_exceeded', - context: { - limit: env.rateLimitWindow, - }, - }); - return res.status(status).json(body); - }, - }); + keyGenerator: req => req.rateLimitKey || keyGenerator(req), + store: await createStore('api'), + handler: handleRateExceeded + }) - const apiLimiterStream = rateLimit({ + const apiTunnelLimiter = rateLimit({ windowMs: env.rateLimitWindow * 1000, - max: env.rateLimitMax, - standardHeaders: true, + limit: (req) => req.rateLimitMax || env.rateLimitMax, + standardHeaders: 'draft-6', legacyHeaders: false, - keyGenerator: (req) => generateHmac(getIP(req), ipSalt), - handler: (req, res) => { - return res.sendStatus(429); - }, - }); + keyGenerator: req => req.rateLimitKey || keyGenerator(req), + store: await createStore('tunnel'), + handler: (_, res) => { + return res.sendStatus(429) + } + }) app.set('trust proxy', ['loopback', 'uniquelocal']); - app.use( - '/', - cors({ - methods: ['GET', 'POST'], - exposedHeaders: [ - 'Ratelimit-Limit', - 'Ratelimit-Policy', - 'Ratelimit-Remaining', - 'Ratelimit-Reset', - ], - ...corsConfig, - }), - ); + app.use('/', cors({ + methods: ['GET', 'POST'], + exposedHeaders: [ + 'Ratelimit-Limit', + 'Ratelimit-Policy', + 'Ratelimit-Remaining', + 'Ratelimit-Reset' + ], + ...corsConfig, + })); - app.post('/', apiLimiter); - app.use('/tunnel', apiLimiterStream); + app.post('/', (req, res, next) => { + if (!acceptRegex.test(req.header('Accept'))) { + return fail(res, "error.api.header.accept"); + } + if (!acceptRegex.test(req.header('Content-Type'))) { + return fail(res, "error.api.header.content_type"); + } + next(); + }); app.post('/', (req, res, next) => { - if (!env.turnstileSecret || !env.jwtSecret) { - req.authorized = true; // bypass auth if not configured + if (!env.apiKeyURL) { return next(); } - try { - // const authorization = req.header("Authorization"); - // if (!authorization) { - // return fail(res, "error.api.auth.jwt.missing"); - // } + const { success, error } = APIKeys.validateAuthorization(req); + if (!success) { + // We call next() here if either if: + // a) we have user sessions enabled, meaning the request + // will still need a Bearer token to not be rejected, or + // b) we do not require the user to be authenticated, and + // so they can just make the request with the regular + // rate limit configuration; + // otherwise, we reject the request. + if ( + (env.sessionEnabled || !env.authRequired) + && ['missing', 'not_api_key'].includes(error) + ) { + return next(); + } + + return fail(res, `error.api.auth.key.${error}`); + } + + return next(); + }); - // if (!authorization.startsWith("Bearer ") || authorization.length > 256) { - // return fail(res, "error.api.auth.jwt.invalid"); - // } + app.post('/', (req, res, next) => { + if (!env.sessionEnabled || req.rateLimitKey) { + return next(); + } - // const verifyJwt = jwt.verify( - // authorization.split("Bearer ", 2)[1] - // ); + try { + const authorization = req.header("Authorization"); + if (!authorization) { + return fail(res, "error.api.auth.jwt.missing"); + } - // if (!verifyJwt) { - // return fail(res, "error.api.auth.jwt.invalid"); - // } + if (authorization.length >= 256) { + return fail(res, "error.api.auth.jwt.invalid"); + } - if (!acceptRegex.test(req.header('Accept'))) { - return fail(res, 'error.api.header.accept'); + const [ type, token, ...rest ] = authorization.split(" "); + if (!token || type.toLowerCase() !== 'bearer' || rest.length) { + return fail(res, "error.api.auth.jwt.invalid"); } - if (!acceptRegex.test(req.header('Content-Type'))) { - return fail(res, 'error.api.header.content_type'); + if (!jwt.verify(token)) { + return fail(res, "error.api.auth.jwt.invalid"); } - req.authorized = true; + req.rateLimitKey = hashHmac(token, 'rate'); } catch { - return fail(res, 'error.api.generic'); + return fail(res, "error.api.generic"); } next(); }); + app.post('/', apiLimiter); app.use('/', express.json({ limit: 1024 })); + app.use('/', (err, _, res, next) => { if (err) { - const { status, body } = createResponse('error', { - code: 'error.api.invalid_body', + const { status, body } = createResponse("error", { + code: "error.api.invalid_body", }); return res.status(status).json(body); } @@ -170,30 +198,51 @@ export const runAPI = (express, app, __dirname) => { next(); }); + app.post("/session", sessionLimiter, async (req, res) => { + if (!env.sessionEnabled) { + return fail(res, "error.api.auth.not_configured") + } + + const turnstileResponse = req.header("cf-turnstile-response"); + + if (!turnstileResponse) { + return fail(res, "error.api.auth.turnstile.missing"); + } + + const turnstileResult = await verifyTurnstileToken( + turnstileResponse, + req.ip + ); + + if (!turnstileResult) { + return fail(res, "error.api.auth.turnstile.invalid"); + } + + try { + res.json(jwt.generate()); + } catch { + return fail(res, "error.api.generic"); + } + }); + app.post('/', async (req, res) => { const request = req.body; - const lang = languageCode(req); if (!request.url) { - return fail(res, 'error.api.link.missing'); + return fail(res, "error.api.link.missing"); } - if (request.youtubeDubBrowserLang) { - request.youtubeDubLang = lang; - } - - const { success, data: normalizedRequest } = - await normalizeRequest(request); + const { success, data: normalizedRequest } = await normalizeRequest(request); if (!success) { - return fail(res, 'error.api.invalid_body'); + return fail(res, "error.api.invalid_body"); } const parsed = extract(normalizedRequest.url); if (!parsed) { - return fail(res, 'error.api.link.invalid'); + return fail(res, "error.api.link.invalid"); } - if ('error' in parsed) { + if ("error" in parsed) { let context; if (parsed?.context) { context = parsed.context; @@ -210,19 +259,30 @@ export const runAPI = (express, app, __dirname) => { res.status(result.status).json(result.body); } catch { - fail(res, 'error.api.generic'); + fail(res, "error.api.generic"); } - }); + }) - app.get('/tunnel', (req, res) => { + app.get('/tunnel', apiTunnelLimiter, async (req, res) => { const id = String(req.query.id); - const exp = Number(req.query.exp); + const exp = String(req.query.exp); + const sig = String(req.query.sig); + const sec = String(req.query.sec); + const iv = String(req.query.iv); - if (!id || !exp) { + const checkQueries = id && exp && sig && sec && iv; + const checkBaseLength = id.length === 21 && exp.length === 13; + const checkSafeLength = sig.length === 43 && sec.length === 43 && iv.length === 22; + + if (!checkQueries || !checkBaseLength || !checkSafeLength) { return res.status(400).end(); } - const streamInfo = verifyStream(id, exp); + if (req.query.p) { + return res.status(200).end(); + } + + const streamInfo = await verifyStream(id, sig, exp, sec, iv); if (!streamInfo?.service) { return res.status(streamInfo.status).end(); } @@ -232,9 +292,9 @@ export const runAPI = (express, app, __dirname) => { } return stream(res, streamInfo); - }); + }) - app.get('/itunnel', (req, res) => { + const itunnelHandler = (req, res) => { if (!req.ip.endsWith('127.0.0.1')) { return res.sendStatus(403); } @@ -250,72 +310,85 @@ export const runAPI = (express, app, __dirname) => { streamInfo.headers = new Map([ ...(streamInfo.headers || []), - ...Object.entries(req.headers), + ...Object.entries(req.headers) ]); return stream(res, { type: 'internal', ...streamInfo }); - }); + }; + + app.get('/itunnel', itunnelHandler); app.get('/', (_, res) => { res.type('json'); res.status(200).send(serverInfo); - }); + }) app.get('/favicon.ico', (req, res) => { res.status(404).end(); - }); + }) app.get('/*', (req, res) => { res.redirect('/'); - }); + }) // handle all express errors app.use((_, __, res, ___) => { - return fail(res, 'error.api.generic'); - }); + return fail(res, "error.api.generic"); + }) - // randomizeCiphers(); - // setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes + randomizeCiphers(); + setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes if (env.externalProxy) { if (env.freebindCIDR) { - throw new Error( - 'Freebind is not available when external proxy is enabled', - ); + throw new Error('Freebind is not available when external proxy is enabled') } - setGlobalDispatcher(new ProxyAgent(env.externalProxy)); + setGlobalDispatcher(new ProxyAgent(env.externalProxy)) } - app.listen(env.apiPort, env.listenAddress, () => { - console.log( - `\n` + - Bright(Cyan('Team Hydra Video DL')) + - Bright('API :3') + - '\n' + - '~~~~~~\n' + - Bright('version: ') + - version + - '\n' + - Bright('commit: ') + - git.commit + - '\n' + - Bright('branch: ') + - git.branch + - '\n' + - Bright('remote: ') + - git.remote + - '\n' + - Bright('start time: ') + - startTime.toUTCString() + - '\n' + - '~~~~~~\n' + - Bright('url: ') + - Bright(Cyan(env.apiURL)) + - '\n' + - Bright('port: ') + - env.apiPort + - '\n', - ); + http.createServer(app).listen({ + port: env.apiPort, + host: env.listenAddress, + reusePort: env.instanceCount > 1 || undefined + }, () => { + if (isPrimary) { + console.log(`\n` + + Bright(Cyan("cobalt ")) + Bright("API ^ω⁠^") + "\n" + + + "~~~~~~\n" + + Bright("version: ") + version + "\n" + + Bright("commit: ") + git.commit + "\n" + + Bright("branch: ") + git.branch + "\n" + + Bright("remote: ") + git.remote + "\n" + + Bright("start time: ") + startTime.toUTCString() + "\n" + + "~~~~~~\n" + + + Bright("url: ") + Bright(Cyan(env.apiURL)) + "\n" + + Bright("port: ") + env.apiPort + "\n" + ); + } + + if (env.apiKeyURL) { + APIKeys.setup(env.apiKeyURL); + } + + if (env.cookiePath) { + Cookies.setup(env.cookiePath); + } }); -}; + + if (isCluster) { + const istreamer = express(); + istreamer.get('/itunnel', itunnelHandler); + const server = istreamer.listen({ + port: 0, + host: '127.0.0.1', + exclusive: true + }, () => { + const { port } = server.address(); + console.log(`${Green('[✓]')} cobalt sub-instance running on 127.0.0.1:${port}`); + setTunnelPort(port); + }); + } +} diff --git a/api/src/index.js b/api/src/index.js new file mode 100644 index 0000000..41fc750 --- /dev/null +++ b/api/src/index.js @@ -0,0 +1,32 @@ +import 'dotenv/config'; + +import express from 'express'; +import cluster from 'node:cluster'; + +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { env, isCluster } from './config.js'; +import { Red } from './misc/console-text.js'; +import { initCluster } from './misc/cluster.js'; + +const app = express(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename).slice(0, -4); + +app.disable('x-powered-by'); + +if (env.apiURL) { + const { runAPI } = await import('./core/api.js'); + + if (isCluster) { + await initCluster(); + } + + runAPI(express, app, __dirname, cluster.isPrimary); +} else { + console.log( + Red("API_URL env variable is missing, Team Hydra Video Downloader api can't start."), + ); +} diff --git a/api/src/misc/cluster.js b/api/src/misc/cluster.js new file mode 100644 index 0000000..56664d1 --- /dev/null +++ b/api/src/misc/cluster.js @@ -0,0 +1,71 @@ +import cluster from "node:cluster"; +import net from "node:net"; +import { syncSecrets } from "../security/secrets.js"; +import { env, isCluster } from "../config.js"; + +export { isPrimary, isWorker } from "node:cluster"; + +export const supportsReusePort = async () => { + try { + await new Promise((resolve, reject) => { + const server = net.createServer().listen({ port: 0, reusePort: true }); + server.on('listening', () => server.close(resolve)); + server.on('error', (err) => (server.close(), reject(err))); + }); + + return true; + } catch { + return false; + } +} + +export const initCluster = async () => { + if (cluster.isPrimary) { + for (let i = 1; i < env.instanceCount; ++i) { + cluster.fork(); + } + } + + await syncSecrets(); +} + +export const broadcast = (message) => { + if (!isCluster || !cluster.isPrimary || !cluster.workers) { + return; + } + + for (const worker of Object.values(cluster.workers)) { + worker.send(message); + } +} + +export const send = (message) => { + if (!isCluster) { + return; + } + + if (cluster.isPrimary) { + return broadcast(message); + } else { + return process.send(message); + } +} + +export const waitFor = (key) => { + return new Promise(resolve => { + const listener = (message) => { + if (key in message) { + process.off('message', listener); + return resolve(message); + } + } + + process.on('message', listener); + }); +} + +export const mainOnMessage = (cb) => { + for (const worker of Object.values(cluster.workers)) { + worker.on('message', cb); + } +} diff --git a/api/src/misc/console-text.js b/api/src/misc/console-text.js index 014584a..8df8fcc 100644 --- a/api/src/misc/console-text.js +++ b/api/src/misc/console-text.js @@ -1,16 +1,36 @@ -function t(color, tt) { - return color + tt + "\x1b[0m" +const ANSI = { + RESET: "\x1b[0m", + BRIGHT: "\x1b[1m", + RED: "\x1b[31m", + GREEN: "\x1b[32m", + CYAN: "\x1b[36m", + YELLOW: "\x1b[93m" } -export function Bright(tt) { - return t("\x1b[1m", tt) +function wrap(color, text) { + if (!ANSI[color.toUpperCase()]) { + throw "invalid color"; + } + + return ANSI[color.toUpperCase()] + text + ANSI.RESET; +} + +export function Bright(text) { + return wrap('bright', text); +} + +export function Red(text) { + return wrap('red', text); } -export function Red(tt) { - return t("\x1b[31m", tt) + +export function Green(text) { + return wrap('green', text); } -export function Green(tt) { - return t("\x1b[32m", tt) + +export function Cyan(text) { + return wrap('cyan', text); } -export function Cyan(tt) { - return t("\x1b[36m", tt) + +export function Yellow(text) { + return wrap('yellow', text); } diff --git a/api/src/misc/crypto.js b/api/src/misc/crypto.js index 3a52015..e0f8858 100644 --- a/api/src/misc/crypto.js +++ b/api/src/misc/crypto.js @@ -1,15 +1,7 @@ -import { createHmac, createCipheriv, createDecipheriv, randomBytes } from "crypto"; +import { createCipheriv, createDecipheriv } from "crypto"; const algorithm = "aes256"; -export function generateSalt() { - return randomBytes(64).toString('hex'); -} - -export function generateHmac(str, salt) { - return createHmac("sha256", salt).update(str).digest("base64url"); -} - export function encryptStream(plaintext, iv, secret) { const buff = Buffer.from(JSON.stringify(plaintext)); const key = Buffer.from(secret, "base64url"); diff --git a/api/src/misc/run-test.js b/api/src/misc/run-test.js index 10d19ae..21d97d0 100644 --- a/api/src/misc/run-test.js +++ b/api/src/misc/run-test.js @@ -41,4 +41,4 @@ export async function runTest(url, params, expect) { if (result.body.status === 'tunnel') { // TODO: stream testing } -} \ No newline at end of file +} diff --git a/api/src/misc/utils.js b/api/src/misc/utils.js index 05192d9..fd497d1 100644 --- a/api/src/misc/utils.js +++ b/api/src/misc/utils.js @@ -1,50 +1,3 @@ -const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '=']; - -export function metadataManager(obj) { - const keys = Object.keys(obj); - const tags = [ - "album", - "copyright", - "title", - "artist", - "track", - "date" - ] - let commands = [] - - for (const i in keys) { - if (tags.includes(keys[i])) - commands.push('-metadata', `${keys[i]}=${obj[keys[i]]}`) - } - return commands; -} - -export function cleanString(string) { - for (const i in forbiddenCharsString) { - string = string.replaceAll("/", "_") - .replaceAll(forbiddenCharsString[i], '') - } - return string; -} -export function verifyLanguageCode(code) { - const langCode = String(code.slice(0, 2).toLowerCase()); - if (RegExp(/[a-z]{2}/).test(code)) { - return langCode - } - return "en" -} -export function languageCode(req) { - if (req.header('Accept-Language')) { - return verifyLanguageCode(req.header('Accept-Language')) - } - return "en" -} -export function cleanHTML(html) { - let clean = html.replace(/ {4}/g, ''); - clean = clean.replace(/\n/g, ''); - return clean -} - export function getRedirectingURL(url) { return fetch(url, { redirect: 'manual' }).then((r) => { if ([301, 302, 303].includes(r.status) && r.headers.has('location')) @@ -65,3 +18,14 @@ export function merge(a, b) { return a; } + +export function splitFilenameExtension(filename) { + const parts = filename.split('.'); + const ext = parts.pop(); + + if (!parts.length) { + return [ ext, "" ] + } else { + return [ parts.join('.'), ext ] + } +} diff --git a/api/src/processing/cookie/cookie.js b/api/src/processing/cookie/cookie.js index 6dd95fc..1d9636d 100644 --- a/api/src/processing/cookie/cookie.js +++ b/api/src/processing/cookie/cookie.js @@ -4,16 +4,24 @@ export default class Cookie { constructor(input) { assert(typeof input === 'object'); this._values = {}; - this.set(input) + + for (const [ k, v ] of Object.entries(input)) + this.set(k, v); } - set(values) { - Object.entries(values).forEach( - ([ key, value ]) => this._values[key] = value - ) + + set(key, value) { + const old = this._values[key]; + if (old === value) + return false; + + this._values[key] = value; + return true; } + unset(keys) { for (const key of keys) delete this._values[key] } + static fromString(str) { const obj = {}; @@ -25,12 +33,15 @@ export default class Cookie { return new Cookie(obj) } + toString() { return Object.entries(this._values).map(([ name, value ]) => `${name}=${value}`).join('; ') } + toJSON() { return this.toString() } + values() { return Object.freeze({ ...this._values }) } diff --git a/api/src/processing/cookie/manager.js b/api/src/processing/cookie/manager.js index 3e41473..25f41c2 100644 --- a/api/src/processing/cookie/manager.js +++ b/api/src/processing/cookie/manager.js @@ -1,81 +1,157 @@ import Cookie from './cookie.js'; + import { readFile, writeFile } from 'fs/promises'; +import { Red, Green, Yellow } from '../../misc/console-text.js'; import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser'; -import { env } from '../../config.js'; - -const WRITE_INTERVAL = 60000, - cookiePath = env.cookiePath, - COUNTER = Symbol('counter'); +import * as cluster from '../../misc/cluster.js'; +import { isCluster } from '../../config.js'; + +const WRITE_INTERVAL = 60000; +const VALID_SERVICES = new Set([ + 'instagram', + 'instagram_bearer', + 'reddit', + 'twitter', + 'youtube', + 'youtube_oauth' +]); + +const invalidCookies = {}; +let cookies = {}, dirty = false, intervalId; + +function writeChanges(cookiePath) { + if (!dirty) return; + dirty = false; -let cookies = {}, - dirty = false, - intervalId; + const cookieData = JSON.stringify({ ...cookies, ...invalidCookies }, null, 4); + writeFile(cookiePath, cookieData).catch((e) => { + console.warn(`${Yellow('[!]')} failed writing updated cookies to storage`); + console.warn(e); + clearInterval(intervalId); + intervalId = null; + }) +} -const setup = async () => { +const setupMain = async (cookiePath) => { try { - console.log('Loading cookies from', cookiePath); - if (!cookiePath) return; - cookies = await readFile(cookiePath, 'utf8'); cookies = JSON.parse(cookies); - intervalId = setInterval(writeChanges, WRITE_INTERVAL); - - console.log('Loaded cookies! Cookie dump:', cookies); + for (const serviceName in cookies) { + if (!VALID_SERVICES.has(serviceName)) { + console.warn(`${Yellow('[!]')} ignoring unknown service in cookie file: ${serviceName}`); + } else if (!Array.isArray(cookies[serviceName])) { + console.warn(`${Yellow('[!]')} ${serviceName} in cookies file is not an array, ignoring it`); + } else if (cookies[serviceName].some(c => typeof c !== 'string')) { + console.warn(`${Yellow('[!]')} some cookie for ${serviceName} contains non-string value in cookies file`); + } else continue; + + invalidCookies[serviceName] = cookies[serviceName]; + delete cookies[serviceName]; + } + + if (!intervalId) { + intervalId = setInterval(() => writeChanges(cookiePath), WRITE_INTERVAL); + } + + cluster.broadcast({ cookies }); + + console.log(`${Green('[✓]')} cookies loaded successfully!`); } catch (e) { - console.log(`Failed to load cookies: ${e}`); + console.error(`${Yellow('[!]')} failed to load cookies.`); + console.error('error:', e); } -}; +} -setup(); +const setupWorker = async () => { + cookies = (await cluster.waitFor('cookies')).cookies; +} + +export const loadFromFile = async (path) => { + if (cluster.isPrimary) { + await setupMain(path); + } else if (cluster.isWorker) { + await setupWorker(); + } -function writeChanges() { - if (!dirty) return; dirty = false; +} - writeFile(cookiePath, JSON.stringify(cookies, null, 4)).catch(() => { - clearInterval(intervalId); - }); +export const setup = async (path) => { + await loadFromFile(path); + + if (isCluster) { + const messageHandler = (message) => { + if ('cookieUpdate' in message) { + const { cookieUpdate } = message; + + if (cluster.isPrimary) { + dirty = true; + cluster.broadcast({ cookieUpdate }); + } + + const { service, idx, cookie } = cookieUpdate; + cookies[service][idx] = cookie; + } + } + + if (cluster.isPrimary) { + cluster.mainOnMessage(messageHandler); + } else { + process.on('message', messageHandler); + } + } } export function getCookie(service) { - console.log('Getting cookie for', service); + if (!VALID_SERVICES.has(service)) { + console.error( + `${Red('[!]')} ${service} not in allowed services list for cookies.` + + ' if adding a new cookie type, include it there.' + ); + return; + } + if (!cookies[service] || !cookies[service].length) return; - console.log('Cookies:', cookies[service]); - let n; - if (cookies[service][COUNTER] === undefined) { - n = cookies[service][COUNTER] = 0; - } else { - ++cookies[service][COUNTER]; - n = cookies[service][COUNTER] %= cookies[service].length; + const idx = Math.floor(Math.random() * cookies[service].length); + + const cookie = cookies[service][idx]; + if (typeof cookie === 'string') { + cookies[service][idx] = Cookie.fromString(cookie); } - const cookie = cookies[service][n]; - if (typeof cookie === 'string') - cookies[service][n] = Cookie.fromString(cookie); + cookies[service][idx].meta = { service, idx }; + return cookies[service][idx]; +} + +export function updateCookieValues(cookie, values) { + let changed = false; + + for (const [ key, value ] of Object.entries(values)) { + changed = cookie.set(key, value) || changed; + } - return cookies[service][n]; + if (changed && cookie.meta) { + dirty = true; + if (isCluster) { + const message = { cookieUpdate: { ...cookie.meta, cookie } }; + cluster.send(message); + } + } + + return changed; } export function updateCookie(cookie, headers) { if (!cookie) return; const parsed = parseSetCookie( - splitCookiesString(headers.get('set-cookie')), - { decodeValues: false }, - ), - values = {}; - - cookie.unset( - parsed.filter((c) => c.expires < new Date()).map((c) => c.name), - ); - parsed - .filter((c) => !c.expires || c.expires > new Date()) - .forEach((c) => (values[c.name] = c.value)); - updateCookieValues(cookie, values); -} + splitCookiesString(headers.get('set-cookie')), + { decodeValues: false } + ), values = {} -export function updateCookieValues(cookie, values) { - cookie.set(values); - if (Object.keys(values).length) dirty = true; + cookie.unset(parsed.filter(c => c.expires < new Date()).map(c => c.name)); + parsed.filter(c => !c.expires || c.expires > new Date()).forEach(c => values[c.name] = c.value); + + updateCookieValues(cookie, values); } diff --git a/api/src/processing/create-filename.js b/api/src/processing/create-filename.js index 216b15a..911b560 100644 --- a/api/src/processing/create-filename.js +++ b/api/src/processing/create-filename.js @@ -1,3 +1,13 @@ +const illegalCharacters = ['}', '{', '%', '>', '<', '^', ';', ':', '`', '$', '"', "@", '=', '?', '|', '*']; + +const sanitizeString = (string) => { + for (const i in illegalCharacters) { + string = string.replaceAll("/", "_").replaceAll("\\", "_") + .replaceAll(illegalCharacters[i], '') + } + return string; +} + export default (f, style, isAudioOnly, isAudioMuted) => { let filename = ''; @@ -5,7 +15,11 @@ export default (f, style, isAudioOnly, isAudioMuted) => { let classicTags = [...infoBase]; let basicTags = []; - const title = `${f.title} - ${f.author}`; + let title = sanitizeString(f.title); + + if (f.author) { + title += ` - ${sanitizeString(f.author)}`; + } if (f.resolution) { classicTags.push(f.resolution); diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 6b5395d..5c72862 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -3,12 +3,13 @@ import createFilename from "./create-filename.js"; import { createResponse } from "./request.js"; import { audioIgnore } from "./service-config.js"; import { createStream } from "../stream/manage.js"; +import { splitFilenameExtension } from "../misc/utils.js"; export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) { let action, responseType = "tunnel", defaultParams = { - u: r.urls, + url: r.urls, headers: r.headers, service: host, filename: r.filenameAttributes ? @@ -23,7 +24,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab else if (r.isGif && twitterGif) action = "gif"; else if (isAudioOnly) action = "audio"; else if (isAudioMuted) action = "muteVideo"; - else if (r.isM3U8) action = "m3u8"; + else if (r.isHLS) action = "hls"; else action = "video"; if (action === "picker" || action === "audio") { @@ -32,10 +33,11 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab } if (action === "muteVideo" && isAudioMuted && !r.filenameAttributes) { - const parts = r.filename.split("."); - const ext = parts.pop(); - - defaultParams.filename = `${parts.join(".")}_mute.${ext}`; + const [ name, ext ] = splitFilenameExtension(r.filename); + defaultParams.filename = `${name}_mute.${ext}`; + } else if (action === "gif") { + const [ name ] = splitFilenameExtension(r.filename); + defaultParams.filename = `${name}.gif`; } switch (action) { @@ -52,20 +54,22 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab params = { type: "gif" }; break; - case "m3u8": + case "hls": params = { - type: Array.isArray(r.urls) ? "merge" : "remux" + type: Array.isArray(r.urls) ? "merge" : "remux", + isHLS: true, } break; case "muteVideo": let muteType = "mute"; - if (Array.isArray(r.urls) && !r.isM3U8) { + if (Array.isArray(r.urls) && !r.isHLS) { muteType = "proxy"; } params = { type: muteType, - u: Array.isArray(r.urls) ? r.urls[0] : r.urls + url: Array.isArray(r.urls) ? r.urls[0] : r.urls, + isHLS: r.isHLS } if (host === "reddit" && r.typeId === "redirect") { responseType = "redirect"; @@ -90,12 +94,12 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab } params = { picker: r.picker, - u: createStream({ + url: createStream({ service: "tiktok", type: audioStreamType, - u: r.urls, + url: r.urls, headers: r.headers, - filename: r.audioFilename, + filename: `${r.audioFilename}.${audioFormat}`, isAudioOnly: true, audioFormat, }) @@ -135,13 +139,13 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab } break; + case "ok": case "vk": case "tiktok": params = { type: "proxy" }; break; case "facebook": - case "vine": case "instagram": case "tumblr": case "pinterest": @@ -157,7 +161,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab case "audio": if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) { return createResponse("error", { - code: "error.api.fetch.empty" + code: "error.api.service.audio_not_supported" }) } @@ -181,18 +185,20 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab } } - if (r.isM3U8 || host === "vimeo") { + if (r.isHLS || host === "vimeo") { copy = false; processType = "audio"; } params = { type: processType, - u: Array.isArray(r.urls) ? r.urls[1] : r.urls, + url: Array.isArray(r.urls) ? r.urls[1] : r.urls, audioBitrate, audioCopy: copy, audioFormat, + + isHLS: r.isHLS, } break; } diff --git a/api/src/processing/match.js b/api/src/processing/match.js index ffb92c2..57f04b3 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -19,7 +19,6 @@ import tumblr from "./services/tumblr.js"; import vimeo from "./services/vimeo.js"; import soundcloud from "./services/soundcloud.js"; import instagram from "./services/instagram.js"; -import vine from "./services/vine.js"; import pinterest from "./services/pinterest.js"; import streamable from "./services/streamable.js"; import twitch from "./services/twitch.js"; @@ -78,8 +77,9 @@ export default async function({ host, patternMatch, params }) { case "vk": r = await vk({ - userId: patternMatch.userId, + ownerId: patternMatch.ownerId, videoId: patternMatch.videoId, + accessKey: patternMatch.accessKey, quality: params.videoQuality }); break; @@ -97,13 +97,14 @@ export default async function({ host, patternMatch, params }) { case "youtube": let fetchInfo = { + dispatcher, id: patternMatch.id.slice(0, 11), quality: params.videoQuality, format: params.youtubeVideoCodec, isAudioOnly, isAudioMuted, dubLang: params.youtubeDubLang, - dispatcher + youtubeHLS: params.youtubeHLS, } if (url.hostname === "music.youtube.com" || isAudioOnly) { @@ -127,7 +128,7 @@ export default async function({ host, patternMatch, params }) { case "tiktok": r = await tiktok({ postId: patternMatch.postId, - id: patternMatch.id, + shortLink: patternMatch.shortLink, fullAudio: params.tiktokFullAudio, isAudioOnly, h265: params.tiktokH265, @@ -174,12 +175,6 @@ export default async function({ host, patternMatch, params }) { }) break; - case "vine": - r = await vine({ - id: patternMatch.id - }); - break; - case "pinterest": r = await pinterest({ id: patternMatch.id, @@ -239,7 +234,8 @@ export default async function({ host, patternMatch, params }) { case "bsky": r = await bluesky({ ...patternMatch, - alwaysProxy: params.alwaysProxy + alwaysProxy: params.alwaysProxy, + dispatcher }); break; diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 4287267..d512bfe 100644 --- a/api/src/processing/request.js +++ b/api/src/processing/request.js @@ -37,7 +37,7 @@ export function createResponse(responseType, responseData) { case "redirect": response = { - url: responseData?.u, + url: responseData?.url, filename: responseData?.filename } break; @@ -52,7 +52,7 @@ export function createResponse(responseType, responseData) { case "picker": response = { picker: responseData?.picker, - audio: responseData?.u, + audio: responseData?.url, audioFilename: responseData?.filename } break; diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index 172d480..48d8b05 100644 --- a/api/src/processing/schema.js +++ b/api/src/processing/schema.js @@ -1,7 +1,5 @@ import { z } from "zod"; - import { normalizeURL } from "./url.js"; -import { verifyLanguageCode } from "../misc/utils.js"; export const apiSchema = z.object({ url: z.string() @@ -33,15 +31,21 @@ export const apiSchema = z.object({ ).default("1080"), youtubeDubLang: z.string() - .length(2) - .transform(verifyLanguageCode) + .min(2) + .max(8) + .regex(/^[0-9a-zA-Z\-]+$/) .optional(), + // TODO: remove this variable as it's no longer used + // and is kept for schema compatibility reasons + youtubeDubBrowserLang: z.boolean().default(false), + alwaysProxy: z.boolean().default(false), disableMetadata: z.boolean().default(false), tiktokFullAudio: z.boolean().default(false), tiktokH265: z.boolean().default(false), twitterGif: z.boolean().default(true), - youtubeDubBrowserLang: z.boolean().default(false), + + youtubeHLS: z.boolean().default(false), }) .strict(); diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 67542e9..81afaf3 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -1,7 +1,7 @@ import UrlPattern from "url-pattern"; export const audioIgnore = ["vk", "ok", "loom"]; -export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky"]; +export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky", "youtube"]; export const services = { bilibili: { @@ -30,7 +30,7 @@ export const services = { "reel/:id", "share/:shareType/:id" ], - subdomains: ["web"], + subdomains: ["web", "m"], altDomains: ["fb.watch"], }, instagram: { @@ -46,7 +46,7 @@ export const services = { altDomains: ["ddinstagram.com"], }, loom: { - patterns: ["share/:id"], + patterns: ["share/:id", "embed/:id"], }, ok: { patterns: [ @@ -111,10 +111,10 @@ export const services = { tiktok: { patterns: [ ":user/video/:postId", - ":id", - "t/:id", + ":shortLink", + "t/:shortLink", ":user/photo/:postId", - "v/:id.html" + "v/:postId.html" ], subdomains: ["vt", "vm", "m"], }, @@ -137,15 +137,12 @@ export const services = { ":user/status/:id/video/:index", ":user/status/:id/photo/:index", ":user/status/:id/mediaviewer", - ":user/status/:id/mediaViewer" + ":user/status/:id/mediaViewer", + "i/bookmarks?post_id=:id" ], subdomains: ["mobile"], altDomains: ["x.com", "vxtwitter.com", "fixvx.com"], }, - vine: { - patterns: ["v/:id"], - tld: "co", - }, vimeo: { patterns: [ ":id", @@ -157,11 +154,17 @@ export const services = { }, vk: { patterns: [ - "video:userId_:videoId", - "clip:userId_:videoId", - "clips:duplicate?z=clip:userId_:videoId" + "video:ownerId_:videoId", + "clip:ownerId_:videoId", + "clips:duplicate?z=clip:ownerId_:videoId", + "videos:duplicate?z=video:ownerId_:videoId", + "video:ownerId_:videoId_:accessKey", + "clip:ownerId_:videoId_:accessKey", + "clips:duplicate?z=clip:ownerId_:videoId_:accessKey", + "videos:duplicate?z=video:ownerId_:videoId_:accessKey" ], subdomains: ["m"], + altDomains: ["vkvideo.ru", "vk.ru"], }, youtube: { patterns: [ @@ -176,7 +179,7 @@ export const services = { Object.values(services).forEach(service => { service.patterns = service.patterns.map( pattern => new UrlPattern(pattern, { - segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\.' + segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\.:' }) ) }) diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index 2105a56..e8c4663 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -36,10 +36,10 @@ export const testers = { || pattern.shortLink?.length <= 16, "streamable": pattern => - pattern.id?.length === 6, + pattern.id?.length <= 6, "tiktok": pattern => - pattern.postId?.length <= 21 || pattern.id?.length <= 13, + pattern.postId?.length <= 21 || pattern.shortLink?.length <= 13, "tumblr": pattern => pattern.id?.length < 21 @@ -55,11 +55,9 @@ export const testers = { pattern.id?.length <= 11 && (!pattern.password || pattern.password.length < 16), - "vine": pattern => - pattern.id?.length <= 12, - "vk": pattern => - pattern.userId?.length <= 10 && pattern.videoId?.length <= 10, + (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) || + (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18), "youtube": pattern => pattern.id?.length <= 11, diff --git a/api/src/processing/services/bluesky.js b/api/src/processing/services/bluesky.js index 5f5cbce..bc88743 100644 --- a/api/src/processing/services/bluesky.js +++ b/api/src/processing/services/bluesky.js @@ -2,12 +2,19 @@ import HLS from "hls-parser"; import { cobaltUserAgent } from "../../config.js"; import { createStream } from "../../stream/manage.js"; -const extractVideo = async ({ media, filename }) => { - const urlMasterHLS = media?.playlist; - if (!urlMasterHLS) return { error: "fetch.empty" }; - if (!urlMasterHLS.startsWith("https://video.bsky.app/")) return { error: "fetch.empty" }; +const extractVideo = async ({ media, filename, dispatcher }) => { + let urlMasterHLS = media?.playlist; - const masterHLS = await fetch(urlMasterHLS) + if (!urlMasterHLS || !urlMasterHLS.startsWith("https://video.bsky.app/")) { + return { error: "fetch.empty" }; + } + + urlMasterHLS = urlMasterHLS.replace( + "video.bsky.app/watch/", + "video.cdn.bsky.app/hls/" + ); + + const masterHLS = await fetch(urlMasterHLS, { dispatcher }) .then(r => { if (r.status !== 200) return; return r.text(); @@ -26,7 +33,7 @@ const extractVideo = async ({ media, filename }) => { urls: videoURL, filename: `${filename}.mp4`, audioFilename: `${filename}_audio`, - isM3U8: true, + isHLS: true, } } @@ -48,7 +55,7 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => { let proxiedImage = createStream({ service: "bluesky", type: "proxy", - u: url, + url, filename: `${filename}_${i + 1}.jpg`, }); @@ -64,7 +71,7 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => { return { picker }; } -export default async function ({ user, post, alwaysProxy }) { +export default async function ({ user, post, alwaysProxy, dispatcher }) { const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0"); apiEndpoint.searchParams.set( "uri", @@ -73,8 +80,9 @@ export default async function ({ user, post, alwaysProxy }) { const getPost = await fetch(apiEndpoint, { headers: { - "user-agent": cobaltUserAgent - } + "user-agent": cobaltUserAgent, + }, + dispatcher }).then(r => r.json()).catch(() => {}); if (!getPost) return { error: "fetch.empty" }; @@ -87,7 +95,7 @@ export default async function ({ user, post, alwaysProxy }) { case "InvalidRequest": return { error: "link.unsupported" }; default: - return { error: "fetch.empty" }; + return { error: "content.post.unavailable" }; } } diff --git a/api/src/processing/services/dailymotion.js b/api/src/processing/services/dailymotion.js index a403a16..a30a8bc 100644 --- a/api/src/processing/services/dailymotion.js +++ b/api/src/processing/services/dailymotion.js @@ -92,7 +92,7 @@ export default async function({ id }) { return { urls: bestQuality.uri, - isM3U8: true, + isHLS: true, filenameAttributes: { service: 'dailymotion', id: media.xid, diff --git a/api/src/processing/services/instagram.js b/api/src/processing/services/instagram.js index 17e78ec..d9a646a 100644 --- a/api/src/processing/services/instagram.js +++ b/api/src/processing/services/instagram.js @@ -177,7 +177,7 @@ export default function(obj) { if (alwaysProxy) proxyFile = createStream({ service: "instagram", type: "proxy", - u: url, + url, filename: `instagram_${id}_${i + 1}.${itemExt}` }); @@ -189,7 +189,7 @@ export default function(obj) { thumb: createStream({ service: "instagram", type: "proxy", - u: e.node?.display_url, + url: e.node?.display_url, filename: `instagram_${id}_${i + 1}.jpg` }) } @@ -230,7 +230,7 @@ export default function(obj) { if (alwaysProxy) proxyFile = createStream({ service: "instagram", type: "proxy", - u: url, + url, filename: `instagram_${id}_${i + 1}.${itemExt}` }); @@ -242,7 +242,7 @@ export default function(obj) { thumb: createStream({ service: "instagram", type: "proxy", - u: imageUrl, + url: imageUrl, filename: `instagram_${id}_${i + 1}.jpg` }) } @@ -266,6 +266,7 @@ export default function(obj) { } async function getPost(id, alwaysProxy) { + const hasData = (data) => data && data.gql_data !== null; let data, result; try { const cookie = getCookie('instagram'); @@ -282,16 +283,16 @@ export default function(obj) { if (media_id && token) data = await requestMobileApi(media_id, { token }); // mobile api (no cookie, cookie) - if (media_id && !data) data = await requestMobileApi(media_id); - if (media_id && cookie && !data) data = await requestMobileApi(media_id, { cookie }); + if (media_id && !hasData(data)) data = await requestMobileApi(media_id); + if (media_id && cookie && !hasData(data)) data = await requestMobileApi(media_id, { cookie }); // html embed (no cookie, cookie) - if (!data) data = await requestHTML(id); - if (!data && cookie) data = await requestHTML(id, cookie); + if (!hasData(data)) data = await requestHTML(id); + if (!hasData(data) && cookie) data = await requestHTML(id, cookie); // web app graphql api (no cookie, cookie) - if (!data) data = await requestGQL(id); - if (!data && cookie) data = await requestGQL(id, cookie); + if (!hasData(data)) data = await requestGQL(id); + if (!hasData(data) && cookie) data = await requestGQL(id, cookie); } catch {} if (!data) return { error: "fetch.fail" }; diff --git a/api/src/processing/services/ok.js b/api/src/processing/services/ok.js index 2fb6082..10fb785 100644 --- a/api/src/processing/services/ok.js +++ b/api/src/processing/services/ok.js @@ -1,5 +1,4 @@ import { genericUserAgent, env } from "../../config.js"; -import { cleanString } from "../../misc/utils.js"; const resolutions = { "ultra": "2160", @@ -44,8 +43,8 @@ export default async function(o) { let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1]; let fileMetadata = { - title: cleanString(videoData.movie.title.trim()), - author: cleanString((videoData.author?.name || videoData.compilationTitle).trim()), + title: videoData.movie.title.trim(), + author: (videoData.author?.name || videoData.compilationTitle).trim(), } if (bestVideo) return { diff --git a/api/src/processing/services/rutube.js b/api/src/processing/services/rutube.js index 4305241..5b50245 100644 --- a/api/src/processing/services/rutube.js +++ b/api/src/processing/services/rutube.js @@ -1,7 +1,5 @@ import HLS from "hls-parser"; - import { env } from "../../config.js"; -import { cleanString } from "../../misc/utils.js"; async function requestJSON(url) { try { @@ -35,6 +33,10 @@ export default async function(obj) { const play = await requestJSON(requestURL); if (!play) return { error: "fetch.fail" }; + if (play.detail?.type === "blocking_rule") { + return { error: "content.video.region" }; + } + if (play.detail || !play.video_balancer) return { error: "fetch.empty" }; if (play.live_streams?.hls) return { error: "content.video.live" }; @@ -59,13 +61,13 @@ export default async function(obj) { }); const fileMetadata = { - title: cleanString(play.title.trim()), - artist: cleanString(play.author.name.trim()), + title: play.title.trim(), + artist: play.author.name.trim(), } return { urls: matchingQuality.uri, - isM3U8: true, + isHLS: true, filenameAttributes: { service: "rutube", id: obj.id, diff --git a/api/src/processing/services/snapchat.js b/api/src/processing/services/snapchat.js index acb6813..4c62a5f 100644 --- a/api/src/processing/services/snapchat.js +++ b/api/src/processing/services/snapchat.js @@ -73,7 +73,7 @@ async function getStory(username, storyId, alwaysProxy) { const proxy = createStream({ service: "snapchat", type: "proxy", - u: snapUrl, + url: snapUrl, filename: `snapchat_${username}_${snap.timestampInSec.value}.${snapExt}`, }); @@ -81,7 +81,7 @@ async function getStory(username, storyId, alwaysProxy) { if (snapType === "video") thumbProxy = createStream({ service: "snapchat", type: "proxy", - u: snap.snapUrls.mediaPreviewUrl.value, + url: snap.snapUrls.mediaPreviewUrl.value, }); if (alwaysProxy) snapUrl = proxy; diff --git a/api/src/processing/services/soundcloud.js b/api/src/processing/services/soundcloud.js index 394f7df..ad53547 100644 --- a/api/src/processing/services/soundcloud.js +++ b/api/src/processing/services/soundcloud.js @@ -1,5 +1,4 @@ import { env } from "../../config.js"; -import { cleanString } from "../../misc/utils.js"; const cachedID = { version: '', @@ -63,7 +62,17 @@ export default async function(obj) { if (!json) return { error: "fetch.fail" }; - if (!json.media.transcodings) return { error: "fetch.empty" }; + if (json?.policy === "BLOCK") { + return { error: "content.region" }; + } + + if (json?.policy === "SNIP") { + return { error: "content.paid" }; + } + + if (!json?.media?.transcodings || !json?.media?.transcodings.length === 0) { + return { error: "fetch.empty" }; + } let bestAudio = "opus", selectedStream = json.media.transcodings.find(v => v.preset === "opus_0_0"), @@ -75,6 +84,10 @@ export default async function(obj) { bestAudio = "mp3" } + if (!selectedStream) { + return { error: "fetch.empty" }; + } + let fileUrlBase = selectedStream.url; let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`; @@ -91,8 +104,8 @@ export default async function(obj) { if (!file) return { error: "fetch.empty" }; let fileMetadata = { - title: cleanString(json.title.trim()), - artist: cleanString(json.user.username.trim()), + title: json.title.trim(), + artist: json.user.username.trim(), } return { diff --git a/api/src/processing/services/tiktok.js b/api/src/processing/services/tiktok.js index 3c70e03..6978e07 100644 --- a/api/src/processing/services/tiktok.js +++ b/api/src/processing/services/tiktok.js @@ -12,7 +12,7 @@ export default async function(obj) { let postId = obj.postId; if (!postId) { - let html = await fetch(`${shortDomain}${obj.id}`, { + let html = await fetch(`${shortDomain}${obj.shortLink}`, { redirect: "manual", headers: { "user-agent": genericUserAgent.split(' Chrome/1')[0] @@ -24,7 +24,7 @@ export default async function(obj) { if (html.startsWith('')[1] - .split('')[0] - const data = JSON.parse(json) - detail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]["itemInfo"]["itemStruct"] + .split('')[0]; + + const data = JSON.parse(json); + const videoDetail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]; + + if (!videoDetail) throw "no video detail found"; + + // status_deleted or etc + if (videoDetail.statusMsg) { + return { error: "content.post.unavailable"}; + } + + detail = videoDetail?.itemInfo?.itemStruct; } catch { return { error: "fetch.fail" }; } + if (detail.isContentClassified) { + return { error: "content.post.age" }; + } + + if (!detail.author) { + return { error: "fetch.empty" }; + } + let video, videoFilename, audioFilename, audio, images, - filenameBase = `tiktok_${detail.author.uniqueId}_${postId}`, + filenameBase = `tiktok_${detail.author?.uniqueId}_${postId}`, bestAudio; // will get defaulted to m4a later on in match-action images = detail.imagePost?.images; - let playAddr = detail.video.playAddr; + let playAddr = detail.video?.playAddr; + if (obj.h265) { const h265PlayAddr = detail?.video?.bitrateInfo?.find(b => b.CodecType.includes("h265"))?.PlayAddr.UrlList[0] playAddr = h265PlayAddr || playAddr @@ -102,7 +121,7 @@ export default async function(obj) { if (obj.alwaysProxy) url = createStream({ service: "tiktok", type: "proxy", - u: url, + url, filename: `${filenameBase}_photo_${i + 1}.jpg` }) diff --git a/api/src/processing/services/tumblr.js b/api/src/processing/services/tumblr.js index b361b98..2b8aa4c 100644 --- a/api/src/processing/services/tumblr.js +++ b/api/src/processing/services/tumblr.js @@ -1,4 +1,4 @@ -import psl from "psl"; +import psl from "@imput/psl"; const API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z'; const API_BASE = 'https://api-http2.tumblr.com'; diff --git a/api/src/processing/services/twitch.js b/api/src/processing/services/twitch.js index ac85fbc..4b9d455 100644 --- a/api/src/processing/services/twitch.js +++ b/api/src/processing/services/twitch.js @@ -1,5 +1,4 @@ import { env } from "../../config.js"; -import { cleanString } from '../../misc/utils.js'; const gqlURL = "https://gql.twitch.tv/gql"; const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" }; @@ -73,13 +72,13 @@ export default async function (obj) { token: req_token[0].data.clip.playbackAccessToken.value })}`, fileMetadata: { - title: cleanString(clipMetadata.title.trim()), + title: clipMetadata.title.trim(), artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`, }, filenameAttributes: { service: "twitch", id: clipMetadata.id, - title: cleanString(clipMetadata.title.trim()), + title: clipMetadata.title.trim(), author: `${clipMetadata.broadcaster.login}, clipped by ${clipMetadata.curator.login}`, qualityLabel: `${format.quality}p`, extension: 'mp4' diff --git a/api/src/processing/services/twitter.js b/api/src/processing/services/twitter.js index 18866b4..b4a1d55 100644 --- a/api/src/processing/services/twitter.js +++ b/api/src/processing/services/twitter.js @@ -159,10 +159,10 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) { const getFileExt = (url) => new URL(url).pathname.split(".", 2)[1]; - const proxyMedia = (u, filename) => createStream({ + const proxyMedia = (url, filename) => createStream({ service: "twitter", type: "proxy", - u, filename, + url, filename, }) switch (media?.length) { @@ -208,7 +208,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) { let url = bestQuality(content.video_info.variants); const shouldRenderGif = content.type === "animated_gif" && toGif; - const videoFilename = `twitter_${id}_${i + 1}.mp4`; + const videoFilename = `twitter_${id}_${i + 1}.${shouldRenderGif ? "gif" : "mp4"}`; let type = "video"; if (shouldRenderGif) type = "gif"; @@ -217,7 +217,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) { url = createStream({ service: "twitter", type: shouldRenderGif ? "gif" : "remux", - u: url, + url, filename: videoFilename, }) } else if (alwaysProxy) { diff --git a/api/src/processing/services/vimeo.js b/api/src/processing/services/vimeo.js index 23e8419..8d70477 100644 --- a/api/src/processing/services/vimeo.js +++ b/api/src/processing/services/vimeo.js @@ -1,7 +1,6 @@ import HLS from "hls-parser"; - import { env } from "../../config.js"; -import { cleanString, merge } from '../../misc/utils.js'; +import { merge } from '../../misc/utils.js'; const resolutionMatch = { "3840": 2160, @@ -122,7 +121,7 @@ const getHLS = async (configURL, obj) => { return { urls, - isM3U8: true, + isHLS: true, filenameAttributes: { resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`, qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`, @@ -152,8 +151,8 @@ export default async function(obj) { } const fileMetadata = { - title: cleanString(info.name), - artist: cleanString(info.user.name), + title: info.name, + artist: info.user.name, }; return merge( diff --git a/api/src/processing/services/vk.js b/api/src/processing/services/vk.js index e3c18e4..33224d6 100644 --- a/api/src/processing/services/vk.js +++ b/api/src/processing/services/vk.js @@ -1,63 +1,140 @@ -import { cleanString } from "../../misc/utils.js"; -import { genericUserAgent, env } from "../../config.js"; +import { env } from "../../config.js"; -const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"]; +const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"]; -export default async function(o) { - let html, url, quality = o.quality === "max" ? 2160 : o.quality; +const oauthUrl = "https://oauth.vk.com/oauth/get_anonym_token"; +const apiUrl = "https://api.vk.com/method"; - html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, { +const clientId = "51552953"; +const clientSecret = "qgr0yWwXCrsxA1jnRtRX"; + +// used in stream/shared.js for accessing media files +export const vkClientAgent = "com.vk.vkvideo.prod/822 (iPhone, iOS 16.7.7, iPhone10,4, Scale/2.0) SAK/1.119"; + +const cachedToken = { + token: "", + expiry: 0, + device_id: "", +}; + +const getToken = async () => { + if (cachedToken.expiry - 10 > Math.floor(new Date().getTime() / 1000)) { + return cachedToken.token; + } + + const randomDeviceId = crypto.randomUUID().toUpperCase(); + + const anonymOauth = new URL(oauthUrl); + anonymOauth.searchParams.set("client_id", clientId); + anonymOauth.searchParams.set("client_secret", clientSecret); + anonymOauth.searchParams.set("device_id", randomDeviceId); + + const oauthResponse = await fetch(anonymOauth.toString(), { headers: { - "user-agent": genericUserAgent + "user-agent": vkClientAgent, } + }).then(r => { + if (r.status === 200) { + return r.json(); + } + }); + + if (!oauthResponse) return; + + if (oauthResponse?.token && oauthResponse?.expired_at && typeof oauthResponse?.expired_at === "number") { + cachedToken.token = oauthResponse.token; + cachedToken.expiry = oauthResponse.expired_at; + cachedToken.device_id = randomDeviceId; + } + + if (!cachedToken.token) return; + + return cachedToken.token; +} + +const getVideo = async (ownerId, videoId, accessKey) => { + const video = await fetch(`${apiUrl}/video.get`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded; charset=utf-8", + "user-agent": vkClientAgent, + }, + body: new URLSearchParams({ + anonymous_token: cachedToken.token, + device_id: cachedToken.device_id, + lang: "en", + v: "5.244", + videos: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}` + }).toString() }) - .then(r => r.arrayBuffer()) - .catch(() => {}); + .then(r => { + if (r.status === 200) { + return r.json(); + } + }); - if (!html) return { error: "fetch.fail" }; + return video; +} + +export default async function ({ ownerId, videoId, accessKey, quality }) { + const token = await getToken(); + if (!token) return { error: "fetch.fail" }; - // decode cyrillic from windows-1251 because vk still uses apis from prehistoric times - let decoder = new TextDecoder('windows-1251'); - html = decoder.decode(html); + const videoGet = await getVideo(ownerId, videoId, accessKey); - if (!html.includes(`{"lang":`)) return { error: "fetch.empty" }; + if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) { + return { error: "fetch.empty" }; + } - let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]); + const video = videoGet.response.items[0]; - if (Number(js.mvData.is_active_live) !== 0) { - return { error: "content.video.live" }; + if (video.restriction) { + const title = video.restriction.title; + if (title.endsWith("country") || title.endsWith("region.")) { + return { error: "content.video.region" }; + } + if (title === "Processing video") { + return { error: "fetch.empty" }; + } + return { error: "content.video.unavailable" }; + } + + if (!video.files || !video.duration) { + return { error: "fetch.fail" }; } - if (js.mvData.duration > env.durationLimit) { + if (video.duration > env.durationLimit) { return { error: "content.too_long" }; } - for (let i in resolutions) { - if (js.player.params[0][`url${resolutions[i]}`]) { - quality = resolutions[i]; + const userQuality = quality === "max" ? resolutions[0] : quality; + let pickedQuality; + + for (const resolution of resolutions) { + if (video.files[`mp4_${resolution}`] && +resolution <= +userQuality) { + pickedQuality = resolution; break } } - if (Number(quality) > Number(o.quality)) quality = o.quality; - url = js.player.params[0][`url${quality}`]; + const url = video.files[`mp4_${pickedQuality}`]; + + if (!url) return { error: "fetch.fail" }; - let fileMetadata = { - title: cleanString(js.player.params[0].md_title.trim()), - author: cleanString(js.player.params[0].md_author.trim()), + const fileMetadata = { + title: video.title.trim(), } - if (url) return { + return { urls: url, + fileMetadata, filenameAttributes: { service: "vk", - id: `${o.userId}_${o.videoId}`, + id: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`, title: fileMetadata.title, - author: fileMetadata.author, - resolution: `${quality}p`, - qualityLabel: `${quality}p`, + resolution: `${pickedQuality}p`, + qualityLabel: `${pickedQuality}p`, extension: "mp4" } } - return { error: "fetch.empty" } } diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index d1e28cd..6559978 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -1,40 +1,56 @@ -import { fetch } from 'undici'; +import HLS from "hls-parser"; -import { Innertube, Session } from 'youtubei.js'; +import { fetch } from "undici"; +import { Innertube, Session } from "youtubei.js"; -import { env } from '../../config.js'; -import { cleanString } from '../../misc/utils.js'; -import { getCookie, updateCookieValues } from '../cookie/manager.js'; +import { env } from "../../config.js"; +import { getCookie, updateCookieValues } from "../cookie/manager.js"; const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms let innertube, lastRefreshedAt; -const codecMatch = { +const codecList = { h264: { - videoCodec: 'avc1', - audioCodec: 'mp4a', - container: 'mp4', + videoCodec: "avc1", + audioCodec: "mp4a", + container: "mp4" }, av1: { - videoCodec: 'av01', - audioCodec: 'opus', - container: 'webm', + videoCodec: "av01", + audioCodec: "opus", + container: "webm" }, vp9: { - videoCodec: 'vp9', - audioCodec: 'opus', - container: 'webm', + videoCodec: "vp9", + audioCodec: "opus", + container: "webm" + } +} + +const hlsCodecList = { + h264: { + videoCodec: "avc1", + audioCodec: "mp4a", + container: "mp4" }, -}; + vp9: { + videoCodec: "vp09", + audioCodec: "mp4a", + container: "webm" + } +} + +const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320]; const transformSessionData = (cookie) => { - if (!cookie) return; + if (!cookie) + return; const values = { ...cookie.values() }; const REQUIRED_VALUES = ['access_token', 'refresh_token']; - if (REQUIRED_VALUES.some((x) => typeof values[x] !== 'string')) { + if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) { return; } @@ -46,14 +62,22 @@ const transformSessionData = (cookie) => { } return values; -}; +} const cloneInnertube = async (customFetch) => { - const shouldRefreshPlayer = - lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date(); + const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date(); + + const rawCookie = getCookie('youtube'); + const rawCookieValues = rawCookie?.values(); + const cookie = rawCookie?.toString(); + if (!innertube || shouldRefreshPlayer) { innertube = await Innertube.create({ fetch: customFetch, + retrieve_player: !!cookie, + cookie, + po_token: rawCookieValues?.po_token, + visitor_data: rawCookieValues?.visitor_data, }); lastRefreshedAt = +new Date(); } @@ -64,270 +88,432 @@ const cloneInnertube = async (customFetch) => { innertube.session.api_version, innertube.session.account_index, innertube.session.player, - undefined, + cookie, customFetch ?? innertube.session.http.fetch, - innertube.session.cache, + innertube.session.cache ); - const cookie = getCookie('youtube_oauth'); - const oauthData = transformSessionData(cookie); + const oauthCookie = getCookie('youtube_oauth'); + const oauthData = transformSessionData(oauthCookie); if (!session.logged_in && oauthData) { await session.oauth.init(oauthData); session.logged_in = true; } - if (session.logged_in) { + if (session.logged_in && oauthData) { if (session.oauth.shouldRefreshToken()) { await session.oauth.refreshAccessToken(); } - const cookieValues = cookie.values(); + const cookieValues = oauthCookie.values(); const oldExpiry = new Date(cookieValues.expiry_date); const newExpiry = new Date(session.oauth.oauth2_tokens.expiry_date); if (oldExpiry.getTime() !== newExpiry.getTime()) { - updateCookieValues(cookie, { + updateCookieValues(oauthCookie, { ...session.oauth.client_id, ...session.oauth.oauth2_tokens, - expiry_date: newExpiry.toISOString(), + expiry_date: newExpiry.toISOString() }); } } const yt = new Innertube(session); return yt; -}; +} export default async function (o) { let yt; try { - yt = await cloneInnertube((input, init) => - fetch(input, { + yt = await cloneInnertube( + (input, init) => fetch(input, { ...init, - dispatcher: o.dispatcher, - }), + dispatcher: o.dispatcher + }) ); } catch (e) { - if (e.message?.endsWith('decipher algorithm')) { - return { error: 'youtube.decipher' }; - } else if (e.message?.includes('refresh access token')) { - return { error: 'youtube.token_expired' }; + if (e.message?.endsWith("decipher algorithm")) { + return { error: "youtube.decipher" } + } else if (e.message?.includes("refresh access token")) { + return { error: "youtube.token_expired" } } else throw e; } - const quality = o.quality === 'max' ? '9000' : o.quality; + const cookie = getCookie('youtube')?.toString(); - let info, - isDubbed, - format = o.format || 'h264'; + let useHLS = o.youtubeHLS; - function qual(i) { - if (!i.quality_label) { - return; - } + // HLS playlists don't contain the av1 video format, at least with the iOS client + if (useHLS && o.format === "av1") { + useHLS = false; + } + + let innertubeClient = "ANDROID"; - return i.quality_label.split('p')[0].split('s')[0]; + if (cookie) { + useHLS = false; + innertubeClient = "WEB"; } + if (useHLS) { + innertubeClient = "IOS"; + } + + let info; try { - info = await yt.getBasicInfo( - o.id, - yt.session.logged_in ? 'ANDROID' : 'IOS', - ); + info = await yt.getBasicInfo(o.id, innertubeClient); } catch (e) { - if (e?.info?.reason === 'This video is private') { - return { error: 'content.video.private' }; - } else if (e?.message === 'This video is unavailable') { - return { error: 'content.video.unavailable' }; - } else { - return { error: 'fetch.fail' }; + if (e?.info) { + const errorInfo = JSON.parse(e?.info); + + if (errorInfo?.reason === "This video is private") { + return { error: "content.video.private" }; + } + if (["INVALID_ARGUMENT", "UNAUTHENTICATED"].includes(errorInfo?.error?.status)) { + return { error: "youtube.api_error" }; + } + } + + if (e?.message === "This video is unavailable") { + return { error: "content.video.unavailable" }; } + + return { error: "fetch.fail" }; } - if (!info) return { error: 'fetch.fail' }; + if (!info) return { error: "fetch.fail" }; const playability = info.playability_status; const basicInfo = info.basic_info; - if (playability.status === 'LOGIN_REQUIRED') { - if (playability.reason.endsWith('bot')) { - return { error: 'youtube.login' }; - } - if (playability.reason.endsWith('age')) { - return { error: 'content.video.age' }; - } - if (playability?.error_screen?.reason?.text === 'Private video') { - return { error: 'content.video.private' }; - } + switch (playability.status) { + case "LOGIN_REQUIRED": + if (playability.reason.endsWith("bot")) { + return { error: "youtube.login" } + } + if (playability.reason.endsWith("age")) { + return { error: "content.video.age" } + } + if (playability?.error_screen?.reason?.text === "Private video") { + return { error: "content.video.private" } + } + break; + + case "UNPLAYABLE": + if (playability?.reason?.endsWith("request limit.")) { + return { error: "fetch.rate" } + } + if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) { + return { error: "content.video.region" } + } + if (playability?.error_screen?.reason?.text === "Private video") { + return { error: "content.video.private" } + } + break; + + case "AGE_VERIFICATION_REQUIRED": + return { error: "content.video.age" }; } - if (playability.status === 'UNPLAYABLE') { - if (playability?.reason?.endsWith('request limit.')) { - return { error: 'fetch.rate' }; - } - if ( - playability?.error_screen?.subreason?.text?.endsWith( - 'in your country', - ) - ) { - return { error: 'content.video.region' }; - } - if (playability?.error_screen?.reason?.text === 'Private video') { - return { error: 'content.video.private' }; - } + if (playability.status !== "OK") { + return { error: "content.video.unavailable" }; } - if (playability.status !== 'OK') { - return { error: 'content.video.unavailable' }; - } if (basicInfo.is_live) { - return { error: 'content.video.live' }; + return { error: "content.video.live" }; + } + + if (basicInfo.duration > env.durationLimit) { + return { error: "content.too_long" }; } // return a critical error if returned video is "Video Not Available" // or a similar stub by youtube if (basicInfo.id !== o.id) { return { - error: 'fetch.fail', - critical: true, - }; + error: "fetch.fail", + critical: true + } } - const filterByCodec = (formats) => - formats - .filter( - (e) => - e.mime_type.includes(codecMatch[format].videoCodec) || - e.mime_type.includes(codecMatch[format].audioCodec), - ) - .sort((a, b) => Number(b.bitrate) - Number(a.bitrate)); - - let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); + const quality = o.quality === "max" ? 9000 : Number(o.quality); - if (adaptive_formats.length === 0 && format === 'vp9') { - format = 'h264'; - adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); + const normalizeQuality = res => { + const shortestSide = res.height > res.width ? res.width : res.height; + return videoQualities.find(qual => qual >= shortestSide); } - let bestQuality; + let video, audio, dubbedLanguage, + codec = o.format || "h264"; - const bestVideo = adaptive_formats.find( - (i) => i.has_video && i.content_length, - ); - const hasAudio = adaptive_formats.find( - (i) => i.has_audio && i.content_length, - ); + if (useHLS) { + const hlsManifest = info.streaming_data.hls_manifest_url; - if (bestVideo) bestQuality = qual(bestVideo); + if (!hlsManifest) { + return { error: "youtube.no_hls_streams" }; + } - if ((!bestQuality && !o.isAudioOnly) || !hasAudio) - return { error: 'youtube.codec' }; + const fetchedHlsManifest = await fetch(hlsManifest, { + dispatcher: o.dispatcher, + }).then(r => { + if (r.status === 200) { + return r.text(); + } else { + throw new Error("couldn't fetch the HLS playlist"); + } + }).catch(() => { }); + + if (!fetchedHlsManifest) { + return { error: "youtube.no_hls_streams" }; + } + + const variants = HLS.parse(fetchedHlsManifest).variants.sort( + (a, b) => Number(b.bandwidth) - Number(a.bandwidth) + ); - if (basicInfo.duration > env.durationLimit) - return { error: 'content.too_long' }; + if (!variants || variants.length === 0) { + return { error: "youtube.no_hls_streams" }; + } - const checkBestAudio = (i) => i.has_audio && !i.has_video; + const matchHlsCodec = codecs => ( + codecs.includes(hlsCodecList[codec].videoCodec) + ); - let audio = adaptive_formats.find( - (i) => checkBestAudio(i) && i.is_original, - ); + const best = variants.find(i => matchHlsCodec(i.codecs)); - if (o.dubLang) { - let dubbedAudio = adaptive_formats.find( - (i) => - checkBestAudio(i) && i.language === o.dubLang && i.audio_track, + const preferred = variants.find(i => + matchHlsCodec(i.codecs) && normalizeQuality(i.resolution) === quality ); - if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) { - audio = dubbedAudio; - isDubbed = true; + let selected = preferred || best; + + if (!selected) { + codec = "h264"; + selected = variants.find(i => matchHlsCodec(i.codecs)); + } + + if (!selected) { + return { error: "youtube.no_matching_format" }; + } + + audio = selected.audio.find(i => i.isDefault); + + // some videos (mainly those with AI dubs) don't have any tracks marked as default + // why? god knows, but we assume that a default track is marked as such in the title + if (!audio) { + audio = selected.audio.find(i => i.name.endsWith("- original")); + } + + if (o.dubLang) { + const dubbedAudio = selected.audio.find(i => + i.language?.startsWith(o.dubLang) + ); + + if (dubbedAudio && !dubbedAudio.isDefault) { + dubbedLanguage = dubbedAudio.language; + audio = dubbedAudio; + } + } + + selected.audio = []; + selected.subtitles = []; + video = selected; + } else { + // i miss typescript so bad + const sorted_formats = { + h264: { + video: [], + audio: [], + bestVideo: undefined, + bestAudio: undefined, + }, + vp9: { + video: [], + audio: [], + bestVideo: undefined, + bestAudio: undefined, + }, + av1: { + video: [], + audio: [], + bestVideo: undefined, + bestAudio: undefined, + }, + } + + const checkFormat = (format, pCodec) => format.content_length && + (format.mime_type.includes(codecList[pCodec].videoCodec) + || format.mime_type.includes(codecList[pCodec].audioCodec)); + + // sort formats & weed out bad ones + info.streaming_data.adaptive_formats.sort((a, b) => + Number(b.bitrate) - Number(a.bitrate) + ).forEach(format => { + Object.keys(codecList).forEach(yCodec => { + const sorted = sorted_formats[yCodec]; + const goodFormat = checkFormat(format, yCodec); + if (!goodFormat) return; + + if (format.has_video) { + sorted.video.push(format); + if (!sorted.bestVideo) sorted.bestVideo = format; + } + if (format.has_audio) { + sorted.audio.push(format); + if (!sorted.bestAudio) sorted.bestAudio = format; + } + }) + }); + + const noBestMedia = () => { + const vid = sorted_formats[codec]?.bestVideo; + const aud = sorted_formats[codec]?.bestAudio; + return (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly) + }; + + if (noBestMedia()) { + if (codec === "av1") codec = "vp9"; + else if (codec === "vp9") codec = "av1"; + + // if there's no higher quality fallback, then use h264 + if (noBestMedia()) codec = "h264"; + } + + // if there's no proper combo of av1, vp9, or h264, then give up + if (noBestMedia()) { + return { error: "youtube.no_matching_format" }; + } + + audio = sorted_formats[codec].bestAudio; + + if (audio?.audio_track && !audio?.audio_track?.audio_is_default) { + audio = sorted_formats[codec].audio.find(i => + i?.audio_track?.audio_is_default + ); + } + + if (o.dubLang) { + const dubbedAudio = sorted_formats[codec].audio.find(i => + i.language?.startsWith(o.dubLang) && i.audio_track + ); + + if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) { + audio = dubbedAudio; + dubbedLanguage = dubbedAudio.language; + } + } + + if (!o.isAudioOnly) { + const qual = (i) => { + return normalizeQuality({ + width: i.width, + height: i.height, + }) + } + + const bestQuality = qual(sorted_formats[codec].bestVideo); + const useBestQuality = quality >= bestQuality; + + video = useBestQuality + ? sorted_formats[codec].bestVideo + : sorted_formats[codec].video.find(i => qual(i) === quality); + + if (!video) video = sorted_formats[codec].bestVideo; } } - if (!audio) { - audio = adaptive_formats.find((i) => checkBestAudio(i)); + const fileMetadata = { + title: basicInfo.title.trim(), + artist: basicInfo.author.replace("- Topic", "").trim() } - let fileMetadata = { - title: cleanString(basicInfo.title.trim()), - artist: cleanString(basicInfo.author.replace('- Topic', '').trim()), - }; - - if (basicInfo?.short_description?.startsWith('Provided to YouTube by')) { - let descItems = basicInfo.short_description.split('\n\n'); - fileMetadata.album = descItems[2]; - fileMetadata.copyright = descItems[3]; - if (descItems[4].startsWith('Released on:')) { - fileMetadata.date = descItems[4] - .replace('Released on: ', '') - .trim(); + if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) { + const descItems = basicInfo.short_description.split("\n\n", 5); + + if (descItems.length === 5) { + fileMetadata.album = descItems[2]; + fileMetadata.copyright = descItems[3]; + if (descItems[4].startsWith("Released on:")) { + fileMetadata.date = descItems[4].replace("Released on: ", '').trim(); + } } } - let filenameAttributes = { - service: 'youtube', + const filenameAttributes = { + service: "youtube", id: o.id, title: fileMetadata.title, author: fileMetadata.artist, - youtubeDubName: isDubbed ? o.dubLang : false, - }; + youtubeDubName: dubbedLanguage || false, + } + + if (audio && o.isAudioOnly) { + let bestAudio = codec === "h264" ? "m4a" : "opus"; + let urls = audio.url; + + if (useHLS) { + bestAudio = "mp3"; + urls = audio.uri; + } + + if (innertubeClient === "WEB" && innertube) { + urls = audio.decipher(innertube.session.player); + } - if (audio && o.isAudioOnly) return { - type: 'audio', + type: "audio", isAudioOnly: true, - urls: audio.decipher(yt.session.player), - filenameAttributes: filenameAttributes, - fileMetadata: fileMetadata, - bestAudio: format === 'h264' ? 'm4a' : 'opus', - }; - - const matchingQuality = - Number(quality) > Number(bestQuality) ? bestQuality : quality, - checkSingle = (i) => - qual(i) === matchingQuality && - i.mime_type.includes(codecMatch[format].videoCodec), - checkRender = (i) => - qual(i) === matchingQuality && i.has_video && !i.has_audio; - - let match, type, urls; - - // prefer good premuxed videos if available - if ( - !o.isAudioOnly && - !o.isAudioMuted && - format === 'h264' && - bestVideo.fps <= 30 - ) { - match = info.streaming_data.formats.find(checkSingle); - type = 'proxy'; - urls = match?.decipher(yt.session.player); + urls, + filenameAttributes, + fileMetadata, + bestAudio, + isHLS: useHLS, + } } - const video = adaptive_formats.find(checkRender); + if (video && audio) { + let resolution; - if (!match && video && audio) { - match = video; - type = 'merge'; - urls = [ - video.decipher(yt.session.player), - audio.decipher(yt.session.player), - ]; - } + if (useHLS) { + resolution = normalizeQuality(video.resolution); + filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`; + filenameAttributes.extension = hlsCodecList[codec].container; + + video = video.uri; + audio = audio.uri; + } else { + resolution = normalizeQuality({ + width: video.width, + height: video.height, + }); + + filenameAttributes.resolution = `${video.width}x${video.height}`; + filenameAttributes.extension = codecList[codec].container; + + video = video.url; + audio = audio.url; + + if (innertubeClient === "WEB" && innertube) { + video = video.decipher(innertube.session.player); + audio = audio.decipher(innertube.session.player); + } + } + + filenameAttributes.qualityLabel = `${resolution}p`; + filenameAttributes.youtubeFormat = codec; - if (match) { - filenameAttributes.qualityLabel = match.quality_label; - filenameAttributes.resolution = `${match.width}x${match.height}`; - filenameAttributes.extension = codecMatch[format].container; - filenameAttributes.youtubeFormat = format; return { - type, - urls, + type: "merge", + urls: [ + video, + audio, + ], filenameAttributes, fileMetadata, - }; + isHLS: useHLS, + } } - return { error: 'fetch.fail' }; + return { error: "youtube.no_matching_format" }; } diff --git a/api/src/processing/url.js b/api/src/processing/url.js index a8e6993..8f0e7dc 100644 --- a/api/src/processing/url.js +++ b/api/src/processing/url.js @@ -1,4 +1,4 @@ -import psl from "psl"; +import psl from "@imput/psl"; import { strict as assert } from "node:assert"; import { env } from "../config.js"; @@ -42,7 +42,7 @@ function aliasURL(url) { case "fixvx": case "x": if (services.twitter.altDomains.includes(url.hostname)) { - url.hostname = 'twitter.com' + url.hostname = 'twitter.com'; } break; @@ -85,6 +85,13 @@ function aliasURL(url) { url.hostname = 'instagram.com'; } break; + + case "vk": + case "vkvideo": + if (services.vk.altDomains.includes(url.hostname)) { + url.hostname = 'vk.com'; + } + break; } return url @@ -120,6 +127,11 @@ function cleanURL(url) { limitQuery('p') } break; + case "twitter": + if (url.searchParams.get('post_id')) { + limitQuery('post_id') + } + break; } if (stripQuery) { @@ -169,6 +181,11 @@ export function extract(url) { } if (!env.enabledServices.has(host)) { + // show a different message when youtube is disabled on official instances + // as it only happens when shit hits the fan + if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") { + return { error: "youtube.temporary_disabled" }; + } return { error: "service.disabled" }; } diff --git a/api/src/security/api-keys.js b/api/src/security/api-keys.js new file mode 100644 index 0000000..d534999 --- /dev/null +++ b/api/src/security/api-keys.js @@ -0,0 +1,227 @@ +import { env } from "../config.js"; +import { readFile } from "node:fs/promises"; +import { Green, Yellow } from "../misc/console-text.js"; +import ip from "ipaddr.js"; +import * as cluster from "../misc/cluster.js"; + +// this function is a modified variation of code +// from https://stackoverflow.com/a/32402438/14855621 +const generateWildcardRegex = rule => { + var escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); + return new RegExp("^" + rule.split("*").map(escapeRegex).join(".*") + "$"); +} + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +let keys = {}; + +const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']); + +/* Expected format pseudotype: +** type KeyFileContents = Record< +** UUIDv4String, +** { +** name?: string, +** limit?: number | "unlimited", +** ips?: CIDRString[], +** userAgents?: string[] +** } +** >; +*/ + +const validateKeys = (input) => { + if (typeof input !== 'object' || input === null) { + throw "input is not an object"; + } + + if (Object.keys(input).some(x => !UUID_REGEX.test(x))) { + throw "key file contains invalid key(s)"; + } + + Object.values(input).forEach(details => { + if (typeof details !== 'object' || details === null) { + throw "some key(s) are incorrectly configured"; + } + + const unexpected_key = Object.keys(details).find(k => !ALLOWED_KEYS.has(k)); + if (unexpected_key) { + throw "detail object contains unexpected key: " + unexpected_key; + } + + if (details.limit && details.limit !== 'unlimited') { + if (typeof details.limit !== 'number') + throw "detail object contains invalid limit (not a number)"; + else if (details.limit < 1) + throw "detail object contains invalid limit (not a positive number)"; + } + + if (details.ips) { + if (!Array.isArray(details.ips)) + throw "details object contains value for `ips` which is not an array"; + + const invalid_ip = details.ips.find( + addr => typeof addr !== 'string' || (!ip.isValidCIDR(addr) && !ip.isValid(addr)) + ); + + if (invalid_ip) { + throw "`ips` in details contains an invalid IP or CIDR range: " + invalid_ip; + } + } + + if (details.userAgents) { + if (!Array.isArray(details.userAgents)) + throw "details object contains value for `userAgents` which is not an array"; + + const invalid_ua = details.userAgents.find(ua => typeof ua !== 'string'); + if (invalid_ua) { + throw "`userAgents` in details contains an invalid user agent: " + invalid_ua; + } + } + }); +} + +const formatKeys = (keyData) => { + const formatted = {}; + + for (let key in keyData) { + const data = keyData[key]; + key = key.toLowerCase(); + + formatted[key] = {}; + + if (data.limit) { + if (data.limit === "unlimited") { + data.limit = Infinity; + } + + formatted[key].limit = data.limit; + } + + if (data.ips) { + formatted[key].ips = data.ips.map(addr => { + if (ip.isValid(addr)) { + const parsed = ip.parse(addr); + const range = parsed.kind() === 'ipv6' ? 128 : 32; + return [ parsed, range ]; + } + + return ip.parseCIDR(addr); + }); + } + + if (data.userAgents) { + formatted[key].userAgents = data.userAgents.map(generateWildcardRegex); + } + } + + return formatted; +} + +const updateKeys = (newKeys) => { + keys = formatKeys(newKeys); +} + +const loadKeys = async (source) => { + let updated; + if (source.protocol === 'file:') { + const pathname = source.pathname === '/' ? '' : source.pathname; + updated = JSON.parse( + await readFile( + decodeURIComponent(source.host + pathname), + 'utf8' + ) + ); + } else { + updated = await fetch(source).then(a => a.json()); + } + + validateKeys(updated); + + cluster.broadcast({ api_keys: updated }); + + updateKeys(updated); +} + +const wrapLoad = (url, initial = false) => { + loadKeys(url) + .then(() => { + if (initial) { + console.log(`${Green('[✓]')} api keys loaded successfully!`) + } + }) + .catch((e) => { + console.error(`${Yellow('[!]')} Failed loading API keys at ${new Date().toISOString()}.`); + console.error('Error:', e); + }) +} + +const err = (reason) => ({ success: false, error: reason }); + +export const validateAuthorization = (req) => { + const authHeader = req.get('Authorization'); + + if (typeof authHeader !== 'string') { + return err("missing"); + } + + const [ authType, keyString ] = authHeader.split(' ', 2); + if (authType.toLowerCase() !== 'api-key') { + return err("not_api_key"); + } + + if (!UUID_REGEX.test(keyString) || `${authType} ${keyString}` !== authHeader) { + return err("invalid"); + } + + const matchingKey = keys[keyString.toLowerCase()]; + if (!matchingKey) { + return err("not_found"); + } + + if (matchingKey.ips) { + let addr; + try { + addr = ip.parse(req.ip); + } catch { + return err("invalid_ip"); + } + + const ip_allowed = matchingKey.ips.some( + ([ allowed, size ]) => { + return addr.kind() === allowed.kind() + && addr.match(allowed, size); + } + ); + + if (!ip_allowed) { + return err("ip_not_allowed"); + } + } + + if (matchingKey.userAgents) { + const userAgent = req.get('User-Agent'); + if (!matchingKey.userAgents.some(regex => regex.test(userAgent))) { + return err("ua_not_allowed"); + } + } + + req.rateLimitKey = keyString.toLowerCase(); + req.rateLimitMax = matchingKey.limit; + + return { success: true }; +} + +export const setup = (url) => { + if (cluster.isPrimary) { + wrapLoad(url, true); + if (env.keyReloadInterval > 0) { + setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000); + } + } else if (cluster.isWorker) { + process.on('message', (message) => { + if ('api_keys' in message) { + updateKeys(message.api_keys); + } + }); + } +} diff --git a/api/src/security/secrets.js b/api/src/security/secrets.js new file mode 100644 index 0000000..fff24f8 --- /dev/null +++ b/api/src/security/secrets.js @@ -0,0 +1,62 @@ +import cluster from "node:cluster"; +import { createHmac, randomBytes } from "node:crypto"; + +const generateSalt = () => { + if (cluster.isPrimary) + return randomBytes(64); + + return null; +} + +let rateSalt = generateSalt(); +let streamSalt = generateSalt(); + +export const syncSecrets = () => { + return new Promise((resolve, reject) => { + if (cluster.isPrimary) { + let remaining = Object.values(cluster.workers).length; + const handleReady = (worker, m) => { + if (m.ready) + worker.send({ rateSalt, streamSalt }); + + if (!--remaining) + resolve(); + } + + for (const worker of Object.values(cluster.workers)) { + worker.once( + 'message', + (m) => handleReady(worker, m) + ); + } + } else if (cluster.isWorker) { + if (rateSalt || streamSalt) + return reject(); + + process.send({ ready: true }); + process.once('message', (message) => { + if (rateSalt || streamSalt) + return reject(); + + if (message.rateSalt && message.streamSalt) { + streamSalt = Buffer.from(message.streamSalt); + rateSalt = Buffer.from(message.rateSalt); + resolve(); + } + }); + } else reject(); + }); +} + + +export const hashHmac = (value, type) => { + let salt; + if (type === 'rate') + salt = rateSalt; + else if (type === 'stream') + salt = streamSalt; + else + throw "unknown salt"; + + return createHmac("sha256", salt).update(value).digest(); +} diff --git a/api/src/store/base-store.js b/api/src/store/base-store.js new file mode 100644 index 0000000..c2a59ff --- /dev/null +++ b/api/src/store/base-store.js @@ -0,0 +1,48 @@ +const _stores = new Set(); + +export class Store { + id; + + constructor(name) { + name = name.toUpperCase(); + + if (_stores.has(name)) + throw `${name} store already exists`; + _stores.add(name); + + this.id = name; + } + + async _has(_key) { await Promise.reject("needs implementation"); } + has(key) { + if (typeof key !== 'string') { + key = key.toString(); + } + + return this._has(key); + } + + async _get(_key) { await Promise.reject("needs implementation"); } + async get(key) { + if (typeof key !== 'string') { + key = key.toString(); + } + + const val = await this._get(key); + if (val === null) + return null; + + return val; + } + + async _set(_key, _val, _exp_sec = -1) { await Promise.reject("needs implementation") } + set(key, val, exp_sec = -1) { + if (typeof key !== 'string') { + key = key.toString(); + } + + exp_sec = Math.round(exp_sec); + + return this._set(key, val, exp_sec); + } +}; diff --git a/api/src/store/memory-store.js b/api/src/store/memory-store.js new file mode 100644 index 0000000..100a0e0 --- /dev/null +++ b/api/src/store/memory-store.js @@ -0,0 +1,77 @@ +import { MinPriorityQueue } from '@datastructures-js/priority-queue'; +import { Store } from './base-store.js'; + +// minimum delay between sweeps to avoid repeatedly +// sweeping entries close in proximity one by one. +const MIN_THRESHOLD_MS = 2500; + +export default class MemoryStore extends Store { + #store = new Map(); + #timeouts = new MinPriorityQueue/*<{ t: number, k: unknown }>*/((obj) => obj.t); + #nextSweep = { id: null, t: null }; + + constructor(name) { + super(name); + } + + _has(key) { + return this.#store.has(key); + } + + _get(key) { + const val = this.#store.get(key); + + return val === undefined ? null : val; + } + + _set(key, val, exp_sec = -1) { + if (this.#store.has(key)) { + this.#timeouts.remove(o => o.k === key); + } + + if (exp_sec > 0) { + const exp = 1000 * exp_sec; + const timeout_at = +new Date() + exp; + + this.#timeouts.enqueue({ k: key, t: timeout_at }); + } + + this.#store.set(key, val); + this.#reschedule(); + } + + #reschedule() { + const current_time = new Date().getTime(); + const time = this.#timeouts.front()?.t; + if (!time) { + return; + } else if (time < current_time) { + return this.#sweepNow(); + } + + const sweep = this.#nextSweep; + if (sweep.id === null || sweep.t > time) { + if (sweep.id) { + clearTimeout(sweep.id); + } + + sweep.t = time; + sweep.id = setTimeout( + () => this.#sweepNow(), + Math.max(MIN_THRESHOLD_MS, time - current_time) + ); + sweep.id.unref(); + } + } + + #sweepNow() { + while (this.#timeouts.front()?.t < new Date().getTime()) { + const item = this.#timeouts.dequeue(); + this.#store.delete(item.k); + } + + this.#nextSweep.id = null; + this.#nextSweep.t = null; + this.#reschedule(); + } +} diff --git a/api/src/store/redis-ratelimit.js b/api/src/store/redis-ratelimit.js new file mode 100644 index 0000000..64d11e5 --- /dev/null +++ b/api/src/store/redis-ratelimit.js @@ -0,0 +1,19 @@ +import { env } from "../config.js"; + +let client, redis, redisLimiter; + +export const createStore = async (name) => { + if (!env.redisURL) return; + + if (!client) { + redis = await import('redis'); + redisLimiter = await import('rate-limit-redis'); + client = redis.createClient({ url: env.redisURL }); + await client.connect(); + } + + return new redisLimiter.default({ + prefix: `RL${name}_`, + sendCommand: (...args) => client.sendCommand(args), + }); +} diff --git a/api/src/store/redis-store.js b/api/src/store/redis-store.js new file mode 100644 index 0000000..0b35952 --- /dev/null +++ b/api/src/store/redis-store.js @@ -0,0 +1,64 @@ +import { commandOptions, createClient } from "redis"; +import { env } from "../config.js"; +import { Store } from "./base-store.js"; + +export default class RedisStore extends Store { + #client = createClient({ + url: env.redisURL, + }); + #connected; + + constructor(name) { + super(name); + this.#connected = this.#client.connect(); + } + + #keyOf(key) { + return this.id + '_' + key; + } + + async _has(key) { + await this.#connected; + + return this.#client.hExists(key); + } + + async _get(key) { + await this.#connected; + + const valueType = await this.#client.get(this.#keyOf(key) + '_t'); + const value = await this.#client.get( + commandOptions({ returnBuffers: true }), + this.#keyOf(key) + ); + + if (!value) { + return null; + } + + if (valueType === 'b') + return value; + else + return JSON.parse(value); + } + + async _set(key, val, exp_sec = -1) { + await this.#connected; + + const options = exp_sec > 0 ? { EX: exp_sec } : undefined; + + if (val instanceof Buffer) { + await this.#client.set( + this.#keyOf(key) + '_t', + 'b', + options + ); + } + + await this.#client.set( + this.#keyOf(key), + val, + options + ); + } +} diff --git a/api/src/store/store.js b/api/src/store/store.js new file mode 100644 index 0000000..e268d88 --- /dev/null +++ b/api/src/store/store.js @@ -0,0 +1,10 @@ +import { env } from '../config.js'; + +let _export; +if (env.redisURL) { + _export = await import('./redis-store.js'); +} else { + _export = await import('./memory-store.js'); +} + +export default _export.default; diff --git a/api/src/stream/internal-hls.js b/api/src/stream/internal-hls.js index 07fcebd..55634c7 100644 --- a/api/src/stream/internal-hls.js +++ b/api/src/stream/internal-hls.js @@ -16,15 +16,17 @@ function transformObject(streamInfo, hlsObject) { let fullUrl; if (getURL(hlsObject.uri)) { - fullUrl = hlsObject.uri; + fullUrl = new URL(hlsObject.uri); } else { fullUrl = new URL(hlsObject.uri, streamInfo.url); } - hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo); + if (fullUrl.hostname !== '127.0.0.1') { + hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo); - if (hlsObject.map) { - hlsObject.map = transformObject(streamInfo, hlsObject.map); + if (hlsObject.map) { + hlsObject.map = transformObject(streamInfo, hlsObject.map); + } } return hlsObject; @@ -53,7 +55,7 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) { const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"]; -export function isHlsRequest (req) { +export function isHlsResponse (req) { return HLS_MIME_TYPES.includes(req.headers['content-type']); } diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js index 51552d4..7d8bf4c 100644 --- a/api/src/stream/internal.js +++ b/api/src/stream/internal.js @@ -1,7 +1,7 @@ import { request } from "undici"; import { Readable } from "node:stream"; import { closeRequest, getHeaders, pipe } from "./shared.js"; -import { handleHlsPlaylist, isHlsRequest } from "./internal-hls.js"; +import { handleHlsPlaylist, isHlsResponse } from "./internal-hls.js"; const CHUNK_SIZE = BigInt(8e6); // 8 MB const min = (a, b) => a < b ? a : b; @@ -83,7 +83,7 @@ async function handleGenericStream(streamInfo, res) { const cleanup = () => res.end(); try { - const req = await request(streamInfo.url, { + const fileResponse = await request(streamInfo.url, { headers: { ...Object.fromEntries(streamInfo.headers), host: undefined @@ -93,19 +93,28 @@ async function handleGenericStream(streamInfo, res) { maxRedirections: 16 }); - res.status(req.statusCode); - req.body.on('error', () => {}); + res.status(fileResponse.statusCode); + fileResponse.body.on('error', () => {}); - for (const [ name, value ] of Object.entries(req.headers)) - res.setHeader(name, value) + // bluesky's cdn responds with wrong content-type for the hls playlist, + // so we enforce it here until they fix it + const isHls = isHlsResponse(fileResponse) + || (streamInfo.service === "bsky" && streamInfo.url.endsWith('.m3u8')); - if (req.statusCode < 200 || req.statusCode > 299) + for (const [ name, value ] of Object.entries(fileResponse.headers)) { + if (!isHls || name.toLowerCase() !== 'content-length') { + res.setHeader(name, value); + } + } + + if (fileResponse.statusCode < 200 || fileResponse.statusCode > 299) { return cleanup(); + } - if (isHlsRequest(req)) { - await handleHlsPlaylist(streamInfo, req, res); + if (isHls) { + await handleHlsPlaylist(streamInfo, fileResponse, res); } else { - pipe(req.body, res, cleanup); + pipe(fileResponse.body, res, cleanup); } } catch { closeRequest(streamInfo.controller); @@ -114,7 +123,11 @@ async function handleGenericStream(streamInfo, res) { } export function internalStream(streamInfo, res) { - if (streamInfo.service === 'youtube') { + if (streamInfo.headers) { + streamInfo.headers.delete('icy-metadata'); + } + + if (streamInfo.service === 'youtube' && !streamInfo.isHLS) { return handleYoutubeStream(streamInfo, res); } diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js index 81f7a06..79b5c1d 100644 --- a/api/src/stream/manage.js +++ b/api/src/stream/manage.js @@ -1,37 +1,32 @@ -import NodeCache from 'node-cache'; +import Store from "../store/store.js"; -import { nanoid } from 'nanoid'; -import { randomBytes } from 'crypto'; -import { strict as assert } from 'assert'; -import { setMaxListeners } from 'node:events'; +import { nanoid } from "nanoid"; +import { randomBytes } from "crypto"; +import { strict as assert } from "assert"; +import { setMaxListeners } from "node:events"; -import { env } from '../config.js'; -import { closeRequest } from './shared.js'; -import { decryptStream, encryptStream, generateHmac } from '../misc/crypto.js'; +import { env } from "../config.js"; +import { closeRequest } from "./shared.js"; +import { decryptStream, encryptStream } from "../misc/crypto.js"; +import { hashHmac } from "../security/secrets.js"; // optional dependency -const freebind = env.freebindCIDR && (await import('freebind').catch(() => {})); +const freebind = env.freebindCIDR && await import('freebind').catch(() => {}); -const streamCache = new NodeCache({ - stdTTL: env.streamLifespan, - checkperiod: 10, - deleteOnExpire: true, -}); - -streamCache.on('expired', (key) => { - streamCache.del(key); -}); +const streamCache = new Store('streams'); const internalStreamCache = new Map(); -const hmacSalt = randomBytes(64).toString('hex'); export function createStream(obj) { const streamID = nanoid(), + iv = randomBytes(16).toString('base64url'), + secret = randomBytes(32).toString('base64url'), exp = new Date().getTime() + env.streamLifespan * 1000, + hmac = hashHmac(`${streamID},${exp},${iv},${secret}`, 'stream').toString('base64url'), streamData = { exp: exp, type: obj.type, - urls: obj.u, + urls: obj.url, service: obj.service, filename: obj.filename, @@ -43,15 +38,28 @@ export function createStream(obj) { audioBitrate: obj.audioBitrate, audioCopy: !!obj.audioCopy, audioFormat: obj.audioFormat, + + isHLS: obj.isHLS || false, }; - streamCache.set(streamID, streamData); + // FIXME: this is now a Promise, but it is not awaited + // here. it may happen that the stream is not + // stored in the Store before it is requested. + streamCache.set( + streamID, + encryptStream(streamData, iv, secret), + env.streamLifespan + ); + let streamLink = new URL('/tunnel', env.apiURL); const params = { - id: streamID, - exp: exp, - }; + 'id': streamID, + 'exp': exp, + 'sig': hmac, + 'sec': secret, + 'iv': iv + } for (const [key, value] of Object.entries(params)) { streamLink.searchParams.append(key, value); @@ -67,11 +75,9 @@ export function getInternalStream(id) { export function createInternalStream(url, obj = {}) { assert(typeof url === 'string'); - let dispatcher; + let dispatcher = obj.dispatcher; if (obj.requestIP) { - dispatcher = freebind?.dispatcherFromIP(obj.requestIP, { - strict: false, - }); + dispatcher = freebind?.dispatcherFromIP(obj.requestIP, { strict: false }) } const streamID = nanoid(); @@ -93,15 +99,16 @@ export function createInternalStream(url, obj = {}) { headers, controller, dispatcher, + isHLS: obj.isHLS, }); - let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.apiPort}`); + let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.tunnelPort}`); streamLink.searchParams.set('id', streamID); const cleanup = () => { destroyInternalStream(streamLink); controller.signal.removeEventListener('abort', cleanup); - }; + } controller.signal.addEventListener('abort', cleanup); @@ -126,14 +133,11 @@ function wrapStream(streamInfo) { const url = streamInfo.urls; if (typeof url === 'string') { - console.log(`[wrapStream] url is a string`); streamInfo.urls = createInternalStream(url, streamInfo); } else if (Array.isArray(url)) { - console.log(`[wrapStream] url is an array`); for (const idx in streamInfo.urls) { streamInfo.urls[idx] = createInternalStream( - streamInfo.urls[idx], - streamInfo, + streamInfo.urls[idx], streamInfo ); } } else throw 'invalid urls'; @@ -141,13 +145,24 @@ function wrapStream(streamInfo) { return streamInfo; } -export function verifyStream(id, exp) { - console.log(`[verifyStream] id: ${id}`); - const streamInfo = streamCache.get(id.toString()); - console.log(`[verifyStream] streamInfo: ${streamInfo}`); - if (!streamInfo) return { status: 404 }; +export async function verifyStream(id, hmac, exp, secret, iv) { + try { + const ghmac = hashHmac(`${id},${exp},${iv},${secret}`, 'stream').toString('base64url'); + const cache = await streamCache.get(id.toString()); + + if (ghmac !== String(hmac)) return { status: 401 }; + if (!cache) return { status: 404 }; - if (Number(exp) <= new Date().getTime()) return { status: 404 }; - console.log(`[verifyStream] stream is OK! Returning streamInfo`); - return wrapStream(streamInfo); + const streamInfo = JSON.parse(decryptStream(cache, iv, secret)); + + if (!streamInfo) return { status: 404 }; + + if (Number(exp) <= new Date().getTime()) + return { status: 404 }; + + return wrapStream(streamInfo); + } + catch { + return { status: 500 }; + } } diff --git a/api/src/stream/shared.js b/api/src/stream/shared.js index 91e1ac2..65af03f 100644 --- a/api/src/stream/shared.js +++ b/api/src/stream/shared.js @@ -1,4 +1,5 @@ import { genericUserAgent } from "../config.js"; +import { vkClientAgent } from "../processing/services/vk.js"; const defaultHeaders = { 'user-agent': genericUserAgent @@ -13,6 +14,9 @@ const serviceHeaders = { origin: 'https://www.youtube.com', referer: 'https://www.youtube.com', DNT: '?1' + }, + vk: { + 'user-agent': vkClientAgent } } diff --git a/api/src/stream/types.js b/api/src/stream/types.js index aa9becf..0a4e2d4 100644 --- a/api/src/stream/types.js +++ b/api/src/stream/types.js @@ -1,10 +1,9 @@ -import { request } from "undici"; +import { Agent, request } from "undici"; import ffmpeg from "ffmpeg-static"; import { spawn } from "child_process"; import { create as contentDisposition } from "content-disposition-header"; import { env } from "../config.js"; -import { metadataManager } from "../misc/utils.js"; import { destroyInternalStream } from "./manage.js"; import { hlsExceptions } from "../processing/service-config.js"; import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js"; @@ -16,6 +15,29 @@ const ffmpegArgs = { gif: ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"] } +const metadataTags = [ + "album", + "copyright", + "title", + "artist", + "track", + "date", +]; + +const convertMetadataToFFmpeg = (metadata) => { + let args = []; + + for (const [ name, value ] of Object.entries(metadata)) { + if (metadataTags.includes(name)) { + args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); + } else { + throw `${name} metadata tag is not supported.`; + } + } + + return args; +} + const toRawHeaders = (headers) => { return Object.entries(headers) .map(([key, value]) => `${key}: ${value}\r\n`) @@ -38,6 +60,8 @@ const getCommand = (args) => { return [ffmpeg, args] } +const defaultAgent = new Agent(); + const proxy = async (streamInfo, res) => { const abortController = new AbortController(); const shutdown = () => ( @@ -56,7 +80,8 @@ const proxy = async (streamInfo, res) => { Range: streamInfo.range }, signal: abortController.signal, - maxRedirections: 16 + maxRedirections: 16, + dispatcher: defaultAgent, }); res.status(statusCode); @@ -101,12 +126,16 @@ const merge = (streamInfo, res) => { args = args.concat(ffmpegArgs[format]); - if (hlsExceptions.includes(streamInfo.service)) { - args.push('-bsf:a', 'aac_adtstoasc') + if (hlsExceptions.includes(streamInfo.service) && streamInfo.isHLS) { + if (streamInfo.service === "youtube" && format === "webm") { + args.push('-c:a', 'libopus'); + } else { + args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc'); + } } if (streamInfo.metadata) { - args = args.concat(metadataManager(streamInfo.metadata)) + args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata)) } args.push('-f', format, 'pipe:3'); @@ -238,7 +267,7 @@ const convertAudio = (streamInfo, res) => { } if (streamInfo.metadata) { - args = args.concat(metadataManager(streamInfo.metadata)) + args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata)) } args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3'); @@ -291,7 +320,7 @@ const convertGif = (streamInfo, res) => { const [,,, muxOutput] = process.stdio; res.setHeader('Connection', 'keep-alive'); - res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename.split('.')[0] + ".gif")); + res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); pipe(muxOutput, res, shutdown); diff --git a/api/src/thvideodl.js b/api/src/thvideodl.js deleted file mode 100644 index 09b1707..0000000 --- a/api/src/thvideodl.js +++ /dev/null @@ -1,27 +0,0 @@ -import "dotenv/config"; - -import express from "express"; - -import path from 'path'; -import { fileURLToPath } from 'url'; - -import { env } from "./config.js" -import { Bright, Green, Red } from "./misc/console-text.js"; - -const app = express(); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename).slice(0, -4); - -app.disable('x-powered-by'); - -if (env.apiURL) { - const { runAPI } = await import('./core/api.js'); - runAPI(express, app, __dirname) -} else { - console.log( - Red(`Team Hydra Video DL wasn't configured yet or configuration is invalid.\n`) - + Bright(`please run the setup script to fix this: `) - + Green(`npm run setup`) - ) -} diff --git a/api/src/util/generate-jwt-secret.js b/api/src/util/generate-jwt-secret.js new file mode 100644 index 0000000..8db6e23 --- /dev/null +++ b/api/src/util/generate-jwt-secret.js @@ -0,0 +1,22 @@ +// run with `pnpm -r token:jwt` + +const makeSecureString = (length = 64) => { + const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; + const out = []; + + while (out.length < length) { + for (const byte of crypto.getRandomValues(new Uint8Array(length))) { + if (byte < alphabet.length) { + out.push(alphabet[byte]); + } + + if (out.length === length) { + break; + } + } + } + + return out.join(''); +} + +console.log(`JWT_SECRET: ${JSON.stringify(makeSecureString(64))}`) diff --git a/api/src/util/test.js b/api/src/util/test.js index 34afde7..2ba555e 100644 --- a/api/src/util/test.js +++ b/api/src/util/test.js @@ -1,84 +1,129 @@ -import "dotenv/config"; +import path from "node:path"; -import { services } from "../processing/service-config.js"; -import { extract } from "../processing/url.js"; -import match from "../processing/match.js"; -import { loadJSON } from "../misc/load-from-fs.js"; -import { normalizeRequest } from "../processing/request.js"; import { env } from "../config.js"; +import { runTest } from "../misc/run-test.js"; +import { loadJSON } from "../misc/load-from-fs.js"; +import { Red, Bright } from "../misc/console-text.js"; +import { randomizeCiphers } from "../misc/randomize-ciphers.js"; -env.apiURL = 'http://localhost:9000' -let tests = loadJSON('./src/util/tests.json'); - -let noTest = []; -let failed = []; -let success = 0; - -function addToFail(service, testName, url, status, response) { - failed.push({ - service: service, - name: testName, - url: url, - status: status, - response: response - }) -} -for (let i in services) { - if (tests[i]) { - console.log(`\nRunning tests for ${i}...\n`) - for (let k = 0; k < tests[i].length; k++) { - let test = tests[i][k]; - - console.log(`Running test ${k+1}: ${test.name}`); - console.log('params:'); - let params = {...{url: test.url}, ...test.params}; - console.log(params); - - let chck = await normalizeRequest(params); - if (chck.success) { - chck = chck.data; - - const parsed = extract(chck.url); - if (parsed === null) { - throw `Invalid URL: ${chck.url}` - } +import { services } from "../processing/service-config.js"; - let j = await match({ - host: parsed.host, - patternMatch: parsed.patternMatch, - params: chck, - }); - console.log('\nReceived:'); - console.log(j) - if (j.status === test.expected.code && j.body.status === test.expected.status) { - console.log("\n✅ Success.\n"); - success++ - } else { - console.log(`\n❌ Fail. Expected: ${test.expected.code} & ${test.expected.status}, received: ${j.status} & ${j.body.status}\n`); - addToFail(i, test.name, test.url, j.body.status, j) - } - } else { - console.log("\n❌ couldn't validate the request JSON.\n"); - addToFail(i, test.name, test.url, "unknown", {}) +const getTestPath = service => path.join('./src/util/tests/', `./${service}.json`); +const getTests = (service) => loadJSON(getTestPath(service)); + +// services that are known to frequently fail due to external +// factors (e.g. rate limiting) +const finnicky = new Set(['bilibili', 'instagram', 'facebook', 'youtube', 'vk', 'twitter']); + +const runTestsFor = async (service) => { + const tests = getTests(service); + let softFails = 0, fails = 0; + + if (!tests) { + throw "no such service: " + service; + } + + for (const test of tests) { + const { name, url, params, expected } = test; + const canFail = test.canFail || finnicky.has(service); + + try { + await runTest(url, params, expected); + console.log(`${service}/${name}: ok`); + + } catch (e) { + softFails += !canFail; + fails++; + + let failText = canFail ? `${Red('FAIL')} (ignored)` : Bright(Red('FAIL')); + if (canFail && process.env.GITHUB_ACTION) { + console.log(`::warning title=${service}/${name.replace(/,/g, ';')}::failed and was ignored`); } + + console.error(`${service}/${name}: ${failText}`); + const errorString = e.toString().split('\n'); + let c = '┃'; + errorString.forEach((line, index) => { + line = line.replace('!=', Red('!=')); + + if (index === errorString.length - 1) { + c = '┗'; + } + + console.error(` ${c}`, line); + }); } - console.log("\n\n") - } else { - console.warn(`No tests found for ${i}.`); - noTest.push(i) } -} -console.log(`✅ ${success} tests succeeded.`); -console.log(`❌ ${failed.length} tests failed.`); -console.log(`❔ ${noTest.length} services weren't tested.`); + return { fails, softFails }; +} -if (failed.length > 0) { - console.log(`\nFailed tests:`); - console.log(failed) +const printHeader = (service, padLen) => { + const padding = padLen - service.length; + service = service.padEnd(1 + service.length + padding, ' '); + console.log(service + '='.repeat(50)); } -if (noTest.length > 0) { - console.log(`\nMissing tests:`); - console.log(noTest) +const action = process.argv[2]; +switch (action) { + case "get-services": + const fromConfig = Object.keys(services); + + const missingTests = fromConfig.filter( + service => { + const tests = getTests(service); + return !tests || tests.length === 0 + } + ); + + if (missingTests.length) { + console.error('services have no tests:', missingTests); + process.exitCode = 1; + break; + } + + console.log(JSON.stringify(fromConfig)); + break; + + case "run-tests-for": + env.streamLifespan = 10000; + env.apiURL = 'http://x/'; + randomizeCiphers(); + + try { + const { softFails } = await runTestsFor(process.argv[3]); + process.exitCode = Number(!!softFails); + } catch (e) { + console.error(e); + process.exitCode = 1; + break; + } + + break; + default: + const maxHeaderLen = Object.keys(services).reduce((n, v) => v.length > n ? v.length : n, 0); + const failCounters = {}; + + env.streamLifespan = 10000; + env.apiURL = 'http://x/'; + randomizeCiphers(); + + for (const service in services) { + printHeader(service, maxHeaderLen); + const { fails, softFails } = await runTestsFor(service); + failCounters[service] = fails; + console.log(); + + if (!process.exitCode && softFails) + process.exitCode = 1; + } + + console.log('='.repeat(50 + maxHeaderLen)); + console.log( + Bright('total fails:'), + Object.values(failCounters).reduce((a, b) => a + b) + ); + for (const [ service, fails ] of Object.entries(failCounters)) { + if (fails) console.log(`${Bright(service)} fails: ${fails}`); + } } diff --git a/api/src/util/tests/bilibili.json b/api/src/util/tests/bilibili.json new file mode 100644 index 0000000..61d6013 --- /dev/null +++ b/api/src/util/tests/bilibili.json @@ -0,0 +1,60 @@ +[ + { + "name": "1080p video", + "url": "https://www.bilibili.com/video/BV18i4y1m7xV/", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "1080p video muted", + "url": "https://www.bilibili.com/video/BV18i4y1m7xV/", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "1080p vertical video", + "url": "https://www.bilibili.com/video/BV1uu411z7VV/", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "1080p vertical video muted", + "url": "https://www.bilibili.com/video/BV1uu411z7VV/", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "b23.tv shortlink", + "url": "https://b23.tv/lbMyOI9", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "bilibili.tv link", + "url": "https://www.bilibili.tv/en/video/4789599404426256", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/bsky.json b/api/src/util/tests/bsky.json new file mode 100644 index 0000000..840f116 --- /dev/null +++ b/api/src/util/tests/bsky.json @@ -0,0 +1,78 @@ +[ + { + "name": "horizontal video", + "url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3giwtwp222m", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "horizontal video, recordWithMedia", + "url": "https://bsky.app/profile/did:plc:ywbm3iywnhzep3ckt6efhoh7/post/3l3wonhk23g2i", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "vertical video", + "url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "vertical video (muted)", + "url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "vertical video (audio)", + "url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "single image", + "url": "https://bsky.app/profile/did:plc:k4a7d65fcyevbrnntjxh57go/post/3l33flpoygt26", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "several images", + "url": "https://bsky.app/profile/did:plc:rai7s6su2sy22ss7skouedl7/post/3kzxuxbiul626", + "params": {}, + "expected": { + "code": 200, + "status": "picker" + } + }, + { + "name": "deleted post/invalid user", + "url": "https://bsky.app/profile/notreal.bsky.team/post/3l2udah76ch2c", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/dailymotion.json b/api/src/util/tests/dailymotion.json new file mode 100644 index 0000000..4de9302 --- /dev/null +++ b/api/src/util/tests/dailymotion.json @@ -0,0 +1,29 @@ +[ + { + "name": "regular video", + "url": "https://www.dailymotion.com/video/x8t1eho", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "private video", + "url": "https://www.dailymotion.com/video/k41fZWpx2TaAORA2nok", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "dai.ly shortened link", + "url": "https://dai.ly/k41fZWpx2TaAORA2nok", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/facebook.json b/api/src/util/tests/facebook.json new file mode 100644 index 0000000..876ac7f --- /dev/null +++ b/api/src/util/tests/facebook.json @@ -0,0 +1,67 @@ +[ + { + "name": "direct video with username and id", + "url": "https://web.facebook.com/100048111287134/videos/1157798148685638/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "direct video with id as query param", + "url": "https://web.facebook.com/watch/?v=883839773514682&ref=sharing", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "direct video with caption", + "url": "https://web.facebook.com/wood57/videos/𝐒𝐞𝐛𝐚𝐬𝐤𝐨𝐦-𝐟𝐮𝐥𝐥/883839773514682", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "shortlink video", + "url": "https://fb.watch/r1K6XHMfGT/", + "canFail": true, + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "reel video", + "url": "https://web.facebook.com/reel/730293269054758", + "canFail": true, + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "shared video link", + "url": "https://www.facebook.com/share/v/NEf87jbPTvFE8LsL/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "shared video link v2", + "url": "https://web.facebook.com/share/r/JFZfPVgLkiJQmWrr/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/instagram.json b/api/src/util/tests/instagram.json new file mode 100644 index 0000000..2ee4219 --- /dev/null +++ b/api/src/util/tests/instagram.json @@ -0,0 +1,123 @@ +[ + { + "name": "single photo post", + "url": "https://www.instagram.com/p/CwIgW8Yu5-I/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "various picker (photos + video)", + "url": "https://www.instagram.com/p/CvYrSgnsKjv/", + "params": {}, + "expected": { + "code": 200, + "status": "picker" + } + }, + { + "name": "reel", + "url": "https://www.instagram.com/reel/CoEBV3eM4QR/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "regular video", + "url": "https://www.instagram.com/p/CmCVWoIr9OH/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "reel (isAudioOnly)", + "url": "https://www.instagram.com/reel/CoEBV3eM4QR/", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "reel (isAudioMuted)", + "url": "https://www.instagram.com/reel/CoEBV3eM4QR/", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "inexistent reel", + "url": "https://www.instagram.com/reel/XXXXXXXXXX/", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "inexistent post", + "url": "https://www.instagram.com/p/XXXXXXXXXX/", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "post info in an array (for whatever reason??)", + "url": "https://www.instagram.com/reel/CrVB9tatUDv/?igshid=blaBlABALALbLABULLSHIT==", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "prone to get rate limited", + "url": "https://www.instagram.com/reel/CrO-T7Qo6rq/?igshid=fuckYouNoTrackingIdForYou==", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "ddinstagram link", + "url": "https://ddinstagram.com/p/CmCVWoIr9OH/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "d.ddinstagram.com link", + "url": "https://d.ddinstagram.com/p/CmCVWoIr9OH/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "g.ddinstagram.com link", + "url": "https://g.ddinstagram.com/p/CmCVWoIr9OH/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/loom.json b/api/src/util/tests/loom.json new file mode 100644 index 0000000..1849a00 --- /dev/null +++ b/api/src/util/tests/loom.json @@ -0,0 +1,33 @@ +[ + { + "name": "1080p video", + "url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "1080p video (muted)", + "url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "1080p video (audio only)", + "url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 400, + "status": "error" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/ok.json b/api/src/util/tests/ok.json new file mode 100644 index 0000000..8eb103e --- /dev/null +++ b/api/src/util/tests/ok.json @@ -0,0 +1,11 @@ +[ + { + "name": "regular video", + "url": "https://ok.ru/video/7204071410346", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/pinterest.json b/api/src/util/tests/pinterest.json new file mode 100644 index 0000000..2f15fb0 --- /dev/null +++ b/api/src/util/tests/pinterest.json @@ -0,0 +1,87 @@ +[ + { + "name": "regular video", + "url": "https://www.pinterest.com/pin/70437485604616/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "regular video (isAudioOnly)", + "url": "https://www.pinterest.com/pin/70437485604616/", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "regular video (isAudioMuted)", + "url": "https://www.pinterest.com/pin/70437485604616/", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "regular video (.ca TLD)", + "url": "https://www.pinterest.ca/pin/70437485604616/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "story", + "url": "https://www.pinterest.com/pin/gadget-cool-products-amazon-product-technology-kitchen-gadgets--1084663891475263837/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "regular picture", + "url": "https://www.pinterest.com/pin/412994228343400946/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "regular picture (.ca TLD)", + "url": "https://www.pinterest.ca/pin/412994228343400946/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "regular gif", + "url": "https://www.pinterest.com/pin/814447913881127862/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "regular gif (.ca TLD)", + "url": "https://www.pinterest.ca/pin/814447913881127862/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/reddit.json b/api/src/util/tests/reddit.json new file mode 100644 index 0000000..3afc612 --- /dev/null +++ b/api/src/util/tests/reddit.json @@ -0,0 +1,60 @@ +[ + { + "name": "video with audio", + "url": "https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "video with audio (isAudioOnly)", + "url": "https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "video with audio (isAudioMuted)", + "url": "https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "video without audio", + "url": "https://www.reddit.com/r/catvideos/comments/ftoeo7/luna_doesnt_want_to_be_bothered_while_shes_napping/?utm_source=share&utm_medium=web2x&context=3", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "actual gif, not looping video", + "url": "https://www.reddit.com/r/whenthe/comments/109wqy1/god_really_did_some_trolling/?utm_source=share&utm_medium=web2x&context=3", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "different audio link, live render", + "url": "https://www.reddit.com/r/TikTokCringe/comments/15hce91/asian_daddy_kink/", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/rutube.json b/api/src/util/tests/rutube.json new file mode 100644 index 0000000..2eaf69b --- /dev/null +++ b/api/src/util/tests/rutube.json @@ -0,0 +1,100 @@ +[ + { + "name": "regular video", + "url": "https://rutube.ru/video/b2f6c27649907c2fde0af411b03825eb/", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "vertical video (isAudioMuted)", + "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "russian region lock", + "url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "vertical video", + "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "yappy", + "url": "https://rutube.ru/yappy/c8c32bf7aee04412837656ea26c2b25b/", + "canFail": true, + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "shorts", + "url": "https://rutube.ru/shorts/935c1afafd0e7d52836d671967d53dac/", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "vertical video (isAudioOnly)", + "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "vertical video (isAudioMuted)", + "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "private video", + "url": "https://rutube.ru/video/private/1161415be0e686214bb2a498165cab3e/?p=_IL1G8RSnKutunnTYwhZ5A", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "region locked video, should fail", + "canFail": true, + "url": "https://rutube.ru/video/e7ac82708cc22bd068a3bf6a7004d1b1/", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/snapchat.json b/api/src/util/tests/snapchat.json new file mode 100644 index 0000000..44f764c --- /dev/null +++ b/api/src/util/tests/snapchat.json @@ -0,0 +1,29 @@ +[ + { + "name": "spotlight", + "url": "https://www.snapchat.com/spotlight/W7_EDlXWTBiXAEEniNoMPwAAYdWxucG9pZmNqAY46j0a5AY46j0YbAAAAAQ", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "shortlinked spotlight", + "url": "https://t.snapchat.com/4ZsiBLDi", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "story", + "url": "https://www.snapchat.com/add/bazerkmakane", + "params": {}, + "expected": { + "code": 200, + "status": "picker" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/soundcloud.json b/api/src/util/tests/soundcloud.json new file mode 100644 index 0000000..04ed863 --- /dev/null +++ b/api/src/util/tests/soundcloud.json @@ -0,0 +1,106 @@ +[ + { + "name": "public song (best)", + "url": "https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing", + "params": { + "audioFormat": "best" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "public song (mp3, isAudioMuted)", + "url": "https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing", + "params": { + "downloadMode": "mute", + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "private song", + "url": "https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90", + "params": { + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "private song (wav, isAudioMuted)", + "url": "https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90", + "params": { + "downloadMode": "mute", + "audioFormat": "wav" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "private song (ogg, isAudioMuted, isAudioOnly)", + "url": "https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90", + "params": { + "downloadMode": "audio", + "audioFormat": "ogg" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "on.soundcloud link", + "url": "https://on.soundcloud.com/wLZre", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "on.soundcloud link, different stream type", + "url": "https://on.soundcloud.com/AG4c", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "no opus audio, fallback to mp3", + "url": "https://soundcloud.com/frums/credits", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "go+ song, should fail", + "url": "https://soundcloud.com/dualipa/illusion-1", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "region locked song, should fail", + "canFail": true, + "url": "https://soundcloud.com/gotye/somebody-2024-feat-kimbra", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/streamable.json b/api/src/util/tests/streamable.json new file mode 100644 index 0000000..bf03c22 --- /dev/null +++ b/api/src/util/tests/streamable.json @@ -0,0 +1,51 @@ +[ + { + "name": "regular video", + "url": "https://streamable.com/p9cln4", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "embedded link", + "url": "https://streamable.com/e/rsmo56", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "regular video (isAudioOnly)", + "url": "https://streamable.com/p9cln4", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "regular video (isAudioMuted)", + "url": "https://streamable.com/p9cln4", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "inexistent video", + "url": "https://streamable.com/XXXXXX", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/tiktok.json b/api/src/util/tests/tiktok.json new file mode 100644 index 0000000..c8dbce8 --- /dev/null +++ b/api/src/util/tests/tiktok.json @@ -0,0 +1,47 @@ +[ + { + "name": "long link video", + "url": "https://www.tiktok.com/@fatfatmillycat/video/7195741644585454894", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "images", + "url": "https://www.tiktok.com/@matryoshk4/video/7231234675476532526", + "params": {}, + "expected": { + "code": 200, + "status": "picker" + } + }, + { + "name": "long link inexistent", + "url": "https://www.tiktok.com/@blablabla/video/7120851458451417478", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "short link inexistent", + "url": "https://vt.tiktok.com/2p4ewa7/", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "age restricted video", + "url": "https://www.tiktok.com/@.kyle.films/video/7415757181145877793", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/tumblr.json b/api/src/util/tests/tumblr.json new file mode 100644 index 0000000..8735225 --- /dev/null +++ b/api/src/util/tests/tumblr.json @@ -0,0 +1,49 @@ +[ + { + "name": "at.tumblr link", + "url": "https://at.tumblr.com/music/704177038274281472/n7x7pr7x4w2b", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "user subdomain link", + "url": "https://garfield-69.tumblr.com/post/696499862852780032", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "web app link", + "url": "https://www.tumblr.com/rongzhi/707729381162958848/english-added-by-me?source=share", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "tumblr audio", + "url": "https://www.tumblr.com/zedneon/737815079301562368/zedneon-ft-mr-sauceman-tech-n9ne-speed-of?source=share", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "tumblr video converted to audio", + "url": "https://garfield-69.tumblr.com/post/696499862852780032", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/twitch.json b/api/src/util/tests/twitch.json new file mode 100644 index 0000000..fd6b84a --- /dev/null +++ b/api/src/util/tests/twitch.json @@ -0,0 +1,33 @@ +[ + { + "name": "clip", + "url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "clip (isAudioOnly)", + "url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "clip (isAudioMuted)", + "url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/twitter.json b/api/src/util/tests/twitter.json new file mode 100644 index 0000000..0024d09 --- /dev/null +++ b/api/src/util/tests/twitter.json @@ -0,0 +1,213 @@ +[ + { + "name": "regular video", + "url": "https://twitter.com/X/status/1697304622749086011", + "params": { + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "video with mobile web mediaviewer", + "url": "https://twitter.com/X/status/1697304622749086011/mediaViewer?currentTweet=1697304622749086011¤tTweetUser=X¤tTweet=1697304622749086011¤tTweetUser=X", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "embedded twitter video", + "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20", + "params": { + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "mixed media (image + gif)", + "url": "https://twitter.com/sky_mj26/status/1807756010712428565", + "params": { + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "picker" + } + }, + { + "name": "picker: mixed media (3 videos)", + "url": "https://twitter.com/DankGameAlert/status/1584726006094794774", + "params": { + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "picker" + } + }, + { + "name": "audio from embedded twitter video (mp3, isAudioOnly)", + "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20", + "params": { + "downloadMode": "audio", + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "audio from embedded twitter video (best, isAudioOnly)", + "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20", + "params": { + "downloadMode": "audio", + "audioFormat": "best" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "audio from embedded twitter video (ogg, isAudioOnly, isAudioMuted)", + "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20", + "params": { + "downloadMode": "audio", + "audioFormat": "best" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "muted embedded twitter video", + "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20", + "params": { + "downloadMode": "mute", + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "retweeted video", + "url": "https://twitter.com/uwukko/status/1696901469633421344", + "params": {}, + "canFail": true, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "age restricted video", + "url": "https://x.com/XSpaces/status/1526955853743546372", + "params": {}, + "canFail": true, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "twitter voice + x.com link", + "url": "https://x.com/eggsaladscreams/status/1693089534886506756?s=46", + "params": {}, + "canFail": true, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "vxtwitter link", + "url": "https://vxtwitter.com/dustbin_nie/status/1624596567188717568?s=20", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "post with 1 image", + "url": "https://x.com/PopCrave/status/1815960083475423235", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "post with 4 images", + "url": "https://x.com/PopCrave/status/1816260887147114696", + "params": {}, + "expected": { + "code": 200, + "status": "picker" + } + }, + { + "name": "retweeted video, isAudioOnly", + "url": "https://twitter.com/hugekiwinuts/status/1618671150829309953?s=46&t=gItGzgwGQQJJaJrO6qc1Pg", + "params": { + "downloadMode": "mute", + "audioFormat": "mp3" + }, + "canFail": true, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "inexistent post", + "url": "https://twitter.com/test/status/9487653", + "params": { + "audioFormat": "best" + }, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "post with no media content", + "url": "https://twitter.com/elonmusk/status/1604617643973124097?s=20", + "params": { + "audioFormat": "best" + }, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "bookmarked video", + "url": "https://twitter.com/i/bookmarks?post_id=1828099210220294314", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "bookmarked photo", + "url": "https://twitter.com/i/bookmarks?post_id=1837430141179289876", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/vimeo.json b/api/src/util/tests/vimeo.json new file mode 100644 index 0000000..6c44a47 --- /dev/null +++ b/api/src/util/tests/vimeo.json @@ -0,0 +1,64 @@ +[ + { + "name": "4k progressive", + "url": "https://vimeo.com/288386543", + "params": { + "videoQuality": "2160" + }, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "720p progressive", + "url": "https://vimeo.com/288386543", + "params": { + "videoQuality": "720" + }, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "1080p dash parcel", + "url": "https://vimeo.com/967252742", + "params": { + "videoQuality": "1440" + }, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "720p dash parcel", + "url": "https://vimeo.com/967252742", + "params": { + "videoQuality": "360" + }, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "private video", + "url": "https://vimeo.com/903115595/f14d06da38", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "mature video", + "url": "https://vimeo.com/973212054", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/vk.json b/api/src/util/tests/vk.json new file mode 100644 index 0000000..71720af --- /dev/null +++ b/api/src/util/tests/vk.json @@ -0,0 +1,82 @@ +[ + { + "name": "clip, defaults", + "url": "https://vk.com/clip-57274055_456239788", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "clip, 360", + "url": "https://vk.com/clip-57274055_456239788", + "params": { + "videoQuality": "360" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "clip different link, max", + "url": "https://vk.com/clips-57274055?z=clip-57274055_456239788", + "params": { + "videoQuality": "max" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "video, defaults", + "url": "https://vk.com/video-57274055_456239399", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "big 4k video", + "url": "https://vk.com/video-1112285_456248465", + "params": { + "videoQuality": "max" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "short 4k video, 480p, vkvideo.ru domain", + "url": "https://vkvideo.ru/video-26006257_456245538", + "params": { + "videoQuality": "480" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "ancient video (fallback to 240p)", + "url": "https://vk.com/video-1959_28496479", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "inexistent video", + "url": "https://vk.com/video-53333333_456233333", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + } +] diff --git a/api/src/util/tests/youtube.json b/api/src/util/tests/youtube.json new file mode 100644 index 0000000..0655e68 --- /dev/null +++ b/api/src/util/tests/youtube.json @@ -0,0 +1,240 @@ +[ + { + "name": "4k video (h264, 1440)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "youtubeVideoCodec": "h264", + "videoQuality": "1440" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "4k video (vp9, 720)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "youtubeVideoCodec": "vp9", + "videoQuality": "720" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "4k video (av1, max)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "youtubeVideoCodec": "av1", + "videoQuality": "max" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "4k video (h264, 720)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "youtubeVideoCodec": "h264", + "videoQuality": "720" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "4k video (vp9, max, isAudioMuted)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "downloadMode": "mute", + "youtubeVideoCodec": "vp9", + "videoQuality": "max" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "4k video (h264, max, isAudioMuted)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "downloadMode": "mute", + "youtubeVideoCodec": "h264", + "videoQuality": "max" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "4k video (av1, max, isAudioMuted, isAudioOnly, mp3)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "downloadMode": "audio", + "audioFormat": "mp3", + "youtubeVideoCodec": "av1", + "videoQuality": "max" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "4k video (av1, max, isAudioMuted, isAudioOnly, best)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "downloadMode": "audio", + "audioFormat": "best", + "youtubeVideoCodec": "av1", + "videoQuality": "max" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "music (mp3, isAudioOnly, isAudioMuted)", + "url": "https://music.youtube.com/watch?v=5rGTsvZCEdk&feature=share", + "params": { + "downloadMode": "audio", + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "music (mp3)", + "url": "https://music.youtube.com/watch?v=5rGTsvZCEdk&feature=share", + "params": { + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "audio bitrate higher than video, no vp9 video in response (mp3, isAudioOnly)", + "url": "https://www.youtube.com/watch?v=t5nC_ucYBrc", + "params": { + "downloadMode": "audio", + "audioFormat": "mp3" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "short, defaults", + "url": "https://www.youtube.com/shorts/r5FpeOJItbw", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "vr 360, av1, max", + "url": "https://www.youtube.com/watch?v=hEdzv7D4CbQ", + "params": { + "youtubeVideoCodec": "vp9", + "videoQuality": "max" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "live link, defaults", + "url": "https://www.youtube.com/live/ENxZS6PUDuI?feature=shared", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "inexistent video", + "url": "https://youtube.com/watch?v=gnjuHYWGEW", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "broken audioOnly download", + "url": "https://www.youtube.com/watch?v=ink80Al5nbw", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "hls video (h264, 1440p)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "youtubeVideoCodec": "h264", + "videoQuality": "1440", + "youtubeHLS": true + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "hls video (vp9, 360p)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "youtubeVideoCodec": "vp9", + "videoQuality": "360", + "youtubeHLS": true + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "hls video (audio mode)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "downloadMode": "audio", + "youtubeHLS": true + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "hls video (audio mode, best format)", + "url": "https://www.youtube.com/watch?v=vPwaXytZcgI", + "params": { + "downloadMode": "audio", + "youtubeHLS": true, + "audioFormat": "best" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] \ No newline at end of file diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 676d22b..560d1af 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -9,7 +9,7 @@ "license": "MIT", "devDependencies": { "prettier": "3.3.3", - "tsup": "^8.2.4", + "tsup": "^8.3.0", "typescript": "^5.4.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f9dedb..4984f52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,12 @@ importers: api: dependencies: + '@datastructures-js/priority-queue': + specifier: ^6.3.1 + version: 6.3.1 + '@imput/psl': + specifier: ^2.0.4 + version: 2.0.4 '@imput/version-info': specifier: workspace:^ version: link:../packages/version-info @@ -22,15 +28,12 @@ importers: dotenv: specifier: ^16.0.1 version: 16.4.5 - esbuild: - specifier: ^0.14.51 - version: 0.14.54 express: - specifier: ^4.18.1 - version: 4.19.2 + specifier: ^4.21.2 + version: 4.21.2 express-rate-limit: - specifier: ^6.3.0 - version: 6.11.2(express@4.19.2) + specifier: ^7.4.1 + version: 7.5.0(express@4.21.2) ffmpeg-static: specifier: ^5.1.0 version: 5.2.0 @@ -38,17 +41,11 @@ importers: specifier: ^0.10.7 version: 0.10.9 ipaddr.js: - specifier: 2.1.0 - version: 2.1.0 + specifier: 2.2.0 + version: 2.2.0 nanoid: - specifier: ^4.0.2 - version: 4.0.2 - node-cache: - specifier: ^5.1.2 - version: 5.1.2 - psl: - specifier: 1.9.0 - version: 1.9.0 + specifier: ^5.0.9 + version: 5.0.9 set-cookie-parser: specifier: 2.6.0 version: 2.6.0 @@ -59,8 +56,8 @@ importers: specifier: 1.0.3 version: 1.0.3 youtubei.js: - specifier: ^10.3.0 - version: 10.3.0 + specifier: ^12.2.0 + version: 12.2.0 zod: specifier: ^3.23.8 version: 3.23.8 @@ -68,6 +65,12 @@ importers: freebind: specifier: ^0.2.2 version: 0.2.2 + rate-limit-redis: + specifier: ^4.2.0 + version: 4.2.0(express-rate-limit@7.5.0(express@4.21.2)) + redis: + specifier: ^4.7.0 + version: 4.7.0 packages/api-client: devDependencies: @@ -75,8 +78,8 @@ importers: specifier: 3.3.3 version: 3.3.3 tsup: - specifier: ^8.2.4 - version: 8.2.4(postcss@8.4.40)(typescript@5.5.4) + specifier: ^8.3.0 + version: 8.3.5(postcss@8.4.40)(typescript@5.5.4) typescript: specifier: ^5.4.5 version: 5.5.4 @@ -183,6 +186,15 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@bufbuild/protobuf@2.2.3': + resolution: {integrity: sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg==} + + '@datastructures-js/heap@4.3.3': + resolution: {integrity: sha512-UcUu/DLh/aM4W3C8zZfwxxm6/6FIZUlm3mcAXuNOCa6Aj4iizNvNXQyb8DjZQH2jKSQbMRyNlngP6TPimuGjpQ==} + + '@datastructures-js/priority-queue@6.3.1': + resolution: {integrity: sha512-eoxkWql/j0VJ0UFMFTpnyJz4KbEEVQ6aZ/JuJUgenu0Im4tYKylAycNGsYCHGXiVNEd7OKGVwfx1Ac3oYkuu7A==} + '@derhuerst/http-basic@8.2.4': resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==} engines: {node: '>=6.0.0'} @@ -193,8 +205,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.23.0': - resolution: {integrity: sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==} + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -205,8 +217,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.23.0': - resolution: {integrity: sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==} + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -217,8 +229,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.23.0': - resolution: {integrity: sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==} + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -229,8 +241,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.23.0': - resolution: {integrity: sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==} + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -241,8 +253,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.23.0': - resolution: {integrity: sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==} + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -253,8 +265,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.23.0': - resolution: {integrity: sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==} + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -265,8 +277,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.23.0': - resolution: {integrity: sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==} + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -277,8 +289,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.23.0': - resolution: {integrity: sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==} + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -289,8 +301,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.23.0': - resolution: {integrity: sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==} + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -301,8 +313,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.23.0': - resolution: {integrity: sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==} + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -313,26 +325,20 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.23.0': - resolution: {integrity: sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==} + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.14.54': - resolution: {integrity: sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.23.0': - resolution: {integrity: sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==} + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -343,8 +349,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.23.0': - resolution: {integrity: sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==} + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -355,8 +361,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.23.0': - resolution: {integrity: sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==} + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -367,8 +373,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.23.0': - resolution: {integrity: sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==} + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -379,8 +385,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.23.0': - resolution: {integrity: sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==} + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -391,26 +397,32 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.23.0': - resolution: {integrity: sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==} + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.23.0': - resolution: {integrity: sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==} + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.23.0': - resolution: {integrity: sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==} + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -421,8 +433,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.23.0': - resolution: {integrity: sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==} + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -433,8 +445,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.23.0': - resolution: {integrity: sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==} + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -445,8 +457,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.23.0': - resolution: {integrity: sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==} + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -457,8 +469,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.23.0': - resolution: {integrity: sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==} + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -469,8 +481,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.23.0': - resolution: {integrity: sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==} + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -526,6 +538,9 @@ packages: '@imput/libav.js-remux-cli@5.5.6': resolution: {integrity: sha512-XdAab90EZKf6ULtD/x9Y2bnlmNJodXSO6w8aWrn97+N2IRuOS8zv3tAFPRC69SWKa8Utjeu5YTYuTolnX3QprQ==} + '@imput/psl@2.0.4': + resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -567,6 +582,35 @@ packages: '@polka/url@1.0.0-next.25': resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.0': + resolution: {integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@rollup/plugin-commonjs@26.0.1': resolution: {integrity: sha512-UnsKoZK6/aGIH6AdkptXhNvhaqftcjq3zZdT+LY5Ftms6JR06nADcDsYp5hTU9E2lbJUEOhdlY5J4DNTneM+jQ==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -608,81 +652,176 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.29.2': + resolution: {integrity: sha512-s/8RiF4bdmGnc/J0N7lHAr5ZFJj+NdJqJ/Hj29K+c4lEdoVlukzvWXB9XpWZCdakVT0YAw8iyIqUP2iFRz5/jA==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.19.2': resolution: {integrity: sha512-k0OC/b14rNzMLDOE6QMBCjDRm3fQOHAL8Ldc9bxEWvMo4Ty9RY6rWmGetNTWhPo+/+FNd1lsQYRd0/1OSix36A==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.29.2': + resolution: {integrity: sha512-mKRlVj1KsKWyEOwR6nwpmzakq6SgZXW4NUHNWlYSiyncJpuXk7wdLzuKdWsRoR1WLbWsZBKvsUCdCTIAqRn9cA==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.19.2': resolution: {integrity: sha512-IIARRgWCNWMTeQH+kr/gFTHJccKzwEaI0YSvtqkEBPj7AshElFq89TyreKNFAGh5frLfDCbodnq+Ye3dqGKPBw==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.29.2': + resolution: {integrity: sha512-vJX+vennGwygmutk7N333lvQ/yKVAHnGoBS2xMRQgXWW8tvn46YWuTDOpKroSPR9BEW0Gqdga2DHqz8Pwk6X5w==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.19.2': resolution: {integrity: sha512-52udDMFDv54BTAdnw+KXNF45QCvcJOcYGl3vQkp4vARyrcdI/cXH8VXTEv/8QWfd6Fru8QQuw1b2uNersXOL0g==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.29.2': + resolution: {integrity: sha512-e2rW9ng5O6+Mt3ht8fH0ljfjgSCC6ffmOipiLUgAnlK86CHIaiCdHCzHzmTkMj6vEkqAiRJ7ss6Ibn56B+RE5w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.29.2': + resolution: {integrity: sha512-/xdNwZe+KesG6XJCK043EjEDZTacCtL4yurMZRLESIgHQdvtNyul3iz2Ab03ZJG0pQKbFTu681i+4ETMF9uE/Q==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.29.2': + resolution: {integrity: sha512-eXKvpThGzREuAbc6qxnArHh8l8W4AyTcL8IfEnmx+bcnmaSGgjyAHbzZvHZI2csJ+e0MYddl7DX0X7g3sAuXDQ==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.19.2': resolution: {integrity: sha512-r+SI2t8srMPYZeoa1w0o/AfoVt9akI1ihgazGYPQGRilVAkuzMGiTtexNZkrPkQsyFrvqq/ni8f3zOnHw4hUbA==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.29.2': + resolution: {integrity: sha512-h4VgxxmzmtXLLYNDaUcQevCmPYX6zSj4SwKuzY7SR5YlnCBYsmvfYORXgiU8axhkFCDtQF3RW5LIXT8B14Qykg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.19.2': resolution: {integrity: sha512-+tYiL4QVjtI3KliKBGtUU7yhw0GMcJJuB9mLTCEauHEsqfk49gtUBXGtGP3h1LW8MbaTY6rSFIQV1XOBps1gBA==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.29.2': + resolution: {integrity: sha512-EObwZ45eMmWZQ1w4N7qy4+G1lKHm6mcOwDa+P2+61qxWu1PtQJ/lz2CNJ7W3CkfgN0FQ7cBUy2tk6D5yR4KeXw==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.19.2': resolution: {integrity: sha512-OR5DcvZiYN75mXDNQQxlQPTv4D+uNCUsmSCSY2FolLf9W5I4DSoJyg7z9Ea3TjKfhPSGgMJiey1aWvlWuBzMtg==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.29.2': + resolution: {integrity: sha512-Z7zXVHEXg1elbbYiP/29pPwlJtLeXzjrj4241/kCcECds8Zg9fDfURWbZHRIKrEriAPS8wnVtdl4ZJBvZr325w==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.19.2': resolution: {integrity: sha512-Hw3jSfWdUSauEYFBSFIte6I8m6jOj+3vifLg8EU3lreWulAUpch4JBjDMtlKosrBzkr0kwKgL9iCfjA8L3geoA==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.29.2': + resolution: {integrity: sha512-TF4kxkPq+SudS/r4zGPf0G08Bl7+NZcFrUSR3484WwsHgGgJyPQRLCNrQ/R5J6VzxfEeQR9XRpc8m2t7lD6SEQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.29.2': + resolution: {integrity: sha512-kO9Fv5zZuyj2zB2af4KA29QF6t7YSxKrY7sxZXfw8koDQj9bx5Tk5RjH+kWKFKok0wLGTi4bG117h31N+TIBEg==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.19.2': resolution: {integrity: sha512-rhjvoPBhBwVnJRq/+hi2Q3EMiVF538/o9dBuj9TVLclo9DuONqt5xfWSaE6MYiFKpo/lFPJ/iSI72rYWw5Hc7w==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.29.2': + resolution: {integrity: sha512-gIh776X7UCBaetVJGdjXPFurGsdWwHHinwRnC5JlLADU8Yk0EdS/Y+dMO264OjJFo7MXQ5PX4xVFbxrwK8zLqA==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.19.2': resolution: {integrity: sha512-EAz6vjPwHHs2qOCnpQkw4xs14XJq84I81sDRGPEjKPFVPBw7fwvtwhVjcZR6SLydCv8zNK8YGFblKWd/vRmP8g==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.29.2': + resolution: {integrity: sha512-YgikssQ5UNq1GoFKZydMEkhKbjlUq7G3h8j6yWXLBF24KyoA5BcMtaOUAXq5sydPmOPEqB6kCyJpyifSpCfQ0w==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.19.2': resolution: {integrity: sha512-IJSUX1xb8k/zN9j2I7B5Re6B0NNJDJ1+soezjNojhT8DEVeDNptq2jgycCOpRhyGj0+xBn7Cq+PK7Q+nd2hxLA==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.29.2': + resolution: {integrity: sha512-9ouIR2vFWCyL0Z50dfnon5nOrpDdkTG9lNDs7MRaienQKlTyHcDxplmk3IbhFlutpifBSBr2H4rVILwmMLcaMA==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.19.2': resolution: {integrity: sha512-OgaToJ8jSxTpgGkZSkwKE+JQGihdcaqnyHEFOSAU45utQ+yLruE1dkonB2SDI8t375wOKgNn8pQvaWY9kPzxDQ==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.29.2': + resolution: {integrity: sha512-ckBBNRN/F+NoSUDENDIJ2U9UWmIODgwDB/vEXCPOMcsco1niTkxTXa6D2Y/pvCnpzaidvY2qVxGzLilNs9BSzw==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.19.2': resolution: {integrity: sha512-5V3mPpWkB066XZZBgSd1lwozBk7tmOkKtquyCJ6T4LN3mzKENXyBwWNQn8d0Ci81hvlBw5RoFgleVpL6aScLYg==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.29.2': + resolution: {integrity: sha512-jycl1wL4AgM2aBFJFlpll/kGvAjhK8GSbEmFT5v3KC3rP/b5xZ1KQmv0vQQ8Bzb2ieFQ0kZFPRMbre/l3Bu9JA==} + cpu: [x64] + os: [linux] + '@rollup/rollup-win32-arm64-msvc@4.19.2': resolution: {integrity: sha512-ayVstadfLeeXI9zUPiKRVT8qF55hm7hKa+0N1V6Vj+OTNFfKSoUxyZvzVvgtBxqSb5URQ8sK6fhwxr9/MLmxdA==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.29.2': + resolution: {integrity: sha512-S2V0LlcOiYkNGlRAWZwwUdNgdZBfvsDHW0wYosYFV3c7aKgEVcbonetZXsHv7jRTTX+oY5nDYT4W6B1oUpMNOg==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.19.2': resolution: {integrity: sha512-Mda7iG4fOLHNsPqjWSjANvNZYoW034yxgrndof0DwCy0D3FvTjeNo+HGE6oGWgvcLZNLlcp0hLEFcRs+UGsMLg==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.29.2': + resolution: {integrity: sha512-pW8kioj9H5f/UujdoX2atFlXNQ9aCfAxFRaa+mhczwcsusm6gGrSo4z0SLvqLF5LwFqFTjiLCCzGkNK/LE0utQ==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.19.2': resolution: {integrity: sha512-DPi0ubYhSow/00YqmG1jWm3qt1F8aXziHc/UNy8bo9cpCacqhuWu+iSq/fp2SyEQK7iYTZ60fBU9cat3MXTjIQ==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.29.2': + resolution: {integrity: sha512-p6fTArexECPf6KnOHvJXRpAEq0ON1CBtzG/EY4zw08kCHk/kivBc5vUEtnCFNCHOpJZ2ne77fxwRLIKD4wuW2Q==} + cpu: [x64] + os: [win32] + '@sveltejs/adapter-node@5.2.2': resolution: {integrity: sha512-BCX4zP0cf86TXpmvLQTnnT/tp7P12UMezf+5LwljP1MJC1fFzn9XOXpAHQCyP+pyHGy2K7p5gY0LyLcZFAL02w==} peerDependencies: @@ -745,6 +884,9 @@ packages: '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/fluent-ffmpeg@2.1.25': resolution: {integrity: sha512-a9/Jtv/RVaCG4lUwWIcuClWE5eXJFoFS/oHOecOv/RS8n+lQdJzcJVmDlxA8Xbk4B82YpO88Dijcoljb6sYTcA==} @@ -901,8 +1043,8 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - body-parser@1.20.2: - resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} brace-expansion@1.1.11: @@ -959,9 +1101,13 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - clone@2.1.2: - resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} - engines: {node: '>=0.8'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} code-red@1.0.4: resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} @@ -1012,6 +1158,10 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -1041,6 +1191,15 @@ packages: supports-color: optional: true + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1099,6 +1258,10 @@ packages: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1114,138 +1277,13 @@ packages: es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} - esbuild-android-64@0.14.54: - resolution: {integrity: sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - esbuild-android-arm64@0.14.54: - resolution: {integrity: sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - esbuild-darwin-64@0.14.54: - resolution: {integrity: sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - esbuild-darwin-arm64@0.14.54: - resolution: {integrity: sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - esbuild-freebsd-64@0.14.54: - resolution: {integrity: sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - esbuild-freebsd-arm64@0.14.54: - resolution: {integrity: sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - esbuild-linux-32@0.14.54: - resolution: {integrity: sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - esbuild-linux-64@0.14.54: - resolution: {integrity: sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - esbuild-linux-arm64@0.14.54: - resolution: {integrity: sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - esbuild-linux-arm@0.14.54: - resolution: {integrity: sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - esbuild-linux-mips64le@0.14.54: - resolution: {integrity: sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - esbuild-linux-ppc64le@0.14.54: - resolution: {integrity: sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - esbuild-linux-riscv64@0.14.54: - resolution: {integrity: sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - esbuild-linux-s390x@0.14.54: - resolution: {integrity: sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - esbuild-netbsd-64@0.14.54: - resolution: {integrity: sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - esbuild-openbsd-64@0.14.54: - resolution: {integrity: sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - esbuild-sunos-64@0.14.54: - resolution: {integrity: sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - esbuild-windows-32@0.14.54: - resolution: {integrity: sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - esbuild-windows-64@0.14.54: - resolution: {integrity: sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - esbuild-windows-arm64@0.14.54: - resolution: {integrity: sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - esbuild@0.14.54: - resolution: {integrity: sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} hasBin: true - esbuild@0.23.0: - resolution: {integrity: sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==} + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} engines: {node: '>=18'} hasBin: true @@ -1302,18 +1340,14 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - express-rate-limit@6.11.2: - resolution: {integrity: sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==} - engines: {node: '>= 14'} + express-rate-limit@7.5.0: + resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==} + engines: {node: '>= 16'} peerDependencies: - express: ^4 || ^5 + express: ^4.11 || 5 || ^5.0.0-beta.1 - express@4.19.2: - resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} fast-deep-equal@3.1.3: @@ -1332,6 +1366,14 @@ packages: fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + fdir@6.4.2: + resolution: {integrity: sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + ffmpeg-static@5.2.0: resolution: {integrity: sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==} engines: {node: '>=16'} @@ -1344,8 +1386,8 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.2.0: - resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} find-up@5.0.0: @@ -1385,14 +1427,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1465,10 +1507,6 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -1503,6 +1541,10 @@ packages: resolution: {integrity: sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==} engines: {node: '>= 10'} + ipaddr.js@2.2.0: + resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + engines: {node: '>= 10'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -1544,18 +1586,14 @@ packages: is-reference@3.0.2: resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jintr@2.1.1: - resolution: {integrity: sha512-89cwX4ouogeDGOBsEVsVYsnWWvWjchmwXBB4kiBhmjOKw19FiOKhNhMhpxhTlK2ctl7DS+d/ethfmuBpzoNNgA==} + jintr@3.2.0: + resolution: {integrity: sha512-psD1yf05kMKDNsUdW1l5YhO59pHScQ6OIHHb8W5SKSM2dCOFPsqolmIuSHgVA8+3Dc47NJR181CXZ4alCAPTkA==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} @@ -1627,11 +1665,8 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} - merge-descriptors@1.0.1: - resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} @@ -1663,10 +1698,6 @@ packages: engines: {node: '>=16'} hasBin: true - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -1714,9 +1745,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@4.0.2: - resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} - engines: {node: ^14 || ^16 || >=18} + nanoid@5.0.9: + resolution: {integrity: sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==} + engines: {node: ^18 || >=20} hasBin: true natural-compare@1.4.0: @@ -1726,18 +1757,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - node-cache@5.1.2: - resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} - engines: {node: '>= 8.0.0'} - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1753,10 +1776,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1802,8 +1821,8 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@0.1.7: - resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -1815,10 +1834,17 @@ packages: picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} @@ -1869,15 +1895,12 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - psl@1.9.0: - resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.11.0: - resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -1887,6 +1910,12 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rate-limit-redis@4.2.0: + resolution: {integrity: sha512-wV450NQyKC24NmPosJb2131RoczLdfIJdKCReNwtVpm5998U8SgKrAZrIHaN/NfQgqOHaan8Uq++B4sa5REwjA==} + engines: {node: '>= 16'} + peerDependencies: + express-rate-limit: '>= 6' + raw-body@2.5.2: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} @@ -1899,6 +1928,13 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.0.2: + resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} + engines: {node: '>= 14.16.0'} + + redis@4.7.0: + resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1930,6 +1966,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.29.2: + resolution: {integrity: sha512-tJXpsEkzsEzyAKIaB3qv3IuvTVcTN7qBw1jL4SPPXM3vzDrJgiLGFY6+HodgFaUHAJ2RYJ94zV5MKRJCoQzQeA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1951,12 +1992,12 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.18.0: - resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} - serve-static@1.15.0: - resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} set-cookie-parser@2.6.0: @@ -1981,9 +2022,6 @@ packages: resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} engines: {node: '>= 0.4'} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2031,10 +2069,6 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -2167,6 +2201,13 @@ packages: tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.10: + resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==} + engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2202,8 +2243,8 @@ packages: tslib@2.6.3: resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} - tsup@8.2.4: - resolution: {integrity: sha512-akpCPePnBnC/CXgRrcy72ZSntgIEUa1jN0oJbbvpALWKNOz1B7aM+UVDWGRGIO/T/PZugAESWDJUAb5FD48o8Q==} + tsup@8.3.5: + resolution: {integrity: sha512-Tunf6r6m6tnZsG9GYWndg0z8dEV7fD733VBFzFJ5Vcm1FtlXB8xBD/rtrBi2a3YKEV7hHtxiZtW5EAVADoe1pA==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -2351,12 +2392,15 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - youtubei.js@10.3.0: - resolution: {integrity: sha512-tLmeJCECK2xF2hZZtF2nEqirdKVNLFSDpa0LhTaXY3tngtL7doQXyy7M2CLueramDTlmCnFaW+rctHirTPFaRQ==} + youtubei.js@12.2.0: + resolution: {integrity: sha512-G+50qrbJCToMYhu8jbaHiS3Vf+RRul+CcDbz3hEGwHkGPh+zLiWwD6SS+YhYF+2/op4ZU5zDYQJrGqJ+wKh7Gw==} zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -2368,6 +2412,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@bufbuild/protobuf@2.2.3': {} + + '@datastructures-js/heap@4.3.3': {} + + '@datastructures-js/priority-queue@6.3.1': + dependencies: + '@datastructures-js/heap': 4.3.3 + '@derhuerst/http-basic@8.2.4': dependencies: caseless: 0.12.0 @@ -2378,145 +2430,145 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true - '@esbuild/aix-ppc64@0.23.0': + '@esbuild/aix-ppc64@0.24.2': optional: true '@esbuild/android-arm64@0.21.5': optional: true - '@esbuild/android-arm64@0.23.0': + '@esbuild/android-arm64@0.24.2': optional: true '@esbuild/android-arm@0.21.5': optional: true - '@esbuild/android-arm@0.23.0': + '@esbuild/android-arm@0.24.2': optional: true '@esbuild/android-x64@0.21.5': optional: true - '@esbuild/android-x64@0.23.0': + '@esbuild/android-x64@0.24.2': optional: true '@esbuild/darwin-arm64@0.21.5': optional: true - '@esbuild/darwin-arm64@0.23.0': + '@esbuild/darwin-arm64@0.24.2': optional: true '@esbuild/darwin-x64@0.21.5': optional: true - '@esbuild/darwin-x64@0.23.0': + '@esbuild/darwin-x64@0.24.2': optional: true '@esbuild/freebsd-arm64@0.21.5': optional: true - '@esbuild/freebsd-arm64@0.23.0': + '@esbuild/freebsd-arm64@0.24.2': optional: true '@esbuild/freebsd-x64@0.21.5': optional: true - '@esbuild/freebsd-x64@0.23.0': + '@esbuild/freebsd-x64@0.24.2': optional: true '@esbuild/linux-arm64@0.21.5': optional: true - '@esbuild/linux-arm64@0.23.0': + '@esbuild/linux-arm64@0.24.2': optional: true '@esbuild/linux-arm@0.21.5': optional: true - '@esbuild/linux-arm@0.23.0': + '@esbuild/linux-arm@0.24.2': optional: true '@esbuild/linux-ia32@0.21.5': optional: true - '@esbuild/linux-ia32@0.23.0': - optional: true - - '@esbuild/linux-loong64@0.14.54': + '@esbuild/linux-ia32@0.24.2': optional: true '@esbuild/linux-loong64@0.21.5': optional: true - '@esbuild/linux-loong64@0.23.0': + '@esbuild/linux-loong64@0.24.2': optional: true '@esbuild/linux-mips64el@0.21.5': optional: true - '@esbuild/linux-mips64el@0.23.0': + '@esbuild/linux-mips64el@0.24.2': optional: true '@esbuild/linux-ppc64@0.21.5': optional: true - '@esbuild/linux-ppc64@0.23.0': + '@esbuild/linux-ppc64@0.24.2': optional: true '@esbuild/linux-riscv64@0.21.5': optional: true - '@esbuild/linux-riscv64@0.23.0': + '@esbuild/linux-riscv64@0.24.2': optional: true '@esbuild/linux-s390x@0.21.5': optional: true - '@esbuild/linux-s390x@0.23.0': + '@esbuild/linux-s390x@0.24.2': optional: true '@esbuild/linux-x64@0.21.5': optional: true - '@esbuild/linux-x64@0.23.0': + '@esbuild/linux-x64@0.24.2': + optional: true + + '@esbuild/netbsd-arm64@0.24.2': optional: true '@esbuild/netbsd-x64@0.21.5': optional: true - '@esbuild/netbsd-x64@0.23.0': + '@esbuild/netbsd-x64@0.24.2': optional: true - '@esbuild/openbsd-arm64@0.23.0': + '@esbuild/openbsd-arm64@0.24.2': optional: true '@esbuild/openbsd-x64@0.21.5': optional: true - '@esbuild/openbsd-x64@0.23.0': + '@esbuild/openbsd-x64@0.24.2': optional: true '@esbuild/sunos-x64@0.21.5': optional: true - '@esbuild/sunos-x64@0.23.0': + '@esbuild/sunos-x64@0.24.2': optional: true '@esbuild/win32-arm64@0.21.5': optional: true - '@esbuild/win32-arm64@0.23.0': + '@esbuild/win32-arm64@0.24.2': optional: true '@esbuild/win32-ia32@0.21.5': optional: true - '@esbuild/win32-ia32@0.23.0': + '@esbuild/win32-ia32@0.24.2': optional: true '@esbuild/win32-x64@0.21.5': optional: true - '@esbuild/win32-x64@0.23.0': + '@esbuild/win32-x64@0.24.2': optional: true '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': @@ -2566,6 +2618,10 @@ snapshots: '@imput/libav.js-remux-cli@5.5.6': {} + '@imput/psl@2.0.4': + dependencies: + punycode: 2.3.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2609,6 +2665,38 @@ snapshots: '@polka/url@1.0.0-next.25': {} + '@redis/bloom@1.2.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + optional: true + + '@redis/client@1.6.0': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + optional: true + + '@redis/graph@1.1.1(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + optional: true + + '@redis/json@1.0.7(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + optional: true + + '@redis/search@1.2.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + optional: true + + '@redis/time-series@1.1.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + optional: true + '@rollup/plugin-commonjs@26.0.1(rollup@4.19.2)': dependencies: '@rollup/pluginutils': 5.1.0(rollup@4.19.2) @@ -2648,51 +2736,108 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.19.2': optional: true + '@rollup/rollup-android-arm-eabi@4.29.2': + optional: true + '@rollup/rollup-android-arm64@4.19.2': optional: true + '@rollup/rollup-android-arm64@4.29.2': + optional: true + '@rollup/rollup-darwin-arm64@4.19.2': optional: true + '@rollup/rollup-darwin-arm64@4.29.2': + optional: true + '@rollup/rollup-darwin-x64@4.19.2': optional: true + '@rollup/rollup-darwin-x64@4.29.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.29.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.29.2': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.19.2': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.29.2': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.19.2': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.29.2': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.19.2': optional: true + '@rollup/rollup-linux-arm64-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-arm64-musl@4.19.2': optional: true + '@rollup/rollup-linux-arm64-musl@4.29.2': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.19.2': optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.19.2': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.19.2': optional: true + '@rollup/rollup-linux-s390x-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-x64-gnu@4.19.2': optional: true + '@rollup/rollup-linux-x64-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-x64-musl@4.19.2': optional: true + '@rollup/rollup-linux-x64-musl@4.29.2': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.19.2': optional: true + '@rollup/rollup-win32-arm64-msvc@4.29.2': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.19.2': optional: true + '@rollup/rollup-win32-ia32-msvc@4.29.2': + optional: true + '@rollup/rollup-win32-x64-msvc@4.19.2': optional: true + '@rollup/rollup-win32-x64-msvc@4.29.2': + optional: true + '@sveltejs/adapter-node@5.2.2(@sveltejs/kit@2.5.19(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))': dependencies: '@rollup/plugin-commonjs': 26.0.1(rollup@4.19.2) @@ -2772,6 +2917,8 @@ snapshots: '@types/estree@1.0.5': {} + '@types/estree@1.0.6': {} + '@types/fluent-ffmpeg@2.1.25': dependencies: '@types/node': 20.14.14 @@ -2934,7 +3081,7 @@ snapshots: binary-extensions@2.3.0: {} - body-parser@1.20.2: + body-parser@1.20.3: dependencies: bytes: 3.1.2 content-type: 1.0.5 @@ -2944,7 +3091,7 @@ snapshots: http-errors: 2.0.0 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.11.0 + qs: 6.13.0 raw-body: 2.5.2 type-is: 1.6.18 unpipe: 1.0.0 @@ -2970,9 +3117,9 @@ snapshots: builtin-modules@3.3.0: {} - bundle-require@5.0.0(esbuild@0.23.0): + bundle-require@5.0.0(esbuild@0.24.2): dependencies: - esbuild: 0.23.0 + esbuild: 0.24.2 load-tsconfig: 0.2.5 bytes@3.1.2: {} @@ -3008,7 +3155,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - clone@2.1.2: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.0.2 + + cluster-key-slot@1.1.2: + optional: true code-red@1.0.4: dependencies: @@ -3053,6 +3205,8 @@ snapshots: cookie@0.6.0: {} + cookie@0.7.1: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -3077,6 +3231,10 @@ snapshots: dependencies: ms: 2.1.2 + debug@4.4.0: + dependencies: + ms: 2.1.3 + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -3117,6 +3275,8 @@ snapshots: encodeurl@1.0.2: {} + encodeurl@2.0.0: {} + env-paths@2.2.1: {} es-define-property@1.0.0: @@ -3127,90 +3287,6 @@ snapshots: es6-promise@3.3.1: {} - esbuild-android-64@0.14.54: - optional: true - - esbuild-android-arm64@0.14.54: - optional: true - - esbuild-darwin-64@0.14.54: - optional: true - - esbuild-darwin-arm64@0.14.54: - optional: true - - esbuild-freebsd-64@0.14.54: - optional: true - - esbuild-freebsd-arm64@0.14.54: - optional: true - - esbuild-linux-32@0.14.54: - optional: true - - esbuild-linux-64@0.14.54: - optional: true - - esbuild-linux-arm64@0.14.54: - optional: true - - esbuild-linux-arm@0.14.54: - optional: true - - esbuild-linux-mips64le@0.14.54: - optional: true - - esbuild-linux-ppc64le@0.14.54: - optional: true - - esbuild-linux-riscv64@0.14.54: - optional: true - - esbuild-linux-s390x@0.14.54: - optional: true - - esbuild-netbsd-64@0.14.54: - optional: true - - esbuild-openbsd-64@0.14.54: - optional: true - - esbuild-sunos-64@0.14.54: - optional: true - - esbuild-windows-32@0.14.54: - optional: true - - esbuild-windows-64@0.14.54: - optional: true - - esbuild-windows-arm64@0.14.54: - optional: true - - esbuild@0.14.54: - optionalDependencies: - '@esbuild/linux-loong64': 0.14.54 - esbuild-android-64: 0.14.54 - esbuild-android-arm64: 0.14.54 - esbuild-darwin-64: 0.14.54 - esbuild-darwin-arm64: 0.14.54 - esbuild-freebsd-64: 0.14.54 - esbuild-freebsd-arm64: 0.14.54 - esbuild-linux-32: 0.14.54 - esbuild-linux-64: 0.14.54 - esbuild-linux-arm: 0.14.54 - esbuild-linux-arm64: 0.14.54 - esbuild-linux-mips64le: 0.14.54 - esbuild-linux-ppc64le: 0.14.54 - esbuild-linux-riscv64: 0.14.54 - esbuild-linux-s390x: 0.14.54 - esbuild-netbsd-64: 0.14.54 - esbuild-openbsd-64: 0.14.54 - esbuild-sunos-64: 0.14.54 - esbuild-windows-32: 0.14.54 - esbuild-windows-64: 0.14.54 - esbuild-windows-arm64: 0.14.54 - esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -3237,32 +3313,33 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 - esbuild@0.23.0: + esbuild@0.24.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.23.0 - '@esbuild/android-arm': 0.23.0 - '@esbuild/android-arm64': 0.23.0 - '@esbuild/android-x64': 0.23.0 - '@esbuild/darwin-arm64': 0.23.0 - '@esbuild/darwin-x64': 0.23.0 - '@esbuild/freebsd-arm64': 0.23.0 - '@esbuild/freebsd-x64': 0.23.0 - '@esbuild/linux-arm': 0.23.0 - '@esbuild/linux-arm64': 0.23.0 - '@esbuild/linux-ia32': 0.23.0 - '@esbuild/linux-loong64': 0.23.0 - '@esbuild/linux-mips64el': 0.23.0 - '@esbuild/linux-ppc64': 0.23.0 - '@esbuild/linux-riscv64': 0.23.0 - '@esbuild/linux-s390x': 0.23.0 - '@esbuild/linux-x64': 0.23.0 - '@esbuild/netbsd-x64': 0.23.0 - '@esbuild/openbsd-arm64': 0.23.0 - '@esbuild/openbsd-x64': 0.23.0 - '@esbuild/sunos-x64': 0.23.0 - '@esbuild/win32-arm64': 0.23.0 - '@esbuild/win32-ia32': 0.23.0 - '@esbuild/win32-x64': 0.23.0 + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 escape-html@1.0.3: {} @@ -3346,50 +3423,38 @@ snapshots: etag@1.8.1: {} - execa@5.1.1: - dependencies: - cross-spawn: 7.0.3 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - express-rate-limit@6.11.2(express@4.19.2): + express-rate-limit@7.5.0(express@4.21.2): dependencies: - express: 4.19.2 + express: 4.21.2 - express@4.19.2: + express@4.21.2: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.2 + body-parser: 1.20.3 content-disposition: 0.5.4 content-type: 1.0.5 - cookie: 0.6.0 + cookie: 0.7.1 cookie-signature: 1.0.6 debug: 2.6.9 depd: 2.0.0 - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.2.0 + finalhandler: 1.3.1 fresh: 0.5.2 http-errors: 2.0.0 - merge-descriptors: 1.0.1 + merge-descriptors: 1.0.3 methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 - path-to-regexp: 0.1.7 + path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.11.0 + qs: 6.13.0 range-parser: 1.2.1 safe-buffer: 5.2.1 - send: 0.18.0 - serve-static: 1.15.0 + send: 0.19.0 + serve-static: 1.16.2 setprototypeof: 1.2.0 statuses: 2.0.1 type-is: 1.6.18 @@ -3416,6 +3481,10 @@ snapshots: dependencies: reusify: 1.0.4 + fdir@6.4.2(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + ffmpeg-static@5.2.0: dependencies: '@derhuerst/http-basic': 8.2.4 @@ -3433,10 +3502,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.2.0: + finalhandler@1.3.1: dependencies: debug: 2.6.9 - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 @@ -3481,6 +3550,9 @@ snapshots: function-bind@1.1.2: {} + generic-pool@3.9.0: + optional: true + get-intrinsic@1.2.4: dependencies: es-errors: 1.3.0 @@ -3489,8 +3561,6 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.2 - get-stream@6.0.1: {} - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3577,8 +3647,6 @@ snapshots: transitivePeerDependencies: - supports-color - human-signals@2.1.0: {} - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -3603,7 +3671,10 @@ snapshots: ipaddr.js@1.9.1: {} - ipaddr.js@2.1.0: {} + ipaddr.js@2.1.0: + optional: true + + ipaddr.js@2.2.0: {} is-binary-path@2.1.0: dependencies: @@ -3639,8 +3710,6 @@ snapshots: dependencies: '@types/estree': 1.0.5 - is-stream@2.0.1: {} - isexe@2.0.0: {} jackspeak@3.4.3: @@ -3649,7 +3718,7 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jintr@2.1.1: + jintr@3.2.0: dependencies: acorn: 8.12.1 @@ -3710,9 +3779,7 @@ snapshots: media-typer@0.3.0: {} - merge-descriptors@1.0.1: {} - - merge-stream@2.0.0: {} + merge-descriptors@1.0.3: {} merge2@1.4.1: {} @@ -3733,8 +3800,6 @@ snapshots: mime@4.0.4: {} - mimic-fn@2.1.0: {} - min-indent@1.0.1: {} minimatch@3.1.2: @@ -3771,22 +3836,14 @@ snapshots: nanoid@3.3.7: {} - nanoid@4.0.2: {} + nanoid@5.0.9: {} natural-compare@1.4.0: {} negotiator@0.6.3: {} - node-cache@5.1.2: - dependencies: - clone: 2.1.2 - normalize-path@3.0.0: {} - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - object-assign@4.1.1: {} object-inspect@1.13.2: {} @@ -3799,10 +3856,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3843,7 +3896,7 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@0.1.7: {} + path-to-regexp@0.1.12: {} path-type@4.0.0: {} @@ -3855,8 +3908,12 @@ snapshots: picocolors@1.0.1: {} + picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.2: {} + pirates@4.0.6: {} postcss-load-config@6.0.1(postcss@8.4.40): @@ -3886,11 +3943,9 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - psl@1.9.0: {} - punycode@2.3.1: {} - qs@6.11.0: + qs@6.13.0: dependencies: side-channel: 1.0.6 @@ -3898,6 +3953,11 @@ snapshots: range-parser@1.2.1: {} + rate-limit-redis@4.2.0(express-rate-limit@7.5.0(express@4.21.2)): + dependencies: + express-rate-limit: 7.5.0(express@4.21.2) + optional: true + raw-body@2.5.2: dependencies: bytes: 3.1.2 @@ -3915,6 +3975,18 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.0.2: {} + + redis@4.7.0: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.0) + '@redis/client': 1.6.0 + '@redis/graph': 1.1.1(@redis/client@1.6.0) + '@redis/json': 1.0.7(@redis/client@1.6.0) + '@redis/search': 1.2.0(@redis/client@1.6.0) + '@redis/time-series': 1.1.0(@redis/client@1.6.0) + optional: true + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -3957,6 +4029,31 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.19.2 fsevents: 2.3.3 + rollup@4.29.2: + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.29.2 + '@rollup/rollup-android-arm64': 4.29.2 + '@rollup/rollup-darwin-arm64': 4.29.2 + '@rollup/rollup-darwin-x64': 4.29.2 + '@rollup/rollup-freebsd-arm64': 4.29.2 + '@rollup/rollup-freebsd-x64': 4.29.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.29.2 + '@rollup/rollup-linux-arm-musleabihf': 4.29.2 + '@rollup/rollup-linux-arm64-gnu': 4.29.2 + '@rollup/rollup-linux-arm64-musl': 4.29.2 + '@rollup/rollup-linux-loongarch64-gnu': 4.29.2 + '@rollup/rollup-linux-powerpc64le-gnu': 4.29.2 + '@rollup/rollup-linux-riscv64-gnu': 4.29.2 + '@rollup/rollup-linux-s390x-gnu': 4.29.2 + '@rollup/rollup-linux-x64-gnu': 4.29.2 + '@rollup/rollup-linux-x64-musl': 4.29.2 + '@rollup/rollup-win32-arm64-msvc': 4.29.2 + '@rollup/rollup-win32-ia32-msvc': 4.29.2 + '@rollup/rollup-win32-x64-msvc': 4.29.2 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3978,7 +4075,7 @@ snapshots: semver@7.6.3: {} - send@0.18.0: + send@0.19.0: dependencies: debug: 2.6.9 depd: 2.0.0 @@ -3996,12 +4093,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.15.0: + serve-static@1.16.2: dependencies: - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.18.0 + send: 0.19.0 transitivePeerDependencies: - supports-color @@ -4031,8 +4128,6 @@ snapshots: get-intrinsic: 1.2.4 object-inspect: 1.13.2 - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} sirv@2.0.4: @@ -4082,8 +4177,6 @@ snapshots: dependencies: ansi-regex: 6.0.1 - strip-final-newline@2.0.0: {} - strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -4190,6 +4283,13 @@ snapshots: globalyzer: 0.1.0 globrex: 0.1.2 + tinyexec@0.3.2: {} + + tinyglobby@0.2.10: + dependencies: + fdir: 6.4.2(picomatch@4.0.2) + picomatch: 4.0.2 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4214,23 +4314,23 @@ snapshots: tslib@2.6.3: {} - tsup@8.2.4(postcss@8.4.40)(typescript@5.5.4): + tsup@8.3.5(postcss@8.4.40)(typescript@5.5.4): dependencies: - bundle-require: 5.0.0(esbuild@0.23.0) + bundle-require: 5.0.0(esbuild@0.24.2) cac: 6.7.14 - chokidar: 3.6.0 + chokidar: 4.0.3 consola: 3.2.3 - debug: 4.3.6 - esbuild: 0.23.0 - execa: 5.1.1 - globby: 11.1.0 + debug: 4.4.0 + esbuild: 0.24.2 joycon: 3.1.1 - picocolors: 1.0.1 + picocolors: 1.1.1 postcss-load-config: 6.0.1(postcss@8.4.40) resolve-from: 5.0.0 - rollup: 4.19.2 + rollup: 4.29.2 source-map: 0.8.0-beta.0 sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.10 tree-kill: 1.2.2 optionalDependencies: postcss: 8.4.40 @@ -4339,11 +4439,15 @@ snapshots: wrappy@1.0.2: {} + yallist@4.0.0: + optional: true + yocto-queue@0.1.0: {} - youtubei.js@10.3.0: + youtubei.js@12.2.0: dependencies: - jintr: 2.1.1 + '@bufbuild/protobuf': 2.2.3 + jintr: 3.2.0 tslib: 2.6.3 undici: 5.28.4 diff --git a/web/i18n/en/a11y/save.json b/web/i18n/en/a11y/save.json index 3a92de6..2dc8515 100644 --- a/web/i18n/en/a11y/save.json +++ b/web/i18n/en/a11y/save.json @@ -1,5 +1,6 @@ { "link_area": "link input area", + "link_area.turnstile": "link input area. checking if you're not a robot.", "clear_input": "clear input", "download": "download", "download.think": "processing the link...", diff --git a/web/i18n/en/about.json b/web/i18n/en/about.json index 914c5cf..c833971 100644 --- a/web/i18n/en/about.json +++ b/web/i18n/en/about.json @@ -1,5 +1,5 @@ { - "page.general": "what's team hydra video downloader?", + "page.general": "what's Team Hydra Video Downloader?", "page.faq": "FAQ", "page.community": "community & support", @@ -8,9 +8,28 @@ "page.terms": "terms and ethics", "page.credits": "thanks & licenses", - "community.discord": "community discord server", - "community.twitter": "news account on twitter", - "community.github": "github repo", - "community.email": "support email", - "community.telegram": "news channel on telegram" + "heading.general": "general terms", + "heading.licenses": "licenses", + "heading.summary": "best way to save what you love", + "heading.privacy": "leading privacy", + "heading.community": "open community", + "heading.local": "on-device processing", + "heading.saving": "saving", + "heading.encryption": "encryption", + "heading.plausible": "anonymous traffic analytics", + "heading.cloudflare": "web privacy & security", + "heading.responsibility": "user responsibilities", + "heading.abuse": "reporting abuse", + "heading.motivation": "motivation", + "heading.testers": "beta testers", + + "support.github": "check out Team Hydra Video Downloader's source code, contribute changes, or report issues", + "support.discord": "chat with the community and developers about Team Hydra Video Downloader or ask for help", + "support.twitter": "follow Team Hydra Video Downloader's updates and development on your twitter timeline", + "support.telegram": "stay up to date with latest Team Hydra Video Downloader updates via a telegram channel", + "support.bluesky": "follow Team Hydra Video Downloader's updates and development on your bluesky feed", + + "support.description.issue": "if you want to report a bug or some other recurring issue, please do it on github.", + "support.description.help": "use discord for any other questions. describe the issue properly in #Team Hydra Video Downloader-support or else no one will be able help you.", + "support.description.best-effort": "all support is best effort and not guaranteed, a reply might take some time." } diff --git a/web/i18n/en/button.json b/web/i18n/en/button.json index 849ac98..1ea7fb4 100644 --- a/web/i18n/en/button.json +++ b/web/i18n/en/button.json @@ -7,6 +7,7 @@ "download": "download", "share": "share", "copy": "copy", + "copy.section": "copy the section link", "copied": "copied", "import": "import", "continue": "continue", diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json index 11980ac..791555b 100644 --- a/web/i18n/en/dialog.json +++ b/web/i18n/en/dialog.json @@ -1,6 +1,6 @@ { - "reset.title": "reset all settings?", - "reset.body": "are you sure you want to reset all settings? this action is immediate and irreversible.", + "reset.title": "reset all data?", + "reset.body": "are you sure you want to reset all data? this action is immediate and irreversible.", "picker.title": "select what to save", "picker.description.desktop": "click an item to save it. images can also be saved via the right click menu.", @@ -8,18 +8,18 @@ "picker.description.ios": "press an item to save it with a shortcut. images can also be saved with a long press.", "saving.title": "choose how to save", - "saving.blocked": "team hydra video downloader tried opening the file in a new tab, but your browser blocked it. you can allow pop-ups for team hydra video downloader to prevent this from happening next time.", - "saving.timeout": "team hydra video downloader tried saving the file automatically, but your browser stopped it. you have to select a preferred method manually.", + "saving.blocked": "Team Hydra Video Downloader tried opening the file in a new tab, but your browser blocked it. you can allow pop-ups for Team Hydra Video Downloader to prevent this from happening next time.", + "saving.timeout": "Team Hydra Video Downloader tried saving the file automatically, but your browser stopped it. you have to select a preferred method manually.", "safety.title": "important safety notice", - "import.body": "importing unknown or corrupted files may unexpectedly alter or break team hydra video downloader functionality. only import files that you've personally exported and haven't modified. if you were asked to import this file by someone - don't do it.\n\nwe are not responsible for any harm caused by importing unknown setting files.", + "import.body": "importing unknown or corrupted files may unexpectedly alter or break Team Hydra Video Downloader functionality. only import files that you've personally exported and haven't modified. if you were asked to import this file by someone - don't do it.\n\nwe are not responsible for any harm caused by importing unknown setting files.", "api.override.title": "processing instance override", "api.override.body": "{{ value }} is now the processing instance. if you don't trust it, press \"cancel\" and it'll be ignored.\n\nyou can change your choice later in processing settings.", - "safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from team hydra video downloader and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.", + "safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from Team Hydra Video Downloader and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.", - "processing.ongoing": "team hydra video downloader is currently processing media in this tab. going away will abort it. are you sure you want to do this?", + "processing.ongoing": "Team Hydra Video Downloader is currently processing media in this tab. going away will abort it. are you sure you want to do this?", "processing.title.ongoing": "processing will be cancelled" } diff --git a/web/i18n/en/donate.json b/web/i18n/en/donate.json index bf99eb8..cb52b08 100644 --- a/web/i18n/en/donate.json +++ b/web/i18n/en/donate.json @@ -1,15 +1,16 @@ { "banner.title": "Support a safe\nand open Internet", - "banner.subtitle": "donate to imput or share the\njoy of cobalt with a friend", + "banner.subtitle": "donate to imput or share the\njoy of Team Hydra Video Downloader with a friend", - "body.motivation": "cobalt helps thousands of producers, educators, and other creative people to do what they love. we created cobalt because we believe that internet doesn’t have to be scary. greed and ads have ruined the internet — we fight back with friendly and open tools that are made with love, not for profit.", - "body.keep_going": "you can help us stay motivated & keep creating safe alternatives to abusive tools by sharing cobalt with a friend or donating.", + "body.motivation": "Team Hydra Video Downloader helps producers, educators, video makers, and many others to do what they love. it's a different kind of service that is made with love, not for profit.", + "body.no_bullshit": "we believe that the internet doesn't have to be scary, which is why Team Hydra Video Downloader will never have ads or other kinds of malicious content. it's a promise that we firmly stand by. everything we do is built with privacy, accessibility, and ease of use in mind, making Team Hydra Video Downloader available for everyone.", + "body.keep_going": "if you found Team Hydra Video Downloader useful, please consider supporting our work! you can help us by making a donation or sharing Team Hydra Video Downloader with a friend. every donation is highly appreciated and helps us keep working on Team Hydra Video Downloader and other projects.", "card.once": "one-time donation", - "card.monthly": "monthly donation", + "card.recurring": "recurring donation", "card.custom": "custom amount (from $2)", - "card.processor": "processed by {{value}}", + "card.processor": "via {{value}}", "card.option.5": "cup of coffee", "card.option.10": "full size pizza", @@ -27,7 +28,7 @@ "card.custom.submit": "donate custom amount", - "share.title": "share cobalt with a friend", + "share.title": "share Team Hydra Video Downloader with a friend", "alternative.title": "alternative ways to donate", diff --git a/web/i18n/en/error.json b/web/i18n/en/error.json index 4469590..9d78c89 100644 --- a/web/i18n/en/error.json +++ b/web/i18n/en/error.json @@ -1,52 +1,70 @@ { - "import.no_data": "there's nothing to load from the file. are you sure it's the right one?", - "import.invalid": "your file doesn't have valid team hydra video downloader settings to import. are you sure it's the right one?", + "import.no_data": "there are no settings to load from this file. are you sure it's the right one?", + "import.invalid": "this file doesn't have valid Team Hydra Video Downloader settings to import. are you sure it's the right one?", "import.unknown": "couldn't load data from the file. it may be corrupted or of wrong format. here's the error i got:\n\n{{ value }}", "remux.corrupted": "couldn't read the metadata from this file, it may be corrupted.", - "remux.out_of_resources": "team hydra video downloader ran out of resources and can't continue with on-device processing. this is related to limitations on your browser's side. try refreshing or reopening the app and trying again. some devices can only process tiny files.", + "remux.out_of_resources": "Team Hydra Video Downloader ran out of resources and can't continue with on-device processing. this is caused by your browser's limitations. refresh or reopen the app and try again!", - "tunnel.probe": "couldn't verify whether you can download this file. try again in a few seconds!", + "tunnel.probe": "couldn't test this tunnel. your browser or network configuration may be blocking access to one of Team Hydra Video Downloader servers. are you sure you don't have any weird browser extensions?", - "api.auth.jwt.missing": "couldn't confirm whether you're not a robot because the processing server didn't receive the human access token. try again in a few seconds or reload the page!", - "api.auth.jwt.invalid": "couldn't confirm whether you're not a robot because your human access token expired and wasn't renewed. try again in a few seconds or reload the page!", - "api.auth.turnstile.missing": "couldn't confirm whether you're not a robot because the processing server didn't receive the human access token. try again in a few seconds or reload the page!", - "api.auth.turnstile.invalid": "couldn't confirm whether you're not a robot because your human access token expired and wasn't renewed. try again in a few seconds or reload the page!", + "captcha_ongoing": "cloudflare turnstile is still checking if you're not a bot. if it takes too long, you can try: disabling weird browser extensions, changing networks, using a different browser, or checking your device for malware.", - "api.unreachable": "couldn't connect to the processing server. check your internet connection and try again.", - "api.timed_out": "the processing server took way too long to respond. it may be overwhelmed at the moment, try again in a few seconds!", - "api.rate_exceeded": "you're making way too many requests. try again in {{ limit }} seconds!", - "api.capacity": "team hydra video downloader is at capacity and can't process your request at the moment. try again in a few seconds. if it still doesn't work, let us know and we'll try to help!", + "api.auth.jwt.missing": "couldn't authenticate with the processing instance because the access token is missing. try again in a few seconds or reload the page!", + "api.auth.jwt.invalid": "couldn't authenticate with the processing instance because the access token is invalid. try again in a few seconds or reload the page!", + "api.auth.turnstile.missing": "couldn't authenticate with the processing instance because the captcha solution is missing. try again in a few seconds or reload the page!", + "api.auth.turnstile.invalid": "couldn't authenticate with the processing instance because the captcha solution is invalid. try again in a few seconds or reload the page!", - "api.generic": "something went wrong and i couldn't get anything for you. try again in a few seconds, but if issue sticks, let us know and we'll try to help!", - "api.unknown_response": "couldn't parse the response from the server. this could be caused by a version mismatch. are you sure you're on the latest version of team hydra video downloader?", + "api.auth.key.missing": "an access key is required to use this processing instance but it's missing. add it in instance settings!", + "api.auth.key.not_api_key": "an access key is required to use this processing instance but it's missing. add it in instance settings!", + + "api.auth.key.invalid": "the access key is invalid. reset it in instance settings and use a proper one!", + "api.auth.key.not_found": "the access key you used couldn't be found. are you sure this instance has your key?", + "api.auth.key.invalid_ip": "your ip address couldn't be parsed. something went very wrong. report this issue!", + "api.auth.key.ip_not_allowed": "your ip address is not allowed to use this access key. use a different instance or network!", + "api.auth.key.ua_not_allowed": "your user agent is not allowed to use this access key. use a different client or device!", + + "api.unreachable": "couldn't connect to the processing instance. check your internet connection and try again!", + "api.timed_out": "the processing instance took too long to respond. it may be overwhelmed at the moment, try again in a few seconds!", + "api.rate_exceeded": "you're making too many requests. try again in {{ limit }} seconds.", + "api.capacity": "Team Hydra Video Downloader is at capacity and can't process your request at the moment. try again in a few seconds!", + + "api.generic": "something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!", + "api.unknown_response": "couldn't read the response from the processing instance. this could be caused by a version mismatch between Team Hydra Video Downloader instances.", "api.service.unsupported": "this service is not supported yet. have you pasted the right link?", - "api.service.disabled": "this service is supported by team hydra video downloader, but it's disabled on this instance. try a link from another service!", + "api.service.disabled": "this service is generally supported by Team Hydra Video Downloader, but it's disabled on this processing instance. try a link from another service!", + "api.service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!", "api.link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?", "api.link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?", - "api.fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't find anything for you. are you sure your link works? if it does and you still see this error, let us know and we'll try to help!", - "api.fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if issue sticks, let us know!", + "api.fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!", + "api.fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", "api.fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?", - "api.fetch.rate": "the team hydra video downloader processing server got rate limited by the {{ service }} api. try again in a few seconds!", - "api.fetch.short_link": "couldn't get link info from the short link. are you sure it works? if it does and you still get this error, let us know, and we'll try to help!", + "api.fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!", + "api.fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!", + + "api.content.too_long": "media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!", - "api.content.too_long": "the media you requested is too long. current duration limit is {{ limit }} minutes. try something shorter instead!", + "api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!", + "api.content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!", + "api.content.video.private": "this video is private, so i can't access it. change its visibility or try another one!", + "api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try a different link!", + "api.content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!", - "api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. have you pasted the right link?", - "api.content.video.live": "this video is currently live, so i can't download it yet. wait for the livestream to finish, and then try again!", - "api.content.video.private": "this video is private, so i cannot access it. change its visibility or try another one!", - "api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try another one!", - "api.content.video.region": "this video is region locked, and the processing server is in a different location. try another one!", + "api.content.region": "this content is region locked, and the processing instance is in a different location. try a different link!", + "api.content.paid": "this content requires purchase. Team Hydra Video Downloader can't download paid content. try a different link!", - "api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist at all. make sure your link works and try again in a few seconds!", - "api.content.post.private": "this post is from a private account, so i can't access it. have you pasted the right link?", - "api.content.post.age": "this post is age-restricted, so i can't access it anonymously. have you pasted the right link?", + "api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!", + "api.content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!", + "api.content.post.age": "this post is age-restricted and isn't available without logging in. try a different link!", - "api.youtube.codec": "youtube didn't return anything with your preferred codec & resolution. try another set of settings!", - "api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video.\n\ntry again in a few seconds, but if issue sticks, contact us for support.", - "api.youtube.login": "couldn't get this video because youtube labeled me as a bot. this is potentially caused by the processing instance not having any active account tokens. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!", - "api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!" + "api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!", + "api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!", + "api.youtube.login": "couldn't get this video because youtube asked the instance to log in. this is potentially caused by the processing instance not having any active account tokens or youtube updating something about their api. try again in a few seconds, but if it still doesn't work, please report this issue!", + "api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!", + "api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!", + "api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!", + "api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check Team Hydra Video Downloader's socials or github for timely updates!" } diff --git a/web/i18n/en/general.json b/web/i18n/en/general.json index c3667f2..80b2bd2 100644 --- a/web/i18n/en/general.json +++ b/web/i18n/en/general.json @@ -1,7 +1,7 @@ { - "cobalt": "cobalt", + "cobalt": "THVDL", "meowbalt": "meowbalt", "beta": "beta", - "embed.description": "save what you love without ads, tracking, paywalls or other nonsense. cobalt is a truly open web app, built with love and care by imput." + "embed.description": "Team Hydra Video Downloader lets you save what you love without ads, tracking, paywalls or other nonsense. just paste the link and you're ready to rock!" } diff --git a/web/i18n/en/remux.json b/web/i18n/en/remux.json index b7a7f34..d8b031c 100644 --- a/web/i18n/en/remux.json +++ b/web/i18n/en/remux.json @@ -1,3 +1,8 @@ { - "description": "remuxing often fixes compatibility issues with old software. it's fast, lossless, and everything is processed on-device." + "bullet.purpose.title": "what does remux do?", + "bullet.purpose.description": "remux fixes any issues with the file container, such as missing time info. it helps increase compatibility with old software, such as vegas pro and windows media player.", + "bullet.explainer.title": "how does it work?", + "bullet.explainer.description": "remuxing takes existing codec data and copies it over to a new media container. it's lossless, media data doesn't get re-encoded.", + "bullet.privacy.title": "on-device processing", + "bullet.privacy.description": "cobalt remuxes files locally. files never leave your device, so processing is nearly instant." } diff --git a/web/i18n/en/save.json b/web/i18n/en/save.json index 6327388..3f141a2 100644 --- a/web/i18n/en/save.json +++ b/web/i18n/en/save.json @@ -10,14 +10,16 @@ "services.title": "supported services", "services.title_show": "show supported services", "services.title_hide": "hide supported services", - "services.disclaimer": "team hydra video downloader is not affiliated with any of the services listed above.", + "services.disclaimer": "Team Hydra Video Downloader is not affiliated with any of the services listed above.", "tutorial.title": "how to save on ios?", "tutorial.intro": "to save media conveniently on ios, you'll need to use a companion siri shortcut from the share sheet.", "tutorial.step.1": "add companion siri shortcuts:", - "tutorial.step.2": "press the \"share\" button in team hydra video downloader's saving dialog.", + "tutorial.step.2": "press the \"share\" button in Team Hydra Video Downloader's saving dialog.", "tutorial.step.3": "select the respective shortcut in the share sheet.", - "tutorial.outro": "these shortcuts will work only from the team hydra video downloader app, sharing links from other apps will not work.", + "tutorial.outro": "these shortcuts will work only from the Team Hydra Video Downloader app, sharing links from other apps will not work.", "tutorial.shortcut.photos": "to photos", - "tutorial.shortcut.files": "to files" + "tutorial.shortcut.files": "to files", + + "label.community_instance": "community instance" } diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index 99b18ad..b0ebe45 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -5,7 +5,7 @@ "page.audio": "audio", "page.download": "downloading", "page.advanced": "advanced", - "page.debug": "debug information", + "page.debug": "info for nerds", "page.instances": "instances", "section.general": "general", @@ -29,8 +29,12 @@ "video.quality.144": "144p", "video.quality.description": "if preferred video quality isn't available, next best is picked instead.", - "video.youtube.codec": "youtube video codec and container", - "video.youtube.codec.description": "h264: best compatibility, average bitrate. max quality is 1080p. \nav1: best quality, efficiency, and bitrate. supports 8k & HDR. \nvp9: same quality & bitrate as av1, but file is approximately two times bigger. supports 4k & HDR.\n\nav1 and vp9 aren't as widely supported as h264.", + "video.youtube.codec": "youtube codec and container", + "video.youtube.codec.description": "h264: best compatibility, average quality. max quality is 1080p. \nav1: best quality and efficiency. supports 8k & HDR. \nvp9: same quality as av1, but file is ~2x bigger. supports 4k & HDR.\n\nav1 and vp9 aren't as widely supported as h264. if av1 or vp9 isn't available, h264 is used instead.", + + "video.youtube.hls": "youtube hls formats", + "video.youtube.hls.title": "prefer hls for video & audio", + "video.youtube.hls.description": "files download faster and are less prone to errors or getting abruptly cut off. only h264 and vp9 codecs are available in this mode. original audio codec is aac, it's re-encoded for compatibility, audio quality may be slightly worse than the non-HLS counterpart.\n\nthis option is experimental, it may go away or change in the future.", "video.twitter.gif": "twitter/x", "video.twitter.gif.title": "convert looping videos to GIF", @@ -46,26 +50,27 @@ "audio.format.ogg": "ogg", "audio.format.wav": "wav", "audio.format.opus": "opus", - "audio.format.description": "all formats but \"best\" are converted, meaning that there'll be some quality loss. audio is not reencoded only when \"best\" format is selected.", + "audio.format.description": "all formats but \"best\" are converted from the source format, there will be some quality loss. when \"best\" format is selected, the audio is kept in its original format whenever possible.", "audio.bitrate": "audio bitrate", "audio.bitrate.kbps": "kb/s", - "audio.bitrate.description": "bitrate applies only to audio conversion. team hydra video downloader can't improve the source audio quality, so choosing a bitrate over 128kbps may inflate the file size with no audible difference. perceived quality may vary by format.", + "audio.bitrate.description": "bitrate is applied only when converting audio to a lossy format. Team Hydra Video Downloader can't improve the source audio quality, so choosing a bitrate over 128kbps may inflate the file size with no audible difference. perceived quality may vary by format.", - "audio.youtube.dub": "youtube", - "audio.youtube.dub.title": "use browser language for dubbed videos", - "audio.youtube.dub.description": "works even if team hydra video downloader isn't translated to your language.", + "audio.youtube.dub": "youtube audio track", + "audio.youtube.dub.title": "preferred dub language", + "audio.youtube.dub.description": "Team Hydra Video Downloader will use a dubbed audio track for selected language if it's available. if not, original will be used instead.", + "youtube.dub.original": "original", "audio.tiktok.original": "tiktok", "audio.tiktok.original.title": "download original sound", - "audio.tiktok.original.description": "team hydra video downloader will download the sound from the video without any changes by the post's author.", + "audio.tiktok.original.description": "Team Hydra Video Downloader will download the sound from the video without any changes by the post's author.", "metadata.filename": "filename style", "metadata.filename.classic": "classic", "metadata.filename.basic": "basic", "metadata.filename.pretty": "pretty", "metadata.filename.nerdy": "nerdy", - "metadata.filename.description": "filename style will only be used for files tunneled by team hydra video downloader. some services don't support filename styles other than classic.", + "metadata.filename.description": "filename style will only be used for files tunneled by Team Hydra Video Downloader. some services don't support filename styles other than classic.", "metadata.filename.preview.video": "Video Title", "metadata.filename.preview.audio": "Audio Title - Audio Author", @@ -79,43 +84,43 @@ "saving.download": "download", "saving.share": "share", "saving.copy": "copy", - "saving.description": "preferred way of saving the file or link from team hydra video downloader. if preferred method is unavailable or something goes wrong, team hydra video downloader will ask you what to do next.", + "saving.description": "preferred way of saving the file or link from Team Hydra Video Downloader. if preferred method is unavailable or something goes wrong, Team Hydra Video Downloader will ask you what to do next.", "accessibility": "accessibility", "accessibility.transparency.title": "reduce visual transparency", - "accessibility.transparency.description": "reduces transparency of surfaces and disables blur effects.", + "accessibility.transparency.description": "reduces transparency of surfaces and disables blur effects. may also improve ui performance on low performance devices.", "accessibility.motion.title": "reduce motion", "accessibility.motion.description": "disables animations and transitions whenever possible.", "language": "language", - "language.auto.title": "use default browser language", - "language.auto.description": "automatically picks the best language for you. if preferred browser language isn't available, english is used instead.", + "language.auto.title": "automatic selection", + "language.auto.description": "Team Hydra Video Downloader will use your browser's default language if translation is available. if not, english will be used instead.", "language.preferred.title": "preferred language", - "language.preferred.description": "if any text isn’t translated to the preferred language, it will fall back to english.", + "language.preferred.description": "this language will be used when automatic selection is disabled. any text that isn't translated will be displayed in english.\n\nsome languages use community-sourced translations, they may be inaccurate or incomplete.", "privacy.analytics": "anonymous traffic analytics", "privacy.analytics.title": "don't contribute to analytics", - "privacy.analytics.description": "anonymous traffic analytics are needed to get an approximate number of active team hydra video downloader users. no identifiable information about you is ever stored. all processed data is anonymized and aggregated.\n\nwe use a self-hosted plausible instance that doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.", + "privacy.analytics.description": "anonymous traffic analytics are needed to get an approximate number of active Team Hydra Video Downloader users. no identifiable information about you is ever stored. all processed data is anonymized and aggregated.\n\nwe use a self-hosted plausible instance that doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.", "privacy.analytics.learnmore": "learn more about plausible's dedication to privacy.", "privacy.tunnel": "tunneling", "privacy.tunnel.title": "always tunnel files", - "privacy.tunnel.description": "team hydra video downloader will hide your ip address, browser info, and bypass local network restrictions. when enabled, files will also have readable filenames that otherwise would be gibberish.", + "privacy.tunnel.description": "Team Hydra Video Downloader will hide your ip address, browser info, and bypass local network restrictions. when enabled, files will also have readable filenames that otherwise would be gibberish.", "advanced.debug": "debug", - "advanced.debug.title": "enable debug features", - "advanced.debug.description": "gives you access to a page with various info that can be useful for debugging.", - - "advanced.data": "settings data", + "advanced.debug.title": "enable features for nerds", + "advanced.debug.description": "gives you easy access to app info that can be useful for debugging. enabling this does not affect functionality of Team Hydra Video Downloader in any way.", - "processing.override": "default instance override", - "processing.override.title": "use the instance-provided processing server", - "processing.override.description": "if web instance provides its own default processing server, you can choose to use it over the main processing server. make sure it's a server by someone you trust.", + "advanced.data": "data management", "processing.community": "community instances", - "processing.enable_custom.title": "use a custom processing server", - "processing.enable_custom.description": "team hydra video downloader will use a custom processing server if you choose to. even though team hydra video downloader has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\n\nplease be mindful of what instances you use and make sure they're hosted by people you trust.", + "processing.enable_custom.description": "Team Hydra Video Downloader will use a custom processing instance if you choose to. even though Team Hydra Video Downloader has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\n\nplease be mindful of what instances you use and make sure they're hosted by people you trust.", + + "processing.access_key": "instance access key", + "processing.access_key.title": "use an instance access key", + "processing.access_key.description": "Team Hydra Video Downloader will use this key to make requests to the processing instance instead of other authentication methods. make sure the instance supports api keys!", - "processing.custom.placeholder": "custom instance domain" + "processing.custom_instance.input.alt_text": "custom instance domain", + "processing.access_key.input.alt_text": "u-u-i-d access key" } diff --git a/web/src/app.html b/web/src/app.html index 2c877be..b60acb3 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -1,46 +1,35 @@ - + - - - - - + + + + + %sveltekit.head% - - - + + + - - + + - - + + + + - -
%sveltekit.body%
+ +
+ %sveltekit.body% +
diff --git a/web/src/components/about/AboutSupport.svelte b/web/src/components/about/AboutSupport.svelte new file mode 100644 index 0000000..713c003 --- /dev/null +++ b/web/src/components/about/AboutSupport.svelte @@ -0,0 +1,121 @@ + + + + + diff --git a/web/src/components/buttons/SettingsToggle.svelte b/web/src/components/buttons/SettingsToggle.svelte index 75a564e..5d5941a 100644 --- a/web/src/components/buttons/SettingsToggle.svelte +++ b/web/src/components/buttons/SettingsToggle.svelte @@ -39,16 +39,14 @@ updateSetting({ [settingContext]: { [settingId]: !isEnabled, - }, - })} + } + }) + } >

{title}

- + {#if description}
{description}
{/if} @@ -78,7 +76,7 @@ align-items: center; gap: var(--padding); justify-content: space-between; - text-align: left; + text-align: start; transform: none; padding: calc(var(--switcher-padding) * 2) 16px; border-radius: var(--border-radius); diff --git a/web/src/components/buttons/Switcher.svelte b/web/src/components/buttons/Switcher.svelte index a72c4e7..0792c9e 100644 --- a/web/src/components/buttons/Switcher.svelte +++ b/web/src/components/buttons/Switcher.svelte @@ -43,10 +43,25 @@ border-bottom-left-radius: 0; } + .switcher:not(.big):dir(rtl) :global(.button:first-child) { + border-top-right-radius: inherit; + border-bottom-right-radius: inherit; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .switcher:not(.big):dir(rtl) :global(.button:last-child) { + border-top-left-radius: inherit; + border-bottom-left-radius: inherit; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + .switcher.big { background: var(--button); box-shadow: var(--button-box-shadow); padding: var(--switcher-padding); + gap: calc(var(--switcher-padding) - 1.5px); } .switcher :global(.button.active) { diff --git a/web/src/components/changelog/ChangelogEntry.svelte b/web/src/components/changelog/ChangelogEntry.svelte index acb7256..e6ba53d 100644 --- a/web/src/components/changelog/ChangelogEntry.svelte +++ b/web/src/components/changelog/ChangelogEntry.svelte @@ -141,11 +141,12 @@ :global(.changelog-banner) { display: block; object-fit: cover; - max-height: 320pt; - min-height: 210pt; + max-height: 350pt; + min-height: 180pt; width: 100%; aspect-ratio: 16/9; border-radius: var(--padding); + pointer-events: all; } .changelog-banner.loading { diff --git a/web/src/components/dialog/DialogButton.svelte b/web/src/components/dialog/DialogButton.svelte index f977cc6..69aaf72 100644 --- a/web/src/components/dialog/DialogButton.svelte +++ b/web/src/components/dialog/DialogButton.svelte @@ -23,21 +23,36 @@ onDestroy(() => clearInterval(interval)); } - - - +{#if button.link} +
+ {button.text} + +{:else} + +{/if} diff --git a/web/src/components/dialog/PickerItem.svelte b/web/src/components/dialog/PickerItem.svelte index 7edf8c6..4a515a6 100644 --- a/web/src/components/dialog/PickerItem.svelte +++ b/web/src/components/dialog/PickerItem.svelte @@ -14,6 +14,7 @@ export let number: number; let imageLoaded = false; + const isTunnel = new URL(item.url).pathname === "/tunnel"; $: itemType = item.type ?? "photo"; @@ -23,6 +24,7 @@ on:click={() => downloadFile({ url: item.url, + urlType: isTunnel ? "tunnel" : "redirect", })} >
diff --git a/web/src/components/dialog/SavingDialog.svelte b/web/src/components/dialog/SavingDialog.svelte index f71e2b9..0312735 100644 --- a/web/src/components/dialog/SavingDialog.svelte +++ b/web/src/components/dialog/SavingDialog.svelte @@ -10,6 +10,8 @@ shareFile, } from "$lib/download"; + import type { CobaltFileUrlType } from "$lib/types/api"; + import DialogContainer from "$components/dialog/DialogContainer.svelte"; import Meowbalt from "$components/misc/Meowbalt.svelte"; @@ -22,12 +24,14 @@ import IconFileDownload from "@tabler/icons-svelte/IconFileDownload.svelte"; import CopyIcon from "$components/misc/CopyIcon.svelte"; + export let id: string; export let dismissable = true; export let bodyText: string = ""; export let url: string = ""; export let file: File | undefined = undefined; + export let urlType: CobaltFileUrlType | undefined = undefined; let close: () => void; @@ -55,7 +59,7 @@
- {#if device.supports.directDownload} + {#if device.supports.directDownload && !(device.is.iOS && urlType === "redirect")} import '@fontsource/redaction-10/400.css'; - import { t } from '$lib/i18n/translations'; - import Imput from '$components/icons/Imput.svelte'; import Meowbalt from '$components/misc/Meowbalt.svelte'; - import IconHeart from '@tabler/icons-svelte/IconHeart.svelte'; @@ -22,8 +19,9 @@ give us money lol