From 4195b20e65ba5c12498b99e35ce5bfee86ae13b1 Mon Sep 17 00:00:00 2001 From: r4 Date: Tue, 20 Sep 2022 00:54:22 +0200 Subject: [PATCH] init --- DEBIAN/conffiles | 1 + DEBIAN/control | 6 + DEBIAN/postinst | 5 + DEBIAN/prerm | 2 + LICENSE | 674 ++++++++++++++ Makefile | 57 ++ README.md | 108 +++ TODO.md | 4 + audio/audio.go | 167 ++++ audio/ogg.go | 279 ++++++ build-all.sh | 49 ++ cmd/dischord/bye.opus | Bin 0 -> 31683 bytes cmd/dischord/copyright.go | 773 +++++++++++++++++ cmd/dischord/dischord.go | 1204 ++++++++++++++++++++++++++ config/config.go | 234 +++++ config/util.go | 191 ++++ extractor/builtins/builtins.go | 7 + extractor/extractor.go | 208 +++++ extractor/extractor_test.go | 220 +++++ extractor/spotify/providers.go | 96 ++ extractor/spotify/spotify.go | 378 ++++++++ extractor/util/util.go | 59 ++ extractor/youtube/providers.go | 102 +++ extractor/youtube/util.go | 58 ++ extractor/youtube/youtube.go | 416 +++++++++ extractor/youtube/youtube_decrypt.go | 245 ++++++ extractor/ytdl/providers.go | 35 + extractor/ytdl/ytdl.go | 135 +++ go.mod | 16 + go.sum | 17 + player/player.go | 553 ++++++++++++ util/strings.go | 57 ++ util/time.go | 41 + 33 files changed, 6397 insertions(+) create mode 100644 DEBIAN/conffiles create mode 100644 DEBIAN/control create mode 100755 DEBIAN/postinst create mode 100755 DEBIAN/prerm create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 TODO.md create mode 100644 audio/audio.go create mode 100644 audio/ogg.go create mode 100755 build-all.sh create mode 100644 cmd/dischord/bye.opus create mode 100644 cmd/dischord/copyright.go create mode 100644 cmd/dischord/dischord.go create mode 100644 config/config.go create mode 100644 config/util.go create mode 100644 extractor/builtins/builtins.go create mode 100644 extractor/extractor.go create mode 100644 extractor/extractor_test.go create mode 100644 extractor/spotify/providers.go create mode 100644 extractor/spotify/spotify.go create mode 100644 extractor/util/util.go create mode 100644 extractor/youtube/providers.go create mode 100644 extractor/youtube/util.go create mode 100644 extractor/youtube/youtube.go create mode 100644 extractor/youtube/youtube_decrypt.go create mode 100644 extractor/ytdl/providers.go create mode 100644 extractor/ytdl/ytdl.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 player/player.go create mode 100644 util/strings.go create mode 100644 util/time.go 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 0000000000000000000000000000000000000000..2c4d4dace95cd76b9c358e20e352b47026d9dcb6 GIT binary patch literal 31683 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 p5=2U9j2PZDJH8gJOtLhPoi$yD + 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) +}