commit 4195b20e65ba5c12498b99e35ce5bfee86ae13b1 Author: r4 Date: Tue Sep 20 00:54:22 2022 +0200 init diff --git a/DEBIAN/conffiles b/DEBIAN/conffiles new file mode 100644 index 0000000..fa6a9f1 --- /dev/null +++ b/DEBIAN/conffiles @@ -0,0 +1 @@ +/etc/dischord/config.json diff --git a/DEBIAN/control b/DEBIAN/control new file mode 100644 index 0000000..177e2f3 --- /dev/null +++ b/DEBIAN/control @@ -0,0 +1,6 @@ +Package: dischord +Version: 0.1 +Maintainer: r4 +Architecture: amd64 +Description: Discord music bot, supports YouTube, Spotify and many other sites +Depends: youtube-dl, ffmpeg diff --git a/DEBIAN/postinst b/DEBIAN/postinst new file mode 100755 index 0000000..3de5496 --- /dev/null +++ b/DEBIAN/postinst @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +cd /etc/dischord +dischord -wizard -quit_after_wizard +cd - +systemctl enable --now dischord.service diff --git a/DEBIAN/prerm b/DEBIAN/prerm new file mode 100755 index 0000000..f15a62e --- /dev/null +++ b/DEBIAN/prerm @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +systemctl disable --now dischord.service || true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..12e3371 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type 'show c' for details. + +The hypothetical commands 'show w' and 'show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ab71592 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +GO = go + +PREFIX = /usr/local +CFGPREFIX = /etc + +EXE = dischord + +all: + $(GO) build -o $(EXE) cmd/$(EXE)/*.go + +debug: + $(GO) build -o $(EXE) -gcflags=all="-N -l" cmd/$(EXE)/*.go + dlv exec ./dischord + +install: all + mkdir -p $(DESTDIR)$(PREFIX)/bin + cp -f $(EXE) $(DESTDIR)$(PREFIX)/bin + chmod 755 $(DESTDIR)$(PREFIX)/bin/$(EXE) + mkdir -p $(DESTDIR)$(CFGPREFIX)/$(EXE) + @if command -v systemd; then \ + $(MAKE) install-systemd; \ + else \ + @echo "Systemd not found, if you want to add $(DESTDIR)$(PREFIX)/bin/$(EXE) as a service, please do so manually."; \ + fi + +install-systemd: + mkdir -p $(DESTDIR)$(CFGPREFIX)/systemd/system + echo \ +[Unit] \ +Description=$(EXE) \ +Requires=network-online.target \ +After=network-online.target \ +[Service] \ +Type=simple \ +ExecStart=$(PREFIX)/bin/$(EXE) \ +WorkingDirectory=$(CFGPREFIX)/$(EXE) \ +Restart=on-failure \ +[Install] \ +WantedBy=multi-user.target \ +| sed 's/ /\n/g' > $(DESTDIR)$(CFGPREFIX)/systemd/system/$(EXE).service + +uninstall: + @if command -v systemd; then \ + systemctl stop $(EXE); \ + systemctl disable $(EXE); \ + fi + rm -f $(DESTDIR)$(PREFIX)/bin/$(EXE) + rm -f $(DESTDIR)$(CFGPREFIX)/systemd/system/$(EXE).service + +test: + $(GO) test -count=1 -v $(EXE)/extractor + +.PHONY: all debug install uninstall clean + +clean: + rm -f $(EXE) + rm -rf build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6bddcd --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Dischord +### A simple, easy-to-deploy Discord music bot written in go +### Supports YouTube, Spotify and hundreds of other sites using youtube-dl + +--- + +## Setup +- [Windows](#windows) +- [MacOS](#macos) +- [Linux](#linux) +- [From source (BSDs etc.)](#from-source) + +### Windows +#### Download .exe from releases +- [64-bit](https://github.com/xypwn/Dischord/releases/download/latest/dischord-windows-amd64.exe) (reasonably modern hardware) +- [32-bit](https://github.com/xypwn/Dischord/releases/download/latest/dischord-windows-x86.exe) (very old hardware) + +#### Preparations +After you're done downloading, I would recommend putting the executable into a new +folder so your downloads don't get too cluttered, since a few more files will +be created upon running the executable. + +#### Initial setup / running the .exe +To start the program, simply double click the .exe. This will bring up a command +window saying it will have to first download a few additional programs. After +it's finished downloading, edit the newly generated config.toml file and +replace the text after `token = ` with your Discord bot token, leaving the +surrounding "". After editing, the line should look something like this: + +```toml +token = "dmMpDY4K8dFyqMoypaZg3QXP.QUp5Sg.e7VQhRpEfud6SajSvyFZpxZpVwwBrwNYr2L3m7" +``` + +Now if you start the .exe again, it should just work. + +### MacOS +#### Download the executable from releases +- [Apple silicon](https://github.com/xypwn/Dischord/releases/download/latest/dischord-macos-apple-silicon) (newer models) +- [Intel hardware (untested)](https://github.com/xypwn/Dischord/releases/download/latest/dischord-macos-intel) (slightly older models) + +#### Preparations +After you're done downloading, I would recommend putting the executable into a new +folder so your downloads don't get too cluttered, since a few more files will +be created upon running the executable. + +In order to run the program, you will need to enable opening a terminal in +the current folder. To do so, go to +**System Preferences -> Keyboard -> Keyboard Shortcuts -> Services** +and enable +**Files and Folders -> New Terminal at Folder** and **New Terminal Tab at Folder**. + +#### Initial setup / running the executable +Navigate to the folder containing your executable, right-click and select +**Services -> New Terminal at Folder**. In the command +window that opens up, type `chmod +x dischord-macos-*` and hit `Enter` (you +will only need to do this once). + +Then, to run the executable, type `./dischord-macos-*` and hit `Enter`. + +On the first run, it will download a few additional programs. +When it's done, open the newly generated config.toml file with a text editor +and replace the text after `token = ` with your Discord bot token, leaving the +surrounding "". After editing, the line should look something like this: + +```toml +token = "dmMpDY4K8dFyqMoypaZg3QXP.QUp5Sg.e7VQhRpEfud6SajSvyFZpxZpVwwBrwNYr2L3m7" +``` + +Done! Now you can just run the executable and everything should work. + +### Linux +#### Download the executable from releases +- [amd64/x86_64/x64](https://github.com/xypwn/Dischord/releases/download/latest/dischord-linux-amd64) +- [i386/x86](https://github.com/xypwn/Dischord/releases/download/latest/dischord-linux-x86) +- [arm64 (untested)](https://github.com/xypwn/Dischord/releases/download/latest/dischord-linux-arm64) +- [armhf/arm32 (untested)](https://github.com/xypwn/Dischord/releases/download/latest/dischord-linux-arm32) + +#### Preparations +After you're done downloading, I would recommend putting the executable into a new +folder so your downloads don't get too cluttered, since a few more files will +be created upon running the executable. + +#### Initial setup / running the executable +First, `cd` into the executable's directory. + +Then, run `chmod +x dischord-linux-*` to make the file executable. + +Run the executable with `./dischord-linux-*`. + +On the first run, it will download **youtube-dl** and **FFmpeg** if they aren't +already installed on your system (for example through your package manager). +When it's done, open the newly generated config.toml file with a text editor +and replace the text after `token = ` with your Discord bot token, leaving the +surrounding "". After editing, the line should look something like this: + +```toml +token = "dmMpDY4K8dFyqMoypaZg3QXP.QUp5Sg.e7VQhRpEfud6SajSvyFZpxZpVwwBrwNYr2L3m7" +``` + +Done! Now you can just run the executable and everything should work. + +### From source (BSDs etc.) + +After installing [go](https://go.dev/dl/), you can simply run the makefile to +build a native binary. + +In case you are using a non-Linux OS, you will have to manually install +[youtube-dl](https://yt-dl.org/) and [FFmpeg](https://ffmpeg.org/) first before being able to run the bot. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..54d0ded --- /dev/null +++ b/TODO.md @@ -0,0 +1,4 @@ +- Replace the fmt.Prints in `player/player.go` with proper logging +- Add /skip +- Add expressions like -18..45 or -18..-2 to delete +- Decide on whether to keep `make install` and the Debian package template diff --git a/audio/audio.go b/audio/audio.go new file mode 100644 index 0000000..810c615 --- /dev/null +++ b/audio/audio.go @@ -0,0 +1,167 @@ +package audio + +import ( + "errors" + "io" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "fmt" +) + +const ( + BufferLength = 300 // 5min / 3.6MB + Channels = 1 // unfortunately, Discord doesn't seem to support stereo at the time + BitRate = 96000 + SampleRate = 48000 + FrameSize = 960 // 960 samples + FrameDuration = float64(FrameSize) / float64(SampleRate) // 20ms + FramesPerSecond = SampleRate / FrameSize +) + +var ( + ErrNotHttp = errors.New("the requested resource is not an http/https address") +) + +type frameIdx struct { + start uint + end uint +} + +// Takes a file path/HTTP(S) stream URL and returns Discord audio frames through +// audioFrameCh. After audioFrameCh is closed, errCh can be read to get any +// potential error. Will cleanly kill ffmpeg if a struct{} is sent through +// killCh (IMPORTANT: only send the kill signal ONCE: there is a chance that +// this goroutine exits just before you send a kill signal; this will be +// absorbed by the channel buffer, but your program might get stuck if you try +// to send two kill signals to a dead stream). +func StreamToDiscordOpus(ffmpegPath, input string, stdin io.Reader, seekSeconds float64, playbackSpeed float64, inetOnly bool) (audioFrameCh <-chan []byte, errCh <-chan error, killCh chan<- struct{}) { + out := make(chan []byte, BufferLength*FramesPerSecond) + errch := make(chan error, 1) + killch := make(chan struct{}, 1) + + go func() { + defer close(out) + defer close(errch) + + if inetOnly && !(strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://")) { + errch <- ErrNotHttp + return + } + + // Set ffmpeg options + var cmdOpts []string + cmdOpts = append(cmdOpts, + "-vn", // no video + "-sn", // no subs + "-dn") // no data encoding + if seekSeconds != 0.0 { + cmdOpts = append(cmdOpts, + "-accurate_seek", + "-ss", strconv.FormatFloat(seekSeconds, 'f', 5, 64)) // seek duration + } + cmdOpts = append(cmdOpts, + "-i", input) + if playbackSpeed != 1.0 { + cmdOpts = append(cmdOpts, + "-filter:a", "atempo="+strconv.FormatFloat(playbackSpeed, 'f', 5, 64)) // playback speed + } + cmdOpts = append(cmdOpts, + "-ab", strconv.Itoa(BitRate), // audio bit rate + "-ac", strconv.Itoa(Channels), // audio channels + "-frame_size", strconv.Itoa(int(FrameDuration*1000)), // frame size (in ms) + "-f", "opus", // output OPUS audio + "pipe:1") // output to stdout + + // Prepare ffmpeg command + cmd := exec.Command(ffmpegPath, cmdOpts...) + if stdin != nil { + cmd.Stdin = stdin + } + stdout, err := cmd.StdoutPipe() + if err != nil { + errch <- err + return + } + + // Start ffmpeg + if err := cmd.Start(); err != nil { + errch <- err + return + } + + // We want to let our main loop know if ffmpeg is done + donech := make(chan error) + go func() { + donech <- cmd.Wait() + }() + + // Ogg decoder + dec := newOggDecoder(stdout) + var segDec *oggSegmentDecoder + startSegDec := false + + // Avoid dropping frames + wantNewFrame := true + + // Main opus encoder loop + for { + var frame []byte + + for wantNewFrame { + if startSegDec && segDec.More() { + frame = make([]byte, segDec.SegmentSize()) + if err := segDec.ReadSegment(frame); err != nil { + errch <- err + return + } + wantNewFrame = false + } else if dec.More() { + var err error + var hdr oggPageHeader + hdr, segDec, err = dec.Page() + if err != nil { + errch <- err + return + } + if hdr.GranulePosition != 0 { + startSegDec = true + } + } else { + out = nil + break + } + } + + // Channel IO + select { + case err := <-donech: + fmt.Println("Audio done, waiting for read to finish") + if err != nil { + // Send error and exit + errch <- err + return + } + // Process exited normally, wait until all samples are read + // before closing the channels + for len(out) != 0 { + time.Sleep(20 * time.Millisecond) + } + fmt.Println("Audio read finished") + return + case <-killch: + // Process was killed by user + cmd.Process.Signal(os.Interrupt) + return + case out <- frame: + // Output is ready to receive a new frame + wantNewFrame = true + } + } + }() + + return out, errch, killch +} diff --git a/audio/ogg.go b/audio/ogg.go new file mode 100644 index 0000000..021ee0e --- /dev/null +++ b/audio/ogg.go @@ -0,0 +1,279 @@ +// Minimal Ogg decoder according to rfc3533 +// (https://www.xiph.org/ogg/doc/rfc3533.txt). Whenever 'the spec' is mentioned, +// it is referring to rfc3533. +package audio + +import ( + "encoding/binary" + "errors" + "io" +) + +var ( + ErrOggInvalidMagicNumber = errors.New("ogg: invalid magic number") + ErrOggInvalidChecksum = errors.New("ogg: invalid checksum") + ErrOggInvalidSegmentBufferSize = errors.New("ogg: WriteSegment(): invalid segment buffer size") +) + +const ( + headerSize = 27 + // Uncombined segments are meant here (see Page.Segments or the spec for + // more details about segments). + maxSegments = 255 + maxSegmentSize = 255 + maxPageSize = headerSize + maxSegments + maxSegments*maxSegmentSize +) + +// Using polynomial 0x04c11db7 (see the spec). +var crcLookup = [256]uint32{ + 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, + 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005, + 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, + 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd, + 0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9, + 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75, + 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011, + 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd, + 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039, + 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5, + 0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81, + 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d, + 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49, + 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95, + 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1, + 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d, + 0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae, + 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072, + 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16, + 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca, + 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde, + 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02, + 0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066, + 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba, + 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e, + 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692, + 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6, + 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a, + 0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e, + 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2, + 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686, + 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a, + 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637, + 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb, + 0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f, + 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53, + 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47, + 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b, + 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff, + 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623, + 0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7, + 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b, + 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f, + 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3, + 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7, + 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b, + 0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f, + 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3, + 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640, + 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c, + 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8, + 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24, + 0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30, + 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec, + 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088, + 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654, + 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0, + 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c, + 0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18, + 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4, + 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0, + 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c, + 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668, + 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4, +} + +type crc32Writer struct { + sum uint32 +} + +func (w *crc32Writer) Write(p []byte) (n int, err error) { + for _, v := range p { + w.sum = (w.sum << 8) ^ crcLookup[uint8(w.sum>>24)^v] + } + return len(p), nil +} + +var ( + FHeaderTypeContinuation = uint8(0x1) + FHeaderTypeBOS = uint8(0x2) // Beginning of stream + FHeaderTypeEOS = uint8(0x4) // End of stream +) + +// See the spec for more info on the individual fields. +type oggPageHeader struct { + MagicNumber [4]uint8 + Version uint8 + HeaderType uint8 + GranulePosition uint64 + BitstreamSerialNum uint32 + PageSequenceNum uint32 + Checksum uint32 + NumSegments uint8 // Number of uncombined segments (see Page.Segments or the spec for more details). +} + +type oggPage struct { + Header oggPageHeader + // These are the combined segments. The spec says that when a segment is + // said to have a length of 255, it is to be combined with the next segment, + // which is already done here. + Segments [][]byte +} + +type oggDecoder struct { + r io.Reader + sum *crc32Writer + givenSum uint32 + done bool + sd *oggSegmentDecoder +} + +// An oggDecoder decodes the given raw Ogg data according to rfc3533 +// (https://www.xiph.org/ogg/doc/rfc3533.txt). +// The given reader `r` must be set to start at the beginning of the page, i.e. +// at the magic number "OggS". +// The data is dispensed in units of pages, each of which returns a segment +// decoder for getting the individual segments. +func newOggDecoder(r io.Reader) *oggDecoder { + return &oggDecoder{ + r: r, + } +} + +func (d *oggDecoder) More() bool { + return !d.done +} + +func (d *oggDecoder) Page() (hdr oggPageHeader, sd *oggSegmentDecoder, err error) { + if d.sd != nil { + for d.sd.More() { + err = d.sd.SkipSegment() + if err != nil { + return + } + } + } + + sd = &oggSegmentDecoder{} + d.sd = sd + + // Read header + err = binary.Read(d.r, binary.LittleEndian, &hdr) + if err != nil { + return + } + + // Validate magic number + if string(hdr.MagicNumber[:]) != "OggS" { + err = ErrOggInvalidMagicNumber + return + } + + // Save the given checksum to match later + sd.givenSum = hdr.Checksum + + // Set checksum field to 0 before recalculating the checksum + hdr.Checksum = 0 + + sd.sum = &crc32Writer{} + + // Write header to checksum + binary.Write(sd.sum, binary.LittleEndian, hdr) + + // Write any future data read by the segment reader to the checksum as well + sd.r = io.TeeReader(d.r, sd.sum) + + // Read the sizes of all page segments + segsizes := make([]byte, hdr.NumSegments) + sd.r.Read(segsizes) + + // Whether to add to the last segment size: Whenever a segment length is + // specified as being 255, that means the next segment should be appended + // to it. + var add bool + + for _, s := range segsizes { + if add { + sd.fullSegsizes[len(sd.fullSegsizes)-1] += int(s) + } else { + sd.fullSegsizes = append(sd.fullSegsizes, int(s)) + } + add = s == 255 + } + + if hdr.HeaderType == FHeaderTypeEOS { + d.done = true + } + + return +} + +type oggSegmentDecoder struct { + dec *oggDecoder + r io.Reader + sum *crc32Writer + givenSum uint32 + fullSegsizes []int + i int // segment index + done bool +} + +func (d *oggSegmentDecoder) More() bool { + return !d.done +} + +func (d *oggSegmentDecoder) SegmentSize() int { + return d.fullSegsizes[d.i] +} + +func (d *oggSegmentDecoder) SkipSegment() error { + _, err := io.CopyN(io.Discard, d.r, int64(d.fullSegsizes[d.i])) + if err != nil { + d.done = true + return err + } + + d.i++ + + if d.i == len(d.fullSegsizes) { + d.done = true + + if d.sum.sum != d.givenSum { + return ErrOggInvalidChecksum + } + } + return nil +} + +// Assumes buf to be exactly the right size (use SegmentSize() to get the size beforehand) +func (d *oggSegmentDecoder) ReadSegment(buf []byte) error { + if len(buf) != d.fullSegsizes[d.i] { + return ErrOggInvalidSegmentBufferSize + } + + _, err := io.ReadFull(d.r, buf) + if err != nil { + d.done = true + return err + } + + d.i++ + + if d.i == len(d.fullSegsizes) { + d.done = true + + if d.sum.sum != d.givenSum { + return ErrOggInvalidChecksum + } + } + return nil +} diff --git a/build-all.sh b/build-all.sh new file mode 100755 index 0000000..a8b249f --- /dev/null +++ b/build-all.sh @@ -0,0 +1,49 @@ +#! /usr/bin/env sh + +SRC=cmd/dischord/*.go +BUILDDIR="build" + +BUILD_CMDS="" + +BUILDS=0 +DONE=0 + +[ "$1" = "clean" ] && echo "Cleaning $BUILDDIR" && rm -rf "$BUILDDIR" && exit 0 + +do_build() { + [ "$OS" = "windows" ] && EXT=".exe" + env GOOS="$OS" GOARCH="$ARCH" CGO_ENABLED=0 go build -ldflags "-s -w" -o "$BUILDDIR/$NAME$EXT" $SRC +} + +add() { + BUILDS=$((BUILDS+1)) + BUILD_CMDS=$(printf "%s\n%s" "$BUILD_CMDS" "$1 $2 $3") +} + +build() { + mkdir -p "$BUILDDIR" + BUILD_CMDS="$(printf "%s" "$BUILD_CMDS" | grep '.')" + while IFS= read -r l; do + OS=$(echo $l|cut -d\ -f1) + ARCH=$(echo $l|cut -d\ -f2) + NAME=$(echo $l|cut -d\ -f3) + echo "Building for $OS on $ARCH" + printf "%s\r" "Progress: $((DONE*100/BUILDS))%" + do_build + DONE=$((DONE+1)) + done << EOF +$BUILD_CMDS +EOF + echo "Progress: DONE" +} + +# see `go tool dist list` for possible configs +add linux 386 dischord-linux-x86 +add linux amd64 dischord-linux-amd64 +add linux arm dischord-linux-arm32 +add linux arm64 dischord-linux-arm64 +add darwin arm64 dischord-macos-apple-silicon +add darwin amd64 dischord-macos-intel +add windows 386 dischord-windows-x86 +add windows amd64 dischord-windows-amd64 +build diff --git a/cmd/dischord/bye.opus b/cmd/dischord/bye.opus new file mode 100644 index 0000000..2c4d4da Binary files /dev/null and b/cmd/dischord/bye.opus differ diff --git a/cmd/dischord/copyright.go b/cmd/dischord/copyright.go new file mode 100644 index 0000000..c20935d --- /dev/null +++ b/cmd/dischord/copyright.go @@ -0,0 +1,773 @@ +package main + +const copyrightText = `=== Dischord === + +Copyright (C) Darwin Schuppan +License: GNU General Public License Version 3 (see below) + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type 'show c' for details. + +The hypothetical commands 'show w' and 'show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + + + + +=== https://github.com/BurntSushi/toml === + +The MIT License (MIT) + +Copyright (c) 2013 TOML authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + + + +=== https://github.com/ulikunitz/xz === + +Copyright (c) 2014-2021 Ulrich Kunitz +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* My name, Ulrich Kunitz, may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + + +=== https://github.com/bwmarrin/discordgo === + +Copyright (c) 2015, Bruce Marriner +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of discordgo nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.` diff --git a/cmd/dischord/dischord.go b/cmd/dischord/dischord.go new file mode 100644 index 0000000..e599104 --- /dev/null +++ b/cmd/dischord/dischord.go @@ -0,0 +1,1204 @@ +package main + +import ( + dc "github.com/bwmarrin/discordgo" + + "git.nobrain.org/r4/dischord/config" + "git.nobrain.org/r4/dischord/extractor" + _ "git.nobrain.org/r4/dischord/extractor/builtins" + "git.nobrain.org/r4/dischord/extractor/ytdl" + "git.nobrain.org/r4/dischord/player" + "git.nobrain.org/r4/dischord/util" + + "errors" + "flag" + "fmt" + "os" + "os/signal" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + _ "embed" +) + +var copyright bool +var autoconf bool +var registerCommands bool +var unregisterCommands bool + +//go:embed bye.opus +var resByeOpus []byte + +func init() { + flag.BoolVar(©right, "copyright", false, "print copyright info and quit") + flag.BoolVar(&autoconf, "autoconf", false, "launch automatic configurator program (overwriting any existing configuration)") + flag.BoolVar(®isterCommands, "register_commands", true, "register commands with Discord upon startup") + flag.BoolVar(&unregisterCommands, "unregister_commands", false, "unregister registered commands with Discord upon shutdown") +} + +// A UserError is shown to the user +type UserError struct { + error +} + +const ( + interactionFlags = uint64(dc.MessageFlagsEphemeral) +) + +var ( + ErrVoiceNotConnected = UserError{errors.New("bot is currently not connected to a voice channel")} + ErrUnsupportedUrl = UserError{errors.New("unsupported URL")} + ErrStartThinkingNotInitialResponse = errors.New("StartThinking() must be the initial response") + ErrInvalidAutocompleteCall = errors.New("invalid autocomplete call") +) + +type MessageData struct { + Content string + Files []*dc.File + Components []dc.MessageComponent + Embeds []*dc.MessageEmbed +} + +type MessageWriter struct { + session *dc.Session + interaction *dc.Interaction + first bool + thinking bool +} + +func NewMessageWriter(s *dc.Session, ia *dc.Interaction) *MessageWriter { + return &MessageWriter{ + session: s, + interaction: ia, + first: true, + thinking: false, + } +} + +func (m *MessageWriter) StartThinking() error { + if !m.first { + return ErrStartThinkingNotInitialResponse + } + err := m.session.InteractionRespond(m.interaction, &dc.InteractionResponse{ + Type: dc.InteractionResponseDeferredChannelMessageWithSource, + Data: &dc.InteractionResponseData{ + Flags: interactionFlags, + }, + }) + if err != nil { + return err + } + m.first = false + m.thinking = true + return nil +} + +func (m *MessageWriter) Message(d *MessageData) error { + var err error + if m.first { + err = m.session.InteractionRespond(m.interaction, &dc.InteractionResponse{ + Type: dc.InteractionResponseChannelMessageWithSource, + Data: &dc.InteractionResponseData{ + Content: d.Content, + Flags: interactionFlags, + Files: d.Files, + Components: d.Components, + Embeds: d.Embeds, + }, + }) + } else if m.thinking { + _, err = m.session.InteractionResponseEdit(m.interaction, &dc.WebhookEdit{ + Content: d.Content, + Files: d.Files, + Components: d.Components, + Embeds: d.Embeds, + }) + } else { + _, err = m.session.FollowupMessageCreate(m.interaction, true, &dc.WebhookParams{ + Content: d.Content, + Flags: interactionFlags, + Files: d.Files, + Components: d.Components, + Embeds: d.Embeds, + }) + } + if err != nil { + return err + } + m.first = false + m.thinking = false + return nil +} + +func main() { + flag.Parse() + + if copyright { + fmt.Println(copyrightText) + return + } + + var clients sync.Map // guild ID string to player.Client + + // Load / create configuration file + cfgfile := "config.toml" + var cfg *config.Config + var err error + if autoconf || func() bool {cfg, err = config.Load(cfgfile); return err != nil}() { + if err != nil { + if os.IsNotExist(err) { + fmt.Println("Configuration file not found, launching automatic configurator.") + fmt.Println("Hit Ctrl+C to cancel anytime.") + } else { + fmt.Println("Error:", err) + return + } + } + cfg, err = config.Autoconf(cfgfile) + if err != nil { + if err == config.ErrPythonNotInstalled { + if runtime.GOOS == "darwin" { + fmt.Println("Python is required to run youtube-dl, but no python installation was found. To fix this, please install Xcode Command Line Tools.") + } else { + fmt.Println("Python is required to run youtube-dl, but no python installation was found. To fix this, please install Python from your package manager.") + } + } else { + fmt.Println("Error:", err) + } + return + } + if runtime.GOOS == "windows" { + fmt.Println("Hit Enter to close this window.") + fmt.Scanln() + } + return + } + + getClient := func(s *dc.Session, ia *dc.Interaction, create bool) (client player.Client, err error, created bool) { + clI, exists := clients.Load(ia.GuildID) + if exists { + return clI.(player.Client), nil, false + } + + if !create { + return player.Client{}, ErrVoiceNotConnected, false + } + + g, err := s.State.Guild(ia.GuildID) + if err != nil { + return player.Client{}, err, false + } + + voiceChannelId := "" + for _, v := range g.VoiceStates { + if v.UserID == ia.Member.User.ID { + voiceChannelId = v.ChannelID + break + } + } + + if voiceChannelId == "" { + return player.Client{}, UserError{errors.New("bot doesn't know where to join, please enter a voice channel")}, false + } + + vc, err := s.ChannelVoiceJoin(ia.GuildID, voiceChannelId, false, true) + if err != nil { + return player.Client{}, err, false + } + + cl := player.NewClient(cfg.Extractors, cfg.FfmpegPath, vc.OpusSend, func(e player.EventStreamUpdated) { + if err := vc.Speaking(true); err != nil { + fmt.Println("Unable to speak:", err) + } + }, func(e player.EventKilled) { + vc.Disconnect() + }) + + clients.Store(ia.GuildID, cl) + + go func() { + for err := range cl.ErrCh { + fmt.Println("Playback error:", err) + } + }() + + return cl, nil, true + } + + getOptions := func(d *dc.ApplicationCommandInteractionData) map[string]*dc.ApplicationCommandInteractionDataOption { + opts := make(map[string]*dc.ApplicationCommandInteractionDataOption, len(d.Options)) + for _, v := range d.Options { + opts[v.Name] = v + } + return opts + } + + floatptr := func(f float64) *float64 { + res := new(float64) + *res = f + return res + } + + commands := []*dc.ApplicationCommand{ + { + Name: "queue", + Description: "Show current playing queue", + }, + { + Name: "play", + Description: "Resume playback or replace playing queue", + Options: []*dc.ApplicationCommandOption{ + { + Type: dc.ApplicationCommandOptionString, + Name: "url-or-query", + Description: "Video/music/playlist URL or search query to start playing", + Required: false, + Autocomplete: true, + }, + }, + }, + { + Name: "add", + Description: "Add a track or playlist to queue", + Options: []*dc.ApplicationCommandOption{ + { + Type: dc.ApplicationCommandOptionString, + Name: "url-or-query", + Description: "Video/music/playlist URL or search query to add to queue", + Required: true, + Autocomplete: true, + }, + }, + }, + { + Name: "pause", + Description: "Pause playback", + }, + { + Name: "loop", + Description: "Toggle loop", + }, + { + Name: "stop", + Description: "Stop playback and disconnect", + }, + { + Name: "disconnect", + Description: "Alias for stop (stop playback and disconnect)", + }, + { + Name: "dc", + Description: "Alias for stop (stop playback and disconnect)", + }, + { + Name: "jump", + Description: "Jump to a track by number or name", + Options: []*dc.ApplicationCommandOption{ + { + Type: dc.ApplicationCommandOptionString, + Name: "track", + Description: "Track number or matching string", + Required: true, + Autocomplete: true, + }, + }, + }, + { + Name: "seek", + Description: "Jump to a playback position. Format is ss, mm:ss or hh:mm:ss. Use prefixes +/- to jump relatively", + Options: []*dc.ApplicationCommandOption{ + { + Type: dc.ApplicationCommandOptionString, + Name: "pos", + Description: "Target playback position. Format is ss, mm:ss or hh:mm:ss. Use prefixes +/- to jump relatively", + Required: true, + }, + }, + }, + { + Name: "pos", + Description: "Get current playback position (time)", + }, + { + Name: "speed", + Description: "Get or set the playback speed", + Options: []*dc.ApplicationCommandOption{ + { + Type: dc.ApplicationCommandOptionNumber, + Name: "speed", + Description: "New playback speed", + Required: false, + MinValue: floatptr(0.5), + MaxValue: 3.0, + }, + }, + }, + { + Name: "shuffle", + Description: "Shuffle all items in the queue", + Options: []*dc.ApplicationCommandOption{}, + }, + { + Name: "unshuffle", + Description: "Undoes what shuffle did (may no longer be available after certain queue modifications)", + Options: []*dc.ApplicationCommandOption{}, + }, + { + Name: "swap", + Description: "Swap two items' positions in the queue", + Options: []*dc.ApplicationCommandOption{ + { + Type: dc.ApplicationCommandOptionString, + Name: "track-1", + Description: "Track number or matching string", + Required: true, + Autocomplete: true, + }, + { + Type: dc.ApplicationCommandOptionString, + Name: "track-2", + Description: "Track number or matching string", + Required: true, + Autocomplete: true, + }, + }, + }, + { + Name: "delete", + Description: "Delete up to five items from the queue (use delete-from to delete even more at once)", + Options: []*dc.ApplicationCommandOption{ + { + Type: dc.ApplicationCommandOptionString, + Name: "track-1", + Description: "Track number or matching string", + Required: true, + Autocomplete: true, + }, + { + Type: dc.ApplicationCommandOptionString, + Name: "track-2", + Description: "Track number or matching string", + Required: false, + Autocomplete: true, + }, + { + Type: dc.ApplicationCommandOptionString, + Name: "track-3", + Description: "Track number or matching string", + Required: false, + Autocomplete: true, + }, + { + Type: dc.ApplicationCommandOptionString, + Name: "track-4", + Description: "Track number or matching string", + Required: false, + Autocomplete: true, + }, + { + Type: dc.ApplicationCommandOptionString, + Name: "track-5", + Description: "Track number or matching string", + Required: false, + Autocomplete: true, + }, + }, + }, + { + Name: "delete-from", + Description: "Delete the specified track along with all tracks following it from the queue", + Options: []*dc.ApplicationCommandOption{ + { + Type: dc.ApplicationCommandOptionString, + Name: "track-1", + Description: "Track number or matching string", + Required: true, + Autocomplete: true, + }, + }, + }, + } + + addToQueue := func(s *dc.Session, m *MessageWriter, cl player.Client, input string) error { + if err := m.StartThinking(); err != nil { + return err + } + + data, err := extractor.Extract(cfg.Extractors, input) + if err != nil { + if exerr, ok := err.(*extractor.Error); ok && exerr.Err == ytdl.ErrUnsupportedUrl { + return ErrUnsupportedUrl + } + return err + } + + cl.CmdCh <- player.CmdAddBack(data) + + var msg string + if len(data) == 1 { + msg = fmt.Sprintf("Added %v to queue", data[0].Title) + } else if len(data) > 0 { + msg = fmt.Sprintf("Added playlist %v to queue (%v items)", data[0].PlaylistTitle, len(data)) + } else { + return UserError{errors.New("extractor returned no results")} + } + + if err := m.Message(&MessageData{Content: msg}); err != nil { + return err + } + return nil + } + + matchTracks := func(cl player.Client, search string, n int) []struct { + title string + relIdx int + } { + var res []struct { + title string + relIdx int + } + maybeAdd := func(title string, relIdx int) { + if strings.Contains(strings.ToLower(title), strings.ToLower(search)) { + res = append(res, struct { + title string + relIdx int + }{ + title: title, + relIdx: relIdx, + }) + } + } + queue := cl.GetQueue() + for i, v := range queue.Done { + maybeAdd(v.Title, i-len(queue.Done)) + } + if queue.Playing != nil { + maybeAdd(queue.Playing.Title, 0) + } + for i, v := range queue.Ahead { + maybeAdd(v.Title, i+1) + } + sort.Slice(res, func(i, j int) bool { + cost := func(s string) int { + return strings.Index(strings.ToLower(s), strings.ToLower(search)) + } + return cost(res[i].title) < cost(res[j].title) + }) + if n < len(res) { + return res[:n] + } else { + return res + } + } + + checkQueueBounds := func(queue *player.Queue, i int) error { + if i == 0 && queue.Playing == nil { + return UserError{errors.New("track index 0 is invalid when no track is playing")} + } + if i < 0 && -i-1 >= len(queue.Done) { + return UserError{fmt.Errorf("track index %v is too low (minimum is %v)", i, -len(queue.Done))} + } + if i > 0 && i-1 >= len(queue.Ahead) { + return UserError{fmt.Errorf("track index %v is too high (maximum is %v)", i, len(queue.Ahead))} + } + return nil + } + + getTrackNum := func(cl player.Client, input string) (int, error) { + n, err := strconv.Atoi(input) + if err != nil { + tracks := matchTracks(cl, input, 1) + if len(tracks) > 0 { + return tracks[0].relIdx, nil + } else { + return 0, UserError{errors.New("no matching track found")} + } + } + return n, nil + } + + getTrackEmbed := func(queue *player.Queue, i int) *dc.MessageEmbed { + if !queue.InBounds(i) { + return nil + } + + track := queue.At(i) + url := track.SourceUrl + id := strings.TrimSuffix(strings.TrimPrefix(url, "https://www.youtube.com/watch?v="), "/") + var desc string + if i == 0 { + if queue.Paused { + desc = "Paused" + } else { + desc = "Playing" + } + if queue.Loop { + desc += " (loop)" + } + } else { + desc = strconv.Itoa(i) + } + return &dc.MessageEmbed{ + Title: track.Title, + Description: desc, + URL: url + "/" + strconv.Itoa(i), + Thumbnail: &dc.MessageEmbedThumbnail{ + URL: "https://i.ytimg.com/vi/" + id + "/mqdefault.jpg", + }, + } + } + + var commandHandlers map[string]func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error + commandHandlers = map[string]func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error{ + "queue": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + + queue := cl.GetQueue() + + if len(queue.Done) == 0 && queue.Playing == nil && len(queue.Ahead) == 0 { + if err := m.Message(&MessageData{Content: "Queue is empty"}); err != nil { + return err + } + return nil + } + + var embeds []*dc.MessageEmbed + trySend := func(flush bool) error { + if len(embeds) >= 10 || (len(embeds) > 0 && flush) { + err := m.Message(&MessageData{ + Embeds: embeds, + }) + if err != nil { + return err + } + embeds = nil + } + return nil + } + + for i := -len(queue.Done); i <= len(queue.Ahead); i++ { + if i == 0 && queue.Playing == nil { + continue + } + embeds = append(embeds, getTrackEmbed(queue, i)) + if err := trySend(false); err != nil { + return err + } + } + if err := trySend(true); err != nil { + return err + } + return nil + }, + "play": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + opts := getOptions(d) + inputI, exists := opts["url-or-query"] + if exists { + cl, err, _ := getClient(s, ia, true) + if err != nil { + return err + } + + input := inputI.StringValue() + + cl.CmdCh <- player.CmdSkipAll{} + + err = addToQueue(s, m, cl, input) + if err != nil { + return err + } + + cl.CmdCh <- player.CmdPlay{} + } else { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + + queue := cl.GetQueue() + + if queue.Paused { + cl.CmdCh <- player.CmdPlay{} + if err := m.Message(&MessageData{Content: "Playback resumed"}); err != nil { + return err + } + } else if queue.Playing == nil && len(queue.Ahead) > 0 { + cl.CmdCh <- player.CmdPlay{} + if err := m.Message(&MessageData{Content: "Started playing"}); err != nil { + return err + } + } else if queue.Playing == nil && len(queue.Ahead) == 0 { + return UserError{errors.New("nothing in queue to resume from")} + } else { + return UserError{errors.New("already playing")} + } + } + return nil + }, + "add": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, created := getClient(s, ia, true) + if err != nil { + return err + } + + err = addToQueue(s, m, cl, d.Options[0].StringValue()) + if err != nil { + return err + } + + if created { + if err := m.Message(&MessageData{Content: "Use /play to start playing"}); err != nil { + return err + } + } + return nil + }, + "pause": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + if cl.GetQueue().Paused { + return UserError{errors.New("already paused")} + } else { + cl.CmdCh <- player.CmdPause{} + if err := m.Message(&MessageData{Content: "Playback paused"}); err != nil { + return err + } + } + return nil + }, + "loop": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + loop := cl.GetQueue().Loop + cl.CmdCh <- player.CmdLoop(!loop) + var msg string + if loop { + msg = "Loop disabled" + } else { + msg = "Loop enabled" + } + if err := m.Message(&MessageData{Content: msg}); err != nil { + return err + } + return nil + }, + "stop": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + ch := make(chan struct{}) + cl.CmdCh <- player.CmdPlayFileAndStop{ch, resByeOpus} + if err := m.Message(&MessageData{Content: "Bye, have a great time"}); err != nil { + return err + } + <-ch + clients.Delete(ia.GuildID) + close(cl.CmdCh) + return nil + }, + "disconnect": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + return commandHandlers["stop"](s, m, ia, d) + }, + "dc": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + return commandHandlers["stop"](s, m, ia, d) + }, + "jump": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + track := d.Options[0].StringValue() + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + + n, err := getTrackNum(cl, track) + if err != nil { + return err + } + + cl.CmdCh <- player.CmdJump(n) + + var msg string + queue := cl.GetQueue() + if queue.Playing != nil { + msg = fmt.Sprintf("Jumped to %v", queue.Playing.Title) + } else { + msg = "Playback finished" + } + if err := m.Message(&MessageData{Content: msg}); err != nil { + return err + } + return nil + }, + "seek": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + + var relFactor int + input := d.Options[0].StringValue() + if strings.HasPrefix(input, "+") { + relFactor = 1 + input = strings.TrimPrefix(input, "+") + } else if strings.HasPrefix(input, "-") { + relFactor = -1 + input = strings.TrimPrefix(input, "-") + } + + ntI, err := util.ParseDurationSeconds(input) + if err != nil { + return UserError{errors.New("invalid time format")} + } + + queue := cl.GetQueue() + + if queue.Playing != nil { + time := int(cl.GetTime()) + d := util.FormatDurationSeconds(int(queue.Playing.Duration)) + if relFactor != 0 { + ntI = time + relFactor*ntI + } + if ntI < 0 || ntI >= queue.Playing.Duration { + return UserError{errors.New("time out of range")} + } + nt := util.FormatDurationSeconds(ntI) + relI := ntI - time + var rel string + if relI < 0 { + rel = "-" + util.FormatDurationSeconds(-relI) + } else { + rel = "+" + util.FormatDurationSeconds(relI) + } + cl.CmdCh <- player.CmdSeek(int64(ntI)) + if err := m.Message(&MessageData{Content: fmt.Sprintf("Sought to: %v/%v (%v)", nt, d, rel)}); err != nil { + return err + } + } else { + return UserError{errors.New("not playing anything")} + } + return nil + }, + "pos": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + + queue := cl.GetQueue() + + if queue.Playing != nil { + time := cl.GetTime() + t := util.FormatDurationSeconds(int(time)) + d := util.FormatDurationSeconds(int(queue.Playing.Duration)) + + err := m.Message(&MessageData{ + Content: fmt.Sprintf("Position: %v/%v", t, d), + Embeds: []*dc.MessageEmbed{ + getTrackEmbed(queue, 0), + }, + }) + if err != nil { + return err + } + } else { + return UserError{errors.New("not playing anything")} + } + return nil + }, + "speed": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + opts := getOptions(d) + inputI, exists := opts["speed"] + + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + + if exists { + speed := inputI.FloatValue() + cl.CmdCh <- player.CmdSpeed(speed) + if err := m.Message(&MessageData{Content: fmt.Sprintf("Playing at %vx speed", speed)}); err != nil { + return err + } + return nil + } else { + if err := m.Message(&MessageData{Content: fmt.Sprintf("Current playback speed: %vx", cl.GetSpeed())}); err != nil { + return err + } + return nil + } + }, + "shuffle": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + cl.CmdCh <- player.CmdShuffle{} + if err := m.Message(&MessageData{Content: fmt.Sprintf("Shuffled queue (%v items)", len(cl.GetQueue().Ahead))}); err != nil { + return err + } + return nil + }, + "unshuffle": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + queue := cl.GetQueue() + if queue.AheadUnshuffled == nil { + return UserError{errors.New("cannot unshuffle queue: either it is not shuffled, or too many modifications have been made to reverse the shuffle")} + } + cl.CmdCh <- player.CmdUnshuffle{} + if err := m.Message(&MessageData{Content: fmt.Sprintf("Unshuffled queue (%v items)", len(queue.Ahead))}); err != nil { + return err + } + return nil + }, + "swap": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + queue := cl.GetQueue() + sa, sb := d.Options[0].StringValue(), d.Options[1].StringValue() + + var a, b int + a, err = getTrackNum(cl, sa) + if err != nil { + return err + } + b, err = getTrackNum(cl, sb) + if err != nil { + return err + } + + if err := checkQueueBounds(queue, a); err != nil { + return err + } + if err := checkQueueBounds(queue, b); err != nil { + return err + } + + ta, tb := queue.At(a), queue.At(b) + cl.CmdCh <- player.CmdSwap{a, b} + if err := m.Message(&MessageData{Content: fmt.Sprintf("Swapped item %v: '%v' with %v: '%v'", ta.Title, tb.Title)}); err != nil { + return err + } + return nil + }, + "delete": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + queue := cl.GetQueue() + var toDel []int + for _, opt := range d.Options { + a, err := getTrackNum(cl, opt.StringValue()) + if err != nil { + return err + } + if err := checkQueueBounds(queue, a); err != nil { + return err + } + toDel = append(toDel, a) + } + cl.CmdCh <- player.CmdDelete(toDel) + var msg string + msg = "Deleted " + for _, i := range toDel { + msg += fmt.Sprintf("%v: '%v'", i, queue.At(i).Title) + if i == len(toDel)-2 { + msg += "and " + } else if i != len(toDel)-1 { + msg += ", " + } + } + msg += " from queue" + if err := m.Message(&MessageData{Content: msg}); err != nil { + return err + } + return nil + }, + "delete-from": func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + return err + } + queue := cl.GetQueue() + a, err := getTrackNum(cl, d.Options[0].StringValue()) + if err != nil { + return err + } + if err := checkQueueBounds(queue, a); err != nil { + return err + } + var toDel []int + i := a + for { + if i != 0 && queue.At(i) == nil { + break + } + toDel = append(toDel, i) + i++ + } + cl.CmdCh <- player.CmdDelete(toDel) + if err := m.Message(&MessageData{Content: fmt.Sprintf("Deleted %v items starting with %v: '%v'", len(toDel), a, queue.At(a).Title)}); err != nil { + return err + } + return nil + }, + } + + autocompleteBySearch := func(s *dc.Session, ia *dc.Interaction, input string) error { + var choices []*dc.ApplicationCommandOptionChoice + if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { + choices = []*dc.ApplicationCommandOptionChoice{ + { + Name: input, + Value: input, + }, + } + } else if input != "" { + res, err := extractor.Search(cfg.Extractors, input) + if err != nil { + return err + } + + choices = make([]*dc.ApplicationCommandOptionChoice, len(res)) + for i, v := range res { + switch { + case v.Title != "": + var prefix string + if v.OfficialArtist { + prefix = "🎵 " + } + choices[i] = &dc.ApplicationCommandOptionChoice{ + Name: prefix + v.Title, + Value: v.SourceUrl, + } + case v.PlaylistTitle != "": + choices[i] = &dc.ApplicationCommandOptionChoice{ + Name: "𝘗𝘭𝘢𝘺𝘭𝘪𝘴𝘵: " + v.PlaylistTitle, + Value: v.PlaylistUrl, + } + } + } + } + + err = s.InteractionRespond(ia, &dc.InteractionResponse{ + Type: dc.InteractionApplicationCommandAutocompleteResult, + Data: &dc.InteractionResponseData{ + Choices: choices, + }, + }) + if err != nil { + return err + } + return nil + } + + autocompleteTrack := func(s *dc.Session, ia *dc.Interaction, input string) error { + cl, err, _ := getClient(s, ia, false) + if err != nil { + if errors.Is(err, ErrVoiceNotConnected) { + err = s.InteractionRespond(ia, &dc.InteractionResponse{ + Type: dc.InteractionApplicationCommandAutocompleteResult, + Data: &dc.InteractionResponseData{}, + }) + if err != nil { + return err + } + return nil + } + return err + } + + tracks := matchTracks(cl, input, 10) + + choices := make([]*dc.ApplicationCommandOptionChoice, len(tracks)) + for i := range tracks { + choices[i] = &dc.ApplicationCommandOptionChoice{ + Name: tracks[i].title, + Value: strconv.Itoa(tracks[i].relIdx), + } + } + + err = s.InteractionRespond(ia, &dc.InteractionResponse{ + Type: dc.InteractionApplicationCommandAutocompleteResult, + Data: &dc.InteractionResponseData{ + Choices: choices, + }, + }) + if err != nil { + return err + } + return nil + } + + autocompleteHandlers := map[string]func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error{ + "play": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + opts := getOptions(d) + inputI, exists := opts["url-or-query"] + if !exists { + return nil + } + input := inputI.StringValue() + + return autocompleteBySearch(s, ia, input) + }, + "add": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + return autocompleteBySearch(s, ia, d.Options[0].StringValue()) + }, + "jump": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + return autocompleteTrack(s, ia, d.Options[0].StringValue()) + }, + "swap": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + for i := 0; i < 5; i++ { + if d.Options[i].Focused { + return autocompleteTrack(s, ia, d.Options[i].StringValue()) + } + } + return ErrInvalidAutocompleteCall + }, + "delete": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + for i := 0; i < 5; i++ { + if d.Options[i].Focused { + return autocompleteTrack(s, ia, d.Options[i].StringValue()) + } + } + return ErrInvalidAutocompleteCall + }, + "delete-from": func(s *dc.Session, ia *dc.Interaction, d *dc.ApplicationCommandInteractionData) error { + return autocompleteTrack(s, ia, d.Options[0].StringValue()) + }, + } + + componentHandlers := map[string]func(s *dc.Session, m *MessageWriter, ia *dc.Interaction, d *dc.MessageComponentInteractionData) error{} + + // Create Discord session + dg, err := dc.New("Bot " + cfg.Token) + if err != nil { + fmt.Println("Error creating Discord session:", err) + return + } + dg.Identify.Intents = dc.IntentsAllWithoutPrivileged + + // Set up handlers + readyCh := make(chan string) + dg.AddHandler(func(s *dc.Session, e *dc.Ready) { + u := s.State.User + readyCh <- u.Username + "#" + u.Discriminator + }) + dg.AddHandler(func(s *dc.Session, e *dc.InteractionCreate) { + switch e.Type { + case dc.InteractionApplicationCommand: + d := e.ApplicationCommandData() + m := NewMessageWriter(s, e.Interaction) + if e.GuildID == "" { + if err := m.Message(&MessageData{Content: "This bot only works on servers"}); err != nil { + fmt.Printf("Error: %v\n", err) + } + return + } + if h, exists := commandHandlers[d.Name]; exists { + if err := h(s, m, e.Interaction, &d); err != nil { + if _, ok := err.(UserError); ok { + if err := m.Message(&MessageData{Content: util.CapitalizeFirst(err.Error())}); err != nil { + fmt.Printf("Error: %v\n", err) + } + } else { + fmt.Printf("Error in commandHandlers[%v]: %v\n", d.Name, err) + if err := m.Message(&MessageData{Content: "An internal error occurred :("}); err != nil { + fmt.Printf("Error: %v\n", err) + } + } + } + } + case dc.InteractionApplicationCommandAutocomplete: + d := e.ApplicationCommandData() + if h, exists := autocompleteHandlers[d.Name]; exists { + if err := h(s, e.Interaction, &d); err != nil { + fmt.Printf("Error in autocompleteHandlers[%v]: %v\n", d.Name, err) + } + } + case dc.InteractionMessageComponent: + d := e.MessageComponentData() + m := NewMessageWriter(s, e.Interaction) + if h, exists := componentHandlers[d.CustomID]; exists { + if err := h(s, m, e.Interaction, &d); err != nil { + if _, ok := err.(UserError); ok { + if err := m.Message(&MessageData{Content: util.CapitalizeFirst(err.Error())}); err != nil { + fmt.Printf("Error: %v\n", err) + } + } else { + fmt.Printf("Error in componentHandlers[%v]: %v\n", d.CustomID, err) + } + } + } + default: + fmt.Println("Unhandled interaction type:", e.Type) + } + }) + + // Open Discord session + err = dg.Open() + if err != nil { + fmt.Println("Error opening Discord session:", err) + return + } + + // Wait until discord session ready + fmt.Printf("Logged in as %v\n", <-readyCh) + + // Set up commands + if registerCommands { + fmt.Println("Registering commands...") + for i, v := range commands { + cmd, err := dg.ApplicationCommandCreate(dg.State.User.ID, "", v) + if err != nil { + fmt.Printf("Error adding command %v: %v\n", v.Name, err) + return + } + commands[i] = cmd + fmt.Printf(" %v/%v done\r", i+1, len(commands)) + } + fmt.Printf("%v commands registered\n", len(commands)) + } + + // Exit gracefully when the program is terminated + fmt.Println("Bot is now running, press Ctrl+C to stop") + sc := make(chan os.Signal, 1) + signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill) + <-sc + fmt.Println() + fmt.Println("Received stop signal, shutting down cleanly") + clients.Range(func(key, value any) bool { + cl := value.(player.Client) + close(cl.CmdCh) + return true + }) + if registerCommands && unregisterCommands { + fmt.Println("Unregistering commands...") + for i, v := range commands { + err := dg.ApplicationCommandDelete(dg.State.User.ID, "", v.ID) + if err != nil { + fmt.Printf("Error deleting command %v: %v\n", v.Name, err) + return + } + fmt.Printf(" %v/%v done\r", i+1, len(commands)) + } + fmt.Printf("%v commands unregistered\n", len(commands)) + } + dg.Close() +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..3871e51 --- /dev/null +++ b/config/config.go @@ -0,0 +1,234 @@ +package config + +import ( + "github.com/BurntSushi/toml" + + "git.nobrain.org/r4/dischord/extractor" + + "errors" + "fmt" + "os" + "os/exec" + "runtime" +) + +var ( + ErrTokenNotSet = errors.New("bot token not set") + ErrInvalidYoutubeDlPath = errors.New("invalid youtube-dl path") + ErrInvalidFfmpegPath = errors.New("invalid FFmpeg path") + ErrYoutubeDlNotFound = errors.New("youtube-dl not found, please install it from https://youtube-dl.org/ first") + ErrFfmpegNotFound = errors.New("FFmpeg not found, please install it from https://ffmpeg.org first") + ErrPythonNotInstalled = errors.New("python not installed") +) + +type Config struct { + Token string `toml:"bot-token"` + FfmpegPath string `toml:"ffmpeg-path"` + Extractors extractor.Config `toml:"extractors"` +} + +const ( + defaultToken = "insert your Discord bot token here" +) + +var ( + ffmpegPaths = []string{"ffmpeg", "./ffmpeg"} + youtubeDlPaths = []string{"youtube-dl", "./youtube-dl", "yt-dlp", "./yt-dlp", "youtube-dlc", "./youtube-dlc"} +) + +// Returns a valid path if one exists. Returns "" if none found. +func searchExecPaths(paths ...string) string { + for _, pathbase := range paths { + path := pathbase + if runtime.GOOS == "windows" { + path += ".exe" + } + if _, err := exec.LookPath(path); err == nil { + return path + } + } + return "" +} + +func write(filename string, cfg *Config) error { + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + if _, err := file.WriteString(`# Insert your Discord bot token here. +# It can be found at https://discord.com/developers/applications -> -> Bot -> Reset Token. +# Make sure to keep the "" around your token text. +`); err != nil { + return err + } + enc := toml.NewEncoder(file) + enc.Indent = "" + if err := enc.Encode(cfg); err != nil { + return err + } + return nil +} + +func macosEnableExecutable(filename string) error { + if runtime.GOOS != "darwin" { + return nil + } + // Commands from http://www.osxexperts.net/ + if runtime.GOARCH == "arm64" { + cmd := exec.Command("xattr", "-cr", filename) + if err := cmd.Run(); err != nil { + return err + } + cmd = exec.Command("codesign", "-s", "-", filename) + if err := cmd.Run(); err != nil { + return err + } + } else if runtime.GOARCH == "amd64" { + cmd := exec.Command("xattr", "-dr", "com.apple.quarantine", filename) + if err := cmd.Run(); err != nil { + return err + } + } + return nil +} + +// Tries to load the given TOML config file. Returns an error if the +// configuration file does not exist or is invalid. +func Load(filename string) (*Config, error) { + cfg := &Config{} + _, err := toml.DecodeFile(filename, cfg) + if err != nil { + return nil, err + } + if cfg.Token == defaultToken || cfg.Token == "" { + return nil, ErrTokenNotSet + } + if err := cfg.Extractors.CheckTypes(); err != nil { + return nil, err + } + if _, err := exec.LookPath(cfg.Extractors["youtube-dl"]["youtube-dl-path"].(string)); err != nil { + return nil, ErrInvalidYoutubeDlPath + } + if _, err := exec.LookPath(cfg.FfmpegPath); err != nil { + return nil, ErrInvalidFfmpegPath + } + return cfg, nil +} + +// Automatically creates a TOML configuration file with the default values and +// prints information and instructions for the user to stdout. +func Autoconf(filename string) (*Config, error) { + cfg := &Config{ + Token: defaultToken, + Extractors: extractor.DefaultConfig(), + } + + download := func(executable bool, urlsByOS map[string]map[string]string) (filename string, err error) { + filename, err = download(executable, urlsByOS, func(progress float32){ + fmt.Printf("Progress: %.1f%%\r", progress*100.0) + }) + if err != nil { + fmt.Println() + return "", err + } else { + fmt.Println("Progress: Finished downloading") + } + return filename, nil + } + + python3IsPython := false + if runtime.GOOS != "windows" { + if _, err := exec.LookPath("python"); err != nil { + if _, err := exec.LookPath("python3"); err == nil { + python3IsPython = true + } else { + return nil, ErrPythonNotInstalled + } + } + } + + youtubeDlPath := searchExecPaths(youtubeDlPaths...) + if youtubeDlPath == "" { + fmt.Println("Downloading youtube-dl") + filename, err := download(true, map[string]map[string]string{ + "windows": { + "amd64": "https://yt-dl.org/downloads/latest/youtube-dl.exe", + "386": "https://yt-dl.org/downloads/latest/youtube-dl.exe", + }, + "any": { + "any": "https://yt-dl.org/downloads/latest/youtube-dl", + }, + }) + if err != nil { + return nil, err + } + youtubeDlPath = "./"+filename + macosEnableExecutable(youtubeDlPath) + if python3IsPython { + // Replace first line with `replacement` + data, err := os.ReadFile(youtubeDlPath) + if err != nil { + return nil, err + } + replacement := []byte("#!/usr/bin/env python3") + for i, c := range data { + if c == '\n' { + data = append(replacement, data[i:]...) + break + } + } + if err := os.WriteFile(youtubeDlPath, data, 0777); err != nil { + return nil, err + } + } + } else { + fmt.Println("Using youtube-dl executable found at", youtubeDlPath) + } + cfg.Extractors["youtube-dl"]["youtube-dl-path"] = youtubeDlPath + + cfg.FfmpegPath = searchExecPaths(ffmpegPaths...) + if cfg.FfmpegPath == "" { + targetFile := "ffmpeg" + if runtime.GOOS == "windows" { + targetFile += ".exe" + } + fmt.Println("Downloading FFmpeg") + filename, err := download(false, map[string]map[string]string{ + "linux": { + "amd64": "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz", + "386": "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-i686-static.tar.xz", + "arm64": "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz", + "arm": "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz", + }, + "windows": { + "amd64": "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", + "386": "https://github.com/sudo-nautilus/FFmpeg-Builds-Win32/releases/download/latest/ffmpeg-n5.1-latest-win32-gpl-5.1.zip", + }, + "darwin": { + "amd64": "https://evermeet.cx/ffmpeg/getrelease/zip", + "arm64": "https://www.osxexperts.net/FFmpeg511ARM.zip", + }, + }) + if err != nil { + return nil, err + } + fmt.Println("Unpacking", targetFile, "from", filename) + if err := unarchiveSingleFile(filename, targetFile); err != nil { + return nil, err + } + if err := os.Remove(filename); err != nil { + return nil, err + } + cfg.FfmpegPath = "./"+targetFile + macosEnableExecutable(cfg.FfmpegPath) + } else { + fmt.Println("Using FFmpeg executable found at", cfg.FfmpegPath) + } + + fmt.Println("Writing configuration to", filename) + write(filename, cfg) + fmt.Println("Almost done. Now just edit", filename, "and set your bot token.") + + return cfg, nil +} diff --git a/config/util.go b/config/util.go new file mode 100644 index 0000000..55e8861 --- /dev/null +++ b/config/util.go @@ -0,0 +1,191 @@ +package config + +import ( + "github.com/ulikunitz/xz" + + "archive/tar" + "archive/zip" + "compress/gzip" + "errors" + "io" + "io/fs" + "mime" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +var ( + ErrUnsupportedOSAndArch = errors.New("no download available for your operating system and hardware architecture") + ErrFileNotFoundInArchive = errors.New("file not found in archive") + ErrUnsupportedArchive = errors.New("unsupported archive format (supported are .tar, .tar.gz, .tar.xz and .zip") +) + +func download(executable bool, urlsByOS map[string]map[string]string, progCallback func(progress float32)) (filename string, err error) { + // Find appropriate URL + var url string + var urlByArch map[string]string + var ok bool + if urlByArch, ok = urlsByOS[runtime.GOOS]; !ok { + urlByArch, ok = urlsByOS["any"] + } + if ok { + if url, ok = urlByArch[runtime.GOARCH]; !ok { + url, ok = urlByArch["any"] + } + } + if !ok { + return "", ErrUnsupportedOSAndArch + } + + // Initiate request + lastPath := url + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // Get the filename to save the downloaded file to + var savePath string + if v := resp.Header.Get("Content-Disposition"); v != "" { + disposition, params, err := mime.ParseMediaType(v) + if err != nil { + return "", err + } + if disposition == "attachment" { + lastPath = params["filename"] + } + } + if savePath == "" { + sp := strings.Split(lastPath, "/") + savePath = sp[len(sp)-1] + } + + // Download resource + size, _ := strconv.Atoi(resp.Header.Get("content-length")) + var perms uint32 = 0666 + if executable { + perms = 0777 + } + file, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fs.FileMode(perms)) + if err != nil { + return "", err + } + for i := 0; ; i += 100_000 { + _, err := io.CopyN(file, resp.Body, 100_000) + if err != nil { + if err == io.EOF { + break + } else { + return "", err + } + } + if progCallback != nil && size != 0 { + progCallback(float32(i)/float32(size)) + } + } + return savePath, nil +} + +func unarchiveSingleFile(archive, target string) error { + unzip := func() error { + ar, err := zip.OpenReader(archive) + if err != nil { + return err + } + defer ar.Close() + + found := false + for _, file := range ar.File { + if !file.FileInfo().IsDir() && filepath.Base(file.Name) == target { + found = true + dstFile, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return err + } + defer dstFile.Close() + fileReader, err := file.Open() + if err != nil { + return err + } + defer fileReader.Close() + if _, err := io.Copy(dstFile, fileReader); err != nil { + return err + } + } + } + if !found { + return ErrFileNotFoundInArchive + } + return nil + } + untar := func(rd io.Reader) error { + ar := tar.NewReader(rd) + for { + hdr, err := ar.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == target { + dstFile, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fs.FileMode(hdr.Mode)) + if err != nil { + return err + } + defer dstFile.Close() + if _, err := io.Copy(dstFile, ar); err != nil { + return err + } + return nil + } + } + return ErrFileNotFoundInArchive + } + match := func(name string, patterns ...string) bool { + for _, pattern := range patterns { + matches, err := filepath.Match(pattern, archive) + if err != nil { + panic(err) + } + if matches { + return true + } + } + return false + } + if match(archive, "*.zip") { + return unzip() + } else if match(archive, "*.tar", "*.tar.[gx]z") { + file, err := os.Open(archive) + if err != nil { + return err + } + defer file.Close() + var uncompressedFile io.Reader + if match(archive, "*.tar") { + uncompressedFile = file + } else if match(archive, "*.tar.gz") { + gz, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gz.Close() + uncompressedFile = gz + } else if match(archive, "*.tar.xz") { + uncompressedFile, err = xz.NewReader(file) + if err != nil { + return err + } + } + return untar(uncompressedFile) + } else { + return ErrUnsupportedArchive + } + return nil +} diff --git a/extractor/builtins/builtins.go b/extractor/builtins/builtins.go new file mode 100644 index 0000000..01e7327 --- /dev/null +++ b/extractor/builtins/builtins.go @@ -0,0 +1,7 @@ +package builtins + +import ( + _ "git.nobrain.org/r4/dischord/extractor/spotify" + _ "git.nobrain.org/r4/dischord/extractor/youtube" + _ "git.nobrain.org/r4/dischord/extractor/ytdl" +) diff --git a/extractor/extractor.go b/extractor/extractor.go new file mode 100644 index 0000000..0b0f51d --- /dev/null +++ b/extractor/extractor.go @@ -0,0 +1,208 @@ +package extractor + +import ( + "errors" + "fmt" + "reflect" + "time" +) + +var ( + ErrNoSearchResults = errors.New("no search provider available") + ErrNoSearchProvider = errors.New("no search provider available") + ErrNoSuggestionProvider = errors.New("no search suggestion provider available") +) + +var ( + providers []provider + extractors []extractor + searchers []searcher + suggestors []suggestor + defaultConfig Config +) + +func Extract(cfg Config, input string) ([]Data, error) { + if err := cfg.CheckTypes(); err != nil { + return nil, err + } + for _, e := range extractors { + if e.Matches(cfg[e.name], input) { + data, err := e.Extract(cfg[e.name], input) + if err != nil { + return nil, &Error{e.name, err} + } + return data, nil + } + } + d, err := Search(cfg, input) + if err != nil { + return nil, err + } + if len(d) == 0 { + return nil, ErrNoSearchResults + } + return []Data{d[0]}, nil +} + +func Search(cfg Config, input string) ([]Data, error) { + if err := cfg.CheckTypes(); err != nil { + return nil, err + } + for _, s := range searchers { + data, err := s.Search(cfg[s.name], input) + if err != nil { + return nil, &Error{s.name, err} + } + return data, nil + } + return nil, ErrNoSearchProvider +} + +func Suggest(cfg Config, input string) ([]string, error) { + if err := cfg.CheckTypes(); err != nil { + return nil, err + } + for _, s := range suggestors { + data, err := s.Suggest(cfg[s.name], input) + if err != nil { + return nil, &Error{s.name, err} + } + return data, nil + } + return nil, ErrNoSuggestionProvider +} + +type Error struct { + ProviderName string + Err error +} + +func (e *Error) Error() string { + return "extractor[" + e.ProviderName + "]: " + e.Err.Error() +} + +type provider struct { + Provider + name string +} + +type extractor struct { + Extractor + name string +} + +type searcher struct { + Searcher + name string +} + +type suggestor struct { + Suggestor + name string +} + +type Config map[string]ProviderConfig + +func DefaultConfig() Config { + if defaultConfig == nil { + cfg := make(Config) + for _, e := range providers { + cfg[e.name] = e.DefaultConfig() + } + return cfg + } else { + return defaultConfig + } +} + +func (cfg Config) CheckTypes() error { + for provider, pCfg := range cfg { + if pCfg == nil { + return fmt.Errorf("extractor config for %v is nil", provider) + } + for k, v := range pCfg { + got, expected := reflect.TypeOf(v), reflect.TypeOf(DefaultConfig()[provider][k]) + if got != expected { + return &ConfigTypeError{ + Provider: provider, + Key: k, + Expected: expected, + Got: got, + } + } + } + } + return nil +} + +type ConfigTypeError struct { + Provider string + Key string + Expected reflect.Type + Got reflect.Type +} + +func (e *ConfigTypeError) Error() string { + expectedName := "nil" + if e.Expected != nil { + expectedName = e.Expected.Name() + } + gotName := "nil" + if e.Got != nil { + gotName = e.Got.Name() + } + return "extractor config type error: "+e.Provider+"."+e.Key+": expected "+expectedName+" but got "+gotName +} + +type ProviderConfig map[string]any + +type Provider interface { + DefaultConfig() ProviderConfig +} + +type Extractor interface { + Provider + Matches(cfg ProviderConfig, input string) bool + Extract(cfg ProviderConfig, input string) ([]Data, error) +} + +func AddExtractor(name string, e Extractor) { + providers = append(providers, provider{e, name}) + extractors = append(extractors, extractor{e, name}) +} + +type Searcher interface { + Provider + Search(cfg ProviderConfig, input string) ([]Data, error) +} + +func AddSearcher(name string, s Searcher) { + providers = append(providers, provider{s, name}) + searchers = append(searchers, searcher{s, name}) +} + +type Suggestor interface { + Provider + Suggest(cfg ProviderConfig, input string) ([]string, error) +} + +func AddSuggestor(name string, s Suggestor) { + providers = append(providers, provider{s, name}) + suggestors = append(suggestors, suggestor{s, name}) +} + +type Data struct { + // Each instance of this struct should be reconstructable by calling + // Extract() on the SourceUrl + // String values are "" if not present + SourceUrl string + StreamUrl string // may expire, see Expires + Title string + PlaylistUrl string + PlaylistTitle string + Description string + Uploader string + Duration int // in seconds; -1 if unknown + Expires time.Time // when StreamUrl expires + OfficialArtist bool // only for sites that have non-music (e.g. YouTube); search results only +} diff --git a/extractor/extractor_test.go b/extractor/extractor_test.go new file mode 100644 index 0000000..b197369 --- /dev/null +++ b/extractor/extractor_test.go @@ -0,0 +1,220 @@ +package extractor_test + +import ( + "git.nobrain.org/r4/dischord/extractor" + _ "git.nobrain.org/r4/dischord/extractor/builtins" + + "net/http" + "net/url" + "strings" + + "testing" +) + +var extractorTestCfg = extractor.DefaultConfig() + +func validYtStreamUrl(strmUrl string) bool { + u, err := url.Parse(strmUrl) + if err != nil { + return false + } + q, err := url.ParseQuery(u.RawQuery) + if err != nil { + return false + } + looksOk := u.Scheme == "https" && + strings.HasSuffix(u.Host, ".googlevideo.com") && + u.Path == "/videoplayback" && + q.Has("expire") && + q.Has("id") + if !looksOk { + return false + } + resp, err := http.Get(strmUrl) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == 200 +} + +func verifySearchResult(t *testing.T, data []extractor.Data, targetUrl string) { + if len(data) == 0 { + t.Fatalf("Expected search results but got none") + } + first := data[0] + if first.SourceUrl != targetUrl { + t.Fatalf("Invalid search result: expected '%v' but got '%v'", targetUrl, first.SourceUrl) + } + strmData, err := extractor.Extract(extractorTestCfg, first.SourceUrl) + if err != nil { + t.Fatalf("Error retrieving video data: %v", err) + } + if len(strmData) != 1 { + t.Fatalf("Expected exactly one extraction result") + } + if !validYtStreamUrl(strmData[0].StreamUrl) { + t.Fatalf("Invalid YouTube stream URL: got '%v'", strmData[0].StreamUrl) + } +} + +func TestSearch(t *testing.T) { + extractor.Extract(extractorTestCfg, "https://open.spotify.com/track/22z9GL53FudbuFJqa43Nzj") + + data, err := extractor.Search(extractorTestCfg, "nilered turns water into wine like jesus") + if err != nil { + t.Fatalf("Error searching YouTube: %v", err) + } + verifySearchResult(t, data, "https://www.youtube.com/watch?v=tAU0FX1d044") +} + +func TestSearchPlaylist(t *testing.T) { + data, err := extractor.Search(extractorTestCfg, "instant regret clicking this playlist epic donut dude") + if err != nil { + t.Fatalf("Error searching YouTube: %v", err) + } + if len(data) == 0 { + t.Fatalf("Expected search results but got none") + } + target := "https://www.youtube.com/playlist?list=PLv3TTBr1W_9tppikBxAE_G6qjWdBljBHJ" + if data[0].PlaylistUrl != target { + t.Fatalf("Invalid search result: expected '%v' but got '%v'", target, data[0].SourceUrl) + } +} + +func TestSearchSuggestions(t *testing.T) { + sug, err := extractor.Suggest(extractorTestCfg, "a") + if err != nil { + t.Fatalf("Error: %v", err) + } + if len(sug) == 0 { + t.Fatalf("Function didn't return any suggestions") + } +} + +func TestSearchIntegrityWeirdCharacters(t *testing.T) { + data, err := extractor.Extract(extractorTestCfg, "test lol | # !@#%&(*)!&*!äöfáßö®©œæ %% %3 %32") + if err != nil { + t.Fatalf("Error searching YouTube: %v", err) + } + if len(data) != 1 { + t.Fatalf("Expected exactly one URL but got %v", len(data)) + } +} + +func TestYoutubeMusicVideo(t *testing.T) { + data, err := extractor.Extract(extractorTestCfg, "https://www.youtube.com/watch?v=dQw4w9WgXcQ") + if err != nil { + t.Fatalf("Error searching YouTube: %v", err) + } + if len(data) != 1 { + t.Fatalf("Expected exactly one URL but got %v", len(data)) + } + if !validYtStreamUrl(data[0].StreamUrl) { + t.Fatalf("Invalid YouTube stream URL: got '%v'", data[0].StreamUrl) + } +} + +func TestYoutubeMusicVideoMulti(t *testing.T) { + for i := 0; i < 10; i++ { + TestYoutubeMusicVideo(t) + } +} + +func TestYoutubePlaylist(t *testing.T) { + cfg := extractor.DefaultConfig() + cfg["YouTube"]["Require direct playlist URL"] = "true" + + url := "https://www.youtube.com/watch?v=jdUXfsMTv7o&list=PLdImBTpIvHA1xN1Dfw2Ec5NQ5d-LF3ZP5" + pUrl := "https://www.youtube.com/playlist?list=PLdImBTpIvHA1xN1Dfw2Ec5NQ5d-LF3ZP5" + + data, err := extractor.Extract(cfg, url) + if err != nil { + t.Fatalf("Error: %v", err) + } + if len(data) != 1 { + t.Fatalf("Expected only a single video") + } + if data[0].PlaylistTitle != "" { + t.Fatalf("Did not expect a playlist") + } + + data, err = extractor.Extract(cfg, pUrl) + if err != nil { + t.Fatalf("Error: %v", err) + } + if len(data) != 14 { + t.Fatalf("Invalid playlist item count: got '%v'", len(data)) + } + + data, err = extractor.Extract(extractorTestCfg, url) + if err != nil { + t.Fatalf("Error: %v", err) + } + if len(data) != 14 { + t.Fatalf("Invalid playlist item count: got '%v'", len(data)) + } + if data[0].Title != "Why I use Linux" { + t.Fatalf("Invalid title of first item: got '%v'", data[0].Title) + } + if data[0].Duration != 70 { + t.Fatalf("Invalid duration of first item: got '%v'", data[0].Duration) + } +} + +func TestSpotifyTrack(t *testing.T) { + data, err := extractor.Extract(extractorTestCfg, "https://open.spotify.com/track/7HjaeqTHY6QlwPY0MEjuMF") + if err != nil { + t.Fatalf("Error: %v", err) + } + if len(data) != 1 { + t.Fatalf("Expected exactly one URL but got %v", len(data)) + } + if data[0].Title != "Infected Mushroom, Ninet Tayeb - Black Velvet" { + t.Fatalf("Invalid song title: %v", data[0].Title) + } + if data[0].Uploader != "Infected Mushroom, Ninet Tayeb" { + t.Fatalf("Invalid artists: %v", data[0].Uploader) + } + if !validYtStreamUrl(data[0].StreamUrl) { + t.Fatalf("Invalid YouTube stream URL: got '%v'", data[0].StreamUrl) + } +} + +func TestSpotifyAlbum(t *testing.T) { + data, err := extractor.Extract(extractorTestCfg, "https://open.spotify.com/album/6YEjK95sgoXQn1yGbYjHsp") + if err != nil { + t.Fatalf("Error: %v", err) + } + if len(data) != 11 { + t.Fatalf("Expected exactly 11 tracks but got %v", len(data)) + } + if data[0].Title != "Infected Mushroom, Ninet Tayeb - Black Velvet" { + t.Fatalf("Invalid title of first item: got '%v'", data[0].Title) + } + if data[0].Uploader != "Infected Mushroom, Ninet Tayeb" { + t.Fatalf("Invalid artists in first item: %v", data[0].Uploader) + } + if data[1].Title != "Infected Mushroom - While I'm in the Mood" { + t.Fatalf("Invalid title of second item: got '%v'", data[1].Title) + } +} + +func TestYoutubeDl(t *testing.T) { + data, err := extractor.Extract(extractorTestCfg, "https://soundcloud.com/pendulum/sets/hold-your-colour-1") + if err != nil { + t.Fatalf("Error: %v", err) + } + if len(data) != 14 { + t.Fatalf("Invalid playlist item count: got '%v'", len(data)) + } + if data[0].Title != "Prelude" { + t.Fatalf("Invalid title of first item: got '%v'", data[0].Title) + } + if data[1].Title != "Slam" { + t.Fatalf("Invalid title of second item: got '%v'", data[1].Title) + } + if data[0].PlaylistTitle != "Hold Your Colour" { + t.Fatalf("Invalid playlist title: got '%v'", data[0].PlaylistTitle) + } +} diff --git a/extractor/spotify/providers.go b/extractor/spotify/providers.go new file mode 100644 index 0000000..cc54fd3 --- /dev/null +++ b/extractor/spotify/providers.go @@ -0,0 +1,96 @@ +package spotify + +import ( + "git.nobrain.org/r4/dischord/extractor" + "git.nobrain.org/r4/dischord/extractor/youtube" + + "errors" + "net/url" + "strings" +) + +func init() { + extractor.AddExtractor("spotify", NewExtractor()) +} + +type matchType int + +const ( + matchTypeNone matchType = iota + matchTypeTrack + matchTypeAlbum + matchTypePlaylist +) + +var ( + ErrInvalidInput = errors.New("invalid input") +) + +func matches(input string) (string, matchType) { + u, err := url.Parse(input) + if err != nil { + return "", matchTypeNone + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", matchTypeNone + } + if u.Host != "open.spotify.com" { + return "", matchTypeNone + } + sp := strings.Split(u.Path, "/") + if len(sp) != 3 || sp[0] != "" { + return "", matchTypeNone + } + switch sp[1] { + case "track": + return sp[2], matchTypeTrack + case "album": + return sp[2], matchTypeAlbum + case "playlist": + return sp[2], matchTypePlaylist + } + return "", matchTypeNone +} + +type Extractor struct { + ytSearcher *youtube.Searcher + ytSearcherConfig extractor.ProviderConfig + ytExtractor *youtube.Extractor + ytExtractorConfig extractor.ProviderConfig + token apiToken +} + +func NewExtractor() *Extractor { + extractor := &Extractor{} + extractor.ytSearcher = &youtube.Searcher{} + extractor.ytSearcherConfig = extractor.ytSearcher.DefaultConfig() + extractor.ytExtractor = &youtube.Extractor{} + extractor.ytExtractorConfig = extractor.ytExtractor.DefaultConfig() + return extractor +} + +func (e *Extractor) DefaultConfig() extractor.ProviderConfig { + return extractor.ProviderConfig{} +} + +func (e *Extractor) Matches(cfg extractor.ProviderConfig, input string) bool { + _, m := matches(input) + return m != matchTypeNone +} + +func (e *Extractor) Extract(cfg extractor.ProviderConfig, input string) ([]extractor.Data, error) { + id, m := matches(input) + switch m { + case matchTypeTrack: + d, err := getTrack(e, id) + if err != nil { + return nil, err + } + return []extractor.Data{d}, nil + case matchTypeAlbum: + return getAlbum(e, id) + case matchTypePlaylist: + return getPlaylist(e, id) + } + return nil, ErrInvalidInput +} diff --git a/extractor/spotify/spotify.go b/extractor/spotify/spotify.go new file mode 100644 index 0000000..fcc1d24 --- /dev/null +++ b/extractor/spotify/spotify.go @@ -0,0 +1,378 @@ +package spotify + +import ( + "git.nobrain.org/r4/dischord/extractor" + exutil "git.nobrain.org/r4/dischord/extractor/util" + + "encoding/json" + "errors" + "net/http" + "strings" + "time" +) + +var ( + ErrGettingSessionData = errors.New("unable to get session data") + ErrInvalidTrackData = errors.New("invalid track data") + ErrTrackNotFound = errors.New("unable to find track on YouTube") + ErrUnableToGetYoutubeStream = errors.New("unable to get YouTube stream") + ErrDecodingApiResponse = errors.New("error decoding API response") +) + +// distance between two integers +func iDist(a, b int) int { + if a > b { + return a - b + } else { + return b - a + } +} + +func containsIgnoreCase(s, substr string) bool { + return strings.Contains(strings.ToUpper(s), strings.ToUpper(substr)) +} + +type sessionData struct { + AccessToken string `json:"accessToken"` + AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"` +} + +type apiToken struct { + token string + expires time.Time +} + +func updateApiToken(token *apiToken) error { + if time.Now().Before(token.expires) { + // Token already up-to-date + return nil + } + + // Get new token + var data sessionData + var funcErr error + err := exutil.GetHTMLScriptFunc("https://open.spotify.com", false, func(code string) bool { + if strings.HasPrefix(code, "{\"accessToken\":\"") { + // Parse session data + if err := json.Unmarshal([]byte(code), &data); err != nil { + funcErr = err + return false + } + return false + } + return true + }) + if err != nil { + return err + } + if funcErr != nil { + return funcErr + } + *token = apiToken{ + token: data.AccessToken, + expires: time.UnixMilli(data.AccessTokenExpirationTimestampMs), + } + return nil +} + +type trackData struct { + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + DurationMs int `json:"duration_ms"` + ExternalUrls struct { + Spotify string `json:"spotify"` + } `json:"external_urls"` + Name string `json:"name"` +} + +func (d trackData) artistsString() (res string) { + for i, v := range d.Artists { + if i != 0 { + res += ", " + } + res += v.Name + } + return +} + +func (d trackData) titleString() string { + return d.artistsString() + " - " + d.Name +} + +func getTrack(e *Extractor, trackId string) (extractor.Data, error) { + if err := updateApiToken(&e.token); err != nil { + return extractor.Data{}, err + } + + // Make API request for track info + req, err := http.NewRequest("GET", "https://api.spotify.com/v1/tracks/"+trackId, nil) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", "Bearer "+e.token.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return extractor.Data{}, err + } + defer resp.Body.Close() + + // Parse track info + var data trackData + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&data); err != nil { + return extractor.Data{}, ErrDecodingApiResponse + } + + if len(data.Artists) == 0 { + return extractor.Data{}, ErrInvalidTrackData + } + + // Search for track on YouTube + results, err := e.ytSearcher.Search(e.ytSearcherConfig, data.Name+" - "+data.artistsString()) + if err != nil { + return extractor.Data{}, err + } + if len(results) == 0 { + return extractor.Data{}, ErrTrackNotFound + } + + // Lower is better + score := func(ytd extractor.Data, resIdx int) (score int) { + // This function determines the likelihood of a given YouTube video + // belonging to the Spotify song. + // It may look pretty complicated, but here's the gist: + // - lower scores are better + // - the general formula is: resIdx - matchAccuracy / penalty + // where 'resIdx' is the position in the search results, + // 'matchAccuracy' is how well the video superficially matches + // with the Spotify song (title, artists, duration) and 'penalty' + // measures the hints pointing to the current video being the + // wrong one (awfully wrong duration, instrumental version, remix + // etc.) + // - if the video is from an official artist channel, that makes the + // penalty points even more credible, so they're squared + // - accuracy and penalty points are multiplicative; this makes them + // have exponentially more weight the more they are given + + matchAccuracy := 1.0 + matchPenalty := 1.0 + sqrPenalty := false + if ytd.OfficialArtist || strings.HasSuffix(ytd.Uploader, " - Topic") { + matchAccuracy *= 4.0 + sqrPenalty = true + } + if containsIgnoreCase(ytd.Title, data.Name) { + matchAccuracy *= 4.0 + } + matchingArtists := 0.0 + firstMatches := false + for i, artist := range data.Artists { + if containsIgnoreCase(ytd.Uploader, artist.Name) || + containsIgnoreCase(ytd.Title, artist.Name) { + matchingArtists += 1.0 + if i == 0 { + firstMatches = true + } + } + } + if firstMatches { + matchAccuracy *= 2.0 + } + matchAccuracy *= 2.0 * (matchingArtists / float64(len(data.Artists))) + durationDist := iDist(ytd.Duration, data.DurationMs/1000) + if durationDist <= 5 { + matchAccuracy *= 8.0 + } else if durationDist >= 300 { + matchPenalty *= 16.0 + } + spotiArtists := data.artistsString() + onlyYtTitleContains := func(s string) bool { + return !containsIgnoreCase(data.Name, s) && + !containsIgnoreCase(spotiArtists, s) && + containsIgnoreCase(ytd.Title, s) + } + if onlyYtTitleContains("instrumental") || onlyYtTitleContains("cover") || + onlyYtTitleContains("live") || onlyYtTitleContains("album") { + matchPenalty *= 8.0 + } + if onlyYtTitleContains("remix") || onlyYtTitleContains("rmx") { + matchPenalty *= 8.0 + } else if onlyYtTitleContains("mix") { + matchPenalty *= 6.0 + } + if onlyYtTitleContains("vip") { + matchPenalty *= 6.0 + } + totalPenalty := matchPenalty + if sqrPenalty { + totalPenalty *= totalPenalty + } + return resIdx - int(matchAccuracy/totalPenalty) + } + + // Select the result with the lowest (best) score + lowestIdx := -1 + lowest := 2147483647 + for i, v := range results { + score := score(v, i) + //fmt.Println(i, score, v) + if score < lowest { + lowestIdx = i + lowest = score + } + } + + ytData, err := e.ytExtractor.Extract(e.ytExtractorConfig, results[lowestIdx].SourceUrl) + if err != nil { + return extractor.Data{}, err + } + + if len(ytData) != 1 { + return extractor.Data{}, ErrUnableToGetYoutubeStream + } + + return extractor.Data{ + SourceUrl: data.ExternalUrls.Spotify, + StreamUrl: ytData[0].StreamUrl, + Title: data.titleString(), + Uploader: data.artistsString(), + Duration: ytData[0].Duration, + Expires: ytData[0].Expires, + }, nil +} + +type playlistData struct { + ExternalUrls struct { + Spotify string `json:"spotify"` + } `json:"external_urls"` + Name string `json:"name"` + Tracks struct { + Items []struct { + Track trackData `json:"track"` + } `json:"items"` + Next string `json:"next"` + } `json:"tracks"` +} + +func getPlaylist(e *Extractor, playlistId string) ([]extractor.Data, error) { + if err := updateApiToken(&e.token); err != nil { + return nil, err + } + + var data playlistData + trackOnlyReq := false + reqUrl := "https://api.spotify.com/v1/playlists/" + playlistId + var res []extractor.Data + for { + // Make API request for playlist info + req, err := http.NewRequest("GET", reqUrl, nil) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", "Bearer "+e.token.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Parse playlist info + dec := json.NewDecoder(resp.Body) + if trackOnlyReq { + // JSON decoder doesn't always overwrite the set value + data.Tracks.Next = "" + data.Tracks.Items = nil + + err = dec.Decode(&data.Tracks) + } else { + err = dec.Decode(&data) + } + if err != nil { + return nil, ErrDecodingApiResponse + } + + for _, v := range data.Tracks.Items { + res = append(res, extractor.Data{ + SourceUrl: v.Track.ExternalUrls.Spotify, + Title: v.Track.titleString(), + Uploader: v.Track.artistsString(), + PlaylistUrl: data.ExternalUrls.Spotify, + PlaylistTitle: data.Name, + }) + } + + if data.Tracks.Next == "" { + break + } else { + reqUrl = data.Tracks.Next + trackOnlyReq = true + } + } + return res, nil +} + +type albumData struct { + ExternalUrls struct { + Spotify string `json:"spotify"` + } `json:"external_urls"` + Name string `json:"name"` + Tracks struct { + Items []trackData `json:"items"` + Next string `json:"next"` + } `json:"tracks"` +} + +func getAlbum(e *Extractor, albumId string) ([]extractor.Data, error) { + // This function is pretty much copied from getPlaylist, with minor + // modifications + + if err := updateApiToken(&e.token); err != nil { + return nil, err + } + + var data albumData + trackOnlyReq := false + reqUrl := "https://api.spotify.com/v1/albums/" + albumId + var res []extractor.Data + for { + // Make API request for album info + req, err := http.NewRequest("GET", reqUrl, nil) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", "Bearer "+e.token.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Parse album info + dec := json.NewDecoder(resp.Body) + if trackOnlyReq { + // JSON decoder doesn't always overwrite the set value + data.Tracks.Next = "" + data.Tracks.Items = nil + + err = dec.Decode(&data.Tracks) + } else { + err = dec.Decode(&data) + } + if err != nil { + return nil, ErrDecodingApiResponse + } + + for _, v := range data.Tracks.Items { + res = append(res, extractor.Data{ + SourceUrl: v.ExternalUrls.Spotify, + Title: v.titleString(), + Uploader: v.artistsString(), + PlaylistUrl: data.ExternalUrls.Spotify, + PlaylistTitle: data.Name, + }) + } + + if data.Tracks.Next == "" { + break + } else { + reqUrl = data.Tracks.Next + trackOnlyReq = true + } + } + return res, nil +} diff --git a/extractor/util/util.go b/extractor/util/util.go new file mode 100644 index 0000000..13027e6 --- /dev/null +++ b/extractor/util/util.go @@ -0,0 +1,59 @@ +package util + +import ( + "golang.org/x/net/html" + + "net/http" +) + +// Retrieve JavaScript embedded in HTML +func GetHTMLScriptFunc(url string, readCodeLineByLine bool, codeFunc func(code string) bool) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + z := html.NewTokenizer(resp.Body) + isScript := false + + for { + tt := z.Next() + + switch tt { + case html.ErrorToken: + return z.Err() + case html.TextToken: + if codeFunc != nil && isScript { + t := string(z.Text()) + if readCodeLineByLine { + // NOTE: a bufio line scanner doesn't work (bufio.Scanner: token too long); maybe this is a bug + // Iterate over each line in the script + ls := 0 // line start + le := 0 // line end + for ls < len(t) { + if le == len(t) || t[le] == '\n' { + ln := t[ls:le] + + if !codeFunc(ln) { + return nil + } + + ls = le + 1 + } + le++ + } + } else { + if !codeFunc(t) { + return nil + } + } + } + case html.StartTagToken, html.EndTagToken: + tn, _ := z.TagName() + if string(tn) == "script" { + isScript = tt == html.StartTagToken + } + } + } +} diff --git a/extractor/youtube/providers.go b/extractor/youtube/providers.go new file mode 100644 index 0000000..1f9ed93 --- /dev/null +++ b/extractor/youtube/providers.go @@ -0,0 +1,102 @@ +package youtube + +import ( + "git.nobrain.org/r4/dischord/extractor" + + "errors" + "net/url" +) + +func init() { + extractor.AddExtractor("youtube", &Extractor{}) + extractor.AddSearcher("youtube-search", &Searcher{}) + extractor.AddSuggestor("youtube-search-suggestions", &Suggestor{}) +} + +type matchType int + +const ( + matchTypeNone matchType = iota + matchTypeVideo + matchTypePlaylist +) + +var ( + ErrInvalidInput = errors.New("invalid input") +) + +func matches(requireDirectPlaylistUrl bool, input string) matchType { + u, err := url.Parse(input) + if err != nil { + return matchTypeNone + } + if u.Scheme != "http" && u.Scheme != "https" { + return matchTypeNone + } + q, err := url.ParseQuery(u.RawQuery) + if err != nil { + return matchTypeNone + } + switch u.Host { + case "www.youtube.com", "youtube.com": + if u.Path != "/watch" && u.Path != "/playlist" { + return matchTypeNone + } + if q.Has("list") && (!requireDirectPlaylistUrl || u.Path == "/playlist") { + return matchTypePlaylist + } + return matchTypeVideo + case "youtu.be": + return matchTypeVideo + default: + return matchTypeNone + } +} + +type Extractor struct { + decryptor decryptor +} + +func (e *Extractor) DefaultConfig() extractor.ProviderConfig { + return extractor.ProviderConfig{ + "require-direct-playlist-url": false, + } +} + +func (e *Extractor) Matches(cfg extractor.ProviderConfig, input string) bool { + return matches(cfg["require-direct-playlist-url"].(bool), input) != matchTypeNone +} + +func (e *Extractor) Extract(cfg extractor.ProviderConfig, input string) ([]extractor.Data, error) { + switch matches(cfg["require-direct-playlist-url"].(bool), input) { + case matchTypeVideo: + d, err := getVideo(&e.decryptor, input) + if err != nil { + return nil, err + } + return []extractor.Data{d}, nil + case matchTypePlaylist: + return getPlaylist(input) + } + return nil, ErrInvalidInput +} + +type Searcher struct{} + +func (s *Searcher) DefaultConfig() extractor.ProviderConfig { + return extractor.ProviderConfig{} +} + +func (s *Searcher) Search(cfg extractor.ProviderConfig, input string) ([]extractor.Data, error) { + return getSearch(input) +} + +type Suggestor struct{} + +func (s *Suggestor) DefaultConfig() extractor.ProviderConfig { + return extractor.ProviderConfig{} +} + +func (s *Suggestor) Suggest(cfg extractor.ProviderConfig, input string) ([]string, error) { + return getSearchSuggestions(input) +} diff --git a/extractor/youtube/util.go b/extractor/youtube/util.go new file mode 100644 index 0000000..829c174 --- /dev/null +++ b/extractor/youtube/util.go @@ -0,0 +1,58 @@ +package youtube + +import ( + "golang.org/x/net/html" + + "net/http" +) + +func getHTMLScriptFunc(url string, readCodeLineByLine bool, codeFunc func(code string) bool) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + z := html.NewTokenizer(resp.Body) + isScript := false + + for { + tt := z.Next() + + switch tt { + case html.ErrorToken: + return z.Err() + case html.TextToken: + if codeFunc != nil && isScript { + t := string(z.Text()) + if readCodeLineByLine { + // NOTE: a bufio line scanner doesn't work (bufio.Scanner: token too long); maybe this is a bug + // Iterate over each line in the script + ls := 0 // line start + le := 0 // line end + for ls < len(t) { + if le == len(t) || t[le] == '\n' { + ln := t[ls:le] + + if !codeFunc(ln) { + return nil + } + + ls = le + 1 + } + le++ + } + } else { + if !codeFunc(t) { + return nil + } + } + } + case html.StartTagToken, html.EndTagToken: + tn, _ := z.TagName() + if string(tn) == "script" { + isScript = tt == html.StartTagToken + } + } + } +} diff --git a/extractor/youtube/youtube.go b/extractor/youtube/youtube.go new file mode 100644 index 0000000..a371491 --- /dev/null +++ b/extractor/youtube/youtube.go @@ -0,0 +1,416 @@ +package youtube + +import ( + "git.nobrain.org/r4/dischord/extractor" + exutil "git.nobrain.org/r4/dischord/extractor/util" + "git.nobrain.org/r4/dischord/util" + + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +var ( + ErrNoSuitableFormat = errors.New("no suitable audio-only format found") + ErrGettingUrlFromSignatureCipher = errors.New("error getting URL from signature cipher") + ErrDecryptFunctionBroken = errors.New("signature decryptor function is broken (perhaps the extractor is out of date)") + ErrMalformedJson = errors.New("malformed JSON") +) + +type playerData struct { + StreamingData struct { + ExpiresInSeconds string `json:"expiresInSeconds"` + Formats []struct { + Url string `json:"url"` + SignatureCipher string `json:"signatureCipher"` + MimeType string `json:"mimeType"` + Bitrate int `json:"bitrate"` + ApproxDurationMs string `json:"approxDurationMs"` + AudioSampleRate string `json:"audioSampleRate"` + AudioChannels int `json:"audioChannels"` + } `json:"formats"` + AdaptiveFormats []struct { + Url string `json:"url"` + SignatureCipher string `json:"signatureCipher"` + MimeType string `json:"mimeType"` + Bitrate int `json:"bitrate"` + ApproxDurationMs string `json:"approxDurationMs"` + AudioSampleRate string `json:"audioSampleRate"` + AudioChannels int `json:"audioChannels"` + } `json:"adaptiveFormats"` + } `json:"streamingData"` + VideoDetails struct { + VideoId string `json:"videoId"` + Title string `json:"title"` + LengthSeconds string `json:"lengthSeconds"` + ShortDescription string `json:"shortDescription"` + Author string `json:"author"` + } `json:"videoDetails"` +} + +func getVideo(decryptor *decryptor, vUrl string) (extractor.Data, error) { + try := func() (extractor.Data, error) { + // Get JSON string from YouTube + v, err := getJSVar(vUrl, "ytInitialPlayerResponse") + if err != nil { + return extractor.Data{}, err + } + + // Parse player data scraped from YouTube + var data playerData + if err := json.Unmarshal([]byte(v), &data); err != nil { + return extractor.Data{}, err + } + + // Get audio format with maximum bitrate + maxBr := -1 + for i, f := range data.StreamingData.AdaptiveFormats { + if strings.HasPrefix(f.MimeType, "audio/") { + if maxBr == -1 || f.Bitrate > data.StreamingData.AdaptiveFormats[maxBr].Bitrate { + maxBr = i + } + } + } + if maxBr == -1 { + return extractor.Data{}, ErrNoSuitableFormat + } + + duration, err := strconv.Atoi(data.VideoDetails.LengthSeconds) + if err != nil { + duration = -1 + } + expires, err := strconv.Atoi(data.StreamingData.ExpiresInSeconds) + if err != nil { + return extractor.Data{}, err + } + + ft := data.StreamingData.AdaptiveFormats[maxBr] + var resUrl string + if ft.Url != "" { + resUrl = ft.Url + } else { + // For music, YouTube makes getting the resource URL a bit trickier + q, err := url.ParseQuery(ft.SignatureCipher) + if err != nil { + return extractor.Data{}, ErrGettingUrlFromSignatureCipher + } + sig := q.Get("s") + sigParam := q.Get("sp") + baseUrl := q.Get("url") + sigDecrypted, err := decryptor.decrypt(sig) + if err != nil { + return extractor.Data{}, err + } + resUrl = baseUrl + "&" + sigParam + "=" + sigDecrypted + } + + return extractor.Data{ + SourceUrl: vUrl, + StreamUrl: resUrl, + Title: data.VideoDetails.Title, + Description: data.VideoDetails.ShortDescription, + Uploader: data.VideoDetails.Author, + Duration: duration, + Expires: time.Now().Add(time.Duration(expires) * time.Second), + }, nil + } + + isOk := func(strmUrl string) bool { + resp, err := http.Get(strmUrl) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == 200 + } + + // Sometimes we just get an invalid stream URL, and I didn't find anything + // simple to do about it, so we just try the stream URL we get and repeat + // if it's invalid + for tries := 0; tries < 10; tries++ { + data, err := try() + if err != nil { + return extractor.Data{}, err + } + if isOk(data.StreamUrl) { + return data, nil + } + } + + return extractor.Data{}, ErrDecryptFunctionBroken +} + +type playlistVideoData struct { + Contents struct { + TwoColumnWatchNextResults struct { + Playlist struct { + Playlist struct { + Title string `json:"title"` + Contents []struct { + PlaylistPanelVideoRenderer struct { + NavigationEndpoint struct { + WatchEndpoint struct { + VideoId string `json:"videoId"` + Index int `json:"index"` + } `json:"watchEndpoint"` + } `json:"navigationEndpoint"` + Title struct { + SimpleText string `json:"simpleText"` + } `json:"title"` + ShortBylineText struct { + Runs []struct { + Text string `json:"text"` // uploader name + } `json:"runs"` + } `json:"shortBylineText"` + LengthText struct { + SimpleText string `json:"simpleText"` + } `json:"lengthText"` + } `json:"playlistPanelVideoRenderer"` + } `json:"contents"` + } `json:"playlist"` + } `json:"playlist"` + } `json:"twoColumnWatchNextResults"` + } `json:"contents"` +} + +// Only gets superficial data, the actual stream URL must be extracted from SourceUrl +func getPlaylist(pUrl string) ([]extractor.Data, error) { + u, err := url.Parse(pUrl) + if err != nil { + return nil, err + } + q, err := url.ParseQuery(u.RawQuery) + if err != nil { + return nil, err + } + listId := q.Get("list") + vidId := "" + index := 0 + + var res []extractor.Data + + // This loop uses the playlist sidebar: each video played in the context + // of a playlist loads 100 or so of the following videos' infos, which we + // add to the returned slice; then we take the last retrieved video's infos + // and use its sidebar and so on + for { + vUrl := "https://www.youtube.com/watch?v=" + vidId + "&list=" + listId + "&index=" + strconv.Itoa(index+1) + + // Get JSON string from YouTube + v, err := getJSVar(vUrl, "ytInitialData") + if err != nil { + return nil, err + } + + // Parse playlist data scraped from YouTube + var data playlistVideoData + if err := json.Unmarshal([]byte(v), &data); err != nil { + return nil, err + } + + added := false + for _, v := range data.Contents.TwoColumnWatchNextResults.Playlist.Playlist.Contents { + vidId = v.PlaylistPanelVideoRenderer.NavigationEndpoint.WatchEndpoint.VideoId + index = v.PlaylistPanelVideoRenderer.NavigationEndpoint.WatchEndpoint.Index + + if index == len(res) { + srcUrl := "https://www.youtube.com/watch?v=" + vidId + + bylineText := v.PlaylistPanelVideoRenderer.ShortBylineText + if len(bylineText.Runs) == 0 { + return nil, ErrMalformedJson + } + uploader := bylineText.Runs[0].Text + + length, err := util.ParseDurationSeconds(v.PlaylistPanelVideoRenderer.LengthText.SimpleText) + if err != nil { + length = -1 + } + + res = append(res, extractor.Data{ + SourceUrl: srcUrl, + Title: v.PlaylistPanelVideoRenderer.Title.SimpleText, + PlaylistUrl: "https://www.youtube.com/playlist?list=" + listId, + PlaylistTitle: data.Contents.TwoColumnWatchNextResults.Playlist.Playlist.Title, + Uploader: uploader, + Duration: length, + }) + + added = true + } + } + + if !added { + break + } + } + + return res, nil +} + +type searchData struct { + Contents struct { + TwoColumnSearchResultsRenderer struct { + PrimaryContents struct { + SectionListRenderer struct { + Contents []struct { + ItemSectionRenderer struct { + Contents []struct { + PlaylistRenderer struct { + PlaylistId string `json:"playlistId"` + Title struct { + SimpleText string `json:"simpleText"` + } `json:"title"` + } `json:"playlistRenderer"` + VideoRenderer struct { + VideoId string `json:"videoId"` + Title struct { + Runs []struct { + Text string `json:"text"` + } `json:"runs"` + } `json:"title"` + LongBylineText struct { + Runs []struct { + Text string `json:"text"` // uploader name + } `json:"runs"` + } `json:"longBylineText"` + LengthText struct { + SimpleText string `json:"simpleText"` + } `json:"lengthText"` + OwnerBadges []struct { + MetadataBadgeRenderer struct { + Style string `json:"style"` + } `json:"metadataBadgeRenderer"` + } `json:"OwnerBadges"` + } `json:"videoRenderer"` + } `json:"contents"` + } `json:"itemSectionRenderer"` + } `json:"contents"` + } `json:"sectionListRenderer"` + } `json:"primaryContents"` + } `json:"twoColumnSearchResultsRenderer"` + } `json:"contents"` +} + +// Only gets superficial data, the actual stream URL must be extracted from SourceUrl +func getSearch(query string) ([]extractor.Data, error) { + // Get JSON string from YouTube + sanitizedQuery := url.QueryEscape(strings.ReplaceAll(query, " ", "+")) + queryUrl := "https://www.youtube.com/results?search_query=" + sanitizedQuery + v, err := getJSVar(queryUrl, "ytInitialData") + if err != nil { + return nil, err + } + + // Parse search data scraped from YouTube + var data searchData + if err := json.Unmarshal([]byte(v), &data); err != nil { + return nil, err + } + + var res []extractor.Data + for _, v0 := range data.Contents.TwoColumnSearchResultsRenderer.PrimaryContents.SectionListRenderer.Contents { + for _, v1 := range v0.ItemSectionRenderer.Contents { + if v1.VideoRenderer.VideoId != "" { + titleRuns := v1.VideoRenderer.Title.Runs + if len(titleRuns) == 0 { + return nil, ErrMalformedJson + } + title := titleRuns[0].Text + + bylineText := v1.VideoRenderer.LongBylineText + if len(bylineText.Runs) == 0 { + return nil, ErrMalformedJson + } + uploader := bylineText.Runs[0].Text + + length, err := util.ParseDurationSeconds(v1.VideoRenderer.LengthText.SimpleText) + if err != nil { + length = -1 + } + + badges := v1.VideoRenderer.OwnerBadges + + res = append(res, extractor.Data{ + SourceUrl: "https://www.youtube.com/watch?v=" + v1.VideoRenderer.VideoId, + Title: title, + Duration: length, + Uploader: uploader, + OfficialArtist: len(badges) != 0 && badges[0].MetadataBadgeRenderer.Style == "BADGE_STYLE_TYPE_VERIFIED_ARTIST", + }) + } else if v1.PlaylistRenderer.PlaylistId != "" { + res = append(res, extractor.Data{ + PlaylistUrl: "https://www.youtube.com/playlist?list=" + v1.PlaylistRenderer.PlaylistId, + PlaylistTitle: v1.PlaylistRenderer.Title.SimpleText, + }) + } + } + } + + return res, nil +} + +func getSearchSuggestions(query string) ([]string, error) { + url := "https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&ds=yt&q=" + url.QueryEscape(query) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + raw = []byte(strings.TrimSuffix(strings.TrimPrefix(string(raw), "window.google.ac.h("), ")")) + + var data []any + if err := json.Unmarshal(raw, &data); err != nil { + return nil, err + } + + if len(data) != 3 { + return nil, ErrMalformedJson + } + rawSuggestions, ok := data[1].([]any) + if !ok { + return nil, ErrMalformedJson + } + + var res []string + for _, v := range rawSuggestions { + rawSuggestion, ok := v.([]any) + if !ok || len(rawSuggestion) != 3 { + return nil, ErrMalformedJson + } + suggestion, ok := rawSuggestion[0].(string) + if !ok { + return nil, ErrMalformedJson + } + res = append(res, suggestion) + } + return res, nil +} + +// Gets a constant JavaScript variable's value from a URL and a variable name +// (variable format must be: var someVarName = {"somekey": "lol"};) +func getJSVar(url, varName string) (string, error) { + match := "var " + varName + " = " + + var res string + err := exutil.GetHTMLScriptFunc(url, true, func(code string) bool { + if strings.HasPrefix(code, match) { + res = strings.TrimRight(code[len(match):], ";") + return false + } + return true + }) + if err != nil { + return "", err + } + return res, nil +} diff --git a/extractor/youtube/youtube_decrypt.go b/extractor/youtube/youtube_decrypt.go new file mode 100644 index 0000000..ca31910 --- /dev/null +++ b/extractor/youtube/youtube_decrypt.go @@ -0,0 +1,245 @@ +package youtube + +import ( + exutil "git.nobrain.org/r4/dischord/extractor/util" + + "encoding/json" + "errors" + "io" + "net/http" + "regexp" + "strconv" + "strings" +) + +var ( + ErrDecryptGettingFunctionName = errors.New("error getting signature decryption function name") + ErrDecryptGettingFunction = errors.New("error getting signature decryption function") + ErrDecryptGettingOpTable = errors.New("error getting signature decryption operation table") + ErrGettingBaseJs = errors.New("unable to get base.js") +) + +type decryptorOp struct { + fn func(a *string, b int) + arg int +} + +type decryptor struct { + // base.js version ID, used for caching + versionId string + // The actual decryption algorithm can be split up into a list of known + // operations + ops []decryptorOp +} + +func (d *decryptor) decrypt(input string) (string, error) { + if err := updateDecryptor(d); err != nil { + return "", err + } + + s := input + for _, op := range d.ops { + op.fn(&s, op.arg) + } + return s, nil +} + +type configData struct { + PlayerJsUrl string `json:"PLAYER_JS_URL"` +} + +func updateDecryptor(d *decryptor) error { + prefix := "(function() {window.ytplayer={};\nytcfg.set(" + endStr := ");" + // Get base.js URL + var url string + var funcErr error + err := exutil.GetHTMLScriptFunc("https://www.youtube.com", false, func(code string) bool { + if strings.HasPrefix(code, prefix) { + // Cut out the JSON part + code = code[len(prefix):] + end := strings.Index(code, endStr) + if end == -1 { + funcErr = ErrGettingBaseJs + return false + } + + // Parse config data + var data configData + if err := json.Unmarshal([]byte(code[:end]), &data); err != nil { + funcErr = ErrGettingBaseJs + return false + } + + url = "https://www.youtube.com" + data.PlayerJsUrl + return false + } + return true + }) + if err != nil { + return err + } + if funcErr != nil { + return err + } + + // Get base.js version ID + sp := strings.SplitN(strings.TrimPrefix(url, "/s/player/"), "/", 2) + if len(sp) != 2 { + return ErrGettingBaseJs + } + verId := sp[0] + + if d.versionId == verId { + // Decryptor already up-to-date + return nil + } + + // Get base.js contents + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return ErrGettingBaseJs + } + + // Copy contents to buffer + buf := new(strings.Builder) + _, err = io.Copy(buf, resp.Body) + if err != nil { + return err + } + + // Get decryption operations + ops, err := getDecryptOps(buf.String()) + if err != nil { + return err + } + + d.versionId = verId + d.ops = ops + return nil +} + +var decryptFunctionNameRegexp = regexp.MustCompile(`[a-zA-Z]*&&\([a-zA-Z]*=([a-zA-Z]*)\(decodeURIComponent\([a-zA-Z]*\)\),[a-zA-Z]*\.set\([a-zA-Z]*,encodeURIComponent\([a-zA-Z]*\)\)\)`) + +func getDecryptFunction(baseJs string) (string, error) { + idx := decryptFunctionNameRegexp.FindSubmatchIndex([]byte(baseJs)) + if len(idx) != 4 { + return "", ErrDecryptGettingFunctionName + } + fnName := baseJs[idx[2]:idx[3]] + + startMatch := fnName + `=function(a){a=a.split("");` + endMatch := `;return a.join("")};` + start := strings.Index(baseJs, startMatch) + if start == -1 { + return "", ErrDecryptGettingFunction + } + fn := baseJs[start+len(startMatch):] + end := strings.Index(fn, endMatch) + if start == -1 { + return "", ErrDecryptGettingFunction + } + return fn[:end], nil +} + +func getDecryptOps(baseJs string) ([]decryptorOp, error) { + // Extract main decryptor function JS + decrFn, err := getDecryptFunction(baseJs) + if err != nil { + return nil, err + } + + // Get decyptor operation JS + var ops string + { + sp := strings.SplitN(decrFn, ".", 2) + if len(sp) != 2 { + return nil, ErrDecryptGettingOpTable + } + opsObjName := sp[0] + + startMatch := `var ` + opsObjName + `={` + endMatch := `};` + start := strings.Index(baseJs, startMatch) + if start == -1 { + return nil, ErrDecryptGettingOpTable + } + ops = baseJs[start+len(startMatch):] + end := strings.Index(ops, endMatch) + if start == -1 { + return nil, ErrDecryptGettingOpTable + } + ops = ops[:end] + } + + // Make a decryptor operation table that associates the operation + // names with a specific action on an input string + opTable := make(map[string]func(a *string, b int)) + { + lns := strings.Split(ops, "\n") + if len(lns) != 3 { + return nil, ErrDecryptGettingOpTable + } + for _, ln := range lns { + sp := strings.Split(ln, ":") + if len(sp) != 2 { + return nil, ErrDecryptGettingOpTable + } + name := sp[0] + fn := sp[1] + switch { + case strings.HasPrefix(fn, `function(a){a.reverse()}`): + opTable[name] = func(a *string, b int) { + // Reverse a + var res string + for _, c := range *a { + res = string(c) + res + } + *a = res + } + case strings.HasPrefix(fn, `function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c}`): + opTable[name] = func(a *string, b int) { + // Swap a[0] and a[b % len(a)] + c := []byte(*a) + c[0], c[b%len(*a)] = c[b%len(*a)], c[0] + *a = string(c) + } + case strings.HasPrefix(fn, `function(a,b){a.splice(0,b)}`): + opTable[name] = func(a *string, b int) { + // Slice off all elements of a up to a[b] + *a = (*a)[b:] + } + } + } + } + + // Parse all operations in the main decryptor function and return them in + // order + var res []decryptorOp + for _, fn := range strings.Split(decrFn, ";") { + sp := strings.SplitN(fn, ".", 2) + if len(sp) != 2 { + return nil, ErrDecryptGettingOpTable + } + sp = strings.SplitN(sp[1], "(", 2) + if len(sp) != 2 { + return nil, ErrDecryptGettingOpTable + } + name := sp[0] + argS := strings.TrimSuffix(strings.TrimPrefix(sp[1], "a,"), ")") + arg, err := strconv.Atoi(argS) + if err != nil { + return nil, ErrDecryptGettingOpTable + } + callableOp, exists := opTable[name] + if !exists { + return nil, ErrDecryptGettingOpTable + } + res = append(res, decryptorOp{callableOp, arg}) + } + return res, nil +} diff --git a/extractor/ytdl/providers.go b/extractor/ytdl/providers.go new file mode 100644 index 0000000..2f9988d --- /dev/null +++ b/extractor/ytdl/providers.go @@ -0,0 +1,35 @@ +package ytdl + +import ( + "git.nobrain.org/r4/dischord/extractor" + + "strings" +) + +func init() { + extractor.AddExtractor("youtube-dl", &Extractor{}) +} + +type Extractor struct{} + +func (e *Extractor) DefaultConfig() extractor.ProviderConfig { + return extractor.ProviderConfig{ + "youtube-dl-path": "youtube-dl", + } +} + +func (e *Extractor) Matches(cfg extractor.ProviderConfig, input string) bool { + return strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") +} + +func (e *Extractor) Extract(cfg extractor.ProviderConfig, input string) ([]extractor.Data, error) { + var res []extractor.Data + dch, errch := ytdlGet(cfg["youtube-dl-path"].(string), input) + for v := range dch { + res = append(res, v) + } + for err := range errch { + return nil, err + } + return res, nil +} diff --git a/extractor/ytdl/ytdl.go b/extractor/ytdl/ytdl.go new file mode 100644 index 0000000..0de1d6b --- /dev/null +++ b/extractor/ytdl/ytdl.go @@ -0,0 +1,135 @@ +package ytdl + +import ( + "git.nobrain.org/r4/dischord/extractor" + + "bufio" + "encoding/json" + "errors" + "os/exec" + "strings" + "time" +) + +var ( + ErrUnsupportedUrl = errors.New("unsupported URL") +) + +// A very reduced version of the JSON structure returned by youtube-dl +type ytdlMetadata struct { + Title string `json:"title"` + Extractor string `json:"extractor"` + Duration float32 `json:"duration"` + WebpageUrl string `json:"webpage_url"` + Playlist string `json:"playlist"` + Uploader string `json:"uploader"` + Description string `json:"description"` + Formats []struct { + Url string `json:"url"` + Format string `json"format"` + VCodec string `json:"vcodec"` + } `json:"formats"` +} + +// Gradually sends all audio URLs through the string channel. If an error occurs, it is sent through the +// error channel. Both channels are closed after either an error occurs or all URLs have been output. +func ytdlGet(youtubeDLPath, input string) (<-chan extractor.Data, <-chan error) { + out := make(chan extractor.Data) + errch := make(chan error, 1) + + go func() { + defer close(out) + defer close(errch) + + // Set youtube-dl args + var ytdlArgs []string + ytdlArgs = append(ytdlArgs, "-j", input) + + // Prepare command for execution + cmd := exec.Command(youtubeDLPath, ytdlArgs...) + cmd.Env = []string{"LC_ALL=en_US.UTF-8"} // Youtube-dl doesn't recognize some chars if LC_ALL=C or not set at all + stdout, err := cmd.StdoutPipe() + if err != nil { + errch <- err + return + } + stderr, err := cmd.StderrPipe() + if err != nil { + errch <- err + return + } + + // Catch any errors put out by youtube-dl + stderrReadDoneCh := make(chan struct{}) + var ytdlError string + go func() { + sc := bufio.NewScanner(stderr) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "ERROR: ") { + ytdlError = strings.TrimPrefix(line, "ERROR: ") + } + } + stderrReadDoneCh <- struct{}{} + }() + + // Start youtube-dl + if err := cmd.Start(); err != nil { + errch <- err + return + } + + // We want to let our main loop know when youtube-dl is done + donech := make(chan error) + go func() { + donech <- cmd.Wait() + }() + + // Main JSON decoder loop + dec := json.NewDecoder(stdout) + for dec.More() { + // Read JSON + var m ytdlMetadata + if err := dec.Decode(&m); err != nil { + errch <- err + return + } + + // Extract URL from metadata (the latter formats are always the better with youtube-dl) + for i := len(m.Formats) - 1; i >= 0; i-- { + format := m.Formats[i] + if format.VCodec == "none" { + out <- extractor.Data{ + SourceUrl: m.WebpageUrl, + StreamUrl: format.Url, + Title: m.Title, + PlaylistTitle: m.Playlist, + Description: m.Description, + Uploader: m.Uploader, + Duration: int(m.Duration), + Expires: time.Now().Add(10 * 365 * 24 * time.Hour), + } + break + } + } + } + + // Wait for command to finish executing and catch any errors + err = <-donech + <-stderrReadDoneCh + if err != nil { + if ytdlError == "" { + errch <- err + } else { + if strings.HasPrefix(ytdlError, "Unsupported URL: ") { + errch <- ErrUnsupportedUrl + } else { + errch <- errors.New("ytdl: " + ytdlError) + } + } + return + } + }() + + return out, errch +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e51fb66 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module dischord + +go 1.18 + +require ( + github.com/BurntSushi/toml v1.2.0 + github.com/bwmarrin/discordgo v0.25.0 + github.com/ulikunitz/xz v0.5.10 + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 +) + +require ( + github.com/gorilla/websocket v1.4.2 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa804e7 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= +github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/bwmarrin/discordgo v0.25.0 h1:NXhdfHRNxtwso6FPdzW2i3uBvvU7UIQTghmV2T4nqAs= +github.com/bwmarrin/discordgo v0.25.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= +github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/player/player.go b/player/player.go new file mode 100644 index 0000000..6ac666f --- /dev/null +++ b/player/player.go @@ -0,0 +1,553 @@ +package player + +import ( + "git.nobrain.org/r4/dischord/audio" + "git.nobrain.org/r4/dischord/extractor" + + "bytes" + "errors" + "fmt" + "math/rand" + "sort" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +type Queue struct { + Done []extractor.Data + Playing *extractor.Data + Ahead []extractor.Data + AheadUnshuffled []extractor.Data + ShuffleOffset int + Paused bool + Loop bool +} + +func (q *Queue) Copy() *Queue { + res := &Queue{ + Done: append([]extractor.Data{}, q.Done...), + Playing: nil, + Ahead: append([]extractor.Data{}, q.Ahead...), + AheadUnshuffled: append([]extractor.Data{}, q.AheadUnshuffled...), + ShuffleOffset: q.ShuffleOffset, + Paused: q.Paused, + Loop: q.Loop, + } + if q.Playing != nil { + res.Playing = &extractor.Data{} + *res.Playing = *q.Playing + } + return res +} + +func (q *Queue) InBounds(i int) bool { + return !(i == 0 && q.Playing == nil) && + !(i < 0 && -i-1 >= len(q.Done)) && + !(i > 0 && i-1 >= len(q.Ahead)) +} + +func (q *Queue) At(i int) *extractor.Data { + if !q.InBounds(i) { + return nil + } + if i < 0 { + return &q.Done[len(q.Done)+i] + } else if i == 0 { + return q.Playing + } else { + return &q.Ahead[i-1] + } +} + +// Client commands +// Get commands can most easily be executed via the builtin convenience functions +// NOTE: When using commands like Jump, Swap and Delete, we can't be sure +// that our versions of the indices correspond to the versions the caller +// is referring to, but in this context, that should be fine +type Cmd interface{} +type CmdPlay struct{} +type CmdPause struct{} +type CmdLoop bool +type CmdJump int // relative track to jump to (e.g. -2, -1, 4) +type CmdSkipAll struct{} +type CmdShuffle struct{} +type CmdUnshuffle struct{} +type CmdSwap struct{ A, B int } +type CmdDelete []int +type CmdAddFront []extractor.Data +type CmdAddBack []extractor.Data +type CmdSeek float64 // seconds +type CmdSpeed float64 // speed factor +type CmdPlayFileAndStop struct { + DoneCh chan<- struct{} + Data []byte +} +type CmdGetTime chan<- float64 +type CmdGetQueue chan<- *Queue +type CmdGetSpeed chan<- float64 + +type Client struct { + CmdCh chan<- Cmd + // ErrCh is blocking meaning that you will have to constantly read it, + // either using another goroutine or a select statement whenever sending a + // command. + ErrCh <-chan error +} + +func (c Client) GetTime() float64 { + ch := make(chan float64) + c.CmdCh <- CmdGetTime(ch) + return <-ch +} + +func (c Client) GetQueue() *Queue { + ch := make(chan *Queue) + c.CmdCh <- CmdGetQueue(ch) + return <-ch +} + +func (c Client) GetSpeed() float64 { + ch := make(chan float64) + c.CmdCh <- CmdGetSpeed(ch) + return <-ch +} + +type Event interface{} +type EventStreamUpdated struct{} +type EventKilled struct{} + +type Callback interface{} + +// Creates a new player client that will run in parallel and receive commands +// via the returned Client.CmdCh. All audio will be sent via the given outCh. +// Closing the returned Client.CmdCh channel acts as a kill signal. +func NewClient(excfg extractor.Config, ffmpegPath string, outCh chan<- []byte, callbacks ...Callback) Client { + // Client channels + cCmdCh := make(chan Cmd) + cErrCh := make(chan error) + + // Callback setup + var callbacksStreamUpdated []func(EventStreamUpdated) + var callbacksKilled []func(EventKilled) + for _, c := range callbacks { + switch v := c.(type) { + case func(EventStreamUpdated): + callbacksStreamUpdated = append(callbacksStreamUpdated, v) + case func(EventKilled): + callbacksKilled = append(callbacksKilled, v) + default: + panic("player.NewClient(): invalid callback function type: " + fmt.Sprintf("%T", v)) + } + } + + go func() { + nFrames := 0 + tStart := 0.0 + playbackSpeed := 1.0 + + var queue Queue + + lastStreamErr := time.Unix(0, 0) + + var filePlaybackDoneCh chan<- struct{} + + // Mostly notes to self: + // This entire setup is pretty fragile, so I'll try to explain it: + // Each stream consists of the three fundamental channels below. + // - audioch sends us the encoded audio frames; if audioch is closed, + // that means the stream exited successfully + // - errch sends any potential error (singular!): if any error occurs, + // the client automatically shuts down + // - killch is where we can send a manual kill signal; no error or + // shutdown confirmation will follow + // + // Now, it is important here that any time a stream is terminated (either + // by a user or the stream terminates itself by closing audio ch or + // sending an error) all three channels are set to nil. Otherwise (and + // I have spent lots of time debugging this), the goroutine will just + // lock up trying to send a kill signal through a channel which no + // longer has a receiver. + // + // To achieve a kind of stability, I try to follow the basics rule that: + // - any time I do manipulate queue.Playing, I kill the current + // stream first + // - there are exactly three ways a stream can end and all have to be + // handled separately: termination by the user (only through + // calling killStream), exiting with an error (handled in select: + // case err, ok := <-errch), and exiting successfully by closing + // audioch (handled in select: case cmd, ok := <-cCmdCh: if !ok) + var audioch <-chan []byte + var errch <-chan error + var killch chan<- struct{} + + getPlaybackTime := func() float64 { + return tStart + float64(nFrames)*audio.FrameDuration*playbackSpeed + } + + getMaxCachedPlaybackTime := func() float64 { + return tStart + float64(nFrames+len(audioch))*audio.FrameDuration*playbackSpeed + } + + readAudioCh := func() <-chan []byte { + if queue.Paused { + return nil + } else { + return audioch + } + } + + killStream := func() { + if killch != nil { + killch <- struct{}{} + audioch = nil + errch = nil + killch = nil + } + } + + var jumpTracks func(nRel int) + + refreshStream := func(seek float64, speed float64) { + if queue.Playing == nil { + // Reset stream info + nFrames = 0 + tStart = 0.0 + playbackSpeed = 1.0 + } else { + // Kill the potential current stream + killStream() + + // Refresh stream URL if necessary + if queue.Playing.StreamUrl == "" || time.Now().After(queue.Playing.Expires) { + var data []extractor.Data + var err error + for { + data, err = extractor.Extract(excfg, queue.Playing.SourceUrl) + if err == nil { + break + } else { + now := time.Now() + if lastStreamErr.Sub(now) > 5*time.Second { + lastStreamErr = now + continue + } else { + jumpTracks(1) + lastStreamErr = now + cErrCh <- errors.New("skipping stream due to multiple errors") + break + } + } + } + if err == nil { + if len(data) == 1 { + *queue.Playing = data[0] + } else { + cErrCh <- errors.New("got invalid data refreshing stream") + } + } + } + + // Get new stream + audioch, errch, killch = audio.StreamToDiscordOpus(ffmpegPath, queue.Playing.StreamUrl, nil, seek, speed, true) + + // Reset stream info + nFrames = 0 + tStart = seek + playbackSpeed = speed + } + + for _, c := range callbacksStreamUpdated { + c(EventStreamUpdated{}) + } + } + + // Queue overflow safe + jumpTracks = func(nRel int) { + // Kill the potential current stream + killStream() + + if nRel > 0 && nRel > len(queue.Ahead) { + nRel = len(queue.Ahead) + if nRel == 0 { + nRel = 1 + } + } else if nRel < 0 && -nRel > len(queue.Done) { + nRel = len(queue.Done) + if nRel == 0 { + nRel = -1 + } + } + + // We can imagine this algorithm like a tape where A B C D E are + // the items, B is currently playing and we want to skip 2 tracks + // ahead (D: queue.Done, P: queue.Playing, A: queue.Ahead): + // A [B] C D E + // D: A; P: B; A: C D E + if nRel > 0 { + // A B [] C D E + // D: A B, P: , A: C D E + if queue.Playing != nil { + queue.Done = append(queue.Done, *queue.Playing) + } + + // A B C [] D E + // D: A B C, P: , A: D E + queue.Done = append(queue.Done, queue.Ahead[:nRel-1]...) + queue.Ahead = queue.Ahead[nRel-1:] + + // A B C [D] E + // D: A B C, P: D, A: E + if len(queue.Ahead) > 0 { + queue.Playing = new(extractor.Data) + *queue.Playing = queue.Ahead[0] + queue.Ahead = queue.Ahead[1:] + } else { + queue.Playing = nil + } + + queue.ShuffleOffset -= nRel + } else if nRel < 0 { + // The same thing in reverse + + nRel *= -1 + + if queue.Playing != nil { + queue.Ahead = append([]extractor.Data{*queue.Playing}, queue.Ahead...) + } + + ql := len(queue.Done) + queue.Ahead = append(queue.Done[ql-(nRel-1):ql], queue.Ahead...) + queue.Done = queue.Done[:ql-(nRel-1)] + + if len(queue.Done) > 0 { + ql := len(queue.Done) + queue.Playing = new(extractor.Data) + *queue.Playing = queue.Done[ql-1] + queue.Done = queue.Done[:ql-1] + } else { + queue.Playing = nil + } + + queue.ShuffleOffset += nRel + } + + // Update stream + refreshStream(0, playbackSpeed) + } + + var unshuffle func() + + shuffle := func() { + if queue.AheadUnshuffled != nil { + unshuffle() + } + queue.AheadUnshuffled = append([]extractor.Data{}, queue.Ahead...) + rand.Shuffle(len(queue.Ahead), func(i, j int) { + queue.Ahead[i], queue.Ahead[j] = queue.Ahead[j], queue.Ahead[i] + }) + queue.ShuffleOffset = 0 + } + + unshuffle = func() { + if queue.AheadUnshuffled == nil { + return + } + if queue.ShuffleOffset <= 0 { + if -queue.ShuffleOffset <= len(queue.AheadUnshuffled) { + queue.AheadUnshuffled = queue.AheadUnshuffled[-queue.ShuffleOffset:] + } + queue.ShuffleOffset = 0 + } + queue.Ahead = append(queue.Ahead[:queue.ShuffleOffset], queue.AheadUnshuffled...) + queue.AheadUnshuffled = nil + } + + // Main IO loop + for { + select { + case frame, ok := <-readAudioCh(): + if ok { + outCh <- frame + nFrames++ + } else { + // Audio channel was closed -> stream is finished -> reset all stream channels + audioch = nil + errch = nil + killch = nil + + if filePlaybackDoneCh != nil { + filePlaybackDoneCh <- struct{}{} + } + filePlaybackDoneCh = nil + + fmt.Println("Audio channel closed, going to next track") + if queue.Loop { + refreshStream(0, playbackSpeed) + } else { + jumpTracks(1) + } + } + case err, ok := <-errch: + if ok { + // Propagate error + cErrCh <- err + + // Stream has closed with error -> reset all of its channels + audioch = nil + errch = nil + killch = nil + + // Try to resurrect stream (if it fails again in the + // next 5 seconds, we'll skip the track instead) + now := time.Now() + if lastStreamErr.Sub(now) > 5*time.Second { + refreshStream(getPlaybackTime(), playbackSpeed) + } else { + jumpTracks(1) + cErrCh <- errors.New("skipping stream due to multiple errors") + } + lastStreamErr = now + } else { + // Stream is done without err, but not fully read yet -> block + // all future errch reads + // Also, killch is now unnecessary + errch = nil + killch = nil + } + case cmd, ok := <-cCmdCh: + if !ok { + // cCmdCh was closed by the user -> client is told to shut down + fmt.Println("Command channel closed, killing client") + killStream() + for _, c := range callbacksKilled { + c(EventKilled{}) + } + return + } else { + switch v := cmd.(type) { + case CmdPlay: + queue.Paused = false + if audioch == nil { + jumpTracks(1) + } + case CmdPause: + queue.Paused = true + case CmdLoop: + queue.Loop = bool(v) + case CmdJump: + jumpTracks(int(v)) + case CmdSkipAll: + killStream() + if queue.Playing != nil { + queue.Done = append(queue.Done, *queue.Playing) + queue.Playing = nil + } + queue.Done = append(queue.Done, queue.Ahead...) + queue.Ahead = nil + case CmdShuffle: + shuffle() + case CmdUnshuffle: + unshuffle() + case CmdSwap: + queue.AheadUnshuffled = nil + sw := struct{ A, B int }(v) + if queue.InBounds(sw.A) && queue.InBounds(sw.B) { + replacePlaying := sw.A == 0 || sw.B == 0 + if replacePlaying { + killStream() + } + *queue.At(sw.A), *queue.At(sw.B) = *queue.At(sw.B), *queue.At(sw.A) + if replacePlaying { + refreshStream(0, playbackSpeed) + } + } + case CmdDelete: + queue.AheadUnshuffled = nil + idxs := []int(v) + + // Sort indices descendingly by absolute value so we don't + // mess the future indices up in the process of removal + sort.Slice(idxs, func(i, j int) bool { + abs := func(i int) int { + if i < 0 { + return -i + } + return i + } + return idxs[abs(j)] < idxs[abs(i)] + }) + + for _, i := range idxs { + if i < 0 { + i = len(queue.Done) + i + if i < len(queue.Done) { + queue.Done = append(queue.Done[:i], queue.Done[i+1:]...) + } + } else if i == 0 { + killStream() + queue.Playing = nil + refreshStream(0, playbackSpeed) + } else { + i -= 1 + if i < len(queue.Ahead) { + queue.Ahead = append(queue.Ahead[:i], queue.Ahead[i+1:]...) + } + } + } + case CmdAddFront: + queue.Ahead = append([]extractor.Data(v), queue.Ahead...) + queue.ShuffleOffset++ + case CmdAddBack: + queue.Ahead = append(queue.Ahead, []extractor.Data(v)...) + case CmdSeek: + if float64(v) > getPlaybackTime() && float64(v) < getMaxCachedPlaybackTime() { + fmt.Println("Quick seeking to", v) + // Seek to location in buffer + for getPlaybackTime() < float64(v) { + _, ok := <-audioch + if !ok { + break + } + nFrames++ + } + } else { + fmt.Println("Slow seeking to", v) + // Restart stream from other location (seek using ffmpeg) + refreshStream(float64(v), playbackSpeed) + } + case CmdSpeed: + refreshStream(getPlaybackTime(), float64(v)) + case CmdPlayFileAndStop: + cmd := struct { + DoneCh chan<- struct{} + Data []byte + }(v) + + audioch, errch, killch = audio.StreamToDiscordOpus(ffmpegPath, "pipe:", bytes.NewReader(cmd.Data), 0, 1.0, false) + + // Reset stream info + nFrames = 0 + tStart = 0 + playbackSpeed = 1.0 + + queue.Paused = false + queue.Loop = false + + filePlaybackDoneCh = cmd.DoneCh + case CmdGetTime: + v <- getPlaybackTime() + case CmdGetQueue: + v <- queue.Copy() + case CmdGetSpeed: + v <- playbackSpeed + } + } + } + } + }() + + return Client{CmdCh: cCmdCh, ErrCh: cErrCh} +} diff --git a/util/strings.go b/util/strings.go new file mode 100644 index 0000000..73fedbe --- /dev/null +++ b/util/strings.go @@ -0,0 +1,57 @@ +package util + +import ( + "strings" +) + +func CapitalizeFirst(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// A StringTabulator aligns columns similarly to the tabulator +// character (but 100% rigorously). +type StringTabulator [][]string + +func (t *StringTabulator) WriteRow(cols ...string) { + *t = append(*t, cols) +} + +func (t *StringTabulator) String() string { + if t == nil { + return "" + } + + // Find which row has the most columns. + longestRow := 0 + for _, row := range *t { + if len(row) > longestRow { + longestRow = len(row) + } + } + + // For each column, find the field with the longest string. + longestField := make([]int, longestRow) + for _, row := range *t { + for i, col := range row { + if len(col) > longestField[i] { + longestField[i] = len(col) + } + } + } + + // Add each line tabulated. + var res strings.Builder + for _, row := range *t { + for i, col := range row { + res.WriteString(col) + res.WriteString(strings.Repeat(" ", longestField[i]-len(col))) + } + res.WriteString("\n") + } + + // Return the result. + return res.String() +} diff --git a/util/time.go b/util/time.go new file mode 100644 index 0000000..9406441 --- /dev/null +++ b/util/time.go @@ -0,0 +1,41 @@ +package util + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +// Convert a duration specifier to seconds (e.g. 64 (seconds), 1:04, 0:1:04 etc.) +func ParseDurationSeconds(s string) (int, error) { + var secs int + sp := strings.Split(s, ":") + if len(sp) < 1 || len(sp) > 3 { + return 0, errors.New("invalid duration format") + } + magnitude := 1 + for i := len(sp) - 1; i >= 0; i-- { + n, err := strconv.Atoi(sp[i]) + if n < 0 || err != nil { + return 0, errors.New("invalid duration") + } + secs += n * magnitude + magnitude *= 60 + } + return secs, nil +} + +func FormatDurationSeconds(s int) string { + var h, m int + h = s / 3600 + s -= h * 3600 + m = s / 60 + s -= m * 60 + + var hs string + if h > 0 { + hs = fmt.Sprintf("%02d:", h) + } + return fmt.Sprintf("%s%02d:%02d", hs, m, s) +}