commit ec08d487e12afd413137b622ee25b86e5d036e9c Author: DiheChen Date: Fri Oct 30 01:32:19 2026 +0800 feat: add backend, database, and protocol foundations diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /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/README.md b/README.md new file mode 100644 index 0000000..6c6c895 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Alisten + +在线音乐共享房间,支持实时同步播放、共享队列、多音源和管理后台。 diff --git a/cmd/alisten/main.go b/cmd/alisten/main.go new file mode 100644 index 0000000..ad063c3 --- /dev/null +++ b/cmd/alisten/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/dihemoe/alisten/internal/bootstrap" +) + +func main() { + os.Exit(bootstrap.Main(os.Args[1:])) +} diff --git a/cmd/protocolgen/godecode.go b/cmd/protocolgen/godecode.go new file mode 100644 index 0000000..b971301 --- /dev/null +++ b/cmd/protocolgen/godecode.go @@ -0,0 +1,173 @@ +package main + +import ( + "bytes" + "fmt" + "go/format" + "maps" + "slices" + "strings" +) + +type goPattern struct { + name, expr string +} + +type goDecoder struct { + b bytes.Buffer + patterns []goPattern +} + +func goDecode(d *idl) ([]byte, error) { + var g goDecoder + g.registry("decodeConnectionRequest", d.WS.Requests) + g.registry("decodeRoomRequest", d.Requests) + for _, e := range d.HTTP { + if e.Request != nil { + name := e.Request.name + fmt.Fprintf(&g.b, "\nfunc Decode%s(body []byte) (%s, error) {\nreturn decode%s(body, \"\")\n}\n", name, name, name) + } + } + for _, name := range d.typeNames() { + if !d.inbound[name] { + continue + } + if s := d.named[name]; s.Enum != nil { + g.enum(name, s) + } else { + g.object(name, s) + } + } + + var b bytes.Buffer + b.WriteString(goHeader) + if len(g.patterns) == 0 { + b.WriteString("\nimport \"encoding/json\"\n") + } else { + b.WriteString("\nimport (\n\"encoding/json\"\n\"regexp\"\n)\n\nvar (\n") + for _, p := range g.patterns { + fmt.Fprintf(&b, "%s = regexp.MustCompile(%s)\n", p.name, goRawString(p.expr)) + } + b.WriteString(")\n") + } + b.Write(g.b.Bytes()) + return format.Source(b.Bytes()) +} + +func (g *goDecoder) registry(fn string, messages []*message) { + fmt.Fprintf(&g.b, "\nfunc %s(typ string, raw json.RawMessage, path string) (any, bool, error) {\nswitch typ {\n", fn) + for _, m := range messages { + fmt.Fprintf(&g.b, "case Request%s:\nv, err := decode%s(raw, path)\nreturn v, true, err\n", pascal(m.Type), m.Request.name) + } + g.b.WriteString("}\nreturn nil, false, nil\n}\n") +} + +func (g *goDecoder) enum(name string, s *schema) { + consts := make([]string, len(s.Enum)) + for i, v := range s.Enum { + consts[i] = name + pascal(v) + } + reason := "取值应为 " + strings.Join(s.Enum, "、") + " 之一" + fmt.Fprintf(&g.b, "\nfunc decode%s(raw json.RawMessage, path string) (%s, error) {\ns, err := decodeString(raw, path)\nif err != nil {\nreturn \"\", err\n}\nswitch v := %s(s); v {\ncase %s:\nreturn v, nil\n}\nreturn \"\", invalid(path, %q)\n}\n", + name, name, name, strings.Join(consts, ", "), reason) +} + +func (g *goDecoder) object(name string, s *schema) { + fmt.Fprintf(&g.b, "\nfunc decode%s(raw json.RawMessage, path string) (%s, error) {\nvar v %s\n", name, name, name) + fields := slices.Sorted(maps.Keys(s.Properties)) + if len(fields) == 0 { + g.b.WriteString("_, err := decodeObject(raw, path)\nreturn v, err\n}\n") + return + } + fmt.Fprintf(&g.b, "m, err := decodeObject(raw, path, %s)\nif err != nil {\nreturn v, err\n}\n", goStrings(fields)) + if len(s.Required) > 0 { + fmt.Fprintf(&g.b, "if err = requireFields(m, path, %s); err != nil {\nreturn v, err\n}\n", goStrings(slices.Sorted(slices.Values(s.Required)))) + } + if s.OneOf != nil { + groups := make([]string, len(s.OneOf)) + reasons := make([]string, len(s.OneOf)) + for i, alt := range s.OneOf { + groups[i] = "[]string{" + goStrings(alt.Required) + "}" + reasons[i] = strings.Join(alt.Required, "、") + } + optional := "nil" + if names := optionalFields(s.OneOf[0]); len(names) > 0 { + optional = "[]string{" + goStrings(names) + "}" + } + fmt.Fprintf(&g.b, "if err = checkOneOf(m, path, %q, %s, %s); err != nil {\nreturn v, err\n}\n", + "只能提供以下字段组合之一:"+strings.Join(reasons, ";"), optional, strings.Join(groups, ", ")) + } + for _, f := range fields { + p := s.Properties[f] + at := fmt.Sprintf("field(path, %q)", f) + decode := "decodeString" + switch { + case p.Ref != "": + decode = "decode" + refName(p.Ref) + case p.Type == "integer": + decode = "decodeInteger" + } + if slices.Contains(s.Required, f) { + fmt.Fprintf(&g.b, "if v.%s, err = %s(m[%q], %s); err != nil {\nreturn v, err\n}\n", pascal(f), decode, f, at) + g.checks("v."+pascal(f), at, p, name+pascal(f)) + continue + } + fmt.Fprintf(&g.b, "if r, ok := m[%q]; ok {\nx, err := %s(r, %s)\nif err != nil {\nreturn v, err\n}\n", f, decode, at) + g.checks("x", at, p, name+pascal(f)) + fmt.Fprintf(&g.b, "v.%s = &x\n}\n", pascal(f)) + } + for _, entry := range s.AllOf { + cond := entry.If.Required[0] + value := *entry.If.Properties[cond].Const + fmt.Fprintf(&g.b, "if v.%s == %s%s {\n", pascal(cond), refName(s.Properties[cond].Ref), pascal(value)) + for _, f := range slices.Sorted(maps.Keys(entry.Then.Properties)) { + g.checks("v."+pascal(f), fmt.Sprintf("field(path, %q)", f), entry.Then.Properties[f], name+pascal(f)+"When"+pascal(value)) + } + g.b.WriteString("}\n") + } + g.b.WriteString("return v, nil\n}\n") +} + +func (g *goDecoder) checks(value, at string, s *schema, patternName string) { + check := func(fn, limit string) { + fmt.Fprintf(&g.b, "if err = %s(%s, %s, %s); err != nil {\nreturn v, err\n}\n", fn, value, at, limit) + } + if s.MinLength != nil { + check("checkMinLength", fmt.Sprint(*s.MinLength)) + } + if s.MaxLength != nil { + check("checkMaxLength", fmt.Sprint(*s.MaxLength)) + } + if s.MaxBytes != nil { + check("checkMaxBytes", fmt.Sprint(*s.MaxBytes)) + } + if s.URL != nil { + fmt.Fprintf(&g.b, "if err = checkURL(%s, %s); err != nil {\nreturn v, err\n}\n", value, at) + } + if s.Pattern != "" { + name := "pattern" + patternName + g.patterns = append(g.patterns, goPattern{name: name, expr: s.Pattern}) + check("checkPattern", name) + } + if s.Minimum != nil { + check("checkMinimum", fmt.Sprint(*s.Minimum)) + } + if s.Maximum != nil { + check("checkMaximum", fmt.Sprint(*s.Maximum)) + } +} + +func goStrings(values []string) string { + quoted := make([]string, len(values)) + for i, v := range values { + quoted[i] = fmt.Sprintf("%q", v) + } + return strings.Join(quoted, ", ") +} + +func goRawString(s string) string { + if strings.Contains(s, "`") { + return fmt.Sprintf("%q", s) + } + return "`" + s + "`" +} diff --git a/cmd/protocolgen/gotypes.go b/cmd/protocolgen/gotypes.go new file mode 100644 index 0000000..cda060c --- /dev/null +++ b/cmd/protocolgen/gotypes.go @@ -0,0 +1,199 @@ +package main + +import ( + "bytes" + "fmt" + "go/format" + "maps" + "slices" + "strings" +) + +const goHeader = "// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.\n\npackage protocol\n" + +func goTypes(d *idl) ([]byte, error) { + var body bytes.Buffer + fmt.Fprintf(&body, "\nconst Version = %q\n", d.version) + marshal := false + for _, name := range d.typeNames() { + s := d.named[name] + if s.Enum != nil { + writeGoEnum(&body, name, s) + continue + } + writeGoStruct(&body, name, s) + marshal = writeGoMarshal(&body, name, s) || marshal + } + body.WriteString("\ntype ErrorResponse struct {\nError ErrorInfo `json:\"error\"`\n}\n") + writeGoMessageTypes(&body, d) + writeGoCloseCodes(&body, d.WS.CloseCodes) + writeGoRoutes(&body, d.HTTP) + writeGoRoomRoutes(&body, d) + writeGoHTTPStatus(&body, d) + + var b bytes.Buffer + b.WriteString(goHeader) + if marshal { + b.WriteString("\nimport \"encoding/json\"\n") + } + b.Write(body.Bytes()) + return format.Source(b.Bytes()) +} + +func writeGoEnum(b *bytes.Buffer, name string, s *schema) { + fmt.Fprintf(b, "\ntype %s string\n\nconst (\n", name) + for _, v := range s.Enum { + fmt.Fprintf(b, "%s%s %s = %q\n", name, pascal(v), name, v) + } + b.WriteString(")\n") +} + +func writeGoStruct(b *bytes.Buffer, name string, s *schema) { + if len(s.Properties) == 0 { + fmt.Fprintf(b, "\ntype %s struct{}\n", name) + return + } + fmt.Fprintf(b, "\ntype %s struct {\n", name) + for _, p := range slices.Sorted(maps.Keys(s.Properties)) { + required := slices.Contains(s.Required, p) + tag := p + if !required { + tag += ",omitempty" + } + fmt.Fprintf(b, "%s %s `json:%q`\n", pascal(p), goFieldType(s.Properties[p], required), tag) + } + b.WriteString("}\n") +} + +func goFieldType(s *schema, required bool) string { + switch { + case s.AnyOf != nil: + return "*" + goValueType(s.AnyOf[0]) + case s.Type == "array": + return "[]" + goValueType(s.Items) + case !required: + return "*" + goValueType(s) + default: + return goValueType(s) + } +} + +func goValueType(s *schema) string { + if s.Ref != "" { + return refName(s.Ref) + } + return map[string]string{"string": "string", "integer": "int64", "number": "float64", "boolean": "bool"}[s.Type] +} + +func writeGoMarshal(b *bytes.Buffer, name string, s *schema) bool { + var arrays []string + for _, p := range slices.Sorted(maps.Keys(s.Properties)) { + if s.Properties[p].Type == "array" { + arrays = append(arrays, p) + } + } + if len(arrays) == 0 { + return false + } + fmt.Fprintf(b, "\nfunc (v %s) MarshalJSON() ([]byte, error) {\ntype plain %s\n", name, name) + for _, p := range arrays { + fmt.Fprintf(b, "if v.%s == nil {\nv.%s = []%s{}\n}\n", pascal(p), pascal(p), goValueType(s.Properties[p].Items)) + } + b.WriteString("return json.Marshal(plain(v))\n}\n") + return true +} + +func writeGoMessageTypes(b *bytes.Buffer, d *idl) { + b.WriteString("\nconst (\n") + for _, m := range slices.Concat(d.WS.Requests, d.Requests) { + fmt.Fprintf(b, "Request%s = %q\n", pascal(m.Type), m.Type) + } + b.WriteString(")\n\nconst (\n") + for _, e := range slices.Concat(d.WS.Events, d.Events) { + fmt.Fprintf(b, "Event%s = %q\n", pascal(e.Type), e.Type) + } + b.WriteString(")\n") +} + +func writeGoCloseCodes(b *bytes.Buffer, codes []*closeCode) { + if len(codes) == 0 { + return + } + b.WriteString("\nconst (\n") + for _, c := range codes { + fmt.Fprintf(b, "Close%s = %d\n", pascal(c.Name), c.Code) + } + b.WriteString(")\n") +} + +func writeGoRoutes(b *bytes.Buffer, endpoints []*endpoint) { + if len(endpoints) == 0 { + return + } + b.WriteString("\nvar (\n") + for _, e := range endpoints { + fmt.Fprintf(b, "Route%s = Route{Pattern: %q, Auth: Auth%s, Status: %d%s}\n", pascal(e.Name), e.Method+" "+e.Path, pascal(e.Auth), e.status(), goLocation(e.Location)) + } + b.WriteString(")\n") +} + +func goLocation(location bool) string { + if location { + return ", Location: true" + } + return "" +} + +func writeGoRoomRoutes(b *bytes.Buffer, d *idl) { + if len(d.Requests) == 0 { + return + } + b.WriteString("\nvar RoomRoutes = []RoomRoute{\n") + for _, m := range d.Requests { + h := m.HTTP + fmt.Fprintf(b, "{Route: Route{Pattern: %q, Auth: AuthTicket, Status: %d%s}, Type: Request%s", h.Method+" "+h.Path, h.status(), goLocation(h.Location), pascal(m.Type)) + params := pathParams(h.Path) + if len(params) > 0 { + fmt.Fprintf(b, ", params: []string{%s}", goStrings(params)) + } + if h.Method != "GET" && h.Method != "DELETE" { + b.WriteString(", body: true") + } else if query := d.queryKinds(m.Request, params); len(query) > 0 { + fmt.Fprintf(b, ", query: map[string]queryKind{%s}", strings.Join(query, ", ")) + } + b.WriteString("},\n") + } + b.WriteString("}\n") +} + +func (d *idl) queryKinds(s *schema, params []string) []string { + var kinds []string + for _, name := range slices.Sorted(maps.Keys(s.Properties)) { + if slices.Contains(params, name) { + continue + } + kind := "queryString" + if d.jsonType(s.Properties[name]) == "integer" { + kind = "queryInteger" + } + kinds = append(kinds, fmt.Sprintf("%q: %s", name, kind)) + } + return kinds +} + +func writeGoHTTPStatus(b *bytes.Buffer, d *idl) { + for _, name := range d.typeNames() { + s := d.named[name] + if s.HTTPStatus == nil { + continue + } + table := lowerFirst(name) + "HTTPStatus" + fmt.Fprintf(b, "\nvar %s = map[%s]int{\n", table, name) + for _, v := range s.Enum { + if status, ok := s.HTTPStatus[v]; ok { + fmt.Fprintf(b, "%s%s: %d,\n", name, pascal(v), status) + } + } + fmt.Fprintf(b, "}\n\n// IDL 没有为某个取值指定 HTTP 状态码时返回 0。\nfunc (v %s) HTTPStatus() int {\nreturn %s[v]\n}\n", name, table) + } +} diff --git a/cmd/protocolgen/idl.go b/cmd/protocolgen/idl.go new file mode 100644 index 0000000..d59ddd9 --- /dev/null +++ b/cmd/protocolgen/idl.go @@ -0,0 +1,1081 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "maps" + "path/filepath" + "reflect" + "regexp" + "slices" + "strings" +) + +type idl struct { + Schema string `json:"$schema"` + Title string `json:"title"` + Defs map[string]*schema `json:"$defs"` + WS wsSection `json:"x-ws"` + Requests []*message `json:"x-requests"` + Events []*event `json:"x-events"` + HTTP []*endpoint `json:"x-http"` + + version string + named map[string]*schema + inbound map[string]bool + goNames map[string]string +} + +type wsSection struct { + Requests []*message `json:"requests"` + Events []*event `json:"events"` + CloseCodes []*closeCode `json:"closeCodes"` +} + +type message struct { + Type string `json:"type"` + Description string `json:"description"` + HTTP *httpBinding `json:"http"` + Request *schema `json:"request"` + Response *schema `json:"response"` +} + +type event struct { + Type string `json:"type"` + Description string `json:"description"` + Data *schema `json:"data"` +} + +type endpoint struct { + Name string `json:"name"` + Description string `json:"description"` + httpBinding + Auth string `json:"auth"` + Request *schema `json:"request"` + Response *schema `json:"response"` +} + +type httpBinding struct { + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` + Location bool `json:"location"` +} + +func (b httpBinding) status() int { + if b.Status == 0 { + return 200 + } + return b.Status +} + +func (b httpBinding) check(where string) error { + if !slices.Contains([]string{"GET", "POST", "PUT", "PATCH", "DELETE"}, b.Method) || !pathPattern.MatchString(b.Path) { + return fmt.Errorf("%s:method %q 或 path %q 不合法", where, b.Method, b.Path) + } + if b.Status != 0 && b.Status != 201 && b.Status != 204 { + return fmt.Errorf("%s:status 只能是 201 或 204,缺省为 200", where) + } + if b.Location && b.Status != 201 { + return fmt.Errorf("%s:location 只能用于 201 的接口", where) + } + return nil +} + +type closeCode struct { + Name string `json:"name"` + Code int `json:"code"` + Description string `json:"description"` +} + +// 生成 OpenAPI 时会把 schema 原样编码回 JSON,所以每个字段都要带 omitempty。 +type schema struct { + Ref string `json:"$ref,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + Const *string `json:"const,omitempty"` + Properties map[string]*schema `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + AdditionalProperties *bool `json:"additionalProperties,omitempty"` + Items *schema `json:"items,omitempty"` + AnyOf []*schema `json:"anyOf,omitempty"` + OneOf []*schema `json:"oneOf,omitempty"` + AllOf []*schema `json:"allOf,omitempty"` + If *schema `json:"if,omitempty"` + Then *schema `json:"then,omitempty"` + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Minimum *int64 `json:"minimum,omitempty"` + Maximum *int64 `json:"maximum,omitempty"` + Pattern string `json:"pattern,omitempty"` + MaxBytes *int `json:"x-maxBytes,omitempty"` + URL *bool `json:"x-url,omitempty"` + HTTPStatus map[string]int `json:"x-httpStatus,omitempty"` + + name string +} + +func parseIDL(data []byte) (*idl, error) { + if err := checkJSON(json.NewDecoder(bytes.NewReader(data)), "", false); err != nil { + return nil, fmt.Errorf("解析 IDL:%w", err) + } + var d idl + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&d); err != nil { + return nil, fmt.Errorf("解析 IDL:%w", err) + } + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return nil, errors.New("解析 IDL:JSON 之后还有多余的内容") + } + version, err := protocolVersion(data) + if err != nil { + return nil, err + } + d.version = version + if err := d.check(); err != nil { + return nil, err + } + return &d, nil +} + +// encoding/json 对重复的键只保留最后一个值,并把显式的 null 解码为 nil,而之后的检查和生成都假定节点存在,所以解码之前先拒绝这两种写法。 +// encoding/json 匹配结构体字段时不区分大小写,会把 "MAXLENGTH" 当作 maxLength,所以关键字只接受规范的写法。 +func checkJSON(dec *json.Decoder, where string, named bool) error { + tok, err := dec.Token() + if err != nil { + return err + } + switch tok { + case nil: + return fmt.Errorf("%s:不能是 null", jsonPath(where)) + case json.Delim('{'): + var keys []string + for dec.More() { + tok, err := dec.Token() + if err != nil { + return err + } + key := tok.(string) + if slices.Contains(keys, key) { + return fmt.Errorf("%s:键 %q 重复", jsonPath(where), key) + } + if i := slices.IndexFunc(idlKeywords, func(k string) bool { return strings.EqualFold(k, key) }); !named && i >= 0 && idlKeywords[i] != key { + return fmt.Errorf("%s:键 %q 应写作 %q", jsonPath(where), key, idlKeywords[i]) + } + keys = append(keys, key) + if err := checkJSON(dec, strings.TrimPrefix(where+"."+key, "."), !named && slices.Contains(nameKeyed, key)); err != nil { + return err + } + } + _, err = dec.Token() + return err + case json.Delim('['): + for i := 0; dec.More(); i++ { + if err := checkJSON(dec, fmt.Sprintf("%s[%d]", where, i), false); err != nil { + return err + } + } + _, err = dec.Token() + return err + } + return nil +} + +// 值为对象、键由 IDL 作者命名的字段(类型名、属性名、状态码)。新增这类字段时要加入这个列表,否则与关键字只有大小写不同的名字会被拒绝。 +var nameKeyed = []string{"$defs", "properties", "x-httpStatus"} + +var idlKeywords = jsonNames(idl{}, wsSection{}, message{}, event{}, endpoint{}, httpBinding{}, closeCode{}, schema{}) + +func jsonNames(values ...any) []string { + var names []string + for _, v := range values { + t := reflect.TypeOf(v) + for i := range t.NumField() { + if name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ","); name != "" { + names = append(names, name) + } + } + } + return names +} + +func jsonPath(where string) string { + if where == "" { + return "顶层" + } + return where +} + +// 重新编码会按键排序并去掉空白,所以版本只随 IDL 的内容变化,不受格式影响。 +func protocolVersion(data []byte) (string, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return "", fmt.Errorf("解析 IDL:%w", err) + } + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return "", err + } + sum := sha256.Sum256(bytes.TrimSuffix(b.Bytes(), []byte("\n"))) + return hex.EncodeToString(sum[:])[:16], nil +} + +const pathParam = `\{([a-z][A-Za-z0-9]*)\}` + +var ( + typeNamePattern = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) + enumValuePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) + fieldNamePattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`) + messageTypePattern = regexp.MustCompile(`^[a-z][A-Za-z]*(\.[a-z][A-Za-z]*)*$`) + pathPattern = regexp.MustCompile(`^/api(/[a-z][a-z0-9.-]*|/` + pathParam + `)+$`) + pathParamPattern = regexp.MustCompile(pathParam) +) + +func pathParams(path string) []string { + var params []string + for _, m := range pathParamPattern.FindAllStringSubmatch(path, -1) { + params = append(params, m[1]) + } + return params +} + +// 生成的 TypeScript client 中,路径参数与这些名字是同一个函数中的标识符,不能重名。 +var tsClientNames = []string{"body", "send", "encodeURIComponent"} + +var tsFixedNames = []string{ + "PROTOCOL_VERSION", "RequestDataMap", "ResponseDataMap", "ConnectionEventDataMap", "RoomEventDataMap", "RequestType", + "ConnectionEventType", "RoomEventType", "RequestMessage", "ResponseMessage", "ConnectionEventMessage", "RoomEventMessage", + "ServerMessage", "CloseCode", "Send", "roomRequests", "ErrorResponse", "Auth", "HttpRequest", "HttpSend", "httpClient", +} + +// ES 模块总是严格模式,所以列表包含严格模式的保留字,以及 eval 和 arguments。 +var jsReservedWords = []string{ + "arguments", "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", + "else", "enum", "eval", "export", "extends", "false", "finally", "for", "function", "if", "implements", "import", "in", + "instanceof", "interface", "let", "new", "null", "package", "private", "protected", "public", "return", "static", "super", + "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield", +} + +func (d *idl) check() error { + if d.Title == "" { + return errors.New("IDL 缺少 title,它是 OpenAPI 文档的标题") + } + d.named = map[string]*schema{} + d.inbound = map[string]bool{} + for _, name := range slices.Sorted(maps.Keys(d.Defs)) { + if !typeNamePattern.MatchString(name) { + return fmt.Errorf("$defs.%s:类型名应为大写字母开头的驼峰式", name) + } + if err := d.checkDef("$defs."+name, d.Defs[name]); err != nil { + return err + } + d.Defs[name].name = name + if !d.Defs[name].scalar() { + d.named[name] = d.Defs[name] + } + } + for _, name := range []string{"Empty", "ErrorInfo"} { + if s := d.Defs[name]; s == nil || s.Type != "object" { + return fmt.Errorf("$defs 缺少对象类型 %s,生成的消息信封要用到它", name) + } + } + + requests := map[string]bool{} + for i, m := range d.WS.Requests { + where := fmt.Sprintf("x-ws.requests[%d]", i) + if m.HTTP != nil { + return fmt.Errorf("%s:只在 WebSocket 上使用的请求没有 http", where) + } + if err := d.checkMessage(where, m, requests); err != nil { + return err + } + } + for i, m := range d.Requests { + if err := d.checkMessage(fmt.Sprintf("x-requests[%d]", i), m, requests); err != nil { + return err + } + } + events := map[string]bool{} + for i, e := range d.WS.Events { + if err := d.checkEvent(fmt.Sprintf("x-ws.events[%d]", i), e, events); err != nil { + return err + } + } + if !slices.ContainsFunc(d.WS.Events, func(e *event) bool { return e.Type == "error" }) { + return errors.New("x-ws.events 缺少 error 事件,生成的消息信封要用到它") + } + for i, e := range d.Events { + if err := d.checkEvent(fmt.Sprintf("x-events[%d]", i), e, events); err != nil { + return err + } + } + // 房间的事件流由 api 适配器手写,不在 IDL 中。 + names := endpointNames{operations: map[string]bool{}, constants: map[string]bool{}, routes: map[string]string{"GET /api/room/events": "房间的事件流"}, paths: map[string]string{}} + for i, m := range d.Requests { + names.operations[camel(m.Type)] = true + if err := d.checkRoomRoute(fmt.Sprintf("x-requests[%d]", i), m, names); err != nil { + return err + } + } + for i, e := range d.HTTP { + if err := d.checkEndpoint(fmt.Sprintf("x-http[%d]", i), e, names); err != nil { + return err + } + } + if err := checkCloseCodes(d.WS.CloseCodes); err != nil { + return err + } + for _, name := range d.typeNames() { + if d.inbound[name] && d.Defs[name] == nil { + if err := d.markInbound(d.named[name]); err != nil { + return err + } + } + } + d.inlineScalars() + if err := d.checkGoNames(); err != nil { + return err + } + for _, name := range d.typeNames() { + if slices.Contains(tsFixedNames, name) { + return fmt.Errorf("生成的 TypeScript 标识符 %s 重复:既是生成器写出的 %s,也是类型 %s", name, name, name) + } + } + return nil +} + +func (d *idl) checkDef(where string, s *schema) error { + switch { + case s.Type == "object": + return d.checkObject(where, s, false) + case s.Enum == nil: + if s.Type != "string" && s.Type != "integer" { + return fmt.Errorf(`%s:类型应为对象、字符串枚举、"string" 或 "integer"`, where) + } + return checkScalar(where, s, true) + } + if err := allowOnly(where, s, "type", "description", "enum", "x-httpStatus"); err != nil { + return err + } + if s.Type != "string" || len(s.Enum) == 0 { + return fmt.Errorf("%s:枚举应为非空的字符串枚举", where) + } + names := map[string]bool{} + for _, v := range s.Enum { + if !enumValuePattern.MatchString(v) || names[pascal(v)] { + return fmt.Errorf("%s:枚举值 %q 应由字母、数字和下划线组成,且不能与其他值生成相同的常量名", where, v) + } + names[pascal(v)] = true + } + for _, code := range slices.Sorted(maps.Keys(s.HTTPStatus)) { + if status := s.HTTPStatus[code]; !slices.Contains(s.Enum, code) || status < 100 || status > 599 { + return fmt.Errorf("%s:x-httpStatus 中的 %s: %d 不是枚举值或不是 HTTP 状态码", where, code, status) + } + } + return nil +} + +func (d *idl) checkObject(where string, s *schema, inbound bool) error { + if err := allowOnly(where, s, "type", "description", "properties", "required", "additionalProperties", "oneOf", "allOf"); err != nil { + return err + } + if s.Type != "object" || s.AdditionalProperties == nil || *s.AdditionalProperties { + return fmt.Errorf(`%s:对象应写明 "type": "object" 和 "additionalProperties": false`, where) + } + for _, name := range s.Required { + if s.Properties[name] == nil { + return fmt.Errorf("%s:required 中的 %s 不在 properties 里", where, name) + } + } + goNames := map[string]string{} + for _, name := range slices.Sorted(maps.Keys(s.Properties)) { + if !fieldNamePattern.MatchString(name) { + return fmt.Errorf("%s.properties.%s:字段名应为小写字母开头的驼峰式", where, name) + } + if other, ok := goNames[pascal(name)]; ok { + return fmt.Errorf("%s.properties:%s 和 %s 生成相同的 Go 字段名 %s", where, other, name, pascal(name)) + } + goNames[pascal(name)] = name + if err := d.checkField(where+".properties."+name, s.Properties[name], slices.Contains(s.Required, name), inbound); err != nil { + return err + } + } + if s.OneOf != nil { + if err := checkAlternatives(where, s); err != nil { + return err + } + } + if s.AllOf != nil { + return d.checkConditions(where, s) + } + return nil +} + +func (d *idl) checkField(where string, s *schema, required, inbound bool) error { + switch { + case s.AnyOf != nil: + if inbound || !required { + return fmt.Errorf("%s:可以为 null 的字段只能出现在响应和事件中,并且必须是必填字段", where) + } + if err := allowOnly(where, s, "anyOf", "description"); err != nil { + return err + } + if len(s.AnyOf) != 2 || s.AnyOf[1].Type != "null" || len(s.AnyOf[1].keywords()) != 1 { + return fmt.Errorf(`%s:anyOf 只支持 [类型, {"type": "null"}]`, where) + } + return d.checkValue(where+".anyOf[0]", s.AnyOf[0], inbound) + case s.Type == "array": + if inbound || !required { + return fmt.Errorf("%s:数组只能出现在响应和事件中,并且必须是必填字段", where) + } + if err := allowOnly(where, s, "type", "description", "items"); err != nil { + return err + } + if s.Items == nil { + return fmt.Errorf("%s:数组缺少 items", where) + } + return d.checkValue(where+".items", s.Items, inbound) + default: + return d.checkValue(where, s, inbound) + } +} + +func (d *idl) checkValue(where string, s *schema, inbound bool) error { + if s.Ref != "" { + if err := allowOnly(where, s, "$ref", "description"); err != nil { + return err + } + _, err := d.resolve(where, s.Ref) + return err + } + switch s.Type { + case "string", "integer": + return checkScalar(where, s, false) + case "number", "boolean": + if inbound { + return fmt.Errorf("%s:请求中不支持 %s", where, s.Type) + } + return allowOnly(where, s, "type", "description") + default: + return fmt.Errorf("%s:不支持的类型 %q", where, s.Type) + } +} + +// x-url 只能写在 $defs 的标量类型中(def 为真),因为网页按标量类型生成同名的校验函数。 +func checkScalar(where string, s *schema, def bool) error { + if s.Type == "integer" { + if err := allowOnly(where, s, "type", "description", "minimum", "maximum"); err != nil { + return err + } + return checkBounds(where, s) + } + allowed := []string{"type", "description", "minLength", "maxLength", "pattern", "x-maxBytes"} + if def { + allowed = append(allowed, "x-url") + } + if err := allowOnly(where, s, allowed...); err != nil { + return err + } + // 网页生成的校验函数不检查 pattern,因为 JavaScript 和 Go 的正则表达式语法不同。 + if s.URL != nil && (!*s.URL || s.Pattern != "") { + return fmt.Errorf("%s:x-url 只能是 true,并且不能与 pattern 同时使用", where) + } + if err := checkBounds(where, s); err != nil { + return err + } + return checkPattern(where, s.Pattern) +} + +func checkBounds(where string, s *schema) error { + for _, n := range []*int{s.MinLength, s.MaxLength, s.MaxBytes} { + if n != nil && *n < 0 { + return fmt.Errorf("%s:minLength、maxLength 和 x-maxBytes 不能是负数", where) + } + } + if s.MinLength != nil && s.MaxLength != nil && *s.MinLength > *s.MaxLength { + return fmt.Errorf("%s:minLength 大于 maxLength", where) + } + if s.Minimum != nil && s.Maximum != nil && *s.Minimum > *s.Maximum { + return fmt.Errorf("%s:minimum 大于 maximum", where) + } + return nil +} + +func checkPattern(where, pattern string) error { + if pattern == "" { + return nil + } + if _, err := regexp.Compile(pattern); err != nil { + return fmt.Errorf("%s:pattern 无法编译:%w", where, err) + } + return nil +} + +func (d *idl) resolve(where, ref string) (*schema, error) { + name, ok := strings.CutPrefix(ref, "#/$defs/") + if !ok || d.Defs[name] == nil { + return nil, fmt.Errorf("%s:引用的类型 %s 不存在", where, ref) + } + return d.Defs[name], nil +} + +// 分支的 properties 中不在 required 里的字段是各分支共用的可选字段:每个分支都要列出同样的一组,解码时不参与匹配分支。 +func checkAlternatives(where string, s *schema) error { + if len(s.OneOf) < 2 { + return fmt.Errorf("%s:oneOf 至少要有两个分支", where) + } + shared := optionalFields(s.OneOf[0]) + for _, name := range slices.Sorted(maps.Keys(s.Properties)) { + if !slices.Contains(shared, name) && !slices.ContainsFunc(s.OneOf, func(alt *schema) bool { return slices.Contains(alt.Required, name) }) { + return fmt.Errorf("%s:%s 不属于 oneOf 的任何分支", where, name) + } + } + for i, alt := range s.OneOf { + w := fmt.Sprintf("%s.oneOf[%d]", where, i) + if err := allowOnly(w, alt, "properties", "required", "additionalProperties"); err != nil { + return err + } + if alt.AdditionalProperties == nil || *alt.AdditionalProperties || len(alt.Required) == 0 { + return fmt.Errorf(`%s:分支应由 required、properties 和 "additionalProperties": false 组成,required 不能为空`, w) + } + for j, name := range alt.Required { + if alt.Properties[name] == nil { + return fmt.Errorf("%s:required 中的 %s 不在 properties 里", w, name) + } + if slices.Contains(alt.Required[:j], name) { + return fmt.Errorf("%s:required 中的 %s 重复", w, name) + } + } + if optional := optionalFields(alt); !slices.Equal(optional, shared) { + return fmt.Errorf("%s:可选字段应与第一个分支相同(%s),实际为(%s)", w, strings.Join(shared, "、"), strings.Join(optional, "、")) + } + for _, name := range slices.Sorted(maps.Keys(alt.Properties)) { + if len(alt.Properties[name].keywords()) != 0 { + return fmt.Errorf("%s:properties 中的 %s 应为 {}", w, name) + } + if s.Properties[name] == nil || slices.Contains(s.Required, name) { + return fmt.Errorf("%s:%s 应是外层对象的可选字段", w, name) + } + } + } + return nil +} + +// 按名称排序。 +func optionalFields(alt *schema) []string { + var names []string + for _, name := range slices.Sorted(maps.Keys(alt.Properties)) { + if !slices.Contains(alt.Required, name) { + names = append(names, name) + } + } + return names +} + +func (d *idl) checkConditions(where string, s *schema) error { + if len(s.AllOf) == 0 { + return fmt.Errorf("%s:allOf 至少要有一个条件", where) + } + seen := map[string]int{} + for i, entry := range s.AllOf { + w := fmt.Sprintf("%s.allOf[%d]", where, i) + if err := allowOnly(w, entry, "if", "then"); err != nil { + return err + } + if entry.If == nil || entry.Then == nil { + return fmt.Errorf("%s:要同时包含 if 和 then", w) + } + if err := d.checkCondition(w, s, entry.If, entry.Then); err != nil { + return err + } + name := entry.If.Required[0] + key := name + "\x00" + *entry.If.Properties[name].Const + if first, ok := seen[key]; ok { + return fmt.Errorf("%s:与 allOf[%d] 重复,同一个字段的同一个取值只能有一个条件", w, first) + } + seen[key] = i + } + return nil +} + +func (d *idl) checkCondition(where string, s *schema, cond, then *schema) error { + if err := allowOnly(where+".if", cond, "properties", "required"); err != nil { + return err + } + if len(cond.Properties) != 1 || len(cond.Required) != 1 || cond.Properties[cond.Required[0]] == nil { + return fmt.Errorf("%s.if:应写成 {\"properties\": {字段: {\"const\": 值}}, \"required\": [字段]}", where) + } + name := cond.Required[0] + c := cond.Properties[name] + if err := allowOnly(where+".if.properties."+name, c, "const"); err != nil { + return err + } + p := s.Properties[name] + if c.Const == nil || p == nil || p.Ref == "" || !slices.Contains(s.Required, name) { + return fmt.Errorf("%s.if:只支持按必填的枚举字段取值判断", where) + } + if !slices.Contains(d.Defs[refName(p.Ref)].Enum, *c.Const) { + return fmt.Errorf("%s.if:%q 不是 %s 的取值", where, *c.Const, p.Ref) + } + if err := allowOnly(where+".then", then, "properties"); err != nil { + return err + } + for _, target := range slices.Sorted(maps.Keys(then.Properties)) { + w := where + ".then.properties." + target + if p := s.Properties[target]; p == nil || p.Type != "string" || !slices.Contains(s.Required, target) { + return fmt.Errorf("%s:then 只能约束必填的字符串字段", w) + } + c := then.Properties[target] + if c.Ref != "" { + if err := allowOnly(w, c, "$ref"); err != nil { + return err + } + if def, err := d.resolve(w, c.Ref); err != nil { + return err + } else if !def.scalar() || def.Type != "string" { + return fmt.Errorf("%s:只能引用字符串的标量类型", w) + } + continue + } + if err := allowOnly(w, c, "minLength", "maxLength", "pattern", "x-maxBytes"); err != nil { + return err + } + if err := checkBounds(w, c); err != nil { + return err + } + if err := checkPattern(w, c.Pattern); err != nil { + return err + } + } + return nil +} + +func (d *idl) checkMessage(where string, m *message, seen map[string]bool) error { + if !messageTypePattern.MatchString(m.Type) || seen[pascal(m.Type)] { + return fmt.Errorf("%s:消息类型 %q 格式不对或重复", where, m.Type) + } + seen[pascal(m.Type)] = true + if m.Request == nil || m.Request.Ref != "" { + return fmt.Errorf("%s.request:请求要内联定义为对象,每种请求解码出不同的类型", where) + } + if err := d.addInline(where+".request", m.Request, pascal(m.Type)+"Request", true); err != nil { + return err + } + return d.addData(where+".response", m.Response, pascal(m.Type)+"Response") +} + +func (d *idl) checkEvent(where string, e *event, seen map[string]bool) error { + if !messageTypePattern.MatchString(e.Type) || seen[pascal(e.Type)] { + return fmt.Errorf("%s:事件类型 %q 格式不对或重复", where, e.Type) + } + seen[pascal(e.Type)] = true + return d.addData(where+".data", e.Data, pascal(e.Type)+"Event") +} + +type endpointNames struct { + operations map[string]bool + // 接口名的大驼峰形式是 Go 中 Route 常量名的一部分,不同的接口名可能转成相同的形式。 + constants map[string]bool + routes map[string]string + paths map[string]string +} + +func (n endpointNames) addRoute(where string, b httpBinding, owner string) error { + // 路径参数的名字不影响路由:ServeMux 登记两条这样的路由会 panic,OpenAPI 也把它们视为同一个 path。 + shape := pathParamPattern.ReplaceAllString(b.Path, "{}") + route := b.Method + " " + shape + if first, ok := n.routes[route]; ok { + return fmt.Errorf("%s:%s %s 与%s重复,路径参数的名字不参与比较", where, b.Method, b.Path, first) + } + n.routes[route] = owner + // OpenAPI 中各方法共用这个 path 的参数名。 + if first, ok := n.paths[shape]; ok && first != b.Path { + return fmt.Errorf("%s:路径 %s 与 %s 只有参数名不同,同一个路径形状要用相同的参数名", where, b.Path, first) + } + n.paths[shape] = b.Path + return nil +} + +func roomPath(path string) bool { + return path == "/api/room" || strings.HasPrefix(path, "/api/room/") +} + +// 检查 http 绑定能否按 protocol/http.go 中 RoomRoute 的规则组装出请求。 +func (d *idl) checkRoomRoute(where string, m *message, names endpointNames) error { + if m.HTTP == nil { + return fmt.Errorf("%s:缺少 http", where) + } + b, where := *m.HTTP, where+".http" + if err := b.check(where); err != nil { + return err + } + if !roomPath(b.Path) { + return fmt.Errorf("%s:path %q 应是 /api/room 或在它之下", where, b.Path) + } + response := m.Response + if response.Ref != "" { + response = d.Defs[refName(response.Ref)] + } + if b.status() == 204 && len(response.Properties) > 0 { + return fmt.Errorf("%s:204 的请求的 response 应为空对象,HTTP 不返回它", where) + } + params := pathParams(b.Path) + for i, p := range params { + if slices.Contains(params[:i], p) { + return fmt.Errorf("%s:路径参数 %s 重复", where, p) + } + if f := m.Request.Properties[p]; f == nil || !slices.Contains(m.Request.Required, p) || d.jsonType(f) != "string" { + return fmt.Errorf("%s:路径参数 %s 应是请求中必填的字符串字段", where, p) + } + } + if b.Method == "GET" || b.Method == "DELETE" { + for _, name := range slices.Sorted(maps.Keys(m.Request.Properties)) { + if d.jsonType(m.Request.Properties[name]) == "object" { + return fmt.Errorf("%s:%s 请求的字段 %s 来自路径或查询串,应是字符串、整数或枚举", where, b.Method, name) + } + } + } + return names.addRoute(where, b, "房间内请求 "+m.Type+" 的路由") +} + +// 请求的字段已经检查过,不会是数组、布尔或 null,所以只有 string、integer 和 object 三种。 +func (d *idl) jsonType(s *schema) string { + if s.Ref != "" { + return d.Defs[refName(s.Ref)].Type + } + return s.Type +} + +func (d *idl) checkEndpoint(where string, e *endpoint, names endpointNames) error { + if !fieldNamePattern.MatchString(e.Name) || names.operations[e.Name] || names.constants[pascal(e.Name)] { + return fmt.Errorf("%s:接口名 %q 格式不对,或与其他接口、房间请求的 operationId 重复", where, e.Name) + } + names.operations[e.Name], names.constants[pascal(e.Name)] = true, true + if err := e.check(where); err != nil { + return err + } + if roomPath(e.Path) { + return fmt.Errorf("%s:path %q 是 /api/room 或在它之下,这些路径留给房间内请求", where, e.Path) + } + params := pathParams(e.Path) + for i, p := range params { + if slices.Contains(params[:i], p) || slices.Contains(tsClientNames, p) { + return fmt.Errorf("%s:路径参数 %s 重复,或与生成的 TypeScript client 中的标识符重名", where, p) + } + if slices.Contains(jsReservedWords, p) { + return fmt.Errorf("%s:路径参数 %s 是 JavaScript 的保留字,不能作为生成的 TypeScript client 中的参数名", where, p) + } + } + if err := names.addRoute(where, e.httpBinding, "接口 "+e.Name+" 的路由"); err != nil { + return err + } + if !slices.Contains([]string{"none", "member", "admin"}, e.Auth) { + return fmt.Errorf("%s:auth 应为 none、member 或 admin", where) + } + if e.Method == "GET" || e.Method == "DELETE" { + if e.Request != nil { + return fmt.Errorf("%s:%s 请求没有请求体", where, e.Method) + } + } else { + if e.Request == nil || e.Request.Ref != "" { + return fmt.Errorf("%s.request:请求体要内联定义为对象", where) + } + if err := d.addInline(where+".request", e.Request, pascal(e.Name)+"Request", true); err != nil { + return err + } + } + if e.status() == 204 { + if e.Response != nil { + return fmt.Errorf("%s:204 的接口没有 response", where) + } + return nil + } + return d.addData(where+".response", e.Response, pascal(e.Name)+"Response") +} + +func (d *idl) addData(where string, s *schema, name string) error { + if s == nil { + return fmt.Errorf("%s:缺少定义", where) + } + if s.Ref == "" { + return d.addInline(where, s, name, false) + } + if err := allowOnly(where, s, "$ref"); err != nil { + return err + } + def, err := d.resolve(where, s.Ref) + if err != nil { + return err + } + if def.Type != "object" { + return fmt.Errorf("%s:应引用对象类型", where) + } + return nil +} + +func (d *idl) addInline(where string, s *schema, name string, inbound bool) error { + if d.named[name] != nil { + return fmt.Errorf("%s:生成的类型名 %s 与其他类型重复", where, name) + } + if err := d.checkObject(where, s, inbound); err != nil { + return err + } + s.name = name + d.named[name] = s + d.inbound[name] = inbound + return nil +} + +func checkCloseCodes(codes []*closeCode) error { + names, values := map[string]bool{}, map[int]bool{} + for i, c := range codes { + if !fieldNamePattern.MatchString(c.Name) || names[pascal(c.Name)] || values[c.Code] || c.Code < 1000 || c.Code > 4999 { + return fmt.Errorf("x-ws.closeCodes[%d]:名称 %q 或关闭码 %d 不合法或重复", i, c.Name, c.Code) + } + names[pascal(c.Name)], values[c.Code] = true, true + } + return nil +} + +func (d *idl) checkGoNames() error { + declared := map[string]string{} + var err error + declare := func(name, what string) { + if first, ok := declared[name]; ok && err == nil { + err = fmt.Errorf("生成的 Go 标识符 %s 重复:既是%s,也是%s", name, first, what) + } + declared[name] = what + } + declare("Version", "生成器写出的 Version") + declare("ErrorResponse", "生成器写出的 ErrorResponse") + declare("decodeConnectionRequest", "生成器写出的 decodeConnectionRequest") + declare("decodeRoomRequest", "生成器写出的 decodeRoomRequest") + declare("RoomRoutes", "生成器写出的 RoomRoutes") + for _, name := range d.typeNames() { + s := d.named[name] + declare(name, "类型 "+name) + for _, v := range s.Enum { + declare(name+pascal(v), fmt.Sprintf("枚举 %s 的取值 %s", name, v)) + } + if s.HTTPStatus != nil { + declare(lowerFirst(name)+"HTTPStatus", fmt.Sprintf("枚举 %s 的 HTTP 状态码表", name)) + } + if !d.inbound[name] { + continue + } + declare("decode"+name, fmt.Sprintf("类型 %s 的解码函数", name)) + for _, f := range slices.Sorted(maps.Keys(s.Properties)) { + if s.Properties[f].Pattern != "" { + declare("pattern"+name+pascal(f), fmt.Sprintf("类型 %s 的字段 %s 的 pattern", name, f)) + } + } + for _, entry := range s.AllOf { + value := *entry.If.Properties[entry.If.Required[0]].Const + for _, f := range slices.Sorted(maps.Keys(entry.Then.Properties)) { + if entry.Then.Properties[f].Pattern != "" { + declare("pattern"+name+pascal(f)+"When"+pascal(value), fmt.Sprintf("类型 %s 的字段 %s 在 %s 时的 pattern", name, f, value)) + } + } + } + } + for _, m := range slices.Concat(d.WS.Requests, d.Requests) { + declare("Request"+pascal(m.Type), fmt.Sprintf("请求 %s 的常量", m.Type)) + } + for _, e := range slices.Concat(d.WS.Events, d.Events) { + declare("Event"+pascal(e.Type), fmt.Sprintf("事件 %s 的常量", e.Type)) + } + for _, c := range d.WS.CloseCodes { + declare("Close"+pascal(c.Name), fmt.Sprintf("关闭码 %s 的常量", c.Name)) + } + for _, e := range d.HTTP { + declare("Route"+pascal(e.Name), fmt.Sprintf("接口 %s 的常量", e.Name)) + if e.Request != nil { + declare("Decode"+e.Request.name, fmt.Sprintf("接口 %s 的请求体的解码函数", e.Name)) + } + } + d.goNames = declared + return err +} + +func (d *idl) checkHandwritten(dir string) error { + paths, err := filepath.Glob(filepath.Join(dir, "*.go")) + if err != nil { + return err + } + fset := token.NewFileSet() + for _, path := range paths { + if strings.HasSuffix(path, ".gen.go") || strings.HasSuffix(path, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + return err + } + for _, name := range topLevelNames(f) { + if what, ok := d.goNames[name]; ok { + return fmt.Errorf("生成的 Go 标识符 %s 与 %s 中手写的声明重名:它是%s", name, filepath.Base(path), what) + } + } + } + return nil +} + +func topLevelNames(f *ast.File) []string { + var names []string + for _, decl := range f.Decls { + switch decl := decl.(type) { + case *ast.FuncDecl: + if decl.Recv == nil { + names = append(names, decl.Name.Name) + } + case *ast.GenDecl: + for _, spec := range decl.Specs { + switch spec := spec.(type) { + case *ast.TypeSpec: + names = append(names, spec.Name.Name) + case *ast.ValueSpec: + for _, n := range spec.Names { + names = append(names, n.Name) + } + } + } + } + } + return names +} + +// 请求引用到的类型要生成解码函数,所以也按请求的限制再检查一遍。 +func (d *idl) markInbound(s *schema) error { + for _, name := range slices.Sorted(maps.Keys(s.Properties)) { + p := s.Properties[name] + if p.Ref == "" || d.inbound[refName(p.Ref)] || d.Defs[refName(p.Ref)].scalar() { + continue + } + def := d.Defs[refName(p.Ref)] + d.inbound[def.name] = true + if def.Enum != nil { + continue + } + if err := d.checkObject("$defs."+def.name, def, true); err != nil { + return err + } + if err := d.markInbound(def); err != nil { + return err + } + } + return nil +} + +func (d *idl) typeNames() []string { + return slices.Sorted(maps.Keys(d.named)) +} + +func (d *idl) urlDefs() []string { + var names []string + for _, name := range slices.Sorted(maps.Keys(d.Defs)) { + if d.Defs[name].URL != nil { + names = append(names, name) + } + } + return names +} + +func (s *schema) scalar() bool { + return s.Type != "object" && s.Enum == nil +} + +func (d *idl) inlineScalars() { + for _, s := range d.named { + for name, p := range s.Properties { + s.Properties[name] = d.inlined(p) + } + for _, entry := range s.AllOf { + for name, p := range entry.Then.Properties { + entry.Then.Properties[name] = d.inlined(p) + } + } + } +} + +func (d *idl) inlined(s *schema) *schema { + switch { + case s.AnyOf != nil: + s.AnyOf[0] = d.inlined(s.AnyOf[0]) + case s.Items != nil: + s.Items = d.inlined(s.Items) + case s.Ref != "" && d.Defs[refName(s.Ref)].scalar(): + v := *d.Defs[refName(s.Ref)] + v.name = "" + if s.Description != "" { + v.Description = s.Description + } + return &v + } + return s +} + +func (s *schema) dataType() string { + if s.Ref != "" { + return refName(s.Ref) + } + return s.name +} + +func refName(ref string) string { + return strings.TrimPrefix(ref, "#/$defs/") +} + +func (s *schema) keywords() []string { + var k []string + add := func(set bool, name string) { + if set { + k = append(k, name) + } + } + add(s.Ref != "", "$ref") + add(s.Type != "", "type") + add(s.Description != "", "description") + add(s.Enum != nil, "enum") + add(s.Const != nil, "const") + add(s.Properties != nil, "properties") + add(s.Required != nil, "required") + add(s.AdditionalProperties != nil, "additionalProperties") + add(s.Items != nil, "items") + add(s.AnyOf != nil, "anyOf") + add(s.OneOf != nil, "oneOf") + add(s.AllOf != nil, "allOf") + add(s.If != nil, "if") + add(s.Then != nil, "then") + add(s.MinLength != nil, "minLength") + add(s.MaxLength != nil, "maxLength") + add(s.Minimum != nil, "minimum") + add(s.Maximum != nil, "maximum") + add(s.Pattern != "", "pattern") + add(s.MaxBytes != nil, "x-maxBytes") + add(s.URL != nil, "x-url") + add(s.HTTPStatus != nil, "x-httpStatus") + return k +} + +func allowOnly(where string, s *schema, allowed ...string) error { + for _, k := range s.keywords() { + if !slices.Contains(allowed, k) { + return fmt.Errorf("%s:这里不支持 %s", where, k) + } + } + return nil +} diff --git a/cmd/protocolgen/main.go b/cmd/protocolgen/main.go new file mode 100644 index 0000000..c0a725d --- /dev/null +++ b/cmd/protocolgen/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +type output struct { + path string + data []byte +} + +func main() { + os.Exit(run(os.Args[1:], os.Stderr)) +} + +func run(args []string, stderr io.Writer) int { + flags := flag.NewFlagSet("protocolgen", flag.ContinueOnError) + flags.SetOutput(stderr) + root := flags.String("root", ".", "仓库根目录") + check := flags.Bool("check", false, "只检查生成物是否与 IDL 一致,不写入文件") + if err := flags.Parse(args); err != nil { + return 2 + } + outputs, err := generateFrom(filepath.Join(*root, "protocol", "idl.json")) + if err != nil { + report(stderr, "%v", err) + return 1 + } + status := 0 + for _, out := range outputs { + path := filepath.Join(*root, filepath.FromSlash(out.path)) + if *check { + old, err := os.ReadFile(path) + switch { + case err != nil && !errors.Is(err, fs.ErrNotExist): + report(stderr, "读取 %s:%v", out.path, err) + status = 1 + case !bytes.Equal(old, out.data): + report(stderr, "%s 不是最新的,运行 go run ./cmd/protocolgen 重新生成", out.path) + status = 1 + } + continue + } + if err := writeOutput(path, out.data); err != nil { + report(stderr, "%v", err) + return 1 + } + } + return status +} + +func report(stderr io.Writer, format string, args ...any) { + _, _ = fmt.Fprintf(stderr, "protocolgen:"+format+"\n", args...) +} + +func generateFrom(path string) ([]output, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + d, err := parseIDL(data) + if err != nil { + return nil, err + } + if err := d.checkHandwritten(filepath.Dir(path)); err != nil { + return nil, err + } + return generate(d) +} + +// 生成函数只处理 check 接受过的 IDL,直接依赖它保证的结构,不再检查:引用都指向存在的类型(标量类型已经展开),数组都有 items, +// anyOf 是 [类型, {"type": "null"}],allOf 的每个条件只按必填的枚举字段判断,需要解码函数的类型都已标记在 inbound 中。 +func generate(d *idl) ([]output, error) { + types, err := goTypes(d) + if err != nil { + return nil, err + } + decode, err := goDecode(d) + if err != nil { + return nil, err + } + api, err := openAPI(d) + if err != nil { + return nil, err + } + outputs := []output{ + {"protocol/types.gen.go", types}, + {"protocol/decode.gen.go", decode}, + {"protocol/openapi.gen.json", api}, + {"web/src/core/realtime/protocol.gen.ts", tsProtocol(d)}, + {"web/src/core/api/http.gen.ts", tsHTTP(d)}, + } + if len(d.urlDefs()) > 0 { + outputs = append(outputs, output{"web/src/core/realtime/protocol.gen.test.ts", tsValidatorTests(d)}) + } + return outputs, nil +} + +func writeOutput(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} diff --git a/cmd/protocolgen/names.go b/cmd/protocolgen/names.go new file mode 100644 index 0000000..cc6a699 --- /dev/null +++ b/cmd/protocolgen/names.go @@ -0,0 +1,54 @@ +package main + +import "strings" + +var initialisms = map[string]string{"id": "ID", "qq": "QQ", "url": "URL"} + +func words(s string) []string { + var out []string + for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == '.' || r == '_' }) { + start := 0 + for i := 1; i < len(part); i++ { + if isUpper(part[i]) && !isUpper(part[i-1]) { + out = append(out, part[start:i]) + start = i + } + } + out = append(out, part[start:]) + } + return out +} + +func isUpper(c byte) bool { + return 'A' <= c && c <= 'Z' +} + +func pascal(s string) string { + var b strings.Builder + for _, w := range words(s) { + b.WriteString(title(w)) + } + return b.String() +} + +func camel(s string) string { + w := words(s) + var b strings.Builder + b.WriteString(strings.ToLower(w[0])) + for _, x := range w[1:] { + b.WriteString(title(x)) + } + return b.String() +} + +func title(w string) string { + lower := strings.ToLower(w) + if s, ok := initialisms[lower]; ok { + return s + } + return strings.ToUpper(lower[:1]) + lower[1:] +} + +func lowerFirst(s string) string { + return strings.ToLower(s[:1]) + s[1:] +} diff --git a/cmd/protocolgen/openapi.go b/cmd/protocolgen/openapi.go new file mode 100644 index 0000000..cad280c --- /dev/null +++ b/cmd/protocolgen/openapi.go @@ -0,0 +1,252 @@ +package main + +import ( + "bytes" + "encoding/json" + "maps" + "slices" + "strconv" + "strings" +) + +func openAPI(d *idl) ([]byte, error) { + schemas := map[string]any{ + "ErrorResponse": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{"error": componentRef("ErrorInfo")}, + "required": []string{"error"}, + }, + } + for name, s := range d.Defs { + if s.scalar() { + continue + } + if err := addComponent(schemas, name, s); err != nil { + return nil, err + } + } + paths := map[string]map[string]any{} + for _, e := range d.HTTP { + op := map[string]any{ + "operationId": e.Name, + "responses": openAPIResponses(e.httpBinding, e.Response), + } + if e.Description != "" { + op["summary"] = e.Description + } + if params := pathParams(e.Path); len(params) > 0 { + list := make([]any, len(params)) + for i, p := range params { + list[i] = map[string]any{"name": p, "in": "path", "required": true, "schema": map[string]any{"type": "string"}} + } + op["parameters"] = list + } + if e.Request != nil { + op["requestBody"] = openAPIBody(e.Request) + } + security := []any{} + if scheme := map[string]string{"member": "memberToken", "admin": "adminToken"}[e.Auth]; scheme != "" { + security = append(security, map[string]any{scheme: []any{}}) + } + op["security"] = security + for _, s := range []*schema{e.Request, e.Response} { + if err := addInlineComponent(schemas, s); err != nil { + return nil, err + } + } + if paths[e.Path] == nil { + paths[e.Path] = map[string]any{} + } + paths[e.Path][strings.ToLower(e.Method)] = op + } + for _, m := range d.Requests { + op, err := roomOperation(schemas, m) + if err != nil { + return nil, err + } + if paths[m.HTTP.Path] == nil { + paths[m.HTTP.Path] = map[string]any{} + } + paths[m.HTTP.Path][strings.ToLower(m.HTTP.Method)] = op + } + + bearer := map[string]any{"type": "http", "scheme": "bearer"} + ticket := map[string]any{"type": "http", "scheme": "bearer", "description": "进房票据或常驻房间的 API Key"} + doc := map[string]any{ + "openapi": "3.1.0", + "info": map[string]any{"title": d.Title, "version": d.version}, + "paths": paths, + "components": map[string]any{ + "schemas": schemas, + "securitySchemes": map[string]any{"memberToken": bearer, "ticket": ticket, "adminToken": bearer}, + }, + } + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(doc); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +func componentRef(name string) map[string]any { + return map[string]any{"$ref": "#/components/schemas/" + name} +} + +func openAPIBody(s *schema) map[string]any { + return map[string]any{ + "required": true, + "content": map[string]any{"application/json": map[string]any{"schema": componentRef(s.dataType())}}, + } +} + +func openAPIResponses(b httpBinding, s *schema) map[string]any { + success := map[string]any{"description": "成功"} + if s != nil { + success["content"] = map[string]any{"application/json": map[string]any{"schema": componentRef(s.dataType())}} + } + if b.Location { + success["headers"] = map[string]any{"Location": map[string]any{"description": "新资源的地址", "schema": map[string]any{"type": "string"}}} + } + return map[string]any{ + strconv.Itoa(b.status()): success, + "default": map[string]any{ + "description": "失败", + "content": map[string]any{"application/json": map[string]any{"schema": componentRef("ErrorResponse")}}, + }, + } +} + +// 路径参数不在查询串和请求体中;204 的请求没有响应体。 +func roomOperation(schemas map[string]any, m *message) (map[string]any, error) { + b := *m.HTTP + response := m.Response + if b.status() == 204 { + response = nil + } + op := map[string]any{ + "operationId": camel(m.Type), + "responses": openAPIResponses(b, response), + "security": []any{map[string]any{"ticket": []any{}}}, + } + if m.Description != "" { + op["summary"] = m.Description + } + if err := addInlineComponent(schemas, response); err != nil { + return nil, err + } + pathNames := pathParams(b.Path) + var params []any + for _, name := range pathNames { + s, err := openAPISchema(m.Request.Properties[name]) + if err != nil { + return nil, err + } + params = append(params, map[string]any{"name": name, "in": "path", "required": true, "schema": s}) + } + if b.Method == "GET" || b.Method == "DELETE" { + for _, name := range slices.Sorted(maps.Keys(m.Request.Properties)) { + if slices.Contains(pathNames, name) { + continue + } + s, err := openAPISchema(m.Request.Properties[name]) + if err != nil { + return nil, err + } + params = append(params, map[string]any{"name": name, "in": "query", "required": slices.Contains(m.Request.Required, name), "schema": s}) + } + } else { + body, err := roomBody(schemas, m.Request, pathNames) + if err != nil { + return nil, err + } + op["requestBody"] = body + } + if len(params) > 0 { + op["parameters"] = params + } + return op, nil +} + +// 请求体没有字段时可以不带请求体。 +func roomBody(schemas map[string]any, s *schema, pathNames []string) (map[string]any, error) { + var bodySchema any = componentRef(s.name) + fields := len(s.Properties) + if len(pathNames) == 0 { + if err := addInlineComponent(schemas, s); err != nil { + return nil, err + } + } else { + rest := *s + rest.Properties = maps.Clone(s.Properties) + for _, name := range pathNames { + delete(rest.Properties, name) + } + rest.Required = slices.DeleteFunc(slices.Clone(s.Required), func(name string) bool { return slices.Contains(pathNames, name) }) + fields = len(rest.Properties) + v, err := openAPISchema(&rest) + if err != nil { + return nil, err + } + bodySchema = v + } + return map[string]any{ + "required": fields > 0, + "content": map[string]any{"application/json": map[string]any{"schema": bodySchema}}, + }, nil +} + +func addInlineComponent(schemas map[string]any, s *schema) error { + if s == nil || s.Ref != "" { + return nil + } + return addComponent(schemas, s.name, s) +} + +func addComponent(schemas map[string]any, name string, s *schema) error { + v, err := openAPISchema(s) + if err != nil { + return err + } + schemas[name] = v + return nil +} + +func openAPISchema(s *schema) (any, error) { + data, err := json.Marshal(s) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, err + } + return rewriteSchema(v), nil +} + +func rewriteSchema(v any) any { + switch v := v.(type) { + case map[string]any: + for k, x := range v { + if s, ok := x.(string); ok && k == "$ref" { + v[k] = "#/components/schemas/" + refName(s) + } else { + v[k] = rewriteSchema(x) + } + } + // 服务端接受非 ASCII 的主机名和路径,所以 format 是 iri 而不是 uri。 + if v["x-url"] == true { + v["format"] = "iri" + } + case []any: + for i, x := range v { + v[i] = rewriteSchema(x) + } + } + return v +} diff --git a/cmd/protocolgen/ts.go b/cmd/protocolgen/ts.go new file mode 100644 index 0000000..7b138ee --- /dev/null +++ b/cmd/protocolgen/ts.go @@ -0,0 +1,331 @@ +package main + +import ( + "bytes" + "fmt" + "maps" + "slices" + "strings" +) + +const tsHeader = "// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.\n" + +func tsProtocol(d *idl) []byte { + httpTypes := d.httpTypeNames() + var b bytes.Buffer + b.WriteString(tsHeader) + fmt.Fprintf(&b, "\nexport const PROTOCOL_VERSION = '%s'\n", d.version) + for _, name := range d.typeNames() { + if !httpTypes[name] { + writeTSType(&b, name, d.named[name]) + } + } + + requests := slices.Concat(d.WS.Requests, d.Requests) + writeTSMap(&b, "RequestDataMap", requests, func(m *message) (string, *schema) { return m.Type, m.Request }) + writeTSMap(&b, "ResponseDataMap", requests, func(m *message) (string, *schema) { return m.Type, m.Response }) + writeTSMap(&b, "ConnectionEventDataMap", d.WS.Events, func(e *event) (string, *schema) { return e.Type, e.Data }) + writeTSMap(&b, "RoomEventDataMap", d.Events, func(e *event) (string, *schema) { return e.Type, e.Data }) + b.WriteString(tsEnvelopes) + + b.WriteString("\nexport const CloseCode = {\n") + for _, c := range d.WS.CloseCodes { + fmt.Fprintf(&b, " %s: %d,\n", c.Name, c.Code) + } + b.WriteString("} as const\n") + + b.WriteString("\nexport type Send = (type: T, data: RequestDataMap[T]) => Promise\n") + b.WriteString("\nexport function roomRequests(send: Send) {\n return {\n") + for _, m := range d.Requests { + if len(m.Request.Properties) == 0 { + fmt.Fprintf(&b, " %s: () => send('%s', {}),\n", camel(m.Type), m.Type) + } else { + fmt.Fprintf(&b, " %s: (data: %s) => send('%s', data),\n", camel(m.Type), m.Request.name, m.Type) + } + } + b.WriteString(" }\n}\n") + if names := d.urlDefs(); len(names) > 0 { + b.WriteString(tsURLRule) + for _, name := range names { + writeTSValidator(&b, name, d.Defs[name]) + } + } + return b.Bytes() +} + +// 与 protocol/decode.go 中的 validURL 是同一套规则,两边由 protocol/testdata 中共用的用例检查。 +const tsURLRule = ` +function validURL(value: string): boolean { + if (/[\u0000-\u0020\u007F\\]/.test(value)) + return false + const scheme = /^https?:\/\//i.exec(value) + if (scheme === null) + return false + const rest = value.slice(scheme[0].length) + const end = rest.search(/[/?#]/) + const authority = end < 0 ? rest : rest.slice(0, end) + const tail = end < 0 ? '' : rest.slice(end) + let host = authority + let port = '' + if (authority.startsWith('[')) { + const close = authority.indexOf(']') + 1 + host = authority.slice(0, close) + if (close === 0 || !/^\[[0-9A-Fa-f:.]+\]$/.test(host)) + return false + const after = authority.slice(close) + if (after !== '') { + if (!after.startsWith(':')) + return false + port = after.slice(1) + } + } + else { + const colon = authority.indexOf(':') + if (colon >= 0) { + host = authority.slice(0, colon) + port = authority.slice(colon + 1) + } + if (!/^[\w.~\u0080-\u{10FFFF}-]+$/u.test(host)) + return false + const labels = host.replace(/\.$/, '').split('.') + if (/^(?:\d+|0x[0-9a-f]*)$/i.test(labels[labels.length - 1]) && !/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/.test(host)) + return false + } + if (!/^\d*$/.test(port) || Number(port) > 65535) + return false + const hash = tail.indexOf('#') + const path = (hash < 0 ? tail : tail.slice(0, hash)).split('?')[0] + const fragment = hash < 0 ? '' : tail.slice(hash + 1) + const badEscape = /%(?![0-9A-Fa-f]{2})/ + if (badEscape.test(path) || badEscape.test(fragment)) + return false + try { + return new URL(value).hostname !== '' + } + catch { + return false + } +} +` + +func writeTSValidator(b *bytes.Buffer, name string, s *schema) { + var checks []string + if s.MinLength != nil { + checks = append(checks, fmt.Sprintf("[...value].length >= %d", *s.MinLength)) + } + if s.MaxLength != nil { + checks = append(checks, fmt.Sprintf("[...value].length <= %d", *s.MaxLength)) + } + if s.MaxBytes != nil { + checks = append(checks, fmt.Sprintf("new TextEncoder().encode(value).length <= %d", *s.MaxBytes)) + } + checks = append(checks, "validURL(value)") + fmt.Fprintf(b, "\nexport function is%s(value: string): boolean {\n return %s\n}\n", name, strings.Join(checks, " && ")) +} + +func tsValidatorTests(d *idl) []byte { + names := d.urlDefs() + validators := make([]string, len(names)) + for i, name := range names { + validators[i] = "is" + name + } + var b bytes.Buffer + b.WriteString(tsHeader) + fmt.Fprintf(&b, "\nimport { readFileSync } from 'node:fs'\nimport { describe, expect, it } from 'vitest'\nimport { %s } from './protocol.gen'\n", strings.Join(validators, ", ")) + b.WriteString(` +function cases(name: string): { value: string, valid: boolean }[] { + return JSON.parse(readFileSync(new URL(` + "`../../../../protocol/testdata/${name}.json`" + `, import.meta.url), 'utf8')) as { value: string, valid: boolean }[] +} +`) + for _, name := range names { + fmt.Fprintf(&b, "\ndescribe('is%s', () => {\n it('对 protocol/testdata/%s.json 中的每个用例与服务端的判断相同', () => {\n expect(cases('%s').filter(c => is%s(c.value) !== c.valid).map(c => c.value)).toEqual([])\n })\n})\n", name, name, name, name) + } + return b.Bytes() +} + +const tsEnvelopes = ` +export type RequestType = keyof RequestDataMap +export type ConnectionEventType = keyof ConnectionEventDataMap +export type RoomEventType = keyof RoomEventDataMap + +export interface RequestMessage { + kind: 'request' + type: T + id: string + data: RequestDataMap[T] +} + +export type ResponseMessage = { + [K in T]: { kind: 'response', type: K, id: string } & ( + | { data: ResponseDataMap[K], error?: undefined } + | { data: Empty, error: ErrorInfo } + ) +}[T] + +export type ConnectionEventMessage = { + [K in T]: { kind: 'event', type: K, data: ConnectionEventDataMap[K] } +}[T] + +export type RoomEventMessage = { + [K in T]: { kind: 'event', type: K, term: number, seq: number, data: RoomEventDataMap[K] } +}[T] + +export type ServerMessage = ResponseMessage | ConnectionEventMessage | RoomEventMessage +` + +func tsHTTP(d *idl) []byte { + httpTypes := d.httpTypeNames() + var body bytes.Buffer + imports := map[string]bool{"ErrorInfo": true} + for _, name := range slices.Sorted(maps.Keys(httpTypes)) { + s := d.named[name] + writeTSType(&body, name, s) + for _, p := range s.Properties { + if ref := tsRef(p); ref != "" { + imports[ref] = true + } + } + } + body.WriteString(tsHTTPRuntime) + body.WriteString("\nexport function httpClient(send: HttpSend) {\n return {\n") + for _, e := range d.HTTP { + response := "void" + if e.Response != nil { + response = e.Response.dataType() + if e.Response.Ref != "" { + imports[response] = true + } + } + var params []string + path := e.Path + for _, p := range pathParams(e.Path) { + params = append(params, p+": string") + path = strings.Replace(path, "{"+p+"}", "${encodeURIComponent("+p+")}", 1) + } + quoted := "'" + path + "'" + if strings.Contains(path, "${") { + quoted = "`" + path + "`" + } + requestBody := "" + if e.Request != nil { + if len(e.Request.Properties) == 0 { + requestBody = ", body: {}" + } else { + params = append(params, "body: "+e.Request.name) + requestBody = ", body" + } + } + fmt.Fprintf(&body, " %s: (%s) => send<%s>({ method: '%s', path: %s, auth: '%s'%s }),\n", e.Name, strings.Join(params, ", "), response, e.Method, quoted, e.Auth, requestBody) + } + body.WriteString(" }\n}\n") + + var b bytes.Buffer + b.WriteString(tsHeader) + fmt.Fprintf(&b, "\nimport type { %s } from '../realtime/protocol.gen'\n", strings.Join(slices.Sorted(maps.Keys(imports)), ", ")) + b.Write(body.Bytes()) + return b.Bytes() +} + +const tsHTTPRuntime = ` +export interface ErrorResponse { + error: ErrorInfo +} + +export type Auth = 'none' | 'member' | 'admin' + +export interface HttpRequest { + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + path: string + auth: Auth + body?: unknown +} + +export type HttpSend = (request: HttpRequest) => Promise +` + +func (d *idl) httpTypeNames() map[string]bool { + names := map[string]bool{} + for _, e := range d.HTTP { + for _, s := range []*schema{e.Request, e.Response} { + if s != nil && s.Ref == "" { + names[s.name] = true + } + } + } + return names +} + +func writeTSMap[T any](b *bytes.Buffer, name string, items []T, entry func(T) (string, *schema)) { + fmt.Fprintf(b, "\nexport interface %s {\n", name) + for _, item := range items { + key, s := entry(item) + fmt.Fprintf(b, " '%s': %s\n", key, s.dataType()) + } + b.WriteString("}\n") +} + +func writeTSType(b *bytes.Buffer, name string, s *schema) { + switch { + case s.Enum != nil: + values := make([]string, len(s.Enum)) + for i, v := range s.Enum { + values[i] = "'" + v + "'" + } + fmt.Fprintf(b, "\nexport type %s = %s\n", name, strings.Join(values, " | ")) + case len(s.Properties) == 0: + fmt.Fprintf(b, "\nexport type %s = Record\n", name) + case s.OneOf != nil: + optional := optionalFields(s.OneOf[0]) + alternatives := make([]string, len(s.OneOf)) + for i, alt := range s.OneOf { + fields := make([]string, 0, len(alt.Required)+len(optional)) + for _, f := range alt.Required { + fields = append(fields, f+": "+tsFieldType(s.Properties[f])) + } + for _, f := range optional { + fields = append(fields, f+"?: "+tsFieldType(s.Properties[f])) + } + alternatives[i] = "{ " + strings.Join(fields, ", ") + " }" + } + fmt.Fprintf(b, "\nexport type %s = %s\n", name, strings.Join(alternatives, " | ")) + default: + fmt.Fprintf(b, "\nexport interface %s {\n", name) + for _, p := range slices.Sorted(maps.Keys(s.Properties)) { + optional := "" + if !slices.Contains(s.Required, p) { + optional = "?" + } + fmt.Fprintf(b, " %s%s: %s\n", p, optional, tsFieldType(s.Properties[p])) + } + b.WriteString("}\n") + } +} + +func tsFieldType(s *schema) string { + switch { + case s.AnyOf != nil: + return tsValueType(s.AnyOf[0]) + " | null" + case s.Type == "array": + return tsValueType(s.Items) + "[]" + default: + return tsValueType(s) + } +} + +func tsValueType(s *schema) string { + if s.Ref != "" { + return refName(s.Ref) + } + return map[string]string{"string": "string", "integer": "number", "number": "number", "boolean": "boolean"}[s.Type] +} + +func tsRef(s *schema) string { + switch { + case s.AnyOf != nil: + return refName(s.AnyOf[0].Ref) + case s.Type == "array": + return refName(s.Items.Ref) + default: + return refName(s.Ref) + } +} diff --git a/db/migrations/00001_schema.sql b/db/migrations/00001_schema.sql new file mode 100644 index 0000000..2bbb181 --- /dev/null +++ b/db/migrations/00001_schema.sql @@ -0,0 +1,100 @@ +-- +goose Up +-- 可选的文本值在没有或不适用时为 NULL;空串只表示值本身为空,例如被清空的房间描述。 +CREATE TABLE members ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + token_hash bytea NOT NULL UNIQUE, + name text NOT NULL, + avatar_hash text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE rooms ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + kind text NOT NULL CHECK (kind IN ('persistent', 'ephemeral')), + slug text UNIQUE CHECK (slug IS NULL OR kind = 'persistent'), + name text NOT NULL, + description text NOT NULL, + password_hash text, + -- 接管房间时加 1;落盘以它为条件,因此过期的 owner 无法写入。 + term bigint NOT NULL DEFAULT 0, + play_mode text NOT NULL DEFAULT 'sequential' CHECK (play_mode IN ('sequential', 'random')), + entry_seq bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE room_entries ( + room_id uuid NOT NULL REFERENCES rooms ON DELETE CASCADE, + entry_id bigint NOT NULL, + slot text NOT NULL CHECK (slot IN ('waiting', 'pending', 'current')), + source text NOT NULL, + track_id text NOT NULL, + title text NOT NULL, + artist text, + album text, + cover_url text, + cover_key text, + meta_duration_ms bigint NOT NULL, + -- AutoDJ 加入的条目没有点歌的成员,by_* 都为 NULL。 + by_member_id uuid, + by_name text, + by_avatar_hash text, + auto boolean NOT NULL, + likes integer NOT NULL, + added_at timestamptz NOT NULL, + started_at timestamptz CHECK ((slot = 'current') = (started_at IS NOT NULL)), + PRIMARY KEY (room_id, entry_id) +); + +CREATE INDEX ON room_entries (source, track_id); + +CREATE TABLE room_tickets ( + token_hash bytea PRIMARY KEY, + room_id uuid NOT NULL REFERENCES rooms ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES members, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ON room_tickets (room_id); + +-- 只保存准备成功的结果。 +CREATE TABLE tracks ( + source text NOT NULL, + track_id text NOT NULL, + title text NOT NULL, + artist text, + album text, + cover_url text, + cover_key text, + duration_ms bigint NOT NULL, + gain double precision NOT NULL, + lyric text, + kuwo_id text NOT NULL, + media_key text, + media_size bigint, + prepared_at timestamptz NOT NULL DEFAULT now(), + -- 临时房间的播放记录随房间删除,所以不从 plays 计算。 + last_played_at timestamptz, + PRIMARY KEY (source, track_id) +); + +CREATE TABLE plays ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + room_id uuid NOT NULL REFERENCES rooms ON DELETE CASCADE, + source text NOT NULL, + track_id text NOT NULL, + by_member_id uuid, + by_name text, + by_avatar_hash text, + auto boolean NOT NULL, + started_at timestamptz NOT NULL, + ended_at timestamptz NOT NULL +); + +CREATE INDEX ON plays (room_id, started_at); + +-- 只有已设置的凭据才有对应的行。 +CREATE TABLE credentials ( + name text PRIMARY KEY, + value text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/db/migrations/00002_page_url.sql b/db/migrations/00002_page_url.sql new file mode 100644 index 0000000..0153913 --- /dev/null +++ b/db/migrations/00002_page_url.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE room_entries ADD COLUMN page_url text NOT NULL DEFAULT ''; +ALTER TABLE room_entries ALTER COLUMN page_url DROP DEFAULT; +ALTER TABLE tracks ADD COLUMN page_url text NOT NULL DEFAULT ''; +ALTER TABLE tracks ALTER COLUMN page_url DROP DEFAULT; diff --git a/db/migrations/00003_drop_kuwo_id.sql b/db/migrations/00003_drop_kuwo_id.sql new file mode 100644 index 0000000..96fd7f7 --- /dev/null +++ b/db/migrations/00003_drop_kuwo_id.sql @@ -0,0 +1,2 @@ +-- +goose Up +ALTER TABLE tracks DROP COLUMN kuwo_id; diff --git a/db/migrations/00004_room_api_keys.sql b/db/migrations/00004_room_api_keys.sql new file mode 100644 index 0000000..4d868e5 --- /dev/null +++ b/db/migrations/00004_room_api_keys.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- 第一次生成 API Key 时创建,此后重置 Key、修改密码都不换。 +ALTER TABLE rooms + ADD COLUMN bot_member_id uuid REFERENCES members, + ADD CHECK (bot_member_id IS NULL OR kind = 'persistent'); + +-- 凭证只存哈希的唯一例外:API Key 要能随时给进过房间的成员和管理员查看。Key 就是 bot 成员的票据,票据作废时明文随之删除。 +CREATE TABLE room_api_keys ( + token_hash bytea PRIMARY KEY REFERENCES room_tickets ON DELETE CASCADE, + key text NOT NULL +); diff --git a/db/migrations/00005_rotation.sql b/db/migrations/00005_rotation.sql new file mode 100644 index 0000000..9382ef2 --- /dev/null +++ b/db/migrations/00005_rotation.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- 条目的轮次在入队时定下,轮流播放时点赞数相同的歌按它排列;已有的条目都算第 0 轮。 +ALTER TABLE room_entries ADD COLUMN round bigint NOT NULL DEFAULT 0; +ALTER TABLE rooms + DROP CONSTRAINT rooms_play_mode_check, + ADD CONSTRAINT rooms_play_mode_check CHECK (play_mode IN ('sequential', 'random', 'rotation')); diff --git a/db/migrations/00006_api_key_hash_only.sql b/db/migrations/00006_api_key_hash_only.sql new file mode 100644 index 0000000..50a396e --- /dev/null +++ b/db/migrations/00006_api_key_hash_only.sql @@ -0,0 +1,4 @@ +-- +goose Up +-- 已有的 Key 都给进过房间的成员看过,一起作废;bot 成员留给重新生成的 Key。 +DROP TABLE room_api_keys; +DELETE FROM room_tickets t USING rooms r WHERE t.room_id = r.id AND t.member_id = r.bot_member_id; diff --git a/db/migrations/00007_site.sql b/db/migrations/00007_site.sql new file mode 100644 index 0000000..05789da --- /dev/null +++ b/db/migrations/00007_site.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- 至多一行;没有这一行时品牌名是默认的 Alisten。 +CREATE TABLE site ( + id boolean PRIMARY KEY DEFAULT true CHECK (id), + brand_name text NOT NULL +); diff --git a/db/migrations/migrations.go b/db/migrations/migrations.go new file mode 100644 index 0000000..91cca1c --- /dev/null +++ b/db/migrations/migrations.go @@ -0,0 +1,6 @@ +package migrations + +import "embed" + +//go:embed *.sql +var FS embed.FS diff --git a/db/queries/apikeys.sql b/db/queries/apikeys.sql new file mode 100644 index 0000000..2c0e042 --- /dev/null +++ b/db/queries/apikeys.sql @@ -0,0 +1,14 @@ +-- name: LockRoomBot :one +-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个房间的重置要依次进行, +-- 同时到达的首次生成才只会创建一个 bot 成员,最后只留下一把 Key。 +SELECT bot_member_id FROM rooms WHERE id = @id FOR NO KEY UPDATE; + +-- name: SetRoomBot :exec +UPDATE rooms SET bot_member_id = @bot_member_id::uuid WHERE id = @id; + +-- name: BotTicketCreatedAt :one +SELECT t.created_at FROM room_tickets t JOIN rooms r ON r.id = t.room_id AND r.bot_member_id = t.member_id +WHERE t.room_id = @room_id; + +-- name: DeleteMemberTickets :exec +DELETE FROM room_tickets WHERE room_id = @room_id AND member_id = @member_id; diff --git a/db/queries/credentials.sql b/db/queries/credentials.sql new file mode 100644 index 0000000..7b84c41 --- /dev/null +++ b/db/queries/credentials.sql @@ -0,0 +1,12 @@ +-- name: Credential :one +SELECT value FROM credentials WHERE name = @name; + +-- name: CredentialWithUpdatedAt :one +SELECT value, updated_at FROM credentials WHERE name = @name; + +-- name: SetCredential :exec +INSERT INTO credentials (name, value) VALUES (@name, @value) +ON CONFLICT (name) DO UPDATE SET value = excluded.value, updated_at = now(); + +-- name: DeleteCredential :exec +DELETE FROM credentials WHERE name = @name; diff --git a/db/queries/history.sql b/db/queries/history.sql new file mode 100644 index 0000000..227f968 --- /dev/null +++ b/db/queries/history.sql @@ -0,0 +1,55 @@ +-- name: AutoDJSeeds :many +-- 只看最近 1000 次播放:按 plays (room_id, started_at) 的索引倒序读到 1000 行就停,耗时与历史的长度无关。 +SELECT track_id FROM ( + SELECT DISTINCT ON (track_id) track_id, tier, rank + FROM ( + -- 队列中的歌按等待队列的排序规则排列:轮流播放时点赞数相同的先比轮次。 + SELECT e.track_id, 0 AS tier, + row_number() OVER (ORDER BY CASE e.slot WHEN 'current' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, e.likes DESC, + CASE WHEN r.play_mode = 'rotation' THEN e.round END, e.entry_id) AS rank + FROM room_entries e + JOIN rooms r ON r.id = e.room_id + WHERE e.room_id = @room_id AND e.source = 'netease' AND NOT e.auto + AND (e.slot = 'current' OR @include_queue::boolean) + UNION ALL + SELECT p.track_id, 1, row_number() OVER (ORDER BY max(p.started_at) DESC) + FROM ( + SELECT track_id, source, auto, started_at FROM plays + WHERE room_id = @room_id + ORDER BY started_at DESC + LIMIT 1000 + ) p + WHERE p.source = 'netease' AND NOT p.auto + GROUP BY p.track_id + ) candidates + ORDER BY track_id, tier, rank +) seeds +ORDER BY tier, rank +LIMIT 32; + +-- name: AutoDJExclusions :many +SELECT e.source, e.track_id FROM room_entries e +WHERE e.room_id = @room_id AND e.slot IN ('pending', 'waiting') +UNION +SELECT source, track_id FROM ( + SELECT source, track_id + FROM ( + SELECT c.source, c.track_id, c.started_at FROM room_entries c + WHERE c.room_id = @room_id AND c.slot = 'current' AND c.auto + UNION ALL + SELECT p.source, p.track_id, p.started_at FROM ( + SELECT source, track_id, auto, started_at FROM plays + WHERE room_id = @room_id + ORDER BY started_at DESC + LIMIT 1000 + ) p + WHERE p.auto + ) auto_picks + GROUP BY source, track_id + ORDER BY max(started_at) DESC + LIMIT 128 +) recent +ORDER BY source, track_id; + +-- name: TrackInUse :one +SELECT EXISTS (SELECT 1 FROM room_entries WHERE source = @source AND track_id = @track_id); diff --git a/db/queries/media.sql b/db/queries/media.sql new file mode 100644 index 0000000..a6562ff --- /dev/null +++ b/db/queries/media.sql @@ -0,0 +1,11 @@ +-- name: ListStoredMedia :many +SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks +WHERE media_key IS NOT NULL +ORDER BY prepared_at DESC, track_id; + +-- name: StoredMedia :one +SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks +WHERE media_key = @media_key::text; + +-- name: DeleteStoredMedia :exec +DELETE FROM tracks WHERE media_key = @media_key::text; diff --git a/db/queries/members.sql b/db/queries/members.sql new file mode 100644 index 0000000..64afd3a --- /dev/null +++ b/db/queries/members.sql @@ -0,0 +1,14 @@ +-- name: CreateMember :one +INSERT INTO members (token_hash, name) VALUES (@token_hash, @name) +RETURNING *; + +-- name: MemberByToken :one +SELECT * FROM members WHERE token_hash = @token_hash; + +-- name: UpdateMember :one +-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。 +UPDATE members +SET name = coalesce(sqlc.narg(name), name), + avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END +WHERE id = @id +RETURNING *; diff --git a/db/queries/rooms.sql b/db/queries/rooms.sql new file mode 100644 index 0000000..f85c6bf --- /dev/null +++ b/db/queries/rooms.sql @@ -0,0 +1,24 @@ +-- name: CreateRoom :one +INSERT INTO rooms (kind, slug, name, description, password_hash) +VALUES (@kind, NULLIF(@slug::text, ''), @name, @description, NULLIF(@password_hash::text, '')) +RETURNING id; + +-- name: ListRooms :many +SELECT * FROM rooms ORDER BY id; + +-- name: RoomByID :one +SELECT * FROM rooms WHERE id = @id; + +-- name: RoomBySlug :one +SELECT * FROM rooms WHERE slug = @slug::text; + +-- name: SetRoomSlug :execrows +UPDATE rooms SET slug = NULLIF(@slug::text, '') WHERE id = @id; + +-- name: SetRoomPassword :execrows +UPDATE rooms SET password_hash = NULLIF(@password_hash::text, '') WHERE id = @id; + +-- name: DeleteRoomTicketsExceptBot :exec +-- 没有 bot 成员的房间 bot_member_id 为 NULL,写成 <> 就一张票据都不删。 +DELETE FROM room_tickets +WHERE room_id = @room_id AND member_id IS DISTINCT FROM (SELECT bot_member_id FROM rooms WHERE id = @room_id); diff --git a/db/queries/site.sql b/db/queries/site.sql new file mode 100644 index 0000000..39dae89 --- /dev/null +++ b/db/queries/site.sql @@ -0,0 +1,6 @@ +-- name: BrandName :one +SELECT brand_name FROM site; + +-- name: SetBrandName :exec +INSERT INTO site (brand_name) VALUES (@brand_name) +ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name; diff --git a/db/queries/snapshot.sql b/db/queries/snapshot.sql new file mode 100644 index 0000000..87e31c9 --- /dev/null +++ b/db/queries/snapshot.sql @@ -0,0 +1,40 @@ +-- name: TakeRoom :one +UPDATE rooms SET term = term + 1 WHERE id = @id RETURNING *; + +-- name: RoomEntries :many +SELECT e.entry_id, e.slot, e.source, e.track_id, e.title, e.artist, e.album, e.cover_url, e.cover_key, e.page_url, e.meta_duration_ms, + e.by_member_id, e.by_name, e.by_avatar_hash, e.auto, e.likes, e.round, e.added_at, e.started_at, + t.title AS track_title, t.artist AS track_artist, t.album AS track_album, t.cover_url AS track_cover_url, + t.cover_key AS track_cover_key, t.page_url AS track_page_url, t.duration_ms, t.gain, t.lyric +FROM room_entries e +LEFT JOIN tracks t ON t.source = e.source AND t.track_id = e.track_id +WHERE e.room_id = @room_id +ORDER BY e.entry_id; + +-- name: SaveRoomState :execrows +UPDATE rooms SET name = @name, description = @description, play_mode = @play_mode, entry_seq = @entry_seq +WHERE id = @id AND term = @term; + +-- name: DeleteRoomEntries :exec +DELETE FROM room_entries WHERE room_id = @room_id; + +-- name: InsertRoomEntries :copyfrom +INSERT INTO room_entries ( + room_id, entry_id, slot, source, track_id, title, artist, album, cover_url, cover_key, page_url, meta_duration_ms, + by_member_id, by_name, by_avatar_hash, auto, likes, round, added_at, started_at +) VALUES ( + @room_id, @entry_id, @slot, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @page_url, @meta_duration_ms, + @by_member_id, @by_name, @by_avatar_hash, @auto, @likes, @round, @added_at, @started_at +); + +-- name: InsertPlays :copyfrom +INSERT INTO plays (room_id, source, track_id, by_member_id, by_name, by_avatar_hash, auto, started_at, ended_at) +VALUES (@room_id, @source, @track_id, @by_member_id, @by_name, @by_avatar_hash, @auto, @started_at, @ended_at); + +-- name: MarkPlayed :exec +-- 不同房间同时落盘时,先开播的那一次可能后写入。 +UPDATE tracks SET last_played_at = GREATEST(last_played_at, @started_at::timestamptz) +WHERE source = @source AND track_id = @track_id; + +-- name: DeleteRoom :execrows +DELETE FROM rooms WHERE id = @id AND term = @term; diff --git a/db/queries/tickets.sql b/db/queries/tickets.sql new file mode 100644 index 0000000..bbbd356 --- /dev/null +++ b/db/queries/tickets.sql @@ -0,0 +1,13 @@ +-- name: LockRoomPassword :one +-- FOR SHARE 让修改密码和删除房间等到这次签发提交之后,它们随后删除票据时会删掉这一张。 +SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = @id FOR SHARE; + +-- name: CreateTicket :exec +INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES (@token_hash, @room_id, @member_id); + +-- name: TicketHolder :one +SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot +FROM room_tickets t +JOIN members m ON m.id = t.member_id +JOIN rooms r ON r.id = t.room_id +WHERE t.token_hash = @token_hash; diff --git a/db/queries/tracks.sql b/db/queries/tracks.sql new file mode 100644 index 0000000..c38916b --- /dev/null +++ b/db/queries/tracks.sql @@ -0,0 +1,13 @@ +-- name: PreparedTrack :one +SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size FROM tracks +WHERE source = @source AND track_id = @track_id; + +-- name: SavePreparedTrack :exec +-- 准备任务只对未完成的任务去重,同一首歌可能先后准备两次,以后一次的结果为准。 +INSERT INTO tracks (source, track_id, title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size) +VALUES (@source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @page_url, @duration_ms, @gain, @lyric, @media_key, @media_size) +ON CONFLICT (source, track_id) DO UPDATE SET + title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url, + cover_key = excluded.cover_key, page_url = excluded.page_url, duration_ms = excluded.duration_ms, gain = excluded.gain, + lyric = excluded.lyric, media_key = excluded.media_key, media_size = excluded.media_size, + prepared_at = now(); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..277ae53 --- /dev/null +++ b/go.mod @@ -0,0 +1,125 @@ +module github.com/dihemoe/alisten + +go 1.27.1 + +ignore ./web/node_modules + +require ( + github.com/DiheMoe/gl3d v0.0.0-20261022172000-ae409e94c25a + github.com/aws/aws-sdk-go-v2 v1.47.3 + github.com/aws/aws-sdk-go-v2/credentials v1.20.9 + github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4 + github.com/coder/websocket v1.8.15 + github.com/go-logr/logr v1.4.4 + github.com/go-redis/redis_rate/v10 v10.0.1 + github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/jackc/pgx/v5 v5.11.0 + github.com/mewkiz/flac v1.0.14 + github.com/pressly/goose/v3 v3.29.0 + github.com/prometheus/client_golang v1.25.0 + github.com/redis/go-redis/v9 v9.23.0 + github.com/riverqueue/river v0.49.0 + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.49.0 + github.com/riverqueue/river/rivertype v0.49.0 + github.com/riverqueue/rivercontrib/otelriver v0.12.0 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 + github.com/testcontainers/testcontainers-go/modules/valkey v0.44.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 + go.opentelemetry.io/otel v1.47.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/trace v1.47.0 + golang.org/x/crypto v0.58.0 + golang.org/x/sync v0.24.0 + golang.org/x/sys v0.49.0 + golang.org/x/tools v0.52.0 + rsc.io/qr v0.2.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 // indirect + github.com/Microsoft/go-winio v0.6.3 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect + github.com/aws/smithy-go v1.28.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.8.2 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect + github.com/icza/bitio v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.3 // indirect + github.com/klauspost/compress v1.20.1 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mdelapenya/tlscert v0.2.0 // indirect + github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect + github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.56.1 // indirect + github.com/moby/moby/client v0.6.2 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect + github.com/riverqueue/river/riverdriver v0.49.0 // indirect + github.com/riverqueue/river/rivershared v0.49.0 // indirect + github.com/sethvargo/go-retry v0.5.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect + github.com/stretchr/testify v1.12.1 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.uber.org/atomic v1.12.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/text v0.43.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..92dc49f --- /dev/null +++ b/go.sum @@ -0,0 +1,303 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 h1:m2xcjRABtG288vnlV3U/PKh/HnPURxZp50RKNUwBxM8= +github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4/go.mod h1:MTUE7ZuZO89FAtAwozGg6AWD83pcdefnEq5gF82MB8s= +github.com/DiheMoe/gl3d v0.0.0-20261022172000-ae409e94c25a h1:VIrsshIISZxzDBJ3aq5ASnfu880eYcA5vFol+YAWcM4= +github.com/DiheMoe/gl3d v0.0.0-20261022172000-ae409e94c25a/go.mod h1:lRy4c4S/LQ5hjKoHpDSZF3NQTRkDlUUt5aIL1RzjEN4= +github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4= +github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= +github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk= +github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.23 h1:/fGPpKrXTjzkhsDXExBgNT2cGwxiF9rLrQXh8RlXMhI= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.23/go.mod h1:QWUDuRi9PFjBHIU2iH1Pkldbe7zqLfXJC4wWLbF8nuQ= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4 h1:MrkWRB58Qw5TEkb51QVooVLjlM3IevO2iVXEz74B8pg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4/go.mod h1:yfIxaKRHasvfy6tNEm3/LpqKsupMzQjMaWj9Y0jMVbo= +github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k= +github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= +github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= +github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo= +github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= +github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao= +github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/mewkiz/flac v1.0.14 h1:hyRGAM8NCKznoPmIi9zz2jyO+nfmxY2ErqBnHZ+gxh4= +github.com/mewkiz/flac v1.0.14/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c= +github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.2 h1:ekSPlMccrZGcw6Scb8T9D39qE0mB+XupU/yS0zNw9N4= +github.com/moby/moby/client v0.6.2/go.mod h1:DGcnO7qmPJrqobx/ggQgQ/AUk+2wgVdQv7uxB0/mEsc= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk= +github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pressly/goose/v3 v3.29.0 h1:dtEnBCT7CqkHIUCdgrXsrmeisaN8gFkRU4Y5OEwif4o= +github.com/pressly/goose/v3 v3.29.0/go.mod h1:plA4aP4+u+Q3FMTgbFbhF8TuAWpAdfWpoFGkJsIFWh0= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= +github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s= +github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/riverqueue/river v0.49.0 h1:JUCLFgregbX1Wu+bSTHCjF/WHGdrwGbtz/ddInHfeb0= +github.com/riverqueue/river v0.49.0/go.mod h1:USYb57gpMBXLQm2l7poskAalOPaf5uHjSZgp2AOoYDw= +github.com/riverqueue/river/riverdriver v0.49.0 h1:kSykNQJNB7AeG6kudjB0mThV29PvykoOBIFT8ipCHEc= +github.com/riverqueue/river/riverdriver v0.49.0/go.mod h1:rVUuX/fTF2kAiJjpPT+10Lft8n90kvLqjm6b+rFXaVE= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.49.0 h1:c7YA1plP/nrNS3SEHuKizf4wHx9OQ87Yxuhj3V0eQ50= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.49.0/go.mod h1:o7zkFstM+Fk+mnlOW2sx92mQW4JswSc6zvN191l8Isg= +github.com/riverqueue/river/rivershared v0.49.0 h1:wnCYVwftMiu85kT1JrUPKEgujKkBIWoRtSNZJTUtotY= +github.com/riverqueue/river/rivershared v0.49.0/go.mod h1:E8UzQAdDutFT8rVL1wZeNbuphmIS81TgngU7/1F4U5E= +github.com/riverqueue/river/rivertype v0.49.0 h1:3up3P2DtOqnM2yEYSC1xeK9AjrljOtIfl10yZlaF4aM= +github.com/riverqueue/river/rivertype v0.49.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw= +github.com/riverqueue/rivercontrib/otelriver v0.12.0 h1:FLY0chUrXJaIsBcxR86bASKdVM/as1qs5LVLRS9/TjY= +github.com/riverqueue/rivercontrib/otelriver v0.12.0/go.mod h1:4+HNqZ7s681x0fDyOvZm++kqo9Gwz1RtSybyM905qJw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/sethvargo/go-retry v0.5.0 h1:JYfFKmVJ8ER06GReN1efzc5cm52BrVsFVzmcHiKJwfY= +github.com/sethvargo/go-retry v0.5.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/testcontainers/testcontainers-go/modules/valkey v0.44.0 h1:knKD2DGjhwV4tWgIalmSFyhkFCCpzH+U0lsxrsgJEoo= +github.com/testcontainers/testcontainers-go/modules/valkey v0.44.0/go.mod h1:Rx2WWz5UBBJSkv2mC81iYnIxCe29SF0DkD7PhWnovXQ= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/valkey-io/valkey-go v1.0.59 h1:W67Z0UY+Qqk3k8NKkFCFlM3X4yQUniixl7dSJAch2Qo= +github.com/valkey-io/valkey-go v1.0.59/go.mod h1:bHmwjIEOrGq/ubOJfh5uMRs7Xj6mV3mQ/ZXUbmqpjqY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= +go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 h1:cFbkNigDInw+wVTXc+CyhUsoHGJCw0BR6mlNoRVfre0= +google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8/go.mod h1:QK6p7IgD7IZEZJBhhSUCib5X2vMOzlrGCGUw2XyVDh0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/libc v1.78.0 h1:Lpm+e9K6ruVYEGwWfuBO1xXtYEaXDouFOG1SuWjFDP4= +modernc.org/libc v1.78.0/go.mod h1:wvxR55nOF2/66NkJoNV7/coXBTCvT3HR22rcqW6lOuA= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go new file mode 100644 index 0000000..fe94cd7 --- /dev/null +++ b/internal/adapter/in/api/api.go @@ -0,0 +1,358 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "runtime/debug" + "slices" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/trace" + + "github.com/dihemoe/alisten/internal/app/admin" + "github.com/dihemoe/alisten/internal/app/catalog" + "github.com/dihemoe/alisten/internal/app/identity" + "github.com/dihemoe/alisten/internal/app/lobby" + "github.com/dihemoe/alisten/internal/app/media" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/internal/app/site" + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/room" + "github.com/dihemoe/alisten/internal/lib/logctx" + "github.com/dihemoe/alisten/protocol" +) + +const ( + maxBodyBytes = 64 << 10 + requestDeadline = 3 * time.Minute +) + +type Services struct { + Identity *identity.Service + Lobby *lobby.Service + Admin *admin.Service + Gateway *realtime.Gateway + Catalog *catalog.Service + Media *media.Library + Playback *media.Playback + Site *site.Service + + // 浏览器访问对象存储时使用的地址前缀,结尾没有 /。 + StorageURL string +} + +type server struct { + Services + tracer trace.Tracer + logger *slog.Logger + commands *prometheus.CounterVec + redirects *prometheus.CounterVec +} + +type call struct { + r *http.Request + body []byte + member member.Member + roomID string + ticket string + // 票据是常驻房间的 API Key。 + bot bool +} + +type handlerFunc func(ctx context.Context, c call) (any, error) + +// 带 Location 的接口,处理函数返回 located。 +type located struct { + location string + body any +} + +func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider, logger *slog.Logger) http.Handler { + commands := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "alisten_room_commands_total", + Help: "房间内请求的数量,按请求类型和结果计数,结果为 ok 或错误码。", + }, []string{"type", "result"}) + redirects := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "alisten_media_redirects_total", + Help: "/media 跳转的数量,source 为 netease 或 qq,结果为 ok 或错误码。", + }, []string{"source", "result"}) + metrics.MustRegister(commands, redirects) + srv := &server{ + Services: s, + tracer: tracer.Tracer("github.com/dihemoe/alisten/internal/adapter/in/api"), + logger: logger, + commands: commands, + redirects: redirects, + } + mux := http.NewServeMux() + srv.handle(mux, protocol.RouteCreateMember, srv.createMember) + srv.handle(mux, protocol.RouteGetMe, srv.getMe) + srv.handle(mux, protocol.RouteUpdateMe, srv.updateMe) + srv.handle(mux, protocol.RouteListRooms, srv.listRooms) + srv.handle(mux, protocol.RouteGetRoom, srv.getRoom) + srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom) + srv.handle(mux, protocol.RouteCreateTicket, srv.createTicket) + srv.handle(mux, protocol.RouteGetSite, srv.getSite) + for _, route := range protocol.RoomRoutes { + srv.handle(mux, route.Route, srv.roomRequest(route)) + } + srv.handle(mux, protocol.RouteAdminListRooms, srv.adminListRooms) + srv.handle(mux, protocol.RouteAdminCreateRoom, srv.adminCreateRoom) + srv.handle(mux, protocol.RouteAdminUpdateRoom, srv.adminUpdateRoom) + srv.handle(mux, protocol.RouteAdminDeleteRoom, srv.adminDeleteRoom) + srv.handle(mux, protocol.RouteAdminGetRoomApiKey, srv.adminGetRoomAPIKey) + srv.handle(mux, protocol.RouteAdminResetRoomApiKey, srv.adminResetRoomAPIKey) + srv.handle(mux, protocol.RouteAdminGetNeteaseCredential, srv.adminGetNeteaseCredential) + srv.handle(mux, protocol.RouteAdminGetQQCredential, srv.adminGetQQCredential) + srv.handle(mux, protocol.RouteAdminStartNeteaseLogin, srv.adminStartNeteaseLogin) + srv.handle(mux, protocol.RouteAdminCheckNeteaseLogin, srv.adminCheckNeteaseLogin) + srv.handle(mux, protocol.RouteAdminStartQQLogin, srv.adminStartQQLogin) + srv.handle(mux, protocol.RouteAdminCheckQQLogin, srv.adminCheckQQLogin) + srv.handle(mux, protocol.RouteAdminGetBilibiliCredential, srv.adminGetBilibiliCredential) + srv.handle(mux, protocol.RouteAdminStartBilibiliLogin, srv.adminStartBilibiliLogin) + srv.handle(mux, protocol.RouteAdminCheckBilibiliLogin, srv.adminCheckBilibiliLogin) + srv.handle(mux, protocol.RouteAdminGetYoutubeCredential, srv.adminGetYoutubeCredential) + srv.handle(mux, protocol.RouteAdminSetYoutubeCredential, srv.adminSetYoutubeCredential) + srv.handle(mux, protocol.RouteAdminDeleteYoutubeCredential, srv.adminDeleteYoutubeCredential) + srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia) + srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia) + srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite) + mux.HandleFunc("GET /ws", srv.websocket) + mux.HandleFunc("GET /api/room/events", srv.roomEvents) + mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia) + // HTTP 的错误响应体是 JSON,而 ServeMux 自带的 404、405 响应是纯文本。不带方法的兜底路由也会匹配方法不符的请求, + // 因此它们得到 404:错误码中没有与 405 对应的。 + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + writeError(w, protocol.ErrorInfo{Code: protocol.ErrorCodeNotFound, Message: "接口不存在"}) + }) + return mux +} + +// 不带 Origin 的请求不检查:bot 从服务端发起的调用没有 Origin。 +func SameOrigin(allowedOrigins []string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" && !slices.Contains(allowedOrigins, origin) { + writeError(w, protocol.ErrorInfo{Code: protocol.ErrorCodeForbidden, Message: "服务器不接受从这个网址发出的操作,请用网站的正式地址打开。"}) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc) { + mux.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), requestDeadline) + defer cancel() + ctx, resp, err := s.serve(ctx, w, r, route.Auth, func(ctx context.Context, c call) (any, error) { + resp, err := fn(ctx, c) + if err != nil || !route.Location { + return resp, err + } + l := resp.(located) + w.Header().Set("Location", l.location) + return l.body, nil + }) + if err != nil { + s.fail(ctx, w, r, err) + return + } + if route.Status == http.StatusNoContent { + w.WriteHeader(http.StatusNoContent) + return + } + writeJSON(w, route.Status, resp) + }) +} + +// 先鉴权再读请求体:后台没有启用时,无论请求是否合法都返回 FORBIDDEN。返回的 ctx 带有凭证所属的成员和房间, +// 出错时的日志也要带上它们。 +func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, fn handlerFunc) (logCtx context.Context, _ any, err error) { + defer func() { + if value := recover(); value != nil { + logCtx, err = ctx, s.panicked(ctx, r, value) + } + }() + c := call{r: r} + switch auth { + case protocol.AuthNone: + case protocol.AuthMember: + m, err := s.Identity.Authenticate(ctx, bearer(r)) + if err != nil { + return ctx, nil, err + } + c.member = m + ctx = logctx.WithMember(ctx, m.ID) + case protocol.AuthTicket: + c.ticket = bearer(r) + holder, err := s.Identity.VerifyTicket(ctx, c.ticket) + if err != nil { + return ctx, nil, err + } + c.roomID, c.member, c.bot = holder.RoomID, holder.Member, holder.Bot + ctx = logctx.WithMember(logctx.WithRoom(ctx, holder.RoomID), holder.Member.ID) + case protocol.AuthAdmin: + if err := s.Admin.Authorize(bearer(r)); err != nil { + return ctx, nil, err + } + default: + // 未知的凭证类型按失败处理,不能当作不需要凭证。 + return ctx, nil, fmt.Errorf("不支持的凭证类型 %d", auth) + } + if r.Method != http.MethodGet && r.Method != http.MethodDelete { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) + if err != nil { + return ctx, nil, bodyError(r, err) + } + c.body = body + } + resp, err := fn(ctx, c) + return ctx, resp, err +} + +// HTTP 的错误响应体是 JSON,所以 panic 要在写出响应之前恢复,交给 fail 回复 INTERNAL。 +func (s *server) panicked(ctx context.Context, r *http.Request, value any) error { + // ErrAbortHandler 表示有意中止响应,net/http 处理它时不记录日志。 + if err, ok := value.(error); ok && errors.Is(err, http.ErrAbortHandler) { + panic(value) + } + s.logger.ErrorContext(ctx, "处理 HTTP 请求时发生 panic", + "panic", fmt.Sprint(value), + "method", r.Method, + "path", r.URL.Path, + "stack", string(debug.Stack()), + ) + return errPanicked +} + +var errUnreadableBody = errors.New("无法读取请求体") + +// 恢复 panic 的地方已经记录了日志,回复时不再重复记录。 +var errPanicked = errors.New("服务器出错了,请稍后再试。") + +// 无法读取请求体是客户端的问题,不是内部错误。连接读取失败时,net/http 在返回错误之前已经取消了 +// r.Context(),此时按客户端断开处理;连接仍在时,是请求体本身不合法,比如分块编码有误。 +func bodyError(r *http.Request, err error) error { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + return err + } + if r.Context().Err() != nil { + return r.Context().Err() + } + return errUnreadableBody +} + +// 认证方案的名称不区分大小写,和凭证之间可以有多个空格(RFC 9110)。 +func bearer(r *http.Request) string { + scheme, credential, ok := strings.Cut(r.Header.Get("Authorization"), " ") + if !ok || !strings.EqualFold(scheme, "Bearer") { + return "" + } + return strings.TrimLeft(credential, " ") +} + +var knownErrors = []struct { + err error + code protocol.ErrorCode +}{ + {errUnreadableBody, protocol.ErrorCodeInvalidArgument}, + {port.ErrRoomNotFound, protocol.ErrorCodeNotFound}, + {port.ErrSlugTaken, protocol.ErrorCodeConflict}, + {media.ErrNotFound, protocol.ErrorCodeNotFound}, + {media.ErrNotInRoom, protocol.ErrorCodeNotFound}, + {media.ErrInUse, protocol.ErrorCodeConflict}, + {identity.ErrInvalidMemberToken, protocol.ErrorCodeUnauthorized}, + {identity.ErrWrongPassword, protocol.ErrorCodeUnauthorized}, + {admin.ErrInvalidToken, protocol.ErrorCodeUnauthorized}, + {admin.ErrDisabled, protocol.ErrorCodeForbidden}, + {admin.ErrUnrecognizedCookies, protocol.ErrorCodeInvalidArgument}, + {identity.ErrInvalidTicket, protocol.ErrorCodeUnauthorized}, + {identity.ErrBotSession, protocol.ErrorCodeUnauthorized}, + {realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument}, + {site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument}, + {realtime.ErrUnavailable, protocol.ErrorCodeUnavailable}, + {realtime.ErrTooManyObservers, protocol.ErrorCodeRateLimited}, + {port.ErrProviderFailed, protocol.ErrorCodeProviderError}, + {media.ErrNoPlayback, protocol.ErrorCodeProviderError}, + {catalog.ErrRateLimited, protocol.ErrorCodeRateLimited}, + {errClientOutdated, protocol.ErrorCodeClientOutdated}, + {errPanicked, protocol.ErrorCodeInternal}, +} + +func errorInfo(err error) (info protocol.ErrorInfo, expected bool) { + var argument *protocol.ArgumentError + if errors.As(err, &argument) { + return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: argument.Error()}, true + } + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: "请求体不能超过 64 KiB"}, true + } + var roomErr *room.Error + if errors.As(err, &roomErr) { + return protocol.ErrorInfo{Code: roomErrorCodes[roomErr.Kind], Message: roomErr.Message}, true + } + var rejectedLink *catalog.RejectedLinkError + if errors.As(err, &rejectedLink) { + return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: rejectedLink.Error()}, true + } + for _, known := range knownErrors { + if errors.Is(err, known.err) { + return protocol.ErrorInfo{Code: known.code, Message: known.err.Error()}, true + } + } + if errors.Is(err, context.DeadlineExceeded) { + return protocol.ErrorInfo{Code: protocol.ErrorCodeUnavailable, Message: "服务器没有及时响应,请稍后再试。"}, true + } + return protocol.ErrorInfo{Code: protocol.ErrorCodeInternal, Message: "服务器出错了,请稍后再试。"}, false +} + +// 请求类型取自解码之后的请求,取值有限,可以作为标签。 +func (s *server) count(typ string, err error) { + s.commands.WithLabelValues(typ, resultLabel(err)).Inc() +} + +func resultLabel(err error) string { + if err == nil { + return "ok" + } + info, _ := errorInfo(err) + return string(info.Code) +} + +func (s *server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, context.Canceled) && r.Context().Err() != nil { + return + } + info, expected := errorInfo(err) + if !expected { + s.logger.ErrorContext(withRequestID(ctx, err), "处理 HTTP 请求失败", "method", r.Method, "path", r.URL.Path, "error", err) + } + writeError(w, info) +} + +func withRequestID(ctx context.Context, err error) context.Context { + var failed *realtime.RequestError + if errors.As(err, &failed) { + return logctx.WithRequest(ctx, string(failed.ID)) + } + return ctx +} + +func writeError(w http.ResponseWriter, info protocol.ErrorInfo) { + writeJSON(w, info.Code.HTTPStatus(), protocol.ErrorResponse{Error: info}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/adapter/in/api/catalog.go b/internal/adapter/in/api/catalog.go new file mode 100644 index 0000000..e4ac161 --- /dev/null +++ b/internal/adapter/in/api/catalog.go @@ -0,0 +1,64 @@ +package api + +import ( + "context" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" + "github.com/dihemoe/alisten/protocol" +) + +func (s *server) answer(ctx context.Context, roomID string, data any) (resp any, handled bool, err error) { + switch d := data.(type) { + case protocol.SearchTracksRequest: + songs, total, err := s.Catalog.SearchTracks(ctx, roomID, track.Source(d.Source), d.Keyword, d.Page, d.PageSize) + if err != nil { + return nil, true, err + } + return protocol.SearchTracksResponse{Items: summaries(songs), Total: total}, true, nil + case protocol.SearchPlaylistsRequest: + found, total, err := s.Catalog.SearchPlaylists(ctx, roomID, track.Source(d.Source), d.Keyword, d.Page, d.PageSize) + if err != nil { + return nil, true, err + } + return protocol.SearchPlaylistsResponse{Items: playlists(d.Source, found), Total: total}, true, nil + case protocol.PlaylistTracksRequest: + songs, err := s.Catalog.PlaylistTracks(ctx, roomID, track.Source(d.Source), d.PlaylistID) + if err != nil { + return nil, true, err + } + return protocol.PlaylistTracksResponse{Items: summaries(songs), Total: int64(len(songs))}, true, nil + case protocol.RecommendListRequest: + songs, err := s.Catalog.RecommendList(ctx, roomID) + if err != nil { + return nil, true, err + } + return protocol.RecommendListResponse{Items: summaries(songs)}, true, nil + } + return nil, false, nil +} + +func summaries(songs []port.Song) []protocol.TrackSummary { + items := make([]protocol.TrackSummary, len(songs)) + for i, s := range songs { + items[i] = protocol.TrackSummary{Track: protocol.Track{Source: protocol.Source(s.Track.Source), ID: s.Track.ID}, Meta: meta(s.Meta)} + } + return items +} + +func playlists(source protocol.Source, found []port.Playlist) []protocol.PlaylistSummary { + items := make([]protocol.PlaylistSummary, len(found)) + for i, p := range found { + items[i] = protocol.PlaylistSummary{ + Source: source, + ID: p.ID, + Name: p.Name, + CoverURL: p.CoverURL, + Creator: p.Creator, + Description: p.Description, + TrackCount: p.TrackCount, + PlayCount: p.PlayCount, + } + } + return items +} diff --git a/internal/adapter/in/api/convert.go b/internal/adapter/in/api/convert.go new file mode 100644 index 0000000..8bf8cdb --- /dev/null +++ b/internal/adapter/in/api/convert.go @@ -0,0 +1,255 @@ +package api + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/dihemoe/alisten/internal/app/media" + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/room" + "github.com/dihemoe/alisten/internal/domain/track" + "github.com/dihemoe/alisten/protocol" +) + +var roomErrorCodes = map[room.ErrorKind]protocol.ErrorCode{ + room.NotFound: protocol.ErrorCodeNotFound, + room.Forbidden: protocol.ErrorCodeForbidden, + room.Conflict: protocol.ErrorCodeConflict, + room.QueueFull: protocol.ErrorCodeQueueFull, + room.RateLimited: protocol.ErrorCodeRateLimited, + room.NothingPlaying: protocol.ErrorCodeNothingPlaying, +} + +// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人,bot 代填了点歌人时换成那个名字。 +func (s *server) action(ctx context.Context, by member.Member, bot bool, data any) (room.Action, member.Member, error) { + switch d := data.(type) { + case protocol.RoomSyncRequest: + return room.Sync{}, by, nil + case protocol.QueueAddRequest: + return s.Gateway.Resolve(ctx, by, bot, pick(d)) + case protocol.QueueRemoveRequest: + return room.Remove{Entry: entryID(d.EntryID)}, by, nil + case protocol.QueueLikeRequest: + return room.Like{Entry: entryID(d.EntryID)}, by, nil + case protocol.PlaybackVoteSkipRequest: + return room.VoteSkip{}, by, nil + case protocol.SettingsSetPlayModeRequest: + return room.SetPlayMode{Mode: playModeOf(d.PlayMode)}, by, nil + case protocol.ChatSendRequest: + return room.Chat{Text: d.Content}, by, nil + } + panic(fmt.Sprintf("未知的房间请求 %T", data)) +} + +// 协议解码保证要么只给出 track,要么只给出 source 和 keyword。 +func pick(d protocol.QueueAddRequest) realtime.Pick { + if d.Track != nil { + t := track.Track{Source: track.Source(d.Track.Source), ID: d.Track.ID} + if t.Source == track.Link { + t.ID = track.LinkID(t.ID) + } + return realtime.Pick{Track: t, Requester: deref(d.Requester)} + } + return realtime.Pick{Source: track.Source(*d.Source), Keyword: *d.Keyword, Requester: deref(d.Requester)} +} + +// 条目 ID 从 1 开始,无法解析的 ID 当作不存在的 0。 +func entryID(id string) room.EntryID { + n, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return 0 + } + return room.EntryID(n) +} + +func (s *server) result(res room.Result) any { + switch res := res.(type) { + case room.Done: + return protocol.Empty{} + case room.EnqueueResult: + return protocol.QueueAddResponse{Entry: s.entry(res.Entry)} + case room.LikeResult: + return protocol.QueueLikeResponse{Likes: int64(res.Likes)} + case room.VoteResult: + return protocol.PlaybackVoteSkipResponse{Votes: int64(res.Votes), Required: int64(res.Required), Skipped: res.Skipped} + case room.SyncResult: + return protocol.RoomSyncResponse{Room: s.snapshot(res.View)} + } + panic(fmt.Sprintf("未知的房间回复 %T", res)) +} + +func (s *server) event(e room.Event) (string, any) { + switch e := e.(type) { + case room.PlaybackChanged: + return protocol.EventPlaybackUpdated, protocol.PlaybackUpdatedEvent{NowPlaying: s.nowPlaying(e.Current)} + case room.QueueChanged: + return protocol.EventQueueUpdated, protocol.QueueUpdatedEvent{Pending: s.entryPtr(e.Pending), Items: s.entries(e.Waiting)} + case room.MembersChanged: + return protocol.EventMembersUpdated, members(e.Sessions) + case room.ModeChanged: + return protocol.EventSettingsUpdated, protocol.SettingsUpdatedEvent{PlayMode: playMode(e.Mode)} + case room.InfoChanged: + return protocol.EventRoomUpdated, protocol.RoomUpdatedEvent{Name: e.Name, Description: e.Description} + case room.ChatPosted: + return protocol.EventChatMessage, protocol.ChatMessageEvent{Member: memberDTO(e.Member), Content: e.Text, SentAtMs: e.SentAt.UnixMilli()} + case room.Activity: + return protocol.EventRoomActivity, s.activity(e) + } + panic(fmt.Sprintf("未知的房间事件 %T", e)) +} + +func (s *server) snapshot(v room.View) protocol.RoomSnapshot { + return protocol.RoomSnapshot{ + Term: int64(v.Term), + Seq: int64(v.Seq), + Room: protocol.RoomInfo{ID: v.Snapshot.Info.ID, Name: v.Snapshot.Info.Name, Description: v.Snapshot.Info.Description, Kind: roomKind(v.Snapshot.Info.Kind)}, + PlayMode: playMode(v.Snapshot.Mode), + NowPlaying: s.nowPlaying(v.Snapshot.Current), + Pending: s.entryPtr(v.Snapshot.Pending), + Items: s.entries(v.Snapshot.Waiting), + Members: members(v.Sessions), + } +} + +func (s *server) nowPlaying(p *room.NowPlaying) *protocol.NowPlaying { + if p == nil { + return nil + } + return &protocol.NowPlaying{ + Entry: s.entry(p.Entry), + MediaURL: s.mediaURL(p.Entry.Track), + StartedAtMs: p.StartedAt.UnixMilli(), + DurationMs: p.Entry.Prep.Duration.Milliseconds(), + Gain: p.Entry.Prep.Gain, + Lyric: p.Entry.Prep.Lyric, + } +} + +func (s *server) mediaURL(t track.Track) string { + if media.Stored(t.Source) { + return s.StorageURL + "/" + media.AudioObject(media.Key(t.ID)) + } + return "/media/" + string(t.Source) + "/" + url.PathEscape(t.ID) +} + +func (s *server) entry(e room.Entry) protocol.Entry { + d := protocol.Entry{ + ID: strconv.FormatUint(uint64(e.ID), 10), + Track: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID}, + Meta: meta(e.Meta), + Auto: e.Auto, + Likes: int64(e.Likes), + Status: protocol.EntryStatusPreparing, + } + if e.Meta.CoverKey != "" { + d.Meta.CoverURL = s.StorageURL + "/" + string(e.Meta.CoverKey) + } + if !e.Auto { + by := memberDTO(e.By) + d.By = &by + } + if e.Ready { + d.Status = protocol.EntryStatusReady + } + return d +} + +func (s *server) entryPtr(e *room.Entry) *protocol.Entry { + if e == nil { + return nil + } + d := s.entry(*e) + return &d +} + +func (s *server) entries(es []room.Entry) []protocol.Entry { + items := make([]protocol.Entry, len(es)) + for i, e := range es { + items[i] = s.entry(e) + } + return items +} + +func meta(m track.Meta) protocol.TrackMeta { + return protocol.TrackMeta{Title: m.Title, Artist: m.Artist, Album: m.Album, CoverURL: m.CoverURL, PageURL: m.PageURL, DurationMs: m.Duration.Milliseconds()} +} + +func members(sessions []room.Session) protocol.Members { + list := make([]protocol.Member, len(sessions)) + for i, s := range sessions { + list[i] = memberDTO(s.Member) + } + return protocol.Members{Sessions: list, OnlineCount: int64(len(sessions))} +} + +var activityKinds = map[room.ActivityKind]protocol.ActivityKind{ + room.ActivityAdded: protocol.ActivityKindAdded, + room.ActivityRemoved: protocol.ActivityKindRemoved, + room.ActivityLiked: protocol.ActivityKindLiked, + room.ActivityVoted: protocol.ActivityKindVoted, + room.ActivitySkipped: protocol.ActivityKindSkipped, + room.ActivityFailed: protocol.ActivityKindFailed, +} + +func (s *server) activity(a room.Activity) protocol.RoomActivityEvent { + e := s.entry(a.Entry) + d := protocol.RoomActivityEvent{Kind: activityKinds[a.Kind], Entry: &e, Message: activityMessage(a)} + if a.Member != (member.Member{}) { + m := memberDTO(a.Member) + d.Member = &m + } + if a.Kind == room.ActivityVoted || a.Kind == room.ActivitySkipped { + votes, required := int64(a.Votes), int64(a.Required) + d.Votes, d.Required = &votes, &required + } + return d +} + +// 歌名里已经有书名号时不再加一层:B 站的标题常带《》。 +func bookTitle(title string) string { + if strings.ContainsAny(title, "《》") { + return title + } + return "《" + title + "》" +} + +func activityMessage(a room.Activity) string { + title := a.Entry.Meta.Title + switch a.Kind { + case room.ActivityAdded: + return "点了" + bookTitle(title) + case room.ActivityRemoved: + return "移除了" + bookTitle(title) + case room.ActivityLiked: + return fmt.Sprintf("给%s点了赞,共 %d 个赞", bookTitle(title), a.Entry.Likes) + case room.ActivityVoted: + return fmt.Sprintf("投票切歌(%d/%d)", a.Votes, a.Required) + case room.ActivitySkipped: + return "投票切歌成功,换下一首" + } + return fmt.Sprintf("%s没能准备好:%s", bookTitle(title), a.Reason) +} + +func playMode(m room.PlayMode) protocol.PlayMode { + switch m { + case room.Random: + return protocol.PlayModeRandom + case room.Rotation: + return protocol.PlayModeRotation + } + return protocol.PlayModeSequential +} + +func playModeOf(m protocol.PlayMode) room.PlayMode { + switch m { + case protocol.PlayModeRandom: + return room.Random + case protocol.PlayModeRotation: + return room.Rotation + } + return room.Sequential +} diff --git a/internal/adapter/in/api/events.go b/internal/adapter/in/api/events.go new file mode 100644 index 0000000..81b9114 --- /dev/null +++ b/internal/adapter/in/api/events.go @@ -0,0 +1,141 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/protocol" +) + +const ( + // 小于入口的读超时:nginx 默认 60 秒收不到数据就断开。 + streamHeartbeat = 25 * time.Second + // 客户端不再读取时,写入在这个期限之后失败,事件流随之结束:否则关闭事件流(包括退出时)要一直等阻塞的写入。 + streamWriteTimeout = 10 * time.Second +) + +var streamCloses = map[realtime.CloseReason]streamClose{ + realtime.CloseRestart: {Code: protocol.CloseServiceRestart, Message: "服务端实例退出或订阅中断,请重新连接"}, + realtime.CloseRevoked: {Code: protocol.CloseUnauthorized, Message: "凭证已失效:房间设置或修改了密码,或者 API Key 已重置"}, + realtime.CloseRoomGone: {Code: protocol.CloseNotFound, Message: "房间已关闭或被删除"}, + realtime.CloseOverflow: {Code: protocol.CloseSendQueueFull, Message: "积压的事件过多,请重新连接"}, + // 不是 1012:共用一把 Key 的两个 bot 看到它就该停下,而不是重连之后互相顶掉。 + realtime.CloseReplaced: {Code: 4409, Message: "同一凭证打开了新的事件流"}, +} + +type streamClose struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// 房间的事件流:先发 snapshot,data 与 room.sync 的响应相同;之后是房间事件,data 与 WebSocket 上的报文相同; +// 结束之前发 close,code 沿用 WebSocket 的关闭码,让给同一凭证的新事件流时是 4409。每次连接都从新快照开始, +// 不按 Last-Event-ID 补发断开期间的事件。 +func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) { + // 打开事件流的期限与 WebSocket 的握手相同,打开之后的推送不设期限。 + setup, cancel := context.WithTimeout(r.Context(), helloTimeout) + ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, s.observe) + cancel() + if err != nil { + s.fail(ctx, w, r, err) + return + } + o := opened.(*realtime.Observer) + defer s.Gateway.Unobserve(o) + header := w.Header() + header.Set("Content-Type", "text/event-stream") + header.Set("Cache-Control", "no-cache") + // nginx 默认缓冲 /api/ 的响应。 + header.Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + stream := eventStream{w: w, rc: http.NewResponseController(w), logger: s.logger} + for { + wait, cancel := context.WithTimeout(r.Context(), streamHeartbeat) + m, reason, err := o.Next(wait) + cancel() + switch { + case r.Context().Err() != nil: + return + case err != nil: + if !stream.write([]byte(": ping\n\n")) { + return + } + case reason != 0: + stream.event(ctx, "close", "", streamCloses[reason]) + return + default: + name, id, data := s.streamMessage(m) + if !stream.event(ctx, name, id, data) { + return + } + } + } +} + +// 确认票据失败或 panic 时都要退订,否则事件流的名额和房间频道一直被占着。 +func (s *server) observe(ctx context.Context, c call) (any, error) { + // 内存中只留凭证的哈希。 + credential := sha256.Sum256([]byte(c.ticket)) + o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.bot, string(credential[:])) + if err != nil { + return nil, err + } + confirmed := false + defer func() { + if !confirmed { + s.Gateway.Unobserve(o) + } + }() + // 作废时先删除票据、再通知断开:事件流在本实例生效之前发出的通知它收不到,所以生效之后再确认一次票据。 + if _, err := s.Identity.VerifyTicket(ctx, c.ticket); err != nil { + return nil, err + } + confirmed = true + return o, nil +} + +func (s *server) streamMessage(m realtime.Message) (name, id string, data any) { + switch m := m.(type) { + case realtime.Snapshot: + return "snapshot", "", protocol.RoomSyncResponse{Room: s.snapshot(m.View)} + case realtime.Event: + msg := s.message(m, "") + return msg.Type, strconv.FormatInt(msg.Seq, 10), msg + } + panic(fmt.Sprintf("未知的事件流消息 %T", m)) +} + +type eventStream struct { + w http.ResponseWriter + rc *http.ResponseController + logger *slog.Logger +} + +// data 只占一行:json.Marshal 的输出中没有换行。 +func (st eventStream) event(ctx context.Context, name, id string, data any) bool { + payload, err := json.Marshal(data) + if err != nil { + st.logger.ErrorContext(ctx, "编码事件流的消息失败", "event", name, "error", err) + return false + } + frame := "event: " + name + "\n" + if id != "" { + frame += "id: " + id + "\n" + } + return st.write([]byte(frame + "data: " + string(payload) + "\n\n")) +} + +func (st eventStream) write(frame []byte) bool { + _ = st.rc.SetWriteDeadline(time.Now().Add(streamWriteTimeout)) + if _, err := st.w.Write(frame); err != nil { + return false + } + return st.rc.Flush() == nil +} diff --git a/internal/adapter/in/api/handlers.go b/internal/adapter/in/api/handlers.go new file mode 100644 index 0000000..9dc2a65 --- /dev/null +++ b/internal/adapter/in/api/handlers.go @@ -0,0 +1,206 @@ +package api + +import ( + "context" + "regexp" + + "github.com/dihemoe/alisten/internal/app/admin" + "github.com/dihemoe/alisten/internal/app/identity" + "github.com/dihemoe/alisten/internal/app/lobby" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/room" + "github.com/dihemoe/alisten/protocol" +) + +// 路径中的房间 ID 和别名不经过协议解码。格式不正确的房间 ID 或别名一定不存在,其中含有 U+0000 等字符的还会让 +// PostgreSQL 报错,所以直接按不存在处理。 +var ( + uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) +) + +func (s *server) createMember(ctx context.Context, c call) (any, error) { + if _, err := protocol.DecodeCreateMemberRequest(c.body); err != nil { + return nil, err + } + session, err := s.Identity.CreateMember(ctx) + if err != nil { + return nil, err + } + return protocol.CreateMemberResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member)}, nil +} + +func (s *server) getMe(_ context.Context, c call) (any, error) { + return protocol.GetMeResponse{Member: memberDTO(c.member)}, nil +} + +func (s *server) updateMe(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeUpdateMeRequest(c.body) + if err != nil { + return nil, err + } + m, err := s.Identity.UpdateProfile(ctx, c.member.ID, identity.Profile{Name: req.Name, Email: req.Email}) + if err != nil { + return nil, err + } + return protocol.UpdateMeResponse{Member: memberDTO(m)}, nil +} + +func (s *server) listRooms(ctx context.Context, _ call) (any, error) { + rooms, err := s.Lobby.ListRooms(ctx) + if err != nil { + return nil, err + } + return protocol.ListRoomsResponse{Items: listings(rooms)}, nil +} + +func (s *server) getRoom(ctx context.Context, c call) (any, error) { + ref := c.r.PathValue("ref") + if !uuidPattern.MatchString(ref) && !slugPattern.MatchString(ref) { + return nil, port.ErrRoomNotFound + } + r, err := s.Lobby.FindRoom(ctx, ref) + if err != nil { + return nil, err + } + return listing(r), nil +} + +func (s *server) createRoom(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeCreateRoomRequest(c.body) + if err != nil { + return nil, err + } + id, err := s.Lobby.CreateRoom(ctx, lobby.NewRoom{Name: req.Name, Description: req.Description, Password: deref(req.Password)}) + if err != nil { + return nil, err + } + return located{location: "/api/rooms/" + id, body: protocol.CreateRoomResponse{ID: id}}, nil +} + +func (s *server) createTicket(ctx context.Context, c call) (any, error) { + id := c.r.PathValue("id") + if !uuidPattern.MatchString(id) { + return nil, port.ErrRoomNotFound + } + req, err := protocol.DecodeCreateTicketRequest(c.body) + if err != nil { + return nil, err + } + ticket, err := s.Identity.IssueTicket(ctx, c.member.ID, id, deref(req.Password)) + if err != nil { + return nil, err + } + return protocol.CreateTicketResponse{Ticket: ticket}, nil +} + +func (s *server) adminListRooms(ctx context.Context, _ call) (any, error) { + rooms, err := s.Admin.ListRooms(ctx) + if err != nil { + return nil, err + } + return protocol.AdminListRoomsResponse{Items: listings(rooms)}, nil +} + +func (s *server) adminCreateRoom(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeAdminCreateRoomRequest(c.body) + if err != nil { + return nil, err + } + id, err := s.Admin.CreateRoom(ctx, admin.NewRoom{ + Name: req.Name, + Description: req.Description, + Slug: deref(req.Slug), + Password: deref(req.Password), + }) + if err != nil { + return nil, err + } + return located{location: "/api/rooms/" + id, body: protocol.AdminCreateRoomResponse{ID: id}}, nil +} + +func (s *server) adminGetNeteaseCredential(ctx context.Context, _ call) (any, error) { + status, err := s.Admin.NeteaseCredentialStatus(ctx) + if err != nil { + return nil, err + } + return protocol.AdminGetNeteaseCredentialResponse{Set: status.Set, Account: sourceAccountDTO(status.Account)}, nil +} + +func (s *server) adminGetQQCredential(ctx context.Context, _ call) (any, error) { + status, err := s.Admin.QQCredentialStatus(ctx) + if err != nil { + return nil, err + } + return protocol.AdminGetQQCredentialResponse{Set: status.Set, Account: sourceAccountDTO(status.Account)}, nil +} + +func (s *server) adminGetBilibiliCredential(ctx context.Context, _ call) (any, error) { + status, err := s.Admin.BilibiliCredentialStatus(ctx) + if err != nil { + return nil, err + } + return protocol.AdminGetBilibiliCredentialResponse{Set: status.Set, Account: sourceAccountDTO(status.Account)}, nil +} + +func (s *server) adminGetYoutubeCredential(ctx context.Context, _ call) (any, error) { + status, err := s.Admin.YouTubeCookiesStatus(ctx) + if err != nil { + return nil, err + } + resp := protocol.AdminGetYoutubeCredentialResponse{Set: status.Set} + // 零值的 UnixMilli 是负数,协议约定没有填写时为 0。 + if !status.UpdatedAt.IsZero() { + resp.UpdatedAtMs = status.UpdatedAt.UnixMilli() + } + return resp, nil +} + +func sourceAccountDTO(account *port.SourceAccount) *protocol.SourceAccount { + if account == nil { + return nil + } + return &protocol.SourceAccount{ID: account.ID, Name: account.Name, Vip: account.VIP, Verified: account.Verified} +} + +func memberDTO(m member.Member) protocol.Member { + return protocol.Member{ID: m.ID, Name: m.Name, AvatarHash: m.AvatarHash} +} + +func listings(rooms []lobby.Room) []protocol.RoomListing { + items := make([]protocol.RoomListing, len(rooms)) + for i, r := range rooms { + items[i] = listing(r) + } + return items +} + +func listing(r lobby.Room) protocol.RoomListing { + l := protocol.RoomListing{ + ID: r.Info.ID, + Name: r.Info.Name, + Description: r.Info.Description, + Kind: roomKind(r.Info.Kind), + HasPassword: r.HasPassword, + OnlineCount: int64(r.Online), + } + if r.Slug != "" { + l.Slug = &r.Slug + } + return l +} + +func roomKind(k room.Kind) protocol.RoomKind { + if k == room.Persistent { + return protocol.RoomKindPersistent + } + return protocol.RoomKindEphemeral +} + +func deref(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/internal/adapter/in/api/media.go b/internal/adapter/in/api/media.go new file mode 100644 index 0000000..808d897 --- /dev/null +++ b/internal/adapter/in/api/media.go @@ -0,0 +1,75 @@ +package api + +import ( + "context" + "net/http" + "regexp" + "time" + + "github.com/dihemoe/alisten/internal/app/media" + "github.com/dihemoe/alisten/internal/domain/track" + "github.com/dihemoe/alisten/protocol" +) + +func (s *server) redirectMedia(w http.ResponseWriter, r *http.Request) { + source := track.Source(r.PathValue("source")) + if source != track.NetEase && source != track.QQ { + writeError(w, protocol.ErrorInfo{Code: protocol.ErrorCodeNotFound, Message: "接口不存在"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), requestDeadline) + defer cancel() + target, err := s.redirectTarget(ctx, r, track.Track{Source: source, ID: r.PathValue("id")}) + // 客户端已断开不是服务端的错误,不计入跳转的结果。 + if r.Context().Err() != nil { + return + } + s.redirects.WithLabelValues(string(source), resultLabel(err)).Inc() + if err != nil { + s.fail(ctx, w, r, err) + return + } + // 播放地址有时效,不能让浏览器缓存这次跳转。 + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, target, http.StatusFound) +} + +func (s *server) redirectTarget(ctx context.Context, r *http.Request, t track.Track) (_ string, err error) { + defer func() { + if value := recover(); value != nil { + err = s.panicked(ctx, r, value) + } + }() + return s.Playback.URL(ctx, t, time.Now()) +} + +var mediaKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func (s *server) adminListMedia(ctx context.Context, _ call) (any, error) { + list, err := s.Media.List(ctx) + if err != nil { + return nil, err + } + items := make([]protocol.StoredMedia, len(list)) + for i, m := range list { + link := m.Link + // 后台把 link 当作链接打开,B 站的歌给出视频页面。 + if m.Source == track.Bilibili { + link = track.BilibiliPageURL(m.Link) + } + items[i] = protocol.StoredMedia{Key: m.Key, Source: protocol.Source(m.Source), Link: link, Title: m.Title, Size: m.Size, StoredAtMs: m.StoredAt.UnixMilli()} + if m.LastPlayedAt != nil { + played := m.LastPlayedAt.UnixMilli() + items[i].LastPlayedAtMs = &played + } + } + return protocol.AdminListMediaResponse{Items: items}, nil +} + +func (s *server) adminDeleteMedia(ctx context.Context, c call) (any, error) { + key := c.r.PathValue("key") + if !mediaKeyPattern.MatchString(key) { + return nil, media.ErrNotFound + } + return nil, s.Media.Delete(ctx, key) +} diff --git a/internal/adapter/in/api/qrlogin.go b/internal/adapter/in/api/qrlogin.go new file mode 100644 index 0000000..d97b4d5 --- /dev/null +++ b/internal/adapter/in/api/qrlogin.go @@ -0,0 +1,101 @@ +package api + +import ( + "context" + "encoding/base64" + "fmt" + + "rsc.io/qr" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/protocol" +) + +func (s *server) adminStartNeteaseLogin(ctx context.Context, c call) (any, error) { + if _, err := protocol.DecodeAdminStartNeteaseLoginRequest(c.body); err != nil { + return nil, err + } + session, err := s.Admin.CreateNeteaseQRLogin(ctx) + if err != nil { + return nil, err + } + return qrLogin(session) +} + +func (s *server) adminCheckNeteaseLogin(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeAdminCheckNeteaseLoginRequest(c.body) + if err != nil { + return nil, err + } + result, err := s.Admin.CheckNeteaseQRLogin(ctx, req.Key) + if err != nil { + return nil, err + } + return qrLoginResult(result), nil +} + +func (s *server) adminStartQQLogin(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeAdminStartQQLoginRequest(c.body) + if err != nil { + return nil, err + } + session, err := s.Admin.CreateQQQRLogin(ctx, port.QQLoginType(req.Type)) + if err != nil { + return nil, err + } + return qrLogin(session) +} + +func (s *server) adminCheckQQLogin(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeAdminCheckQQLoginRequest(c.body) + if err != nil { + return nil, err + } + result, err := s.Admin.CheckQQQRLogin(ctx, port.QQLoginType(req.Type), req.Key) + if err != nil { + return nil, err + } + return qrLoginResult(result), nil +} + +func (s *server) adminStartBilibiliLogin(ctx context.Context, c call) (any, error) { + if _, err := protocol.DecodeAdminStartBilibiliLoginRequest(c.body); err != nil { + return nil, err + } + session, err := s.Admin.CreateBilibiliQRLogin(ctx) + if err != nil { + return nil, err + } + return qrLogin(session) +} + +func (s *server) adminCheckBilibiliLogin(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeAdminCheckBilibiliLoginRequest(c.body) + if err != nil { + return nil, err + } + result, err := s.Admin.CheckBilibiliQRLogin(ctx, req.Key) + if err != nil { + return nil, err + } + return qrLoginResult(result), nil +} + +func qrLogin(session port.QRLoginSession) (protocol.QRLogin, error) { + image := session.QRImage + if image == "" { + if session.LoginURL == "" { + return protocol.QRLogin{}, fmt.Errorf("音源没有给出二维码图片和登录链接:%w", port.ErrProviderFailed) + } + code, err := qr.Encode(session.LoginURL, qr.M) + if err != nil { + return protocol.QRLogin{}, fmt.Errorf("把登录链接编码成二维码:%w:%w", err, port.ErrProviderFailed) + } + image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()) + } + return protocol.QRLogin{Key: session.Key, QrImage: image, ExpiresAtMs: session.ExpiresAt.UnixMilli()}, nil +} + +func qrLoginResult(r port.QRLoginResult) protocol.QRLoginResult { + return protocol.QRLoginResult{Status: protocol.QRLoginStatus(r.Status), Message: r.Message} +} diff --git a/internal/adapter/in/api/room.go b/internal/adapter/in/api/room.go new file mode 100644 index 0000000..9f5775d --- /dev/null +++ b/internal/adapter/in/api/room.go @@ -0,0 +1,98 @@ +package api + +import ( + "context" + + "github.com/dihemoe/alisten/internal/app/admin" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/protocol" +) + +// bot 没有会话,用房间票据通过 HTTP 发送房间内请求,不出现在在线列表中。 +func (s *server) roomRequest(route protocol.RoomRoute) handlerFunc { + return func(ctx context.Context, c call) (_ any, err error) { + data, err := route.Decode(c.r.PathValue, c.r.URL.RawQuery, c.body) + if err != nil { + return nil, err + } + // 与 WebSocket 一致,客户端已断开的请求不计数。panic 由 serve 恢复并回复 500,这里按 INTERNAL 计数后原样抛出。 + defer func() { + if value := recover(); value != nil { + s.count(route.Type, errPanicked) + panic(value) + } + if c.r.Context().Err() == nil { + s.count(route.Type, err) + } + }() + if resp, handled, err := s.answer(ctx, c.roomID, data); handled { + return resp, err + } + a, by, err := s.action(ctx, c.member, c.bot, data) + if err != nil { + return nil, err + } + res, err := s.Gateway.Do(ctx, c.roomID, by, a) + if err != nil { + return nil, err + } + resp := s.result(res) + if added, ok := resp.(protocol.QueueAddResponse); ok { + return located{location: "/api/room/queue/" + added.Entry.ID, body: added}, nil + } + return resp, nil + } +} + +func (s *server) adminUpdateRoom(ctx context.Context, c call) (any, error) { + id := c.r.PathValue("id") + if !uuidPattern.MatchString(id) { + return nil, port.ErrRoomNotFound + } + req, err := protocol.DecodeAdminUpdateRoomRequest(c.body) + if err != nil { + return nil, err + } + change := admin.RoomChange{Name: req.Name, Description: req.Description, Slug: req.Slug, Password: req.Password} + return nil, s.Admin.UpdateRoom(ctx, id, change) +} + +func (s *server) adminGetRoomAPIKey(ctx context.Context, c call) (any, error) { + id := c.r.PathValue("id") + if !uuidPattern.MatchString(id) { + return nil, port.ErrRoomNotFound + } + status, err := s.Admin.RoomAPIKeyStatus(ctx, id) + if err != nil { + return nil, err + } + resp := protocol.AdminGetRoomApiKeyResponse{Set: status.Set} + // 零值的 UnixMilli 是负数,协议约定没有 Key 时为 0。 + if !status.CreatedAt.IsZero() { + resp.CreatedAtMs = status.CreatedAt.UnixMilli() + } + return resp, nil +} + +func (s *server) adminResetRoomAPIKey(ctx context.Context, c call) (any, error) { + id := c.r.PathValue("id") + if !uuidPattern.MatchString(id) { + return nil, port.ErrRoomNotFound + } + if _, err := protocol.DecodeAdminResetRoomApiKeyRequest(c.body); err != nil { + return nil, err + } + key, err := s.Admin.ResetRoomAPIKey(ctx, id) + if err != nil { + return nil, err + } + return located{location: "/api/admin/rooms/" + id + "/api-key", body: protocol.APIKey{Key: key}}, nil +} + +func (s *server) adminDeleteRoom(ctx context.Context, c call) (any, error) { + id := c.r.PathValue("id") + if !uuidPattern.MatchString(id) { + return nil, port.ErrRoomNotFound + } + return nil, s.Admin.DeleteRoom(ctx, id) +} diff --git a/internal/adapter/in/api/site.go b/internal/adapter/in/api/site.go new file mode 100644 index 0000000..c4105c3 --- /dev/null +++ b/internal/adapter/in/api/site.go @@ -0,0 +1,27 @@ +package api + +import ( + "context" + + "github.com/dihemoe/alisten/protocol" +) + +func (s *server) getSite(ctx context.Context, _ call) (any, error) { + name, err := s.Site.BrandName(ctx) + if err != nil { + return nil, err + } + return protocol.GetSiteResponse{BrandName: name}, nil +} + +func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeAdminUpdateSiteRequest(c.body) + if err != nil { + return nil, err + } + name, err := s.Site.SetBrandName(ctx, req.BrandName) + if err != nil { + return nil, err + } + return protocol.AdminUpdateSiteResponse{BrandName: name}, nil +} diff --git a/internal/adapter/in/api/ws.go b/internal/adapter/in/api/ws.go new file mode 100644 index 0000000..4d6d993 --- /dev/null +++ b/internal/adapter/in/api/ws.go @@ -0,0 +1,299 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "runtime/debug" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/coder/websocket" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/internal/lib/logctx" + "github.com/dihemoe/alisten/protocol" +) + +const ( + helloTimeout = 10 * time.Second + idleTimeout = 30 * time.Second +) + +var errClientOutdated = errors.New("前端与服务端的协议版本不一致,请刷新页面") + +var handshakeCloseCodes = map[protocol.ErrorCode]websocket.StatusCode{ + protocol.ErrorCodeInvalidArgument: protocol.CloseInvalidArgument, + protocol.ErrorCodeInternal: protocol.CloseInternal, + protocol.ErrorCodeUnavailable: protocol.CloseUnavailable, + protocol.ErrorCodeUnauthorized: protocol.CloseUnauthorized, + protocol.ErrorCodeNotFound: protocol.CloseNotFound, + protocol.ErrorCodeClientOutdated: protocol.CloseClientOutdated, +} + +var sessionCloseCodes = map[realtime.CloseReason]websocket.StatusCode{ + realtime.CloseRestart: protocol.CloseServiceRestart, + realtime.CloseRevoked: protocol.CloseUnauthorized, + realtime.CloseRoomGone: protocol.CloseNotFound, + realtime.CloseOverflow: protocol.CloseSendQueueFull, +} + +type requestTag struct { + typ string + id string +} + +func (s *server) websocket(w http.ResponseWriter, r *http.Request) { + var lastFrame atomic.Int64 + touch := func() { lastFrame.Store(time.Now().UnixNano()) } + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + // Accept 默认要求 Origin 与 Host 头一致,而经过入口转发时 Host 不一定与浏览器页面的 Origin 相同。 + InsecureSkipVerify: true, + OnPingReceived: func(context.Context, []byte) bool { + touch() + return true + }, + OnPongReceived: func(context.Context, []byte) { touch() }, + }) + if err != nil { + return + } + defer func() { _ = conn.CloseNow() }() + conn.SetReadLimit(maxBodyBytes) + // 连接被 Hijack 之后,请求的 context 不再反映连接的状态。 + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 超时时先以 4408 关闭连接,再取消 ctx:如果先取消,正在读取第一帧的 Read 会直接断开连接, + // 客户端收不到 4408。 + timeout := time.AfterFunc(helloTimeout, func() { + _ = conn.Close(protocol.CloseHandshakeTimeout, "") + cancel() + }) + sess, helloID := s.handshake(ctx, conn) + if !timeout.Stop() { + if sess != nil { + s.Gateway.Leave(sess) + } + return + } + if sess == nil { + return + } + defer s.Gateway.Leave(sess) + ctx = logctx.WithMember(logctx.WithRoom(ctx, sess.Room()), sess.Member().ID) + + touch() + go watchIdle(ctx, conn, &lastFrame) + go s.write(ctx, conn, sess, helloID) + for { + typ, frame, err := conn.Read(ctx) + if err != nil { + return + } + touch() + s.dispatch(ctx, conn, sess, typ, frame) + } +} + +func (s *server) handshake(ctx context.Context, conn *websocket.Conn) (*realtime.Session, string) { + typ, frame, err := conn.Read(ctx) + if err != nil { + return nil, "" + } + // span 在读到第一帧之后才开始,等待客户端发送的时间不计入。 + ctx, span := s.tracer.Start(ctx, "WS hello", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + req, err := decodeFrame(typ, frame) + if err == nil { + var sess *realtime.Session + if sess, err = s.hello(ctx, req); err == nil { + span.SetAttributes(attribute.String("alisten.room_id", sess.Room())) + return sess, req.ID + } + } + info := s.requestError(ctx, req.Type, err) + s.reject(ctx, conn, req, info) + _ = conn.Close(handshakeCloseCodes[info.Code], "") + return nil, "" +} + +func (s *server) hello(ctx context.Context, req protocol.Request) (*realtime.Session, error) { + if req.Type != protocol.RequestHello { + return nil, &protocol.ArgumentError{Reason: "第一帧必须是 hello"} + } + hello := req.Data.(protocol.HelloRequest) + if hello.ProtocolVersion != protocol.Version { + return nil, errClientOutdated + } + // 不是 UUID 的值会被当作别名查找。 + if !uuidPattern.MatchString(hello.RoomID) { + return nil, port.ErrRoomNotFound + } + return s.Identity.EnterRoom(ctx, hello.RoomID, hello.Ticket) +} + +func decodeFrame(typ websocket.MessageType, frame []byte) (protocol.Request, error) { + if typ != websocket.MessageText || !utf8.Valid(frame) { + return protocol.Request{}, &protocol.ArgumentError{Reason: "只接受 UTF-8 编码的 JSON 文本帧"} + } + return protocol.DecodeRequest(frame) +} + +func (s *server) dispatch(ctx context.Context, conn *websocket.Conn, sess *realtime.Session, typ websocket.MessageType, frame []byte) { + req, err := decodeFrame(typ, frame) + if err == nil && req.Type == protocol.RequestHello { + err = &protocol.ArgumentError{Reason: "已经完成握手"} + } + if err != nil { + s.reject(sess.Context(), conn, req, s.requestError(ctx, req.Type, err)) + return + } + if req.Type == protocol.RequestPing { + ping := req.Data.(protocol.PingRequest) + s.writeMessage(sess.Context(), conn, protocol.Response(req.Type, req.ID, protocol.PingResponse{ClientTimeMs: ping.ClientTimeMs, ServerTimeMs: time.Now().UnixMilli()})) + return + } + if !sess.Begin() { + s.commands.WithLabelValues(req.Type, string(protocol.ErrorCodeRateLimited)).Inc() + s.reject(sess.Context(), conn, req, protocol.ErrorInfo{Code: protocol.ErrorCodeRateLimited, Message: "操作太快了,请稍后再试。"}) + return + } + go s.command(ctx, sess, req) +} + +func (s *server) command(ctx context.Context, sess *realtime.Session, req protocol.Request) { + ctx, cancel := context.WithTimeout(ctx, requestDeadline) + defer cancel() + ctx, span := s.tracer.Start(ctx, "WS "+req.Type, trace.WithSpanKind(trace.SpanKindServer), trace.WithAttributes(attribute.String("alisten.room_id", sess.Room()))) + defer span.End() + tag := requestTag{typ: req.Type, id: req.ID} + // 这个 goroutine 不在 net/http 的恢复范围之内,panic 会让进程退出,本实例上的房间随之全部停止。 + defer func() { + if value := recover(); value != nil { + s.logger.ErrorContext(ctx, "处理 WebSocket 请求时发生 panic", "panic", fmt.Sprint(value), "type", req.Type, "stack", string(debug.Stack())) + s.requestError(ctx, req.Type, errPanicked) + sess.Fail(tag, errPanicked) + } + }() + if err := s.perform(ctx, sess, tag, req.Data); err != nil { + s.requestError(ctx, req.Type, err) + } +} + +func (s *server) perform(ctx context.Context, sess *realtime.Session, tag requestTag, data any) error { + if resp, handled, err := s.answer(ctx, sess.Room(), data); handled { + if err != nil { + sess.Fail(tag, err) + } else { + sess.Answer(tag, resp) + } + return err + } + // 握手拒绝了 API Key,会话的成员不是 bot,房间命令总以会话的成员发出。 + a, _, err := s.action(ctx, sess.Member(), false, data) + if err != nil { + sess.Fail(tag, err) + return err + } + return s.Gateway.Command(ctx, sess, tag, a) +} + +func (s *server) write(ctx context.Context, conn *websocket.Conn, sess *realtime.Session, helloID string) { + for { + m, reason, err := sess.Next(ctx) + if err != nil { + return + } + if reason != 0 { + _ = conn.Close(sessionCloseCodes[reason], "") + return + } + // 写入以会话的 ctx 为期限:客户端不再读取、发送队列溢出时,阻塞的写入随会话关闭而中止。 + if !s.writeMessage(sess.Context(), conn, s.message(m, helloID)) { + return + } + } +} + +func (s *server) message(m realtime.Message, helloID string) protocol.Message { + switch m := m.(type) { + case realtime.Hello: + return protocol.Response(protocol.RequestHello, helloID, protocol.HelloResponse{Member: memberDTO(m.Member), ServerTimeMs: time.Now().UnixMilli(), Room: s.snapshot(m.View)}) + case realtime.Event: + typ, data := s.event(m.Emit.Event) + return protocol.RoomEvent(typ, int64(m.Emit.Term), int64(m.Emit.Seq), data) + case realtime.Reply: + // 按写给客户端的回复计数,会话关闭时没有写出的回复不计。意外的错误已在处理请求时记录。 + tag := m.Tag.(requestTag) + s.count(tag.typ, m.Err) + if m.Err != nil { + info, _ := errorInfo(m.Err) + return protocol.Failure(tag.typ, tag.id, info) + } + return protocol.Response(tag.typ, tag.id, s.result(m.Result)) + case realtime.Answer: + tag := m.Tag.(requestTag) + s.count(tag.typ, nil) + return protocol.Response(tag.typ, tag.id, m.Data) + } + panic(fmt.Sprintf("未知的会话消息 %T", m)) +} + +// 错误码对应 5xx 时把请求的 span 标为出错,与 HTTP 请求的 span 一致。 +// 会话结束时被取消的请求不记录日志,也不标为出错。 +func (s *server) requestError(ctx context.Context, typ string, err error) protocol.ErrorInfo { + info, expected := errorInfo(err) + if errors.Is(err, context.Canceled) { + return info + } + if !expected { + s.logger.ErrorContext(withRequestID(ctx, err), "处理 WebSocket 请求失败", "type", typ, "error", err) + } + if info.Code.HTTPStatus() >= http.StatusInternalServerError { + trace.SpanFromContext(ctx).SetStatus(codes.Error, string(info.Code)) + } + return info +} + +func (s *server) reject(ctx context.Context, conn *websocket.Conn, req protocol.Request, info protocol.ErrorInfo) { + if req.ID == "" { + s.writeMessage(ctx, conn, protocol.ErrorEvent(info)) + return + } + s.writeMessage(ctx, conn, protocol.Failure(req.Type, req.ID, info)) +} + +func (s *server) writeMessage(ctx context.Context, conn *websocket.Conn, m protocol.Message) bool { + frame, err := json.Marshal(m) + if err != nil { + s.logger.ErrorContext(ctx, "编码 WebSocket 消息失败", "type", m.Type, "error", err) + return false + } + return conn.Write(ctx, websocket.MessageText, frame) == nil +} + +func watchIdle(ctx context.Context, conn *websocket.Conn, lastFrame *atomic.Int64) { + timer := time.NewTimer(idleTimeout) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + idle := time.Since(time.Unix(0, lastFrame.Load())) + if idle >= idleTimeout { + _ = conn.CloseNow() + return + } + timer.Reset(idleTimeout - idle) + } + } +} diff --git a/internal/adapter/in/api/youtube.go b/internal/adapter/in/api/youtube.go new file mode 100644 index 0000000..384e013 --- /dev/null +++ b/internal/adapter/in/api/youtube.go @@ -0,0 +1,19 @@ +package api + +import ( + "context" + + "github.com/dihemoe/alisten/protocol" +) + +func (s *server) adminSetYoutubeCredential(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeAdminSetYoutubeCredentialRequest(c.body) + if err != nil { + return nil, err + } + return nil, s.Admin.SetYouTubeCookies(ctx, req.Cookies) +} + +func (s *server) adminDeleteYoutubeCredential(ctx context.Context, _ call) (any, error) { + return nil, s.Admin.ClearYouTubeCookies(ctx) +} diff --git a/internal/adapter/in/ops/ops.go b/internal/adapter/in/ops/ops.go new file mode 100644 index 0000000..422424b --- /dev/null +++ b/internal/adapter/in/ops/ops.go @@ -0,0 +1,25 @@ +package ops + +import ( + "io" + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func NewHandler(ready func() bool, metrics prometheus.Gatherer) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok\n") + }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { + if !ready() { + http.Error(w, "not ready", http.StatusServiceUnavailable) + return + } + _, _ = io.WriteString(w, "ok\n") + }) + mux.Handle("GET /metrics", promhttp.HandlerFor(metrics, promhttp.HandlerOpts{})) + return mux +} diff --git a/internal/adapter/in/worker/worker.go b/internal/adapter/in/worker/worker.go new file mode 100644 index 0000000..4e5847f --- /dev/null +++ b/internal/adapter/in/worker/worker.go @@ -0,0 +1,145 @@ +package worker + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivertype" + "github.com/riverqueue/rivercontrib/otelriver" + "go.opentelemetry.io/otel/trace" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" + "github.com/dihemoe/alisten/internal/lib/logctx" +) + +const ( + prepareQueue = "prepare" + prepareWorkers = 4 + prepareTimeout = 15 * time.Minute + // 进程崩溃后停留在 running 状态的任务,最多这么久之后由 River 重新调度;River 默认至少要等 1 小时, + // 期间这首歌一直处于准备中。 + rescueStuckJobs = 20 * time.Minute +) + +// internal/adapter/out/queue 按同样的队列名提交链接和 B 站的歌的准备任务。 +const ( + linkQueue = "ytdlp" + bilibiliQueue = "bilibili" + mediaWorkers = 1 +) + +// 适配器之间不能互相 import,internal/adapter/out/queue 按同样的 kind 和字段提交任务。 +type prepareArgs struct { + Source string `json:"source"` + ID string `json:"id"` +} + +func (prepareArgs) Kind() string { return "prepare" } + +type prepareWorker struct { + river.WorkerDefaults[prepareArgs] + run func(context.Context, track.Track) error +} + +func (w *prepareWorker) Work(ctx context.Context, job *river.Job[prepareArgs]) error { + err := w.run(logctx.WithJob(ctx, job.ID), job.Args.track()) + var rejected *port.RejectedError + if errors.As(err, &rejected) { + return river.JobCancel(err) + } + return err +} + +func (a prepareArgs) track() track.Track { + return track.Track{Source: track.Source(a.Source), ID: a.ID} +} + +// run 返回 *port.RejectedError 时任务取消,返回其他错误时由 River 重试。 +func NewClient(pool *pgxpool.Pool, run func(context.Context, track.Track) error, tracer trace.TracerProvider, logger *slog.Logger) (*river.Client[pgx.Tx], error) { + return newClient(pool, map[string]river.QueueConfig{prepareQueue: {MaxWorkers: prepareWorkers}}, run, tracer, logger) +} + +// 执行链接和 B 站的歌的准备任务,每个队列在每个进程中同时只执行一个。 +func NewMediaClient(pool *pgxpool.Pool, run func(context.Context, track.Track) error, tracer trace.TracerProvider, logger *slog.Logger) (*river.Client[pgx.Tx], error) { + return newClient(pool, map[string]river.QueueConfig{linkQueue: {MaxWorkers: mediaWorkers}, bilibiliQueue: {MaxWorkers: mediaWorkers}}, run, tracer, logger) +} + +func newClient(pool *pgxpool.Pool, queues map[string]river.QueueConfig, run func(context.Context, track.Track) error, tracer trace.TracerProvider, logger *slog.Logger) (*river.Client[pgx.Tx], error) { + workers := river.NewWorkers() + river.AddWorker(workers, &prepareWorker{run: run}) + return river.NewClient(riverpgxv5.New(pool), &river.Config{ + Queues: queues, + Workers: workers, + JobTimeout: prepareTimeout, + RescueStuckJobsAfter: rescueStuckJobs, + // 任务在单独的 trace 中执行,只链接到提交它的 span:任务可能很久之后才执行,失败后还会重试,同一首歌的任务也由所有等待它的房间共用。 + Plugins: []rivertype.Plugin{otelriver.NewMiddleware(&otelriver.MiddlewareConfig{TracerProvider: tracer, EnableTracePropagation: true})}, + // River 在 info 级别记录每次启停和每个出错的任务,出错的任务由 Watch 记录。 + // 放进 river 分组,River 自己的属性(如 job_id)才不会与日志的关联字段重名。 + Logger: slog.New(warnings{logger.Handler()}).WithGroup("river"), + }) +} + +type warnings struct{ slog.Handler } + +func (h warnings) Enabled(ctx context.Context, level slog.Level) bool { + return level >= slog.LevelWarn && h.Handler.Enabled(ctx, level) +} + +func (h warnings) WithAttrs(attrs []slog.Attr) slog.Handler { + return warnings{h.Handler.WithAttrs(attrs)} +} + +func (h warnings) WithGroup(name string) slog.Handler { return warnings{h.Handler.WithGroup(name)} } + +// 在 client 启动之前调用,返回的函数一直运行到 client 停止。River 的事件可能丢失,等待的一方会定期按任务 ID 核对。 +func Watch(client *river.Client[pgx.Tx], finished func(port.PrepareOutcome), observe func(source track.Source, result string, elapsed time.Duration), logger *slog.Logger) func() { + events, _ := client.Subscribe(river.EventKindJobCompleted, river.EventKindJobCancelled, river.EventKindJobFailed) + return func() { + for e := range events { + if e.Job.Kind != (prepareArgs{}).Kind() { + continue + } + ctx := logctx.WithJob(context.Background(), e.Job.ID) + var args prepareArgs + if err := json.Unmarshal(e.Job.EncodedArgs, &args); err != nil { + logger.ErrorContext(ctx, "无法解码准备任务的参数", "error", err) + continue + } + t := args.track() + outcome := port.PrepareOutcome{Track: t, JobID: e.Job.ID} + switch { + case e.Kind == river.EventKindJobCompleted: + observe(t.Source, "ok", e.JobStats.RunDuration) + case e.Kind == river.EventKindJobCancelled: + outcome.Reason = reason(e.Job) + observe(t.Source, "rejected", e.JobStats.RunDuration) + case e.Job.State == rivertype.JobStateDiscarded: + outcome.Reason = port.UnavailableReason + observe(t.Source, "failed", e.JobStats.RunDuration) + logger.WarnContext(ctx, "准备任务失败", "source", t.Source, "track_id", t.ID, "attempt", e.Job.Attempt, "error", reason(e.Job)) + default: + logger.WarnContext(ctx, "准备任务失败,稍后重试", "source", t.Source, "track_id", t.ID, "attempt", e.Job.Attempt, "error", reason(e.Job)) + continue + } + finished(outcome) + } + } +} + +// River 记录取消的原因时带有 "JobCancelError: " 前缀;internal/adapter/out/queue 以同样的方式提取任务失败的原因。 +func reason(job *rivertype.JobRow) string { + if len(job.Errors) == 0 { + return "准备任务失败" + } + return strings.TrimPrefix(job.Errors[len(job.Errors)-1].Error, "JobCancelError: ") +} diff --git a/internal/adapter/out/bilibili/bilibili.go b/internal/adapter/out/bilibili/bilibili.go new file mode 100644 index 0000000..fb131e9 --- /dev/null +++ b/internal/adapter/out/bilibili/bilibili.go @@ -0,0 +1,234 @@ +package bilibili + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +const ( + qrGenerateURL = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate" + qrPollURL = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll" + navURL = "https://api.bilibili.com/x/web-interface/nav" + viewURL = "https://api.bilibili.com/x/web-interface/view" + pagelistURL = "https://api.bilibili.com/x/player/pagelist" + playurlURL = "https://api.bilibili.com/x/player/playurl" + + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" + referer = "https://www.bilibili.com/" + + apiTimeout = 30 * time.Second + downloadTimeout = 10 * time.Minute + qrLoginTTL = 3 * time.Minute + bodyLimit = 4 << 20 +) + +type Client struct { + api *http.Client + download *http.Client + root string + loadCredential func(context.Context) (string, error) + saveCredential func(context.Context, string) error + observe func(source, op string, elapsed time.Duration, err error) +} + +var ( + _ port.BilibiliSource = (*Client)(nil) + _ port.BilibiliLinks = (*Client)(nil) + _ port.BilibiliQRLoginSource = (*Client)(nil) +) + +// upstream 用于全部平台请求,不能带 Timeout:API 请求的期限由 context 控制(30 秒)。 +// 下载复制一份 client,保留它的 Transport,期限放宽到 downloadTimeout;每次跳转都重新校验目标地址。 +// root 是下载中转文件的目录,必须是进程崩溃后残留会被启动清理删掉的目录(worker 的工作目录); +// 不下载文件的进程传空字符串,此时 Download 显式失败而不是写进系统临时目录。 +func New(upstream *http.Client, root string, loadCredential func(context.Context) (string, error), saveCredential func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error)) *Client { + download := *upstream + download.Timeout = downloadTimeout + download.CheckRedirect = func(req *http.Request, _ []*http.Request) error { + return checkDownloadURL(req.URL) + } + return &Client{ + api: upstream, + download: &download, + root: root, + loadCredential: loadCredential, + saveCredential: saveCredential, + observe: observe, + } +} + +func (c *Client) report(op string, start time.Time, err error) { + if c.observe != nil { + c.observe("bilibili", op, time.Since(start), err) + } +} + +// 凭据对调用方是不透明字符串;Cookie 和 refresh token 的编码由本包决定。 +type credential struct { + Cookie string `json:"cookie"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +func encodeCredential(cookie, refreshToken string) string { + b, _ := json.Marshal(credential{Cookie: cookie, RefreshToken: refreshToken}) + return string(b) +} + +func decodeCredential(raw string) (credential, error) { + var cred credential + if err := json.Unmarshal([]byte(raw), &cred); err != nil { + return credential{}, fmt.Errorf("bilibili 凭据格式无法解析:%w", err) + } + return cred, nil +} + +// cookie 读取保存的凭据;没有凭据时返回空字符串。 +func (c *Client) cookie(ctx context.Context) (string, error) { + raw, err := c.loadCredential(ctx) + if err != nil { + return "", fmt.Errorf("读取 Bilibili 凭据:%w", err) + } + if raw == "" { + return "", nil + } + cred, err := decodeCredential(raw) + if err != nil { + return "", err + } + return cred.Cookie, nil +} + +// 请求地址可能含敏感查询参数,返回错误不得携带地址。 +func (c *Client) get(ctx context.Context, rawURL, cookie string) ([]byte, []*http.Cookie, error) { + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, nil, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Referer", referer) + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + resp, err := c.api.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("请求 B 站接口失败:%w", requestError(err)) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("bilibili 接口返回 HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) + if err != nil { + return nil, nil, fmt.Errorf("读取 B 站接口的响应:%w", err) + } + if len(body) > bodyLimit { + return nil, nil, errors.New("bilibili 接口的响应过大") + } + return body, resp.Cookies(), nil +} + +type envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// getData 解开 code/message/data 信封;code 非 0 时按平台拒绝或普通错误分类。 +func (c *Client) getData(ctx context.Context, rawURL, cookie string, data any) error { + body, _, err := c.get(ctx, rawURL, cookie) + if err != nil { + return err + } + var env envelope + if err := json.Unmarshal(body, &env); err != nil { + return fmt.Errorf("解析 B 站接口的响应:%w", err) + } + if env.Code != 0 { + return classifyCode(env.Code, env.Message) + } + if err := json.Unmarshal(env.Data, data); err != nil { + return fmt.Errorf("解析 B 站接口的数据:%w", err) + } + return nil +} + +// 这些 code 表示平台明确拒绝了这首歌:准备不再重试。 +var rejectionReasons = map[int]string{ + -400: "视频不存在或请求有误", + -404: "视频不存在或已被删除", + -403: "没有权限观看这个视频", + 62002: "视频不可见", + 62004: "视频还在审核中", +} + +func classifyCode(code int, message string) error { + if reason, ok := rejectionReasons[code]; ok { + if message != "" { + reason += "(" + message + ")" + } + return &port.RejectedError{Err: errors.New(reason)} + } + if message == "" { + return fmt.Errorf("bilibili 接口返回错误:code=%d", code) + } + return fmt.Errorf("bilibili 接口返回错误:code=%d message=%s", code, message) +} + +// requestError 去掉 url.Error 携带的方法和地址:签名地址不能进错误和日志。 +func requestError(err error) error { + var uerr *url.Error + if errors.As(err, &uerr) { + return uerr.Err + } + return err +} + +// isDownloadHost 只允许 B 站的域名:bilibili.com、bilivideo.com、hdslb.com 和它们的子域名。 +func isDownloadHost(host string) bool { + for _, d := range []string{"bilibili.com", "bilivideo.com", "hdslb.com"} { + if host == d || strings.HasSuffix(host, "."+d) { + return true + } + } + return false +} + +// downloadURL 校验并规范化平台返回的地址:协议相对地址补成 https,主机必须在 B 站域名内,http 升级成 https。 +func downloadURL(rawURL string) (string, error) { + if strings.HasPrefix(rawURL, "//") { + rawURL = "https:" + rawURL + } + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "", errors.New("平台返回的下载地址无法解析") + } + if err := checkDownloadURL(u); err != nil { + return "", err + } + return u.String(), nil +} + +func checkDownloadURL(u *url.URL) error { + host := strings.ToLower(u.Hostname()) + if !isDownloadHost(host) { + return fmt.Errorf("平台返回的下载地址不在 B 站域名内:%s", host) + } + if u.Scheme == "http" { + u.Scheme = "https" + } + if u.Scheme != "https" { + return fmt.Errorf("平台返回的下载地址不是 HTTPS:%s", u.Scheme) + } + return nil +} diff --git a/internal/adapter/out/bilibili/download.go b/internal/adapter/out/bilibili/download.go new file mode 100644 index 0000000..7829421 --- /dev/null +++ b/internal/adapter/out/bilibili/download.go @@ -0,0 +1,211 @@ +package bilibili + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +func (c *Client) Download(ctx context.Context, id string) (port.BilibiliDownload, func(), error) { + bvid, page, ok := track.ParseBilibiliID(id) + if !ok { + return port.BilibiliDownload{}, nil, fmt.Errorf("不是 Bilibili 曲目 ID:%q", id) + } + if c.root == "" { + return port.BilibiliDownload{}, nil, errors.New("没有配置下载目录,这个进程不下载文件") + } + meta, info, err := c.trackMeta(ctx, "download_view", "download_pagelist", bvid, page) + if err != nil { + return port.BilibiliDownload{}, nil, err + } + cookie, err := c.cookie(ctx) + if err != nil { + return port.BilibiliDownload{}, nil, err + } + audioURLs, err := c.playURLs(ctx, bvid, info.cid, cookie) + if err != nil { + return port.BilibiliDownload{}, nil, err + } + + dir, err := os.MkdirTemp(c.root, "bilibili-") + if err != nil { + return port.BilibiliDownload{}, nil, fmt.Errorf("创建临时目录:%w", err) + } + release := func() { _ = os.RemoveAll(dir) } + // 用 defer 是为了 panic 时也删除目录。 + done := false + defer func() { + if !done { + release() + } + }() + + audio := filepath.Join(dir, "audio") + // 平台返回的节点未必都能下:baseUrl 常指向 EdgeOne 承载的镜像,可能直接拒绝(HTTP 567); + // 同一条流的 base_url、backup_url 仍是完整签名地址,逐个尝试,全部失败才报错。 + var downloadErr error + for _, audioURL := range audioURLs { + if downloadErr = c.downloadFile(ctx, "download_audio", audioURL, cookie, audio); downloadErr == nil { + break + } + } + if downloadErr != nil { + return port.BilibiliDownload{}, nil, fmt.Errorf("下载音频失败(尝试了全部 %d 个节点):%w", len(audioURLs), downloadErr) + } + d := port.BilibiliDownload{Meta: meta, Audio: file(audio)} + if coverURL, ext, ok := coverInfo(info.pic); ok { + cover := filepath.Join(dir, "cover."+ext) + if err := c.downloadFile(ctx, "download_cover", coverURL, cookie, cover); err != nil { + return port.BilibiliDownload{}, nil, err + } + d.Cover, d.CoverExt = file(cover), ext + } + done = true + return d, release, nil +} + +type playurlData struct { + Durl []struct { + URL string `json:"url"` + BackupURL []string `json:"backup_url"` + } `json:"durl"` + Dash struct { + Audio []struct { + ID int `json:"id"` + BaseURL string `json:"baseUrl"` + BaseURL2 string `json:"base_url"` + BackupURL []string `json:"backup_url"` + } `json:"audio"` + } `json:"dash"` +} + +// 优先以 fnval=80 在带 baseUrl 的 dash.audio 中选择 ID 最高的普通立体声 AAC(不选 FLAC 和杜比); +// 没有可用候选地址时以 fnval=0、qn=16 回退到 durl 首项,避免为音频下载高清视频。 +// 候选地址按 baseUrl、base_url、backup_url 排序。 +func (c *Client) playURLs(ctx context.Context, bvid string, cid int64, cookie string) ([]string, error) { + data, err := c.fetchPlayurl(ctx, 80, 127, bvid, cid, cookie) + if err != nil { + return nil, err + } + bestID := -1 + var candidates []string + for _, a := range data.Dash.Audio { + if a.ID > bestID && a.BaseURL != "" { + bestID = a.ID + candidates = dedupe([]string{a.BaseURL, a.BaseURL2}, a.BackupURL) + } + } + if len(candidates) > 0 { + return candidates, nil + } + data, err = c.fetchPlayurl(ctx, 0, 16, bvid, cid, cookie) + if err != nil { + return nil, err + } + if len(data.Durl) > 0 && data.Durl[0].URL != "" { + return dedupe([]string{data.Durl[0].URL}, data.Durl[0].BackupURL), nil + } + return nil, errors.New("平台没有返回可下载的音频") +} + +func dedupe(first []string, rest []string) []string { + seen := map[string]bool{} + var out []string + for _, u := range append(first, rest...) { + if u != "" && !seen[u] { + seen[u] = true + out = append(out, u) + } + } + return out +} + +func (c *Client) fetchPlayurl(ctx context.Context, fnval, qn int, bvid string, cid int64, cookie string) (d playurlData, err error) { + start := time.Now() + defer func() { c.report("download_playurl", start, err) }() + u := fmt.Sprintf("%s?fnval=%d&qn=%d&bvid=%s&cid=%d", playurlURL, fnval, qn, url.QueryEscape(bvid), cid) + err = c.getData(ctx, u, cookie, &d) + return d, err +} + +// 平台返回的地址不可信,发送请求前必须经 downloadURL 校验。 +func (c *Client) downloadFile(ctx context.Context, op, rawURL, cookie, dest string) (err error) { + start := time.Now() + defer func() { c.report(op, start, err) }() + safe, err := downloadURL(rawURL) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, safe, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Referer", referer) + // EdgeOne 承载的节点拦截完整文件的 GET(返回 567),带 Range 的请求会回源到源站放行。 + req.Header.Set("Range", "bytes=0-") + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + resp, err := c.download.Do(req) + if err != nil { + return fmt.Errorf("下载文件失败:%w", requestError(err)) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { + return fmt.Errorf("下载文件返回 HTTP %d", resp.StatusCode) + } + f, err := os.Create(dest) + if err != nil { + return fmt.Errorf("创建文件:%w", err) + } + if _, err := io.Copy(f, resp.Body); err != nil { + _ = f.Close() + return fmt.Errorf("保存文件:%w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("保存文件:%w", err) + } + return nil +} + +// 封面地址无效、不在 B 站域名或格式不支持时跳过封面。 +func coverInfo(pic string) (string, string, bool) { + if pic == "" { + return "", "", false + } + safe, err := downloadURL(pic) + if err != nil { + return "", "", false + } + u, err := url.Parse(safe) + if err != nil { + return "", "", false + } + ext := strings.ToLower(strings.TrimPrefix(path.Ext(u.Path), ".")) + switch ext { + case "jpg", "jpeg": + ext = "jpg" + case "png", "webp": + default: + return "", "", false + } + return safe, ext, true +} + +type file string + +func (f file) Open() (io.ReadSeekCloser, error) { + return os.Open(string(f)) +} diff --git a/internal/adapter/out/bilibili/links.go b/internal/adapter/out/bilibili/links.go new file mode 100644 index 0000000..63341b8 --- /dev/null +++ b/internal/adapter/out/bilibili/links.go @@ -0,0 +1,171 @@ +package bilibili + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +var plainAV = regexp.MustCompile(`^[aA][vV]([0-9]+)$`) + +func (c *Client) Recognize(ctx context.Context, link string) (string, bool, error) { + if id, ok := plainID(link); ok { + return id, true, nil + } + u, err := url.Parse(link) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "", false, nil + } + host := strings.ToLower(u.Hostname()) + if host == "b23.tv" { + return c.resolveShortLink(ctx, u) + } + if !isBilibiliHost(host) { + return "", false, nil + } + return classifyVideoURL(u) +} + +func isBilibiliHost(host string) bool { + return host == "bilibili.com" || strings.HasSuffix(host, ".bilibili.com") +} + +// plainID 处理不带 scheme 的写法:BV 号(可带分 P)和 av 号。 +func plainID(s string) (string, bool) { + if bvid, page, ok := track.ParseBilibiliID(s); ok { + return track.BilibiliID(bvid, page), true + } + if m := plainAV.FindStringSubmatch(s); m != nil { + if aid, err := strconv.ParseInt(m[1], 10, 64); err == nil { + if bvid, ok := avToBV(aid); ok { + return bvid, true + } + } + } + return "", false +} + +// classifyVideoURL 处理 bilibili.com 的链接:视频返回曲目 ID,其他(番剧、直播、个人空间等)都拒绝。 +func classifyVideoURL(u *url.URL) (string, bool, error) { + if !strings.HasPrefix(u.Path, "/video/") { + return "", false, &port.RejectedError{Err: errors.New("这个 B 站链接不是视频,番剧、直播和个人空间等都不支持")} + } + idPart := strings.Trim(strings.TrimPrefix(u.Path, "/video/"), "/") + var bvid string + switch parsed, page, ok := track.ParseBilibiliID(idPart); { + case ok && page == 1: + bvid = parsed + default: + m := plainAV.FindStringSubmatch(idPart) + if m == nil { + return "", false, &port.RejectedError{Err: fmt.Errorf("无法识别的 B 站视频 ID:%s", idPart)} + } + aid, err := strconv.ParseInt(m[1], 10, 64) + if err != nil { + return "", false, &port.RejectedError{Err: fmt.Errorf("无法识别的 B 站视频 ID:%s", idPart)} + } + converted, ok := avToBV(aid) + if !ok { + return "", false, &port.RejectedError{Err: fmt.Errorf("无法识别的 B 站视频 ID:%s", idPart)} + } + bvid = converted + } + page := 1 + if p := u.Query().Get("p"); p != "" { + if n, err := strconv.Atoi(p); err == nil && n >= 1 { + page = n + } + } + // 曲目 ID 的分 P 最多 4 位,更大序号的视频不存在。 + if page > 9999 { + return "", false, &port.RejectedError{Err: fmt.Errorf("这个视频没有第 %d P", page)} + } + return track.BilibiliID(bvid, page), true, nil +} + +func (c *Client) resolveShortLink(ctx context.Context, u *url.URL) (id string, ok bool, err error) { + start := time.Now() + defer func() { c.report("recognize", start, err) }() + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", false, err + } + req.Header.Set("User-Agent", userAgent) + // 保留注入 client 的 Transport,只禁止跟随重定向:跳转地址由下面的识别逻辑校验。 + client := *c.api + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + resp, err := client.Do(req) + if err != nil { + return "", false, fmt.Errorf("请求 b23.tv 短链失败:%w", requestError(err)) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + return "", false, fmt.Errorf("b23.tv 短链返回 HTTP %d", resp.StatusCode) + } + target, err := url.Parse(resp.Header.Get("Location")) + if err != nil || (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" { + return "", false, errors.New("b23.tv 短链没有给出跳转地址") + } + if !isBilibiliHost(strings.ToLower(target.Hostname())) { + return "", false, &port.RejectedError{Err: errors.New("b23.tv 短链指向的不是 B 站视频")} + } + id, _, err = classifyVideoURL(target) + return id, err == nil, err +} + +// av 号和 BV 号的换算,与平台前端使用的算法相同。 +const ( + bvTable = "fZodR9XQDSUm21yCkr6zBqiveYah8bt4xsWpHnJE7jL5VG3guMTKNPAwcF" + bvXOR = 177451812 + bvAdd = 8728348608 +) + +var ( + bvPositions = [6]int{11, 10, 3, 8, 4, 6} + bvBase = [6]int64{1, 58, 58 * 58, 58 * 58 * 58, 58 * 58 * 58 * 58, 58 * 58 * 58 * 58 * 58} +) + +func avToBV(aid int64) (string, bool) { + if aid <= 0 { + return "", false + } + x := (aid ^ bvXOR) + bvAdd + r := []byte("BV1 4 1 7 ") + for i, pos := range bvPositions { + r[pos] = bvTable[x/bvBase[i]%58] + } + bvid := string(r) + // 超出 6 位 base-58 能表示的范围时换算不成立。 + if back, ok := bvToAV(bvid); !ok || back != aid { + return "", false + } + return bvid, true +} + +func bvToAV(bvid string) (int64, bool) { + if len(bvid) != len("BV1 4 1 7 ") { + return 0, false + } + var x int64 + for i, pos := range bvPositions { + idx := strings.IndexByte(bvTable, bvid[pos]) + if idx < 0 { + return 0, false + } + x += int64(idx) * bvBase[i] + } + return (x - bvAdd) ^ bvXOR, true +} diff --git a/internal/adapter/out/bilibili/login.go b/internal/adapter/out/bilibili/login.go new file mode 100644 index 0000000..2bff8a5 --- /dev/null +++ b/internal/adapter/out/bilibili/login.go @@ -0,0 +1,167 @@ +package bilibili + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +func (c *Client) CreateQRLogin(ctx context.Context) (session port.QRLoginSession, err error) { + start := time.Now() + defer func() { c.report("login_create", start, err) }() + var data struct { + URL string `json:"url"` + QRCodeKey string `json:"qrcode_key"` + } + if err := c.getData(ctx, qrGenerateURL, "", &data); err != nil { + return port.QRLoginSession{}, err + } + if strings.TrimSpace(data.QRCodeKey) == "" { + return port.QRLoginSession{}, errors.New("平台没有返回二维码 key") + } + // QRImage 留空:调用方按 LoginURL 生成二维码图片。 + return port.QRLoginSession{ + Key: data.QRCodeKey, + LoginURL: data.URL, + ExpiresAt: time.Now().Add(qrLoginTTL), + }, nil +} + +func (c *Client) CheckQRLogin(ctx context.Context, key string) (result port.QRLoginResult, err error) { + if strings.TrimSpace(key) == "" { + return port.QRLoginResult{}, errors.New("二维码 key 为空") + } + start := time.Now() + defer func() { c.report("login_check", start, err) }() + body, cookies, err := c.get(ctx, qrPollURL+"?qrcode_key="+url.QueryEscape(key), "") + if err != nil { + return port.QRLoginResult{}, err + } + var payload struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Code int `json:"code"` + Message string `json:"message"` + RefreshToken string `json:"refresh_token"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return port.QRLoginResult{}, fmt.Errorf("解析扫码结果:%w", err) + } + if payload.Code != 0 { + return port.QRLoginResult{}, classifyCode(payload.Code, payload.Message) + } + result = port.QRLoginResult{ + Status: qrStatus(payload.Data.Code), + Message: cmp.Or(strings.TrimSpace(payload.Data.Message), strings.TrimSpace(payload.Message)), + } + if payload.Data.Code != 0 { + return result, nil + } + // Set-Cookie 和 refresh_token 只写入服务端凭据,不放入 QRLoginResult。 + cookie := joinCookies(cookies) + if cookie == "" { + return port.QRLoginResult{}, errors.New("扫码成功但平台没有返回 Cookie") + } + if err := c.saveCredential(ctx, encodeCredential(cookie, payload.Data.RefreshToken)); err != nil { + return port.QRLoginResult{}, fmt.Errorf("保存 Bilibili 凭据:%w", err) + } + return result, nil +} + +func qrStatus(code int) port.QRLoginStatus { + switch code { + case 0: + return port.QRLoginSuccess + case 86038: + return port.QRLoginExpired + case 86090: + return port.QRLoginScanned + case 86101: + return port.QRLoginWaiting + default: + return port.QRLoginFailed + } +} + +func joinCookies(cookies []*http.Cookie) string { + parts := make([]string, 0, len(cookies)) + for _, cookie := range cookies { + if strings.TrimSpace(cookie.Name) != "" { + parts = append(parts, cookie.Name+"="+cookie.Value) + } + } + sort.Strings(parts) + return strings.Join(parts, "; ") +} + +type navData struct { + IsLogin bool `json:"isLogin"` + Uname string `json:"uname"` + Mid int64 `json:"mid"` + VipStatus int `json:"vipStatus"` + VipType int `json:"vipType"` +} + +// fetchNav 不检查信封的 code:未登录时平台返回 -101,由调用方按 isLogin 区分。 +func (c *Client) fetchNav(ctx context.Context, op, cookie string) (d navData, err error) { + start := time.Now() + defer func() { c.report(op, start, err) }() + body, _, err := c.get(ctx, navURL, cookie) + if err != nil { + return navData{}, err + } + var payload struct { + Data navData `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return navData{}, fmt.Errorf("解析账号信息:%w", err) + } + return payload.Data, nil +} + +func (n navData) vip() bool { + return n.IsLogin && n.VipStatus == 1 && n.VipType > 0 +} + +func (c *Client) CredentialStatus(ctx context.Context) (port.CredentialStatus, error) { + raw, err := c.loadCredential(ctx) + if err != nil { + return port.CredentialStatus{}, fmt.Errorf("读取 Bilibili 凭据:%w", err) + } + if raw == "" { + return port.CredentialStatus{}, nil + } + cred, err := decodeCredential(raw) + if err != nil { + return port.CredentialStatus{}, err + } + nav, err := c.fetchNav(ctx, "credential_status", cred.Cookie) + if err != nil { + return port.CredentialStatus{}, err + } + if !nav.IsLogin { + return port.CredentialStatus{Set: true}, nil + } + name, vip := nav.Uname, nav.vip() + return port.CredentialStatus{ + Set: true, + Account: &port.SourceAccount{ + ID: strconv.FormatInt(nav.Mid, 10), + Name: &name, + VIP: &vip, + Verified: true, + }, + }, nil +} diff --git a/internal/adapter/out/bilibili/meta.go b/internal/adapter/out/bilibili/meta.go new file mode 100644 index 0000000..955b14c --- /dev/null +++ b/internal/adapter/out/bilibili/meta.go @@ -0,0 +1,99 @@ +package bilibili + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +type viewData struct { + BVID string `json:"bvid"` + Title string `json:"title"` + Pic string `json:"pic"` + Owner struct { + Name string `json:"name"` + Mid int64 `json:"mid"` + } `json:"owner"` +} + +type pageInfo struct { + CID int64 `json:"cid"` + Page int `json:"page"` + Part string `json:"part"` + Duration int `json:"duration"` +} + +type videoInfo struct { + cid int64 + pic string +} + +func (c *Client) TrackMeta(ctx context.Context, id string) (track.Meta, error) { + bvid, page, ok := track.ParseBilibiliID(id) + if !ok { + return track.Meta{}, fmt.Errorf("不是 Bilibili 曲目 ID:%q", id) + } + meta, _, err := c.trackMeta(ctx, "meta_view", "meta_pagelist", bvid, page) + return meta, err +} + +// trackMeta 取标题、UP 主和时长;CoverURL 和 PageURL 留空:封面在准备时下载存储,页面地址由调用方按 ID 拼出。 +// pagelist 是存在性判断和 cid 的来源;view 接口经常被风控(未登录 412、登录后伪装 -404), +// 失败时退化为 pagelist 的元数据:标题用分 P 标题(分 P 标题为空时用 BV 号),UP 主和封面缺失。 +func (c *Client) trackMeta(ctx context.Context, viewOp, pagelistOp, bvid string, page int) (track.Meta, videoInfo, error) { + cookie, err := c.cookie(ctx) + if err != nil { + return track.Meta{}, videoInfo{}, err + } + pages, err := c.fetchPagelist(ctx, pagelistOp, bvid, cookie) + if err != nil { + return track.Meta{}, videoInfo{}, err + } + p, ok := findPage(pages, page) + if !ok { + return track.Meta{}, videoInfo{}, &port.RejectedError{Err: fmt.Errorf("这个视频没有第 %d P", page)} + } + meta := track.Meta{Title: p.Part, Duration: time.Duration(p.Duration) * time.Second} + if meta.Title == "" { + meta.Title = bvid + } + info := videoInfo{cid: p.CID} + view, err := c.fetchView(ctx, viewOp, bvid, cookie) + if err != nil { + return meta, info, nil + } + title := view.Title + if p.Part != "" && p.Part != view.Title { + title = view.Title + " - " + p.Part + } + meta.Title, meta.Artist = title, view.Owner.Name + info.pic = view.Pic + return meta, info, nil +} + +func (c *Client) fetchView(ctx context.Context, op, bvid, cookie string) (d viewData, err error) { + start := time.Now() + defer func() { c.report(op, start, err) }() + err = c.getData(ctx, viewURL+"?bvid="+url.QueryEscape(bvid), cookie, &d) + return d, err +} + +func (c *Client) fetchPagelist(ctx context.Context, op, bvid, cookie string) (pages []pageInfo, err error) { + start := time.Now() + defer func() { c.report(op, start, err) }() + err = c.getData(ctx, pagelistURL+"?bvid="+url.QueryEscape(bvid), cookie, &pages) + return pages, err +} + +func findPage(pages []pageInfo, page int) (pageInfo, bool) { + for _, p := range pages { + if p.Page == page { + return p, true + } + } + return pageInfo{}, false +} diff --git a/internal/adapter/out/ffmpeg/ffmpeg.go b/internal/adapter/out/ffmpeg/ffmpeg.go new file mode 100644 index 0000000..f1e73c6 --- /dev/null +++ b/internal/adapter/out/ffmpeg/ffmpeg.go @@ -0,0 +1,142 @@ +package ffmpeg + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +const ( + executable = "ffmpeg" + waitDelay = 5 * time.Second + outputLimit = 64 << 10 +) + +type Transcoder struct { + root string + logger *slog.Logger +} + +var _ port.Transcoder = (*Transcoder)(nil) + +func New(root string, logger *slog.Logger) *Transcoder { + return &Transcoder{root: root, logger: logger} +} + +func (t *Transcoder) ToMP3(ctx context.Context, audio port.LinkFile) (port.LinkFile, func(), error) { + dir, err := os.MkdirTemp(t.root, "ffmpeg-*") + if err != nil { + return nil, nil, fmt.Errorf("创建临时目录:%w", err) + } + release := func() { _ = os.RemoveAll(dir) } + // 用 defer 是为了 panic 时也删除目录:River 会恢复任务中的 panic,worker 继续运行。 + done := false + defer func() { + if !done { + release() + } + }() + mp3, err := t.transcode(ctx, audio, dir) + if err != nil { + return nil, nil, err + } + done = true + return mp3, release, nil +} + +func (t *Transcoder) transcode(ctx context.Context, audio port.LinkFile, dir string) (port.LinkFile, error) { + // 先复制成文件再交给 ffmpeg:MP4 的 moov 可能在文件末尾,从管道读不了。文件名不带扩展名,ffmpeg 只能按内容识别格式。 + input := filepath.Join(dir, "input") + if err := copyFile(input, audio); err != nil { + return nil, fmt.Errorf("复制输入的音频:%w", err) + } + output := filepath.Join(dir, "audio.mp3") + stderr, err := run(ctx, args(input, output)) + if err == nil { + // 转码成功后立即删除输入副本,不等 release:源音频和 MP3 在上传期间仍占用临时空间, + // 同一容器还可能并行准备其他链接。 + _ = os.Remove(input) + return file(output), nil + } + ctxErr := ctx.Err() + // 任务被取消(worker 退出)不是转码的问题,不记日志。 + if !errors.Is(ctxErr, context.Canceled) { + t.logger.WarnContext(ctx, "ffmpeg 转码失败", "stderr", string(stderr), "error", err) + } + if ctxErr != nil { + return nil, fmt.Errorf("ffmpeg 转码:%w", ctxErr) + } + return nil, fmt.Errorf("ffmpeg 转码失败:%w", err) +} + +func copyFile(dst string, src port.LinkFile) error { + in, err := src.Open() + if err != nil { + return err + } + defer func() { _ = in.Close() }() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} + +func args(input, output string) []string { + return []string{ + "-nostdin", "-hide_banner", "-loglevel", "error", + // 输入可能引用网络地址,例如 HLS 播放列表,所以只允许读本地文件。输入选项要放在 -i 之前。 + "-protocol_whitelist", "file", + "-i", input, + "-map", "0:a:0", "-vn", "-sn", "-dn", + // 来源的标签和章节是用户上传的内容,ffmpeg 默认会把它们复制进 mp3,所以不复制。 + "-map_metadata", "-1", "-map_chapters", "-1", + // 与 yt-dlp 的 --audio-format mp3 默认质量一致。 + "-c:a", "libmp3lame", "-q:a", "5", + output, + } +} + +func run(ctx context.Context, argv []string) (stderr []byte, err error) { + cmd := exec.CommandContext(ctx, executable, argv...) + cmd.WaitDelay = waitDelay + errOut := &tail{limit: outputLimit} + cmd.Stdout, cmd.Stderr = io.Discard, errOut + err = cmd.Run() + return errOut.buf, err +} + +type tail struct { + limit int + buf []byte +} + +func (t *tail) Write(p []byte) (int, error) { + t.buf = append(t.buf, p...) + if extra := len(t.buf) - t.limit; extra > 0 { + t.buf = t.buf[extra:] + } + return len(p), nil +} + +type file string + +func (f file) Open() (io.ReadSeekCloser, error) { + r, err := os.Open(string(f)) + if err != nil { + return nil, err + } + return r, nil +} diff --git a/internal/adapter/out/linkcookies/linkcookies.go b/internal/adapter/out/linkcookies/linkcookies.go new file mode 100644 index 0000000..c336a6f --- /dev/null +++ b/internal/adapter/out/linkcookies/linkcookies.go @@ -0,0 +1,70 @@ +// Package linkcookies 把数据库中保存的 YouTube cookies 写成 yt-dlp 需要的临时文件: +// yt-dlp 只接受文件路径,cookies 必须短暂落盘。文件写在 worker 工作根目录下权限 0700 +// 的临时目录中,下载结束由调用方 release 删除;进程崩溃留下的残留由 worker 启动时的 +// 清理逻辑删除。yt-dlp 运行中会把更新后的 cookies 写回文件,所以 release 删除整个目录。 +package linkcookies + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/dihemoe/alisten/internal/app/port" +) + +type Manager struct { + root string + load func(context.Context) (string, error) +} + +var _ port.LinkCookies = (*Manager)(nil) + +// load 返回保存的 cookies,未填写时返回空字符串。 +func New(root string, load func(context.Context) (string, error)) *Manager { + return &Manager{root: root, load: load} +} + +func (m *Manager) CookieFile(ctx context.Context, link string) (string, func(), bool, error) { + if !isYouTube(link) { + return "", nil, false, nil + } + cookies, err := m.load(ctx) + if err != nil { + return "", nil, false, fmt.Errorf("读取 YouTube cookies:%w", err) + } + if strings.TrimSpace(cookies) == "" { + return "", nil, false, nil + } + dir, err := os.MkdirTemp(m.root, "cookies-") + if err != nil { + return "", nil, false, fmt.Errorf("创建 cookies 临时目录:%w", err) + } + path := filepath.Join(dir, "cookies.txt") + if err := os.WriteFile(path, []byte(cookies), 0o600); err != nil { + _ = os.RemoveAll(dir) + return "", nil, false, fmt.Errorf("写入 cookies 文件:%w", err) + } + release := func() { _ = os.RemoveAll(dir) } + return path, release, true, nil +} + +// 链接由用户提交,yt-dlp 接受不带 scheme 的 YouTube 短链接。 +func isYouTube(link string) bool { + if !strings.Contains(link, "://") { + link = "https://" + link + } + u, err := url.Parse(link) + if err != nil { + return false + } + host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + for _, domain := range []string{"youtube.com", "youtu.be", "youtube-nocookie.com"} { + if host == domain || strings.HasSuffix(host, "."+domain) { + return true + } + } + return false +} diff --git a/internal/adapter/out/netease/account.go b/internal/adapter/out/netease/account.go new file mode 100644 index 0000000..6209bf1 --- /dev/null +++ b/internal/adapter/out/netease/account.go @@ -0,0 +1,41 @@ +package netease + +import ( + "context" + "strconv" + + "github.com/dihemoe/alisten/internal/app/port" +) + +const accountAPI = "https://music.163.com/weapi/nuser/account/get" + +func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error) { + form, err := weapi(map[string]any{"csrf_token": ""}) + if err != nil { + return nil, err + } + var resp struct { + base + Profile struct { + UserID int64 `json:"userId"` + Nickname string `json:"nickname"` + VipType int `json:"vipType"` + } `json:"profile"` + } + if err := c.call(ctx, "account", accountAPI, form, &resp); err != nil { + return nil, err + } + if resp.Profile.UserID == 0 { + return nil, nil + } + account := &port.SourceAccount{ + ID: strconv.FormatInt(resp.Profile.UserID, 10), + VIP: new(bool), + Verified: true, + } + *account.VIP = resp.Profile.VipType != 0 + if resp.Profile.Nickname != "" { + account.Name = &resp.Profile.Nickname + } + return account, nil +} diff --git a/internal/adapter/out/netease/client.go b/internal/adapter/out/netease/client.go new file mode 100644 index 0000000..e0cae04 --- /dev/null +++ b/internal/adapter/out/netease/client.go @@ -0,0 +1,220 @@ +package netease + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/hashicorp/golang-lru/v2/expirable" + + "github.com/dihemoe/alisten/internal/lib/chinaip" +) + +const ( + requestTimeout = 30 * time.Second + downloadTimeout = 10 * time.Minute + cacheSize = 512 + cacheTTL = 30 * time.Minute + cookieTTL = 30 * time.Second + cookieLoadTimeout = 10 * time.Second +) + +const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + +type Client struct { + http *http.Client + api *http.Client + cookie cookieCache + cache *expirable.LRU[string, any] + observe func(source, op string, elapsed time.Duration, err error) +} + +func New(httpClient *http.Client, cookie func(context.Context) (string, error), saveCookie func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error)) *Client { + api := *httpClient + api.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &Client{ + http: httpClient, + api: &api, + cookie: cookieCache{load: cookie, store: saveCookie}, + cache: expirable.NewLRU[string, any](cacheSize, nil, cacheTTL), + observe: observe, + } +} + +type cookieCache struct { + load func(context.Context) (string, error) + store func(context.Context, string) error + mu sync.Mutex + value string + expires time.Time +} + +func (c *cookieCache) get(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if time.Now().Before(c.expires) { + return c.value, nil + } + ctx, cancel := context.WithTimeout(ctx, cookieLoadTimeout) + defer cancel() + value, err := c.load(ctx) + if err != nil { + return "", err + } + c.value, c.expires = value, time.Now().Add(cookieTTL) + return value, nil +} + +func (c *cookieCache) put(ctx context.Context, value string) error { + c.mu.Lock() + defer c.mu.Unlock() + ctx, cancel := context.WithTimeout(ctx, cookieLoadTimeout) + defer cancel() + if err := c.store(ctx, value); err != nil { + return err + } + c.value, c.expires = value, time.Now().Add(cookieTTL) + return nil +} + +func cached[T any](c *Client, key string, fetch func() (T, error)) (T, error) { + if v, ok := c.cache.Get(key); ok { + return v.(T), nil + } + v, err := fetch() + if err != nil { + return v, err + } + c.cache.Add(key, v) + return v, nil +} + +type response interface{ status() int } + +type base struct { + Code int `json:"code"` +} + +func (b base) status() int { return b.Code } + +func (c *Client) call(ctx context.Context, op, endpoint string, form url.Values, out response) error { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + cookie, err := c.cookie.get(ctx) + if err != nil { + return err + } + start := time.Now() + err = c.post(ctx, endpoint, form, cookie, out) + if err == nil && out.status() != http.StatusOK { + err = fmt.Errorf("网易云的接口 %s 返回错误码 %d", op, out.status()) + } + c.observe("netease", op, time.Since(start), err) + return err +} + +func (c *Client) post(ctx context.Context, endpoint string, form url.Values, cookie string, out response) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return errors.New("网易云的请求地址无效") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", "https://music.163.com/") + req.Header.Set("User-Agent", userAgent) + chinaip.Set(req.Header) + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + resp, err := c.api.Do(req) + if err != nil { + return requestError(req.URL.Host, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("网易云返回 HTTP %d", resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("解析网易云的响应:%w", err) + } + return nil +} + +func linux(data any) (url.Values, error) { + b, err := json.Marshal(data) + if err != nil { + return nil, err + } + return url.Values{"eparams": {linuxParams(b)}}, nil +} + +func weapi(data any) (url.Values, error) { + b, err := json.Marshal(data) + if err != nil { + return nil, err + } + params, encSecKey := weapiParams(b, newSecretKey()) + return url.Values{"params": {params}, "encSecKey": {encSecKey}}, nil +} + +func eapi(endpoint string, data any) (url.Values, error) { + b, err := json.Marshal(data) + if err != nil { + return nil, err + } + u, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + return url.Values{"params": {eapiParams(u.Path, b)}}, nil +} + +func (c *Client) download(ctx context.Context, audioURL string) (io.ReadCloser, error) { + ctx, cancel := context.WithTimeout(ctx, downloadTimeout) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, audioURL, nil) + if err != nil { + cancel() + return nil, errors.New("网易云的播放地址无效") + } + req.Header.Set("User-Agent", userAgent) + resp, err := c.http.Do(req) + if err != nil { + cancel() + return nil, requestError(req.URL.Host, err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + cancel() + return nil, fmt.Errorf("下载网易云的音频:HTTP %d", resp.StatusCode) + } + return &audio{ReadCloser: resp.Body, cancel: cancel}, nil +} + +func requestError(host string, err error) error { + var urlError *url.Error + if errors.As(err, &urlError) { + err = urlError.Err + } + // 播放地址的查询参数可能携带签名,错误包装不得泄漏完整 URL。 + if strings.Contains(err.Error(), "failed to parse Location header") { + return fmt.Errorf("请求 %s:解析跳转地址失败", host) + } + return fmt.Errorf("请求 %s:%w", host, err) +} + +type audio struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (a *audio) Close() error { + err := a.ReadCloser.Close() + a.cancel() + return err +} diff --git a/internal/adapter/out/netease/crypto.go b/internal/adapter/out/netease/crypto.go new file mode 100644 index 0000000..d4b941a --- /dev/null +++ b/internal/adapter/out/netease/crypto.go @@ -0,0 +1,77 @@ +package netease + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "fmt" + "math/big" + "math/rand/v2" + "slices" + "strings" +) + +const ( + linuxKey = "rFgB&h#%2?^eDg:Q" + eapiKey = "e82ckenh8dichen8" + weapiNonce = "0CoJUm6Qyw8W8jud" + weapiIV = "0102030405060708" + weapiExponent = "010001" + weapiModulus = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7" +) + +func linuxParams(data []byte) string { + return strings.ToUpper(hex.EncodeToString(ecb(data, linuxKey))) +} + +func eapiParams(path string, payload []byte) string { + path = strings.Replace(path, "/eapi/", "/api/", 1) + digest := md5.Sum(fmt.Appendf(nil, "nobody%suse%smd5forencrypt", path, payload)) + data := fmt.Appendf(nil, "%s-36cd479b6b5-%s-36cd479b6b5-%s", path, payload, hex.EncodeToString(digest[:])) + return hex.EncodeToString(ecb(data, eapiKey)) +} + +func weapiParams(data []byte, secretKey string) (params, encSecKey string) { + params = cbc([]byte(cbc(data, weapiNonce)), secretKey) + reversed := []byte(secretKey) + slices.Reverse(reversed) + text := new(big.Int).SetBytes(reversed) + exponent, _ := new(big.Int).SetString(weapiExponent, 16) + modulus, _ := new(big.Int).SetString(weapiModulus, 16) + return params, fmt.Sprintf("%0256x", text.Exp(text, exponent, modulus)) +} + +func newSecretKey() string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + key := make([]byte, 16) + for i := range key { + key[i] = letters[rand.IntN(len(letters))] + } + return string(key) +} + +func ecb(data []byte, key string) []byte { + block, _ := aes.NewCipher([]byte(key)) + data = pad(data) + out := make([]byte, len(data)) + for i := 0; i < len(data); i += aes.BlockSize { + block.Encrypt(out[i:i+aes.BlockSize], data[i:i+aes.BlockSize]) + } + return out +} + +func cbc(data []byte, key string) string { + block, _ := aes.NewCipher([]byte(key)) + data = pad(data) + out := make([]byte, len(data)) + cipher.NewCBCEncrypter(block, []byte(weapiIV)).CryptBlocks(out, data) + return base64.StdEncoding.EncodeToString(out) +} + +func pad(data []byte) []byte { + n := aes.BlockSize - len(data)%aes.BlockSize + return append(slices.Clip(data), bytes.Repeat([]byte{byte(n)}, n)...) +} diff --git a/internal/adapter/out/netease/login.go b/internal/adapter/out/netease/login.go new file mode 100644 index 0000000..034ec9b --- /dev/null +++ b/internal/adapter/out/netease/login.go @@ -0,0 +1,126 @@ +package netease + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/lib/chinaip" +) + +const ( + qrLoginCreateURL = "https://interface.music.163.com/api/login/qrcode/unikey" + qrLoginCheckURL = "https://interface.music.163.com/api/login/qrcode/client/login" + qrLoginUserAgent = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.0.18.203152" +) + +func (c *Client) CreateQRLogin(ctx context.Context) (port.QRLoginSession, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + var payload struct { + base + Unikey string `json:"unikey"` + } + start := time.Now() + _, err := c.loginRequest(ctx, qrLoginCreateURL, url.Values{"type": {"3"}}, &payload) + if err == nil && payload.Code != http.StatusOK { + err = fmt.Errorf("网易云的接口 login_create 返回错误码 %d", payload.Code) + } + if err == nil && payload.Unikey == "" { + err = errors.New("网易云创建扫码登录的响应缺少 unikey") + } + c.observe("netease", "login_create", time.Since(start), err) + if err != nil { + return port.QRLoginSession{}, err + } + return port.QRLoginSession{ + Key: payload.Unikey, + LoginURL: "https://music.163.com/login?codekey=" + url.QueryEscape(payload.Unikey), + ExpiresAt: time.Now().Add(5 * time.Minute), + }, nil +} + +func (c *Client) CheckQRLogin(ctx context.Context, key string) (result port.QRLoginResult, err error) { + if key == "" { + return port.QRLoginResult{}, errors.New("网易云扫码登录的 key 为空") + } + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + var payload struct { + base + Message string `json:"message"` + Cookie string `json:"cookie"` + } + start := time.Now() + defer func() { c.observe("netease", "login_check", time.Since(start), err) }() + resp, err := c.loginRequest(ctx, qrLoginCheckURL, url.Values{"key": {key}, "type": {"3"}}, &payload) + if err != nil { + return port.QRLoginResult{}, err + } + result = port.QRLoginResult{Status: neteaseLoginStatus(payload.Code), Message: payload.Message} + if result.Status != port.QRLoginSuccess { + return result, nil + } + cookie := payload.Cookie + if cookie == "" { + cookies := resp.Cookies() + sort.SliceStable(cookies, func(i, j int) bool { return cookies[i].Name < cookies[j].Name }) + pairs := make([]string, len(cookies)) + for i, cookie := range cookies { + pairs[i] = cookie.Name + "=" + cookie.Value + } + cookie = strings.Join(pairs, "; ") + } + if cookie == "" { + return port.QRLoginResult{}, errors.New("网易云扫码登录成功的响应缺少 Cookie") + } + if err = c.cookie.put(ctx, cookie); err != nil { + return port.QRLoginResult{}, fmt.Errorf("保存网易云 Cookie:%w", err) + } + return result, nil +} + +func neteaseLoginStatus(code int) port.QRLoginStatus { + switch code { + case 800: + return port.QRLoginExpired + case 801: + return port.QRLoginWaiting + case 802: + return port.QRLoginScanned + case 803: + return port.QRLoginSuccess + default: + return port.QRLoginFailed + } +} + +func (c *Client) loginRequest(ctx context.Context, endpoint string, form url.Values, out any) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", "https://music.163.com/") + req.Header.Set("User-Agent", qrLoginUserAgent) + chinaip.Set(req.Header) + resp, err := c.api.Do(req) + if err != nil { + return nil, fmt.Errorf("请求网易云:%w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("网易云返回 HTTP %d", resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return nil, fmt.Errorf("解析网易云的响应:%w", err) + } + return resp, nil +} diff --git a/internal/adapter/out/netease/netease.go b/internal/adapter/out/netease/netease.go new file mode 100644 index 0000000..bf0bbed --- /dev/null +++ b/internal/adapter/out/netease/netease.go @@ -0,0 +1,355 @@ +package netease + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +const ( + linuxAPI = "https://music.163.com/api/linux/forward" + detailAPI = "https://music.163.com/weapi/v3/song/detail" + playlistAPI = "https://music.163.com/weapi/v3/playlist/detail" + lyricAPI = "https://music.163.com/weapi/song/lyric" + similarAPI = "https://music.163.com/weapi/discovery/simiSong" + weapiURLAPI = "https://music.163.com/weapi/song/enhance/player/url" + eapiURLAPI = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1" +) + +const ( + searchTracks = 1 + searchPlaylists = 1000 + detailBatch = 500 +) + +var ( + _ port.NetEaseSource = (*Client)(nil) + _ port.PlayURLs = (*Client)(nil) +) + +type ( + artist struct { + Name string `json:"name"` + } + + album struct { + Name string `json:"name"` + PicURL string `json:"picUrl"` + } + + song struct { + ID int64 `json:"id"` + Name string `json:"name"` + Artists []artist `json:"ar"` + Album album `json:"al"` + Duration int64 `json:"dt"` + } + + similarSong struct { + ID int64 `json:"id"` + Name string `json:"name"` + Artists []artist `json:"artists"` + Album album `json:"album"` + Duration int64 `json:"duration"` + } + + playlist struct { + ID int64 `json:"id"` + Name string `json:"name"` + CoverImgURL string `json:"coverImgUrl"` + Description string `json:"description"` + Creator struct { + Nickname string `json:"nickname"` + } `json:"creator"` + TrackCount int64 `json:"trackCount"` + PlayCount int64 `json:"playCount"` + } + + urlResponse struct { + base + Data []struct { + URL string `json:"url"` + FreeTrialInfo any `json:"freeTrialInfo"` + } `json:"data"` + } +) + +func (c *Client) SearchTracks(ctx context.Context, keyword string) ([]port.Song, error) { + return cached(c, "search_tracks "+keyword, func() ([]port.Song, error) { + var resp struct { + base + Result struct { + Songs []song `json:"songs"` + } `json:"result"` + } + if err := c.search(ctx, "search_tracks", keyword, searchTracks, &resp); err != nil { + return nil, err + } + songs := make([]port.Song, len(resp.Result.Songs)) + for i, s := range resp.Result.Songs { + songs[i] = s.song() + } + return songs, nil + }) +} + +func (c *Client) SearchPlaylists(ctx context.Context, keyword string) ([]port.Playlist, error) { + return cached(c, "search_playlists "+keyword, func() ([]port.Playlist, error) { + var resp struct { + base + Result struct { + Playlists []playlist `json:"playlists"` + } `json:"result"` + } + if err := c.search(ctx, "search_playlists", keyword, searchPlaylists, &resp); err != nil { + return nil, err + } + playlists := make([]port.Playlist, len(resp.Result.Playlists)) + for i, p := range resp.Result.Playlists { + playlists[i] = port.Playlist{ + ID: strconv.FormatInt(p.ID, 10), + Name: p.Name, + CoverURL: p.CoverImgURL, + Creator: p.Creator.Nickname, + Description: p.Description, + TrackCount: p.TrackCount, + PlayCount: p.PlayCount, + } + } + return playlists, nil + }) +} + +func (c *Client) search(ctx context.Context, op, keyword string, kind int, out response) error { + form, err := linux(map[string]any{ + "method": "POST", + "url": "http://music.163.com/api/cloudsearch/pc", + "params": map[string]any{"s": keyword, "type": kind, "offset": 0, "limit": 100}, + }) + if err != nil { + return err + } + return c.call(ctx, op, linuxAPI, form, out) +} + +func (c *Client) PlaylistTracks(ctx context.Context, playlistID string) ([]port.Song, error) { + return cached(c, "playlist_tracks "+playlistID, func() ([]port.Song, error) { + form, err := weapi(map[string]any{"id": playlistID, "n": 0, "csrf_token": ""}) + if err != nil { + return nil, err + } + var resp struct { + base + Playlist struct { + TrackIDs []struct { + ID int64 `json:"id"` + } `json:"trackIds"` + } `json:"playlist"` + } + if err := c.call(ctx, "playlist_tracks", playlistAPI, form, &resp); err != nil { + return nil, err + } + ids := make([]string, len(resp.Playlist.TrackIDs)) + for i, t := range resp.Playlist.TrackIDs { + ids[i] = strconv.FormatInt(t.ID, 10) + } + var songs []port.Song + for start := 0; start < len(ids); start += detailBatch { + batch, err := c.details(ctx, ids[start:min(start+detailBatch, len(ids))]) + if err != nil { + return nil, err + } + songs = append(songs, batch...) + } + return songs, nil + }) +} + +func (c *Client) TrackMeta(ctx context.Context, id string) (track.Meta, error) { + return cached(c, "track_meta "+id, func() (track.Meta, error) { + songs, err := c.details(ctx, []string{id}) + if err != nil { + return track.Meta{}, err + } + if len(songs) == 0 { + return track.Meta{}, &port.RejectedError{Err: errors.New("网易云中没有这首歌")} + } + return songs[0].Meta, nil + }) +} + +func (c *Client) details(ctx context.Context, ids []string) ([]port.Song, error) { + refs := make([]map[string]string, len(ids)) + for i, id := range ids { + refs[i] = map[string]string{"id": id} + } + cJSON, err := json.Marshal(refs) + if err != nil { + return nil, err + } + idsJSON, err := json.Marshal(ids) + if err != nil { + return nil, err + } + form, err := weapi(map[string]any{"c": string(cJSON), "ids": string(idsJSON)}) + if err != nil { + return nil, err + } + var resp struct { + base + Songs []song `json:"songs"` + } + if err := c.call(ctx, "track_detail", detailAPI, form, &resp); err != nil { + return nil, err + } + found := make(map[string]port.Song, len(resp.Songs)) + for _, s := range resp.Songs { + found[strconv.FormatInt(s.ID, 10)] = s.song() + } + songs := make([]port.Song, 0, len(ids)) + for _, id := range ids { + if s, ok := found[id]; ok { + songs = append(songs, s) + } + } + return songs, nil +} + +func (c *Client) SimilarTracks(ctx context.Context, id string) ([]port.Song, error) { + return cached(c, "similar "+id, func() ([]port.Song, error) { + form, err := weapi(map[string]any{"songid": id, "limit": 50, "offset": 0}) + if err != nil { + return nil, err + } + var resp struct { + base + Songs []similarSong `json:"songs"` + } + if err := c.call(ctx, "similar", similarAPI, form, &resp); err != nil { + return nil, err + } + songs := make([]port.Song, len(resp.Songs)) + for i, s := range resp.Songs { + songs[i] = toSong(s.ID, s.Name, s.Artists, s.Album, s.Duration) + } + return songs, nil + }) +} + +func (c *Client) Resolve(ctx context.Context, id string) (port.Resolved, error) { + meta, err := c.TrackMeta(ctx, id) + if err != nil { + return port.Resolved{}, err + } + lyric, err := c.lyric(ctx, id) + if err != nil { + return port.Resolved{}, err + } + audioURL, err := c.PlayURL(ctx, id) + if err != nil { + return port.Resolved{}, err + } + audio, err := c.download(ctx, audioURL) + if err != nil { + return port.Resolved{}, err + } + return port.Resolved{Meta: meta, Lyric: lyric, Audio: audio}, nil +} + +func (c *Client) lyric(ctx context.Context, id string) (string, error) { + return cached(c, "lyric "+id, func() (string, error) { + form, err := weapi(map[string]any{"csrf_token": "", "id": id, "lv": -1, "tv": -1}) + if err != nil { + return "", err + } + var resp struct { + base + Lrc struct { + Lyric string `json:"lyric"` + } `json:"lrc"` + } + if err := c.call(ctx, "lyric", lyricAPI, form, &resp); err != nil { + return "", err + } + return text(resp.Lrc.Lyric), nil + }) +} + +func (c *Client) PlayURL(ctx context.Context, id string) (string, error) { + eapiForm, err := eapi(eapiURLAPI, map[string]any{ + "ids": []json.Number{json.Number(id)}, + "level": "exhigh", + "encodeType": "flac", + "header": `{"os":"pc","appver":"","osver":"","deviceId":"pyncm!","requestId":"12345678"}`, + }) + if err != nil { + return "", err + } + var exhigh urlResponse + exhighErr := c.call(ctx, "play_url", eapiURLAPI, eapiForm, &exhigh) + if u := exhigh.url(); exhighErr == nil && u != "" { + return u, nil + } + weapiForm, err := weapi(map[string]any{"ids": []string{id}, "br": 320000}) + if err != nil { + return "", err + } + var standard urlResponse + standardErr := c.call(ctx, "play_url", weapiURLAPI, weapiForm, &standard) + if u := standard.url(); standardErr == nil && u != "" { + return u, nil + } + if err := errors.Join(exhighErr, standardErr); err != nil { + return "", err + } + if exhigh.trial() || standard.trial() { + return "", &port.RejectedError{Err: errors.New("这首歌需要网易云会员账号才能完整播放,请管理员在后台设置会员账号的 Cookie")} + } + return "", &port.RejectedError{Err: errors.New("网易云没有提供这首歌的播放地址")} +} + +func (r urlResponse) trial() bool { + return len(r.Data) > 0 && r.Data[0].FreeTrialInfo != nil +} + +func (r urlResponse) url() string { + if len(r.Data) == 0 || r.trial() { + return "" + } + return r.Data[0].URL +} + +func (s song) song() port.Song { + return toSong(s.ID, s.Name, s.Artists, s.Album, s.Duration) +} + +func toSong(id int64, name string, artists []artist, al album, durationMs int64) port.Song { + sid := strconv.FormatInt(id, 10) + names := make([]string, 0, len(artists)) + for _, a := range artists { + if a.Name != "" { + names = append(names, a.Name) + } + } + return port.Song{ + Track: track.Track{Source: track.NetEase, ID: sid}, + Meta: track.Meta{ + Title: text(name), + Artist: text(strings.Join(names, ", ")), + Album: text(al.Name), + CoverURL: text(al.PicURL), + PageURL: "https://music.163.com/#/song?id=" + sid, + Duration: time.Duration(durationMs) * time.Millisecond, + }, + } +} + +func text(s string) string { + return strings.ReplaceAll(strings.ToValidUTF8(s, "\uFFFD"), "\x00", "") +} diff --git a/internal/adapter/out/postgres/apikeys.go b/internal/adapter/out/postgres/apikeys.go new file mode 100644 index 0000000..ebb9ff1 --- /dev/null +++ b/internal/adapter/out/postgres/apikeys.go @@ -0,0 +1,59 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" + "github.com/dihemoe/alisten/internal/app/port" +) + +var _ port.APIKeys = (*Store)(nil) + +const botName = "bot" + +func (s *Store) RoomAPIKeyStatus(ctx context.Context, roomID string) (port.APIKeyStatus, error) { + createdAt, err := s.q.BotTicketCreatedAt(ctx, roomID) + if errors.Is(err, pgx.ErrNoRows) { + return port.APIKeyStatus{}, nil + } + if err != nil { + return port.APIKeyStatus{}, fmt.Errorf("读取房间 %s 的 API Key 状态:%w", roomID, err) + } + return port.APIKeyStatus{Set: true, CreatedAt: createdAt}, nil +} + +func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAPIKey) error { + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + bot, err := q.LockRoomBot(ctx, roomID) + if errors.Is(err, pgx.ErrNoRows) { + return port.ErrRoomNotFound + } + if err != nil { + return err + } + if bot != nil { + if err := q.DeleteMemberTickets(ctx, query.DeleteMemberTicketsParams{RoomID: roomID, MemberID: *bot}); err != nil { + return err + } + } else { + m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName}) + if err != nil { + return err + } + if err := q.SetRoomBot(ctx, query.SetRoomBotParams{ID: roomID, BotMemberID: m.ID}); err != nil { + return err + } + bot = &m.ID + } + return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: k.TokenHash, RoomID: roomID, MemberID: *bot}) + }) + if err != nil { + return fmt.Errorf("重置房间 %s 的 API Key:%w", roomID, err) + } + return nil +} diff --git a/internal/adapter/out/postgres/credentials.go b/internal/adapter/out/postgres/credentials.go new file mode 100644 index 0000000..76eed96 --- /dev/null +++ b/internal/adapter/out/postgres/credentials.go @@ -0,0 +1,116 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" + "github.com/dihemoe/alisten/internal/app/port" +) + +var _ port.Credentials = (*Store)(nil) +var _ port.YouTubeCookies = (*Store)(nil) + +const ( + neteaseCookie = "netease" + qqCookie = "qq" + bilibiliCredential = "bilibili" + youtubeCredential = "youtube" +) + +func (s *Store) NeteaseCookie(ctx context.Context) (string, error) { + cookie, err := s.q.Credential(ctx, neteaseCookie) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("读取网易云 Cookie:%w", err) + } + return cookie, nil +} + +func (s *Store) SetNeteaseCookie(ctx context.Context, cookie string) error { + if err := s.setCredential(ctx, neteaseCookie, cookie); err != nil { + return fmt.Errorf("保存网易云 Cookie:%w", err) + } + return nil +} + +func (s *Store) QQCookie(ctx context.Context) (string, error) { + cookie, err := s.q.Credential(ctx, qqCookie) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("读取 QQ Cookie:%w", err) + } + return cookie, nil +} + +func (s *Store) SetQQCookie(ctx context.Context, cookie string) error { + if err := s.setCredential(ctx, qqCookie, cookie); err != nil { + return fmt.Errorf("保存 QQ Cookie:%w", err) + } + return nil +} + +func (s *Store) BilibiliCredential(ctx context.Context) (string, error) { + credential, err := s.q.Credential(ctx, bilibiliCredential) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("读取 Bilibili 凭据:%w", err) + } + return credential, nil +} + +func (s *Store) SetBilibiliCredential(ctx context.Context, credential string) error { + if err := s.setCredential(ctx, bilibiliCredential, credential); err != nil { + return fmt.Errorf("保存 Bilibili 凭据:%w", err) + } + return nil +} + +func (s *Store) SetYouTubeCookies(ctx context.Context, cookies string) error { + if err := s.setCredential(ctx, youtubeCredential, cookies); err != nil { + return fmt.Errorf("保存 YouTube cookies:%w", err) + } + return nil +} + +func (s *Store) YouTubeCookies(ctx context.Context) (string, error) { + cookies, err := s.q.Credential(ctx, youtubeCredential) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("读取 YouTube cookies:%w", err) + } + return cookies, nil +} + +func (s *Store) SetCookies(ctx context.Context, cookies string) error { + return s.SetYouTubeCookies(ctx, cookies) +} + +func (s *Store) Status(ctx context.Context) (port.YouTubeCookiesStatus, error) { + credential, err := s.q.CredentialWithUpdatedAt(ctx, youtubeCredential) + if errors.Is(err, pgx.ErrNoRows) { + return port.YouTubeCookiesStatus{}, nil + } + if err != nil { + return port.YouTubeCookiesStatus{}, fmt.Errorf("读取 YouTube cookies 状态:%w", err) + } + return port.YouTubeCookiesStatus{Set: true, UpdatedAt: credential.UpdatedAt}, nil +} + +func (s *Store) setCredential(ctx context.Context, name, value string) error { + if value == "" { + return s.q.DeleteCredential(ctx, name) + } + return s.q.SetCredential(ctx, query.SetCredentialParams{Name: name, Value: value}) +} diff --git a/internal/adapter/out/postgres/history.go b/internal/adapter/out/postgres/history.go new file mode 100644 index 0000000..e0d227b --- /dev/null +++ b/internal/adapter/out/postgres/history.go @@ -0,0 +1,47 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +var ( + _ port.PlayHistory = (*Store)(nil) + _ port.TrackUsage = (*Store)(nil) +) + +func (s *Store) AutoDJSeeds(ctx context.Context, roomID string, includeQueue bool) ([]track.Track, error) { + ids, err := s.q.AutoDJSeeds(ctx, query.AutoDJSeedsParams{RoomID: roomID, IncludeQueue: includeQueue}) + if err != nil { + return nil, fmt.Errorf("读取房间 %s 的 AutoDJ 种子:%w", roomID, err) + } + seeds := make([]track.Track, len(ids)) + for i, id := range ids { + seeds[i] = track.Track{Source: track.NetEase, ID: id} + } + return seeds, nil +} + +func (s *Store) AutoDJExclusions(ctx context.Context, roomID string) ([]track.Track, error) { + rows, err := s.q.AutoDJExclusions(ctx, roomID) + if err != nil { + return nil, fmt.Errorf("读取房间 %s 的 AutoDJ 排除项:%w", roomID, err) + } + excluded := make([]track.Track, len(rows)) + for i, r := range rows { + excluded[i] = track.Track{Source: track.Source(r.Source), ID: r.TrackID} + } + return excluded, nil +} + +func (s *Store) TrackInUse(ctx context.Context, t track.Track) (bool, error) { + used, err := s.q.TrackInUse(ctx, query.TrackInUseParams{Source: string(t.Source), TrackID: t.ID}) + if err != nil { + return false, fmt.Errorf("检查曲目 %s %s 是否仍被房间引用:%w", t.Source, t.ID, err) + } + return used, nil +} diff --git a/internal/adapter/out/postgres/internal/query/apikeys.sql.go b/internal/adapter/out/postgres/internal/query/apikeys.sql.go new file mode 100644 index 0000000..badecf9 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/apikeys.sql.go @@ -0,0 +1,64 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: apikeys.sql + +package query + +import ( + "context" + "time" +) + +const botTicketCreatedAt = `-- name: BotTicketCreatedAt :one +SELECT t.created_at FROM room_tickets t JOIN rooms r ON r.id = t.room_id AND r.bot_member_id = t.member_id +WHERE t.room_id = $1 +` + +func (q *Queries) BotTicketCreatedAt(ctx context.Context, roomID string) (time.Time, error) { + row := q.db.QueryRow(ctx, botTicketCreatedAt, roomID) + var created_at time.Time + err := row.Scan(&created_at) + return created_at, err +} + +const deleteMemberTickets = `-- name: DeleteMemberTickets :exec +DELETE FROM room_tickets WHERE room_id = $1 AND member_id = $2 +` + +type DeleteMemberTicketsParams struct { + RoomID string + MemberID string +} + +func (q *Queries) DeleteMemberTickets(ctx context.Context, arg DeleteMemberTicketsParams) error { + _, err := q.db.Exec(ctx, deleteMemberTickets, arg.RoomID, arg.MemberID) + return err +} + +const lockRoomBot = `-- name: LockRoomBot :one +SELECT bot_member_id FROM rooms WHERE id = $1 FOR NO KEY UPDATE +` + +// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个房间的重置要依次进行, +// 同时到达的首次生成才只会创建一个 bot 成员,最后只留下一把 Key。 +func (q *Queries) LockRoomBot(ctx context.Context, id string) (*string, error) { + row := q.db.QueryRow(ctx, lockRoomBot, id) + var bot_member_id *string + err := row.Scan(&bot_member_id) + return bot_member_id, err +} + +const setRoomBot = `-- name: SetRoomBot :exec +UPDATE rooms SET bot_member_id = $1::uuid WHERE id = $2 +` + +type SetRoomBotParams struct { + BotMemberID string + ID string +} + +func (q *Queries) SetRoomBot(ctx context.Context, arg SetRoomBotParams) error { + _, err := q.db.Exec(ctx, setRoomBot, arg.BotMemberID, arg.ID) + return err +} diff --git a/internal/adapter/out/postgres/internal/query/copyfrom.go b/internal/adapter/out/postgres/internal/query/copyfrom.go new file mode 100644 index 0000000..0b95983 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/copyfrom.go @@ -0,0 +1,101 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: copyfrom.go + +package query + +import ( + "context" +) + +// iteratorForInsertPlays implements pgx.CopyFromSource. +type iteratorForInsertPlays struct { + rows []InsertPlaysParams + skippedFirstNextCall bool +} + +func (r *iteratorForInsertPlays) Next() bool { + if len(r.rows) == 0 { + return false + } + if !r.skippedFirstNextCall { + r.skippedFirstNextCall = true + return true + } + r.rows = r.rows[1:] + return len(r.rows) > 0 +} + +func (r iteratorForInsertPlays) Values() ([]interface{}, error) { + return []interface{}{ + r.rows[0].RoomID, + r.rows[0].Source, + r.rows[0].TrackID, + r.rows[0].ByMemberID, + r.rows[0].ByName, + r.rows[0].ByAvatarHash, + r.rows[0].Auto, + r.rows[0].StartedAt, + r.rows[0].EndedAt, + }, nil +} + +func (r iteratorForInsertPlays) Err() error { + return nil +} + +func (q *Queries) InsertPlays(ctx context.Context, arg []InsertPlaysParams) (int64, error) { + return q.db.CopyFrom(ctx, []string{"plays"}, []string{"room_id", "source", "track_id", "by_member_id", "by_name", "by_avatar_hash", "auto", "started_at", "ended_at"}, &iteratorForInsertPlays{rows: arg}) +} + +// iteratorForInsertRoomEntries implements pgx.CopyFromSource. +type iteratorForInsertRoomEntries struct { + rows []InsertRoomEntriesParams + skippedFirstNextCall bool +} + +func (r *iteratorForInsertRoomEntries) Next() bool { + if len(r.rows) == 0 { + return false + } + if !r.skippedFirstNextCall { + r.skippedFirstNextCall = true + return true + } + r.rows = r.rows[1:] + return len(r.rows) > 0 +} + +func (r iteratorForInsertRoomEntries) Values() ([]interface{}, error) { + return []interface{}{ + r.rows[0].RoomID, + r.rows[0].EntryID, + r.rows[0].Slot, + r.rows[0].Source, + r.rows[0].TrackID, + r.rows[0].Title, + r.rows[0].Artist, + r.rows[0].Album, + r.rows[0].CoverURL, + r.rows[0].CoverKey, + r.rows[0].PageURL, + r.rows[0].MetaDurationMs, + r.rows[0].ByMemberID, + r.rows[0].ByName, + r.rows[0].ByAvatarHash, + r.rows[0].Auto, + r.rows[0].Likes, + r.rows[0].Round, + r.rows[0].AddedAt, + r.rows[0].StartedAt, + }, nil +} + +func (r iteratorForInsertRoomEntries) Err() error { + return nil +} + +func (q *Queries) InsertRoomEntries(ctx context.Context, arg []InsertRoomEntriesParams) (int64, error) { + return q.db.CopyFrom(ctx, []string{"room_entries"}, []string{"room_id", "entry_id", "slot", "source", "track_id", "title", "artist", "album", "cover_url", "cover_key", "page_url", "meta_duration_ms", "by_member_id", "by_name", "by_avatar_hash", "auto", "likes", "round", "added_at", "started_at"}, &iteratorForInsertRoomEntries{rows: arg}) +} diff --git a/internal/adapter/out/postgres/internal/query/credentials.sql.go b/internal/adapter/out/postgres/internal/query/credentials.sql.go new file mode 100644 index 0000000..2de0855 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/credentials.sql.go @@ -0,0 +1,62 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: credentials.sql + +package query + +import ( + "context" + "time" +) + +const credential = `-- name: Credential :one +SELECT value FROM credentials WHERE name = $1 +` + +func (q *Queries) Credential(ctx context.Context, name string) (string, error) { + row := q.db.QueryRow(ctx, credential, name) + var value string + err := row.Scan(&value) + return value, err +} + +const credentialWithUpdatedAt = `-- name: CredentialWithUpdatedAt :one +SELECT value, updated_at FROM credentials WHERE name = $1 +` + +type CredentialWithUpdatedAtRow struct { + Value string + UpdatedAt time.Time +} + +func (q *Queries) CredentialWithUpdatedAt(ctx context.Context, name string) (CredentialWithUpdatedAtRow, error) { + row := q.db.QueryRow(ctx, credentialWithUpdatedAt, name) + var i CredentialWithUpdatedAtRow + err := row.Scan(&i.Value, &i.UpdatedAt) + return i, err +} + +const deleteCredential = `-- name: DeleteCredential :exec +DELETE FROM credentials WHERE name = $1 +` + +func (q *Queries) DeleteCredential(ctx context.Context, name string) error { + _, err := q.db.Exec(ctx, deleteCredential, name) + return err +} + +const setCredential = `-- name: SetCredential :exec +INSERT INTO credentials (name, value) VALUES ($1, $2) +ON CONFLICT (name) DO UPDATE SET value = excluded.value, updated_at = now() +` + +type SetCredentialParams struct { + Name string + Value string +} + +func (q *Queries) SetCredential(ctx context.Context, arg SetCredentialParams) error { + _, err := q.db.Exec(ctx, setCredential, arg.Name, arg.Value) + return err +} diff --git a/internal/adapter/out/postgres/internal/query/db.go b/internal/adapter/out/postgres/internal/query/db.go new file mode 100644 index 0000000..31b768a --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/db.go @@ -0,0 +1,33 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package query + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row + CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error) +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/adapter/out/postgres/internal/query/history.sql.go b/internal/adapter/out/postgres/internal/query/history.sql.go new file mode 100644 index 0000000..46ac7ee --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/history.sql.go @@ -0,0 +1,131 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: history.sql + +package query + +import ( + "context" +) + +const autoDJExclusions = `-- name: AutoDJExclusions :many +SELECT e.source, e.track_id FROM room_entries e +WHERE e.room_id = $1 AND e.slot IN ('pending', 'waiting') +UNION +SELECT source, track_id FROM ( + SELECT source, track_id + FROM ( + SELECT c.source, c.track_id, c.started_at FROM room_entries c + WHERE c.room_id = $1 AND c.slot = 'current' AND c.auto + UNION ALL + SELECT p.source, p.track_id, p.started_at FROM ( + SELECT source, track_id, auto, started_at FROM plays + WHERE room_id = $1 + ORDER BY started_at DESC + LIMIT 1000 + ) p + WHERE p.auto + ) auto_picks + GROUP BY source, track_id + ORDER BY max(started_at) DESC + LIMIT 128 +) recent +ORDER BY source, track_id +` + +type AutoDJExclusionsRow struct { + Source string + TrackID string +} + +func (q *Queries) AutoDJExclusions(ctx context.Context, roomID string) ([]AutoDJExclusionsRow, error) { + rows, err := q.db.Query(ctx, autoDJExclusions, roomID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AutoDJExclusionsRow + for rows.Next() { + var i AutoDJExclusionsRow + if err := rows.Scan(&i.Source, &i.TrackID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const autoDJSeeds = `-- name: AutoDJSeeds :many +SELECT track_id FROM ( + SELECT DISTINCT ON (track_id) track_id, tier, rank + FROM ( + -- 队列中的歌按等待队列的排序规则排列:轮流播放时点赞数相同的先比轮次。 + SELECT e.track_id, 0 AS tier, + row_number() OVER (ORDER BY CASE e.slot WHEN 'current' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, e.likes DESC, + CASE WHEN r.play_mode = 'rotation' THEN e.round END, e.entry_id) AS rank + FROM room_entries e + JOIN rooms r ON r.id = e.room_id + WHERE e.room_id = $1 AND e.source = 'netease' AND NOT e.auto + AND (e.slot = 'current' OR $2::boolean) + UNION ALL + SELECT p.track_id, 1, row_number() OVER (ORDER BY max(p.started_at) DESC) + FROM ( + SELECT track_id, source, auto, started_at FROM plays + WHERE room_id = $1 + ORDER BY started_at DESC + LIMIT 1000 + ) p + WHERE p.source = 'netease' AND NOT p.auto + GROUP BY p.track_id + ) candidates + ORDER BY track_id, tier, rank +) seeds +ORDER BY tier, rank +LIMIT 32 +` + +type AutoDJSeedsParams struct { + RoomID string + IncludeQueue bool +} + +// 只看最近 1000 次播放:按 plays (room_id, started_at) 的索引倒序读到 1000 行就停,耗时与历史的长度无关。 +func (q *Queries) AutoDJSeeds(ctx context.Context, arg AutoDJSeedsParams) ([]string, error) { + rows, err := q.db.Query(ctx, autoDJSeeds, arg.RoomID, arg.IncludeQueue) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var track_id string + if err := rows.Scan(&track_id); err != nil { + return nil, err + } + items = append(items, track_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const trackInUse = `-- name: TrackInUse :one +SELECT EXISTS (SELECT 1 FROM room_entries WHERE source = $1 AND track_id = $2) +` + +type TrackInUseParams struct { + Source string + TrackID string +} + +func (q *Queries) TrackInUse(ctx context.Context, arg TrackInUseParams) (bool, error) { + row := q.db.QueryRow(ctx, trackInUse, arg.Source, arg.TrackID) + var exists bool + err := row.Scan(&exists) + return exists, err +} diff --git a/internal/adapter/out/postgres/internal/query/media.sql.go b/internal/adapter/out/postgres/internal/query/media.sql.go new file mode 100644 index 0000000..9621289 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/media.sql.go @@ -0,0 +1,98 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: media.sql + +package query + +import ( + "context" + "time" +) + +const deleteStoredMedia = `-- name: DeleteStoredMedia :exec +DELETE FROM tracks WHERE media_key = $1::text +` + +func (q *Queries) DeleteStoredMedia(ctx context.Context, mediaKey string) error { + _, err := q.db.Exec(ctx, deleteStoredMedia, mediaKey) + return err +} + +const listStoredMedia = `-- name: ListStoredMedia :many +SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks +WHERE media_key IS NOT NULL +ORDER BY prepared_at DESC, track_id +` + +type ListStoredMediaRow struct { + MediaKey string + Source string + TrackID string + Title string + MediaSize int64 + CoverKey *string + PreparedAt time.Time + LastPlayedAt *time.Time +} + +func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, error) { + rows, err := q.db.Query(ctx, listStoredMedia) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListStoredMediaRow + for rows.Next() { + var i ListStoredMediaRow + if err := rows.Scan( + &i.MediaKey, + &i.Source, + &i.TrackID, + &i.Title, + &i.MediaSize, + &i.CoverKey, + &i.PreparedAt, + &i.LastPlayedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const storedMedia = `-- name: StoredMedia :one +SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks +WHERE media_key = $1::text +` + +type StoredMediaRow struct { + MediaKey string + Source string + TrackID string + Title string + MediaSize int64 + CoverKey *string + PreparedAt time.Time + LastPlayedAt *time.Time +} + +func (q *Queries) StoredMedia(ctx context.Context, mediaKey string) (StoredMediaRow, error) { + row := q.db.QueryRow(ctx, storedMedia, mediaKey) + var i StoredMediaRow + err := row.Scan( + &i.MediaKey, + &i.Source, + &i.TrackID, + &i.Title, + &i.MediaSize, + &i.CoverKey, + &i.PreparedAt, + &i.LastPlayedAt, + ) + return i, err +} diff --git a/internal/adapter/out/postgres/internal/query/members.sql.go b/internal/adapter/out/postgres/internal/query/members.sql.go new file mode 100644 index 0000000..0ced928 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/members.sql.go @@ -0,0 +1,78 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: members.sql + +package query + +import ( + "context" +) + +const createMember = `-- name: CreateMember :one +INSERT INTO members (token_hash, name) VALUES ($1, $2) +RETURNING id, token_hash, name, avatar_hash, created_at +` + +type CreateMemberParams struct { + TokenHash []byte + Name string +} + +func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Member, error) { + row := q.db.QueryRow(ctx, createMember, arg.TokenHash, arg.Name) + var i Member + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.Name, + &i.AvatarHash, + &i.CreatedAt, + ) + return i, err +} + +const memberByToken = `-- name: MemberByToken :one +SELECT id, token_hash, name, avatar_hash, created_at FROM members WHERE token_hash = $1 +` + +func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) { + row := q.db.QueryRow(ctx, memberByToken, tokenHash) + var i Member + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.Name, + &i.AvatarHash, + &i.CreatedAt, + ) + return i, err +} + +const updateMember = `-- name: UpdateMember :one +UPDATE members +SET name = coalesce($1, name), + avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END +WHERE id = $3 +RETURNING id, token_hash, name, avatar_hash, created_at +` + +type UpdateMemberParams struct { + Name *string + AvatarHash *string + ID string +} + +// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。 +func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Member, error) { + row := q.db.QueryRow(ctx, updateMember, arg.Name, arg.AvatarHash, arg.ID) + var i Member + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.Name, + &i.AvatarHash, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/adapter/out/postgres/internal/query/models.go b/internal/adapter/out/postgres/internal/query/models.go new file mode 100644 index 0000000..2cb8ec7 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/models.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package query + +import ( + "time" +) + +type Member struct { + ID string + TokenHash []byte + Name string + AvatarHash *string + CreatedAt time.Time +} + +type Room struct { + ID string + Kind string + Slug *string + Name string + Description string + PasswordHash *string + Term int64 + PlayMode string + EntrySeq int64 + CreatedAt time.Time + BotMemberID *string +} diff --git a/internal/adapter/out/postgres/internal/query/rooms.sql.go b/internal/adapter/out/postgres/internal/query/rooms.sql.go new file mode 100644 index 0000000..02ffe41 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/rooms.sql.go @@ -0,0 +1,164 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: rooms.sql + +package query + +import ( + "context" +) + +const createRoom = `-- name: CreateRoom :one +INSERT INTO rooms (kind, slug, name, description, password_hash) +VALUES ($1, NULLIF($2::text, ''), $3, $4, NULLIF($5::text, '')) +RETURNING id +` + +type CreateRoomParams struct { + Kind string + Slug string + Name string + Description string + PasswordHash string +} + +func (q *Queries) CreateRoom(ctx context.Context, arg CreateRoomParams) (string, error) { + row := q.db.QueryRow(ctx, createRoom, + arg.Kind, + arg.Slug, + arg.Name, + arg.Description, + arg.PasswordHash, + ) + var id string + err := row.Scan(&id) + return id, err +} + +const deleteRoomTicketsExceptBot = `-- name: DeleteRoomTicketsExceptBot :exec +DELETE FROM room_tickets +WHERE room_id = $1 AND member_id IS DISTINCT FROM (SELECT bot_member_id FROM rooms WHERE id = $1) +` + +// 没有 bot 成员的房间 bot_member_id 为 NULL,写成 <> 就一张票据都不删。 +func (q *Queries) DeleteRoomTicketsExceptBot(ctx context.Context, roomID string) error { + _, err := q.db.Exec(ctx, deleteRoomTicketsExceptBot, roomID) + return err +} + +const listRooms = `-- name: ListRooms :many +SELECT id, kind, slug, name, description, password_hash, term, play_mode, entry_seq, created_at, bot_member_id FROM rooms ORDER BY id +` + +func (q *Queries) ListRooms(ctx context.Context) ([]Room, error) { + rows, err := q.db.Query(ctx, listRooms) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Room + for rows.Next() { + var i Room + if err := rows.Scan( + &i.ID, + &i.Kind, + &i.Slug, + &i.Name, + &i.Description, + &i.PasswordHash, + &i.Term, + &i.PlayMode, + &i.EntrySeq, + &i.CreatedAt, + &i.BotMemberID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const roomByID = `-- name: RoomByID :one +SELECT id, kind, slug, name, description, password_hash, term, play_mode, entry_seq, created_at, bot_member_id FROM rooms WHERE id = $1 +` + +func (q *Queries) RoomByID(ctx context.Context, id string) (Room, error) { + row := q.db.QueryRow(ctx, roomByID, id) + var i Room + err := row.Scan( + &i.ID, + &i.Kind, + &i.Slug, + &i.Name, + &i.Description, + &i.PasswordHash, + &i.Term, + &i.PlayMode, + &i.EntrySeq, + &i.CreatedAt, + &i.BotMemberID, + ) + return i, err +} + +const roomBySlug = `-- name: RoomBySlug :one +SELECT id, kind, slug, name, description, password_hash, term, play_mode, entry_seq, created_at, bot_member_id FROM rooms WHERE slug = $1::text +` + +func (q *Queries) RoomBySlug(ctx context.Context, slug string) (Room, error) { + row := q.db.QueryRow(ctx, roomBySlug, slug) + var i Room + err := row.Scan( + &i.ID, + &i.Kind, + &i.Slug, + &i.Name, + &i.Description, + &i.PasswordHash, + &i.Term, + &i.PlayMode, + &i.EntrySeq, + &i.CreatedAt, + &i.BotMemberID, + ) + return i, err +} + +const setRoomPassword = `-- name: SetRoomPassword :execrows +UPDATE rooms SET password_hash = NULLIF($1::text, '') WHERE id = $2 +` + +type SetRoomPasswordParams struct { + PasswordHash string + ID string +} + +func (q *Queries) SetRoomPassword(ctx context.Context, arg SetRoomPasswordParams) (int64, error) { + result, err := q.db.Exec(ctx, setRoomPassword, arg.PasswordHash, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setRoomSlug = `-- name: SetRoomSlug :execrows +UPDATE rooms SET slug = NULLIF($1::text, '') WHERE id = $2 +` + +type SetRoomSlugParams struct { + Slug string + ID string +} + +func (q *Queries) SetRoomSlug(ctx context.Context, arg SetRoomSlugParams) (int64, error) { + result, err := q.db.Exec(ctx, setRoomSlug, arg.Slug, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/internal/adapter/out/postgres/internal/query/site.sql.go b/internal/adapter/out/postgres/internal/query/site.sql.go new file mode 100644 index 0000000..b299ea7 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/site.sql.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: site.sql + +package query + +import ( + "context" +) + +const brandName = `-- name: BrandName :one +SELECT brand_name FROM site +` + +func (q *Queries) BrandName(ctx context.Context) (string, error) { + row := q.db.QueryRow(ctx, brandName) + var brand_name string + err := row.Scan(&brand_name) + return brand_name, err +} + +const setBrandName = `-- name: SetBrandName :exec +INSERT INTO site (brand_name) VALUES ($1) +ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name +` + +func (q *Queries) SetBrandName(ctx context.Context, brandName string) error { + _, err := q.db.Exec(ctx, setBrandName, brandName) + return err +} diff --git a/internal/adapter/out/postgres/internal/query/snapshot.sql.go b/internal/adapter/out/postgres/internal/query/snapshot.sql.go new file mode 100644 index 0000000..7c4b253 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/snapshot.sql.go @@ -0,0 +1,232 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: snapshot.sql + +package query + +import ( + "context" + "time" +) + +const deleteRoom = `-- name: DeleteRoom :execrows +DELETE FROM rooms WHERE id = $1 AND term = $2 +` + +type DeleteRoomParams struct { + ID string + Term int64 +} + +func (q *Queries) DeleteRoom(ctx context.Context, arg DeleteRoomParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteRoom, arg.ID, arg.Term) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const deleteRoomEntries = `-- name: DeleteRoomEntries :exec +DELETE FROM room_entries WHERE room_id = $1 +` + +func (q *Queries) DeleteRoomEntries(ctx context.Context, roomID string) error { + _, err := q.db.Exec(ctx, deleteRoomEntries, roomID) + return err +} + +type InsertPlaysParams struct { + RoomID string + Source string + TrackID string + ByMemberID *string + ByName *string + ByAvatarHash *string + Auto bool + StartedAt time.Time + EndedAt time.Time +} + +type InsertRoomEntriesParams struct { + RoomID string + EntryID int64 + Slot string + Source string + TrackID string + Title string + Artist *string + Album *string + CoverURL *string + CoverKey *string + PageURL string + MetaDurationMs int64 + ByMemberID *string + ByName *string + ByAvatarHash *string + Auto bool + Likes int32 + Round int64 + AddedAt time.Time + StartedAt *time.Time +} + +const markPlayed = `-- name: MarkPlayed :exec +UPDATE tracks SET last_played_at = GREATEST(last_played_at, $1::timestamptz) +WHERE source = $2 AND track_id = $3 +` + +type MarkPlayedParams struct { + StartedAt time.Time + Source string + TrackID string +} + +// 不同房间同时落盘时,先开播的那一次可能后写入。 +func (q *Queries) MarkPlayed(ctx context.Context, arg MarkPlayedParams) error { + _, err := q.db.Exec(ctx, markPlayed, arg.StartedAt, arg.Source, arg.TrackID) + return err +} + +const roomEntries = `-- name: RoomEntries :many +SELECT e.entry_id, e.slot, e.source, e.track_id, e.title, e.artist, e.album, e.cover_url, e.cover_key, e.page_url, e.meta_duration_ms, + e.by_member_id, e.by_name, e.by_avatar_hash, e.auto, e.likes, e.round, e.added_at, e.started_at, + t.title AS track_title, t.artist AS track_artist, t.album AS track_album, t.cover_url AS track_cover_url, + t.cover_key AS track_cover_key, t.page_url AS track_page_url, t.duration_ms, t.gain, t.lyric +FROM room_entries e +LEFT JOIN tracks t ON t.source = e.source AND t.track_id = e.track_id +WHERE e.room_id = $1 +ORDER BY e.entry_id +` + +type RoomEntriesRow struct { + EntryID int64 + Slot string + Source string + TrackID string + Title string + Artist *string + Album *string + CoverURL *string + CoverKey *string + PageURL string + MetaDurationMs int64 + ByMemberID *string + ByName *string + ByAvatarHash *string + Auto bool + Likes int32 + Round int64 + AddedAt time.Time + StartedAt *time.Time + TrackTitle *string + TrackArtist *string + TrackAlbum *string + TrackCoverURL *string + TrackCoverKey *string + TrackPageURL *string + DurationMs *int64 + Gain *float64 + Lyric *string +} + +func (q *Queries) RoomEntries(ctx context.Context, roomID string) ([]RoomEntriesRow, error) { + rows, err := q.db.Query(ctx, roomEntries, roomID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RoomEntriesRow + for rows.Next() { + var i RoomEntriesRow + if err := rows.Scan( + &i.EntryID, + &i.Slot, + &i.Source, + &i.TrackID, + &i.Title, + &i.Artist, + &i.Album, + &i.CoverURL, + &i.CoverKey, + &i.PageURL, + &i.MetaDurationMs, + &i.ByMemberID, + &i.ByName, + &i.ByAvatarHash, + &i.Auto, + &i.Likes, + &i.Round, + &i.AddedAt, + &i.StartedAt, + &i.TrackTitle, + &i.TrackArtist, + &i.TrackAlbum, + &i.TrackCoverURL, + &i.TrackCoverKey, + &i.TrackPageURL, + &i.DurationMs, + &i.Gain, + &i.Lyric, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const saveRoomState = `-- name: SaveRoomState :execrows +UPDATE rooms SET name = $1, description = $2, play_mode = $3, entry_seq = $4 +WHERE id = $5 AND term = $6 +` + +type SaveRoomStateParams struct { + Name string + Description string + PlayMode string + EntrySeq int64 + ID string + Term int64 +} + +func (q *Queries) SaveRoomState(ctx context.Context, arg SaveRoomStateParams) (int64, error) { + result, err := q.db.Exec(ctx, saveRoomState, + arg.Name, + arg.Description, + arg.PlayMode, + arg.EntrySeq, + arg.ID, + arg.Term, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const takeRoom = `-- name: TakeRoom :one +UPDATE rooms SET term = term + 1 WHERE id = $1 RETURNING id, kind, slug, name, description, password_hash, term, play_mode, entry_seq, created_at, bot_member_id +` + +func (q *Queries) TakeRoom(ctx context.Context, id string) (Room, error) { + row := q.db.QueryRow(ctx, takeRoom, id) + var i Room + err := row.Scan( + &i.ID, + &i.Kind, + &i.Slug, + &i.Name, + &i.Description, + &i.PasswordHash, + &i.Term, + &i.PlayMode, + &i.EntrySeq, + &i.CreatedAt, + &i.BotMemberID, + ) + return i, err +} diff --git a/internal/adapter/out/postgres/internal/query/tickets.sql.go b/internal/adapter/out/postgres/internal/query/tickets.sql.go new file mode 100644 index 0000000..6666c0d --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/tickets.sql.go @@ -0,0 +1,66 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: tickets.sql + +package query + +import ( + "context" +) + +const createTicket = `-- name: CreateTicket :exec +INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES ($1, $2, $3) +` + +type CreateTicketParams struct { + TokenHash []byte + RoomID string + MemberID string +} + +func (q *Queries) CreateTicket(ctx context.Context, arg CreateTicketParams) error { + _, err := q.db.Exec(ctx, createTicket, arg.TokenHash, arg.RoomID, arg.MemberID) + return err +} + +const lockRoomPassword = `-- name: LockRoomPassword :one +SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = $1 FOR SHARE +` + +// FOR SHARE 让修改密码和删除房间等到这次签发提交之后,它们随后删除票据时会删掉这一张。 +func (q *Queries) LockRoomPassword(ctx context.Context, id string) (string, error) { + row := q.db.QueryRow(ctx, lockRoomPassword, id) + var password_hash string + err := row.Scan(&password_hash) + return password_hash, err +} + +const ticketHolder = `-- name: TicketHolder :one +SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot +FROM room_tickets t +JOIN members m ON m.id = t.member_id +JOIN rooms r ON r.id = t.room_id +WHERE t.token_hash = $1 +` + +type TicketHolderRow struct { + RoomID string + ID string + Name string + AvatarHash *string + Bot bool +} + +func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolderRow, error) { + row := q.db.QueryRow(ctx, ticketHolder, tokenHash) + var i TicketHolderRow + err := row.Scan( + &i.RoomID, + &i.ID, + &i.Name, + &i.AvatarHash, + &i.Bot, + ) + return i, err +} diff --git a/internal/adapter/out/postgres/internal/query/tracks.sql.go b/internal/adapter/out/postgres/internal/query/tracks.sql.go new file mode 100644 index 0000000..8287936 --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/tracks.sql.go @@ -0,0 +1,99 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: tracks.sql + +package query + +import ( + "context" +) + +const preparedTrack = `-- name: PreparedTrack :one +SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size FROM tracks +WHERE source = $1 AND track_id = $2 +` + +type PreparedTrackParams struct { + Source string + TrackID string +} + +type PreparedTrackRow struct { + Title string + Artist *string + Album *string + CoverURL *string + CoverKey *string + PageURL string + DurationMs int64 + Gain float64 + Lyric *string + MediaKey *string + MediaSize *int64 +} + +func (q *Queries) PreparedTrack(ctx context.Context, arg PreparedTrackParams) (PreparedTrackRow, error) { + row := q.db.QueryRow(ctx, preparedTrack, arg.Source, arg.TrackID) + var i PreparedTrackRow + err := row.Scan( + &i.Title, + &i.Artist, + &i.Album, + &i.CoverURL, + &i.CoverKey, + &i.PageURL, + &i.DurationMs, + &i.Gain, + &i.Lyric, + &i.MediaKey, + &i.MediaSize, + ) + return i, err +} + +const savePreparedTrack = `-- name: SavePreparedTrack :exec +INSERT INTO tracks (source, track_id, title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +ON CONFLICT (source, track_id) DO UPDATE SET + title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url, + cover_key = excluded.cover_key, page_url = excluded.page_url, duration_ms = excluded.duration_ms, gain = excluded.gain, + lyric = excluded.lyric, media_key = excluded.media_key, media_size = excluded.media_size, + prepared_at = now() +` + +type SavePreparedTrackParams struct { + Source string + TrackID string + Title string + Artist *string + Album *string + CoverURL *string + CoverKey *string + PageURL string + DurationMs int64 + Gain float64 + Lyric *string + MediaKey *string + MediaSize *int64 +} + +// 准备任务只对未完成的任务去重,同一首歌可能先后准备两次,以后一次的结果为准。 +func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackParams) error { + _, err := q.db.Exec(ctx, savePreparedTrack, + arg.Source, + arg.TrackID, + arg.Title, + arg.Artist, + arg.Album, + arg.CoverURL, + arg.CoverKey, + arg.PageURL, + arg.DurationMs, + arg.Gain, + arg.Lyric, + arg.MediaKey, + arg.MediaSize, + ) + return err +} diff --git a/internal/adapter/out/postgres/media.go b/internal/adapter/out/postgres/media.go new file mode 100644 index 0000000..7f6c913 --- /dev/null +++ b/internal/adapter/out/postgres/media.go @@ -0,0 +1,67 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +var _ port.MediaLibrary = (*Store)(nil) + +func (s *Store) ListStoredMedia(ctx context.Context) ([]port.StoredMedia, error) { + rows, err := s.q.ListStoredMedia(ctx) + if err != nil { + return nil, fmt.Errorf("读取已存储的媒体:%w", err) + } + media := make([]port.StoredMedia, len(rows)) + for i, r := range rows { + media[i] = storedMedia(query.StoredMediaRow(r)) + } + return media, nil +} + +func (s *Store) StoredMedia(ctx context.Context, key string) (port.StoredMedia, bool, error) { + row, err := s.q.StoredMedia(ctx, key) + if errors.Is(err, pgx.ErrNoRows) { + return port.StoredMedia{}, false, nil + } + if err != nil { + return port.StoredMedia{}, false, fmt.Errorf("读取已存储的媒体 %s:%w", key, err) + } + return storedMedia(row), true, nil +} + +func (s *Store) DeleteStoredMedia(ctx context.Context, key string) error { + if err := s.q.DeleteStoredMedia(ctx, key); err != nil { + return fmt.Errorf("清除已存储的媒体 %s 的准备结果:%w", key, err) + } + return nil +} + +func storedMedia(r query.StoredMediaRow) port.StoredMedia { + return port.StoredMedia{ + Key: r.MediaKey, + Source: track.Source(r.Source), + Link: r.TrackID, + Title: r.Title, + Size: r.MediaSize, + CoverKey: track.ObjectKey(orEmpty(r.CoverKey)), + StoredAt: r.PreparedAt.UTC(), + LastPlayedAt: utc(r.LastPlayedAt), + } +} + +func utc(t *time.Time) *time.Time { + if t == nil { + return nil + } + u := t.UTC() + return &u +} diff --git a/internal/adapter/out/postgres/members.go b/internal/adapter/out/postgres/members.go new file mode 100644 index 0000000..a90ce23 --- /dev/null +++ b/internal/adapter/out/postgres/members.go @@ -0,0 +1,49 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/member" +) + +var _ port.Members = (*Store)(nil) + +func (s *Store) CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) { + row, err := s.q.CreateMember(ctx, query.CreateMemberParams{TokenHash: tokenHash, Name: name}) + if err != nil { + return member.Member{}, fmt.Errorf("创建成员:%w", err) + } + return memberOf(row), nil +} + +func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) { + row, err := s.q.MemberByToken(ctx, tokenHash) + if errors.Is(err, pgx.ErrNoRows) { + err = port.ErrMemberNotFound + } + if err != nil { + return member.Member{}, fmt.Errorf("按 token 查找成员:%w", err) + } + return memberOf(row), nil +} + +func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error) { + row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash}) + if errors.Is(err, pgx.ErrNoRows) { + err = port.ErrMemberNotFound + } + if err != nil { + return member.Member{}, fmt.Errorf("修改成员 %s:%w", id, err) + } + return memberOf(row), nil +} + +func memberOf(row query.Member) member.Member { + return member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)} +} diff --git a/internal/adapter/out/postgres/migrate.go b/internal/adapter/out/postgres/migrate.go new file mode 100644 index 0000000..c2f1d87 --- /dev/null +++ b/internal/adapter/out/postgres/migrate.go @@ -0,0 +1,64 @@ +package postgres + +import ( + "context" + "fmt" + "log/slog" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" + + "github.com/dihemoe/alisten/db/migrations" +) + +const migrationLock int64 = 0x616c697374656e + +// 锁所在的事务一直占用一个连接,fn 中的迁移要使用连接池中的其他连接。 +func WithMigrationLock(ctx context.Context, pool *pgxpool.Pool, fn func(context.Context) error) error { + return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error { + // 这个事务在整个迁移期间都处于空闲状态,数据库设置的空闲事务超时或事务超时会断开它,锁随之失效。 + // 后到的迁移要一直等待锁,而数据库设置的 lock_timeout、statement_timeout 和 transaction_timeout 会让它报错而不是等待。 + if _, err := tx.Exec(ctx, `SELECT set_config('idle_in_transaction_session_timeout', '0', true), + set_config('lock_timeout', '0', true), set_config('statement_timeout', '0', true), + set_config('transaction_timeout', '0', true)`); err != nil { + return fmt.Errorf("关闭迁移锁所在事务的超时:%w", err) + } + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLock); err != nil { + return fmt.Errorf("获取迁移锁:%w", err) + } + return fn(ctx) + }) +} + +func MigrateSchema(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error { + db := stdlib.OpenDBFromPool(pool) + defer func() { _ = db.Close() }() + provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS) + if err != nil { + return fmt.Errorf("加载数据库迁移:%w", err) + } + results, err := provider.Up(ctx) + if err != nil { + return fmt.Errorf("执行数据库迁移:%w", err) + } + for _, r := range results { + logger.InfoContext(ctx, "已执行数据库迁移", "migration", r.Source.Version, "file", r.Source.Path, "duration", r.Duration) + } + return nil +} + +func MigrateRiver(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error { + // rivermigrate 的日志带有 version 字段,而 slog 会把重名的字段写两次:放进 river 分组,它才不会与日志的固定字段 version 重名。 + migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Logger: logger.WithGroup("river")}) + if err != nil { + return fmt.Errorf("创建 River 迁移器:%w", err) + } + if _, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil); err != nil { + return fmt.Errorf("执行 River 迁移:%w", err) + } + return nil +} diff --git a/internal/adapter/out/postgres/ready.go b/internal/adapter/out/postgres/ready.go new file mode 100644 index 0000000..2b3dbd3 --- /dev/null +++ b/internal/adapter/out/postgres/ready.go @@ -0,0 +1,54 @@ +package postgres + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/database" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" + + "github.com/dihemoe/alisten/db/migrations" +) + +// 只读取版本表:goose 的 HasPending 在缺少版本表时会建表,就绪检查不能修改数据库。 +func CheckMigrations(ctx context.Context, pool *pgxpool.Pool) error { + db := stdlib.OpenDBFromPool(pool) + defer func() { _ = db.Close() }() + provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS) + if err != nil { + return fmt.Errorf("加载数据库迁移:%w", err) + } + sources := provider.ListSources() + required := sources[len(sources)-1].Version + store, err := database.NewStore(database.DialectPostgres, goose.DefaultTablename) + if err != nil { + return fmt.Errorf("创建 goose 版本表的访问:%w", err) + } + current, err := store.GetLatestVersion(ctx, db) + if err != nil { + return fmt.Errorf("读取数据库迁移的版本:%w", err) + } + if current < required { + return fmt.Errorf("数据库迁移的版本为 %d,低于要求的 %d", current, required) + } + + // rivermigrate 每次校验不通过都会记一条 info 日志,而就绪检查每 5 秒执行一次。 + migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Logger: slog.New(slog.DiscardHandler)}) + if err != nil { + return fmt.Errorf("创建 River 迁移器:%w", err) + } + result, err := migrator.Validate(ctx, nil) + if err != nil { + return fmt.Errorf("检查 River 迁移:%w", err) + } + if !result.OK { + return fmt.Errorf("尚未完成 River 迁移:%s", strings.Join(result.Messages, ";")) + } + return nil +} diff --git a/internal/adapter/out/postgres/rooms.go b/internal/adapter/out/postgres/rooms.go new file mode 100644 index 0000000..28eb745 --- /dev/null +++ b/internal/adapter/out/postgres/rooms.go @@ -0,0 +1,128 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/room" +) + +var _ port.RoomDirectory = (*Store)(nil) + +func (s *Store) CreateRoom(ctx context.Context, r port.NewRoom) (string, error) { + id, err := s.q.CreateRoom(ctx, query.CreateRoomParams{ + Kind: kindName(r.Kind), + Slug: r.Slug, + Name: r.Name, + Description: r.Description, + PasswordHash: r.PasswordHash, + }) + if err != nil { + return "", fmt.Errorf("创建房间:%w", slugError(err)) + } + return id, nil +} + +func (s *Store) ListRooms(ctx context.Context) ([]port.RoomRecord, error) { + rows, err := s.q.ListRooms(ctx) + if err != nil { + return nil, fmt.Errorf("列出房间:%w", err) + } + records := make([]port.RoomRecord, len(rows)) + for i, row := range rows { + records[i] = roomRecord(row) + } + return records, nil +} + +func (s *Store) FindRoom(ctx context.Context, ref string) (port.RoomRecord, error) { + var row query.Room + var err error + // 别名最长 32 个字符,不会是 UUID 的形式。 + if isUUID(ref) { + row, err = s.q.RoomByID(ctx, ref) + } else { + row, err = s.q.RoomBySlug(ctx, ref) + } + if errors.Is(err, pgx.ErrNoRows) { + err = port.ErrRoomNotFound + } + if err != nil { + return port.RoomRecord{}, fmt.Errorf("查找房间 %s:%w", ref, err) + } + return roomRecord(row), nil +} + +func (s *Store) UpdateRoomAccess(ctx context.Context, id string, c port.AccessChange) error { + if !isUUID(id) { + return fmt.Errorf("修改房间 %s:%w", id, port.ErrRoomNotFound) + } + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + if c.Slug != nil { + if err := affected(q.SetRoomSlug(ctx, query.SetRoomSlugParams{ID: id, Slug: *c.Slug})); err != nil { + return err + } + } + if c.PasswordHash == nil { + return nil + } + if err := affected(q.SetRoomPassword(ctx, query.SetRoomPasswordParams{ID: id, PasswordHash: *c.PasswordHash})); err != nil { + return err + } + // 取消密码时,已经签发的票据保持有效。 + if *c.PasswordHash == "" { + return nil + } + return q.DeleteRoomTicketsExceptBot(ctx, id) + }) + if err != nil { + return fmt.Errorf("修改房间 %s:%w", id, slugError(err)) + } + return nil +} + +func affected(rows int64, err error) error { + if err == nil && rows == 0 { + return port.ErrRoomNotFound + } + return err +} + +const uniqueViolation = "23505" + +func slugError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "rooms_slug_key" { + return port.ErrSlugTaken + } + return err +} + +func roomRecord(row query.Room) port.RoomRecord { + return port.RoomRecord{Info: roomInfo(row), Slug: orEmpty(row.Slug), PasswordHash: orEmpty(row.PasswordHash)} +} + +func roomInfo(row query.Room) room.Info { + return room.Info{ID: row.ID, Name: row.Name, Description: row.Description, Kind: kindOf(row.Kind)} +} + +func kindName(k room.Kind) string { + if k == room.Persistent { + return "persistent" + } + return "ephemeral" +} + +func kindOf(name string) room.Kind { + if name == "persistent" { + return room.Persistent + } + return room.Ephemeral +} diff --git a/internal/adapter/out/postgres/site.go b/internal/adapter/out/postgres/site.go new file mode 100644 index 0000000..aab9d1f --- /dev/null +++ b/internal/adapter/out/postgres/site.go @@ -0,0 +1,31 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/dihemoe/alisten/internal/app/port" +) + +var _ port.Site = (*Store)(nil) + +func (s *Store) BrandName(ctx context.Context) (string, error) { + name, err := s.q.BrandName(ctx) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("读取品牌名:%w", err) + } + return name, nil +} + +func (s *Store) SetBrandName(ctx context.Context, name string) error { + if err := s.q.SetBrandName(ctx, name); err != nil { + return fmt.Errorf("保存品牌名:%w", err) + } + return nil +} diff --git a/internal/adapter/out/postgres/snapshot.go b/internal/adapter/out/postgres/snapshot.go new file mode 100644 index 0000000..de68f37 --- /dev/null +++ b/internal/adapter/out/postgres/snapshot.go @@ -0,0 +1,243 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/room" + "github.com/dihemoe/alisten/internal/domain/track" +) + +var _ port.RoomSnapshots = (*Store)(nil) + +const ( + slotWaiting = "waiting" + slotPending = "pending" + slotCurrent = "current" +) + +func (s *Store) TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) { + if !isUUID(id) { + return 0, room.Snapshot{}, fmt.Errorf("接管房间 %s:%w", id, port.ErrRoomNotFound) + } + var term uint32 + var snap room.Snapshot + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + row, err := q.TakeRoom(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + return port.ErrRoomNotFound + } + if err != nil { + return err + } + entries, err := q.RoomEntries(ctx, row.ID) + if err != nil { + return err + } + term, snap = uint32(row.Term), snapshotOf(row, entries) + return nil + }) + if err != nil { + return 0, room.Snapshot{}, fmt.Errorf("接管房间 %s:%w", id, err) + } + return term, snap, nil +} + +func (s *Store) SaveRoom(ctx context.Context, term uint32, snap room.Snapshot, plays []room.RecordPlay) error { + id := snap.Info.ID + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + updated, err := q.SaveRoomState(ctx, query.SaveRoomStateParams{ + ID: id, + Term: int64(term), + Name: snap.Info.Name, + Description: snap.Info.Description, + PlayMode: modeName(snap.Mode), + EntrySeq: int64(snap.EntrySeq), + }) + if err != nil { + return err + } + if updated == 0 { + return port.ErrTermExpired + } + if err := q.DeleteRoomEntries(ctx, id); err != nil { + return err + } + if _, err := q.InsertRoomEntries(ctx, entryRows(id, snap)); err != nil { + return err + } + if len(plays) == 0 { + return nil + } + if _, err := q.InsertPlays(ctx, playRows(id, plays)); err != nil { + return err + } + for _, p := range plays { + err := q.MarkPlayed(ctx, query.MarkPlayedParams{ + Source: string(p.Entry.Track.Source), + TrackID: p.Entry.Track.ID, + StartedAt: p.StartedAt, + }) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return fmt.Errorf("落盘房间 %s:%w", id, err) + } + return nil +} + +func (s *Store) DeleteRoom(ctx context.Context, id string, term uint32) error { + deleted, err := s.q.DeleteRoom(ctx, query.DeleteRoomParams{ID: id, Term: int64(term)}) + if err == nil && deleted == 0 { + err = port.ErrTermExpired + } + if err != nil { + return fmt.Errorf("删除房间 %s:%w", id, err) + } + return nil +} + +func snapshotOf(row query.Room, entries []query.RoomEntriesRow) room.Snapshot { + s := room.Snapshot{ + Info: roomInfo(row), + Mode: modeOf(row.PlayMode), + EntrySeq: room.EntryID(row.EntrySeq), + } + for _, r := range entries { + e := entryOf(r) + switch r.Slot { + case slotCurrent: + s.Current = &room.NowPlaying{Entry: e, StartedAt: r.StartedAt.UTC()} + case slotPending: + s.Pending = &e + default: + s.Waiting = append(s.Waiting, e) + } + } + return s +} + +func entryOf(r query.RoomEntriesRow) room.Entry { + e := room.Entry{ + ID: room.EntryID(r.EntryID), + Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID}, + Meta: track.Meta{ + Title: r.Title, + Artist: orEmpty(r.Artist), + Album: orEmpty(r.Album), + CoverURL: orEmpty(r.CoverURL), + CoverKey: track.ObjectKey(orEmpty(r.CoverKey)), + PageURL: r.PageURL, + Duration: time.Duration(r.MetaDurationMs) * time.Millisecond, + }, + By: member.Member{ID: orEmpty(r.ByMemberID), Name: orEmpty(r.ByName), AvatarHash: orEmpty(r.ByAvatarHash)}, + Auto: r.Auto, + Likes: int(r.Likes), + Round: int(r.Round), + AddedAt: r.AddedAt.UTC(), + } + // 曲目表只保存准备成功的结果,有对应的行就是已就绪。元数据也取自曲目表:在房间没有 owner 时准备好的歌, + // 条目中仍是点歌时的元数据,比如链接曲目的标题仍是链接本身。 + if r.DurationMs != nil { + e.Ready = true + e.Meta, e.Prep = prepared(*r.TrackTitle, orEmpty(r.TrackArtist), orEmpty(r.TrackAlbum), orEmpty(r.TrackCoverURL), orEmpty(r.TrackCoverKey), *r.TrackPageURL, *r.DurationMs, *r.Gain, orEmpty(r.Lyric)) + } + return e +} + +func entryRows(roomID string, s room.Snapshot) []query.InsertRoomEntriesParams { + rows := make([]query.InsertRoomEntriesParams, 0, len(s.Waiting)+2) + if s.Current != nil { + rows = append(rows, entryRow(roomID, slotCurrent, s.Current.Entry, &s.Current.StartedAt)) + } + if s.Pending != nil { + rows = append(rows, entryRow(roomID, slotPending, *s.Pending, nil)) + } + for _, e := range s.Waiting { + rows = append(rows, entryRow(roomID, slotWaiting, e, nil)) + } + return rows +} + +func entryRow(roomID, slot string, e room.Entry, startedAt *time.Time) query.InsertRoomEntriesParams { + return query.InsertRoomEntriesParams{ + RoomID: roomID, + EntryID: int64(e.ID), + Slot: slot, + Source: string(e.Track.Source), + TrackID: e.Track.ID, + Title: e.Meta.Title, + Artist: nullable(e.Meta.Artist), + Album: nullable(e.Meta.Album), + CoverURL: nullable(e.Meta.CoverURL), + CoverKey: nullable(string(e.Meta.CoverKey)), + PageURL: e.Meta.PageURL, + MetaDurationMs: e.Meta.Duration.Milliseconds(), + ByMemberID: memberID(e.By), + ByName: nullable(e.By.Name), + ByAvatarHash: nullable(e.By.AvatarHash), + Auto: e.Auto, + Likes: int32(e.Likes), + Round: int64(e.Round), + AddedAt: e.AddedAt, + StartedAt: startedAt, + } +} + +func playRows(roomID string, plays []room.RecordPlay) []query.InsertPlaysParams { + rows := make([]query.InsertPlaysParams, len(plays)) + for i, p := range plays { + rows[i] = query.InsertPlaysParams{ + RoomID: roomID, + Source: string(p.Entry.Track.Source), + TrackID: p.Entry.Track.ID, + ByMemberID: memberID(p.Entry.By), + ByName: nullable(p.Entry.By.Name), + ByAvatarHash: nullable(p.Entry.By.AvatarHash), + Auto: p.Entry.Auto, + StartedAt: p.StartedAt, + EndedAt: p.EndedAt, + } + } + return rows +} + +func memberID(m member.Member) *string { + if m.ID == "" { + return nil + } + return &m.ID +} + +func modeName(m room.PlayMode) string { + switch m { + case room.Random: + return "random" + case room.Rotation: + return "rotation" + } + return "sequential" +} + +func modeOf(name string) room.PlayMode { + switch name { + case "random": + return room.Random + case "rotation": + return room.Rotation + } + return room.Sequential +} diff --git a/internal/adapter/out/postgres/store.go b/internal/adapter/out/postgres/store.go new file mode 100644 index 0000000..c1d0d5d --- /dev/null +++ b/internal/adapter/out/postgres/store.go @@ -0,0 +1,39 @@ +package postgres + +import ( + "regexp" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" +) + +type Store struct { + pool *pgxpool.Pool + q *query.Queries +} + +func New(pool *pgxpool.Pool) *Store { + return &Store{pool: pool, q: query.New(pool)} +} + +var uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// 房间 ID 可能来自客户端:格式不正确的 uuid 参数会让 PostgreSQL 报错,而这样的房间一定不存在。 +func isUUID(s string) bool { + return uuidPattern.MatchString(s) +} + +func nullable(s string) *string { + if s == "" { + return nil + } + return &s +} + +func orEmpty(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/internal/adapter/out/postgres/tickets.go b/internal/adapter/out/postgres/tickets.go new file mode 100644 index 0000000..53be3b0 --- /dev/null +++ b/internal/adapter/out/postgres/tickets.go @@ -0,0 +1,51 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/member" +) + +var _ port.Tickets = (*Store)(nil) + +func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error { + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.q.WithTx(tx) + hash, err := q.LockRoomPassword(ctx, t.RoomID) + if errors.Is(err, pgx.ErrNoRows) { + return port.ErrRoomNotFound + } + if err != nil { + return err + } + if hash != t.PasswordHash { + return port.ErrPasswordChanged + } + return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: t.TokenHash, RoomID: t.RoomID, MemberID: t.MemberID}) + }) + if err != nil { + return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, err) + } + return nil +} + +func (s *Store) TicketHolder(ctx context.Context, tokenHash []byte) (port.TicketHolder, error) { + row, err := s.q.TicketHolder(ctx, tokenHash) + if errors.Is(err, pgx.ErrNoRows) { + err = port.ErrTicketNotFound + } + if err != nil { + return port.TicketHolder{}, fmt.Errorf("查找票据:%w", err) + } + return port.TicketHolder{ + RoomID: row.RoomID, + Member: member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)}, + Bot: row.Bot, + }, nil +} diff --git a/internal/adapter/out/postgres/tracks.go b/internal/adapter/out/postgres/tracks.go new file mode 100644 index 0000000..6df0a9c --- /dev/null +++ b/internal/adapter/out/postgres/tracks.go @@ -0,0 +1,64 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +var _ port.Tracks = (*Store)(nil) + +func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.PreparedTrack, bool, error) { + row, err := s.q.PreparedTrack(ctx, query.PreparedTrackParams{Source: string(t.Source), TrackID: t.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return port.PreparedTrack{}, false, nil + } + if err != nil { + return port.PreparedTrack{}, false, fmt.Errorf("读取曲目 %s %s 的准备结果:%w", t.Source, t.ID, err) + } + meta, result := prepared(row.Title, orEmpty(row.Artist), orEmpty(row.Album), orEmpty(row.CoverURL), orEmpty(row.CoverKey), row.PageURL, row.DurationMs, row.Gain, orEmpty(row.Lyric)) + return port.PreparedTrack{Meta: meta, Result: result, MediaKey: orEmpty(row.MediaKey), MediaSize: orZero(row.MediaSize)}, true, nil +} + +func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.PreparedTrack) error { + params := query.SavePreparedTrackParams{ + Source: string(t.Source), + TrackID: t.ID, + Title: p.Meta.Title, + Artist: nullable(p.Meta.Artist), + Album: nullable(p.Meta.Album), + CoverURL: nullable(p.Meta.CoverURL), + CoverKey: nullable(string(p.Meta.CoverKey)), + PageURL: p.Meta.PageURL, + DurationMs: p.Result.Duration.Milliseconds(), + Gain: p.Result.Gain, + Lyric: nullable(p.Result.Lyric), + } + if p.MediaKey != "" { + params.MediaKey, params.MediaSize = &p.MediaKey, &p.MediaSize + } + if err := s.q.SavePreparedTrack(ctx, params); err != nil { + return fmt.Errorf("保存曲目 %s %s 的准备结果:%w", t.Source, t.ID, err) + } + return nil +} + +func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric string) (track.Meta, track.Prepared) { + d := time.Duration(durationMs) * time.Millisecond + return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: d}, + track.Prepared{Duration: d, Gain: gain, Lyric: lyric} +} + +func orZero(p *int64) int64 { + if p == nil { + return 0 + } + return *p +} diff --git a/internal/adapter/out/qq/account.go b/internal/adapter/out/qq/account.go new file mode 100644 index 0000000..e480e9b --- /dev/null +++ b/internal/adapter/out/qq/account.go @@ -0,0 +1,41 @@ +package qq + +import ( + "context" + "strings" + + "github.com/dihemoe/alisten/internal/app/port" +) + +func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error) { + cookie, err := c.cookie.get(ctx) + if err != nil { + return nil, err + } + id := qqAccountID(cookie) + if id == "" { + return nil, nil + } + return &port.SourceAccount{ID: id}, nil +} + +func qqAccountID(cookie string) string { + for _, name := range []string{"wxuin", "uin", "ptui_loginuin", "luin", "pt2gguin", "superuin", "p_uin", "musicid", "userid", "user_id"} { + value := qqAccountCookieValue(cookie, name) + if value == "" { + continue + } + return strings.TrimSpace(strings.TrimLeft(strings.TrimPrefix(value, "o"), "0")) + } + return "" +} + +func qqAccountCookieValue(cookie, name string) string { + for part := range strings.SplitSeq(cookie, ";") { + key, value, ok := strings.Cut(part, "=") + if ok && strings.TrimSpace(key) == name { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/adapter/out/qq/client.go b/internal/adapter/out/qq/client.go new file mode 100644 index 0000000..4d98a61 --- /dev/null +++ b/internal/adapter/out/qq/client.go @@ -0,0 +1,199 @@ +package qq + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/hashicorp/golang-lru/v2/expirable" + + "github.com/dihemoe/alisten/internal/lib/chinaip" +) + +const ( + requestTimeout = 30 * time.Second + downloadTimeout = 10 * time.Minute + cacheSize = 512 + cacheTTL = 30 * time.Minute + cookieTTL = 30 * time.Second + cookieLoadTimeout = 10 * time.Second +) + +type Client struct { + http *http.Client + api *http.Client + cookie cookieCache + cache *expirable.LRU[string, any] + observe func(source, op string, elapsed time.Duration, err error) +} + +func New(httpClient *http.Client, loadCookie func(context.Context) (string, error), saveCookie func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error)) *Client { + api := *httpClient + api.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &Client{ + http: httpClient, + api: &api, + cookie: cookieCache{load: loadCookie, store: saveCookie}, + cache: expirable.NewLRU[string, any](cacheSize, nil, cacheTTL), + observe: observe, + } +} + +type cookieCache struct { + load func(context.Context) (string, error) + store func(context.Context, string) error + mu sync.Mutex + value string + expires time.Time +} + +func (c *cookieCache) get(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if time.Now().Before(c.expires) { + return c.value, nil + } + ctx, cancel := context.WithTimeout(ctx, cookieLoadTimeout) + defer cancel() + value, err := c.load(ctx) + if err != nil { + return "", err + } + c.value, c.expires = value, time.Now().Add(cookieTTL) + return value, nil +} + +func (c *cookieCache) put(ctx context.Context, value string) error { + c.mu.Lock() + defer c.mu.Unlock() + ctx, cancel := context.WithTimeout(ctx, cookieLoadTimeout) + defer cancel() + if err := c.store(ctx, value); err != nil { + return err + } + c.value, c.expires = value, time.Now().Add(cookieTTL) + return nil +} + +func cached[T any](c *Client, key string, fetch func() (T, error)) (T, error) { + if v, ok := c.cache.Get(key); ok { + return v.(T), nil + } + v, err := fetch() + if err != nil { + return v, err + } + c.cache.Add(key, v) + return v, nil +} + +type response interface{ check() error } + +type status struct { + Code int `json:"code"` +} + +func (s status) check() error { + if s.Code != 0 { + return fmt.Errorf("QQ 音乐返回错误码 %d", s.Code) + } + return nil +} + +func (c *Client) get(ctx context.Context, source, op, endpoint string, query url.Values, header http.Header, out response) error { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + if source == "qq" { + cookie, err := c.cookie.get(ctx) + if err != nil { + return err + } + if cookie != "" { + header = header.Clone() + header.Set("Cookie", cookie) + } + } + start := time.Now() + err := c.fetch(ctx, endpoint+"?"+query.Encode(), header, out) + if err == nil { + err = out.check() + } + c.observe(source, op, time.Since(start), err) + return err +} + +func (c *Client) fetch(ctx context.Context, target string, header http.Header, out response) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return errors.New("QQ 音乐的请求地址无效") + } + req.Header = header.Clone() + chinaip.Set(req.Header) + resp, err := c.api.Do(req) + if err != nil { + return requestError(req.URL.Host, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s 返回 HTTP %d", req.URL.Host, resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("解析 %s 的响应:%w", req.URL.Host, err) + } + return nil +} + +func (c *Client) download(ctx context.Context, audioURL string) (io.ReadCloser, error) { + ctx, cancel := context.WithTimeout(ctx, downloadTimeout) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, audioURL, nil) + if err != nil { + cancel() + return nil, errors.New("QQ 音乐的播放地址无效") + } + req.Header = audioHeader.Clone() + resp, err := c.http.Do(req) + if err != nil { + cancel() + return nil, requestError(req.URL.Host, err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + cancel() + return nil, fmt.Errorf("下载音频:HTTP %d", resp.StatusCode) + } + return &audio{ReadCloser: resp.Body, cancel: cancel}, nil +} + +func requestError(host string, err error) error { + var urlError *url.Error + if errors.As(err, &urlError) { + err = urlError.Err + } + // 播放地址的查询参数可能携带签名,错误包装不得泄漏完整 URL。 + if strings.Contains(err.Error(), "failed to parse Location header") { + return fmt.Errorf("请求 %s:解析跳转地址失败", host) + } + return fmt.Errorf("请求 %s:%w", host, err) +} + +type audio struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (a *audio) Close() error { + err := a.ReadCloser.Close() + a.cancel() + return err +} + +func text(s string) string { + return strings.ReplaceAll(strings.ToValidUTF8(s, "\uFFFD"), "\x00", "") +} diff --git a/internal/adapter/out/qq/login.go b/internal/adapter/out/qq/login.go new file mode 100644 index 0000000..f52929a --- /dev/null +++ b/internal/adapter/out/qq/login.go @@ -0,0 +1,821 @@ +package qq + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/lib/chinaip" +) + +const ( + qqLoginAppID = "716027609" + qqLoginOAuthClient = "100497308" + qqLoginDaid = "383" + qqLoginXUIURL = "https://xui.ptlogin2.qq.com/cgi-bin/xlogin" + qqLoginQRShowURL = "https://xui.ptlogin2.qq.com/ssl/ptqrshow" + qqLoginQRCheckURL = "https://xui.ptlogin2.qq.com/ssl/ptqrlogin" + qqLoginOAuthJumpURL = "https://graph.qq.com/oauth2.0/login_jump" + qqLoginAuthorizeURL = "https://graph.qq.com/oauth2.0/authorize" + qqLoginMusicURL = "https://u.y.qq.com/cgi-bin/musicu.fcg" + qqLoginRedirectURI = "https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https%3A%2F%2Fy.qq.com%2F" + weChatLoginAppID = "wx48db31d50e334801" + weChatLoginRedirectURI = "https://y.qq.com/portal/wx_redirect.html?login_type=2&surl=https://y.qq.com/" + weChatLoginQRConnectURL = "https://open.weixin.qq.com/connect/qrconnect" + weChatLoginQRCheckURL = "https://lp.open.weixin.qq.com/connect/l/qrconnect" + weChatLoginQRCodeURL = "https://open.weixin.qq.com/connect/qrcode/" +) + +var qqLoginMusicURLs = []string{ + qqLoginMusicURL, + "https://szu.y.qq.com/cgi-bin/musicu.fcg", + "https://shu.y.qq.com/cgi-bin/musicu.fcg", +} + +var ( + _ port.QQQRLoginSource = (*Client)(nil) + weChatQRUUIDPattern = regexp.MustCompile(`(?:connect/l/qrconnect\?uuid=|window\.QRLogin\.uuid\s*=\s*["']|/connect/qrcode/)([A-Za-z0-9_-]+)`) + weChatQRErrorPattern = regexp.MustCompile(`wx_errcode\s*=\s*([0-9]+)`) + weChatQRCodePattern = regexp.MustCompile(`wx_code\s*=\s*["']([^"']*)["']`) +) + +type qqQRLoginState struct { + QRSig string `json:"qrsig"` + LoginSig string `json:"login_sig"` + Cookies map[string]string `json:"cookies"` +} + +type weChatQRLoginState struct { + UUID string `json:"uuid"` + State string `json:"state"` +} + +func (c *Client) CreateQRLogin(ctx context.Context, loginType port.QQLoginType) (session port.QRLoginSession, err error) { + switch loginType { + case port.QQLoginQQ, port.QQLoginWeChat: + default: + return port.QRLoginSession{}, fmt.Errorf("未知的 QQ 登录类型 %q", loginType) + } + start := time.Now() + defer func() { c.observe("qq", "login_create", time.Since(start), err) }() + if loginType == port.QQLoginWeChat { + return c.createWeChatQR(ctx) + } + return c.createQQQR(ctx) +} + +func (c *Client) CheckQRLogin(ctx context.Context, loginType port.QQLoginType, key string) (result port.QRLoginResult, err error) { + switch loginType { + case port.QQLoginQQ, port.QQLoginWeChat: + default: + return port.QRLoginResult{}, fmt.Errorf("未知的 QQ 登录类型 %q", loginType) + } + if key == "" { + return port.QRLoginResult{}, errors.New("QQ 扫码登录的 key 为空") + } + start := time.Now() + defer func() { c.observe("qq", "login_check", time.Since(start), err) }() + if loginType == port.QQLoginWeChat { + return c.checkWeChatQR(ctx, key) + } + return c.checkQQQR(ctx, key) +} + +func (c *Client) createQQQR(ctx context.Context) (port.QRLoginSession, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + jar, err := cookiejar.New(nil) + if err != nil { + return port.QRLoginSession{}, err + } + client := qqLoginClient(c.api, jar) + prewarm := url.Values{ + "appid": {qqLoginAppID}, + "daid": {qqLoginDaid}, + "style": {"33"}, + "login_text": {"登录"}, + "hide_title_bar": {"1"}, + "hide_border": {"1"}, + "target": {"self"}, + "s_url": {qqLoginOAuthJumpURL}, + "pt_3rd_aid": {qqLoginOAuthClient}, + "pt_feedback_link": {"https://support.qq.com/products/77942?customInfo=.appid100497308"}, + "theme": {"2"}, + "verify_theme": {""}, + } + resp, err := qqLoginRequest(ctx, client, http.MethodGet, qqLoginXUIURL+"?"+prewarm.Encode(), nil, nil) + if err != nil { + return port.QRLoginSession{}, err + } + if _, err := readQQLoginBody(resp); err != nil { + return port.QRLoginSession{}, err + } + qrQuery := url.Values{ + "appid": {qqLoginAppID}, + "e": {"2"}, + "l": {"M"}, + "s": {"3"}, + "d": {"72"}, + "v": {"4"}, + "t": {strconv.FormatInt(time.Now().Unix(), 10)}, + "daid": {qqLoginDaid}, + "pt_3rd_aid": {qqLoginOAuthClient}, + "u1": {qqLoginOAuthJumpURL}, + } + resp, err = qqLoginRequest(ctx, client, http.MethodGet, qqLoginQRShowURL+"?"+qrQuery.Encode(), nil, nil) + if err != nil { + return port.QRLoginSession{}, err + } + image, err := readQQLoginBody(resp) + if err != nil { + return port.QRLoginSession{}, err + } + state := qqQRLoginState{Cookies: make(map[string]string)} + mergeQQLoginJar(state.Cookies, jar, qqLoginXUIURL, qqLoginQRShowURL, qqLoginQRCheckURL) + for _, cookie := range resp.Cookies() { + if cookie.Value != "" { + state.Cookies[cookie.Name] = cookie.Value + } + } + state.QRSig = state.Cookies["qrsig"] + state.LoginSig = state.Cookies["pt_login_sig"] + if state.QRSig == "" { + return port.QRLoginSession{}, errors.New("QQ 创建扫码登录的响应缺少 qrsig") + } + encoded, err := json.Marshal(state) + if err != nil { + return port.QRLoginSession{}, err + } + return port.QRLoginSession{ + Key: base64.RawURLEncoding.EncodeToString(encoded), + QRImage: "data:image/png;base64," + base64.StdEncoding.EncodeToString(image), + ExpiresAt: time.Now().Add(2 * time.Minute), + }, nil +} + +func (c *Client) checkQQQR(ctx context.Context, key string) (port.QRLoginResult, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + encoded, err := base64.RawURLEncoding.DecodeString(key) + if err != nil { + return port.QRLoginResult{}, fmt.Errorf("解码 QQ 扫码登录的 key:%w", err) + } + var state qqQRLoginState + if err := json.Unmarshal(encoded, &state); err != nil { + return port.QRLoginResult{}, fmt.Errorf("解析 QQ 扫码登录的 key:%w", err) + } + if state.QRSig == "" { + return port.QRLoginResult{}, errors.New("QQ 扫码登录的 key 缺少 qrsig") + } + if state.Cookies == nil { + state.Cookies = make(map[string]string) + } + state.Cookies["qrsig"] = state.QRSig + if state.LoginSig != "" { + state.Cookies["pt_login_sig"] = state.LoginSig + } + jar, err := cookiejar.New(nil) + if err != nil { + return port.QRLoginResult{}, err + } + names := make([]string, 0, len(state.Cookies)) + for name := range state.Cookies { + names = append(names, name) + } + sort.Strings(names) + cookies := make([]*http.Cookie, 0, len(names)) + for _, name := range names { + if state.Cookies[name] != "" { + cookies = append(cookies, &http.Cookie{Name: name, Value: state.Cookies[name], Path: "/"}) + } + } + for _, rawURL := range []string{qqLoginXUIURL, qqLoginQRShowURL, qqLoginQRCheckURL} { + u, err := url.Parse(rawURL) + if err != nil { + return port.QRLoginResult{}, err + } + jar.SetCookies(u, cookies) + } + client := qqLoginClient(c.api, jar) + query := url.Values{ + "u1": {qqLoginOAuthJumpURL}, + "ptqrtoken": {qqLoginHash(state.QRSig, 0)}, + "ptredirect": {"0"}, + "h": {"1"}, + "t": {"1"}, + "g": {"1"}, + "from_ui": {"1"}, + "ptlang": {"2052"}, + "action": {"0-0-" + strconv.FormatInt(time.Now().UnixMilli(), 10)}, + "js_ver": {"26071711"}, + "js_type": {"1"}, + "login_sig": {state.LoginSig}, + "pt_uistyle": {"40"}, + "aid": {qqLoginAppID}, + "daid": {qqLoginDaid}, + "pt_3rd_aid": {qqLoginOAuthClient}, + "pt_js_version": {"c1987b96"}, + } + resp, err := qqLoginRequest(ctx, client, http.MethodGet, qqLoginQRCheckURL+"?"+query.Encode(), nil, nil) + if err != nil { + return port.QRLoginResult{}, err + } + body, err := readQQLoginBody(resp) + if err != nil { + return port.QRLoginResult{}, err + } + fields, err := qqLoginCallbackFields(string(body)) + if err != nil || len(fields) < 5 { + return port.QRLoginResult{}, errors.New("解析 QQ 扫码轮询的响应失败") + } + code, err := strconv.Atoi(fields[0]) + if err != nil { + return port.QRLoginResult{}, fmt.Errorf("解析 QQ 扫码状态:%w", err) + } + result := port.QRLoginResult{Status: qqQRLoginStatus(code), Message: fields[4]} + if result.Status != port.QRLoginSuccess { + return result, nil + } + if fields[2] == "" { + return port.QRLoginResult{}, errors.New("QQ 扫码成功的响应缺少跳转地址") + } + cookie, err := c.completeQQLogin(ctx, client, fields[2]) + if err != nil { + return port.QRLoginResult{}, err + } + if cookie == "" { + result.Status = port.QRLoginFailed + result.Message = "QQ 音乐登录未返回音乐 key" + return result, nil + } + if err := c.cookie.put(ctx, cookie); err != nil { + return port.QRLoginResult{}, fmt.Errorf("保存 QQ Cookie:%w", err) + } + return result, nil +} + +func (c *Client) createWeChatQR(ctx context.Context) (port.QRLoginSession, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + state, err := qqLoginUUID() + if err != nil { + return port.QRLoginSession{}, err + } + query := url.Values{ + "appid": {weChatLoginAppID}, + "redirect_uri": {weChatLoginRedirectURI}, + "response_type": {"code"}, + "scope": {"snsapi_login"}, + "state": {state}, + "href": {"https://y.qq.com/mediastyle/music_v17/src/css/popup_wechat.css#wechat_redirect"}, + } + loginURL := weChatLoginQRConnectURL + "?" + query.Encode() + client := qqLoginClient(c.api, nil) + resp, err := qqLoginRequest(ctx, client, http.MethodGet, loginURL, nil, nil) + if err != nil { + return port.QRLoginSession{}, err + } + html, err := readQQLoginBody(resp) + if err != nil { + return port.QRLoginSession{}, err + } + matches := weChatQRUUIDPattern.FindSubmatch(html) + if len(matches) != 2 || len(matches[1]) == 0 { + return port.QRLoginSession{}, errors.New("微信创建扫码登录的响应缺少 UUID") + } + uuid := string(matches[1]) + encoded, err := json.Marshal(weChatQRLoginState{UUID: uuid, State: state}) + if err != nil { + return port.QRLoginSession{}, err + } + return port.QRLoginSession{ + Key: base64.RawURLEncoding.EncodeToString(encoded), + LoginURL: loginURL, + QRImage: weChatLoginQRCodeURL + url.PathEscape(uuid), + ExpiresAt: time.Now().Add(5 * time.Minute), + }, nil +} + +func (c *Client) checkWeChatQR(ctx context.Context, key string) (port.QRLoginResult, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + encoded, err := base64.RawURLEncoding.DecodeString(key) + if err != nil { + return port.QRLoginResult{}, fmt.Errorf("解码微信扫码登录的 key:%w", err) + } + var state weChatQRLoginState + if err := json.Unmarshal(encoded, &state); err != nil { + return port.QRLoginResult{}, fmt.Errorf("解析微信扫码登录的 key:%w", err) + } + if state.UUID == "" { + return port.QRLoginResult{}, errors.New("微信扫码登录的 key 缺少 UUID") + } + client := qqLoginClient(c.api, nil) + query := url.Values{ + "uuid": {state.UUID}, + "_": {strconv.FormatInt(time.Now().UnixMilli(), 10)}, + } + resp, err := qqLoginRequest(ctx, client, http.MethodGet, weChatLoginQRCheckURL+"?"+query.Encode(), nil, nil) + if err != nil { + return port.QRLoginResult{}, err + } + body, err := readQQLoginBody(resp) + if err != nil { + return port.QRLoginResult{}, err + } + matches := weChatQRErrorPattern.FindSubmatch(body) + if len(matches) != 2 { + return port.QRLoginResult{}, errors.New("解析微信扫码轮询的响应失败") + } + code, err := strconv.Atoi(string(matches[1])) + if err != nil { + return port.QRLoginResult{}, fmt.Errorf("解析微信扫码状态:%w", err) + } + result := port.QRLoginResult{Status: weChatQRLoginStatus(code)} + if result.Status != port.QRLoginSuccess { + return result, nil + } + matches = weChatQRCodePattern.FindSubmatch(body) + if len(matches) != 2 || len(matches[1]) == 0 { + return port.QRLoginResult{}, errors.New("微信扫码成功的响应缺少授权 code") + } + cookie, err := c.completeWeChatLogin(ctx, client, string(matches[1])) + if err != nil { + return port.QRLoginResult{}, err + } + if cookie == "" { + result.Status = port.QRLoginFailed + result.Message = "QQ 音乐登录未返回音乐 key" + return result, nil + } + if err := c.cookie.put(ctx, cookie); err != nil { + return port.QRLoginResult{}, fmt.Errorf("保存 QQ Cookie:%w", err) + } + return result, nil +} + +func weChatQRLoginStatus(code int) port.QRLoginStatus { + switch code { + case 405: + return port.QRLoginSuccess + case 402: + return port.QRLoginExpired + case 404: + return port.QRLoginScanned + case 408: + return port.QRLoginWaiting + default: + return port.QRLoginFailed + } +} + +func (c *Client) completeWeChatLogin(ctx context.Context, client *http.Client, code string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + requestBody, err := json.Marshal(map[string]any{ + "comm": map[string]any{ + "tmeAppID": "qqmusic", "tmeLoginType": "1", "g_tk": 5381, + "platform": "yqq", "ct": 24, "cv": 0, + }, + "req": map[string]any{ + "module": "music.login.LoginServer", + "method": "Login", + "param": map[string]string{"strAppid": weChatLoginAppID, "code": code}, + }, + }) + if err != nil { + return "", err + } + header := http.Header{ + "Content-Type": {"application/x-www-form-urlencoded"}, + "Referer": {weChatLoginRedirectURI}, + "Origin": {"https://y.qq.com"}, + "Cookie": {"login_type=2"}, + } + var errs []error + for _, endpoint := range qqLoginMusicURLs { + cookie, err := completeWeChatLoginAt(ctx, client, endpoint, requestBody, header) + if err == nil { + return cookie, nil + } + errs = append(errs, err) + } + return "", errors.Join(errs...) +} + +func completeWeChatLoginAt(ctx context.Context, client *http.Client, endpoint string, requestBody []byte, header http.Header) (string, error) { + resp, err := qqLoginRequest(ctx, client, http.MethodPost, endpoint, bytes.NewReader(requestBody), header) + if err != nil { + return "", err + } + musicBody, err := readQQLoginBody(resp) + if err != nil { + return "", err + } + credentials := make(map[string]string) + if err := mergeQQMusicLoginResponse(credentials, resp, musicBody); err != nil { + return "", err + } + return serializeQQLoginCredentials(credentials), nil +} + +func (c *Client) completeQQLogin(ctx context.Context, client *http.Client, redirectURL string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + finalJump, err := followQQLogin(ctx, client, redirectURL) + if err != nil { + return "", err + } + credentials := make(map[string]string) + mergeQQLoginJar(credentials, client.Jar, finalJump, qqLoginAuthorizeURL, qqLoginRedirectURI) + pSKey := credentials["p_skey"] + if pSKey == "" { + return "", errors.New("QQ 登录跳转后缺少 p_skey") + } + ui, err := qqLoginUUID() + if err != nil { + return "", err + } + form := url.Values{ + "response_type": {"code"}, + "client_id": {qqLoginOAuthClient}, + "redirect_uri": {qqLoginRedirectURI}, + "scope": {"get_user_info,get_app_friends"}, + "state": {"state"}, + "switch": {""}, + "from_ptlogin": {"1"}, + "src": {"1"}, + "update_auth": {"1"}, + "openapi": {"1010_1030"}, + "g_tk": {qqLoginHash(pSKey, 5381)}, + "auth_time": {strconv.FormatInt(time.Now().UnixMilli(), 10)}, + "ui": {ui}, + } + header := http.Header{"Content-Type": {"application/x-www-form-urlencoded"}} + resp, err := qqLoginRequest(ctx, client, http.MethodPost, qqLoginAuthorizeURL, strings.NewReader(form.Encode()), header) + if err != nil { + return "", err + } + finalOAuth := resp.Request.URL.String() + if qqLoginRedirect(resp.StatusCode) { + location := resp.Header.Get("Location") + if location == "" { + closeQQLoginBody(resp) + return "", errors.New("QQ OAuth 跳转缺少 Location") + } + next, err := resp.Request.URL.Parse(location) + closeQQLoginBody(resp) + if err != nil { + return "", errors.New("解析 QQ OAuth 跳转地址失败") + } + finalOAuth, err = followQQLogin(ctx, client, next.String()) + if err != nil { + return "", err + } + } else if _, err := readQQLoginBody(resp); err != nil { + return "", err + } + mergeQQLoginJar(credentials, client.Jar, finalOAuth, qqLoginRedirectURI, qqLoginMusicURL) + oauthURL, err := url.Parse(finalOAuth) + if err != nil { + return "", errors.New("解析 QQ OAuth 回调地址失败") + } + code := oauthURL.Query().Get("code") + if code == "" { + return "", errors.New("QQ OAuth 回调缺少 code") + } + requestBody, err := json.Marshal(map[string]any{ + "comm": map[string]any{"g_tk": 5381, "platform": "yqq", "ct": 24, "cv": 0}, + "req": map[string]any{ + "module": "QQConnectLogin.LoginServer", + "method": "QQLogin", + "param": map[string]string{"code": code}, + }, + }) + if err != nil { + return "", err + } + header = http.Header{ + "Content-Type": {"application/json"}, + "Referer": {"https://y.qq.com/"}, + "Origin": {"https://y.qq.com"}, + } + resp, err = qqLoginRequest(ctx, client, http.MethodPost, qqLoginMusicURL, bytes.NewReader(requestBody), header) + if err != nil { + return "", err + } + musicBody, err := readQQLoginBody(resp) + if err != nil { + return "", err + } + mergeQQLoginJar(credentials, client.Jar, qqLoginMusicURL, finalOAuth, finalJump) + if err := mergeQQMusicLoginResponse(credentials, resp, musicBody); err != nil { + return "", err + } + return serializeQQLoginCredentials(credentials), nil +} + +func mergeQQMusicLoginResponse(credentials map[string]string, resp *http.Response, musicBody []byte) error { + var payload struct { + Code *int `json:"code"` + Req struct { + Code *int `json:"code"` + Data map[string]any `json:"data"` + } `json:"req"` + } + decoder := json.NewDecoder(bytes.NewReader(musicBody)) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return fmt.Errorf("解析 QQ 音乐登录的响应:%w", err) + } + if payload.Code == nil || payload.Req.Code == nil { + return errors.New("QQ 音乐登录响应的 code 或 req.code 缺失或为 null") + } + if *payload.Code != 0 || *payload.Req.Code != 0 { + return fmt.Errorf("QQ 音乐登录返回错误码 %d、%d", *payload.Code, *payload.Req.Code) + } + for _, cookie := range resp.Cookies() { + if cookie.Value != "" { + credentials[cookie.Name] = cookie.Value + } + } + for name, value := range payload.Req.Data { + musicKey := false + switch name { + case "qm_keyst", "qqmusic_key", "musickey", "music_key", "strMusicKey": + musicKey = true + case "uin", "musicid", "userid", "user_id", "ptui_loginuin", "luin", "pt2gguin", "superuin", "p_uin", "wxuin", + "refresh_key", "refresh_token", "openid", "unionid", "wxaccess_token": + default: + continue + } + credential := "" + switch value := value.(type) { + case string: + if value == "" { + continue + } + credential = value + case json.Number: + credential = value.String() + default: + continue + } + cookie := &http.Cookie{Name: name, Value: credential} + if cookie.Valid() != nil { + if musicKey { + return fmt.Errorf("QQ 音乐登录响应的音乐 key 字段 %s 无效", name) + } + continue + } + credentials[name] = credential + } + return nil +} + +func serializeQQLoginCredentials(credentials map[string]string) string { + if !normalizeQQLoginCredentials(credentials) { + return "" + } + names := make([]string, 0, len(credentials)) + for name, value := range credentials { + if name != "" && value != "" { + names = append(names, name) + } + } + sort.Strings(names) + pairs := make([]string, len(names)) + for i, name := range names { + pairs[i] = name + "=" + credentials[name] + } + return strings.Join(pairs, "; ") +} + +func followQQLogin(ctx context.Context, client *http.Client, rawURL string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + manual := *client + manual.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + client = &manual + current := rawURL + for redirects := 0; ; { + if !validQQLoginURL(current) { + return "", errors.New("QQ 登录跳转地址仅允许 HTTPS 和 QQ 域名") + } + resp, err := qqLoginRequest(ctx, client, http.MethodGet, current, nil, nil) + if err != nil { + return "", err + } + if !qqLoginRedirect(resp.StatusCode) { + finalURL := resp.Request.URL.String() + if _, err := readQQLoginBody(resp); err != nil { + return "", err + } + return finalURL, nil + } + if redirects >= 10 { + closeQQLoginBody(resp) + return "", errors.New("QQ 登录跳转超过 10 次") + } + location := resp.Header.Get("Location") + if location == "" { + closeQQLoginBody(resp) + return "", errors.New("QQ 登录跳转缺少 Location") + } + next, err := resp.Request.URL.Parse(location) + closeQQLoginBody(resp) + if err != nil { + return "", errors.New("解析 QQ 登录跳转地址失败") + } + current = next.String() + redirects++ + } +} + +func validQQLoginURL(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil || u.Scheme != "https" { + return false + } + host := u.Hostname() + return host == "qq.com" || strings.HasSuffix(host, ".qq.com") +} + +func qqLoginClient(api *http.Client, jar http.CookieJar) *http.Client { + client := *api + client.Jar = jar + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &client +} + +func qqLoginRequest(ctx context.Context, client *http.Client, method, target string, body io.Reader, header http.Header) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, target, body) + if err != nil { + return nil, err + } + if header != nil { + req.Header = header.Clone() + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", desktopHeader.Get("User-Agent")) + } + chinaip.Set(req.Header) + resp, err := client.Do(req) + if err != nil { + var requestError *url.Error + if errors.As(err, &requestError) { + err = requestError.Err + } + // net/http 会在 CheckRedirect 前解析 Location,这个错误没有独立类型。 + // 按文本识别并替换解析错误,避免完整登录跳转地址及查询参数进入错误或日志。 + if strings.Contains(err.Error(), "failed to parse Location header") { + return nil, fmt.Errorf("请求 %s:解析 QQ 登录跳转地址失败", req.URL.Host) + } + return nil, fmt.Errorf("请求 %s:%w", req.URL.Host, err) + } + return resp, nil +} + +func readQQLoginBody(resp *http.Response) ([]byte, error) { + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s 返回 HTTP %d", resp.Request.URL.Host, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 %s 的响应:%w", resp.Request.URL.Host, err) + } + return body, nil +} + +func closeQQLoginBody(resp *http.Response) { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() +} + +func qqLoginRedirect(status int) bool { + return status == http.StatusMovedPermanently || status == http.StatusFound || status == http.StatusSeeOther || + status == http.StatusTemporaryRedirect || status == http.StatusPermanentRedirect +} + +func qqLoginCallbackFields(body string) ([]string, error) { + var fields []string + var field strings.Builder + quoted, escaped := false, false + for _, r := range body { + if !quoted { + if r == '\'' { + quoted = true + field.Reset() + } + continue + } + if escaped { + field.WriteRune(r) + escaped = false + continue + } + switch r { + case '\\': + escaped = true + case '\'': + fields = append(fields, field.String()) + quoted = false + default: + field.WriteRune(r) + } + } + if quoted { + return nil, errors.New("未闭合的回调字段") + } + return fields, nil +} + +func qqQRLoginStatus(code int) port.QRLoginStatus { + switch code { + case 0: + return port.QRLoginSuccess + case 65: + return port.QRLoginExpired + case 66: + return port.QRLoginWaiting + case 67: + return port.QRLoginScanned + default: + return port.QRLoginFailed + } +} + +func qqLoginHash(value string, initial int64) string { + hash := initial + for _, r := range value { + hash += (hash << 5) + int64(r) + } + return strconv.FormatInt(hash&0x7fffffff, 10) +} + +func qqLoginUUID() (string, error) { + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + return "", err + } + id[6] = id[6]&0x0f | 0x40 + id[8] = id[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", id[0:4], id[4:6], id[6:8], id[8:10], id[10:16]), nil +} + +func mergeQQLoginJar(credentials map[string]string, jar http.CookieJar, rawURLs ...string) { + for _, rawURL := range rawURLs { + u, err := url.Parse(rawURL) + if err != nil { + continue + } + for _, cookie := range jar.Cookies(u) { + if cookie.Value != "" { + credentials[cookie.Name] = cookie.Value + } + } + } +} + +func normalizeQQLoginCredentials(credentials map[string]string) bool { + musicKey := "" + for _, name := range []string{"qm_keyst", "qqmusic_key", "musickey", "music_key", "strMusicKey"} { + if credentials[name] != "" { + musicKey = credentials[name] + break + } + } + if musicKey == "" { + return false + } + if credentials["qm_keyst"] == "" { + credentials["qm_keyst"] = musicKey + } + if credentials["qqmusic_key"] == "" { + credentials["qqmusic_key"] = musicKey + } + if credentials["uin"] == "" { + for _, name := range []string{"musicid", "userid", "user_id", "ptui_loginuin", "luin", "pt2gguin", "superuin", "p_uin", "wxuin"} { + if credentials[name] != "" { + credentials["uin"] = credentials[name] + break + } + } + } + return true +} diff --git a/internal/adapter/out/qq/play.go b/internal/adapter/out/qq/play.go new file mode 100644 index 0000000..f7b9da2 --- /dev/null +++ b/internal/adapter/out/qq/play.go @@ -0,0 +1,83 @@ +package qq + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + + "github.com/dihemoe/alisten/internal/app/port" +) + +const ( + playURLAPI = "https://u.y.qq.com/cgi-bin/musicu.fcg" + playURLCDN = "https://ws.stream.qqmusic.qq.com/" +) + +type playURLResponse struct { + Code *int `json:"code"` + Req1 struct { + Code *int `json:"code"` + Data struct { + Items []struct { + Filename string `json:"filename"` + PURL string `json:"purl"` + } `json:"midurlinfo"` + } `json:"data"` + } `json:"req_1"` +} + +func (r playURLResponse) check() error { + if r.Code == nil || r.Req1.Code == nil { + return errors.New("QQ 音乐播放地址响应的 code 或 req_1.code 缺失或为 null") + } + if *r.Code != 0 { + return fmt.Errorf("QQ 音乐返回错误码 %d", *r.Code) + } + if *r.Req1.Code != 0 { + return fmt.Errorf("QQ 音乐播放地址请求返回错误码 %d", *r.Req1.Code) + } + return nil +} + +var _ port.PlayURLs = (*Client)(nil) + +func (c *Client) PlayURL(ctx context.Context, songMID string) (string, error) { + filenames := []string{ + "F000" + songMID + songMID + ".flac", + "M800" + songMID + songMID + ".mp3", + "M500" + songMID + songMID + ".mp3", + } + payload, err := json.Marshal(map[string]any{ + "req_1": map[string]any{ + "module": "music.vkey.GetVkey", + "method": "UrlGetVkey", + "param": map[string]any{ + "filename": filenames, + "guid": "0", + "songmid": []string{songMID}, + "songtype": []int{0}, + "uin": "0", + "loginflag": 1, + "platform": "20", + }, + }, + }) + if err != nil { + return "", err + } + var resp playURLResponse + query := url.Values{"data": {string(payload)}} + if err := c.get(ctx, "qq", "play_url", playURLAPI, query, desktopHeader, &resp); err != nil { + return "", err + } + for _, filename := range filenames { + for _, item := range resp.Req1.Data.Items { + if item.Filename == filename && item.PURL != "" { + return playURLCDN + item.PURL, nil + } + } + } + return "", &port.RejectedError{Err: errors.New("QQ 音乐没有提供这首歌的播放地址")} +} diff --git a/internal/adapter/out/qq/qq.go b/internal/adapter/out/qq/qq.go new file mode 100644 index 0000000..8ac47a2 --- /dev/null +++ b/internal/adapter/out/qq/qq.go @@ -0,0 +1,217 @@ +package qq + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "html" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +const ( + searchAPI = "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp" + searchPlaylistAPI = "https://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist" + detailAPI = "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg" + lyricAPI = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" +) + +var ( + mobileHeader = http.Header{ + "User-Agent": {"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"}, + "Referer": {"http://m.y.qq.com"}, + } + desktopHeader = http.Header{ + "User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"}, + "Referer": {"https://y.qq.com/portal/search.html"}, + } + lyricHeader = http.Header{ + "User-Agent": mobileHeader["User-Agent"], + "Referer": {"https://y.qq.com/portal/player.html"}, + } + audioHeader = http.Header{ + "User-Agent": desktopHeader["User-Agent"], + } +) + +var _ port.Source = (*Client)(nil) + +type singer struct { + Name string `json:"name"` +} + +func (c *Client) SearchTracks(ctx context.Context, keyword string) ([]port.Song, error) { + return cached(c, "search_tracks "+keyword, func() ([]port.Song, error) { + var resp struct { + status + Data struct { + Song struct { + List []struct { + SongMID string `json:"songmid"` + SongName string `json:"songname"` + Singer []singer `json:"singer"` + AlbumName string `json:"albumname"` + AlbumMID string `json:"albummid"` + Interval int64 `json:"interval"` + } `json:"list"` + } `json:"song"` + } `json:"data"` + } + query := url.Values{"w": {keyword}, "format": {"json"}, "p": {"1"}, "n": {"20"}} + if err := c.get(ctx, "qq", "search_tracks", searchAPI, query, mobileHeader, &resp); err != nil { + return nil, err + } + songs := make([]port.Song, len(resp.Data.Song.List)) + for i, s := range resp.Data.Song.List { + songs[i] = song(s.SongMID, s.SongName, s.Singer, s.AlbumName, s.AlbumMID, s.Interval) + } + return songs, nil + }) +} + +func (c *Client) SearchPlaylists(ctx context.Context, keyword string) ([]port.Playlist, error) { + return cached(c, "search_playlists "+keyword, func() ([]port.Playlist, error) { + var resp struct { + status + Data struct { + List []struct { + DissID string `json:"dissid"` + DissName string `json:"dissname"` + ImgURL string `json:"imgurl"` + Introduction string `json:"introduction"` + Creator struct { + Name string `json:"name"` + } `json:"creator"` + ListenNum int64 `json:"listennum"` + SongCount int64 `json:"song_count"` + } `json:"list"` + } `json:"data"` + } + query := url.Values{ + "query": {keyword}, "page_no": {"0"}, "num_per_page": {"20"}, "format": {"json"}, + "remoteplace": {"txt.yqq.playlist"}, "flag_qc": {"0"}, + } + if err := c.get(ctx, "qq", "search_playlists", searchPlaylistAPI, query, desktopHeader, &resp); err != nil { + return nil, err + } + playlists := make([]port.Playlist, len(resp.Data.List)) + for i, p := range resp.Data.List { + playlists[i] = port.Playlist{ + ID: p.DissID, + Name: html.UnescapeString(p.DissName), + CoverURL: p.ImgURL, + Creator: html.UnescapeString(p.Creator.Name), + Description: html.UnescapeString(p.Introduction), + TrackCount: p.SongCount, + PlayCount: p.ListenNum, + } + } + return playlists, nil + }) +} + +func (c *Client) TrackMeta(ctx context.Context, id string) (track.Meta, error) { + return cached(c, "track_meta "+id, func() (track.Meta, error) { + var resp struct { + status + Data []struct { + MID string `json:"mid"` + // title 可包含 Live、伴奏这类版本说明,name 没有。 + Title string `json:"title"` + Singer []singer `json:"singer"` + Album struct { + MID string `json:"mid"` + Name string `json:"name"` + } `json:"album"` + Interval int64 `json:"interval"` + } `json:"data"` + } + query := url.Values{"songmid": {id}, "format": {"json"}} + if err := c.get(ctx, "qq", "track_detail", detailAPI, query, mobileHeader, &resp); err != nil { + return track.Meta{}, err + } + if len(resp.Data) == 0 { + return track.Meta{}, &port.RejectedError{Err: errors.New("QQ 音乐中没有这首歌")} + } + s := resp.Data[0] + return song(s.MID, s.Title, s.Singer, s.Album.Name, s.Album.MID, s.Interval).Meta, nil + }) +} + +func (c *Client) Resolve(ctx context.Context, id string) (port.Resolved, error) { + meta, err := c.TrackMeta(ctx, id) + if err != nil { + return port.Resolved{}, err + } + lyric, err := c.lyric(ctx, id) + if err != nil { + return port.Resolved{}, err + } + audioURL, err := c.PlayURL(ctx, id) + if err != nil { + return port.Resolved{}, err + } + audio, err := c.download(ctx, audioURL) + if err != nil { + return port.Resolved{}, err + } + return port.Resolved{Meta: meta, Lyric: lyric, Audio: audio}, nil +} + +type lyricResponse struct { + Code int `json:"code"` + Lyric string `json:"lyric"` +} + +func (lyricResponse) check() error { return nil } + +func (c *Client) lyric(ctx context.Context, id string) (string, error) { + return cached(c, "lyric "+id, func() (string, error) { + var resp lyricResponse + query := url.Values{ + "songmid": {id}, "loginUin": {"0"}, "hostUin": {"0"}, "format": {"json"}, "inCharset": {"utf8"}, + "outCharset": {"utf-8"}, "notice": {"0"}, "platform": {"y.qq.json"}, "needNewCode": {"0"}, + } + if err := c.get(ctx, "qq", "lyric", lyricAPI, query, lyricHeader, &resp); err != nil { + return "", err + } + if resp.Code != 0 || resp.Lyric == "" { + return "", nil + } + b, err := base64.StdEncoding.DecodeString(resp.Lyric) + if err != nil { + return "", fmt.Errorf("解码 QQ 音乐的歌词:%w", err) + } + return text(string(b)), nil + }) +} + +func song(mid, title string, singers []singer, album, albumMID string, seconds int64) port.Song { + names := make([]string, 0, len(singers)) + for _, s := range singers { + if s.Name != "" { + names = append(names, s.Name) + } + } + cover := "" + if albumMID != "" { + cover = "https://y.gtimg.cn/music/photo_new/T002R300x300M000" + albumMID + ".jpg" + } + return port.Song{ + Track: track.Track{Source: track.QQ, ID: mid}, + Meta: track.Meta{ + Title: text(title), + Artist: text(strings.Join(names, ", ")), + Album: text(album), + CoverURL: text(cover), + PageURL: "https://y.qq.com/n/ryqq/songDetail/" + mid, + Duration: time.Duration(seconds) * time.Second, + }, + } +} diff --git a/internal/adapter/out/queue/queue.go b/internal/adapter/out/queue/queue.go new file mode 100644 index 0000000..4c4ad27 --- /dev/null +++ b/internal/adapter/out/queue/queue.go @@ -0,0 +1,113 @@ +package queue + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +// 任务载荷是跨版本契约:新版本必须能执行旧版本留下的任务。载荷包含曲目的 Source 和 ID。 +// 适配器之间不能互相 import,internal/adapter/in/worker 按相同的 kind 和字段解码。 +type prepareArgs struct { + Source string `json:"source"` + ID string `json:"id"` +} + +func (prepareArgs) Kind() string { return "prepare" } + +// internal/adapter/in/worker 按同样的队列名执行任务。 +const ( + prepareQueue = "prepare" + linkQueue = "ytdlp" + bilibiliQueue = "bilibili" +) + +func (a prepareArgs) InsertOpts() river.InsertOpts { + return river.InsertOpts{ + Queue: queueOf(track.Source(a.Source)), + MaxAttempts: 2, + // 去重不包括已完成的任务:曲目表中的结果被清除之后,River 保留的已完成任务不能阻止重新准备。 + UniqueOpts: river.UniqueOpts{ + ByArgs: true, + ByState: []rivertype.JobState{ + rivertype.JobStateAvailable, rivertype.JobStatePending, rivertype.JobStateRetryable, + rivertype.JobStateRunning, rivertype.JobStateScheduled, + }, + }, + } +} + +// 链接和 B 站的歌由 worker 准备:只有 worker 的镜像里有 yt-dlp 和 ffmpeg。 +func queueOf(source track.Source) string { + switch source { + case track.Link: + return linkQueue + case track.Bilibili: + return bilibiliQueue + } + return prepareQueue +} + +type Queue struct { + client *river.Client[pgx.Tx] +} + +var _ port.PrepareJobs = (*Queue)(nil) + +func New(client *river.Client[pgx.Tx]) *Queue { + return &Queue{client: client} +} + +func (q *Queue) Submit(ctx context.Context, t track.Track) (int64, error) { + res, err := q.client.Insert(ctx, prepareArgs{Source: string(t.Source), ID: t.ID}, nil) + if err != nil { + return 0, fmt.Errorf("提交 %s %s 的准备任务:%w", t.Source, t.ID, err) + } + return res.Job.ID, nil +} + +// 已被 River 清理的任务当作已完成:成功的结果在曲目表中,失败的任务不会再执行。 +func (q *Queue) Status(ctx context.Context, jobID int64) (port.JobState, string, error) { + job, err := q.client.JobGet(ctx, jobID) + if errors.Is(err, river.ErrNotFound) { + return port.JobFinished, "", nil + } + if err != nil { + return 0, "", fmt.Errorf("查询准备任务 %d:%w", jobID, err) + } + switch job.State { + case rivertype.JobStateCompleted: + return port.JobFinished, "", nil + case rivertype.JobStateCancelled: + return port.JobFailed, reason(job), nil + case rivertype.JobStateDiscarded: + return port.JobFailed, port.UnavailableReason, nil + } + return port.JobPending, "", nil +} + +// River 记录取消的原因时带有 "JobCancelError: " 前缀;internal/adapter/in/worker 以同样的方式提取任务失败的原因。 +func reason(job *rivertype.JobRow) string { + if len(job.Errors) == 0 { + return "准备任务失败" + } + return strings.TrimPrefix(job.Errors[len(job.Errors)-1].Error, "JobCancelError: ") +} + +// River client 没有统计任务数的接口,所以直接读 River 的任务表。 +func WaitingLinkJobs(ctx context.Context, pool *pgxpool.Pool) (int64, error) { + var n int64 + if err := pool.QueryRow(ctx, "SELECT count(*) FROM river_job WHERE queue = $1 AND state = 'available'", linkQueue).Scan(&n); err != nil { + return 0, fmt.Errorf("统计等待执行的链接准备任务:%w", err) + } + return n, nil +} diff --git a/internal/adapter/out/redis/bus.go b/internal/adapter/out/redis/bus.go new file mode 100644 index 0000000..7b1d501 --- /dev/null +++ b/internal/adapter/out/redis/bus.go @@ -0,0 +1,395 @@ +package redis + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "maps" + "net" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + goredis "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/propagation" + semconv "go.opentelemetry.io/otel/semconv/v1.43.0" + "go.opentelemetry.io/otel/trace" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/member" +) + +var _ port.Bus = (*Bus)(nil) + +const ( + membersChannel = "members" + preparedChannel = "tracks.prepared" + roomPrefix = "room:" + // 订阅连接空闲这么久发一次 PING,再过这么久仍无回复就换一个新连接: + // 半开的 TCP 连接读不到任何错误,只能这样发现。 + pingInterval = 5 * time.Second + reconnectDelay = time.Second + // 订阅连接上的写入不使用调用方的期限:go-redis 把写超时当作连接已坏而重建共用的订阅连接,本实例的全部订阅随之中断; + // 而调用方可能在 go-redis 内部的锁上一直等到自己的期限用完才开始写,例如 go-redis 正持锁重新连接时。 + // go-redis 从真正开始写的时刻按 WriteTimeout 计算写入的期限,这个上限只是兜底,要长于持锁重新连接的时间: + // 默认的连接、读、写超时各 5 秒,持锁重新连接一次最长约 20–30 秒。 + sharedWriteTimeout = 30 * time.Second +) + +var errBusClosed = errors.New("实例间消息的订阅已经关闭") + +func instanceChannel(id string) string { return "inst:" + id } +func roomChannel(id string) string { return roomPrefix + id } + +type Bus struct { + client *goredis.Client + instance string + version string + tracer trace.Tracer + logger *slog.Logger + dropped *prometheus.CounterVec + done chan struct{} + + // 持有 mu 时不调用可能等网络的 go-redis 方法:Receive 处理 PONG、报告中断都要 mu。 + mu sync.Mutex + closed bool + ps *goredis.PubSub + channels map[string]bool + waiters map[string]chan struct{} + tokens uint64 + // 已换成新的订阅对象,但还没有在上面订阅原来的频道。 + stale bool + + // 以下字段只由 Receive 访问。 + connected bool + pinged bool +} + +func NewBus(client *goredis.Client, instanceID, version string, metrics prometheus.Registerer, tracer trace.TracerProvider, logger *slog.Logger) *Bus { + dropped := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "alisten_bus_dropped_total", + Help: "被丢弃的实例间消息,reason 为 version(构建版本不一致)或 malformed(无法解码)。", + }, []string{"reason"}) + dropped.WithLabelValues("version") + dropped.WithLabelValues("malformed") + metrics.MustRegister(dropped) + return &Bus{ + client: client, + instance: instanceID, + version: version, + tracer: tracer.Tracer("github.com/dihemoe/alisten/internal/adapter/out/redis"), + logger: logger, + dropped: dropped, + done: make(chan struct{}), + ps: client.Subscribe(context.Background()), + channels: map[string]bool{}, + waiters: map[string]chan struct{}{}, + } +} + +func (b *Bus) SubscribeInstance(ctx context.Context) error { + return b.subscribe(ctx, instanceChannel(b.instance), membersChannel, preparedChannel) +} + +func (b *Bus) SubscribeRoom(ctx context.Context, roomID string) error { + return b.subscribe(ctx, roomChannel(roomID)) +} + +// Redis 按顺序处理一个连接上的命令:订阅之后在同一个连接上发一次 PING,收到它的回复时订阅已经生效。 +func (b *Bus) subscribe(ctx context.Context, channels ...string) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("订阅 %s:%w", strings.Join(channels, "、"), err) + } + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return errBusClosed + } + for _, ch := range channels { + b.channels[ch] = true + } + b.tokens++ + token := strconv.FormatUint(b.tokens, 10) + confirmed := make(chan struct{}) + b.waiters[token] = confirmed + ps := b.ps + b.mu.Unlock() + err := writeShared(ctx, func(w context.Context) error { return ps.Subscribe(w, channels...) }) + if err == nil { + err = writeShared(ctx, func(w context.Context) error { return ps.Ping(w, token) }) + } + if err == nil { + select { + case <-confirmed: + return nil + case <-b.done: + err = errBusClosed + case <-ctx.Done(): + err = ctx.Err() + } + } + b.mu.Lock() + delete(b.waiters, token) + b.mu.Unlock() + return fmt.Errorf("订阅 %s:%w", strings.Join(channels, "、"), err) +} + +func (b *Bus) UnsubscribeRoom(ctx context.Context, roomID string) error { + ch := roomChannel(roomID) + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return errBusClosed + } + delete(b.channels, ch) + ps := b.ps + b.mu.Unlock() + if err := writeShared(ctx, func(w context.Context) error { return ps.Unsubscribe(w, ch) }); err != nil { + return fmt.Errorf("退订 %s:%w", ch, err) + } + return nil +} + +func writeShared(ctx context.Context, write func(context.Context) error) error { + if err := ctx.Err(); err != nil { + return err + } + w, cancel := context.WithTimeout(context.WithoutCancel(ctx), sharedWriteTimeout) + defer cancel() + // 调用方结束时取消 w:go-redis 只按 ctx 的期限设置读写超时而不理会取消,所以已有连接上的写入不受影响;取消只打断建立连接, + // 以免调用方持着 go-redis 的锁等到连接超时,拖住排在后面的调用方。 + stop := context.AfterFunc(ctx, cancel) + defer stop() + return write(w) +} + +func (b *Bus) PublishRoom(ctx context.Context, roomID string, m port.RoomMessage) error { + kind, data, err := encodeRoomMessage(m) + if err != nil { + return fmt.Errorf("编码房间 %s 的消息:%w", roomID, err) + } + _, err = b.publish(ctx, roomChannel(roomID), kind, data, nil) + return err +} + +func (b *Bus) SendInput(ctx context.Context, instanceID string, in port.RoomInput) (bool, error) { + data, err := encodeInput(in) + if err != nil { + return false, fmt.Errorf("编码房间 %s 的输入:%w", in.Room, err) + } + carrier := propagation.MapCarrier{} + propagation.TraceContext{}.Inject(ctx, carrier) + receivers, err := b.publish(ctx, instanceChannel(instanceID), kindInput, data, carrier) + return receivers > 0, err +} + +func (b *Bus) PublishMember(ctx context.Context, m member.Member) error { + data, err := json.Marshal(m) + if err != nil { + return err + } + _, err = b.publish(ctx, membersChannel, kindMember, data, nil) + return err +} + +func (b *Bus) PublishPrepared(ctx context.Context, o port.PrepareOutcome) error { + data, err := json.Marshal(o) + if err != nil { + return err + } + _, err = b.publish(ctx, preparedChannel, kindPrepared, data, nil) + return err +} + +func (b *Bus) publish(ctx context.Context, channel, kind string, data json.RawMessage, carrier map[string]string) (int64, error) { + payload, err := json.Marshal(envelope{Version: b.version, From: b.instance, Kind: kind, Trace: carrier, Data: data}) + if err != nil { + return 0, err + } + receivers, err := b.client.Publish(ctx, channel, payload).Result() + if err != nil { + return 0, fmt.Errorf("发布到 %s:%w", channel, err) + } + return receivers, nil +} + +// 连接出错时 go-redis 会自行重新连接并重新订阅全部频道,但断开期间的消息已经丢失;连接只是停止响应时 go-redis 无法察觉, +// 由这里换一个新连接。两种情况都在恢复订阅之前先报告 SubscriptionLost,让调用方尽早释放房间、断开会话。 +func (b *Bus) Receive(ctx context.Context) (port.Delivery, error) { + stop := context.AfterFunc(ctx, b.close) + defer stop() + for { + b.mu.Lock() + ps, closed := b.ps, b.closed + var channels []string + if b.stale { + channels, b.stale = slices.Collect(maps.Keys(b.channels)), false + } + b.mu.Unlock() + if closed { + return nil, errors.Join(errBusClosed, ctx.Err()) + } + if len(channels) > 0 { + // 即使连不上,go-redis 也会记下这些频道,在连接建立时一并订阅。 + _ = ps.Subscribe(ctx, channels...) + } + msg, err := ps.ReceiveTimeout(ctx, pingInterval) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if timeout(err) && !b.pinged { + b.pinged = true + _ = ps.Ping(ctx) + continue + } + if timeout(err) { + b.replace(ps) + } + lost := b.connected + b.connected, b.pinged = false, false + if lost { + return port.SubscriptionLost{}, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(reconnectDelay): + } + continue + } + b.pinged = false + if !b.connected { + b.connected = true + b.repingWaiters(ctx, ps) + } + switch msg := msg.(type) { + case *goredis.Message: + if d, ok := b.decode(ctx, msg); ok { + return d, nil + } + case *goredis.Pong: + b.confirm(msg.Payload) + } + } +} + +func timeout(err error) bool { + var ne net.Error + return errors.As(err, &ne) && ne.Timeout() +} + +// 这里不做 I/O:先报告 SubscriptionLost,下一次接收时再连接并订阅原来的频道,停止响应的服务端不会拖延报告。 +func (b *Bus) replace(old *goredis.PubSub) { + b.mu.Lock() + if b.closed || b.ps != old { + b.mu.Unlock() + return + } + b.ps = b.client.Subscribe(context.Background()) + b.stale = true + b.mu.Unlock() + // Close 要等 old 内部的锁,而持有这把锁的写入可能正在等网络。 + go func() { _ = old.Close() }() +} + +// 新连接建立时已经重新订阅了全部频道,之前发出的 PING 都随旧连接丢失了。 +func (b *Bus) repingWaiters(ctx context.Context, ps *goredis.PubSub) { + b.mu.Lock() + tokens := slices.Collect(maps.Keys(b.waiters)) + b.mu.Unlock() + for _, token := range tokens { + _ = ps.Ping(ctx, token) + } +} + +func (b *Bus) confirm(token string) { + b.mu.Lock() + defer b.mu.Unlock() + if confirmed, ok := b.waiters[token]; ok { + close(confirmed) + delete(b.waiters, token) + } +} + +func (b *Bus) close() { + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return + } + b.closed = true + close(b.done) + ps := b.ps + b.mu.Unlock() + _ = ps.Close() +} + +func (b *Bus) decode(ctx context.Context, msg *goredis.Message) (port.Delivery, bool) { + var e envelope + if err := json.Unmarshal([]byte(msg.Payload), &e); err != nil { + b.malformed(ctx, msg.Channel, err) + return nil, false + } + if e.Version != b.version { + b.dropped.WithLabelValues("version").Inc() + b.logger.ErrorContext(ctx, "丢弃构建版本不同的实例间消息", "channel", msg.Channel, "from", e.From, "remote_version", e.Version) + return nil, false + } + d, err := b.delivery(msg.Channel, e) + if err != nil { + b.malformed(ctx, msg.Channel, err) + return nil, false + } + return d, true +} + +func (b *Bus) delivery(channel string, e envelope) (port.Delivery, error) { + switch { + case channel == instanceChannel(b.instance) && e.Kind == kindInput: + in, err := decodeInput(e.Data) + if err != nil { + return nil, err + } + return port.InputDelivery{Context: b.received(e.Trace, in.Room), Input: in}, nil + case channel == membersChannel && e.Kind == kindMember: + var m member.Member + err := json.Unmarshal(e.Data, &m) + return port.MemberDelivery{Member: m}, err + case channel == preparedChannel && e.Kind == kindPrepared: + var o port.PrepareOutcome + err := json.Unmarshal(e.Data, &o) + return port.PreparedDelivery{Outcome: o}, err + case strings.HasPrefix(channel, roomPrefix): + m, err := decodeRoomMessage(e.Kind, e.Data) + return port.RoomDelivery{Room: strings.TrimPrefix(channel, roomPrefix), Message: m}, err + } + return nil, fmt.Errorf("频道 %s 上不应有 %s 消息", channel, e.Kind) +} + +// 带有追踪上下文的输入延续网关的 trace:记录一个 consumer span,owner 处理这条输入时的调用都在它之下。 +// 这个 span 只标记收到输入的时刻,处理的耗时由它之下的调用体现。 +func (b *Bus) received(carrier map[string]string, roomID string) context.Context { + ctx := propagation.TraceContext{}.Extract(context.Background(), propagation.MapCarrier(carrier)) + if !trace.SpanContextFromContext(ctx).IsValid() { + return ctx + } + ctx, span := b.tracer.Start(ctx, "receive room input", trace.WithSpanKind(trace.SpanKindConsumer), trace.WithAttributes( + semconv.MessagingSystemKey.String("redis"), + semconv.MessagingOperationTypeReceive, + attribute.String("alisten.room_id", roomID), + )) + span.End() + return ctx +} + +func (b *Bus) malformed(ctx context.Context, channel string, err error) { + b.dropped.WithLabelValues("malformed").Inc() + b.logger.ErrorContext(ctx, "丢弃无法解码的实例间消息", "channel", channel, "error", err) +} diff --git a/internal/adapter/out/redis/clock.go b/internal/adapter/out/redis/clock.go new file mode 100644 index 0000000..7ed1ede --- /dev/null +++ b/internal/adapter/out/redis/clock.go @@ -0,0 +1,19 @@ +package redis + +import ( + "context" + "fmt" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +// 单程时延按往返时间的一半估算。 +func ClockOffset(ctx context.Context, client *goredis.Client) (time.Duration, error) { + start := time.Now() + server, err := client.Time(ctx).Result() + if err != nil { + return 0, fmt.Errorf("读取 Redis 服务器的时间:%w", err) + } + return server.Sub(start.Add(time.Since(start) / 2)), nil +} diff --git a/internal/adapter/out/redis/codec.go b/internal/adapter/out/redis/codec.go new file mode 100644 index 0000000..0757fef --- /dev/null +++ b/internal/adapter/out/redis/codec.go @@ -0,0 +1,218 @@ +package redis + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/room" +) + +// 只有同一构建版本的实例互相通信,格式不需要跨版本兼容。 +type envelope struct { + Version string + From string + Kind string + Trace map[string]string `json:",omitempty"` + Data json.RawMessage +} + +type ( + wireEmit struct { + Term uint32 + Seq uint64 + Event string + Data json.RawMessage + } + + wireReply struct { + RequestID room.RequestID + Result string + Data json.RawMessage + Err *room.Error + } + + wireInput struct { + Room string + Gateway string + Takeover bool + Input string + Data json.RawMessage + Patch *port.InfoPatch + } + + wireCommand struct { + RequestID room.RequestID + Member member.Member + Action string + Data json.RawMessage + } +) + +const ( + kindEmitted = "Emitted" + kindReplied = "Replied" + kindInput = "RoomInput" + kindMember = "Member" + kindPrepared = "Prepared" + // room.Command 的 Action 是接口,不能像 inputs 中的类型那样直接编码。 + inputCommand = "Command" +) + +// 接口实现必须登记后才能编码;实现字段中不能再有接口类型。 +type union[T any] map[string]reflect.Type + +func newUnion[T any](variants ...T) union[T] { + u := union[T]{} + for _, v := range variants { + t := reflect.TypeOf(v) + u[t.Name()] = t + } + return u +} + +func (u union[T]) encode(v T) (string, json.RawMessage, error) { + t := reflect.TypeOf(v) + if t == nil || u[t.Name()] != t { + return "", nil, fmt.Errorf("不能编码为实例间消息的类型 %T", v) + } + data, err := json.Marshal(v) + return t.Name(), data, err +} + +func (u union[T]) decode(name string, data json.RawMessage) (T, error) { + var zero T + t, ok := u[name] + if !ok { + return zero, fmt.Errorf("未知的类型 %q", name) + } + p := reflect.New(t) + if err := json.Unmarshal(data, p.Interface()); err != nil { + return zero, err + } + return p.Elem().Interface().(T), nil +} + +var ( + roomMessages = newUnion[port.RoomMessage](port.OwnerChanged{}, port.Rejected{}, port.RoomClosed{}, port.OwnerReleased{}, port.TicketsRevoked{}, port.APIKeyReset{}) + events = newUnion[room.Event](room.PlaybackChanged{}, room.QueueChanged{}, room.MembersChanged{}, room.ModeChanged{}, room.InfoChanged{}, room.ChatPosted{}, room.Activity{}) + results = newUnion[room.Result](room.Done{}, room.EnqueueResult{}, room.LikeResult{}, room.VoteResult{}, room.SyncResult{}) + inputs = newUnion[room.Input](room.Join{}, room.Leave{}, room.MemberUpdated{}, room.Delete{}) + actions = newUnion[room.Action](room.Enqueue{}, room.Remove{}, room.Like{}, room.VoteSkip{}, room.SetPlayMode{}, room.Chat{}, room.Sync{}) +) + +func encodeRoomMessage(m port.RoomMessage) (string, json.RawMessage, error) { + switch m := m.(type) { + case port.Emitted: + w := wireEmit{Term: m.Emit.Term, Seq: m.Emit.Seq} + var err error + if w.Event, w.Data, err = events.encode(m.Emit.Event); err != nil { + return "", nil, err + } + data, err := json.Marshal(w) + return kindEmitted, data, err + case port.Replied: + w := wireReply{RequestID: m.Reply.RequestID, Err: m.Reply.Err} + if m.Reply.Err == nil { + var err error + if w.Result, w.Data, err = results.encode(m.Reply.Result); err != nil { + return "", nil, err + } + } + data, err := json.Marshal(w) + return kindReplied, data, err + } + return roomMessages.encode(m) +} + +func decodeRoomMessage(kind string, data json.RawMessage) (port.RoomMessage, error) { + switch kind { + case kindEmitted: + var w wireEmit + if err := json.Unmarshal(data, &w); err != nil { + return nil, err + } + e, err := events.decode(w.Event, w.Data) + if err != nil { + return nil, err + } + return port.Emitted{Emit: room.Emit{Term: w.Term, Seq: w.Seq, Event: e}}, nil + case kindReplied: + var w wireReply + if err := json.Unmarshal(data, &w); err != nil { + return nil, err + } + reply := room.Reply{RequestID: w.RequestID, Err: w.Err} + if w.Err == nil { + res, err := results.decode(w.Result, w.Data) + if err != nil { + return nil, err + } + reply.Result = res + } + return port.Replied{Reply: reply}, nil + } + return roomMessages.decode(kind, data) +} + +var errEmptyInput = errors.New("房间输入没有内容") + +func encodeInput(in port.RoomInput) (json.RawMessage, error) { + w := wireInput{Room: in.Room, Gateway: in.Gateway, Takeover: in.Takeover, Patch: in.Patch} + switch i := in.Input.(type) { + case nil: + if in.Patch == nil { + return nil, errEmptyInput + } + case room.Command: + c := wireCommand{RequestID: i.RequestID, Member: i.Member} + var err error + if c.Action, c.Data, err = actions.encode(i.Action); err != nil { + return nil, err + } + w.Input = inputCommand + if w.Data, err = json.Marshal(c); err != nil { + return nil, err + } + default: + var err error + if w.Input, w.Data, err = inputs.encode(i); err != nil { + return nil, err + } + } + return json.Marshal(w) +} + +func decodeInput(data json.RawMessage) (port.RoomInput, error) { + var w wireInput + if err := json.Unmarshal(data, &w); err != nil { + return port.RoomInput{}, err + } + in := port.RoomInput{Room: w.Room, Gateway: w.Gateway, Takeover: w.Takeover, Patch: w.Patch} + switch w.Input { + case "": + if w.Patch == nil { + return port.RoomInput{}, errEmptyInput + } + case inputCommand: + var c wireCommand + if err := json.Unmarshal(w.Data, &c); err != nil { + return port.RoomInput{}, err + } + a, err := actions.decode(c.Action, c.Data) + if err != nil { + return port.RoomInput{}, err + } + in.Input = room.Command{RequestID: c.RequestID, Member: c.Member, Action: a} + default: + i, err := inputs.decode(w.Input, w.Data) + if err != nil { + return port.RoomInput{}, err + } + in.Input = i + } + return in, nil +} diff --git a/internal/adapter/out/redis/lease.go b/internal/adapter/out/redis/lease.go new file mode 100644 index 0000000..1779a03 --- /dev/null +++ b/internal/adapter/out/redis/lease.go @@ -0,0 +1,135 @@ +package redis + +import ( + "context" + "fmt" + "strconv" + "time" + + goredis "github.com/redis/go-redis/v9" + + "github.com/dihemoe/alisten/internal/app/port" +) + +var ( + _ port.Leases = (*Leases)(nil) + _ port.OnlineCounts = (*Leases)(nil) +) + +type Leases struct { + client *goredis.Client + instance string +} + +func NewLeases(client *goredis.Client, instanceID string) *Leases { + return &Leases{client: client, instance: instanceID} +} + +func ownerKey(roomID string) string { return "room:" + roomID + ":owner" } +func onlineKey(roomID string) string { return "room:" + roomID + ":online" } + +// 比较租约的值和写入在同一个脚本中完成,其间不会插入其他实例的命令。 +var ( + acquireScript = goredis.NewScript(` +local owner = redis.call('GET', KEYS[1]) +if owner and owner ~= ARGV[1] then + return 0 +end +redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) +redis.call('SET', KEYS[2], 0, 'PX', ARGV[2]) +return 1`) + + renewScript = goredis.NewScript(` +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return 0 +end +redis.call('PEXPIRE', KEYS[1], ARGV[2]) +redis.call('PEXPIRE', KEYS[2], ARGV[2]) +return 1`) + + setOnlineScript = goredis.NewScript(` +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return 0 +end +redis.call('SET', KEYS[2], ARGV[2], 'PX', redis.call('PTTL', KEYS[1])) +return 1`) + + releaseScript = goredis.NewScript(` +if redis.call('GET', KEYS[1]) == ARGV[1] then + redis.call('DEL', KEYS[1], KEYS[2]) +end +return 0`) +) + +func (l *Leases) Acquire(ctx context.Context, roomID string, ttl time.Duration) (bool, error) { + n, err := acquireScript.Run(ctx, l.client, []string{ownerKey(roomID), onlineKey(roomID)}, l.instance, ttl.Milliseconds()).Int() + if err != nil { + return false, fmt.Errorf("取得房间 %s 的租约:%w", roomID, err) + } + return n == 1, nil +} + +func (l *Leases) Renew(ctx context.Context, roomID string, ttl time.Duration) (bool, error) { + n, err := renewScript.Run(ctx, l.client, []string{ownerKey(roomID), onlineKey(roomID)}, l.instance, ttl.Milliseconds()).Int() + if err != nil { + return false, fmt.Errorf("续期房间 %s 的租约:%w", roomID, err) + } + return n == 1, nil +} + +func (l *Leases) SetOnline(ctx context.Context, roomID string, online int) (bool, error) { + n, err := setOnlineScript.Run(ctx, l.client, []string{ownerKey(roomID), onlineKey(roomID)}, l.instance, online).Int() + if err != nil { + return false, fmt.Errorf("写入房间 %s 的在线人数:%w", roomID, err) + } + return n == 1, nil +} + +func (l *Leases) Release(ctx context.Context, roomID string) error { + if err := releaseScript.Run(ctx, l.client, []string{ownerKey(roomID), onlineKey(roomID)}, l.instance).Err(); err != nil { + return fmt.Errorf("释放房间 %s 的租约:%w", roomID, err) + } + return nil +} + +func (l *Leases) Owners(ctx context.Context, roomIDs []string) ([]string, error) { + values, err := mget(ctx, l.client, roomIDs, ownerKey) + if err != nil { + return nil, fmt.Errorf("读取房间的 owner:%w", err) + } + owners := make([]string, len(values)) + for i, v := range values { + owners[i], _ = v.(string) + } + return owners, nil +} + +func (l *Leases) OnlineCounts(ctx context.Context, roomIDs []string) ([]int, error) { + values, err := mget(ctx, l.client, roomIDs, onlineKey) + if err != nil { + return nil, fmt.Errorf("读取房间的在线人数:%w", err) + } + counts := make([]int, len(values)) + for i, v := range values { + s, ok := v.(string) + if !ok { + continue + } + if counts[i], err = strconv.Atoi(s); err != nil { + return nil, fmt.Errorf("房间 %s 的在线人数 %q 不是整数", roomIDs[i], s) + } + } + return counts, nil +} + +// MGET 对不存在的键返回 nil,并且不接受零个键。 +func mget(ctx context.Context, client *goredis.Client, ids []string, key func(string) string) ([]any, error) { + if len(ids) == 0 { + return nil, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = key(id) + } + return client.MGet(ctx, keys...).Result() +} diff --git a/internal/adapter/out/redis/liveness.go b/internal/adapter/out/redis/liveness.go new file mode 100644 index 0000000..fcda4ce --- /dev/null +++ b/internal/adapter/out/redis/liveness.go @@ -0,0 +1,50 @@ +package redis + +import ( + "context" + "errors" + "fmt" + "time" + + goredis "github.com/redis/go-redis/v9" + + "github.com/dihemoe/alisten/internal/app/port" +) + +var _ port.Liveness = (*Liveness)(nil) + +type Liveness struct { + client *goredis.Client + instance string +} + +func NewLiveness(client *goredis.Client, instanceID string) *Liveness { + return &Liveness{client: client, instance: instanceID} +} + +func aliveKey(instanceID string) string { return "inst:" + instanceID + ":alive" } + +func (l *Liveness) Heartbeat(ctx context.Context, ttl time.Duration) (bool, error) { + err := l.client.SetArgs(ctx, aliveKey(l.instance), 1, goredis.SetArgs{TTL: ttl, Get: true}).Err() + if errors.Is(err, goredis.Nil) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("刷新实例的存活键:%w", err) + } + return true, nil +} + +func (l *Liveness) Expired(ctx context.Context, instanceIDs []string) ([]string, error) { + values, err := mget(ctx, l.client, instanceIDs, aliveKey) + if err != nil { + return nil, fmt.Errorf("读取实例的存活键:%w", err) + } + var expired []string + for i, v := range values { + if v == nil { + expired = append(expired, instanceIDs[i]) + } + } + return expired, nil +} diff --git a/internal/adapter/out/redis/quota.go b/internal/adapter/out/redis/quota.go new file mode 100644 index 0000000..e3d3707 --- /dev/null +++ b/internal/adapter/out/redis/quota.go @@ -0,0 +1,34 @@ +// Package redis 只存放可以丢失的协调数据:Redis 被清空后系统能自行恢复。 +package redis + +import ( + "context" + "fmt" + "time" + + "github.com/go-redis/redis_rate/v10" + goredis "github.com/redis/go-redis/v9" + + "github.com/dihemoe/alisten/internal/app/port" +) + +var _ port.SearchQuota = (*SearchQuota)(nil) + +type SearchQuota struct { + limiter *redis_rate.Limiter +} + +func NewSearchQuota(client *goredis.Client) *SearchQuota { + return &SearchQuota{limiter: redis_rate.NewLimiter(client)} +} + +func (q *SearchQuota) TakeSearch(ctx context.Context, roomID string, burst int, refill time.Duration) (time.Duration, error) { + res, err := q.limiter.Allow(ctx, "room:"+roomID+":search", redis_rate.Limit{Rate: 1, Burst: burst, Period: refill}) + if err != nil { + return 0, fmt.Errorf("取房间 %s 的搜索额度:%w", roomID, err) + } + if res.Allowed > 0 { + return 0, nil + } + return res.RetryAfter, nil +} diff --git a/internal/adapter/out/storage/storage.go b/internal/adapter/out/storage/storage.go new file mode 100644 index 0000000..942d4a9 --- /dev/null +++ b/internal/adapter/out/storage/storage.go @@ -0,0 +1,152 @@ +package storage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + + "github.com/dihemoe/alisten/internal/app/port" +) + +const ( + // AWS 的默认区域,在这个区域创建 bucket 时不能指定 LocationConstraint。 + defaultRegion = "us-east-1" + rollbackTimeout = 10 * time.Second +) + +type Config struct { + Endpoint string + Region string + Bucket string + AccessKeyID string + SecretAccessKey string +} + +type Storage struct { + client *s3.Client + bucket string + region string +} + +var _ port.MediaStore = (*Storage)(nil) + +func New(cfg Config) *Storage { + client := s3.New(s3.Options{ + BaseEndpoint: aws.String(cfg.Endpoint), + Region: cfg.Region, + // 为兼容不支持虚拟主机寻址的 S3 服务,统一使用路径寻址。 + UsePathStyle: true, + Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + // 仅在操作要求时计算或验证校验和:部分 S3 兼容服务不支持 SDK 默认添加的灵活校验和。 + RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, + ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, + }) + return &Storage{client: client, bucket: cfg.Bucket, region: cfg.Region} +} + +// 只为新建的 bucket 设置访问策略:匿名请求只能读取 link/ 下的对象,不能列出对象。 +// 设置访问策略失败时删除刚创建的 bucket,否则下一次检查会把这个没有策略的 bucket 当作已经配置好的。 +func (s *Storage) EnsureBucket(ctx context.Context) (created bool, err error) { + _, err = s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)}) + if err == nil { + return false, nil + } + if !notFound(err) { + return false, fmt.Errorf("检查 bucket %s:%w", s.bucket, err) + } + input := &s3.CreateBucketInput{Bucket: aws.String(s.bucket)} + if s.region != defaultRegion { + input.CreateBucketConfiguration = &types.CreateBucketConfiguration{LocationConstraint: types.BucketLocationConstraint(s.region)} + } + if _, err := s.client.CreateBucket(ctx, input); err != nil { + return false, fmt.Errorf("创建 bucket %s:%w", s.bucket, err) + } + _, err = s.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{Bucket: aws.String(s.bucket), Policy: aws.String(s.policy())}) + if err == nil { + return true, nil + } + err = fmt.Errorf("设置 bucket %s 的访问策略:%w", s.bucket, err) + // 迁移被取消或超时往往正是设置访问策略失败的原因,回滚不能随 ctx 一起失败。 + rollback, cancel := context.WithTimeout(context.WithoutCancel(ctx), rollbackTimeout) + defer cancel() + if _, deleteErr := s.client.DeleteBucket(rollback, &s3.DeleteBucketInput{Bucket: aws.String(s.bucket)}); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("删除没有访问策略的 bucket %s:%w", s.bucket, deleteErr)) + } + return false, err +} + +type ( + policy struct { + Version string + Statement []statement + } + statement struct { + Effect string + Principal principal + Action []string + Resource []string + } + principal struct { + AWS []string + } +) + +func (s *Storage) policy() string { + b, _ := json.Marshal(policy{ + Version: "2012-10-17", + Statement: []statement{{ + Effect: "Allow", + Principal: principal{AWS: []string{"*"}}, + Action: []string{"s3:GetObject"}, + Resource: []string{"arn:aws:s3:::" + s.bucket + "/link/*"}, + }}, + }) + return string(b) +} + +func (s *Storage) Put(ctx context.Context, key string, body io.ReadSeeker, contentType string) error { + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: body, + ContentType: aws.String(contentType), + }) + if err != nil { + return fmt.Errorf("上传 %s:%w", key, err) + } + return nil +} + +func (s *Storage) Exists(ctx context.Context, key string) (bool, error) { + _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)}) + switch { + case err == nil: + return true, nil + case notFound(err): + return false, nil + } + return false, fmt.Errorf("检查 %s 是否存在:%w", key, err) +} + +func (s *Storage) Delete(ctx context.Context, key string) error { + if _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)}); err != nil { + return fmt.Errorf("删除 %s:%w", key, err) + } + return nil +} + +// HEAD 请求的 404 响应没有响应体,SDK 只能从状态码判断。 +func notFound(err error) bool { + var resp *awshttp.ResponseError + return errors.As(err, &resp) && resp.HTTPStatusCode() == http.StatusNotFound +} diff --git a/internal/adapter/out/ytdlp/guard.go b/internal/adapter/out/ytdlp/guard.go new file mode 100644 index 0000000..f0baef3 --- /dev/null +++ b/internal/adapter/out/ytdlp/guard.go @@ -0,0 +1,91 @@ +package ytdlp + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "time" +) + +const upstreamTimeout = 30 * time.Second + +// 除回环、私有(包括 IPv6 唯一本地地址)、链路本地(包括云元数据服务的 169.254.169.254)、组播和未指定地址之外, +// 代理还拒绝连接这些地址段。 +var blocked = []netip.Prefix{ + // 「本网络」地址段,不会出现在公网上;IsGlobalUnicast 只排除其中的 0.0.0.0。 + netip.MustParsePrefix("0.0.0.0/8"), + // 运营商级 NAT 使用的共享地址段。 + netip.MustParsePrefix("100.64.0.0/10"), + // IETF 协议分配、基准测试和保留地址段:不会出现在公网上,部分内网和云的网络把它们用作内部地址。 + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("240.0.0.0/4"), + // 文档专用地址段:只用于示例,不会出现在公网上,部分内网把它们用作内部地址。 + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("3fff::/20"), + // 内嵌 IPv4 地址的 IPv6 地址段:NAT64、SIIT 网关和隧道会把它们转换成任意 IPv4 地址,包括私有地址。 + netip.MustParsePrefix("64:ff9b::/96"), + netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("::/96"), + netip.MustParsePrefix("::ffff:0:0:0/96"), + netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("2001::/32"), + // 已废弃的站点本地地址,部分网络仍把它当作内网。 + netip.MustParsePrefix("fec0::/10"), +} + +func public(a netip.Addr) bool { + a = a.Unmap() + if !a.IsGlobalUnicast() || a.IsPrivate() { + return false + } + for _, p := range blocked { + if p.Contains(a) { + return false + } + } + return true +} + +type refusedError struct { + host string +} + +func (e *refusedError) Error() string { return fmt.Sprintf("%s 不是公网地址", e.host) } + +// dialPublic 自己解析目标的域名,只连接解析出的公网地址:连接时不再解析域名,DNS 重绑定就无法绕过检查。 +func dialPublic(resolver *net.Resolver) func(ctx context.Context, network, address string) (net.Conn, error) { + return func(ctx context.Context, _, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + var addrs []netip.Addr + if a, err := netip.ParseAddr(host); err == nil { + addrs = []netip.Addr{a} + } else if addrs, err = resolver.LookupNetIP(ctx, "ip", host); err != nil { + return nil, err + } + dialer := net.Dialer{Timeout: upstreamTimeout} + var errs []error + for _, a := range addrs { + if !public(a) { + continue + } + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(a.Unmap().String(), port)) + if err == nil { + return conn, nil + } + errs = append(errs, err) + } + if len(errs) == 0 { + return nil, &refusedError{host: host} + } + return nil, errors.Join(errs...) + } +} diff --git a/internal/adapter/out/ytdlp/proxy.go b/internal/adapter/out/ytdlp/proxy.go new file mode 100644 index 0000000..c275fdf --- /dev/null +++ b/internal/adapter/out/ytdlp/proxy.go @@ -0,0 +1,147 @@ +package ytdlp + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httputil" + "sync" +) + +// https 的请求经 CONNECT 建立隧道,http 的请求直接转发,两者的目标都只由 dial 连接。 +type proxy struct { + dial func(ctx context.Context, network, address string) (net.Conn, error) + server *http.Server + transport *http.Transport + forward *httputil.ReverseProxy + url string + + mu sync.Mutex + closed bool + refused bool + // 隧道两端的连接在 Hijack 之后不再由 server 管理,代理关闭时要由这里关闭。 + tunnels map[*tunnel]struct{} + running sync.WaitGroup +} + +type tunnel struct { + close func() +} + +func startProxy(dial func(ctx context.Context, network, address string) (net.Conn, error)) (*proxy, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + p := &proxy{dial: dial, url: "http://" + listener.Addr().String(), tunnels: map[*tunnel]struct{}{}} + p.transport = &http.Transport{DialContext: p.connect, ResponseHeaderTimeout: upstreamTimeout} + p.forward = &httputil.ReverseProxy{ + // 代理请求的地址就是要转发的绝对地址,原样转发,不加 X-Forwarded-For。 + Rewrite: func(*httputil.ProxyRequest) {}, + Transport: p.transport, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { p.fail(w, err) }, + } + p.server = &http.Server{Handler: p, ReadHeaderTimeout: upstreamTimeout} + go func() { _ = p.server.Serve(listener) }() + return p, nil +} + +func (p *proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodConnect: + p.tunnel(w, r) + case r.URL.IsAbs(): + p.forward.ServeHTTP(w, r) + default: + http.Error(w, "只接受代理请求", http.StatusBadRequest) + } +} + +func (p *proxy) connect(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := p.dial(ctx, network, address) + var refused *refusedError + if errors.As(err, &refused) { + p.mu.Lock() + p.refused = true + p.mu.Unlock() + } + return conn, err +} + +func (p *proxy) fail(w http.ResponseWriter, err error) { + var refused *refusedError + if errors.As(err, &refused) { + http.Error(w, refused.Error(), http.StatusForbidden) + return + } + http.Error(w, "连接目标失败", http.StatusBadGateway) +} + +func (p *proxy) tunnel(w http.ResponseWriter, r *http.Request) { + upstream, err := p.connect(r.Context(), "tcp", r.Host) + if err != nil { + p.fail(w, err) + return + } + client, buffered, err := http.NewResponseController(w).Hijack() + if err != nil { + _ = upstream.Close() + return + } + t := &tunnel{close: sync.OnceFunc(func() { + _ = client.Close() + _ = upstream.Close() + })} + if !p.hold(t) { + t.close() + return + } + defer p.release(t) + if _, err := io.WriteString(client, "HTTP/1.1 200 Connection established\r\n\r\n"); err != nil { + return + } + go func() { + _, _ = io.Copy(upstream, buffered) + t.close() + }() + _, _ = io.Copy(client, upstream) +} + +func (p *proxy) hold(t *tunnel) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return false + } + p.tunnels[t] = struct{}{} + p.running.Add(1) + return true +} + +func (p *proxy) release(t *tunnel) { + t.close() + p.mu.Lock() + delete(p.tunnels, t) + p.mu.Unlock() + p.running.Done() +} + +func (p *proxy) wasRefused() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.refused +} + +func (p *proxy) Close() { + _ = p.server.Close() + p.mu.Lock() + p.closed = true + for t := range p.tunnels { + t.close() + } + p.mu.Unlock() + p.running.Wait() + p.transport.CloseIdleConnections() +} diff --git a/internal/adapter/out/ytdlp/ytdlp.go b/internal/adapter/out/ytdlp/ytdlp.go new file mode 100644 index 0000000..079d54c --- /dev/null +++ b/internal/adapter/out/ytdlp/ytdlp.go @@ -0,0 +1,277 @@ +// worker 会请求用户提交的任意地址,因此 yt-dlp 的网络请求必须经过只连接公网地址的进程内代理; +// ffmpeg 无论作为下载器还是后处理器都只能读取本地文件。 +package ytdlp + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "syscall" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +const ( + // yt-dlp 从 PATH 中查找,它再从 PATH 中查找 ffmpeg、ffprobe 和 Deno。 + executable = "yt-dlp" + waitDelay = 5 * time.Second + outputLimit = 64 << 10 + reasonLimit = 300 +) + +// 下载失败的原因,用作指标的标签。 +const ( + failureUnsupported = "unsupported" + failureBlocked = "blocked" + failureTimeout = "timeout" + failureError = "error" +) + +type Downloader struct { + root string + resolver *net.Resolver + cookies port.LinkCookies + observe func(elapsed time.Duration, failure string) + logger *slog.Logger +} + +var _ port.LinkDownloader = (*Downloader)(nil) + +// observe 记下每次运行 yt-dlp 的耗时和失败的原因,成功时 failure 为空;任务被取消(worker 退出)的运行不记。 +// cookies 必须非 nil:没有 cookies 来源的调用方传恒返回 ok=false 的实现。 +func New(root string, resolver *net.Resolver, cookies port.LinkCookies, observe func(elapsed time.Duration, failure string), logger *slog.Logger) *Downloader { + return &Downloader{root: root, resolver: resolver, cookies: cookies, observe: observe, logger: logger} +} + +func (d *Downloader) Download(ctx context.Context, link string) (port.LinkDownload, func(), error) { + dir, err := os.MkdirTemp(d.root, "job-") + if err != nil { + return port.LinkDownload{}, nil, fmt.Errorf("创建临时目录:%w", err) + } + release := func() { _ = os.RemoveAll(dir) } + // 用 defer 是为了 panic 时也删除目录:River 会恢复任务中的 panic,worker 继续运行。 + done := false + defer func() { + if !done { + release() + } + }() + result, err := d.download(ctx, link, dir) + if err != nil { + return port.LinkDownload{}, nil, err + } + done = true + return result, release, nil +} + +func (d *Downloader) download(ctx context.Context, link, dir string) (port.LinkDownload, error) { + cookies, releaseCookies, ok, err := d.cookies.CookieFile(ctx, link) + if err != nil { + return port.LinkDownload{}, err + } + if ok { + // cookies 文件只在 yt-dlp 运行期间存在,下载结束(包括失败和取消)就删除。 + defer releaseCookies() + } + p, err := startProxy(dialPublic(d.resolver)) + if err != nil { + return port.LinkDownload{}, fmt.Errorf("启动 yt-dlp 的代理:%w", err) + } + defer p.Close() + start := time.Now() + stdout, stderr, err := run(ctx, dir, args(link, dir, p.url, cookies)) + var result port.LinkDownload + if err == nil { + result, err = collect(dir, stdout) + } + if errors.Is(ctx.Err(), context.Canceled) { + return port.LinkDownload{}, ctx.Err() + } + failure, err := classify(ctx, err, stderr, p.wasRefused()) + d.observe(time.Since(start), failure) + switch failure { + case "": + return result, nil + case failureTimeout, failureError: + d.logger.WarnContext(ctx, "yt-dlp 下载失败", "track_id", link, "failure", failure, "stderr", string(stderr), "error", err) + } + return port.LinkDownload{}, err +} + +func args(link, dir, proxy, cookies string) []string { + a := []string{ + // 配置文件可能带入 cookies、--exec 之类的选项。 + "--ignore-config", + "--proxy", proxy, + "--downloader", "native", + // ffmpeg 的请求不经过代理,所以只允许它读本地文件。原生下载器处理不了时(比如加密的 HLS、直播),yt-dlp 会改用 ffmpeg 下载; + // 后处理器也用 ffmpeg 处理下载到的文件,而文件可能引用网络上的地址。给后处理器的参数要写成「后处理器名+ffmpeg_i」, + // 不带后处理器名的 ffmpeg_i 只对 ffmpeg 下载器生效。这里列出的后处理器会处理音频文件:转 mp3,以及 HLS 和 DASH 的封装修复。 + "--downloader-args", "ffmpeg_i:-protocol_whitelist file", + "--postprocessor-args", "ExtractAudio+ffmpeg_i:-protocol_whitelist file", + "--postprocessor-args", "FixupM3u8+ffmpeg_i:-protocol_whitelist file", + "--postprocessor-args", "FixupM4a+ffmpeg_i:-protocol_whitelist file", + // 来源文件里的标签是用户提交的内容,转码时 ffmpeg 默认把它们复制成 mp3 的标签,所以不复制。 + // 来源已经是 mp3 时 yt-dlp 不转码,文件连同标签原样保留,存储的音频仍然不可信。 + "--postprocessor-args", "ExtractAudio+ffmpeg_o:-map_metadata -1", + // --no-playlist 只对同时指向视频和播放列表的链接有效;对播放列表本身,--playlist-items 1 只下载第一项。 + "--no-playlist", "--playlist-items", "1", + "--format", "bestaudio/best", + "--extract-audio", "--audio-format", "mp3", + "--write-thumbnail", + "--paths", dir, + "--output", "audio.%(ext)s", + "--output", "thumbnail:cover.%(ext)s", + "--no-progress", + "--print", "after_move:%(.{title,artist,uploader,album,duration})j", + // 链接以 - 开头时会被当作选项。 + "--", link, + } + if cookies != "" { + a = slices.Insert(a, 1, "--cookies", cookies) + } + return a +} + +func run(ctx context.Context, dir string, args []string) (stdout, stderr []byte, err error) { + cmd := exec.CommandContext(ctx, executable, args...) + cmd.Dir = dir + cmd.Env = environ() + // yt-dlp 会启动 ffmpeg,超时或取消时杀掉整个进程组。 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = waitDelay + out, errOut := &tail{limit: outputLimit}, &tail{limit: outputLimit} + cmd.Stdout, cmd.Stderr = out, errOut + err = cmd.Run() + return out.buf, redactCookies(errOut.lines()), err +} + +// yt-dlp 读 cookies 文件出错时,会在这些说明后面打印出错的行或值,日志和任务错误里都不能出现。 +var cookieEcho = regexp.MustCompile(`(cookie file entry|Netscape format cookies file|could not convert string to float)[^\S\n]*\S[^\n]*`) + +func redactCookies(stderr []byte) []byte { + return cookieEcho.ReplaceAll(stderr, []byte("${1} [REDACTED]")) +} + +// yt-dlp 模拟浏览器时用 curl 发请求,而 curl 即使指定了代理,仍会按环境变量 no_proxy 绕过代理,所以去掉环境中的代理设置。 +func environ() []string { + return slices.DeleteFunc(os.Environ(), func(kv string) bool { + name, _, _ := strings.Cut(kv, "=") + switch strings.ToLower(name) { + case "http_proxy", "https_proxy", "all_proxy", "no_proxy": + return true + } + return false + }) +} + +type tail struct { + limit int + buf []byte + cut bool +} + +func (t *tail) Write(p []byte) (int, error) { + t.buf = append(t.buf, p...) + if extra := len(t.buf) - t.limit; extra > 0 { + t.buf = t.buf[extra:] + t.cut = true + } + return len(p), nil +} + +// 截断后开头的半行认不出是不是 cookies 的回显,丢掉。 +func (t *tail) lines() []byte { + if !t.cut { + return t.buf + } + _, rest, _ := bytes.Cut(t.buf, []byte("\n")) + return rest +} + +type info struct { + Title string `json:"title"` + Artist string `json:"artist"` + Uploader string `json:"uploader"` + Album string `json:"album"` + Duration *float64 `json:"duration"` +} + +var errNoAudio = errors.New("yt-dlp 没有下载到音频") + +func collect(dir string, stdout []byte) (port.LinkDownload, error) { + audio := filepath.Join(dir, "audio.mp3") + if _, err := os.Stat(audio); err != nil { + return port.LinkDownload{}, errNoAudio + } + var i info + if err := json.Unmarshal(bytes.TrimSpace(stdout), &i); err != nil { + return port.LinkDownload{}, fmt.Errorf("解析 yt-dlp 输出的元数据:%w", err) + } + d := port.LinkDownload{ + Meta: track.Meta{Title: i.Title, Artist: cmp.Or(i.Artist, i.Uploader), Album: i.Album}, + Audio: file(audio), + } + if i.Duration != nil { + d.Meta.Duration = time.Duration(*i.Duration * float64(time.Second)) + } + if covers, _ := filepath.Glob(filepath.Join(dir, "cover.*")); len(covers) == 1 { + d.Cover, d.CoverExt = file(covers[0]), strings.ToLower(strings.TrimPrefix(filepath.Ext(covers[0]), ".")) + } + return d, nil +} + +type file string + +func (f file) Open() (io.ReadSeekCloser, error) { + r, err := os.Open(string(f)) + if err != nil { + return nil, err + } + return r, nil +} + +func classify(ctx context.Context, err error, stderr []byte, refused bool) (string, error) { + switch { + case err == nil: + return "", nil + case ctx.Err() != nil: + return failureTimeout, fmt.Errorf("yt-dlp 下载超时:%w", ctx.Err()) + case refused: + return failureBlocked, &port.RejectedError{Err: errors.New("链接指向的地址不是公网地址,不能下载")} + case errors.Is(err, errNoAudio) || bytes.Contains(stderr, []byte("ERROR: Unsupported URL: ")): + return failureUnsupported, &port.RejectedError{Err: errors.New("yt-dlp 不支持这个链接")} + } + if message := lastError(stderr); message != "" { + return failureError, fmt.Errorf("yt-dlp 下载失败:%s", message) + } + return failureError, fmt.Errorf("yt-dlp 下载失败:%w", err) +} + +func lastError(stderr []byte) string { + lines := strings.Split(strings.TrimSpace(string(stderr)), "\n") + for _, line := range slices.Backward(lines) { + if message, ok := strings.CutPrefix(line, "ERROR: "); ok { + if r := []rune(message); len(r) > reasonLimit { + message = string(r[:reasonLimit]) + "…" + } + return message + } + } + return "" +} diff --git a/internal/app/admin/admin.go b/internal/app/admin/admin.go new file mode 100644 index 0000000..2d0a6a9 --- /dev/null +++ b/internal/app/admin/admin.go @@ -0,0 +1,287 @@ +package admin + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "errors" + "fmt" + "slices" + + "github.com/dihemoe/alisten/internal/app/identity" + "github.com/dihemoe/alisten/internal/app/lobby" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/internal/domain/room" +) + +var ( + ErrDisabled = errors.New("后台没有启用:服务端没有配置管理 token。") + ErrInvalidToken = errors.New("管理 token 无效") +) + +// 除 Authorize 外的方法都不检查凭证,调用方要先用 Authorize 校验管理 token。 +type Service struct { + token string + lobby *lobby.Service + rooms port.RoomDirectory + keys port.APIKeys + credentials port.Credentials + // 扫码登录成功时由音源保存登录状态,后台只得到二维码和扫码结果。 + netease port.NetEaseQRLoginSource + qq port.QQQRLoginSource + bilibili port.BilibiliQRLoginSource + youtube port.YouTubeCookies + gateway *realtime.Gateway +} + +func New(token string, lobbyService *lobby.Service, rooms port.RoomDirectory, keys port.APIKeys, credentials port.Credentials, netease port.NetEaseQRLoginSource, qq port.QQQRLoginSource, bilibili port.BilibiliQRLoginSource, youtube port.YouTubeCookies, gateway *realtime.Gateway) *Service { + return &Service{token: token, lobby: lobbyService, rooms: rooms, keys: keys, credentials: credentials, netease: netease, qq: qq, bilibili: bilibili, youtube: youtube, gateway: gateway} +} + +func (s *Service) Authorize(token string) error { + if s.token == "" { + return ErrDisabled + } + // 比较哈希而不是原文:长度不同时 ConstantTimeCompare 立即返回,会泄露管理 token 的长度。 + want, got := sha256.Sum256([]byte(s.token)), sha256.Sum256([]byte(token)) + if subtle.ConstantTimeCompare(got[:], want[:]) != 1 { + return ErrInvalidToken + } + return nil +} + +func (s *Service) ListRooms(ctx context.Context) ([]lobby.Room, error) { + rooms, err := s.lobby.ListRooms(ctx) + if err != nil { + return nil, err + } + return slices.DeleteFunc(rooms, func(r lobby.Room) bool { return r.Info.Kind != room.Persistent }), nil +} + +type NewRoom struct { + Name string + Description string + // 空字符串表示没有别名。别名必须已经按格式校验:存储按格式区分 UUID 和别名,本身不做检查。 + Slug string + Password string +} + +func (s *Service) CreateRoom(ctx context.Context, r NewRoom) (string, error) { + hash, err := identity.HashPassword(ctx, r.Password) + if err != nil { + return "", err + } + return s.rooms.CreateRoom(ctx, port.NewRoom{ + Kind: room.Persistent, + Name: r.Name, + Description: r.Description, + Slug: r.Slug, + PasswordHash: hash, + }) +} + +// nil 表示不修改。Slug 或 Password 为空字符串表示取消别名或密码。 +type RoomChange struct { + Name *string + Description *string + // 别名必须已经按格式校验:存储按格式区分 UUID 和别名,本身不做检查。 + Slug *string + Password *string +} + +// id 必须是 UUID。名称和描述由房间修改,房间落盘之后才返回。设置或修改密码会作废房间成员的票据并断开会话和成员的事件流, +// 这一步无法撤销,所以放在最后:前面任何一步失败时票据仍然有效,重试也不会再次作废重新签发的票据。 +// 断开的通知发不出去时返回错误,此时票据已经作废。 +// 别名在名称和描述之前修改,别名已被占用时不做任何修改。取消密码不影响已有的票据。 +func (s *Service) UpdateRoom(ctx context.Context, id string, c RoomChange) error { + r, err := s.persistentRoom(ctx, id) + if err != nil { + return err + } + var hash *string + if c.Password != nil { + h, err := identity.HashPassword(ctx, *c.Password) + if err != nil { + return err + } + hash = &h + } + if c.Slug != nil { + if err := s.rooms.UpdateRoomAccess(ctx, r.Info.ID, port.AccessChange{Slug: c.Slug}); err != nil { + return err + } + } + if c.Name != nil || c.Description != nil { + if err := s.gateway.UpdateInfo(ctx, r.Info.ID, c.Name, c.Description); err != nil { + return err + } + } + if hash == nil { + return nil + } + if err := s.rooms.UpdateRoomAccess(ctx, r.Info.ID, port.AccessChange{PasswordHash: hash}); err != nil { + return err + } + if *hash == "" { + return nil + } + return s.gateway.Revoke(ctx, r.Info.ID) +} + +// id 必须是 UUID。 +func (s *Service) DeleteRoom(ctx context.Context, id string) error { + r, err := s.persistentRoom(ctx, id) + if err != nil { + return err + } + return s.gateway.Delete(ctx, r.Info.ID) +} + +// id 必须是 UUID。 +func (s *Service) RoomAPIKeyStatus(ctx context.Context, id string) (port.APIKeyStatus, error) { + r, err := s.persistentRoom(ctx, id) + if err != nil { + return port.APIKeyStatus{}, err + } + return s.keys.RoomAPIKeyStatus(ctx, r.Info.ID) +} + +// id 必须是 UUID。还没有 Key 时生成,已经有时旧 Key 立即失效,用旧 Key 打开的事件流随之断开。 +// 断开事件流的通知发不出去时返回错误,此时 Key 已经重置。 +func (s *Service) ResetRoomAPIKey(ctx context.Context, id string) (string, error) { + r, err := s.persistentRoom(ctx, id) + if err != nil { + return "", err + } + key, k := identity.NewAPIKey() + if err := s.keys.ResetRoomAPIKey(ctx, r.Info.ID, k); err != nil { + return "", err + } + if err := s.gateway.RevokeAPIKey(ctx, r.Info.ID); err != nil { + return "", err + } + return key, nil +} + +func (s *Service) persistentRoom(ctx context.Context, id string) (port.RoomRecord, error) { + r, err := s.rooms.FindRoom(ctx, id) + if err != nil { + return port.RoomRecord{}, err + } + if r.Info.Kind != room.Persistent { + return port.RoomRecord{}, port.ErrRoomNotFound + } + return r, nil +} + +func (s *Service) SetYouTubeCookies(ctx context.Context, cookies string) error { + normalized, err := normalizeCookies(cookies) + if err != nil { + return err + } + return s.youtube.SetCookies(ctx, normalized) +} + +func (s *Service) ClearYouTubeCookies(ctx context.Context) error { + return s.youtube.SetCookies(ctx, "") +} + +func (s *Service) NeteaseCredentialStatus(ctx context.Context) (port.CredentialStatus, error) { + cookie, err := s.credentials.NeteaseCookie(ctx) + if err != nil { + return port.CredentialStatus{}, err + } + status, err := credentialStatus(ctx, cookie, s.netease.CurrentAccount) + if err != nil { + return port.CredentialStatus{}, fmt.Errorf("查询网易云的账号资料:%w:%w", err, port.ErrProviderFailed) + } + return status, nil +} + +func (s *Service) QQCredentialStatus(ctx context.Context) (port.CredentialStatus, error) { + cookie, err := s.credentials.QQCookie(ctx) + if err != nil { + return port.CredentialStatus{}, err + } + status, err := credentialStatus(ctx, cookie, s.qq.CurrentAccount) + if err != nil { + return port.CredentialStatus{}, fmt.Errorf("查询 QQ 的账号资料:%w:%w", err, port.ErrProviderFailed) + } + return status, nil +} + +func credentialStatus(ctx context.Context, cookie string, current func(context.Context) (*port.SourceAccount, error)) (port.CredentialStatus, error) { + if cookie == "" { + return port.CredentialStatus{}, nil + } + account, err := current(ctx) + if err != nil { + return port.CredentialStatus{Set: true}, err + } + return port.CredentialStatus{Set: true, Account: account}, nil +} + +func (s *Service) CreateNeteaseQRLogin(ctx context.Context) (port.QRLoginSession, error) { + session, err := s.netease.CreateQRLogin(ctx) + if err != nil { + return port.QRLoginSession{}, fmt.Errorf("生成网易云的登录二维码:%w:%w", err, port.ErrProviderFailed) + } + return session, nil +} + +func (s *Service) CheckNeteaseQRLogin(ctx context.Context, key string) (port.QRLoginResult, error) { + result, err := s.netease.CheckQRLogin(ctx, key) + if err != nil { + return port.QRLoginResult{}, fmt.Errorf("查询网易云的扫码结果:%w:%w", err, port.ErrProviderFailed) + } + return result, nil +} + +func (s *Service) CreateQQQRLogin(ctx context.Context, typ port.QQLoginType) (port.QRLoginSession, error) { + session, err := s.qq.CreateQRLogin(ctx, typ) + if err != nil { + return port.QRLoginSession{}, fmt.Errorf("生成 QQ 的登录二维码:%w:%w", err, port.ErrProviderFailed) + } + return session, nil +} + +func (s *Service) CheckQQQRLogin(ctx context.Context, typ port.QQLoginType, key string) (port.QRLoginResult, error) { + result, err := s.qq.CheckQRLogin(ctx, typ, key) + if err != nil { + return port.QRLoginResult{}, fmt.Errorf("查询 QQ 的扫码结果:%w:%w", err, port.ErrProviderFailed) + } + return result, nil +} + +func (s *Service) BilibiliCredentialStatus(ctx context.Context) (port.CredentialStatus, error) { + status, err := s.bilibili.CredentialStatus(ctx) + if err != nil { + return port.CredentialStatus{}, fmt.Errorf("查询 B 站的登录状态:%w:%w", err, port.ErrProviderFailed) + } + return status, nil +} + +func (s *Service) CreateBilibiliQRLogin(ctx context.Context) (port.QRLoginSession, error) { + session, err := s.bilibili.CreateQRLogin(ctx) + if err != nil { + return port.QRLoginSession{}, fmt.Errorf("生成 B 站的登录二维码:%w:%w", err, port.ErrProviderFailed) + } + return session, nil +} + +func (s *Service) CheckBilibiliQRLogin(ctx context.Context, key string) (port.QRLoginResult, error) { + result, err := s.bilibili.CheckQRLogin(ctx, key) + if err != nil { + return port.QRLoginResult{}, fmt.Errorf("查询 B 站的扫码结果:%w:%w", err, port.ErrProviderFailed) + } + return result, nil +} + +func (s *Service) YouTubeCookiesStatus(ctx context.Context) (port.YouTubeCookiesStatus, error) { + status, err := s.youtube.Status(ctx) + if err != nil { + return port.YouTubeCookiesStatus{}, fmt.Errorf("查询 YouTube cookies 的状态:%w", err) + } + return status, nil +} diff --git a/internal/app/admin/cookies.go b/internal/app/admin/cookies.go new file mode 100644 index 0000000..61393d1 --- /dev/null +++ b/internal/app/admin/cookies.go @@ -0,0 +1,75 @@ +package admin + +import ( + "errors" + "strconv" + "strings" + "time" +) + +// 用户粘贴的内容无法识别为 cookies 时返回,文本直接展示给后台用户。 +var ErrUnrecognizedCookies = errors.New("不是能识别的 cookies:请粘贴扩展导出的 cookies.txt,或开发者工具里请求头 cookie 的值。") + +// normalizeCookies 把用户粘贴的内容统一成 Netscape 格式:扩展导出的 cookies.txt 原样保存 +// (换行统一成 \n),开发者工具里复制的 Cookie 请求头逐条转换。数据库里只存 Netscape 格式。 +func normalizeCookies(input string) (string, error) { + s := strings.TrimSpace(input) + if s == "" { + return "", ErrUnrecognizedCookies + } + if netscape, ok := normalizeNetscape(s); ok { + return netscape, nil + } + if header, ok := convertCookieHeader(s); ok { + return header, nil + } + return "", ErrUnrecognizedCookies +} + +// 空行和注释行原样保留但不校验;#HttpOnly_ 开头的是有效 cookie 行,不是注释。 +// 其余每行必须是制表符分隔的 7 列,且至少有一行。 +func normalizeNetscape(s string) (string, bool) { + lines := strings.Split(s, "\n") + valid := 0 + for i, line := range lines { + line = strings.TrimSuffix(line, "\r") + lines[i] = line + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#HttpOnly_") { + continue + } + if len(strings.Split(line, "\t")) != 7 { + return "", false + } + valid++ + } + if valid == 0 { + return "", false + } + return strings.Join(lines, "\n"), true +} + +// Cookie 请求头只有一行:可以带不区分大小写的 Cookie: 前缀,内容是用 ; 分隔的 名字=值。 +// 统一转换成 .youtube.com 的 Secure cookie,一年后过期。 +func convertCookieHeader(s string) (string, bool) { + if strings.ContainsAny(s, "\n\t") { + return "", false + } + body := s + if len(body) >= len("cookie:") && strings.EqualFold(body[:len("cookie:")], "cookie:") { + body = strings.TrimSpace(body[len("cookie:"):]) + } + expiry := strconv.FormatInt(time.Now().AddDate(1, 0, 0).Unix(), 10) + var lines []string + for _, part := range strings.Split(body, ";") { + name, value, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok || name == "" { + return "", false + } + lines = append(lines, ".youtube.com\tTRUE\t/\tTRUE\t"+expiry+"\t"+name+"\t"+value) + } + if len(lines) == 0 { + return "", false + } + return "# Netscape HTTP Cookie File\n" + strings.Join(lines, "\n"), true +} diff --git a/internal/app/catalog/catalog.go b/internal/app/catalog/catalog.go new file mode 100644 index 0000000..dc9fe4b --- /dev/null +++ b/internal/app/catalog/catalog.go @@ -0,0 +1,281 @@ +package catalog + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/room" + "github.com/dihemoe/alisten/internal/domain/track" +) + +const ( + searchBurst = 10 + searchRefill = time.Minute + // 最长等待与上游接口 30 秒的超时之和不超过客户端 60 秒的请求超时。 + maxSearchWait = 30 * time.Second + storeTimeout = 10 * time.Second + autoDJCandidates = 25 + autoDJLimit = 10 +) + +var ErrRateLimited = errors.New("搜索过于频繁,请稍后再试。") + +// RejectedLinkError 表示链接属于某个音源,但不能点播,例如 B 站的番剧和直播;它的文本就是返回给点歌人的原因。 +type RejectedLinkError struct { + Reason string +} + +func (e *RejectedLinkError) Error() string { return e.Reason } + +type Service struct { + netease port.NetEaseSource + qq port.Source + bilibili port.BilibiliSource + links port.BilibiliLinks + tracks port.Tracks + history port.PlayHistory + rooms port.RoomDirectory + quota port.SearchQuota +} + +func New(netease port.NetEaseSource, qq port.Source, bilibili port.BilibiliSource, links port.BilibiliLinks, tracks port.Tracks, history port.PlayHistory, rooms port.RoomDirectory, quota port.SearchQuota) *Service { + return &Service{netease: netease, qq: qq, bilibili: bilibili, links: links, tracks: tracks, history: history, rooms: rooms, quota: quota} +} + +// SearchTracks 只在上游一次取回的结果中分页,page 从 1 开始;total 是取回的结果数。 +func (s *Service) SearchTracks(ctx context.Context, roomID string, source track.Source, keyword string, page, size int64) (songs []port.Song, total int64, err error) { + src, err := s.searchable(source) + if err != nil { + return nil, 0, err + } + if err := s.take(ctx, roomID); err != nil { + return nil, 0, err + } + all, err := src.SearchTracks(ctx, keyword) + if err != nil { + return nil, 0, upstream(source, "搜索歌曲", err) + } + return paginate(all, page, size), int64(len(all)), nil +} + +func (s *Service) SearchPlaylists(ctx context.Context, roomID string, source track.Source, keyword string, page, size int64) (playlists []port.Playlist, total int64, err error) { + src, err := s.searchable(source) + if err != nil { + return nil, 0, err + } + if err := s.take(ctx, roomID); err != nil { + return nil, 0, err + } + all, err := src.SearchPlaylists(ctx, keyword) + if err != nil { + return nil, 0, upstream(source, "搜索歌单", err) + } + return paginate(all, page, size), int64(len(all)), nil +} + +func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source track.Source, playlistID string) ([]port.Song, error) { + if source != track.NetEase { + return nil, unsupported(source, "歌单内的歌曲") + } + if err := s.take(ctx, roomID); err != nil { + return nil, err + } + songs, err := s.netease.PlaylistTracks(ctx, playlistID) + if err != nil { + return nil, upstream(source, "获取歌单内的歌曲", err) + } + return songs, nil +} + +func (s *Service) RecommendList(ctx context.Context, roomID string) ([]port.Song, error) { + if err := s.take(ctx, roomID); err != nil { + return nil, err + } + songs, err := s.candidates(ctx, roomID, true) + if errors.Is(err, port.ErrProviderFailed) { + return nil, nil + } + return songs, err +} + +func (s *Service) TrackMeta(ctx context.Context, t track.Track) (track.Meta, error) { + lookup, err := s.metaSource(t.Source) + if err != nil { + return track.Meta{}, err + } + readCtx, cancel := context.WithTimeout(ctx, storeTimeout) + p, ok, err := s.tracks.PreparedTrack(readCtx, t) + cancel() + if err != nil { + return track.Meta{}, err + } + if ok { + return p.Meta, nil + } + m, err := lookup(ctx, t.ID) + if err != nil { + return track.Meta{}, upstream(t.Source, "查询元数据", err) + } + // B 站的适配器不填页面地址,平台的网址规则在领域中。 + if t.Source == track.Bilibili { + m.PageURL = track.BilibiliPageURL(t.ID) + } + return m, nil +} + +// B 站的链接转成 B 站音源的曲目,其他链接仍是链接曲目:链接准备完成后才有元数据,在此之前以链接本身作为标题。 +func (s *Service) ResolveLink(ctx context.Context, link string) (track.Track, track.Meta, error) { + id, ok, err := s.links.Recognize(ctx, link) + var rejected *port.RejectedError + switch { + case errors.As(err, &rejected): + return track.Track{}, track.Meta{}, &RejectedLinkError{Reason: rejected.Error()} + case err != nil: + return track.Track{}, track.Meta{}, upstream(track.Bilibili, "识别链接", err) + case !ok: + return track.Track{Source: track.Link, ID: link}, track.Meta{Title: link, PageURL: link}, nil + } + if _, _, valid := track.ParseBilibiliID(id); !valid { + return track.Track{}, track.Meta{}, fmt.Errorf("哔哩哔哩认出的曲目 ID %q 格式不对:%w", id, port.ErrProviderFailed) + } + t := track.Track{Source: track.Bilibili, ID: id} + m, err := s.TrackMeta(ctx, t) + return t, m, err +} + +// 按关键词点歌的这次搜索不消耗搜索额度。 +func (s *Service) FirstMatch(ctx context.Context, source track.Source, keyword string) (track.Track, track.Meta, error) { + src, err := s.searchable(source) + if err != nil { + return track.Track{}, track.Meta{}, err + } + songs, err := src.SearchTracks(ctx, keyword) + if err != nil { + return track.Track{}, track.Meta{}, upstream(source, "搜索歌曲", err) + } + if len(songs) == 0 { + return track.Track{}, track.Meta{}, fmt.Errorf("在%s搜索 %q 没有结果:%w", source, keyword, port.ErrProviderFailed) + } + return songs[0].Track, songs[0].Meta, nil +} + +func (s *Service) Recommend(ctx context.Context, roomID string) (track.Track, track.Meta, bool, error) { + songs, err := s.candidates(ctx, roomID, false) + if err != nil || len(songs) == 0 { + return track.Track{}, track.Meta{}, false, err + } + pick := songs[rand.IntN(len(songs))] + return pick.Track, pick.Meta, true, nil +} + +func (s *Service) candidates(ctx context.Context, roomID string, includeQueue bool) ([]port.Song, error) { + readCtx, cancel := context.WithTimeout(ctx, storeTimeout) + defer cancel() + seeds, err := s.history.AutoDJSeeds(readCtx, roomID, includeQueue) + if err != nil { + return nil, err + } + excluded, err := s.history.AutoDJExclusions(readCtx, roomID) + if err != nil { + return nil, err + } + skip := make(map[track.Track]bool, len(seeds)+len(excluded)) + for _, t := range seeds { + skip[t] = true + } + for _, t := range excluded { + skip[t] = true + } + var picks []port.Song + for _, seed := range seeds { + if len(picks) > autoDJCandidates { + break + } + similar, err := s.netease.SimilarTracks(ctx, seed.ID) + if err != nil { + return nil, upstream(track.NetEase, "获取相似歌曲", err) + } + for _, song := range similar { + if !skip[song.Track] { + skip[song.Track] = true + picks = append(picks, song) + } + } + } + return picks[:min(len(picks), autoDJLimit)], nil +} + +func (s *Service) take(ctx context.Context, roomID string) error { + readCtx, cancel := context.WithTimeout(ctx, storeTimeout) + r, err := s.rooms.FindRoom(readCtx, roomID) + cancel() + if err != nil { + return err + } + if r.Info.Kind == room.Persistent { + return nil + } + for { + wait, err := s.quota.TakeSearch(ctx, roomID, searchBurst, searchRefill) + if err != nil { + return err + } + if wait == 0 { + return nil + } + if wait > maxSearchWait { + return ErrRateLimited + } + timer := time.NewTimer(wait) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + } + } +} + +func (s *Service) metaSource(source track.Source) (func(context.Context, string) (track.Meta, error), error) { + switch source { + case track.NetEase: + return s.netease.TrackMeta, nil + case track.QQ: + return s.qq.TrackMeta, nil + case track.Bilibili: + return s.bilibili.TrackMeta, nil + } + return nil, unsupported(source, "点歌前查询元数据") +} + +func (s *Service) searchable(source track.Source) (port.Source, error) { + switch source { + case track.NetEase: + return s.netease, nil + case track.QQ: + return s.qq, nil + } + return nil, unsupported(source, "搜索") +} + +func unsupported(source track.Source, what string) error { + return fmt.Errorf("%s不支持%s:%w", source, what, port.ErrProviderFailed) +} + +func upstream(source track.Source, what string, err error) error { + return fmt.Errorf("在%s%s:%w:%w", source, what, err, port.ErrProviderFailed) +} + +// page 从 1 开始,size 至少为 1;两者都来自客户端,乘积可能溢出。 +func paginate[T any](items []T, page, size int64) []T { + n := int64(len(items)) + if page-1 > n/size { + return nil + } + start := (page - 1) * size + return items[start:min(start+size, n)] +} diff --git a/internal/app/identity/identity.go b/internal/app/identity/identity.go new file mode 100644 index 0000000..a160861 --- /dev/null +++ b/internal/app/identity/identity.go @@ -0,0 +1,139 @@ +package identity + +import ( + "context" + "errors" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/lib/logctx" +) + +var ( + ErrInvalidMemberToken = errors.New("成员 token 无效") + ErrInvalidTicket = errors.New("票据无效") + ErrWrongPassword = errors.New("房间密码错误") + ErrBotSession = errors.New("API Key 只能用于 HTTP 接口") +) + +type Service struct { + members port.Members + rooms port.RoomDirectory + tickets port.Tickets + gateway *realtime.Gateway +} + +func New(members port.Members, rooms port.RoomDirectory, tickets port.Tickets, gateway *realtime.Gateway) *Service { + return &Service{members: members, rooms: rooms, tickets: tickets, gateway: gateway} +} + +type Session struct { + MemberToken string + Member member.Member +} + +func (s *Service) CreateMember(ctx context.Context) (Session, error) { + token, hash := newSecret() + m, err := s.members.CreateMember(ctx, hash, guestName()) + if err != nil { + return Session{}, err + } + return Session{MemberToken: token, Member: m}, nil +} + +func (s *Service) Authenticate(ctx context.Context, memberToken string) (member.Member, error) { + if memberToken == "" { + return member.Member{}, ErrInvalidMemberToken + } + m, err := s.members.MemberByToken(ctx, hashSecret(memberToken)) + if errors.Is(err, port.ErrMemberNotFound) { + return member.Member{}, ErrInvalidMemberToken + } + return m, err +} + +type Profile struct { + Name *string + // 只用来计算头像哈希,不保存;空字符串表示清除头像。 + Email *string +} + +func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile) (member.Member, error) { + var avatar *string + if p.Email != nil { + hash := avatarHash(*p.Email) + avatar = &hash + } + m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar) + if err != nil { + return member.Member{}, err + } + if err := s.gateway.MemberUpdated(ctx, m); err != nil { + return member.Member{}, err + } + return m, nil +} + +func (s *Service) IssueTicket(ctx context.Context, memberID, roomID, password string) (string, error) { + r, err := s.rooms.FindRoom(ctx, roomID) + if err != nil { + return "", err + } + if r.PasswordHash != "" { + ok, err := checkPassword(ctx, r.PasswordHash, password) + if err != nil { + return "", err + } + if !ok { + return "", ErrWrongPassword + } + } + ticket, hash := newSecret() + err = s.tickets.CreateTicket(ctx, port.NewTicket{TokenHash: hash, RoomID: r.Info.ID, MemberID: memberID, PasswordHash: r.PasswordHash}) + if errors.Is(err, port.ErrPasswordChanged) { + // 刚才是按旧密码校验的;客户端重新输入密码后,按新密码校验。 + return "", ErrWrongPassword + } + if err != nil { + return "", err + } + return ticket, nil +} + +func (s *Service) VerifyTicket(ctx context.Context, ticket string) (port.TicketHolder, error) { + if ticket == "" { + return port.TicketHolder{}, ErrInvalidTicket + } + holder, err := s.tickets.TicketHolder(ctx, hashSecret(ticket)) + if errors.Is(err, port.ErrTicketNotFound) { + return port.TicketHolder{}, ErrInvalidTicket + } + return holder, err +} + +func (s *Service) EnterRoom(ctx context.Context, roomID, ticket string) (*realtime.Session, error) { + r, err := s.rooms.FindRoom(ctx, roomID) + if err != nil { + return nil, err + } + holder, err := s.VerifyTicket(ctx, ticket) + if err != nil { + return nil, err + } + if holder.RoomID != r.Info.ID { + return nil, ErrInvalidTicket + } + // bot 只用 HTTP 接口:它不出现在在线列表中,重置 Key 之后也不会留下用旧 Key 建立的会话。 + if holder.Bot { + return nil, ErrBotSession + } + return s.gateway.Join(logctx.WithMember(logctx.WithRoom(ctx, r.Info.ID), holder.Member.ID), r.Info.ID, holder.Member) +} + +// 每次都准备好 bot 成员的 token:房间还没有 bot 成员时由存储用它创建,这个随机值不交给任何人。 +func NewAPIKey() (string, port.NewAPIKey) { + key, hash := newSecret() + _, botHash := newSecret() + return key, port.NewAPIKey{TokenHash: hash, BotTokenHash: botHash} +} diff --git a/internal/app/identity/password.go b/internal/app/identity/password.go new file mode 100644 index 0000000..a3d79b6 --- /dev/null +++ b/internal/app/identity/password.go @@ -0,0 +1,78 @@ +package identity + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "runtime" + "strings" + + "golang.org/x/crypto/argon2" +) + +// 采用 OWASP 密码存储建议中 argon2id 的最低配置:任何成员都能请求换取票据,而每次校验都要分配这么多内存。 +const ( + argonTime = 2 + argonMemory = 19 * 1024 // KiB + argonThreads = 1 + argonKeyLen = 32 + argonSaltLen = 16 +) + +var passwordPrefix = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$", argon2.Version, argonMemory, argonTime, argonThreads) + +var errMalformedPasswordHash = errors.New("房间密码哈希的格式不正确") + +// 每次计算都要分配 argonMemory 的内存,而创建临时房间和换取票据任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。 +var argonSlots = make(chan struct{}, runtime.GOMAXPROCS(0)) + +func idKey(ctx context.Context, password string, salt []byte) ([]byte, error) { + select { + case argonSlots <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + defer func() { <-argonSlots }() + return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen), nil +} + +func HashPassword(ctx context.Context, password string) (string, error) { + if password == "" { + return "", nil + } + salt := make([]byte, argonSaltLen) + rand.Read(salt) + key, err := idKey(ctx, password, salt) + if err != nil { + return "", err + } + return passwordPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil +} + +// 只接受按当前参数生成的哈希:存储中的密码哈希都由 HashPassword 生成。 +func checkPassword(ctx context.Context, hash, password string) (bool, error) { + encoded, ok := strings.CutPrefix(hash, passwordPrefix) + if !ok { + return false, errMalformedPasswordHash + } + saltText, keyText, ok := strings.Cut(encoded, "$") + if !ok { + return false, errMalformedPasswordHash + } + salt, err := base64.RawStdEncoding.DecodeString(saltText) + if err != nil { + return false, errMalformedPasswordHash + } + key, err := base64.RawStdEncoding.DecodeString(keyText) + if err != nil || len(key) != argonKeyLen { + return false, errMalformedPasswordHash + } + got, err := idKey(ctx, password, salt) + if err != nil { + return false, err + } + return subtle.ConstantTimeCompare(got, key) == 1, nil +} diff --git a/internal/app/identity/secret.go b/internal/app/identity/secret.go new file mode 100644 index 0000000..96e1d87 --- /dev/null +++ b/internal/app/identity/secret.go @@ -0,0 +1,40 @@ +package identity + +import ( + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" +) + +// 成员 token 和票据都是 256 位随机值,存储中只有它们的 SHA-256。 +func newSecret() (string, []byte) { + b := make([]byte, 32) + // crypto/rand.Read 不会返回错误。 + rand.Read(b) + secret := base64.RawURLEncoding.EncodeToString(b) + return secret, hashSecret(secret) +} + +func hashSecret(secret string) []byte { + sum := sha256.Sum256([]byte(secret)) + return sum[:] +} + +func guestName() string { + b := make([]byte, 2) + rand.Read(b) + return "游客-" + hex.EncodeToString(b) +} + +// Gravatar 用去掉首尾空白、转为小写的邮箱计算哈希。 +func avatarHash(email string) string { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return "" + } + sum := md5.Sum([]byte(email)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/app/lobby/lobby.go b/internal/app/lobby/lobby.go new file mode 100644 index 0000000..bf2ac22 --- /dev/null +++ b/internal/app/lobby/lobby.go @@ -0,0 +1,90 @@ +package lobby + +import ( + "context" + "fmt" + "time" + + "github.com/dihemoe/alisten/internal/app/identity" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/internal/domain/room" +) + +// 对外只表明房间是否有密码,不暴露密码哈希。 +type Room struct { + Info room.Info + Slug string + HasPassword bool + Online int +} + +type NewRoom struct { + Name string + Description string + Password string +} + +const onlineTimeout = 5 * time.Second + +type Service struct { + rooms port.RoomDirectory + online port.OnlineCounts +} + +func New(rooms port.RoomDirectory, online port.OnlineCounts) *Service { + return &Service{rooms: rooms, online: online} +} + +func (s *Service) ListRooms(ctx context.Context) ([]Room, error) { + records, err := s.rooms.ListRooms(ctx) + if err != nil { + return nil, err + } + return s.listed(ctx, records) +} + +// ref 必须是 UUID 或合法别名的形式:存储按形式区分两者,而且含有 U+0000 的文本会让 PostgreSQL 报错。 +func (s *Service) FindRoom(ctx context.Context, ref string) (Room, error) { + r, err := s.rooms.FindRoom(ctx, ref) + if err != nil { + return Room{}, err + } + rooms, err := s.listed(ctx, []port.RoomRecord{r}) + if err != nil { + return Room{}, err + } + return rooms[0], nil +} + +func (s *Service) CreateRoom(ctx context.Context, r NewRoom) (string, error) { + hash, err := identity.HashPassword(ctx, r.Password) + if err != nil { + return "", err + } + return s.rooms.CreateRoom(ctx, port.NewRoom{ + Kind: room.Ephemeral, + Name: r.Name, + Description: r.Description, + PasswordHash: hash, + }) +} + +// 在线人数读不出来时不能当作 0,整个请求按 UNAVAILABLE 失败。 +func (s *Service) listed(ctx context.Context, records []port.RoomRecord) ([]Room, error) { + ids := make([]string, len(records)) + for i, r := range records { + ids[i] = r.Info.ID + } + ctx, cancel := context.WithTimeout(ctx, onlineTimeout) + defer cancel() + counts, err := s.online.OnlineCounts(ctx, ids) + if err != nil { + return nil, fmt.Errorf("%w:%w", realtime.ErrUnavailable, err) + } + rooms := make([]Room, len(records)) + for i, r := range records { + rooms[i] = Room{Info: r.Info, Slug: r.Slug, HasPassword: r.PasswordHash != "", Online: counts[i]} + } + return rooms, nil +} diff --git a/internal/app/media/library.go b/internal/app/media/library.go new file mode 100644 index 0000000..366ce86 --- /dev/null +++ b/internal/app/media/library.go @@ -0,0 +1,57 @@ +package media + +import ( + "context" + "errors" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +var ( + ErrNotFound = errors.New("媒体不存在") + ErrInUse = errors.New("这首歌还在房间的播放列表中,不能删除") +) + +type Library struct { + media port.MediaLibrary + usage port.TrackUsage + store port.MediaStore +} + +func NewLibrary(media port.MediaLibrary, usage port.TrackUsage, store port.MediaStore) *Library { + return &Library{media: media, usage: usage, store: store} +} + +func (l *Library) List(ctx context.Context) ([]port.StoredMedia, error) { + return l.media.ListStoredMedia(ctx) +} + +// 先删除对象,再清除准备结果:中途失败时准备结果还在,后台仍能看到这首歌并重试; +// 音频已经不在的准备结果也不会被使用。 +func (l *Library) Delete(ctx context.Context, key string) error { + m, ok, err := l.media.StoredMedia(ctx, key) + if err != nil { + return err + } + if !ok { + return ErrNotFound + } + used, err := l.usage.TrackInUse(ctx, track.Track{Source: m.Source, ID: m.Link}) + if err != nil { + return err + } + if used { + return ErrInUse + } + objects := []string{AudioObject(m.Key)} + if m.CoverKey != "" { + objects = append(objects, string(m.CoverKey)) + } + for _, object := range objects { + if err := l.store.Delete(ctx, object); err != nil { + return err + } + } + return l.media.DeleteStoredMedia(ctx, m.Key) +} diff --git a/internal/app/media/media.go b/internal/app/media/media.go new file mode 100644 index 0000000..f4786eb --- /dev/null +++ b/internal/app/media/media.go @@ -0,0 +1,21 @@ +package media + +import ( + "crypto/sha256" + "encoding/hex" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +// 链接和 B 站的歌在准备时下载,音频和封面存进对象存储,浏览器从对象存储读取。 +func Stored(s track.Source) bool { return s == track.Link || s == track.Bilibili } + +func Key(link string) string { + sum := sha256.Sum256([]byte(link)) + return hex.EncodeToString(sum[:]) +} + +// 链接和 B 站的歌的对象都在 link/ 下:对象存储的匿名读取策略、nginx 和 Traefik 都只放行这里的对象。 +func AudioObject(key string) string { return "link/" + key + "/audio.mp3" } + +func CoverObject(key, ext string) string { return "link/" + key + "/cover." + ext } diff --git a/internal/app/media/playback.go b/internal/app/media/playback.go new file mode 100644 index 0000000..8904a33 --- /dev/null +++ b/internal/app/media/playback.go @@ -0,0 +1,133 @@ +package media + +import ( + "context" + "errors" + "fmt" + "maps" + "net/url" + "runtime/debug" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +const ( + resolveTimeout = 30 * time.Second + // 各平台播放地址的有效期未知,1 分钟是保守的取值。 + reuseWindow = time.Minute + reuseLimit = 4096 +) + +var ( + ErrNoPlayback = errors.New("没有接入这个音源的播放地址") + ErrNotInRoom = errors.New("这首歌不在任何房间中") +) + +type Playback struct { + sources map[track.Source]port.PlayURLs + usage port.TrackUsage + calls singleflight.Group + + mu sync.Mutex + resolved map[track.Track]resolvedURL +} + +type resolvedURL struct { + url string + expires time.Time +} + +func NewPlayback(sources map[track.Source]port.PlayURLs, usage port.TrackUsage) *Playback { + return &Playback{sources: sources, usage: usage, resolved: map[track.Track]resolvedURL{}} +} + +// 只为至少出现在一个房间的条目中的歌解析:/media 不需要凭证,登录的账号不能被用来解析任意的歌。这项检查每次都做, +// 通过之后才复用 1 分钟内取到的地址。返回的地址总是 https:页面是 https 的,浏览器会拦截 http 的音频。 +func (p *Playback) URL(ctx context.Context, t track.Track, now time.Time) (string, error) { + source, ok := p.sources[t.Source] + if !ok { + return "", ErrNoPlayback + } + inRoom, err := p.usage.TrackInUse(ctx, t) + if err != nil { + return "", err + } + if !inRoom { + return "", ErrNotInRoom + } + if target, ok := p.reuse(t, now); ok { + return target, nil + } + // 合并的解析不随发起它的请求取消:同时等待的其他请求还要用它的结果。 + result := p.calls.DoChan(string(t.Source)+"/"+t.ID, func() (v any, err error) { + // 解析在独立的 goroutine 中执行,panic 回不到处理请求的 goroutine,net/http 无法恢复它,会让进程崩溃。 + defer func() { + if r := recover(); r != nil { + err = &panicError{value: r, stack: debug.Stack()} + } + }() + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), resolveTimeout) + defer cancel() + return source.PlayURL(ctx, t.ID) + }) + select { + case <-ctx.Done(): + return "", ctx.Err() + case r := <-result: + var panicked *panicError + if errors.As(r.Err, &panicked) { + // 音源实现中的 panic 是程序缺陷,不是音源出错:交给调用方当作内部错误记录。 + return "", panicked + } + if r.Err != nil { + return "", fmt.Errorf("解析 %s %s 的播放地址:%w:%w", t.Source, t.ID, r.Err, port.ErrProviderFailed) + } + target, err := secure(r.Val.(string)) + if err != nil { + return "", err + } + p.remember(t, target, now) + return target, nil + } +} + +func (p *Playback) reuse(t track.Track, now time.Time) (string, bool) { + p.mu.Lock() + defer p.mu.Unlock() + r, ok := p.resolved[t] + return r.url, ok && now.Before(r.expires) +} + +func (p *Playback) remember(t track.Track, target string, now time.Time) { + p.mu.Lock() + defer p.mu.Unlock() + maps.DeleteFunc(p.resolved, func(_ track.Track, r resolvedURL) bool { return !now.Before(r.expires) }) + if _, ok := p.resolved[t]; !ok && len(p.resolved) >= reuseLimit { + return + } + p.resolved[t] = resolvedURL{url: target, expires: now.Add(reuseWindow)} +} + +type panicError struct { + value any + stack []byte +} + +func (e *panicError) Error() string { + return fmt.Sprintf("解析播放地址时 panic:%v\n%s", e.value, e.stack) +} + +// 只替换 scheme,其余部分原样保留:签名过的地址重新编码之后可能失效。 +func secure(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { + return "", fmt.Errorf("音源给出的播放地址不是 http 或 https 的绝对地址:%w", port.ErrProviderFailed) + } + return "https" + raw[strings.Index(raw, ":"):], nil +} diff --git a/internal/app/media/ready.go b/internal/app/media/ready.go new file mode 100644 index 0000000..0a0dbc4 --- /dev/null +++ b/internal/app/media/ready.go @@ -0,0 +1,83 @@ +package media + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/room" + "github.com/dihemoe/alisten/internal/domain/track" +) + +// 接管房间的期限是 10 秒,检查链接和 B 站的歌的音频只占用其中一部分,对象存储变慢时接管仍能完成。 +const restoreCheckTimeout = 5 * time.Second + +// Tracks 读取准备结果时,链接和 B 站的歌还要求音频仍在对象存储中:音频被清理或丢失之后,这首歌要重新下载。 +type Tracks struct { + port.Tracks + store port.MediaStore +} + +func NewTracks(tracks port.Tracks, store port.MediaStore) *Tracks { + return &Tracks{Tracks: tracks, store: store} +} + +// 检查对象存储失败时返回错误,调用方稍后重试,不当作没有结果:否则对象存储短暂不可用时,已经存储的歌都会重新下载。 +func (t *Tracks) PreparedTrack(ctx context.Context, tr track.Track) (port.PreparedTrack, bool, error) { + p, ok, err := t.Tracks.PreparedTrack(ctx, tr) + if err != nil || !ok || !Stored(tr.Source) { + return p, ok, err + } + exists, err := t.store.Exists(ctx, AudioObject(p.MediaKey)) + if err != nil { + return port.PreparedTrack{}, false, fmt.Errorf("检查 %s 的音频是否还在对象存储中:%w", tr.ID, err) + } + if !exists { + return port.PreparedTrack{}, false, nil + } + return p, true, nil +} + +// 只检查待播和等待队列:正在播放的歌已经开播。 +type Snapshots struct { + port.RoomSnapshots + store port.MediaStore + logger *slog.Logger +} + +func NewSnapshots(snapshots port.RoomSnapshots, store port.MediaStore, logger *slog.Logger) *Snapshots { + return &Snapshots{RoomSnapshots: snapshots, store: store, logger: logger} +} + +// 对象存储出错时,其余条目按曲目表恢复:对象存储不可用时房间仍要能被接管。任期已经加 1,所以这里不再返回错误。 +func (s *Snapshots) TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) { + term, snap, err := s.RoomSnapshots.TakeRoom(ctx, id) + if err != nil { + return term, snap, err + } + ctx, cancel := context.WithTimeout(ctx, restoreCheckTimeout) + defer cancel() + entries := make([]*room.Entry, 0, len(snap.Waiting)+1) + if snap.Pending != nil { + entries = append(entries, snap.Pending) + } + for i := range snap.Waiting { + entries = append(entries, &snap.Waiting[i]) + } + for _, e := range entries { + if !e.Ready || !Stored(e.Track.Source) { + continue + } + exists, err := s.store.Exists(ctx, AudioObject(Key(e.Track.ID))) + if err != nil { + s.logger.WarnContext(ctx, "检查曲目的音频失败,其余条目按曲目表恢复", "source", e.Track.Source, "track_id", e.Track.ID, "error", err) + break + } + if !exists { + e.Ready, e.Prep = false, track.Prepared{} + } + } + return term, snap, nil +} diff --git a/internal/app/port/apikey.go b/internal/app/port/apikey.go new file mode 100644 index 0000000..0d4535c --- /dev/null +++ b/internal/app/port/apikey.go @@ -0,0 +1,26 @@ +package port + +import ( + "context" + "time" +) + +// 常驻房间的 API Key 是房间 bot 成员的票据。 +type NewAPIKey struct { + TokenHash []byte + // 房间还没有 bot 成员时用它创建:对应的随机值不交给任何人,bot 因此没有可用的成员 token。 + BotTokenHash []byte +} + +type APIKeyStatus struct { + Set bool + // Key 的生成时间,Set 为 false 时为零值。 + CreatedAt time.Time +} + +// roomID 必须是常驻房间。每个房间至多一把 Key;bot 成员在第一次生成时创建,此后一直不变。 +type APIKeys interface { + RoomAPIKeyStatus(ctx context.Context, roomID string) (APIKeyStatus, error) + // 还没有 Key 时生成,已经有时旧 Key 立即失效。 + ResetRoomAPIKey(ctx context.Context, roomID string, k NewAPIKey) error +} diff --git a/internal/app/port/bilibili.go b/internal/app/port/bilibili.go new file mode 100644 index 0000000..3d52e0d --- /dev/null +++ b/internal/app/port/bilibili.go @@ -0,0 +1,41 @@ +package port + +import ( + "context" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +type BilibiliSource interface { + // CoverURL 和 PageURL 留空:平台的图片有防盗链,封面在准备时下载存储;页面地址由调用方按 ID 拼出。 + TrackMeta(ctx context.Context, id string) (track.Meta, error) + // 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。视频不存在、需要付费或地区受限时返回 *RejectedError。 + Download(ctx context.Context, id string) (d BilibiliDownload, release func(), err error) +} + +// 与 LinkDownload 不同,Audio 是平台给的原始文件,可能还带着视频,要转成 mp3 才能分析和上传。 +type BilibiliDownload struct { + // 与 TrackMeta 的结果相同。 + Meta track.Meta + Audio LinkFile + // 封面文件和它的扩展名(小写,不带点),没有封面时都为空。 + Cover LinkFile + CoverExt string +} + +// 不是 B 站的链接时 ok 为 false、err 为 nil;是 B 站的链接但不是视频(番剧、直播、个人空间等)时返回 *RejectedError。 +type BilibiliLinks interface { + Recognize(ctx context.Context, link string) (id string, ok bool, err error) +} + +type BilibiliQRLoginSource interface { + CreateQRLogin(context.Context) (QRLoginSession, error) + CheckQRLogin(context.Context, string) (QRLoginResult, error) + // 是否已登录也由实现判断,凭据不离开适配器。 + CredentialStatus(context.Context) (CredentialStatus, error) +} + +// 只读本地文件,不访问网络。读完 mp3 之后调用 release 删除它;返回错误时没有要删除的文件。 +type Transcoder interface { + ToMP3(ctx context.Context, audio LinkFile) (mp3 LinkFile, release func(), err error) +} diff --git a/internal/app/port/bus.go b/internal/app/port/bus.go new file mode 100644 index 0000000..49bcce4 --- /dev/null +++ b/internal/app/port/bus.go @@ -0,0 +1,120 @@ +package port + +import ( + "context" + + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/room" +) + +// owner 按产生的顺序发布,订阅的网关按同样的顺序收到。 +type RoomMessage interface{ roomMessage() } + +type ( + // 房间开始了新的一次运行:在线列表为空,事件重新编号。各网关要为本地会话重新发送 Join,为事件流重新获取快照。 + OwnerChanged struct { + Term uint32 + } + + Emitted struct { + Emit room.Emit + } + + Replied struct { + Reply room.Reply + } + + // 请求没有被房间处理,之后也不会有回复。 + Rejected struct { + RequestID room.RequestID + // 为真时房间不存在,否则是房间暂时不可用。 + NotFound bool + } + + // 房间的数据已经删除,它的会话和事件流要以 4404 断开。 + RoomClosed struct{} + + OwnerReleased struct{} + + // 房间成员的票据已经作废,会话和成员打开的事件流要以 4401 断开,用 API Key 打开的事件流不受影响。由管理接口发布。 + TicketsRevoked struct{} + + // 常驻房间的 API Key 已经重置,用 Key 打开的事件流要以 4401 断开,成员的会话和事件流不受影响。由管理接口发布。 + APIKeyReset struct{} +) + +func (OwnerChanged) roomMessage() {} +func (Emitted) roomMessage() {} +func (Replied) roomMessage() {} +func (Rejected) roomMessage() {} +func (RoomClosed) roomMessage() {} +func (OwnerReleased) roomMessage() {} +func (TicketsRevoked) roomMessage() {} +func (APIKeyReset) roomMessage() {} + +type RoomInput struct { + Room string + // 发出输入的网关的实例 ID:owner 据此记录会话所在的网关。 + Gateway string + // Input 与 Patch 恰好有一项不为空。 + Input room.Input + Patch *InfoPatch + // 为真时,如果接收输入的实例上没有运行这个房间,就由它接管。 + Takeover bool +} + +// nil 表示保持不变:管理接口只修改给出的字段,由房间按当前的名称和描述补全为 UpdateInfo。 +type InfoPatch struct { + RequestID room.RequestID + Name *string + Description *string +} + +type Delivery interface{ delivery() } + +type ( + RoomDelivery struct { + Room string + Message RoomMessage + } + + InputDelivery struct { + // 带有网关一侧的追踪上下文,owner 在同一条 trace 中处理这条输入;网关一侧没有追踪时为 context.Background()。 + Context context.Context + Input RoomInput + } + + MemberDelivery struct { + Member member.Member + } + + PreparedDelivery struct { + Outcome PrepareOutcome + } + + // 订阅连接曾经断开,断开期间发给本实例的消息都已丢失。 + SubscriptionLost struct{} +) + +func (RoomDelivery) delivery() {} +func (InputDelivery) delivery() {} +func (MemberDelivery) delivery() {} +func (PreparedDelivery) delivery() {} +func (SubscriptionLost) delivery() {} + +// 实现绑定在本实例上:订阅的是本实例的频道,发出的消息带有本实例的 ID。 +type Bus interface { + // 订阅本实例的频道、成员资料的频道和准备结果的频道,返回时订阅已经生效。 + SubscribeInstance(ctx context.Context) error + // 返回时订阅已经生效:此后发布到这个房间频道的消息都会收到。 + SubscribeRoom(ctx context.Context, roomID string) error + UnsubscribeRoom(ctx context.Context, roomID string) error + PublishRoom(ctx context.Context, roomID string, m RoomMessage) error + // 返回 false 表示目标实例没有订阅它的频道,例如已经停止运行。 + SendInput(ctx context.Context, instanceID string, in RoomInput) (bool, error) + PublishMember(ctx context.Context, m member.Member) error + // 各实例都会收到,包括发布的实例。 + PublishPrepared(ctx context.Context, o PrepareOutcome) error + // 按收到的顺序返回下一条消息,直到 ctx 结束;ctx 结束之后 Bus 不能再使用。只能由一个 goroutine 调用。 + Receive(ctx context.Context) (Delivery, error) +} diff --git a/internal/app/port/catalog.go b/internal/app/port/catalog.go new file mode 100644 index 0000000..570255c --- /dev/null +++ b/internal/app/port/catalog.go @@ -0,0 +1,19 @@ +package port + +import ( + "context" + "errors" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +var ErrProviderFailed = errors.New("音乐平台没有正常响应,请稍后再试。") + +// 房间宿主调用 Prepare 时,ctx 在房间停止运行时取消,结果不再交回房间; +// 实现要自己为每次对外请求设置期限。 +type Preparer interface { + // 返回错误表示准备失败,错误的文本就是广播给房间的失败原因。 + Prepare(ctx context.Context, t track.Track) (track.Meta, track.Prepared, error) + // 各实例收到 tracks.prepared 频道上的结果时调用,不能阻塞:调用方是接收实例间消息的 goroutine。 + Finished(o PrepareOutcome) +} diff --git a/internal/app/port/cluster.go b/internal/app/port/cluster.go new file mode 100644 index 0000000..38dcf74 --- /dev/null +++ b/internal/app/port/cluster.go @@ -0,0 +1,33 @@ +package port + +import ( + "context" + "time" +) + +// Leases 是房间的 owner 租约,值是实例 ID。实现绑定在本实例上:取得、续期和释放的都是本实例的租约。 +// 房间的在线人数附在租约上,与租约一起续期、一起过期。 +type Leases interface { + // 租约不存在或已经属于本实例时取得它,在线人数记为 0;返回 false 表示租约属于其他实例。 + Acquire(ctx context.Context, roomID string, ttl time.Duration) (bool, error) + // 租约仍属于本实例时续期;返回 false 表示租约已不属于本实例。 + Renew(ctx context.Context, roomID string, ttl time.Duration) (bool, error) + // 租约仍属于本实例时写入在线人数;返回 false 表示租约已不属于本实例。 + SetOnline(ctx context.Context, roomID string, online int) (bool, error) + // 只删除属于本实例的租约。 + Release(ctx context.Context, roomID string) error + // 按 roomIDs 的顺序返回各房间的 owner,没有 owner 时为空字符串。 + Owners(ctx context.Context, roomIDs []string) ([]string, error) +} + +type OnlineCounts interface { + // 按 roomIDs 的顺序返回各房间的在线人数,没有 owner 的房间为 0。 + OnlineCounts(ctx context.Context, roomIDs []string) ([]int, error) +} + +// Liveness 是各实例的存活键。实现绑定在本实例上:Heartbeat 刷新的是本实例的存活键。 +type Liveness interface { + // 返回 false 表示刷新之前存活键已经不存在:owner 可能已经让本实例的会话离开了在线列表。 + Heartbeat(ctx context.Context, ttl time.Duration) (bool, error) + Expired(ctx context.Context, instanceIDs []string) ([]string, error) +} diff --git a/internal/app/port/credential.go b/internal/app/port/credential.go new file mode 100644 index 0000000..fb84c1a --- /dev/null +++ b/internal/app/port/credential.go @@ -0,0 +1,30 @@ +package port + +import ( + "context" + "time" +) + +// 凭据未设置时,读取方法都返回空字符串。写入方法在具体的 Store 上,与网易云、QQ 的 SetCookie 相同。 +type Credentials interface { + NeteaseCookie(ctx context.Context) (string, error) + QQCookie(ctx context.Context) (string, error) + BilibiliCredential(ctx context.Context) (string, error) +} + +type CredentialStatus struct { + Set bool + Account *SourceAccount +} + +type YouTubeCookies interface { + // cookies 为空字符串时删除已保存的 cookies。 + SetCookies(context.Context, string) error + Status(context.Context) (YouTubeCookiesStatus, error) +} + +type YouTubeCookiesStatus struct { + Set bool + // 最近一次填写的时间,Set 为 false 时为零值。 + UpdatedAt time.Time +} diff --git a/internal/app/port/history.go b/internal/app/port/history.go new file mode 100644 index 0000000..af2804c --- /dev/null +++ b/internal/app/port/history.go @@ -0,0 +1,19 @@ +package port + +import ( + "context" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +type PlayHistory interface { + // 本房间最近 32 首不同的、由成员点播的网易云歌曲:先是正在播放的条目,includeQueue 时接着是待播和 + // 等待队列中的条目,然后是按开播时间从近到远的播放记录。 + AutoDJSeeds(ctx context.Context, roomID string, includeQueue bool) ([]track.Track, error) + // 本房间最近 128 首由 AutoDJ 加入的歌(包括正在播放的条目),加上待播和等待队列中的全部条目。 + AutoDJExclusions(ctx context.Context, roomID string) ([]track.Track, error) +} + +type TrackUsage interface { + TrackInUse(ctx context.Context, t track.Track) (bool, error) +} diff --git a/internal/app/port/jobs.go b/internal/app/port/jobs.go new file mode 100644 index 0000000..677a25e --- /dev/null +++ b/internal/app/port/jobs.go @@ -0,0 +1,37 @@ +package port + +import ( + "context" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +type JobState uint8 + +const ( + // 等待执行、正在执行或者等待重试。 + JobPending JobState = iota + 1 + // 已完成或者已被清理,不会再执行;成功的结果已经写入曲目表。 + JobFinished + // 被取消或者重试之后仍然失败,不会再执行。 + JobFailed +) + +// 任务重试用完时告诉用户的原因。原因会出现在房间动态里,也会被 bot 转发;最后一次的错误可能是下载工具的输出或 IO 错误, +// 只写进日志。音源拒绝时的原因本来就是写给用户的,照旧使用。 +const UnavailableReason = "暂时无法获取这首歌,可以稍后再点一次" + +// Reason 为空表示成功,结果已经写入曲目表。 +type PrepareOutcome struct { + Track track.Track + JobID int64 + Reason string +} + +// 准备任务按曲目去重:同一首歌同一时刻只有一个未结束的任务。 +type PrepareJobs interface { + // 这首歌已有未结束的任务时,返回那个任务的 ID。 + Submit(ctx context.Context, t track.Track) (jobID int64, err error) + // 任务失败时 reason 是写给用户的原因:被取消(音源拒绝)时是拒绝的原因,重试用完时是 UnavailableReason。 + Status(ctx context.Context, jobID int64) (state JobState, reason string, err error) +} diff --git a/internal/app/port/link.go b/internal/app/port/link.go new file mode 100644 index 0000000..ec05a00 --- /dev/null +++ b/internal/app/port/link.go @@ -0,0 +1,33 @@ +package port + +import ( + "context" + "io" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +// Meta 只有链接本身给出的标题、歌手、专辑和时长,时长未知时为 0。 +type LinkDownload struct { + Meta track.Meta + // 转成 mp3 的音频文件。 + Audio LinkFile + // 封面文件和它的扩展名(小写,不带点),没有封面时都为空。 + Cover LinkFile + CoverExt string +} + +type LinkFile interface { + // 分析和上传会同时打开同一个文件,每次打开都从头读到同样的内容。 + Open() (io.ReadSeekCloser, error) +} + +type LinkDownloader interface { + // 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。链接不受支持或指向非公网地址时返回 *RejectedError。 + Download(ctx context.Context, link string) (d LinkDownload, release func(), err error) +} + +// 用 yt-dlp 下载链接之前调用。这个链接不需要 cookies 时 ok 为 false;否则下载结束后调用 release,由实现删除文件。 +type LinkCookies interface { + CookieFile(ctx context.Context, link string) (path string, release func(), ok bool, err error) +} diff --git a/internal/app/port/media.go b/internal/app/port/media.go new file mode 100644 index 0000000..6564298 --- /dev/null +++ b/internal/app/port/media.go @@ -0,0 +1,39 @@ +package port + +import ( + "context" + "io" + "time" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +type MediaStore interface { + // body 要能 Seek:签名要先读一遍算出摘要,失败重试时也要从头再读。 + Put(ctx context.Context, key string, body io.ReadSeeker, contentType string) error + Exists(ctx context.Context, key string) (bool, error) + // 对象不存在时同样成功。 + Delete(ctx context.Context, key string) error +} + +type StoredMedia struct { + // 曲目 ID 的 SHA-256(十六进制)。链接的曲目 ID 是 http(s) 地址,B 站的是 BV 号,两种 ID 不会相同,所以单凭它就能找到这首歌。 + Key string + Source track.Source + // 曲目 ID:链接曲目是链接本身,B 站的歌是 BV 号。 + Link string + Title string + // 音频的字节数。 + Size int64 + CoverKey track.ObjectKey + StoredAt time.Time + LastPlayedAt *time.Time +} + +type MediaLibrary interface { + // 最近存入的在前。 + ListStoredMedia(ctx context.Context) ([]StoredMedia, error) + StoredMedia(ctx context.Context, key string) (m StoredMedia, ok bool, err error) + // 清除这首歌的准备结果。 + DeleteStoredMedia(ctx context.Context, key string) error +} diff --git a/internal/app/port/member.go b/internal/app/port/member.go new file mode 100644 index 0000000..18bba50 --- /dev/null +++ b/internal/app/port/member.go @@ -0,0 +1,18 @@ +package port + +import ( + "context" + "errors" + + "github.com/dihemoe/alisten/internal/domain/member" +) + +var ErrMemberNotFound = errors.New("成员不存在") + +// 成员 token 只以 SHA-256 哈希的形式出现在端口中。 +type Members interface { + CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) + MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) + // name 或 avatarHash 为 nil 时保持原值。 + UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error) +} diff --git a/internal/app/port/quota.go b/internal/app/port/quota.go new file mode 100644 index 0000000..fc6f7d9 --- /dev/null +++ b/internal/app/port/quota.go @@ -0,0 +1,13 @@ +package port + +import ( + "context" + "time" +) + +// 搜索与推荐列表的额度按房间计数,所有实例共用。 +type SearchQuota interface { + // 从房间的额度中取一次:最多突发 burst 次,之后每隔 refill 恢复一次。取到时返回 0, + // 额度用完时返回距离下一次有额度还要等待的时长。 + TakeSearch(ctx context.Context, roomID string, burst int, refill time.Duration) (time.Duration, error) +} diff --git a/internal/app/port/room.go b/internal/app/port/room.go new file mode 100644 index 0000000..9490c70 --- /dev/null +++ b/internal/app/port/room.go @@ -0,0 +1,45 @@ +package port + +import ( + "context" + "errors" + + "github.com/dihemoe/alisten/internal/domain/room" +) + +var ( + ErrRoomNotFound = errors.New("房间不存在") + ErrSlugTaken = errors.New("别名已被占用") +) + +// 别名或密码哈希为空字符串表示没有。 +type ( + RoomRecord struct { + Info room.Info + Slug string + PasswordHash string + } + + NewRoom struct { + Kind room.Kind + Name string + Description string + Slug string + PasswordHash string + } +) + +// nil 表示不修改,空字符串表示取消。 +type AccessChange struct { + Slug *string + PasswordHash *string +} + +type RoomDirectory interface { + CreateRoom(ctx context.Context, r NewRoom) (string, error) + ListRooms(ctx context.Context) ([]RoomRecord, error) + // ref 是房间的 UUID 或别名。 + FindRoom(ctx context.Context, ref string) (RoomRecord, error) + // 设置或修改密码时同时删除房间成员的票据,bot 成员的票据(API Key)保留;调用方随后还要让这个房间的在线会话断开。 + UpdateRoomAccess(ctx context.Context, id string, c AccessChange) error +} diff --git a/internal/app/port/site.go b/internal/app/port/site.go new file mode 100644 index 0000000..6c015ec --- /dev/null +++ b/internal/app/port/site.go @@ -0,0 +1,9 @@ +package port + +import "context" + +// 没有设置过品牌名时,BrandName 返回空字符串。 +type Site interface { + BrandName(ctx context.Context) (string, error) + SetBrandName(ctx context.Context, name string) error +} diff --git a/internal/app/port/snapshot.go b/internal/app/port/snapshot.go new file mode 100644 index 0000000..f8eda3f --- /dev/null +++ b/internal/app/port/snapshot.go @@ -0,0 +1,19 @@ +package port + +import ( + "context" + "errors" + + "github.com/dihemoe/alisten/internal/domain/room" +) + +// 房间已由新的 owner 接管或已被删除,调用方必须放弃这个房间。 +var ErrTermExpired = errors.New("任期已过期") + +type RoomSnapshots interface { + // 任期加 1 之后,之前任期的写入都会失败,所以只有接管房间的实例才能调用。 + // 条目是否就绪只按曲目表判断;链接和 B 站的歌的音频是否还在对象存储中,由调用方检查。 + TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) + SaveRoom(ctx context.Context, term uint32, s room.Snapshot, plays []room.RecordPlay) error + DeleteRoom(ctx context.Context, id string, term uint32) error +} diff --git a/internal/app/port/source.go b/internal/app/port/source.go new file mode 100644 index 0000000..1a2fd85 --- /dev/null +++ b/internal/app/port/source.go @@ -0,0 +1,112 @@ +package port + +import ( + "context" + "io" + "time" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +type QRLoginStatus string + +const ( + QRLoginWaiting QRLoginStatus = "waiting" + QRLoginScanned QRLoginStatus = "scanned" + QRLoginSuccess QRLoginStatus = "success" + QRLoginExpired QRLoginStatus = "expired" + QRLoginFailed QRLoginStatus = "failed" +) + +type QRLoginSession struct { + Key string + // 二维码编码的登录页链接;平台只给图片时为空。 + LoginURL string + // 浏览器可以直接显示的二维码图片:data: URI 或 https 地址;平台只给登录链接时为空。两者至少有一个。 + QRImage string + ExpiresAt time.Time +} + +type QRLoginResult struct { + Status QRLoginStatus + Message string +} + +type SourceAccount struct { + ID string + Name *string + VIP *bool + Verified bool +} + +type QQLoginType string + +const ( + QQLoginQQ QQLoginType = "qq" + QQLoginWeChat QQLoginType = "wx" +) + +type NetEaseQRLoginSource interface { + CreateQRLogin(context.Context) (QRLoginSession, error) + CheckQRLogin(context.Context, string) (QRLoginResult, error) + CurrentAccount(context.Context) (*SourceAccount, error) +} + +type QQQRLoginSource interface { + CreateQRLogin(context.Context, QQLoginType) (QRLoginSession, error) + CheckQRLogin(context.Context, QQLoginType, string) (QRLoginResult, error) + CurrentAccount(context.Context) (*SourceAccount, error) +} + +// RejectedError 表示音源明确拒绝了这首歌,比如歌曲下架或没有版权:准备不再重试。 +// 它的文本就是准备失败的原因。 +type RejectedError struct { + Err error +} + +func (e *RejectedError) Error() string { return e.Err.Error() } +func (e *RejectedError) Unwrap() error { return e.Err } + +type ( + Song struct { + Track track.Track + Meta track.Meta + } + + Playlist struct { + ID string + Name string + CoverURL string + Creator string + Description string + TrackCount int64 + PlayCount int64 + } + + // Audio 由调用方读完后关闭。 + Resolved struct { + Meta track.Meta + Lyric string + Audio io.ReadCloser + } +) + +type Source interface { + SearchTracks(ctx context.Context, keyword string) ([]Song, error) + SearchPlaylists(ctx context.Context, keyword string) ([]Playlist, error) + TrackMeta(ctx context.Context, id string) (track.Meta, error) + Resolve(ctx context.Context, id string) (Resolved, error) +} + +// 只有网易云提供歌单内的歌曲和相似歌曲。 +type NetEaseSource interface { + Source + PlaylistTracks(ctx context.Context, playlistID string) ([]Song, error) + SimilarTracks(ctx context.Context, id string) ([]Song, error) +} + +// 供 /media 跳转使用。播放地址有时效,调用方把取到的地址复用 1 分钟。 +// 音源明确拒绝这首歌时返回 *RejectedError。实现要自己为对外请求设置期限。 +type PlayURLs interface { + PlayURL(ctx context.Context, id string) (string, error) +} diff --git a/internal/app/port/ticket.go b/internal/app/port/ticket.go new file mode 100644 index 0000000..fce12ce --- /dev/null +++ b/internal/app/port/ticket.go @@ -0,0 +1,36 @@ +package port + +import ( + "context" + "errors" + + "github.com/dihemoe/alisten/internal/domain/member" +) + +var ( + ErrTicketNotFound = errors.New("票据不存在") + ErrPasswordChanged = errors.New("房间密码已修改") +) + +type NewTicket struct { + TokenHash []byte + RoomID string + MemberID string + // 签发前校验密码时依据的密码哈希,空字符串表示房间没有密码。 + PasswordHash string +} + +type TicketHolder struct { + RoomID string + // 持有者的当前资料。 + Member member.Member + // 票据是常驻房间的 API Key,持有者是这个房间的 bot 成员。 + Bot bool +} + +type Tickets interface { + // 房间的密码哈希已经不是 t.PasswordHash 时返回 ErrPasswordChanged:密码在校验之后被修改过, + // 设置或修改密码时的作废已经执行,此时签发的票据不会再被作废。 + CreateTicket(ctx context.Context, t NewTicket) error + TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolder, error) +} diff --git a/internal/app/port/tracks.go b/internal/app/port/tracks.go new file mode 100644 index 0000000..530d9f8 --- /dev/null +++ b/internal/app/port/tracks.go @@ -0,0 +1,22 @@ +package port + +import ( + "context" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +// 曲目表中只有准备得到的时长,读出的 Meta.Duration 就是 Result.Duration。 +type PreparedTrack struct { + Meta track.Meta + Result track.Prepared + // 链接和 B 站的歌的 MediaKey 是曲目 ID 的 SHA-256(十六进制),对象存储中音频和封面的对象键由它得出; + // MediaSize 是音频的字节数。其他来源为空。 + MediaKey string + MediaSize int64 +} + +type Tracks interface { + PreparedTrack(ctx context.Context, t track.Track) (p PreparedTrack, ok bool, err error) + SavePreparedTrack(ctx context.Context, t track.Track, p PreparedTrack) error +} diff --git a/internal/app/prepare/media.go b/internal/app/prepare/media.go new file mode 100644 index 0000000..eebe23f --- /dev/null +++ b/internal/app/prepare/media.go @@ -0,0 +1,206 @@ +package prepare + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/url" + "sync" + "time" + + "github.com/dihemoe/alisten/internal/app/media" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +const cleanupTimeout = 10 * time.Second + +// 限定封面格式并不能阻止脚本:链接的封面来自用户提交的网页,而对象存储会按匿名请求中的 response-content-type 改写 Content-Type。 +// 对象存储与页面同源时,要靠入口为 /storage/ 的响应添加 Content-Security-Policy: sandbox 和 X-Content-Type-Options: nosniff 来防范脚本。 +var coverTypes = map[string]string{"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"} + +// MediaRunner 准备音频和封面要存进对象存储的歌:链接由 yt-dlp 下载并转成 mp3,B 站的歌由音源下载原始文件,再转成 mp3。 +type MediaRunner struct { + links port.LinkDownloader + bilibili port.BilibiliSource + transcoder port.Transcoder + store port.MediaStore + tracks port.Tracks + logger *slog.Logger +} + +func NewMediaRunner(links port.LinkDownloader, bilibili port.BilibiliSource, transcoder port.Transcoder, store port.MediaStore, tracks port.Tracks, logger *slog.Logger) *MediaRunner { + return &MediaRunner{links: links, bilibili: bilibili, transcoder: transcoder, store: store, tracks: tracks, logger: logger} +} + +func (r *MediaRunner) Run(ctx context.Context, t track.Track) (err error) { + d, release, err := r.download(ctx, t) + if err != nil { + return err + } + defer release() + + key := media.Key(t.ID) + if err := r.clear(ctx, key); err != nil { + return err + } + // 上传开始之后任何一步失败,都要删除已经上传的对象:它们匿名可读,却没有准备结果,后台也看不到,不会再有人清理。 + defer func() { + if err != nil { + r.discard(ctx, t, key) + } + }() + var ( + wg sync.WaitGroup + result track.Prepared + analyzeErr error + size int64 + coverKey string + uploadErr error + ) + wg.Go(func() { result, analyzeErr = analyzeFile(ctx, r.logger, t, d.Audio, d.Meta.Duration) }) + size, coverKey, uploadErr = r.upload(ctx, key, d) + wg.Wait() + // 音频无法分析时重试也没有用,先于上传的错误返回。 + if analyzeErr != nil { + return analyzeErr + } + if uploadErr != nil { + return uploadErr + } + + meta := track.Meta{Title: d.Meta.Title, Artist: d.Meta.Artist, Album: d.Meta.Album, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL(t), Duration: d.Meta.Duration} + ctx, cancel := context.WithTimeout(ctx, storeTimeout) + defer cancel() + return r.tracks.SavePreparedTrack(ctx, t, port.PreparedTrack{Meta: meta, Result: result, MediaKey: key, MediaSize: size}) +} + +// 任务参数来自 River 的任务表,不一定经过网关的校验。 +func (r *MediaRunner) download(ctx context.Context, t track.Track) (port.LinkDownload, func(), error) { + switch { + case t.Source == track.Link && httpLink(t.ID): + return r.links.Download(ctx, t.ID) + case t.Source == track.Bilibili && bilibiliID(t.ID): + return r.downloadBilibili(ctx, t.ID) + } + return port.LinkDownload{}, nil, &port.RejectedError{Err: fmt.Errorf("不能准备来源为 %q、ID 为 %q 的歌", t.Source, t.ID)} +} + +// 平台给的原始文件转成 mp3 之后,与链接下载到的文件一样分析和上传。两份文件都保留到上传结束。 +func (r *MediaRunner) downloadBilibili(ctx context.Context, id string) (port.LinkDownload, func(), error) { + d, release, err := r.bilibili.Download(ctx, id) + if err != nil { + return port.LinkDownload{}, nil, err + } + // 用 defer 是为了 panic 时也删除文件:River 会恢复任务中的 panic,worker 继续运行。 + done := false + defer func() { + if !done { + release() + } + }() + mp3, releaseMP3, err := r.transcoder.ToMP3(ctx, d.Audio) + if err != nil { + return port.LinkDownload{}, nil, err + } + done = true + return port.LinkDownload{Meta: d.Meta, Audio: mp3, Cover: d.Cover, CoverExt: d.CoverExt}, func() { + releaseMP3() + release() + }, nil +} + +func pageURL(t track.Track) string { + if t.Source == track.Bilibili { + return track.BilibiliPageURL(t.ID) + } + return t.ID +} + +// 对象键由曲目 ID 决定,重新准备时要先清除上一次留下的对象:这一次封面的格式可能不同,也可能没有封面。 +func (r *MediaRunner) clear(ctx context.Context, key string) error { + errs := []error{r.store.Delete(ctx, media.AudioObject(key))} + for ext := range coverTypes { + errs = append(errs, r.store.Delete(ctx, media.CoverObject(key, ext))) + } + return errors.Join(errs...) +} + +// 尽力删除已经上传的对象,失败只记 WARN:下一次准备同一首歌时还会再清理一遍。ctx 可能已经结束(任务被取消或超时), +// 所以使用脱离 ctx 的短期限。 +func (r *MediaRunner) discard(ctx context.Context, t track.Track, key string) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cleanupTimeout) + defer cancel() + if err := r.clear(ctx, key); err != nil { + r.logger.WarnContext(ctx, "删除没有准备成功的歌已上传的对象失败", "source", t.Source, "track_id", t.ID, "error", err) + } +} + +func httpLink(link string) bool { + u, err := url.Parse(link) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" +} + +func bilibiliID(id string) bool { + _, _, ok := track.ParseBilibiliID(id) + return ok +} + +func analyzeFile(ctx context.Context, logger *slog.Logger, t track.Track, audio port.LinkFile, fallback time.Duration) (track.Prepared, error) { + f, err := audio.Open() + if err != nil { + return track.Prepared{}, err + } + defer func() { _ = f.Close() }() + return analyze(ctx, logger, t, ctxReader{ctx: ctx, r: f}, fallback, "") +} + +// 解码不接收 ctx,只能靠这层包装在 ctx 结束后让读取失败;否则任务取消或超时之后,长文件的分析仍会继续。 +type ctxReader struct { + ctx context.Context + r io.Reader +} + +func (c ctxReader) Read(p []byte) (int, error) { + if err := c.ctx.Err(); err != nil { + return 0, err + } + return c.r.Read(p) +} + +func (r *MediaRunner) upload(ctx context.Context, key string, d port.LinkDownload) (int64, string, error) { + size, err := r.put(ctx, media.AudioObject(key), d.Audio, "audio/mpeg") + if err != nil { + return 0, "", err + } + contentType, ok := coverTypes[d.CoverExt] + if d.Cover == nil || !ok { + return size, "", nil + } + coverKey := media.CoverObject(key, d.CoverExt) + if _, err := r.put(ctx, coverKey, d.Cover, contentType); err != nil { + return 0, "", err + } + return size, coverKey, nil +} + +func (r *MediaRunner) put(ctx context.Context, key string, file port.LinkFile, contentType string) (int64, error) { + f, err := file.Open() + if err != nil { + return 0, err + } + defer func() { _ = f.Close() }() + size, err := f.Seek(0, io.SeekEnd) + if err != nil { + return 0, err + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return 0, err + } + if err := r.store.Put(ctx, key, f, contentType); err != nil { + return 0, err + } + return size, nil +} diff --git a/internal/app/prepare/prepare.go b/internal/app/prepare/prepare.go new file mode 100644 index 0000000..f2b5408 --- /dev/null +++ b/internal/app/prepare/prepare.go @@ -0,0 +1,158 @@ +package prepare + +import ( + "context" + "errors" + "log/slog" + "sync" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" + "github.com/dihemoe/alisten/internal/lib/logctx" +) + +const ( + // 准备结果的通知可能丢失,任务也可能在写入结果之后、River 记录完成之前停在 running,所以等待中的准备每隔这么久核对一次。 + checkInterval = 30 * time.Second + storeTimeout = 10 * time.Second +) + +type Preparer struct { + tracks port.Tracks + jobs port.PrepareJobs + logger *slog.Logger + + mu sync.Mutex + waiting map[track.Track]map[chan port.PrepareOutcome]struct{} +} + +var _ port.Preparer = (*Preparer)(nil) + +func New(tracks port.Tracks, jobs port.PrepareJobs, logger *slog.Logger) *Preparer { + return &Preparer{tracks: tracks, jobs: jobs, logger: logger, waiting: map[track.Track]map[chan port.PrepareOutcome]struct{}{}} +} + +// 一直等到这首歌准备好或任务最终失败。读取曲目表、提交或查询任务出错时不当作准备失败,等下一次核对时重试。 +func (p *Preparer) Prepare(ctx context.Context, t track.Track) (track.Meta, track.Prepared, error) { + outcomes := p.watch(t) + defer p.unwatch(t, outcomes) + check := time.NewTicker(checkInterval) + defer check.Stop() + var job int64 + for { + result, ok, readErr := p.prepared(ctx, t) + if ok { + return result.Meta, result.Result, nil + } + var reason string + if readErr == nil { + var again bool + if job, reason, again = p.follow(ctx, t, job); again { + continue + } + } + if reason == "" { + var err error + if reason, err = p.wait(ctx, job, outcomes, check.C); err != nil { + return track.Meta{}, track.Prepared{}, err + } + } + if reason != "" { + return track.Meta{}, track.Prepared{}, errors.New(reason) + } + } +} + +func (p *Preparer) Finished(o port.PrepareOutcome) { + p.mu.Lock() + defer p.mu.Unlock() + for ch := range p.waiting[o.Track] { + // 只保留最新的结果:较早的结果属于已经结束的任务。 + select { + case <-ch: + default: + } + ch <- o + } +} + +func (p *Preparer) prepared(ctx context.Context, t track.Track) (port.PreparedTrack, bool, error) { + ctx, cancel := context.WithTimeout(ctx, storeTimeout) + defer cancel() + result, ok, err := p.tracks.PreparedTrack(ctx, t) + if err != nil { + p.logger.WarnContext(ctx, "读取准备结果失败,稍后重试", "source", t.Source, "track_id", t.ID, "error", err) + } + return result, ok, err +} + +// 返回之后要跟进的任务,提交失败时为 0。任务已经完成时返回 again,由调用方重新读取曲目表:结果可能是任务在刚才读取曲目表之后才写入的, +// 若直接重新提交,链接曲目的任务会先删掉已存储的音频再重新下载。 +func (p *Preparer) follow(ctx context.Context, t track.Track, job int64) (int64, string, bool) { + if job != 0 { + state, reason, err := p.status(ctx, job) + switch { + case err != nil: + p.logger.WarnContext(logctx.WithJob(ctx, job), "核对准备任务失败", "source", t.Source, "track_id", t.ID, "error", err) + return job, "", false + case state == port.JobFailed: + return job, reason, false + case state == port.JobPending: + return job, "", false + } + return 0, "", true + } + ctx, cancel := context.WithTimeout(ctx, storeTimeout) + defer cancel() + job, err := p.jobs.Submit(ctx, t) + if err != nil { + p.logger.WarnContext(ctx, "提交准备任务失败,稍后重试", "source", t.Source, "track_id", t.ID, "error", err) + return 0, "", false + } + return job, "", false +} + +func (p *Preparer) wait(ctx context.Context, job int64, outcomes <-chan port.PrepareOutcome, check <-chan time.Time) (string, error) { + for { + select { + case o := <-outcomes: + if o.Reason == "" { + return "", nil + } + if o.JobID == job { + return o.Reason, nil + } + case <-check: + return "", nil + case <-ctx.Done(): + return "", ctx.Err() + } + } +} + +func (p *Preparer) status(ctx context.Context, job int64) (port.JobState, string, error) { + ctx, cancel := context.WithTimeout(ctx, storeTimeout) + defer cancel() + return p.jobs.Status(ctx, job) +} + +func (p *Preparer) watch(t track.Track) chan port.PrepareOutcome { + ch := make(chan port.PrepareOutcome, 1) + p.mu.Lock() + defer p.mu.Unlock() + if p.waiting[t] == nil { + p.waiting[t] = map[chan port.PrepareOutcome]struct{}{} + } + p.waiting[t][ch] = struct{}{} + return ch +} + +func (p *Preparer) unwatch(t track.Track, ch chan port.PrepareOutcome) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.waiting[t], ch) + if len(p.waiting[t]) == 0 { + delete(p.waiting, t) + } +} diff --git a/internal/app/prepare/run.go b/internal/app/prepare/run.go new file mode 100644 index 0000000..2bb783a --- /dev/null +++ b/internal/app/prepare/run.go @@ -0,0 +1,76 @@ +package prepare + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" + "github.com/dihemoe/alisten/internal/lib/loudness" +) + +type Runner struct { + sources map[track.Source]port.Source + tracks port.Tracks + logger *slog.Logger +} + +func NewRunner(sources map[track.Source]port.Source, tracks port.Tracks, logger *slog.Logger) *Runner { + return &Runner{sources: sources, tracks: tracks, logger: logger} +} + +func (r *Runner) Run(ctx context.Context, t track.Track) error { + source, ok := r.sources[t.Source] + if !ok || t.ID == "" { + return &port.RejectedError{Err: fmt.Errorf("不能准备来源为 %q、ID 为 %q 的歌", t.Source, t.ID)} + } + resolved, err := source.Resolve(ctx, t.ID) + if err != nil { + return err + } + result, err := analyze(ctx, r.logger, t, resolved.Audio, resolved.Meta.Duration, resolved.Lyric) + _ = resolved.Audio.Close() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, storeTimeout) + defer cancel() + return r.tracks.SavePreparedTrack(ctx, t, port.PreparedTrack{Meta: resolved.Meta, Result: result}) +} + +func analyze(ctx context.Context, logger *slog.Logger, t track.Track, audio io.Reader, fallback time.Duration, lyric string) (track.Prepared, error) { + src := &download{r: audio} + result, err := loudness.AnalyzeReader(src) + if src.err != nil { + return track.Prepared{}, fmt.Errorf("下载音频:%w", src.err) + } + prepared := track.Prepared{Duration: fallback, Gain: 1, Lyric: lyric} + if err == nil { + prepared.Duration, prepared.Gain = result.Duration, result.Coefficient() + } else { + logger.WarnContext(ctx, "音频无法解码,增益取 1、时长取元数据", "source", t.Source, "track_id", t.ID, "error", err) + } + // 时长为 0 的歌会在开播时立刻结束。 + if prepared.Duration <= 0 { + return track.Prepared{}, &port.RejectedError{Err: errors.New("这首歌的音频无法播放")} + } + return prepared, nil +} + +// 记下读取音频时的错误,以区分下载中断和音频本身无法解码。 +type download struct { + r io.Reader + err error +} + +func (d *download) Read(p []byte) (int, error) { + n, err := d.r.Read(p) + if err != nil && !errors.Is(err, io.EOF) { + d.err = err + } + return n, err +} diff --git a/internal/app/realtime/gateway.go b/internal/app/realtime/gateway.go new file mode 100644 index 0000000..1f8cf93 --- /dev/null +++ b/internal/app/realtime/gateway.go @@ -0,0 +1,765 @@ +package realtime + +import ( + "context" + "errors" + "fmt" + "log/slog" + "maps" + "slices" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/dihemoe/alisten/internal/app/catalog" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/room" + "github.com/dihemoe/alisten/internal/domain/track" + "github.com/dihemoe/alisten/internal/lib/logctx" +) + +const ( + ownerReplyTimeout = 10 * time.Second + unsubscribeDelay = 60 * time.Second + retryInterval = 5 * time.Second + aliveTTL = 15 * time.Second + heartbeatInterval = 5 * time.Second + presenceRetryMin = time.Second + presenceRetryMax = 10 * time.Second +) + +type Gateway struct { + self string + host *Host + catalog *catalog.Service + leases port.Leases + liveness port.Liveness + bus port.Bus + logger *slog.Logger + ids atomic.Uint64 + + mu sync.Mutex + closing bool + pending map[room.RequestID]*pendingRequest + sessions map[string]map[*Session]struct{} + // 每个房间的事件流按打开的顺序排列。 + observers map[string][]*Observer + links map[string]*roomLink + // 订阅连接每断开一次就加 1:新连接是否已经订阅了这些频道,要等确认才知道,所以之前确认过的房间订阅要重新确认。 + epoch uint64 + active sync.WaitGroup + stopped chan struct{} +} + +// RequestError 是网关为房间请求生成 RequestID 之后,该请求失败时返回的错误。owner 处理这个请求时的日志带有同一个 ID, +// 调用方记录失败时带上它,网关和 owner 两侧的日志就能对应起来。 +type RequestError struct { + ID room.RequestID + Err error +} + +func (e *RequestError) Error() string { return e.Err.Error() } +func (e *RequestError) Unwrap() error { return e.Err } + +func failed(id room.RequestID, err error) error { + if err == nil { + return nil + } + return &RequestError{ID: id, Err: err} +} + +type pendingRequest struct { + // 由从 pending 中取走请求的一方调用一次,然后关闭 done;在接收消息的 goroutine 上调用时不能阻塞。 + deliver func(room.Result, error) + done chan struct{} +} + +type roomLink struct { + users int + lastHTTP time.Time + timer *time.Timer + leaving chan struct{} + confirmed uint64 +} + +func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, music *catalog.Service, preparer port.Preparer, cluster Cluster, logger *slog.Logger) (*Host, *Gateway) { + h := newHost(snapshots, directory, preparer, music, cluster, logger) + g := &Gateway{ + self: cluster.Instance, + host: h, + catalog: music, + leases: cluster.Leases, + liveness: cluster.Liveness, + bus: cluster.Bus, + logger: logger, + stopped: make(chan struct{}), + pending: map[room.RequestID]*pendingRequest{}, + sessions: map[string]map[*Session]struct{}{}, + observers: map[string][]*Observer{}, + links: map[string]*roomLink{}, + epoch: 1, + } + return h, g +} + +// 会话结束时必须调用 Leave。 +func (g *Gateway) Join(ctx context.Context, roomID string, m member.Member) (*Session, error) { + link, err := g.hold(ctx, roomID) + if err != nil { + return nil, err + } + s := newSession(room.SessionID(g.nextID("s")), roomID, m) + s.link = link + if !g.register(s) { + g.unhold(roomID, link, false) + return nil, ErrUnavailable + } + if err := g.route(ctx, roomID, envelope{input: room.Join{Session: s.id, Member: m}}, true); err != nil { + g.Leave(s) + return nil, err + } + // Join 没有回复,用紧接着的 Sync 回复中的快照作为握手的结果。 + id := g.newRequestID() + g.request(ctx, roomID, id, envelope{input: room.Command{RequestID: id, Member: m, Action: room.Sync{}}}, false, func(res room.Result, e error) { + if err = e; err == nil { + s.welcome(res.(room.SyncResult).View) + } + }) + if err != nil { + g.Leave(s) + return nil, failed(id, err) + } + return s, nil +} + +// 可以重复调用。 +func (g *Gateway) Leave(s *Session) { + g.mu.Lock() + set, ok := g.sessions[s.room] + if _, joined := set[s]; !ok || !joined { + g.mu.Unlock() + return + } + delete(set, s) + if len(set) == 0 { + delete(g.sessions, s.room) + } + g.mu.Unlock() + s.left.Store(true) + // 第一次发送在调用方的 goroutine 上进行:Shutdown 会等会话 Leave,它返回时 Leave 已经交给 owner;发送失败的才交给后台重试。 + if leave := leaveOf(s); !g.sendPresence(s.room, false, leave) { + go g.retryPresence(s.room, false, leave) + } + g.unhold(s.room, s.link, false) + g.active.Done() +} + +// 回复和房间的事件按房间产生的顺序放进会话的发送队列。 +// 调用之前要先用 s.Begin 占用名额。 +func (g *Gateway) Command(ctx context.Context, s *Session, tag any, a room.Action) error { + id := g.newRequestID() + var err error + g.request(ctx, s.room, id, envelope{input: room.Command{RequestID: id, Member: s.Member(), Action: a}}, false, func(res room.Result, e error) { + err = e + s.reply(tag, res, e) + }) + return failed(id, err) +} + +func (g *Gateway) Do(ctx context.Context, roomID string, m member.Member, a room.Action) (room.Result, error) { + id := g.newRequestID() + var res room.Result + var err error + g.request(ctx, roomID, id, envelope{input: room.Command{RequestID: id, Member: m, Action: a}}, true, func(r room.Result, e error) { + res, err = r, e + }) + return res, failed(id, err) +} + +// 参数为 nil 表示保持不变;房间落盘之后才返回。 +func (g *Gateway) UpdateInfo(ctx context.Context, roomID string, name, description *string) error { + id := g.newRequestID() + var err error + g.request(ctx, roomID, id, envelope{patch: &port.InfoPatch{RequestID: id, Name: name, Description: description}}, true, func(_ room.Result, e error) { + err = e + }) + return failed(id, err) +} + +// 房间删除数据之后,各实例上这个房间的会话和事件流以 CloseRoomGone 关闭。 +func (g *Gateway) Delete(ctx context.Context, roomID string) error { + id := g.newRequestID() + var err error + g.request(ctx, roomID, id, envelope{input: room.Delete{RequestID: id}}, true, func(_ room.Result, e error) { + err = e + }) + return failed(id, err) +} + +// Revoke 在房间成员的票据作废之后通知各实例,以 CloseRevoked 关闭这个房间的会话和成员打开的事件流。 +func (g *Gateway) Revoke(ctx context.Context, roomID string) error { + return g.announce(ctx, roomID, port.TicketsRevoked{}) +} + +// RevokeAPIKey 在常驻房间的 API Key 重置之后通知各实例,以 CloseRevoked 关闭用 Key 打开的事件流。 +func (g *Gateway) RevokeAPIKey(ctx context.Context, roomID string) error { + return g.announce(ctx, roomID, port.APIKeyReset{}) +} + +func (g *Gateway) announce(ctx context.Context, roomID string, m port.RoomMessage) error { + ctx, cancel := context.WithTimeout(ctx, clusterTimeout) + defer cancel() + if err := g.bus.PublishRoom(ctx, roomID, m); err != nil { + return unavailable(err) + } + return nil +} + +func (g *Gateway) MemberUpdated(ctx context.Context, m member.Member) error { + ctx, cancel := context.WithTimeout(ctx, clusterTimeout) + defer cancel() + if err := g.bus.PublishMember(ctx, m); err != nil { + return unavailable(err) + } + return nil +} + +// 广播失败时只记录日志:等待的一方会定期按任务 ID 核对。 +func (g *Gateway) PublishPrepared(o port.PrepareOutcome) { + ctx, cancel := context.WithTimeout(logctx.WithJob(context.Background(), o.JobID), clusterTimeout) + defer cancel() + if err := g.bus.PublishPrepared(ctx, o); err != nil { + g.logger.WarnContext(ctx, "广播准备结果失败", "source", o.Track.Source, "track_id", o.Track.ID, "error", err) + } +} + +func (g *Gateway) Shutdown(ctx context.Context) error { + g.mu.Lock() + if !g.closing { + g.closing = true + close(g.stopped) + } + g.mu.Unlock() + g.closeAll(CloseRestart) + done := make(chan struct{}) + go func() { + g.active.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return fmt.Errorf("等待会话关闭:%w", ctx.Err()) + } +} + +func (g *Gateway) Sessions() int { + g.mu.Lock() + defer g.mu.Unlock() + n := 0 + for _, set := range g.sessions { + n += len(set) + } + return n +} + +var ErrRequesterNotAllowed = errors.New("requester:只有用常驻房间的 API Key 点歌时才能填写") + +type Pick struct { + Track track.Track + Source track.Source + Keyword string + // 点歌人的名字,由 bot 代填。 + Requester string +} + +// by 是发出请求的成员,bot 表示他是常驻房间的 bot 成员。返回的成员是条目的点歌人,房间命令以他的名义发出。 +func (g *Gateway) Resolve(ctx context.Context, by member.Member, bot bool, p Pick) (room.Enqueue, member.Member, error) { + if p.Requester != "" { + if !bot { + return room.Enqueue{}, member.Member{}, ErrRequesterNotAllowed + } + by = member.Member{ID: by.ID, Name: p.Requester + "(bot)"} + } + switch { + case p.Keyword != "": + t, m, err := g.catalog.FirstMatch(ctx, p.Source, p.Keyword) + return room.Enqueue{Track: t, Meta: m}, by, err + case p.Track.Source == track.Link: + t, m, err := g.catalog.ResolveLink(ctx, p.Track.ID) + return room.Enqueue{Track: t, Meta: m}, by, err + default: + m, err := g.catalog.TrackMeta(ctx, p.Track) + return room.Enqueue{Track: p.Track, Meta: m}, by, err + } +} + +// deliver 恰好被调用一次:收到回复时在接收消息的 goroutine 上调用,失败时在这里调用;返回时 deliver 已经调用过。 +// 网关先确认订阅了房间频道再发出输入,因为回复和事件都经由房间频道到达。 +func (g *Gateway) request(ctx context.Context, roomID string, id room.RequestID, e envelope, http bool, deliver func(room.Result, error)) { + p := &pendingRequest{deliver: deliver, done: make(chan struct{})} + g.mu.Lock() + g.pending[id] = p + g.mu.Unlock() + ctx, cancel := context.WithTimeoutCause(ctx, ownerReplyTimeout, ErrUnavailable) + defer cancel() + link, err := g.hold(ctx, roomID) + if err == nil { + defer g.unhold(roomID, link, http) + err = g.route(ctx, roomID, e, true) + } + if err != nil { + g.settle(id, nil, err) + } else { + select { + case <-p.done: + case <-ctx.Done(): + g.settle(id, nil, context.Cause(ctx)) + } + } + <-p.done +} + +func (g *Gateway) route(ctx context.Context, roomID string, e envelope, takeover bool) error { + for retried := false; ; retried = true { + ownersCtx, cancel := context.WithTimeout(ctx, clusterTimeout) + owners, err := g.leases.Owners(ownersCtx, []string{roomID}) + cancel() + if err != nil { + return unavailable(err) + } + switch owner := owners[0]; { + case owner == "" && !takeover: + return nil + case owner == "" || owner == g.self: + err := g.host.send(ctx, roomID, e, takeover) + // 读到没有 owner 之后、本实例接管之前,其他实例先取得了租约。 + if errors.Is(err, errOwnedElsewhere) && !retried { + continue + } + return err + default: + sendCtx, cancel := context.WithTimeout(ctx, clusterTimeout) + delivered, err := g.bus.SendInput(sendCtx, owner, port.RoomInput{Room: roomID, Gateway: g.self, Input: e.input, Patch: e.patch, Takeover: takeover}) + cancel() + if err != nil { + return unavailable(err) + } + // owner 所在的实例没有订阅自己的频道:它已经停止,但租约还没有过期。 + if !delivered { + return ErrUnavailable + } + return nil + } + } +} + +func unavailable(err error) error { + return fmt.Errorf("%w:%w", ErrUnavailable, err) +} + +// 回复、超时和房间不再处理请求可能同时发生,先从 pending 中取走请求的一方负责送达。 +func (g *Gateway) settle(id room.RequestID, res room.Result, err error) { + g.mu.Lock() + p, ok := g.pending[id] + delete(g.pending, id) + g.mu.Unlock() + if ok { + p.deliver(res, err) + close(p.done) + } +} + +// 在接收消息的 goroutine 上按房间频道上的顺序调用,不能阻塞。 +func (g *Gateway) deliver(roomID string, m port.RoomMessage) { + switch m := m.(type) { + case port.OwnerChanged: + g.rejoin(roomID, false) + for _, o := range g.roomObservers(roomID) { + if o.ownerChanged(m.Term) { + go g.resync(o) + } + } + case port.OwnerReleased: + // 房间暂时没有 owner,而这里还有它的会话,所以立即接管。 + g.rejoin(roomID, true) + case port.Emitted: + for _, q := range g.recipients(roomID) { + q.event(m.Emit) + } + case port.Replied: + var err error + if m.Reply.Err != nil { + err = m.Reply.Err + } + g.settle(m.Reply.RequestID, m.Reply.Result, err) + case port.Rejected: + err := ErrUnavailable + if m.NotFound { + err = port.ErrRoomNotFound + } + g.settle(m.RequestID, nil, err) + case port.RoomClosed: + for _, q := range g.recipients(roomID) { + q.close(CloseRoomGone) + } + case port.TicketsRevoked: + for _, s := range g.roomSessions(roomID) { + s.close(CloseRevoked) + } + for _, o := range g.roomObservers(roomID) { + if !o.bot { + o.close(CloseRevoked) + } + } + case port.APIKeyReset: + for _, o := range g.roomObservers(roomID) { + if o.bot { + o.close(CloseRevoked) + } + } + } +} + +// 房间的新一次运行从空的在线列表开始,本地会话重新加入,在线列表由此重建。不能在接收消息的 goroutine 上等待房间收下输入。 +func (g *Gateway) rejoin(roomID string, takeover bool) { + sessions := g.roomSessions(roomID) + if len(sessions) == 0 { + return + } + go func() { + for _, s := range sessions { + g.deliverPresence(roomID, takeover, joinOf(s)) + // 会话在重新加入的途中离开时,它的 Leave 可能排在这次 Join 之前,所以补发一次 Leave,以免它留在在线列表中。 + if s.left.Load() { + g.deliverPresence(roomID, false, leaveOf(s)) + } + } + }() +} + +// 在接收消息的 goroutine 上调用,不能阻塞。 +func (g *Gateway) memberChanged(m member.Member) { + g.mu.Lock() + var rooms []string + for roomID, set := range g.sessions { + updated := false + for s := range set { + if s.update(m) { + updated = true + } + } + if updated { + rooms = append(rooms, roomID) + } + } + g.mu.Unlock() + if len(rooms) == 0 { + return + } + go func() { + for _, roomID := range rooms { + g.deliverPresence(roomID, false, g.memberUpdateOf(roomID, m.ID)) + } + }() +} + +// owner 靠 Join、Leave 和 MemberUpdated 知道谁在线,这几条消息丢失后没有别的办法补回:离开的会话会一直留在在线列表中, +// 临时房间也因此无法关闭;仍然在线的会话则不在列表中。所以送不到 owner 时要一直重试,直到送达。 +// next 在每次尝试前调用,返回这一次要发的输入;返回 false 表示这条消息已经不必再发。 +func (g *Gateway) sendPresence(roomID string, takeover bool, next func() (room.Input, bool)) (done bool) { + in, ok := next() + return !ok || g.route(context.Background(), roomID, envelope{input: in}, takeover) == nil +} + +// 网关停止之后不再重试:进程即将退出,本实例的存活键过期之后,owner 会让这里的会话离开。 +func (g *Gateway) retryPresence(roomID string, takeover bool, next func() (room.Input, bool)) { + for delay := presenceRetryMin; ; delay = min(2*delay, presenceRetryMax) { + select { + case <-g.stopped: + return + case <-time.After(delay): + } + if g.sendPresence(roomID, takeover, next) { + return + } + } +} + +func (g *Gateway) deliverPresence(roomID string, takeover bool, next func() (room.Input, bool)) { + if !g.sendPresence(roomID, takeover, next) { + g.retryPresence(roomID, takeover, next) + } +} + +func joinOf(s *Session) func() (room.Input, bool) { + return func() (room.Input, bool) { + if s.ended() { + return nil, false + } + return room.Join{Session: s.id, Member: s.Member()}, true + } +} + +func leaveOf(s *Session) func() (room.Input, bool) { + return func() (room.Input, bool) { return room.Leave{Session: s.id}, true } +} + +// 每次都发送这个成员在本地会话记录中的最新资料,而不是触发这次发送的那一份:否则资料再次修改之后, +// 先前那次发送的重试如果晚到,会把旧资料写回房间。 +func (g *Gateway) memberUpdateOf(roomID, memberID string) func() (room.Input, bool) { + return func() (room.Input, bool) { + g.mu.Lock() + defer g.mu.Unlock() + for s := range g.sessions[roomID] { + if m := s.Member(); m.ID == memberID { + return room.MemberUpdated{Member: m}, true + } + } + return nil, false + } +} + +// 订阅连接断开过:会话和事件流可能漏掉了房间的事件,等待中的请求可能收不到回复。会话和事件流以 1012 断开, +// 客户端重连之后以新快照为准。 +func (g *Gateway) restart() { + g.mu.Lock() + g.epoch++ + g.mu.Unlock() + g.closeAll(CloseRestart) + g.mu.Lock() + var ids []room.RequestID + for id := range g.pending { + ids = append(ids, id) + } + g.mu.Unlock() + for _, id := range ids { + g.settle(id, nil, ErrUnavailable) + } +} + +func (g *Gateway) closeAll(reason CloseReason) { + g.mu.Lock() + var all []*sendQueue + for _, set := range g.sessions { + for s := range set { + all = append(all, s.sendQueue) + } + } + for _, set := range g.observers { + for _, o := range set { + all = append(all, o.sendQueue) + } + } + g.mu.Unlock() + for _, q := range all { + q.close(reason) + } +} + +// 订阅本实例的频道并刷新存活键:此后其他实例的网关才能把输入转发到这里,owner 也不会认为本实例的网关已经退出。 +func (g *Gateway) open(ctx context.Context) error { + for { + attemptCtx, cancel := context.WithTimeout(ctx, clusterTimeout) + err := g.bus.SubscribeInstance(attemptCtx) + if err == nil { + _, err = g.liveness.Heartbeat(attemptCtx, aliveTTL) + } + cancel() + if err == nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + g.logger.WarnContext(ctx, "订阅本实例的频道失败,稍后重试", "error", err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryInterval): + } + } +} + +func (g *Gateway) heartbeat(ctx context.Context) { + ticker := time.NewTicker(heartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + beatCtx, cancel := context.WithTimeout(ctx, clusterTimeout) + alive, err := g.liveness.Heartbeat(beatCtx, aliveTTL) + cancel() + // 存活键曾经过期(例如 Redis 被清空),owner 可能已经让本地会话离开了在线列表。 + if err == nil && !alive { + for _, roomID := range g.joinedRooms() { + g.rejoin(roomID, false) + } + } + } +} + +func (g *Gateway) joinedRooms() []string { + g.mu.Lock() + defer g.mu.Unlock() + return slices.Collect(maps.Keys(g.sessions)) +} + +// 订阅连接断开时只通知 lost,不在这里处理:恢复订阅要等这里收到确认。 +func (g *Gateway) receive(ctx context.Context, lost chan<- struct{}) { + for { + d, err := g.bus.Receive(ctx) + if err != nil { + return + } + switch d := d.(type) { + case port.RoomDelivery: + g.deliver(d.Room, d.Message) + case port.InputDelivery: + g.host.deliver(d.Context, d.Input) + case port.MemberDelivery: + g.memberChanged(d.Member) + case port.PreparedDelivery: + g.host.preparer.Finished(d.Outcome) + case port.SubscriptionLost: + select { + case lost <- struct{}{}: + default: + } + } + } +} + +// 返回时订阅已经生效。确认过的订阅不再重复订阅和等待确认,Redis 不可用时请求也不必等待。 +// 用完之后调用 unhold。 +func (g *Gateway) hold(ctx context.Context, roomID string) (*roomLink, error) { + for { + g.mu.Lock() + link := g.links[roomID] + if link != nil && link.leaving != nil { + leaving := link.leaving + g.mu.Unlock() + select { + case <-leaving: + continue + case <-ctx.Done(): + return nil, context.Cause(ctx) + } + } + if link == nil { + link = &roomLink{} + g.links[roomID] = link + } + link.users++ + if link.timer != nil { + link.timer.Stop() + link.timer = nil + } + epoch := g.epoch + confirmed := link.confirmed == epoch + g.mu.Unlock() + if confirmed { + return link, nil + } + subscribeCtx, cancel := context.WithTimeout(ctx, clusterTimeout) + err := g.bus.SubscribeRoom(subscribeCtx, roomID) + cancel() + if err != nil { + g.unhold(roomID, link, false) + return nil, unavailable(err) + } + g.mu.Lock() + link.confirmed = max(link.confirmed, epoch) + g.mu.Unlock() + return link, nil + } +} + +func (g *Gateway) unhold(roomID string, link *roomLink, http bool) { + g.mu.Lock() + defer g.mu.Unlock() + link.users-- + if http { + link.lastHTTP = time.Now() + } + if link.users == 0 { + link.timer = time.AfterFunc(time.Until(link.lastHTTP.Add(unsubscribeDelay)), func() { g.unsubscribe(roomID, link) }) + } +} + +func (g *Gateway) unsubscribe(roomID string, link *roomLink) { + g.mu.Lock() + if g.links[roomID] != link || link.users > 0 || link.leaving != nil { + g.mu.Unlock() + return + } + link.leaving = make(chan struct{}) + g.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), clusterTimeout) + _ = g.bus.UnsubscribeRoom(ctx, roomID) + cancel() + g.mu.Lock() + delete(g.links, roomID) + close(link.leaving) + g.mu.Unlock() +} + +func (g *Gateway) register(s *Session) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.closing { + return false + } + set := g.sessions[s.room] + if set == nil { + set = map[*Session]struct{}{} + g.sessions[s.room] = set + } + set[s] = struct{}{} + g.active.Add(1) + return true +} + +func (g *Gateway) roomSessions(roomID string) []*Session { + g.mu.Lock() + defer g.mu.Unlock() + sessions := make([]*Session, 0, len(g.sessions[roomID])) + for s := range g.sessions[roomID] { + sessions = append(sessions, s) + } + return sessions +} + +func (g *Gateway) roomObservers(roomID string) []*Observer { + g.mu.Lock() + defer g.mu.Unlock() + return slices.Clone(g.observers[roomID]) +} + +// 会话和事件流都收到房间的事件和关闭通知。 +func (g *Gateway) recipients(roomID string) []*sendQueue { + g.mu.Lock() + defer g.mu.Unlock() + var all []*sendQueue + for s := range g.sessions[roomID] { + all = append(all, s.sendQueue) + } + for _, o := range g.observers[roomID] { + all = append(all, o.sendQueue) + } + return all +} + +func (g *Gateway) newRequestID() room.RequestID { + return room.RequestID(g.nextID("r")) +} + +func (g *Gateway) nextID(prefix string) string { + return g.self + "/" + prefix + strconv.FormatUint(g.ids.Add(1), 10) +} diff --git a/internal/app/realtime/host.go b/internal/app/realtime/host.go new file mode 100644 index 0000000..05f9b17 --- /dev/null +++ b/internal/app/realtime/host.go @@ -0,0 +1,808 @@ +package realtime + +import ( + "context" + "errors" + "fmt" + "log/slog" + "maps" + "math/rand/v2" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/dihemoe/alisten/internal/app/catalog" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/room" + "github.com/dihemoe/alisten/internal/domain/track" + "github.com/dihemoe/alisten/internal/lib/logctx" +) + +const ( + storeTimeout = 10 * time.Second + reconcileInterval = 5 * time.Second + reconcileJitter = time.Second + leaseTTL = 15 * time.Second + renewInterval = 5 * time.Second + // 距上次续约成功超过这个时长就停止处理房间的输入。它早于租约过期,时钟正常时同一时刻只有一个 owner 在处理房间的输入。 + ownerDeadline = 10 * time.Second + // 租约、存活键和实例间消息单次调用的期限,不能超过续约的间隔。 + clusterTimeout = 5 * time.Second + takeoverLogRepeat = time.Minute +) + +var ( + ErrUnavailable = errors.New("房间暂时不可用,请稍后再试。") + errOwnedElsewhere = fmt.Errorf("房间由其他实例运行:%w", ErrUnavailable) +) + +// Leases、Liveness 和 Bus 都要用 Instance 这个实例 ID 构造。 +type Cluster struct { + Instance string + Leases port.Leases + Liveness port.Liveness + Bus port.Bus +} + +type Host struct { + self string + snapshots port.RoomSnapshots + directory port.RoomDirectory + preparer port.Preparer + catalog *catalog.Service + leases port.Leases + liveness port.Liveness + bus port.Bus + logger *slog.Logger + + owned atomic.Int64 + leasesLost atomic.Uint64 + // 上一条接管房间失败的 WARN 的时刻,接管成功之后清空。存储不可用时,每次对账都会为每个没有 owner 的房间尝试接管,且每次都失败: + // takeoverLogRepeat 内只记一条 WARN,其余记 DEBUG,日志量不随房间数和对账次数增长。 + takeoverWarned atomic.Pointer[time.Time] + + mu sync.Mutex + state hostState + running map[string]*hostedRoom + forwarding map[string]*[]forwardedInput + work sync.WaitGroup +} + +type forwardedInput struct { + port.RoomInput + ctx context.Context + arrived time.Time +} + +type hostState uint8 + +const ( + idle hostState = iota + accepting + stopped +) + +type envelope struct { + input room.Input + patch *port.InfoPatch + gateway string + ctx context.Context +} + +// 房间的一次运行。每次接管都新建一个,旧运行的计时器和推荐结果因此不会落到新的 Room 上。 +type hostedRoom struct { + id string + inbox chan envelope + started chan struct{} + done chan struct{} + // 接管失败的原因,在 done 关闭之前写入。 + err error + ctx context.Context + cancel context.CancelFunc + // 上次续约成功的时刻加上 ownerDeadline,Unix 纳秒。 + deadline atomic.Int64 + // 租约已经不属于本实例,或者无法确认还属于本实例:不再执行任何 effect,也不释放租约。 + lost atomic.Bool + releasing atomic.Bool + + mu sync.Mutex + // 会话所在的网关只是运行时的记录,不进入领域状态。 + gateways map[room.SessionID]string + + // 以下字段只由房间的 goroutine 访问。 + acquired bool + stopKeeping context.CancelFunc + room *room.Room + term uint32 + info room.Info + closed bool + timers map[room.Token]*time.Timer +} + +func newHost(snapshots port.RoomSnapshots, directory port.RoomDirectory, preparer port.Preparer, music *catalog.Service, cluster Cluster, logger *slog.Logger) *Host { + return &Host{ + self: cluster.Instance, + snapshots: snapshots, + directory: directory, + preparer: preparer, + catalog: music, + leases: cluster.Leases, + liveness: cluster.Liveness, + bus: cluster.Bus, + logger: logger, + running: map[string]*hostedRoom{}, + forwarding: map[string]*[]forwardedInput{}, + } +} + +// Start 之后才接管房间:数据库迁移到二进制要求的版本、并且订阅了本实例的频道之前,房间不能运行。 +func (h *Host) Start() { + h.mu.Lock() + defer h.mu.Unlock() + if h.state == idle { + h.state = accepting + } +} + +// 释放全部房间,在下一次 Start 之前不再接管房间:订阅连接断开过,发给这些房间的输入可能已经丢失。 +func (h *Host) pause() { + h.mu.Lock() + defer h.mu.Unlock() + if h.state == accepting { + h.state = idle + } + for _, r := range h.running { + r.release() + } +} + +func (h *Host) Shutdown(ctx context.Context) error { + h.mu.Lock() + h.state = stopped + for _, r := range h.running { + r.release() + } + h.mu.Unlock() + done := make(chan struct{}) + go func() { + h.work.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return fmt.Errorf("等待房间停止:%w", ctx.Err()) + } +} + +func (h *Host) RoomsOwned() int { + return int(h.owned.Load()) +} + +func (h *Host) LeasesLost() uint64 { + return h.leasesLost.Load() +} + +// 每个未关闭的房间都要有 owner:常驻房间没有会话时时间线照常推进,临时房间无人 5 分钟后关闭。 +func (h *Host) Reconcile(ctx context.Context) { + for { + h.checkGateways(ctx) + h.reconcile(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(reconcileInterval + rand.N(reconcileJitter)): + } + } +} + +func (h *Host) reconcile(ctx context.Context) { + listCtx, cancel := context.WithTimeout(ctx, storeTimeout) + rooms, err := h.directory.ListRooms(listCtx) + cancel() + if err != nil { + if ctx.Err() == nil { + h.logger.WarnContext(ctx, "对账时列出房间失败", "error", err) + } + return + } + h.mu.Lock() + var others []string + for _, r := range rooms { + if _, ok := h.running[r.Info.ID]; !ok { + others = append(others, r.Info.ID) + } + } + h.mu.Unlock() + if len(others) == 0 { + return + } + ownersCtx, cancel := context.WithTimeout(ctx, clusterTimeout) + owners, err := h.leases.Owners(ownersCtx, others) + cancel() + if err != nil { + if ctx.Err() == nil { + h.logger.WarnContext(ctx, "对账时读取房间的 owner 失败", "error", err) + } + return + } + for i, id := range others { + if owners[i] == "" { + h.ensure(id) + } + } +} + +type roomSession struct { + room string + session room.SessionID +} + +func (h *Host) checkGateways(ctx context.Context) { + h.mu.Lock() + runs := slices.Collect(maps.Values(h.running)) + h.mu.Unlock() + byGateway := map[string][]roomSession{} + for _, r := range runs { + r.mu.Lock() + for s, g := range r.gateways { + if g != h.self { + byGateway[g] = append(byGateway[g], roomSession{room: r.id, session: s}) + } + } + r.mu.Unlock() + } + if len(byGateway) == 0 { + return + } + checkCtx, cancel := context.WithTimeout(ctx, clusterTimeout) + expired, err := h.liveness.Expired(checkCtx, slices.Sorted(maps.Keys(byGateway))) + cancel() + if err != nil { + if ctx.Err() == nil { + h.logger.WarnContext(ctx, "检查网关是否存活失败", "error", err) + } + return + } + for _, g := range expired { + // 网关可能刚刚恢复了存活键,正在为会话重新 Join。Leave 排在重新加入之后时,会话会一直不在在线列表中, + // 所以发 Leave 之前再查一次,缩小这个窗口。窗口无法消除:复查返回之后网关仍可能恢复。 + recheckCtx, cancel := context.WithTimeout(ctx, clusterTimeout) + still, err := h.liveness.Expired(recheckCtx, []string{g}) + cancel() + if err != nil || len(still) == 0 { + continue + } + for _, s := range byGateway[g] { + // 房间繁忙而没有收下的 Leave,下一次对账再发。 + sendCtx, cancel := context.WithTimeout(ctx, storeTimeout) + _ = h.send(sendCtx, s.room, envelope{input: room.Leave{Session: s.session}}, false) + cancel() + } + } +} + +func (h *Host) ensure(id string) *hostedRoom { + h.mu.Lock() + defer h.mu.Unlock() + if r, ok := h.running[id]; ok { + return r + } + if h.state != accepting { + return nil + } + ctx, cancel := context.WithCancel(logctx.WithRoom(context.Background(), id)) + r := &hostedRoom{ + id: id, + inbox: make(chan envelope), + started: make(chan struct{}), + done: make(chan struct{}), + ctx: ctx, + cancel: cancel, + gateways: map[room.SessionID]string{}, + timers: map[room.Token]*time.Timer{}, + } + h.running[id] = r + h.work.Go(func() { h.run(r) }) + return r +} + +// takeover 为假时,房间不在本实例运行就丢弃输入:重新接管后的 Room +// 从落盘的快照恢复,在线列表由网关重新加入会话来重建。 +func (h *Host) send(ctx context.Context, roomID string, e envelope, takeover bool) error { + // 调用方已经放弃的输入不再交给房间:下面两个 select 的多个分支同时就绪时,Go 随机选择其一,仅靠过期的 ctx 无法阻止输入被交出。 + if ctx.Err() != nil { + return context.Cause(ctx) + } + var r *hostedRoom + if takeover { + if r = h.ensure(roomID); r == nil { + return ErrUnavailable + } + } else { + h.mu.Lock() + r = h.running[roomID] + h.mu.Unlock() + if r == nil { + return nil + } + } + select { + case <-r.started: + case <-r.done: + if r.err != nil { + return r.err + } + return ErrUnavailable + case <-ctx.Done(): + return context.Cause(ctx) + } + e.ctx = ctx + select { + case r.inbox <- e: + return nil + case <-r.ctx.Done(): + return ErrUnavailable + case <-ctx.Done(): + return context.Cause(ctx) + } +} + +// 在接收消息的 goroutine 上调用,不能等房间收下输入: +// 同一房间的输入按到达的顺序排队,由这个房间的转发 goroutine 依次交给房间。 +func (h *Host) deliver(ctx context.Context, in port.RoomInput) { + h.mu.Lock() + defer h.mu.Unlock() + if h.state == stopped { + // Shutdown 已经在等待 work,不能再向其中添加 goroutine。 + go h.reject(ctx, in.Room, (envelope{input: in.Input, patch: in.Patch}).requestID(), ErrUnavailable) + return + } + item := forwardedInput{RoomInput: in, ctx: ctx, arrived: time.Now()} + if queue, ok := h.forwarding[in.Room]; ok { + *queue = append(*queue, item) + return + } + queue := &[]forwardedInput{item} + h.forwarding[in.Room] = queue + h.work.Go(func() { h.forward(in.Room, queue) }) +} + +func (h *Host) forward(roomID string, queue *[]forwardedInput) { + for { + h.mu.Lock() + if len(*queue) == 0 { + delete(h.forwarding, roomID) + h.mu.Unlock() + return + } + in := (*queue)[0] + *queue = (*queue)[1:] + h.mu.Unlock() + e := envelope{input: in.Input, patch: in.Patch, gateway: in.Gateway} + ctx, cancel := forwardContext(in.ctx, e.requestID(), in.arrived) + if err := h.send(ctx, roomID, e, in.Takeover); err != nil { + h.reject(ctx, roomID, e.requestID(), err) + } + cancel() + } +} + +// 网关最多等待回复 ownerReplyTimeout,超过这个时间的请求不必再交给房间。期限从请求到达本实例时算起: +// 排在忙碌房间后面的请求不能因为轮到得晚而多得一份期限。会话的输入不设期限,与本实例的网关一致。 +func forwardContext(received context.Context, id room.RequestID, arrived time.Time) (context.Context, context.CancelFunc) { + if id == "" { + return context.WithCancel(received) + } + return context.WithDeadline(received, arrived.Add(ownerReplyTimeout)) +} + +// 让房间没有处理的请求立即失败,发出请求的网关不必等到超时。请求的 ctx 可能已经结束。 +func (h *Host) reject(ctx context.Context, roomID string, id room.RequestID, err error) { + if id == "" { + return + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), clusterTimeout) + defer cancel() + _ = h.bus.PublishRoom(ctx, roomID, port.Rejected{RequestID: id, NotFound: errors.Is(err, port.ErrRoomNotFound)}) +} + +func (h *Host) run(r *hostedRoom) { + defer h.finish(r) + if !h.acquire(r) { + return + } + // 续约从取得租约时开始,不等接管结束:接管最多耗时 storeTimeout,而期限从取得租约时算起,等接管结束再续约会超过期限。 + keepCtx, stopKeeping := context.WithCancel(context.Background()) + r.stopKeeping = stopKeeping + h.work.Go(func() { h.keep(keepCtx, r) }) + ctx, cancel := context.WithTimeout(r.ctx, storeTimeout) + term, snap, err := h.snapshots.TakeRoom(ctx, r.id) + cancel() + if err != nil { + r.err = ErrUnavailable + if errors.Is(err, port.ErrRoomNotFound) { + r.err = port.ErrRoomNotFound + } else if r.ctx.Err() == nil { + level := slog.LevelDebug + if last := h.takeoverWarned.Load(); last == nil || time.Since(*last) >= takeoverLogRepeat { + if now := time.Now(); h.takeoverWarned.CompareAndSwap(last, &now) { + level = slog.LevelWarn + } + } + h.logger.Log(r.ctx, level, "接管房间失败", "error", err) + } + return + } + h.takeoverWarned.Store(nil) + var effects []room.Effect + r.room, effects = room.Restore(roomEnv(), term, snap) + r.term, r.info = term, snap.Info + h.owned.Add(1) + close(r.started) + if !h.holding(r.ctx, r) || !h.publish(r.ctx, r, port.OwnerChanged{Term: term}) || !h.execute(r.ctx, r, effects, "") { + return + } + for { + select { + case <-r.ctx.Done(): + return + case e := <-r.inbox: + ctx := r.inputContext(e) + // 释放房间时只处理完当前的输入。 + if r.ctx.Err() != nil || !h.holding(ctx, r) { + h.reject(ctx, r.id, e.requestID(), ErrUnavailable) + return + } + in := r.input(e) + r.track(in, e.gateway, h.self) + if t, ok := in.(room.Timer); ok { + delete(r.timers, t.Token) + } + if !h.execute(ctx, r, r.room.Handle(roomEnv(), in), e.requestID()) { + return + } + } + } +} + +func (h *Host) acquire(r *hostedRoom) bool { + start := time.Now() + ctx, cancel := context.WithTimeout(r.ctx, clusterTimeout) + ok, err := h.leases.Acquire(ctx, r.id, leaseTTL) + cancel() + switch { + case err != nil: + r.err = ErrUnavailable + if r.ctx.Err() == nil { + h.logger.WarnContext(r.ctx, "取得房间的租约失败", "error", err) + } + return false + case !ok: + r.err = errOwnedElsewhere + return false + } + r.acquired = true + r.deadline.Store(start.Add(ownerDeadline).UnixNano()) + return true +} + +// 续约不经过房间的 goroutine:房间正在落盘时也要按时续约。 +func (h *Host) keep(ctx context.Context, r *hostedRoom) { + ticker := time.NewTicker(renewInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + start := time.Now() + renewCtx, cancel := context.WithTimeout(ctx, clusterTimeout) + ok, err := h.leases.Renew(renewCtx, r.id, leaseTTL) + cancel() + switch { + case ctx.Err() != nil: + return + case err == nil && ok: + r.deadline.Store(start.Add(ownerDeadline).UnixNano()) + case err == nil: + h.lose(r.ctx, r, "租约已不属于本实例,放弃房间") + return + case time.Now().UnixNano() >= r.deadline.Load(): + h.lose(r.ctx, r, "超过 10 秒没有续约成功,放弃房间", "error", err) + return + } + } +} + +func (h *Host) holding(ctx context.Context, r *hostedRoom) bool { + if r.lost.Load() { + return false + } + if time.Now().UnixNano() < r.deadline.Load() { + return true + } + h.lose(ctx, r, "超过 10 秒没有续约成功,放弃房间") + return false +} + +func (h *Host) lose(ctx context.Context, r *hostedRoom, msg string, args ...any) { + if r.lost.CompareAndSwap(false, true) { + h.leasesLost.Add(1) + h.logger.WarnContext(ctx, msg, args...) + r.cancel() + } +} + +func (h *Host) finish(r *hostedRoom) { + r.cancel() + if r.stopKeeping != nil { + r.stopKeeping() + } + for _, t := range r.timers { + t.Stop() + } + if r.room != nil { + h.owned.Add(-1) + } + // 先删除租约,再发出释放通知:收到通知的网关接管房间时,租约已经空出来了。因出错而放弃房间时不发通知: + // 否则落盘一直失败时,网关收到通知就接管、接管之后又落盘失败,会反复接管同一个房间。 + if r.acquired && !r.lost.Load() { + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.ctx), clusterTimeout) + if err := h.leases.Release(ctx, r.id); err != nil { + h.logger.WarnContext(ctx, "释放房间的租约失败", "error", err) + } + if r.room != nil && !r.closed && r.releasing.Load() { + if err := h.bus.PublishRoom(ctx, r.id, port.OwnerReleased{}); err != nil { + h.logger.ErrorContext(ctx, "发布房间的释放通知失败", "error", err) + } + } + cancel() + } + h.mu.Lock() + if h.running[r.id] == r { + delete(h.running, r.id) + } + h.mu.Unlock() + close(r.done) +} + +// 调用都带着 ctx 的值,但不随它取消。返回 false 表示这次运行结束:落盘失败、租约不再属于本实例或者消息发布失败时放弃房间, +// 不再执行剩下的 effect,request 还没有回复时让它立即失败;输出 Close 之后丢弃 Room。 +func (h *Host) execute(ctx context.Context, r *hostedRoom, effects []room.Effect, request room.RequestID) (ok bool) { + replied := false + defer func() { + if !ok && !replied { + h.reject(ctx, r.id, request, ErrUnavailable) + } + }() + for i, e := range effects { + if !h.holding(ctx, r) { + return false + } + switch e := e.(type) { + case room.Persist: + if err := h.persist(ctx, r, e.Snapshot, plays(effects[i+1:])); err != nil { + h.abandon(ctx, r, "落盘失败,放弃房间", err) + return false + } + r.info = e.Snapshot.Info + case room.RecordPlay: + // 已经和 Persist 在同一个事务中写入。 + case room.Emit: + if m, ok := e.Event.(room.MembersChanged); ok && !h.setOnline(ctx, r, len(m.Sessions)) { + return false + } + if !h.publish(ctx, r, port.Emitted{Emit: e}) { + return false + } + case room.Close: + if err := h.remove(ctx, r); err != nil { + h.abandon(ctx, r, "删除房间失败,放弃房间", err) + return false + } + r.closed = true + if !h.publish(ctx, r, port.RoomClosed{}) { + return false + } + case room.Reply: + if !h.publish(ctx, r, port.Replied{Reply: e}) { + return false + } + replied = true + case room.Prepare: + h.prepare(ctx, r, e.Track) + case room.Recommend: + h.recommend(ctx, r, e.Token) + case room.Schedule: + token := e.Token + r.timers[token] = time.AfterFunc(time.Until(e.At), func() { r.post(room.Timer{Token: token}) }) + } + } + return !r.closed +} + +// 在线人数在广播在线列表之前写入:看到在线列表变化的客户端,在房间列表中看到的也是新的人数。 +// 写入失败只影响房间列表,不放弃房间:真的连不上时续约也会失败,房间随之停止。 +func (h *Host) setOnline(ctx context.Context, r *hostedRoom, n int) bool { + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), clusterTimeout) + ok, err := h.leases.SetOnline(writeCtx, r.id, n) + cancel() + switch { + case err != nil: + h.logger.WarnContext(ctx, "写入在线人数失败", "error", err) + case !ok: + h.lose(ctx, r, "租约已不属于本实例,放弃房间") + return false + } + return true +} + +// 任期过期说明房间已经由其他实例接管,计为失去租约。 +func (h *Host) abandon(ctx context.Context, r *hostedRoom, msg string, err error) { + if errors.Is(err, port.ErrTermExpired) { + h.lose(ctx, r, msg, "error", err) + return + } + h.logger.ErrorContext(ctx, msg, "error", err) +} + +// 房间的消息发布失败时,订阅的网关可能漏掉了它,所以放弃房间:下一个 owner 从落盘的快照恢复房间,各网关重新加入会话。 +func (h *Host) publish(ctx context.Context, r *hostedRoom, m port.RoomMessage) bool { + publishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), clusterTimeout) + defer cancel() + if err := h.bus.PublishRoom(publishCtx, r.id, m); err != nil { + h.logger.ErrorContext(ctx, "发布房间消息失败,放弃房间", "error", err) + return false + } + return true +} + +// 同一批中的 RecordPlay 都排在 Persist 之后,与它在同一个事务中写入。 +func plays(effects []room.Effect) []room.RecordPlay { + var out []room.RecordPlay + for _, e := range effects { + if p, ok := e.(room.RecordPlay); ok { + out = append(out, p) + } + } + return out +} + +// 落盘不随 ctx 取消:释放房间时,正在处理的输入要处理完。 +func (h *Host) persist(ctx context.Context, r *hostedRoom, s room.Snapshot, plays []room.RecordPlay) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout) + defer cancel() + return h.snapshots.SaveRoom(ctx, r.term, s, plays) +} + +func (h *Host) remove(ctx context.Context, r *hostedRoom) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout) + defer cancel() + return h.snapshots.DeleteRoom(ctx, r.id, r.term) +} + +func (h *Host) prepare(ctx context.Context, r *hostedRoom, t track.Track) { + h.work.Go(func() { + ctx, cancel := r.detach(ctx) + defer cancel() + meta, result, err := h.preparer.Prepare(ctx, t) + if err != nil { + r.post(room.PrepareFailed{Track: t, Reason: err.Error()}) + return + } + r.post(room.Prepared{Track: t, Meta: meta, Result: result}) + }) +} + +func (h *Host) recommend(ctx context.Context, r *hostedRoom, token room.Token) { + h.work.Go(func() { + ctx, cancel := r.detach(ctx) + defer cancel() + t, m, ok, err := h.catalog.Recommend(ctx, r.id) + if err != nil && r.ctx.Err() == nil { + h.logger.WarnContext(ctx, "AutoDJ 推荐失败", "error", err) + } + if ok { + r.post(room.Recommended{Token: token, Track: t, Meta: m}) + } + }) +} + +func (r *hostedRoom) release() { + r.releasing.Store(true) + r.cancel() +} + +func (r *hostedRoom) detach(ctx context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + stop := context.AfterFunc(r.ctx, cancel) + return ctx, func() { + stop() + cancel() + } +} + +func (r *hostedRoom) post(in room.Input) { + select { + case r.inbox <- envelope{input: in}: + case <-r.ctx.Done(): + } +} + +func (r *hostedRoom) input(e envelope) room.Input { + if e.patch == nil { + return e.input + } + in := room.UpdateInfo{RequestID: e.patch.RequestID, Name: r.info.Name, Description: r.info.Description} + if e.patch.Name != nil { + in.Name = *e.patch.Name + } + if e.patch.Description != nil { + in.Description = *e.patch.Description + } + return in +} + +func (r *hostedRoom) track(in room.Input, gateway, self string) { + if gateway == "" { + gateway = self + } + r.mu.Lock() + defer r.mu.Unlock() + switch in := in.(type) { + case room.Join: + r.gateways[in.Session] = gateway + case room.Leave: + delete(r.gateways, in.Session) + } +} + +// 请求和成员的 ID 取自输入本身:其他实例转发来的输入只带着追踪上下文。 +func (r *hostedRoom) inputContext(e envelope) context.Context { + ctx := r.ctx + if e.ctx != nil { + ctx = logctx.WithRoom(context.WithoutCancel(e.ctx), r.id) + } + if id := e.requestID(); id != "" { + ctx = logctx.WithRequest(ctx, string(id)) + } + if id := e.memberID(); id != "" { + ctx = logctx.WithMember(ctx, id) + } + return ctx +} + +func (e envelope) requestID() room.RequestID { + if e.patch != nil { + return e.patch.RequestID + } + switch in := e.input.(type) { + case room.Command: + return in.RequestID + case room.UpdateInfo: + return in.RequestID + case room.Delete: + return in.RequestID + } + return "" +} + +func (e envelope) memberID() string { + switch in := e.input.(type) { + case room.Command: + return in.Member.ID + case room.Join: + return in.Member.ID + case room.MemberUpdated: + return in.Member.ID + } + return "" +} + +func roomEnv() room.Env { + return room.Env{Now: time.Now(), Seed: rand.Uint64()} +} diff --git a/internal/app/realtime/instance.go b/internal/app/realtime/instance.go new file mode 100644 index 0000000..c57efc5 --- /dev/null +++ b/internal/app/realtime/instance.go @@ -0,0 +1,38 @@ +package realtime + +import ( + "context" + "sync" +) + +// 返回的函数等后台的 goroutine 在 ctx 结束之后全部退出。 +func Start(ctx context.Context, h *Host, g *Gateway) (wait func(), err error) { + var wg sync.WaitGroup + lost := make(chan struct{}, 1) + wg.Go(func() { g.receive(ctx, lost) }) + if err := g.open(ctx); err != nil { + wg.Wait() + return nil, err + } + h.Start() + wg.Go(func() { g.heartbeat(ctx) }) + wg.Go(func() { h.Reconcile(ctx) }) + wg.Go(func() { + for { + select { + case <-ctx.Done(): + return + case <-lost: + } + g.logger.WarnContext(ctx, "订阅连接断开过,释放全部房间并断开会话") + h.pause() + g.restart() + if g.open(ctx) != nil { + return + } + h.Start() + g.logger.InfoContext(ctx, "订阅已恢复,重新开始接管房间") + } + }) + return wg.Wait, nil +} diff --git a/internal/app/realtime/observer.go b/internal/app/realtime/observer.go new file mode 100644 index 0000000..e31bee6 --- /dev/null +++ b/internal/app/realtime/observer.go @@ -0,0 +1,133 @@ +package realtime + +import ( + "context" + "errors" + "slices" + + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/room" +) + +// 按本实例计数:限制的是每个实例为事件流占用的资源。 +const observerLimit = 5 + +var ErrTooManyObservers = errors.New("这个房间的事件流超过 5 条,请先关闭不用的事件流") + +// Observer 是房间的事件流:先收到房间的快照,之后是房间的事件;房间开始新的一次运行时事件重新编号,再收到一份新的快照。 +type Observer struct { + *sendQueue + room string + link *roomLink + member member.Member + bot bool + credential string + + // 由 sendQueue 的 mu 保护:最近一次快照的任期,和收到过的 owner 变更通知中最大的任期。 + term, latest uint32 +} + +// Observe 订阅房间的事件但不加入房间:事件流不出现在在线列表中,不计入投票切歌所需的人数,也不让 AutoDJ 认为房间里有人。 +// m 是凭证的持有者,bot 表示凭证是常驻房间的 API Key,credential 是凭证的哈希。名额用完时,同一凭证打开的最早的事件流以 +// CloseReplaced 关闭,名额让给这一条;没有同一凭证的事件流时返回 ErrTooManyObservers。 +// 返回时快照已经放进队列。结束时必须调用一次 Unobserve。 +func (g *Gateway) Observe(ctx context.Context, roomID string, m member.Member, bot bool, credential string) (*Observer, error) { + link, err := g.hold(ctx, roomID) + if err != nil { + return nil, err + } + o := &Observer{sendQueue: newSendQueue(), room: roomID, link: link, member: m, bot: bot, credential: credential} + if err := g.watch(o); err != nil { + g.unhold(roomID, link, false) + return nil, err + } + if err := g.sync(ctx, o); err != nil { + g.Unobserve(o) + return nil, err + } + return o, nil +} + +// 被替换的事件流已经不在名单中,只归还它对房间频道的占用。 +func (g *Gateway) Unobserve(o *Observer) { + g.mu.Lock() + if rest := slices.DeleteFunc(g.observers[o.room], func(x *Observer) bool { return x == o }); len(rest) > 0 { + g.observers[o.room] = rest + } else { + delete(g.observers, o.room) + } + g.mu.Unlock() + // 中止正在进行的重新获取快照。 + o.cancel() + g.unhold(o.room, o.link, false) +} + +// 关闭被替换的事件流和占用它的名额在同一次加锁中完成:名额不会超出,同时到达的事件流也不会替换同一条。 +func (g *Gateway) watch(o *Observer) error { + g.mu.Lock() + defer g.mu.Unlock() + if g.closing { + return ErrUnavailable + } + set := g.observers[o.room] + if len(set) == observerLimit { + // 同一凭证打开的最早的事件流。 + i := slices.IndexFunc(set, func(x *Observer) bool { return x.credential == o.credential }) + if i < 0 { + return ErrTooManyObservers + } + set[i].close(CloseReplaced) + set = slices.Delete(set, i, i+1) + } + g.observers[o.room] = append(set, o) + return nil +} + +// 拿到的快照早于收到过的最新一次运行时重新获取。 +func (g *Gateway) sync(ctx context.Context, o *Observer) error { + for { + id := g.newRequestID() + var err error + current := false + g.request(ctx, o.room, id, envelope{input: room.Command{RequestID: id, Member: o.member, Action: room.Sync{}}}, false, func(res room.Result, e error) { + if err = e; err == nil { + current = o.snapshot(res.(room.SyncResult).View) + } + }) + if err != nil || current { + return failed(id, err) + } + } +} + +// 取不到新快照时关闭事件流,客户端重新连接之后以新快照为准。 +func (g *Gateway) resync(o *Observer) { + if g.sync(o.ctx, o) != nil { + o.close(CloseRestart) + } +} + +// 在接收消息的 goroutine 上按房间频道的顺序调用:此后房间的事件才发给这个事件流,快照之前的事件都已包含在快照中。 +// 返回 false 表示这份快照已经过时:之后又收到了更新一次运行的 owner 变更通知,从它接着发事件,编号就对不上。 +func (o *Observer) snapshot(v room.View) bool { + o.mu.Lock() + defer o.mu.Unlock() + if v.Term < o.latest { + return false + } + o.term, o.subscribed = v.Term, true + o.enqueue(Snapshot{View: v}) + return true +} + +// 返回 true 表示要重新获取快照,在此之前不再发事件。正在获取快照时只记下任期,由获取快照的一方判断拿到的快照是否过时。 +func (o *Observer) ownerChanged(term uint32) bool { + o.mu.Lock() + defer o.mu.Unlock() + o.latest = max(o.latest, term) + if !o.subscribed || o.term >= term { + return false + } + o.subscribed = false + return true +} diff --git a/internal/app/realtime/session.go b/internal/app/realtime/session.go new file mode 100644 index 0000000..1449a3d --- /dev/null +++ b/internal/app/realtime/session.go @@ -0,0 +1,230 @@ +package realtime + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/room" +) + +const ( + sendQueueLimit = 256 + requestLimit = 16 +) + +type CloseReason uint8 + +const ( + CloseRestart CloseReason = iota + 1 + CloseRevoked + CloseRoomGone + CloseOverflow + // 只用于事件流:名额用完时让给同一凭证新打开的事件流。 + CloseReplaced +) + +type Message interface{ message() } + +type ( + // 握手的结果,总是会话收到的第一条消息。 + Hello struct { + Member member.Member + View room.View + } + + // 事件流收到的房间快照:总是第一条消息,房间开始新的一次运行之后再收到一份。 + Snapshot struct { + View room.View + } + + Event struct { + Emit room.Emit + } + + // Tag 是发出请求时交给网关的值,原样带回,用来对应请求。 + Reply struct { + Tag any + Result room.Result + Err error + } + + Answer struct { + Tag any + Data any + } +) + +func (Hello) message() {} +func (Snapshot) message() {} +func (Event) message() {} +func (Reply) message() {} +func (Answer) message() {} + +type sendQueue struct { + ctx context.Context + cancel context.CancelFunc + wake chan struct{} + + mu sync.Mutex + subscribed bool + queue []Message + reason CloseReason +} + +func newSendQueue() *sendQueue { + ctx, cancel := context.WithCancel(context.Background()) + return &sendQueue{ctx: ctx, cancel: cancel, wake: make(chan struct{}, 1)} +} + +type Session struct { + *sendQueue + id room.SessionID + room string + link *roomLink + // 在发出 Leave 之前设置。重新加入的 Join 可能排在 Leave 之后,网关据此补发 Leave。 + left atomic.Bool + + // 由 sendQueue 的 mu 保护。 + member member.Member + outstanding int +} + +func newSession(id room.SessionID, roomID string, m member.Member) *Session { + return &Session{sendQueue: newSendQueue(), id: id, room: roomID, member: m} +} + +func (s *Session) ended() bool { + return s.left.Load() || s.ctx.Err() != nil +} + +func (s *Session) Member() member.Member { + s.mu.Lock() + defer s.mu.Unlock() + return s.member +} + +func (s *Session) Room() string { + return s.room +} + +// Context 在关闭时取消。会话向客户端写消息时以它为期限:客户端不再读取时,关闭会话也要能中止阻塞的写入。 +func (q *sendQueue) Context() context.Context { + return q.ctx +} + +// 关闭之后返回关闭的原因,队列中剩下的消息不再发送。 +func (q *sendQueue) Next(ctx context.Context) (Message, CloseReason, error) { + for { + q.mu.Lock() + if q.reason != 0 { + reason := q.reason + q.mu.Unlock() + return nil, reason, nil + } + if len(q.queue) > 0 { + m := q.queue[0] + q.queue = q.queue[1:] + q.mu.Unlock() + return m, 0, nil + } + q.mu.Unlock() + select { + case <-q.wake: + case <-ctx.Done(): + return nil, 0, ctx.Err() + } + } +} + +// 请求的回复放进发送队列时就归还名额,所以客户端收到回复之后总能立即发出下一个请求。 +func (s *Session) Begin() bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.outstanding == requestLimit { + return false + } + s.outstanding++ + return true +} + +func (s *Session) Fail(tag any, err error) { + s.reply(tag, nil, err) +} + +func (s *Session) Answer(tag, data any) { + s.mu.Lock() + defer s.mu.Unlock() + s.outstanding-- + s.enqueue(Answer{Tag: tag, Data: data}) +} + +func (s *Session) reply(tag any, res room.Result, err error) { + s.mu.Lock() + defer s.mu.Unlock() + s.outstanding-- + s.enqueue(Reply{Tag: tag, Result: res, Err: err}) +} + +// 在接收消息的 goroutine 上按房间频道的顺序调用:此后房间的事件才发给这个会话,快照之前的事件都已包含在快照中。 +func (s *Session) welcome(v room.View) { + s.mu.Lock() + defer s.mu.Unlock() + s.subscribed = true + s.enqueue(Hello{Member: s.member, View: v}) +} + +func (s *Session) update(m member.Member) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.member.ID != m.ID { + return false + } + s.member = m + return true +} + +func (q *sendQueue) event(e room.Emit) { + q.mu.Lock() + defer q.mu.Unlock() + if q.subscribed { + q.enqueue(Event{Emit: e}) + } +} + +func (q *sendQueue) close(reason CloseReason) { + q.mu.Lock() + defer q.mu.Unlock() + q.closeLocked(reason) +} + +// 调用方持有 q.mu。 +func (q *sendQueue) enqueue(m Message) { + if q.reason != 0 { + return + } + if len(q.queue) == sendQueueLimit { + q.closeLocked(CloseOverflow) + return + } + q.queue = append(q.queue, m) + q.signal() +} + +func (q *sendQueue) closeLocked(reason CloseReason) { + if q.reason != 0 { + return + } + q.reason = reason + q.queue = nil + q.cancel() + q.signal() +} + +func (q *sendQueue) signal() { + select { + case q.wake <- struct{}{}: + default: + } +} diff --git a/internal/app/site/site.go b/internal/app/site/site.go new file mode 100644 index 0000000..095d53b --- /dev/null +++ b/internal/app/site/site.go @@ -0,0 +1,53 @@ +package site + +import ( + "context" + "errors" + "strings" + "unicode" + "unicode/utf8" + + "github.com/dihemoe/alisten/internal/app/port" +) + +const ( + defaultBrandName = "Alisten" + maxBrandNameLength = 20 +) + +var ErrInvalidBrandName = errors.New("brandName:去掉首尾空白后应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符") + +type Service struct { + site port.Site +} + +func New(site port.Site) *Service { + return &Service{site: site} +} + +func (s *Service) BrandName(ctx context.Context) (string, error) { + name, err := s.site.BrandName(ctx) + if err != nil { + return "", err + } + if name == "" { + return defaultBrandName, nil + } + return name, nil +} + +func (s *Service) SetBrandName(ctx context.Context, name string) (string, error) { + name = strings.TrimSpace(name) + if utf8.RuneCountInString(name) > maxBrandNameLength || strings.ContainsFunc(name, unicode.IsControl) || !strings.ContainsFunc(name, visible) { + return "", ErrInvalidBrandName + } + if err := s.site.SetBrandName(ctx, name); err != nil { + return "", err + } + return name, nil +} + +// unicode.IsGraphic 把 Zs 类的空白也算作 graphic。 +func visible(r rune) bool { + return unicode.IsGraphic(r) && !unicode.IsSpace(r) +} diff --git a/internal/bootstrap/clock.go b/internal/bootstrap/clock.go new file mode 100644 index 0000000..861ffd5 --- /dev/null +++ b/internal/bootstrap/clock.go @@ -0,0 +1,32 @@ +package bootstrap + +import ( + "context" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +const ( + clockInterval = 30 * time.Second + clockTimeout = 5 * time.Second +) + +// 估算本实例与 Redis 服务器的时钟偏差,两个实例之间的时钟差就是两者偏差之差。 +// 估算失败时保留上一次的值:连不上 Redis 已经反映在就绪检查中。 +func watchClock(ctx context.Context, measure func(context.Context) (time.Duration, error), offset prometheus.Gauge) { + ticker := time.NewTicker(clockInterval) + defer ticker.Stop() + for { + measureCtx, cancel := context.WithTimeout(ctx, clockTimeout) + if d, err := measure(measureCtx); err == nil { + offset.Set(d.Seconds()) + } + cancel() + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go new file mode 100644 index 0000000..5e36268 --- /dev/null +++ b/internal/bootstrap/config.go @@ -0,0 +1,286 @@ +package bootstrap + +import ( + "errors" + "fmt" + "log/slog" + "net" + "net/netip" + "net/url" + "os" + "regexp" + "slices" + "strconv" + "strings" + "unicode/utf8" + + "github.com/jackc/pgx/v5/pgxpool" + goredis "github.com/redis/go-redis/v9" + + "github.com/dihemoe/alisten/internal/adapter/out/storage" +) + +type serveConfig struct { + listen string + opsListen string + publicOrigin string + allowedOrigins []string + adminToken string + database *pgxpool.Config + redis *goredis.Options + storage storage.Config + // 浏览器访问对象存储时使用的地址前缀,结尾没有 /。 + storageURL string + tracing bool +} + +func loadServeConfig() (serveConfig, error) { + listen, err := listenAddr("ALISTEN_LISTEN", ":8080") + if err != nil { + return serveConfig{}, err + } + opsListen, err := listenAddr("ALISTEN_OPS_LISTEN", ":9090") + if err != nil { + return serveConfig{}, err + } + publicURL := os.Getenv("ALISTEN_PUBLIC_URL") + // 示例配置在 ALISTEN_DOMAIN 为空时生成 https://,它与空值一样使用本地默认地址。 + if publicURL == "" || publicURL == "https://" { + publicURL = "http://localhost:9876" + } + origin, err := publicOrigin(publicURL) + if err != nil { + return serveConfig{}, err + } + allowedOrigins, err := parseAllowedOrigins(os.Getenv("ALISTEN_ALLOWED_ORIGINS"), origin) + if err != nil { + return serveConfig{}, err + } + adminToken := os.Getenv("ALISTEN_ADMIN_TOKEN") + database, err := loadDatabaseConfig() + if err != nil { + return serveConfig{}, err + } + redis, err := loadRedisConfig() + if err != nil { + return serveConfig{}, err + } + objects, err := loadStorageConfig() + if err != nil { + return serveConfig{}, err + } + storagePublicURL := os.Getenv("STORAGE_PUBLIC_URL") + if storagePublicURL == "" && strings.HasPrefix(origin, "http://") { + storagePublicURL = origin + "/storage/" + objects.Bucket + } + storageURL, err := parseStorageURL(storagePublicURL) + if err != nil { + return serveConfig{}, err + } + tracing, err := loadTracing() + if err != nil { + return serveConfig{}, err + } + return serveConfig{ + listen: listen, + opsListen: opsListen, + publicOrigin: origin, + allowedOrigins: allowedOrigins, + adminToken: adminToken, + database: database, + redis: redis, + storage: objects, + storageURL: storageURL, + tracing: tracing, + }, nil +} + +// 浏览器发送的 Origin 中,scheme 和主机名都是小写,默认端口省略不写,端口不带前导零,国际化域名写成 punycode, +// IP 地址写成规范形式。浏览器不会发出的写法在启动时就报错:否则带 Origin 的请求全部被拒绝。 +func publicOrigin(value string) (string, error) { + if value == "" { + return "", errors.New("缺少 ALISTEN_PUBLIC_URL") + } + u, err := url.Parse(value) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { + return "", fmt.Errorf("ALISTEN_PUBLIC_URL 的值 %q 不是 http 或 https 的绝对地址", value) + } + if port := u.Port(); strings.HasSuffix(u.Host, ":") || port != "" && !canonicalPort(port) { + return "", fmt.Errorf("ALISTEN_PUBLIC_URL 的值 %q 中的端口不合法,必须是 1–65535 的整数,不带前导零", value) + } + if strings.ContainsFunc(u.Hostname(), func(r rune) bool { return r >= utf8.RuneSelf }) { + return "", fmt.Errorf("ALISTEN_PUBLIC_URL 的值 %q 中的主机名要写成 punycode", value) + } + if !canonicalHost(strings.ToLower(u.Hostname()), strings.HasPrefix(u.Host, "[")) { + return "", fmt.Errorf("ALISTEN_PUBLIC_URL 的值 %q 中的 IP 地址要写成浏览器发送的规范形式", value) + } + defaultPort := map[string]string{"http": ":80", "https": ":443"}[u.Scheme] + return u.Scheme + "://" + strings.TrimSuffix(strings.ToLower(u.Host), defaultPort), nil +} + +func parseAllowedOrigins(value, fallback string) ([]string, error) { + if value == "" { + return []string{fallback}, nil + } + var origins []string + for _, raw := range strings.Split(value, ",") { + item := strings.TrimSpace(raw) + if item == "" { + return nil, errors.New("ALISTEN_ALLOWED_ORIGINS 中有空地址") + } + origin, err := publicOrigin(item) + if err != nil { + return nil, fmt.Errorf("ALISTEN_ALLOWED_ORIGINS 中的 %q:%w", item, err) + } + if !slices.Contains(origins, origin) { + origins = append(origins, origin) + } + } + return origins, nil +} + +func canonicalPort(port string) bool { + n, err := strconv.Atoi(port) + return err == nil && n >= 1 && n <= 65535 && strconv.Itoa(n) == port +} + +var numericLabel = regexp.MustCompile(`^(?:[0-9]+|0[xX][0-9a-fA-F]*)$`) + +// 方括号中的主机是 IPv6 地址:浏览器把它写成小写并压缩连续的零,不带 zone,IPv4 映射地址也写成十六进制。 +// 最后一个标签是数字的主机名,浏览器按 IPv4 解析(127.1、0x7f000001、010.0.0.1 都能解析)之后写成点分十进制, +// 所以只有 netip 接受的写法(四段十进制,没有前导零)才与浏览器发送的一致。其他主机名不是 IP 地址,原样发送。 +func canonicalHost(host string, bracketed bool) bool { + if bracketed { + addr, err := netip.ParseAddr(host) + return err == nil && addr.Zone() == "" && !addr.Is4In6() && addr.String() == host + } + labels := strings.Split(strings.TrimSuffix(host, "."), ".") + if !numericLabel.MatchString(labels[len(labels)-1]) { + return true + } + _, err := netip.ParseAddr(host) + return err == nil +} + +// 链接曲目的音频和封面地址由这个前缀直接拼接对象键而成,所以不能带查询串和片段,哪怕只有一个 ? 或 #。 +// 这个地址会发给每个浏览器,所以也不能带用户信息。 +func parseStorageURL(value string) (string, error) { + if err := checkHTTPURL("STORAGE_PUBLIC_URL", value); err != nil { + return "", err + } + return strings.TrimSuffix(value, "/"), nil +} + +// bucket 名称会写进访问策略和对象的地址,所以只接受 S3 规定的字符。 +var bucketPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`) + +func loadStorageConfig() (storage.Config, error) { + endpoint := os.Getenv("STORAGE_ENDPOINT_URL") + if err := checkHTTPURL("STORAGE_ENDPOINT_URL", endpoint); err != nil { + return storage.Config{}, err + } + region := envOr("STORAGE_REGION", "us-east-1") + if region == "" { + return storage.Config{}, errors.New("STORAGE_REGION 不能为空") + } + bucket := envOr("STORAGE_BUCKET_NAME", "alisten") + if !bucketPattern.MatchString(bucket) { + return storage.Config{}, fmt.Errorf("STORAGE_BUCKET_NAME 的值 %q 不是合法的 bucket 名称:只能由小写字母、数字、点和连字符组成,长度 3–63", bucket) + } + cfg := storage.Config{ + Endpoint: endpoint, + Region: region, + Bucket: bucket, + AccessKeyID: os.Getenv("STORAGE_ACCESS_KEY_ID"), + SecretAccessKey: os.Getenv("STORAGE_SECRET_ACCESS_KEY"), + } + if cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" { + return storage.Config{}, errors.New("缺少 STORAGE_ACCESS_KEY_ID 或 STORAGE_SECRET_ACCESS_KEY") + } + return cfg, nil +} + +// 报错时不回显原值,以免其中的凭证进入日志。 +func checkHTTPURL(name, value string) error { + if value == "" { + return fmt.Errorf("缺少 %s", name) + } + u, err := url.Parse(value) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || strings.ContainsAny(value, "?#") { + return fmt.Errorf("%s 必须是 http 或 https 的绝对地址,不带用户信息、查询串和片段", name) + } + return nil +} + +// 导出器自己读取这两个变量,地址有误时它并不报错(无法解析时只记一条日志,改为发往本机),所以启动时在这里检查格式。 +// 设置了其中任何一个就导出。OTEL_EXPORTER_OTLP_ENDPOINT 是基地址,导出器在它的路径后加上 /v1/traces; +// OTEL_EXPORTER_OTLP_TRACES_ENDPOINT 是完整的地址,优先于前者。 +func loadTracing() (bool, error) { + endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + tracesEndpoint := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + if endpoint == "" && tracesEndpoint == "" { + return false, nil + } + if endpoint != "" { + if err := checkHTTPURL("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint); err != nil { + return false, err + } + if u, err := url.Parse(endpoint); err == nil && strings.HasSuffix(strings.TrimRight(u.Path, "/"), "/v1/traces") { + return false, errors.New("OTEL_EXPORTER_OTLP_ENDPOINT 的路径不能以 /v1/traces 结尾:导出器会再加上一次,完整的地址请用 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + } + } + if tracesEndpoint != "" { + if err := checkHTTPURL("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", tracesEndpoint); err != nil { + return false, err + } + } + return true, nil +} + +func loadOpsListen() (string, error) { + return listenAddr("ALISTEN_OPS_LISTEN", ":9090") +} + +var logLevels = map[string]slog.Level{ + "debug": slog.LevelDebug, + "info": slog.LevelInfo, + "warn": slog.LevelWarn, + "error": slog.LevelError, +} + +func loadLogLevel() (slog.Level, error) { + value := envOr("ALISTEN_LOG_LEVEL", "info") + level, ok := logLevels[value] + if !ok { + return slog.LevelInfo, fmt.Errorf("ALISTEN_LOG_LEVEL 的值 %q 不合法,可选值为 debug、info、warn、error", value) + } + return level, nil +} + +func listenAddr(name, fallback string) (string, error) { + value := envOr(name, fallback) + _, port, err := net.SplitHostPort(value) + if err != nil { + return "", fmt.Errorf("%s 的值 %q 不是合法的监听地址:%w", name, value, err) + } + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return "", fmt.Errorf("%s 的值 %q 中的端口不合法,必须是 1–65535 的整数", name, value) + } + return value, nil +} + +func envPort(name string, fallback int) (int, error) { + value := envOr(name, strconv.Itoa(fallback)) + port, err := strconv.Atoi(value) + if err != nil || port < 1 || port > 65535 { + return 0, fmt.Errorf("%s 的值 %q 不合法,必须是 1–65535 的整数", name, value) + } + return port, nil +} + +func envOr(name, fallback string) string { + if value, ok := os.LookupEnv(name); ok { + return value + } + return fallback +} diff --git a/internal/bootstrap/database.go b/internal/bootstrap/database.go new file mode 100644 index 0000000..c8d15e0 --- /dev/null +++ b/internal/bootstrap/database.go @@ -0,0 +1,39 @@ +package bootstrap + +import ( + "errors" + "fmt" + "os" + "slices" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var sslModes = []string{"disable", "allow", "prefer", "require", "verify-ca", "verify-full"} + +func loadDatabaseConfig() (*pgxpool.Config, error) { + host, user, database := os.Getenv("DATABASE_HOST"), os.Getenv("DATABASE_USERNAME"), os.Getenv("DATABASE_NAME") + if host == "" || user == "" || database == "" { + return nil, errors.New("缺少 DATABASE_HOST、DATABASE_USERNAME 或 DATABASE_NAME") + } + port, err := envPort("DATABASE_PORT", 5432) + if err != nil { + return nil, err + } + sslmode := envOr("DATABASE_SSLMODE", "prefer") + if !slices.Contains(sslModes, sslmode) { + return nil, fmt.Errorf("DATABASE_SSLMODE 的值 %q 不合法,可选值为 %s", sslmode, strings.Join(sslModes, "、")) + } + cfg, err := pgxpool.ParseConfig(fmt.Sprintf("host=%s port=%d user=%s dbname=%s sslmode=%s", quoteDSN(host), port, quoteDSN(user), quoteDSN(database), sslmode)) + if err != nil { + return nil, fmt.Errorf("无法由 DATABASE_* 和 PG* 环境变量组成 PostgreSQL 的连接配置:%w", err) + } + // 密码不放进连接串:pgx 的 ConnString() 原样返回连接串,解析错误中的脱敏也只是尽力而为。 + cfg.ConnConfig.Password = os.Getenv("DATABASE_PASSWORD") + return cfg, nil +} + +func quoteDSN(value string) string { + return "'" + strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(value) + "'" +} diff --git a/internal/bootstrap/healthcheck.go b/internal/bootstrap/healthcheck.go new file mode 100644 index 0000000..6b52671 --- /dev/null +++ b/internal/bootstrap/healthcheck.go @@ -0,0 +1,47 @@ +package bootstrap + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/http" + "time" +) + +const healthcheckTimeout = 2 * time.Second + +func runHealthcheck(*slog.Logger) error { + opsListen, err := loadOpsListen() + if err != nil { + return err + } + url := "http://" + loopbackAddr(opsListen) + "/readyz" + + ctx, cancel := context.WithTimeout(context.Background(), healthcheckTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := (&http.Client{}).Do(req) + if err != nil { + return fmt.Errorf("请求 %s:%w", url, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s 返回 %d", url, resp.StatusCode) + } + return nil +} + +func loopbackAddr(listen string) string { + host, port, _ := net.SplitHostPort(listen) + switch host { + case "", "0.0.0.0": + host = "127.0.0.1" + case "::": + host = "::1" + } + return net.JoinHostPort(host, port) +} diff --git a/internal/bootstrap/httpserver.go b/internal/bootstrap/httpserver.go new file mode 100644 index 0000000..583b22a --- /dev/null +++ b/internal/bootstrap/httpserver.go @@ -0,0 +1,45 @@ +package bootstrap + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "runtime/debug" + "time" +) + +// handler 要自己负责恢复 panic,见 recoverPanics。 +func newHTTPServer(handler http.Handler, logger *slog.Logger) *http.Server { + return &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + // 与请求体的上限相同:房间内的 GET 和 DELETE 请求把参数放在 URL 里。 + MaxHeaderBytes: 64 << 10, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + } +} + +// net/http 恢复 panic 时会把堆栈拼进日志消息,而堆栈要写在单独的 stack 字段里。 +func recoverPanics(next http.Handler, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + value := recover() + if value == nil { + return + } + // ErrAbortHandler 表示有意中止响应,net/http 处理它时不记录日志。 + if err, ok := value.(error); ok && errors.Is(err, http.ErrAbortHandler) { + panic(value) + } + logger.ErrorContext(r.Context(), "处理 HTTP 请求时发生 panic", + "panic", fmt.Sprint(value), + "method", r.Method, + "path", r.URL.Path, + "stack", string(debug.Stack()), + ) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + }() + next.ServeHTTP(w, r) + }) +} diff --git a/internal/bootstrap/logging.go b/internal/bootstrap/logging.go new file mode 100644 index 0000000..472c5a8 --- /dev/null +++ b/internal/bootstrap/logging.go @@ -0,0 +1,56 @@ +package bootstrap + +import ( + "context" + "io" + "log/slog" + "strings" + + "go.opentelemetry.io/otel/trace" + + "github.com/dihemoe/alisten/internal/lib/logctx" +) + +const redacted = "[REDACTED]" + +var sensitiveKeyParts = []string{"cookie", "token", "ticket", "password", "secret", "authorization"} + +// 不在这里加 component:slog 的 JSON 输出不合并重复字段,component 只能由各组件派生时加一次。 +func newLogger(w io.Writer, level slog.Level) *slog.Logger { + handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: level, + ReplaceAttr: redactSensitive, + }) + return slog.New(logctx.NewHandler(spanFields{handler})).With("service", "alisten", "version", version) +} + +// 只按字段名判断,含有凭证的结构体作为一个字段记录时不会被脱敏。 +func redactSensitive(_ []string, attr slog.Attr) slog.Attr { + key := strings.ToLower(attr.Key) + for _, part := range sensitiveKeyParts { + if strings.Contains(key, part) { + return slog.String(attr.Key, redacted) + } + } + return attr +} + +// 只有采样的 span 会导出:没有采样时不写 trace_id 和 span_id,日志才不会引用不存在的追踪。 +type spanFields struct { + slog.Handler +} + +func (h spanFields) Handle(ctx context.Context, r slog.Record) error { + if sc := trace.SpanContextFromContext(ctx); sc.IsSampled() { + r.AddAttrs(slog.String("trace_id", sc.TraceID().String()), slog.String("span_id", sc.SpanID().String())) + } + return h.Handler.Handle(ctx, r) +} + +func (h spanFields) WithAttrs(attrs []slog.Attr) slog.Handler { + return spanFields{h.Handler.WithAttrs(attrs)} +} + +func (h spanFields) WithGroup(name string) slog.Handler { + return spanFields{h.Handler.WithGroup(name)} +} diff --git a/internal/bootstrap/main.go b/internal/bootstrap/main.go new file mode 100644 index 0000000..084e68b --- /dev/null +++ b/internal/bootstrap/main.go @@ -0,0 +1,58 @@ +package bootstrap + +import ( + "log/slog" + "os" + "slices" +) + +// 发布构建通过 -ldflags "-X github.com/dihemoe/alisten/internal/bootstrap.version=..." 覆盖。 +var version = "dev" + +type subcommand func(base *slog.Logger) error + +var subcommands = map[string]subcommand{ + "serve": runServe, + "worker": runWorker, + "migrate": runMigrate, + "healthcheck": runHealthcheck, +} + +func Main(args []string) int { + level, levelErr := loadLogLevel() + base := newLogger(os.Stdout, level) + if levelErr != nil { + base.With("component", "main").Error("配置不合法", "error", levelErr) + return 1 + } + + if len(args) == 0 { + base.With("component", "main").Error("缺少子命令", "available", subcommandNames()) + return 2 + } + name := args[0] + run, ok := subcommands[name] + if !ok { + base.With("component", "main").Error("未知的子命令", "command", name, "available", subcommandNames()) + return 2 + } + + logger := base.With("component", name) + // 把标准库 log 包和 OpenTelemetry 的日志接入 slog,第三方库的日志也输出为 JSON。 + slog.SetDefault(logger) + routeOpenTelemetryLogs(base.With("component", "otel")) + if err := run(base); err != nil { + logger.Error("子命令运行失败", "error", err) + return 1 + } + return 0 +} + +func subcommandNames() []string { + names := make([]string, 0, len(subcommands)) + for name := range subcommands { + names = append(names, name) + } + slices.Sort(names) + return names +} diff --git a/internal/bootstrap/metrics.go b/internal/bootstrap/metrics.go new file mode 100644 index 0000000..8bfbf84 --- /dev/null +++ b/internal/bootstrap/metrics.go @@ -0,0 +1,183 @@ +package bootstrap + +import ( + "context" + "errors" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/internal/domain/room" + "github.com/dihemoe/alisten/internal/domain/track" +) + +type providerMetrics struct { + providerRequests *prometheus.CounterVec + providerDuration *prometheus.HistogramVec +} + +func newProviderMetrics(reg prometheus.Registerer) providerMetrics { + m := providerMetrics{ + providerRequests: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "alisten_provider_requests_total", + Help: "对音源上游的请求,source 为 netease、qq 或 bilibili,result 为 ok 或 error。命中进程内缓存的不算。", + }, []string{"source", "op", "result"}), + providerDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "alisten_provider_request_duration_seconds", + Help: "对音源上游的请求的耗时。", + // 接口调用的期限是 30 秒。 + Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 20, 30}, + }, []string{"source", "op"}), + } + reg.MustRegister(m.providerRequests, m.providerDuration) + return m +} + +func (m providerMetrics) provider(source, op string, elapsed time.Duration, err error) { + result := "ok" + if err != nil { + result = "error" + } + m.providerRequests.WithLabelValues(source, op, result).Inc() + m.providerDuration.WithLabelValues(source, op).Observe(elapsed.Seconds()) +} + +type serveMetrics struct { + providerMetrics + prepareMetrics +} + +func newServeMetrics(reg prometheus.Registerer) *serveMetrics { + return &serveMetrics{providerMetrics: newProviderMetrics(reg), prepareMetrics: newPrepareMetrics(reg)} +} + +// 任务的期限是 15 分钟。 +var jobBuckets = []float64{1, 2, 5, 10, 20, 30, 60, 120, 300, 600, 900} + +type prepareMetrics struct { + prepareJobs *prometheus.CounterVec + prepareDuration *prometheus.HistogramVec +} + +func newPrepareMetrics(reg prometheus.Registerer) prepareMetrics { + m := prepareMetrics{ + prepareJobs: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "alisten_prepare_jobs_total", + Help: "本实例执行完的准备任务,result 为 ok、rejected(音源明确拒绝)或 failed(重试之后仍失败)。", + }, []string{"source", "result"}), + prepareDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "alisten_prepare_duration_seconds", + Help: "执行完的准备任务最后一次执行的耗时。", + Buckets: jobBuckets, + }, []string{"source"}), + } + reg.MustRegister(m.prepareJobs, m.prepareDuration) + return m +} + +func (m prepareMetrics) prepared(source track.Source, result string, elapsed time.Duration) { + m.prepareJobs.WithLabelValues(string(source), result).Inc() + m.prepareDuration.WithLabelValues(string(source)).Observe(elapsed.Seconds()) +} + +type workerMetrics struct { + providerMetrics + prepareMetrics + downloadDuration prometheus.Histogram + downloadFailures *prometheus.CounterVec + waitingJobs prometheus.Gauge +} + +func newWorkerMetrics(reg prometheus.Registerer) *workerMetrics { + m := &workerMetrics{ + providerMetrics: newProviderMetrics(reg), + prepareMetrics: newPrepareMetrics(reg), + downloadDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "alisten_ytdlp_download_duration_seconds", + Help: "每次运行 yt-dlp 的耗时,包括失败的运行,不包括 worker 退出时取消的运行。", + Buckets: jobBuckets, + }), + downloadFailures: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "alisten_ytdlp_download_failures_total", + Help: "yt-dlp 下载失败的次数,reason 为 unsupported(链接不受支持)、blocked(指向非公网地址)、timeout(超过任务的期限)或 error(其他错误)。", + }, []string{"reason"}), + waitingJobs: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "alisten_ytdlp_jobs_waiting", + Help: "ytdlp 队列中等待执行的准备任务数,每 15 秒统计一次。这是整个队列的数,各个 worker 报告的值相同。", + }), + } + reg.MustRegister(m.downloadDuration, m.downloadFailures, m.waitingJobs) + return m +} + +func (m *workerMetrics) downloaded(elapsed time.Duration, failure string) { + m.downloadDuration.Observe(elapsed.Seconds()) + if failure != "" { + m.downloadFailures.WithLabelValues(failure).Inc() + } +} + +// 任期过期说明房间已由其他实例接管,计入 alisten_lease_lost_total,不算落盘失败。 +type measuredSnapshots struct { + port.RoomSnapshots + duration prometheus.Histogram + failures prometheus.Counter +} + +func measure(snapshots port.RoomSnapshots, metrics prometheus.Registerer) measuredSnapshots { + m := measuredSnapshots{ + RoomSnapshots: snapshots, + duration: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "alisten_persist_duration_seconds", + Help: "落盘的耗时,包括保存房间和删除房间。", + // 落盘的期限是 10 秒,默认的 bucket 从 5 毫秒到 10 秒。 + Buckets: prometheus.DefBuckets, + }), + failures: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "alisten_persist_failures_total", + Help: "落盘失败的次数,不包括任期过期。", + }), + } + metrics.MustRegister(m.duration, m.failures) + return m +} + +func (m measuredSnapshots) SaveRoom(ctx context.Context, term uint32, s room.Snapshot, plays []room.RecordPlay) error { + start := time.Now() + err := m.RoomSnapshots.SaveRoom(ctx, term, s, plays) + m.observe(start, err) + return err +} + +func (m measuredSnapshots) DeleteRoom(ctx context.Context, id string, term uint32) error { + start := time.Now() + err := m.RoomSnapshots.DeleteRoom(ctx, id, term) + m.observe(start, err) + return err +} + +func (m measuredSnapshots) observe(start time.Time, err error) { + m.duration.Observe(time.Since(start).Seconds()) + if err != nil && !errors.Is(err, port.ErrTermExpired) { + m.failures.Inc() + } +} + +func registerRuntimeMetrics(metrics prometheus.Registerer, host *realtime.Host, gateway *realtime.Gateway) { + metrics.MustRegister( + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "alisten_rooms_owned", + Help: "本实例作为 owner 的房间数。", + }, func() float64 { return float64(host.RoomsOwned()) }), + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "alisten_sessions", + Help: "本实例上的 WebSocket 会话数。", + }, func() float64 { return float64(gateway.Sessions()) }), + prometheus.NewCounterFunc(prometheus.CounterOpts{ + Name: "alisten_lease_lost_total", + Help: "因续约失败、任期过期而放弃房间的次数。", + }, func() float64 { return float64(host.LeasesLost()) }), + ) +} diff --git a/internal/bootstrap/migrate.go b/internal/bootstrap/migrate.go new file mode 100644 index 0000000..b526e5c --- /dev/null +++ b/internal/bootstrap/migrate.go @@ -0,0 +1,63 @@ +package bootstrap + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/dihemoe/alisten/internal/adapter/out/postgres" + "github.com/dihemoe/alisten/internal/adapter/out/storage" +) + +const migrateTimeout = 5 * time.Minute + +func runMigrate(base *slog.Logger) error { + cfg, err := loadDatabaseConfig() + if err != nil { + return err + } + storageCfg, err := loadStorageConfig() + if err != nil { + return err + } + logger := base.With("component", "migrate") + + signals, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stopSignals() + ctx, cancel := context.WithTimeout(signals, migrateTimeout) + defer cancel() + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return fmt.Errorf("创建 PostgreSQL 连接池:%w", err) + } + defer pool.Close() + + err = postgres.WithMigrationLock(ctx, pool, func(ctx context.Context) error { + if err := postgres.MigrateSchema(ctx, pool, logger); err != nil { + return err + } + if err := postgres.MigrateRiver(ctx, pool, logger); err != nil { + return err + } + created, err := storage.New(storageCfg).EnsureBucket(ctx) + if err != nil { + return err + } + if created { + logger.InfoContext(ctx, "已创建对象存储的 bucket", "bucket", storageCfg.Bucket) + } + return nil + }) + if err != nil { + return err + } + logger.InfoContext(ctx, "迁移完成") + return nil +} diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go new file mode 100644 index 0000000..2ebda3e --- /dev/null +++ b/internal/bootstrap/public.go @@ -0,0 +1,158 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" + goredis "github.com/redis/go-redis/v9" + "github.com/riverqueue/river" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + + "github.com/dihemoe/alisten/internal/adapter/in/api" + "github.com/dihemoe/alisten/internal/adapter/in/worker" + "github.com/dihemoe/alisten/internal/adapter/out/postgres" + "github.com/dihemoe/alisten/internal/adapter/out/queue" + "github.com/dihemoe/alisten/internal/adapter/out/redis" + "github.com/dihemoe/alisten/internal/adapter/out/storage" + "github.com/dihemoe/alisten/internal/app/admin" + "github.com/dihemoe/alisten/internal/app/catalog" + "github.com/dihemoe/alisten/internal/app/identity" + "github.com/dihemoe/alisten/internal/app/lobby" + "github.com/dihemoe/alisten/internal/app/media" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/prepare" + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/internal/app/site" + "github.com/dihemoe/alisten/internal/domain/track" +) + +type sources struct { + netease port.NetEaseSource + qq port.Source + bilibili port.BilibiliSource + bilibiliLinks port.BilibiliLinks + playback map[track.Source]port.PlayURLs + neteaseLogin port.NetEaseQRLoginSource + qqLogin port.QQQRLoginSource + bilibiliLogin port.BilibiliQRLoginSource +} + +type public struct { + handler http.Handler + host *realtime.Host + gateway *realtime.Gateway + jobs *river.Client[pgx.Tx] + watch func() +} + +func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instance string, src sources, metrics prometheus.Registerer, observe func(track.Source, string, time.Duration), tracer trace.TracerProvider, base *slog.Logger) (public, error) { + store := postgres.New(pool) + objects := traceObjects(storage.New(cfg.storage), tracer) + logger := base.With("component", "prepare") + runner := prepare.NewRunner(map[track.Source]port.Source{track.NetEase: src.netease, track.QQ: src.qq}, store, logger) + jobs, err := worker.NewClient(pool, runner.Run, insertsInTraces{tracer}, logger) + if err != nil { + return public{}, fmt.Errorf("创建 River client:%w", err) + } + preparer := prepare.New(media.NewTracks(store, objects), queue.New(jobs), logger) + music := catalog.New(src.netease, src.qq, src.bilibili, src.bilibiliLinks, store, store, store, redis.NewSearchQuota(rdb)) + leases := redis.NewLeases(rdb, instance) + cluster := realtime.Cluster{ + Instance: instance, + Leases: leases, + Liveness: redis.NewLiveness(rdb, instance), + Bus: redis.NewBus(rdb, instance, version, metrics, tracer, base.With("component", "bus")), + } + runtimeLogger := base.With("component", "realtime") + host, gateway := realtime.New(measure(media.NewSnapshots(store, objects, runtimeLogger), metrics), store, music, preparer, cluster, runtimeLogger) + registerRuntimeMetrics(metrics, host, gateway) + rooms := lobby.New(store, leases) + services := api.Services{ + Identity: identity.New(store, store, store, gateway), + Lobby: rooms, + Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway), + Gateway: gateway, + Catalog: music, + Media: media.NewLibrary(store, store, objects), + Playback: media.NewPlayback(src.playback, store), + Site: site.New(store), + + StorageURL: cfg.storageURL, + } + httpLogger := base.With("component", "http") + return public{ + handler: publicHandler(api.SameOrigin(cfg.allowedOrigins, api.New(services, metrics, tracer, httpLogger)), cfg.tracing, tracer, httpLogger), + host: host, + gateway: gateway, + jobs: jobs, + watch: worker.Watch(jobs, gateway.PublishPrepared, observe, logger), + }, nil +} + +// trace 在外层,恢复 panic 在内层:这样 panic 的请求才会以状态码 500 记成出错的 span,panic 的日志也带有 trace_id 和 span_id。 +// 每个 HTTP 请求开始一条新的 trace:公开端口上客户端带来的追踪上下文不可信,所以不提取。WebSocket 的升级请求和房间的事件流 +// 都一直持续到连接断开,不记 span;WebSocket 连接中的请求各自记 span。不导出追踪(tracing 为假)时不装 otelhttp:即使使用 +// noop 的 provider,它也会让每个请求付出几微秒和几十次分配。 +func publicHandler(next http.Handler, tracing bool, tracer trace.TracerProvider, logger *slog.Logger) http.Handler { + next = recoverPanics(next, logger) + if !tracing { + return next + } + return otelhttp.NewHandler(next, "alisten", + otelhttp.WithTracerProvider(tracer), + otelhttp.WithPropagators(propagation.NewCompositeTextMapPropagator()), + otelhttp.WithFilter(func(r *http.Request) bool { return r.URL.Path != "/ws" && r.URL.Path != "/api/room/events" }), + ) +} + +// 在数据库迁移完成之后、开始运行房间之前调用。返回的 channel 在 River client 停止时关闭。 +func (p public) start() (<-chan struct{}, error) { + // River client 只由 shutdown 停止,不随 context 结束而停止。 + if err := p.jobs.Start(context.Background()); err != nil { + return nil, fmt.Errorf("启动 River client:%w", err) + } + go p.watch() + return p.jobs.Stopped(), nil +} + +// 房间先于会话停止:会话断开时房间不再处理它们的 Leave,也不再向其余会话广播。 +// 某一步失败时后面的步骤照样执行,返回的错误合并了各步的错误。 +func (p public) shutdown(ctx context.Context, server *http.Server, logger *slog.Logger) error { + var errs []error + if err := p.host.Shutdown(ctx); err != nil { + errs = append(errs, fmt.Errorf("释放房间:%w", err)) + } else { + logger.Info("已释放本实例的全部房间") + } + if err := p.gateway.Shutdown(ctx); err != nil { + errs = append(errs, fmt.Errorf("关闭 WebSocket 会话:%w", err)) + } else { + logger.Info("已关闭全部 WebSocket 会话") + } + if err := server.Shutdown(ctx); err != nil { + errs = append(errs, fmt.Errorf("关闭公开端口:%w", err)) + } + errs = append(errs, p.stopJobs(ctx, logger)) + return errors.Join(errs...) +} + +// 前面的步骤可能已经用完共用的期限,所以 River client 的停止有自己的期限: +// 运行中的任务如果不交还给 River,就会停在 running 状态,要等 20 分钟之后才由 River 重新调度。 +func (p public) stopJobs(ctx context.Context, logger *slog.Logger) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobsStopTimeout) + defer cancel() + if err := p.jobs.StopAndCancel(ctx); err != nil { + return fmt.Errorf("停止 River client:%w", err) + } + logger.Info("已停止执行准备任务") + return nil +} diff --git a/internal/bootstrap/readiness.go b/internal/bootstrap/readiness.go new file mode 100644 index 0000000..8eed097 --- /dev/null +++ b/internal/bootstrap/readiness.go @@ -0,0 +1,49 @@ +package bootstrap + +import ( + "context" + "log/slog" + "regexp" + "sync/atomic" + "time" +) + +const readinessInterval = 5 * time.Second + +// 网络错误带有本机一侧的临时地址和端口(read udp 本机:端口->对端:端口),每次连接都不同,比较原因时要去掉。 +var localAddress = regexp.MustCompile(`\S+->`) + +func reason(err error) string { + return localAddress.ReplaceAllString(err.Error(), "") +} + +// 依赖在后台定期检查,/readyz 只读取最近一次的结果,探针请求本身不访问外部依赖。 +func watchReadiness(ctx context.Context, interval time.Duration, check func(context.Context) error, ready, stopping *atomic.Bool, logger *slog.Logger) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + var last error + for first := true; ; first = false { + checkCtx, cancel := context.WithTimeout(ctx, interval) + err := check(checkCtx) + cancel() + if ctx.Err() != nil { + return + } + ready.Store(err == nil) + // 不可用的原因变化时也要记录,否则日志中只有最初的原因;原因不变时不重复记录。 + switch { + case stopping.Load(): + // 开始退出之后实例有意不就绪,依赖检查的结果不记日志。 + case err != nil && (first || last == nil || reason(err) != reason(last)): + logger.WarnContext(ctx, "依赖检查未通过,实例未就绪", "error", err) + case err == nil && (first || last != nil): + logger.InfoContext(ctx, "依赖检查通过,实例已就绪") + } + last = err + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/internal/bootstrap/redis.go b/internal/bootstrap/redis.go new file mode 100644 index 0000000..d157cd4 --- /dev/null +++ b/internal/bootstrap/redis.go @@ -0,0 +1,76 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "log/slog" + "maps" + "net" + "os" + "strconv" + "sync" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +func loadRedisConfig() (*goredis.Options, error) { + host := os.Getenv("REDIS_HOST") + if host == "" { + return nil, errors.New("缺少 REDIS_HOST") + } + port, err := envPort("REDIS_PORT", 6379) + if err != nil { + return nil, err + } + value := envOr("REDIS_DB", "0") + db, err := strconv.Atoi(value) + if err != nil || db < 0 { + return nil, fmt.Errorf("REDIS_DB 的值 %q 不合法,必须是非负整数", value) + } + return &goredis.Options{ + Addr: net.JoinHostPort(host, strconv.Itoa(port)), + Password: os.Getenv("REDIS_PASSWORD"), + DB: db, + // 不开启时,go-redis 只按自己的读写超时等待,不理会 ctx 的期限。 + ContextTimeoutEnabled: true, + }, nil +} + +func pingRedis(ctx context.Context, rdb *goredis.Client) error { + if err := rdb.Ping(ctx).Err(); err != nil { + return fmt.Errorf("连接 Redis:%w", err) + } + return nil +} + +// Redis 不可用期间,订阅连接每秒重连一次,go-redis 每次都记一条同样的日志:同样的内容一分钟内只记一次。 +const redisLogRepeat = time.Minute + +// go-redis 自己的日志默认以纯文本写到 stderr,接入 slog 之后进程的全部输出才都是 JSON。 +type redisLogger struct { + logger *slog.Logger + + mu sync.Mutex + logged map[string]time.Time +} + +func newRedisLogger(logger *slog.Logger) *redisLogger { + return &redisLogger{logger: logger, logged: map[string]time.Time{}} +} + +func (l *redisLogger) Printf(ctx context.Context, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + now := time.Now() + l.mu.Lock() + maps.DeleteFunc(l.logged, func(_ string, at time.Time) bool { return now.Sub(at) >= redisLogRepeat }) + _, repeated := l.logged[msg] + if !repeated { + l.logged[msg] = now + } + l.mu.Unlock() + if !repeated { + l.logger.WarnContext(ctx, msg) + } +} diff --git a/internal/bootstrap/serve.go b/internal/bootstrap/serve.go new file mode 100644 index 0000000..db2dacb --- /dev/null +++ b/internal/bootstrap/serve.go @@ -0,0 +1,217 @@ +package bootstrap + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + goredis "github.com/redis/go-redis/v9" + + "github.com/dihemoe/alisten/internal/adapter/in/ops" + "github.com/dihemoe/alisten/internal/adapter/out/bilibili" + "github.com/dihemoe/alisten/internal/adapter/out/netease" + "github.com/dihemoe/alisten/internal/adapter/out/postgres" + "github.com/dihemoe/alisten/internal/adapter/out/qq" + "github.com/dihemoe/alisten/internal/adapter/out/redis" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/realtime" + "github.com/dihemoe/alisten/internal/domain/track" +) + +const ( + // 留给入口发现 /readyz 失败并摘除本实例的时间。 + drainDelay = 5 * time.Second + // 释放房间、关闭会话和关闭端口共用这段期限。 + shutdownTimeout = 17 * time.Second + // River client 的停止有自己的期限:即使前面的步骤用完了共用的期限,运行中的任务也要交还给 River。 + // 三者之和加上导出剩余追踪的 flushTimeout,不能超过 Kubernetes 30 秒的退出宽限期。 + jobsStopTimeout = 3 * time.Second +) + +func runServe(base *slog.Logger) (err error) { + cfg, err := loadServeConfig() + if err != nil { + return err + } + logger := base.With("component", "serve") + + signals, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stopSignals() + + // 每次启动都生成新的实例 ID:上一次运行留下的租约和会话不会被当作这一次的。 + instance := rand.Text() + tracer, flush, err := newTracerProvider(signals, cfg.tracing, "alisten-serve", instance) + if err != nil { + return err + } + defer func() { exitAfterFlush(err, flush, logger, "服务已退出") }() + + traceDatabase(cfg.database, tracer) + pool, err := pgxpool.NewWithConfig(signals, cfg.database) + if err != nil { + return fmt.Errorf("创建 PostgreSQL 连接池:%w", err) + } + defer pool.Close() + goredis.SetLogger(newRedisLogger(base.With("component", "redis"))) + rdb := goredis.NewClient(cfg.redis) + defer func() { _ = rdb.Close() }() + traceRedis(rdb, tracer) + + metrics := prometheus.NewRegistry() + observed := newServeMetrics(metrics) + store := postgres.New(pool) + // 每个请求的期限由音源适配器设置。 + upstream := &http.Client{Transport: traceHTTP(http.DefaultTransport, tracer)} + neteaseSource := netease.New(upstream, store.NeteaseCookie, store.SetNeteaseCookie, observed.provider) + qqSource := qq.New(upstream, store.QQCookie, store.SetQQCookie, observed.provider) + // serve 不下载文件,下载由 worker 在自己的工作目录中进行。 + bilibiliSource := bilibili.New(upstream, "", store.BilibiliCredential, store.SetBilibiliCredential, observed.provider) + src := sources{ + netease: neteaseSource, + qq: qqSource, + bilibili: bilibiliSource, + bilibiliLinks: bilibiliSource, + playback: map[track.Source]port.PlayURLs{track.NetEase: neteaseSource, track.QQ: qqSource}, + neteaseLogin: neteaseSource, + qqLogin: qqSource, + bilibiliLogin: bilibiliSource, + } + p, err := newPublic(cfg, pool, rdb, instance, src, metrics, observed.prepared, tracer, base) + if err != nil { + return err + } + clockOffset := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "alisten_clock_offset_seconds", + Help: "Redis 服务器的时钟减去本实例的时钟,每 30 秒估算一次。", + }) + + var dependenciesReady, stopping atomic.Bool + var starting sync.Mutex + ready := func() bool { return dependenciesReady.Load() && !stopping.Load() } + // HTTP 服务和 River client 意外停止时,进程以错误退出,交给容器编排重启。 + fatal := make(chan error, 4) + migrated := make(chan struct{}) + var migratedOnce sync.Once + background, stopBackground := context.WithCancel(context.Background()) + var running sync.WaitGroup + running.Go(func() { + watchReadiness(background, readinessInterval, func(ctx context.Context) error { + if err := postgres.CheckMigrations(ctx, pool); err != nil { + return err + } + migratedOnce.Do(func() { close(migrated) }) + return pingRedis(ctx, rdb) + }, &dependenciesReady, &stopping, logger) + }) + // 数据库迁移到二进制要求的版本之后,才开始执行准备任务、运行房间;realtime.Start 会一直重试,直到订阅了本实例的频道。 + running.Go(func() { + select { + case <-migrated: + case <-background.Done(): + return + } + // 开始退出之后不再启动 River client:shutdown 只停止已经启动的 client, + // 此后才启动的 client 会带着运行中的任务随进程退出,这些任务要等 20 分钟才由 River 重新调度。 + starting.Lock() + if stopping.Load() { + starting.Unlock() + return + } + stopped, err := p.start() + starting.Unlock() + if err != nil { + fatal <- err + return + } + go func() { + <-stopped + if !stopping.Load() { + fatal <- errors.New("执行准备任务的 River client 意外停止") + } + }() + if wait, err := realtime.Start(background, p.host, p.gateway); err == nil { + wait() + } + }) + running.Go(func() { + watchClock(background, func(ctx context.Context) (time.Duration, error) { return redis.ClockOffset(ctx, rdb) }, clockOffset) + }) + defer func() { + stopBackground() + running.Wait() + }() + + metrics.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "alisten_ready", + Help: "本实例最近一次就绪检查的结果,1 为就绪,0 为未就绪。", + }, func() float64 { + if ready() { + return 1 + } + return 0 + }), + clockOffset, + ) + + publicListener, err := net.Listen("tcp", cfg.listen) + if err != nil { + return fmt.Errorf("监听公开端口 %s:%w", cfg.listen, err) + } + opsListener, err := net.Listen("tcp", cfg.opsListen) + if err != nil { + _ = publicListener.Close() + return fmt.Errorf("监听运维端口 %s:%w", cfg.opsListen, err) + } + + opsLogger := base.With("component", "ops") + publicServer := newHTTPServer(p.handler, base.With("component", "http")) + opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, metrics), opsLogger), opsLogger) + go func() { fatal <- fmt.Errorf("HTTP 服务异常退出:%w", publicServer.Serve(publicListener)) }() + go func() { fatal <- fmt.Errorf("HTTP 服务异常退出:%w", opsServer.Serve(opsListener)) }() + logger.Info("服务已启动", "listen", publicListener.Addr().String(), "ops_listen", opsListener.Addr().String(), "instance", instance) + + var failure error + select { + case failure = <-fatal: + case <-signals.Done(): + } + stopSignals() + + starting.Lock() + stopping.Store(true) + starting.Unlock() + // 出错退出时本实例已经无法正常服务,不必等入口摘除它;照样释放房间,其他实例就不必等租约过期。 + if failure == nil { + logger.Info("收到退出信号,等待入口摘除本实例", "drain", drainDelay.String()) + time.Sleep(drainDelay) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + // 释放房间和关闭会话时仍要收发实例间消息,房间运行时最后才停止。 + if err := p.shutdown(shutdownCtx, publicServer, logger); err != nil { + return errors.Join(failure, err) + } + stopBackground() + running.Wait() + if err := opsServer.Shutdown(shutdownCtx); err != nil { + return errors.Join(failure, fmt.Errorf("关闭运维端口:%w", err)) + } + return failure +} diff --git a/internal/bootstrap/spans.go b/internal/bootstrap/spans.go new file mode 100644 index 0000000..6461ce0 --- /dev/null +++ b/internal/bootstrap/spans.go @@ -0,0 +1,277 @@ +package bootstrap + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "sync" + "unicode" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + goredis "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + semconv "go.opentelemetry.io/otel/semconv/v1.43.0" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" + + "github.com/dihemoe/alisten/internal/app/port" +) + +const scope = "github.com/dihemoe/alisten/internal/bootstrap" + +// 外部调用只在已采样的 trace 中记录子 span:续约、心跳、对账和任务轮询等后台调用每隔几秒就有一次,不能各自成为一条 trace。 +// 父 span 已经结束时也照样记录:owner 处理收到的输入、提交准备任务都发生在发起它们的 span 结束之后。 +// 需要计算名字和属性的调用先用它判断,没有采样的后台调用就不必付出这份开销。 +func inSampledTrace(ctx context.Context) bool { + return trace.SpanContextFromContext(ctx).IsSampled() +} + +func startChild(ctx context.Context, tracer trace.Tracer, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + if !inSampledTrace(ctx) { + return ctx, noop.Span{} + } + return tracer.Start(ctx, name, append(opts, trace.WithSpanKind(trace.SpanKindClient))...) +} + +// otelriver 提交任务时总是开始 producer span:没有发起它的请求时(接管之后重新准备、AutoDJ),它会自成一条 trace。 +// 这里只在已采样的 trace 中开始它;没有 span 时,任务的元数据中也没有追踪上下文。执行任务的 consumer span 不受影响,照常另开 trace。 +type insertsInTraces struct{ trace.TracerProvider } + +func (p insertsInTraces) Tracer(name string, opts ...trace.TracerOption) trace.Tracer { + return insertTracer{p.TracerProvider.Tracer(name, opts...)} +} + +type insertTracer struct{ trace.Tracer } + +func (t insertTracer) Start(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + cfg := trace.NewSpanStartConfig(opts...) + if cfg.SpanKind() == trace.SpanKindProducer && !inSampledTrace(ctx) { + return ctx, noop.Span{} + } + return t.Tracer.Start(ctx, name, opts...) +} + +// span 的状态只记出错,不记错误的文本:上游的错误中可能有带签名的地址,它们只写进日志。 +func recordError(span trace.Span, err error) error { + if err != nil { + span.SetStatus(codes.Error, "") + } + return err +} + +// 只记 SQL 语句,不记参数。 +func traceDatabase(cfg *pgxpool.Config, tracer trace.TracerProvider) { + cfg.ConnConfig.Tracer = databaseSpans{tracer: tracer.Tracer(scope)} +} + +type databaseSpans struct { + tracer trace.Tracer +} + +// 在 ctx 中记下 TraceQueryStart 开始的 span:TraceQueryEnd 只结束这个 span,不能结束 ctx 中原有的 span。 +type querySpan struct{} + +func (d databaseSpans) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context { + if !inSampledTrace(ctx) { + return ctx + } + return d.start(ctx, queryName(data.SQL), semconv.DBQueryText(data.SQL)) +} + +func (d databaseSpans) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryEndData) { + endQuery(ctx, data.Err) +} + +func (d databaseSpans) TraceCopyFromStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceCopyFromStartData) context.Context { + if !inSampledTrace(ctx) { + return ctx + } + return d.start(ctx, "COPY "+data.TableName.Sanitize(), semconv.DBCollectionName(data.TableName.Sanitize())) +} + +func (d databaseSpans) TraceCopyFromEnd(ctx context.Context, _ *pgx.Conn, data pgx.TraceCopyFromEndData) { + endQuery(ctx, data.Err) +} + +func (d databaseSpans) start(ctx context.Context, name string, attr attribute.KeyValue) context.Context { + ctx, span := startChild(ctx, d.tracer, name, trace.WithAttributes(semconv.DBSystemNamePostgreSQL, attr)) + if !span.IsRecording() { + return ctx + } + return context.WithValue(ctx, querySpan{}, span) +} + +func endQuery(ctx context.Context, err error) { + if span, ok := ctx.Value(querySpan{}).(trace.Span); ok { + _ = recordError(span, err) + span.End() + } +} + +// sqlc 和 River 的查询都以"-- name: 查询名 :类型"开头,span 以查询名命名;其他语句取第一个词,比如 BEGIN、COMMIT。 +// 只看第一个词:River 每秒轮询的查询有 1.5 KB,不必扫描整条语句。 +func queryName(sql string) string { + rest, named := strings.CutPrefix(sql, "-- name: ") + word := strings.TrimLeftFunc(rest, unicode.IsSpace) + if end := strings.IndexFunc(word, unicode.IsSpace); end >= 0 { + word = word[:end] + } + switch { + case word == "": + return "QUERY" + case named: + return word + } + return strings.ToUpper(word) +} + +func traceRedis(rdb *goredis.Client, tracer trace.TracerProvider) { + rdb.AddHook(redisSpans{tracer: tracer.Tracer(scope)}) +} + +// 只记命令名,不记参数:实例间消息中有成员资料和聊天内容。 +type redisSpans struct { + tracer trace.Tracer +} + +func (redisSpans) DialHook(next goredis.DialHook) goredis.DialHook { + return next +} + +func (h redisSpans) ProcessHook(next goredis.ProcessHook) goredis.ProcessHook { + return func(ctx context.Context, cmd goredis.Cmder) error { + if !inSampledTrace(ctx) { + return next(ctx, cmd) + } + ctx, span := startChild(ctx, h.tracer, cmd.FullName(), trace.WithAttributes(semconv.DBSystemNameRedis)) + defer span.End() + return redisError(span, next(ctx, cmd)) + } +} + +func (h redisSpans) ProcessPipelineHook(next goredis.ProcessPipelineHook) goredis.ProcessPipelineHook { + return func(ctx context.Context, cmds []goredis.Cmder) error { + if !inSampledTrace(ctx) { + return next(ctx, cmds) + } + ctx, span := startChild(ctx, h.tracer, "pipeline", trace.WithAttributes(semconv.DBSystemNameRedis)) + defer span.End() + return redisError(span, next(ctx, cmds)) + } +} + +// goredis.Nil 表示键不存在,不是出错。NOSCRIPT 是 Script.Run 的正常回退:脚本不在 Redis 的缓存中时(Redis 重启或清空缓存之后 +// 每个脚本的第一次调用),EVALSHA 先收到它,随后改用 EVAL。 +func redisError(span trace.Span, err error) error { + if errors.Is(err, goredis.Nil) || goredis.HasErrorPrefix(err, "NOSCRIPT") { + return err + } + return recordError(span, err) +} + +// 只记方法、主机名和状态码:音源的地址中有签名、vkey 和用户的 ID。也不向音源传递追踪上下文。 +// span 在响应体读完或关闭时结束,下载音频的时间也算在其中。 +func traceHTTP(next http.RoundTripper, tracer trace.TracerProvider) http.RoundTripper { + return clientSpans{next: next, tracer: tracer.Tracer(scope)} +} + +type clientSpans struct { + next http.RoundTripper + tracer trace.Tracer +} + +func (t clientSpans) RoundTrip(r *http.Request) (*http.Response, error) { + if !inSampledTrace(r.Context()) { + return t.next.RoundTrip(r) + } + ctx, span := startChild(r.Context(), t.tracer, "HTTP "+r.Method, trace.WithAttributes( + semconv.HTTPRequestMethodKey.String(r.Method), + semconv.ServerAddress(r.URL.Hostname()), + )) + if !span.IsRecording() { + return t.next.RoundTrip(r) + } + resp, err := t.next.RoundTrip(r.WithContext(ctx)) + if err != nil { + _ = recordError(span, err) + span.End() + return nil, err + } + span.SetAttributes(semconv.HTTPResponseStatusCode(resp.StatusCode)) + if resp.StatusCode >= http.StatusBadRequest { + span.SetStatus(codes.Error, http.StatusText(resp.StatusCode)) + } + resp.Body = &spanBody{ReadCloser: resp.Body, span: span} + return resp, nil +} + +type spanBody struct { + io.ReadCloser + span trace.Span + once sync.Once +} + +func (b *spanBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + if err != nil { + b.end() + } + return n, err +} + +func (b *spanBody) Close() error { + b.end() + return b.ReadCloser.Close() +} + +func (b *spanBody) end() { + b.once.Do(func() { b.span.End() }) +} + +func traceDownloads(download port.LinkDownloader, tracer trace.TracerProvider) port.LinkDownloader { + return tracedDownloads{LinkDownloader: download, tracer: tracer.Tracer(scope)} +} + +type tracedDownloads struct { + port.LinkDownloader + tracer trace.Tracer +} + +func (d tracedDownloads) Download(ctx context.Context, link string) (port.LinkDownload, func(), error) { + ctx, span := startChild(ctx, d.tracer, "yt-dlp") + defer span.End() + result, release, err := d.LinkDownloader.Download(ctx, link) + return result, release, recordError(span, err) +} + +func traceObjects(store port.MediaStore, tracer trace.TracerProvider) port.MediaStore { + return tracedObjects{MediaStore: store, tracer: tracer.Tracer(scope)} +} + +type tracedObjects struct { + port.MediaStore + tracer trace.Tracer +} + +func (s tracedObjects) Put(ctx context.Context, key string, body io.ReadSeeker, contentType string) error { + ctx, span := startChild(ctx, s.tracer, "S3 PutObject") + defer span.End() + return recordError(span, s.MediaStore.Put(ctx, key, body, contentType)) +} + +func (s tracedObjects) Exists(ctx context.Context, key string) (bool, error) { + ctx, span := startChild(ctx, s.tracer, "S3 HeadObject") + defer span.End() + ok, err := s.MediaStore.Exists(ctx, key) + return ok, recordError(span, err) +} + +func (s tracedObjects) Delete(ctx context.Context, key string) error { + ctx, span := startChild(ctx, s.tracer, "S3 DeleteObject") + defer span.End() + return recordError(span, s.MediaStore.Delete(ctx, key)) +} diff --git a/internal/bootstrap/tracing.go b/internal/bootstrap/tracing.go new file mode 100644 index 0000000..6515c5a --- /dev/null +++ b/internal/bootstrap/tracing.go @@ -0,0 +1,96 @@ +package bootstrap + +import ( + "context" + "fmt" + "log/slog" + "slices" + "time" + + "github.com/go-logr/logr" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.43.0" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// serve 和 worker 自己的退出期限加上它,仍在 Kubernetes 30 秒的退出宽限期之内。 +const flushTimeout = 2 * time.Second + +// export 为假时返回不记录 span 的 provider,日志中也就没有 trace_id 和 span_id。导出器按 OpenTelemetry 的规范 +// 自己读取 OTEL_EXPORTER_OTLP_ENDPOINT(在它的路径后加上 /v1/traces)和其他 OTEL_EXPORTER_OTLP_* 变量。 +// 返回的函数要在进程退出之前调用,导出尚未发出的 span。 +func newTracerProvider(ctx context.Context, export bool, service, instance string) (trace.TracerProvider, func(context.Context) error, error) { + if !export { + return noop.NewTracerProvider(), func(context.Context) error { return nil }, nil + } + exporter, err := otlptracehttp.New(ctx) + if err != nil { + return nil, nil, fmt.Errorf("创建 OTLP 导出器:%w", err) + } + res, err := resource.Merge(resource.Default(), resource.NewSchemaless( + semconv.ServiceName(service), + semconv.ServiceVersion(version), + semconv.ServiceInstanceID(instance), + )) + if err != nil { + return nil, nil, fmt.Errorf("组装追踪的资源属性:%w", err) + } + provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter), sdktrace.WithResource(res)) + return provider, provider.Shutdown, nil +} + +// 先导出剩下的 span,再写退出日志:导出失败的告警排在退出日志之前,退出日志才是进程真正退出之前的最后一条。 +// 进程出错退出时(err 不为空)由调用方记录错误,不写退出日志。 +func exitAfterFlush(err error, flush func(context.Context) error, logger *slog.Logger, exited string) { + ctx, cancel := context.WithTimeout(context.Background(), flushTimeout) + defer cancel() + if flushErr := flush(ctx); flushErr != nil { + logger.WarnContext(ctx, "导出剩下的追踪失败", "error", flushErr) + } + if err == nil { + logger.Info(exited) + } +} + +// OpenTelemetry 自己的日志默认以纯文本写到 stderr,导出失败之类的错误也只交给标准库 log 包。 +// 它的字段放进 otel 分组:创建 tracer 的日志带有 version 字段,不能与日志的固定字段 version 重名。 +// 它还会把写错的环境变量的原值记成字段(比如 OTEL_EXPORTER_OTLP_HEADERS 中的凭证),所以只保留 err 字段的值。 +func routeOpenTelemetryLogs(logger *slog.Logger) { + otel.SetLogger(logr.FromSlogHandler(errorsOnly{logger.WithGroup("otel").Handler()})) + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { + logger.Warn("OpenTelemetry 报告错误", "error", err) + })) +} + +type errorsOnly struct{ slog.Handler } + +func (h errorsOnly) Handle(ctx context.Context, r slog.Record) error { + out := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + r.Attrs(func(a slog.Attr) bool { + out.AddAttrs(redactUnlessError(a)) + return true + }) + return h.Handler.Handle(ctx, out) +} + +func (h errorsOnly) WithAttrs(attrs []slog.Attr) slog.Handler { + attrs = slices.Clone(attrs) + for i := range attrs { + attrs[i] = redactUnlessError(attrs[i]) + } + return errorsOnly{h.Handler.WithAttrs(attrs)} +} + +func (h errorsOnly) WithGroup(name string) slog.Handler { return errorsOnly{h.Handler.WithGroup(name)} } + +// logr 把 Error 的错误参数记在 err 字段里。 +func redactUnlessError(a slog.Attr) slog.Attr { + if a.Key == "err" { + return a + } + return slog.String(a.Key, redacted) +} diff --git a/internal/bootstrap/worker.go b/internal/bootstrap/worker.go new file mode 100644 index 0000000..bf28b61 --- /dev/null +++ b/internal/bootstrap/worker.go @@ -0,0 +1,325 @@ +package bootstrap + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + goredis "github.com/redis/go-redis/v9" + "github.com/riverqueue/river" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" + + "github.com/dihemoe/alisten/internal/adapter/in/ops" + "github.com/dihemoe/alisten/internal/adapter/in/worker" + "github.com/dihemoe/alisten/internal/adapter/out/bilibili" + "github.com/dihemoe/alisten/internal/adapter/out/ffmpeg" + "github.com/dihemoe/alisten/internal/adapter/out/linkcookies" + "github.com/dihemoe/alisten/internal/adapter/out/postgres" + "github.com/dihemoe/alisten/internal/adapter/out/queue" + "github.com/dihemoe/alisten/internal/adapter/out/redis" + "github.com/dihemoe/alisten/internal/adapter/out/storage" + "github.com/dihemoe/alisten/internal/adapter/out/ytdlp" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/prepare" + "github.com/dihemoe/alisten/internal/lib/logctx" +) + +const ( + // 停止 River client 和关闭运维端口共用这段期限,加上导出剩余追踪的 flushTimeout,不能超过 Kubernetes 30 秒的退出宽限期。 + workerStopTimeout = 25 * time.Second + publishTimeout = 5 * time.Second + waitingInterval = 15 * time.Second + waitingTimeout = 5 * time.Second +) + +type workerConfig struct { + opsListen string + database *pgxpool.Config + redis *goredis.Options + storage storage.Config + tracing bool +} + +func loadWorkerConfig() (workerConfig, error) { + opsListen, err := loadOpsListen() + if err != nil { + return workerConfig{}, err + } + database, err := loadDatabaseConfig() + if err != nil { + return workerConfig{}, err + } + redis, err := loadRedisConfig() + if err != nil { + return workerConfig{}, err + } + objects, err := loadStorageConfig() + if err != nil { + return workerConfig{}, err + } + tracing, err := loadTracing() + if err != nil { + return workerConfig{}, err + } + return workerConfig{opsListen: opsListen, database: database, redis: redis, storage: objects, tracing: tracing}, nil +} + +// yt-dlp 会处理用户提交的任意链接。 +func refuseRoot(euid int) error { + if euid == 0 { + return errors.New("worker 不能以 root 身份运行") + } + return nil +} + +func runWorker(base *slog.Logger) (err error) { + if err := refuseRoot(os.Geteuid()); err != nil { + return err + } + cfg, err := loadWorkerConfig() + if err != nil { + return err + } + signals, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stopSignals() + + instance := rand.Text() + tracer, flush, err := newTracerProvider(signals, cfg.tracing, "alisten-worker", instance) + if err != nil { + return err + } + defer func() { exitAfterFlush(err, flush, base.With("component", "worker"), "worker 已退出") }() + + traceDatabase(cfg.database, tracer) + pool, err := pgxpool.NewWithConfig(signals, cfg.database) + if err != nil { + return fmt.Errorf("创建 PostgreSQL 连接池:%w", err) + } + defer pool.Close() + goredis.SetLogger(newRedisLogger(base.With("component", "redis"))) + rdb := goredis.NewClient(cfg.redis) + defer func() { _ = rdb.Close() }() + traceRedis(rdb, tracer) + + metrics := prometheus.NewRegistry() + observed := newWorkerMetrics(metrics) + workDir, err := prepareWorkDir() + if err != nil { + return err + } + store := postgres.New(pool) + // 每个请求的期限由音源适配器设置。 + upstream := &http.Client{Transport: traceHTTP(http.DefaultTransport, tracer)} + cookies := linkcookies.New(workDir, store.YouTubeCookies) + src := workerSources{ + links: ytdlp.New(workDir, net.DefaultResolver, cookies, observed.downloaded, base.With("component", "ytdlp")), + bilibili: bilibili.New(upstream, workDir, store.BilibiliCredential, store.SetBilibiliCredential, observed.provider), + transcoder: ffmpeg.New(workDir, base.With("component", "ffmpeg")), + } + w, err := newMediaWorker(cfg, pool, rdb, src, metrics, observed, instance, tracer, base) + if err != nil { + return err + } + listener, err := net.Listen("tcp", cfg.opsListen) + if err != nil { + return fmt.Errorf("监听运维端口 %s:%w", cfg.opsListen, err) + } + return w.run(signals, listener) +} + +// 任务的临时目录都建在 TMPDIR 下以主机名命名的目录中。进程崩溃时来不及清理的任务目录会留在那里; +// 同一个容器(Kubernetes 中的同一个 Pod)重启后主机名不变,启动时在这里清除。其他容器的目录不动: +// 它们可能共用同一个 TMPDIR,任务仍在运行。每个容器只能运行一个 worker。 +func prepareWorkDir() (string, error) { + host, err := os.Hostname() + if err != nil { + return "", fmt.Errorf("读取主机名:%w", err) + } + dir := filepath.Join(os.TempDir(), "alisten-link-"+host) + if err := os.RemoveAll(dir); err != nil { + return "", fmt.Errorf("清理上一次运行留下的临时目录 %s:%w", dir, err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("创建临时目录 %s:%w", dir, err) + } + return dir, nil +} + +type workerSources struct { + links port.LinkDownloader + bilibili port.BilibiliSource + transcoder port.Transcoder +} + +type mediaWorker struct { + pool *pgxpool.Pool + rdb *goredis.Client + jobs *river.Client[pgx.Tx] + watch func() + metrics *prometheus.Registry + observed *workerMetrics + instance string + logger *slog.Logger + base *slog.Logger +} + +func newMediaWorker(cfg workerConfig, pool *pgxpool.Pool, rdb *goredis.Client, src workerSources, metrics *prometheus.Registry, observed *workerMetrics, instance string, tracer trace.TracerProvider, base *slog.Logger) (mediaWorker, error) { + logger := base.With("component", "prepare") + runner := prepare.NewMediaRunner(traceDownloads(src.links, tracer), src.bilibili, src.transcoder, traceObjects(storage.New(cfg.storage), tracer), postgres.New(pool), logger) + jobs, err := worker.NewMediaClient(pool, runner.Run, tracer, logger) + if err != nil { + return mediaWorker{}, fmt.Errorf("创建 River client:%w", err) + } + // worker 只发布准备结果、不订阅,不会丢弃实例间消息,所以不暴露丢弃的计数;也不会收到房间输入,所以不记录收到输入的 span。 + bus := redis.NewBus(rdb, instance, version, prometheus.NewRegistry(), noop.NewTracerProvider(), base.With("component", "bus")) + publish := func(o port.PrepareOutcome) { + ctx, cancel := context.WithTimeout(logctx.WithJob(context.Background(), o.JobID), publishTimeout) + defer cancel() + // 广播失败时只记录日志:等待的一方会定期按任务 ID 核对。 + if err := bus.PublishPrepared(ctx, o); err != nil { + logger.WarnContext(ctx, "广播准备结果失败", "source", o.Track.Source, "track_id", o.Track.ID, "error", err) + } + } + return mediaWorker{ + pool: pool, + rdb: rdb, + jobs: jobs, + watch: worker.Watch(jobs, publish, observed.prepared, logger), + metrics: metrics, + observed: observed, + instance: instance, + logger: base.With("component", "worker"), + base: base, + }, nil +} + +// 一直运行到 ctx 结束,或者运维端口、River client 意外停止;后者以错误返回,交给容器编排重启。 +// 数据库迁移完成之前保持未就绪,迁移完成之后才启动 River client。 +func (w mediaWorker) run(ctx context.Context, listener net.Listener) error { + var dependenciesReady, stopping atomic.Bool + var starting sync.Mutex + ready := func() bool { return dependenciesReady.Load() && !stopping.Load() } + fatal := make(chan error, 2) + migrated := make(chan struct{}) + var migratedOnce sync.Once + background, stopBackground := context.WithCancel(context.Background()) + var running sync.WaitGroup + running.Go(func() { + watchReadiness(background, readinessInterval, func(ctx context.Context) error { + if err := postgres.CheckMigrations(ctx, w.pool); err != nil { + return err + } + migratedOnce.Do(func() { close(migrated) }) + return pingRedis(ctx, w.rdb) + }, &dependenciesReady, &stopping, w.logger) + }) + running.Go(func() { + select { + case <-migrated: + case <-background.Done(): + return + } + // 开始退出之后不再启动 River client:此后才启动的 client 不会被停止,运行中的任务要等 20 分钟才由 River 重新调度。 + starting.Lock() + if stopping.Load() { + starting.Unlock() + return + } + // River client 只在退出时停止,不随 context 结束而停止。 + err := w.jobs.Start(context.Background()) + starting.Unlock() + if err != nil { + fatal <- fmt.Errorf("启动 River client:%w", err) + return + } + go w.watch() + select { + case <-w.jobs.Stopped(): + if !stopping.Load() { + fatal <- errors.New("执行准备任务的 River client 意外停止") + } + case <-background.Done(): + } + }) + running.Go(func() { + watchWaitingJobs(background, func(ctx context.Context) (int64, error) { return queue.WaitingLinkJobs(ctx, w.pool) }, w.observed.waitingJobs) + }) + + w.metrics.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "alisten_ready", + Help: "本实例最近一次就绪检查的结果,1 为就绪,0 为未就绪。", + }, func() float64 { + if ready() { + return 1 + } + return 0 + }), + ) + opsLogger := w.base.With("component", "ops") + opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, w.metrics), opsLogger), opsLogger) + go func() { fatal <- fmt.Errorf("运维端口异常退出:%w", opsServer.Serve(listener)) }() + w.logger.Info("worker 已启动", "ops_listen", listener.Addr().String(), "instance", w.instance) + + var failure error + select { + case failure = <-fatal: + case <-ctx.Done(): + } + // worker 不接收流量,不必等待入口摘除。 + starting.Lock() + stopping.Store(true) + starting.Unlock() + + stopCtx, cancel := context.WithTimeout(context.Background(), workerStopTimeout) + defer cancel() + errs := []error{failure} + // 运行中的任务会被取消,交给 River 重试。 + if err := w.jobs.StopAndCancel(stopCtx); err != nil { + errs = append(errs, fmt.Errorf("停止 River client:%w", err)) + } else { + w.logger.Info("已停止执行准备任务") + } + stopBackground() + running.Wait() + if err := opsServer.Shutdown(stopCtx); err != nil { + errs = append(errs, fmt.Errorf("关闭运维端口:%w", err)) + } + return errors.Join(errs...) +} + +// 统计失败时保留上一次的值:连不上数据库已经反映在就绪检查中。 +func watchWaitingJobs(ctx context.Context, count func(context.Context) (int64, error), waiting prometheus.Gauge) { + ticker := time.NewTicker(waitingInterval) + defer ticker.Stop() + for { + countCtx, cancel := context.WithTimeout(ctx, waitingTimeout) + if n, err := count(countCtx); err == nil { + waiting.Set(float64(n)) + } + cancel() + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/internal/domain/member/member.go b/internal/domain/member/member.go new file mode 100644 index 0000000..e4bdae0 --- /dev/null +++ b/internal/domain/member/member.go @@ -0,0 +1,7 @@ +package member + +type Member struct { + ID string + Name string + AvatarHash string +} diff --git a/internal/domain/room/effect.go b/internal/domain/room/effect.go new file mode 100644 index 0000000..30b8960 --- /dev/null +++ b/internal/domain/room/effect.go @@ -0,0 +1,142 @@ +package room + +import ( + "time" + + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/track" +) + +type Effect interface{ effect() } + +type Persist struct { + Snapshot Snapshot +} + +type Emit struct { + Term uint32 + Seq uint64 + Event Event +} + +type Reply struct { + RequestID RequestID + Result Result + Err *Error +} + +type Prepare struct { + Track track.Track +} + +type Recommend struct { + Token Token +} + +type Schedule struct { + At time.Time + Token Token +} + +type RecordPlay struct { + Entry Entry + StartedAt time.Time + EndedAt time.Time +} + +type Close struct{} + +func (Persist) effect() {} +func (Emit) effect() {} +func (Reply) effect() {} +func (Prepare) effect() {} +func (Recommend) effect() {} +func (Schedule) effect() {} +func (RecordPlay) effect() {} +func (Close) effect() {} + +type Event interface{ event() } + +type PlaybackChanged struct { + Current *NowPlaying +} + +type QueueChanged struct { + Pending *Entry + Waiting []Entry +} + +type MembersChanged struct { + Sessions []Session +} + +type ModeChanged struct { + Mode PlayMode +} + +type InfoChanged struct { + Name string + Description string +} + +type ChatPosted struct { + Member member.Member + Text string + SentAt time.Time +} + +type ActivityKind uint8 + +const ( + ActivityAdded ActivityKind = iota + 1 + ActivityRemoved + ActivityLiked + ActivityVoted + ActivitySkipped + ActivityFailed +) + +type Activity struct { + Kind ActivityKind + Member member.Member + Entry Entry + Votes int + Required int + Reason string +} + +func (PlaybackChanged) event() {} +func (QueueChanged) event() {} +func (MembersChanged) event() {} +func (ModeChanged) event() {} +func (InfoChanged) event() {} +func (ChatPosted) event() {} +func (Activity) event() {} + +type Result interface{ result() } + +type Done struct{} + +type EnqueueResult struct { + Entry Entry +} + +type LikeResult struct { + Likes int +} + +type VoteResult struct { + Votes int + Required int + Skipped bool +} + +type SyncResult struct { + View View +} + +func (Done) result() {} +func (EnqueueResult) result() {} +func (LikeResult) result() {} +func (VoteResult) result() {} +func (SyncResult) result() {} diff --git a/internal/domain/room/error.go b/internal/domain/room/error.go new file mode 100644 index 0000000..cd5358e --- /dev/null +++ b/internal/domain/room/error.go @@ -0,0 +1,31 @@ +package room + +import "fmt" + +type ErrorKind uint8 + +const ( + NotFound ErrorKind = iota + 1 + Forbidden + Conflict + QueueFull + RateLimited + NothingPlaying +) + +type Error struct { + Kind ErrorKind + Message string +} + +func (e *Error) Error() string { return e.Message } + +var ( + errRateLimited = &Error{RateLimited, "操作过于频繁,请稍后再试。"} + errLinkForbidden = &Error{Forbidden, "临时房间只能点哔哩哔哩的链接。"} + errQueueFull = &Error{QueueFull, fmt.Sprintf("临时房间的播放列表最多 %d 首歌,等播放几首之后再点。", ephemeralQueueLimit)} + errDuplicatePick = &Error{Conflict, "这首歌已经在播放列表里了。"} + errNotInQueue = &Error{NotFound, "这首歌已经不在播放列表里了。"} + errNothingPlaying = &Error{NothingPlaying, "现在没有在放的歌。"} + errDuplicateVote = &Error{Conflict, "你已经投过票了。"} +) diff --git a/internal/domain/room/input.go b/internal/domain/room/input.go new file mode 100644 index 0000000..ac5f590 --- /dev/null +++ b/internal/domain/room/input.go @@ -0,0 +1,104 @@ +package room + +import ( + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/track" +) + +type Input interface{ input() } + +type Join struct { + Session SessionID + Member member.Member +} + +type Leave struct { + Session SessionID +} + +type MemberUpdated struct { + Member member.Member +} + +type UpdateInfo struct { + RequestID RequestID + Name string + Description string +} + +type Delete struct { + RequestID RequestID +} + +type Command struct { + RequestID RequestID + Member member.Member + Action Action +} + +type Prepared struct { + Track track.Track + Meta track.Meta + Result track.Prepared +} + +type PrepareFailed struct { + Track track.Track + Reason string +} + +type Recommended struct { + Token Token + Track track.Track + Meta track.Meta +} + +type Timer struct { + Token Token +} + +func (Join) input() {} +func (Leave) input() {} +func (MemberUpdated) input() {} +func (UpdateInfo) input() {} +func (Delete) input() {} +func (Command) input() {} +func (Prepared) input() {} +func (PrepareFailed) input() {} +func (Recommended) input() {} +func (Timer) input() {} + +type Action interface{ action() } + +type Enqueue struct { + Track track.Track + Meta track.Meta +} + +type Remove struct { + Entry EntryID +} + +type Like struct { + Entry EntryID +} + +type VoteSkip struct{} + +type SetPlayMode struct { + Mode PlayMode +} + +type Chat struct { + Text string +} + +type Sync struct{} + +func (Enqueue) action() {} +func (Remove) action() {} +func (Like) action() {} +func (VoteSkip) action() {} +func (SetPlayMode) action() {} +func (Chat) action() {} +func (Sync) action() {} diff --git a/internal/domain/room/limiter.go b/internal/domain/room/limiter.go new file mode 100644 index 0000000..ee1cb39 --- /dev/null +++ b/internal/domain/room/limiter.go @@ -0,0 +1,20 @@ +package room + +import "time" + +// 令牌桶用 GCRA 的理论到达时间表示,只做整数时间运算:浮点运算在不同平台上可能被编译器融合成不同的结果,状态机就不再确定。 +type limiter struct { + tat time.Time +} + +func (l *limiter) allow(now time.Time, burst int, interval time.Duration) bool { + tat := l.tat + if tat.Before(now) { + tat = now + } + if tat.Sub(now) > time.Duration(burst-1)*interval { + return false + } + l.tat = tat.Add(interval) + return true +} diff --git a/internal/domain/room/playback.go b/internal/domain/room/playback.go new file mode 100644 index 0000000..599271f --- /dev/null +++ b/internal/domain/room/playback.go @@ -0,0 +1,88 @@ +package room + +import ( + "slices" + "time" + + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/track" +) + +func (r *Room) advanceIfIdle(b *batch) { + if r.current != nil || r.pending != nil || len(r.waiting) == 0 { + return + } + i := 0 + if r.mode == Random { + i = b.rand.IntN(len(r.waiting)) + } + next := r.waiting[i] + r.waiting = slices.Delete(r.waiting, i, i+1) + r.pending = &next + if next.Ready { + r.start(b) + } +} + +func (r *Room) start(b *batch) { + r.current = &NowPlaying{Entry: *r.pending, StartedAt: b.now.Add(startLead)} + r.pending = nil + r.votes = nil + r.endTimer = r.schedule(b, r.current.EndsAt()) + by := requesterOf(r.current.Entry.By) + r.served[by] = max(r.served[by], r.current.Entry.Round) + // 推荐基于网易云的相似歌曲。 + if r.current.Entry.Track.Source == track.NetEase && r.autoDJReady(b.now) { + r.recommendation = r.issue() + b.tasks = append(b.tasks, Recommend{Token: r.recommendation}) + } +} + +func (r *Room) autoDJReady(now time.Time) bool { + return len(r.waiting) == 0 && len(r.sessions) > 0 && now.Sub(r.lastPick) >= autoDJQuiet +} + +func (r *Room) endCurrent(b *batch, at time.Time) { + c := *r.current + r.current = nil + r.endTimer = 0 + // 推荐只对发出它的那首歌有效。recommended 也依赖这一点:token 有效时 current 一定非空。 + r.recommendation = 0 + // 开播前的 startLead 之内被切掉时,结束时间不能早于开始时间。 + if at.Before(c.StartedAt) { + at = c.StartedAt + } + b.plays = append(b.plays, RecordPlay{Entry: c.Entry, StartedAt: c.StartedAt, EndedAt: at}) +} + +// 房间关闭时还在播放的歌也算结束,要写播放记录;关闭可能晚于歌曲原定的结束时间送达,记录不能比歌曲本身还长。 +func (r *Room) stopPlayback(b *batch) { + if r.current == nil { + return + } + at := r.current.EndsAt() + if b.now.Before(at) { + at = b.now + } + r.endCurrent(b, at) +} + +func (r *Room) voteSkip(b *batch, by member.Member) (Result, *Error) { + if r.current == nil { + return nil, errNothingPlaying + } + if slices.Contains(r.votes, by.ID) { + return nil, errDuplicateVote + } + r.votes = append(r.votes, by.ID) + votes, required := len(r.votes), max((len(r.sessions)+2)/3, 1) + entry := r.current.Entry + if votes < required { + b.event(Activity{Kind: ActivityVoted, Member: by, Entry: entry, Votes: votes, Required: required}) + return VoteResult{Votes: votes, Required: required}, nil + } + b.event(Activity{Kind: ActivitySkipped, Member: by, Entry: entry, Votes: votes, Required: required}) + r.endCurrent(b, b.now) + r.advanceIfIdle(b) + return VoteResult{Votes: votes, Required: required, Skipped: true}, nil +} diff --git a/internal/domain/room/presence.go b/internal/domain/room/presence.go new file mode 100644 index 0000000..fe307eb --- /dev/null +++ b/internal/domain/room/presence.go @@ -0,0 +1,30 @@ +package room + +import "slices" + +func (r *Room) join(in Join) { + if slices.ContainsFunc(r.sessions, func(s Session) bool { return s.ID == in.Session }) { + return + } + r.sessions = append(r.sessions, Session{ID: in.Session, Member: in.Member}) + r.closeTimer = 0 +} + +func (r *Room) leave(b *batch, in Leave) { + i := slices.IndexFunc(r.sessions, func(s Session) bool { return s.ID == in.Session }) + if i < 0 { + return + } + r.sessions = slices.Delete(r.sessions, i, i+1) + if len(r.sessions) == 0 && r.info.Kind == Ephemeral { + r.closeTimer = r.schedule(b, b.now.Add(closeDelay)) + } +} + +func (r *Room) memberUpdated(in MemberUpdated) { + for i := range r.sessions { + if r.sessions[i].Member.ID == in.Member.ID { + r.sessions[i].Member = in.Member + } + } +} diff --git a/internal/domain/room/queue.go b/internal/domain/room/queue.go new file mode 100644 index 0000000..8f1eece --- /dev/null +++ b/internal/domain/room/queue.go @@ -0,0 +1,212 @@ +package room + +import ( + "cmp" + "maps" + "slices" + + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/track" +) + +func (r *Room) command(b *batch, c Command) { + var res Result + var err *Error + switch a := c.Action.(type) { + case Enqueue: + res, err = r.enqueue(b, c.Member, a) + case Remove: + res, err = r.remove(b, c.Member, a) + case Like: + res, err = r.like(b, c.Member, a) + case VoteSkip: + res, err = r.voteSkip(b, c.Member) + case SetPlayMode: + r.mode = a.Mode + res = Done{} + case Chat: + b.event(ChatPosted{Member: c.Member, Text: truncate(a.Text, chatLimit), SentAt: b.now}) + res = Done{} + case Sync: + res = SyncResult{View: r.view()} + } + b.respond(c.RequestID, res, err) +} + +func (r *Room) burst() int { + if r.info.Kind == Persistent { + return persistentBurst + } + return ephemeralBurst +} + +func (r *Room) enqueue(b *batch, by member.Member, a Enqueue) (Result, *Error) { + if !r.pickLimit.allow(b.now, r.burst(), refillInterval) { + return nil, errRateLimited + } + if a.Track.Source == track.Link && r.info.Kind == Ephemeral { + return nil, errLinkForbidden + } + if r.info.Kind == Ephemeral && len(r.waiting) >= ephemeralQueueLimit { + return nil, errQueueFull + } + if r.queued(a.Track) { + return nil, errDuplicatePick + } + e := r.add(b, a.Track, a.Meta, by, false) + r.lastPick = b.now + b.event(Activity{Kind: ActivityAdded, Member: by, Entry: e}) + r.advanceIfIdle(b) + return EnqueueResult{Entry: e}, nil +} + +func (r *Room) remove(b *batch, by member.Member, a Remove) (Result, *Error) { + if !r.pickLimit.allow(b.now, r.burst(), refillInterval) { + return nil, errRateLimited + } + i := r.waitingIndex(a.Entry) + if i < 0 { + return nil, errNotInQueue + } + e := r.waiting[i] + r.waiting = slices.Delete(r.waiting, i, i+1) + b.event(Activity{Kind: ActivityRemoved, Member: by, Entry: e}) + return Done{}, nil +} + +func (r *Room) like(b *batch, by member.Member, a Like) (Result, *Error) { + if !r.likeLimit.allow(b.now, r.burst(), refillInterval) { + return nil, errRateLimited + } + i := r.waitingIndex(a.Entry) + if i < 0 { + return nil, errNotInQueue + } + r.waiting[i].Likes++ + e := r.waiting[i] + b.event(Activity{Kind: ActivityLiked, Member: by, Entry: e}) + return LikeResult{Likes: e.Likes}, nil +} + +func (r *Room) add(b *batch, t track.Track, m track.Meta, by member.Member, auto bool) Entry { + r.entrySeq++ + e := Entry{ID: r.entrySeq, Track: t, Meta: m, By: by, Auto: auto, Round: r.nextRound(by), AddedAt: b.now} + r.waiting = append(r.waiting, e) + b.prepare(t) + return e +} + +// 轮次在入队时定下,之后不再改变:新歌排进最早还没放完的一轮;点歌人还有歌在队列中,或者已经有歌开播,就至少排到这些歌中最高一轮的下一轮。 +// 最早的一轮不一定是正在播放的那一轮,点赞会让靠后轮次的歌先放。 +// 点歌人按成员 ID 和名字区分:bot 用同一个成员替不同的人点歌;AutoDJ 加入的歌没有点歌人,算作同一个点歌人。 +func (r *Room) nextRound(by member.Member) int { + entries := slices.Clone(r.waiting) + if r.pending != nil { + entries = append(entries, *r.pending) + } + if r.current != nil { + entries = append(entries, r.current.Entry) + } + if len(entries) == 0 { + // 房间放空之后轮次从 0 重新开始,之前开播的记录作废。 + clear(r.served) + return 0 + } + round := slices.MinFunc(entries, func(a, b Entry) int { return cmp.Compare(a.Round, b.Round) }).Round + // 早于最早一轮的记录不会再影响轮次;bot 代填的点歌人名字不受限制,不删就会一直增长。 + maps.DeleteFunc(r.served, func(_ requester, served int) bool { return served < round }) + key := requesterOf(by) + if served, ok := r.served[key]; ok { + round = max(round, served+1) + } + for _, e := range entries { + if requesterOf(e.By) == key { + round = max(round, e.Round+1) + } + } + return round +} + +type requester struct{ id, name string } + +func requesterOf(m member.Member) requester { return requester{m.ID, m.Name} } + +func (r *Room) prepared(b *batch, in Prepared) { + e := r.unready(in.Track) + if e == nil { + return + } + e.Meta, e.Ready, e.Prep = in.Meta, true, in.Result + if e == r.pending { + r.start(b) + } +} + +func (r *Room) prepareFailed(b *batch, in PrepareFailed) { + e := r.unready(in.Track) + if e == nil { + return + } + failed := *e + if e == r.pending { + r.pending = nil + } else { + r.waiting = slices.DeleteFunc(r.waiting, func(w Entry) bool { return w.ID == failed.ID }) + } + b.event(Activity{Kind: ActivityFailed, Entry: failed, Reason: in.Reason}) + r.advanceIfIdle(b) +} + +func (r *Room) recommended(b *batch, in Recommended) { + if in.Token == 0 || in.Token != r.recommendation { + return + } + r.recommendation = 0 + if !r.autoDJReady(b.now) || r.current.Entry.Track == in.Track { + return + } + e := r.add(b, in.Track, in.Meta, member.Member{}, true) + b.event(Activity{Kind: ActivityAdded, Entry: e}) +} + +func (r *Room) queued(t track.Track) bool { + return r.pending != nil && r.pending.Track == t || + slices.ContainsFunc(r.waiting, func(e Entry) bool { return e.Track == t }) +} + +// 待播一就绪就开播,所以待播总是在准备中。 +func (r *Room) unready(t track.Track) *Entry { + if r.pending != nil && r.pending.Track == t { + return r.pending + } + i := slices.IndexFunc(r.waiting, func(e Entry) bool { return !e.Ready && e.Track == t }) + if i < 0 { + return nil + } + return &r.waiting[i] +} + +func (r *Room) waitingIndex(id EntryID) int { + return slices.IndexFunc(r.waiting, func(e Entry) bool { return e.ID == id }) +} + +func truncate(s string, n int) string { + if runes := []rune(s); len(runes) > n { + return string(runes[:n]) + } + return s +} + +func (r *Room) sortWaiting() { + slices.SortFunc(r.waiting, compareWaiting(r.mode)) +} + +func compareWaiting(mode PlayMode) func(a, b Entry) int { + return func(a, b Entry) int { + c := cmp.Compare(b.Likes, a.Likes) + if mode == Rotation { + c = cmp.Or(c, cmp.Compare(a.Round, b.Round)) + } + return cmp.Or(c, cmp.Compare(a.ID, b.ID)) + } +} diff --git a/internal/domain/room/room.go b/internal/domain/room/room.go new file mode 100644 index 0000000..aae4ad7 --- /dev/null +++ b/internal/domain/room/room.go @@ -0,0 +1,252 @@ +package room + +import ( + "math/rand/v2" + "slices" + "time" + + "github.com/dihemoe/alisten/internal/domain/track" +) + +const ( + startLead = 200 * time.Millisecond + closeDelay = 5 * time.Minute + autoDJQuiet = 10 * time.Second + refillInterval = time.Minute + ephemeralBurst = 5 + persistentBurst = 30 + ephemeralQueueLimit = 10 + chatLimit = 500 +) + +// Room 内部没有锁,同一个 Room 的 Handle 必须由调用方串行调用。 +type Room struct { + term uint32 + seq uint64 + info Info + mode PlayMode + entrySeq EntryID + waiting []Entry + pending *Entry + current *NowPlaying + sessions []Session + votes []string + pickLimit limiter + likeLimit limiter + lastPick time.Time + token Token + endTimer Token + closeTimer Token + recommendation Token + // 各点歌人已开播的最高轮次,只在内存中:换 owner 之后刚放过歌的点歌人可能提前一轮,已在队列中的歌不受影响。 + served map[requester]int +} + +// token 每次 Restore 后都从 1 重新开始。放弃房间后再次恢复时,调用方必须作废旧 Room +// 尚未触发的定时器和尚未返回的推荐,否则旧 token 会被新 Room 当成自己的。 +// 返回的 effect 与 Handle 的要求相同。 +func Restore(env Env, term uint32, s Snapshot) (*Room, []Effect) { + r := &Room{ + term: term, + info: s.Info, + mode: s.Mode, + entrySeq: s.EntrySeq, + waiting: slices.Clone(s.Waiting), + pending: clonePtr(s.Pending), + current: clonePtr(s.Current), + served: map[requester]int{}, + } + // 存储返回的条目顺序不保证符合排序规则。 + r.sortWaiting() + b := r.begin(env) + if r.pending != nil && !r.pending.Ready { + b.prepare(r.pending.Track) + } + for _, e := range r.waiting { + if !e.Ready { + b.prepare(e.Track) + } + } + switch { + case r.current != nil && env.Now.Before(r.current.EndsAt()): + r.endTimer = r.schedule(b, r.current.EndsAt()) + case r.current != nil: + r.endCurrent(b, r.current.EndsAt()) + case r.pending != nil && r.pending.Ready: + r.start(b) + } + r.advanceIfIdle(b) + if r.info.Kind == Ephemeral { + r.closeTimer = r.schedule(b, env.Now.Add(closeDelay)) + } + return r, r.commit(b) +} + +// 返回的 effect 必须按顺序执行:Persist 失败时丢弃本批剩余的 effect 并放弃该房间, +// 否则客户端会看到没有落盘的状态。 +// effect 只能读,不能修改:同一批 effect 共用切片和指针,Reply.Err 指向包级的错误值。 +// 输出 Close 之后必须丢弃这个 Room:关闭时只结束正在播放的歌,不再开播下一首,它的状态已不是运行中房间的状态。 +func (r *Room) Handle(env Env, in Input) []Effect { + b := r.begin(env) + switch in := in.(type) { + case Join: + r.join(in) + case Leave: + r.leave(b, in) + case MemberUpdated: + r.memberUpdated(in) + case UpdateInfo: + r.info.Name, r.info.Description = in.Name, in.Description + b.respond(in.RequestID, Done{}, nil) + case Delete: + r.stopPlayback(b) + b.closed = true + b.respond(in.RequestID, Done{}, nil) + case Command: + r.command(b, in) + case Prepared: + r.prepared(b, in) + case PrepareFailed: + r.prepareFailed(b, in) + case Recommended: + r.recommended(b, in) + case Timer: + r.timer(b, in) + } + // 入队、点赞和切换播放模式都会改变等待队列的顺序,各条规则不必各自排序。 + r.sortWaiting() + return r.commit(b) +} + +type batch struct { + now time.Time + rand *rand.Rand + before Snapshot + sessions []Session + events []Event + plays []RecordPlay + closed bool + answer *Reply + tasks []Effect +} + +func (r *Room) begin(env Env) *batch { + return &batch{ + now: env.Now, + rand: rand.New(rand.NewPCG(env.Seed, 0)), + before: r.snapshot(), + sessions: slices.Clone(r.sessions), + } +} + +func (b *batch) event(e Event) { + b.events = append(b.events, e) +} + +func (b *batch) prepare(t track.Track) { + b.tasks = append(b.tasks, Prepare{Track: t}) +} + +func (b *batch) respond(id RequestID, res Result, err *Error) { + b.answer = &Reply{RequestID: id, Result: res, Err: err} +} + +// 状态事件由处理前后的差异得出,不由各条规则各自发出,这样任何状态变化都不会漏掉广播。 +// Persist 排在最前:运行时落盘成功后才执行其余的 effect,客户端看到的都是已落盘的状态。 +func (r *Room) commit(b *batch) []Effect { + after := r.snapshot() + var out []Effect + if !sameSnapshot(b.before, after) { + out = append(out, Persist{Snapshot: after}) + } + for _, p := range b.plays { + out = append(out, p) + } + events := b.events + if after.Info != b.before.Info { + events = append(events, InfoChanged{Name: after.Info.Name, Description: after.Info.Description}) + } + if after.Mode != b.before.Mode { + events = append(events, ModeChanged{Mode: after.Mode}) + } + if !slices.Equal(b.sessions, r.sessions) { + events = append(events, MembersChanged{Sessions: slices.Clone(r.sessions)}) + } + if !samePtr(b.before.Pending, after.Pending) || !slices.Equal(b.before.Waiting, after.Waiting) { + events = append(events, QueueChanged{Pending: after.Pending, Waiting: after.Waiting}) + } + if !samePtr(b.before.Current, after.Current) { + events = append(events, PlaybackChanged{Current: after.Current}) + } + for _, e := range events { + r.seq++ + out = append(out, Emit{Term: r.term, Seq: r.seq, Event: e}) + } + if b.closed { + out = append(out, Close{}) + } + if b.answer != nil { + out = append(out, *b.answer) + } + return append(out, b.tasks...) +} + +func (r *Room) timer(b *batch, in Timer) { + switch in.Token { + // 没有定时器时 endTimer 和 closeTimer 都是 0。 + case 0: + case r.endTimer: + r.endCurrent(b, r.current.EndsAt()) + r.advanceIfIdle(b) + case r.closeTimer: + r.closeTimer = 0 + r.stopPlayback(b) + b.closed = true + } +} + +func (r *Room) snapshot() Snapshot { + return Snapshot{ + Info: r.info, + Mode: r.mode, + EntrySeq: r.entrySeq, + Waiting: slices.Clone(r.waiting), + Pending: clonePtr(r.pending), + Current: clonePtr(r.current), + } +} + +func (r *Room) view() View { + return View{Term: r.term, Seq: r.seq, Snapshot: r.snapshot(), Sessions: slices.Clone(r.sessions)} +} + +func (r *Room) issue() Token { + r.token++ + return r.token +} + +func (r *Room) schedule(b *batch, at time.Time) Token { + t := r.issue() + b.tasks = append(b.tasks, Schedule{At: at, Token: t}) + return t +} + +func sameSnapshot(a, b Snapshot) bool { + return a.Info == b.Info && a.Mode == b.Mode && a.EntrySeq == b.EntrySeq && + slices.Equal(a.Waiting, b.Waiting) && samePtr(a.Pending, b.Pending) && samePtr(a.Current, b.Current) +} + +func samePtr[T comparable](a, b *T) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +func clonePtr[T any](p *T) *T { + if p == nil { + return nil + } + v := *p + return &v +} diff --git a/internal/domain/room/types.go b/internal/domain/room/types.go new file mode 100644 index 0000000..d0baa90 --- /dev/null +++ b/internal/domain/room/types.go @@ -0,0 +1,85 @@ +package room + +import ( + "time" + + "github.com/dihemoe/alisten/internal/domain/member" + "github.com/dihemoe/alisten/internal/domain/track" +) + +type Kind uint8 + +const ( + Ephemeral Kind = iota + 1 + Persistent +) + +type PlayMode uint8 + +const ( + Sequential PlayMode = iota + 1 + Random + Rotation +) + +type ( + EntryID uint64 + SessionID string + RequestID string + Token uint64 +) + +type Env struct { + Now time.Time + Seed uint64 +} + +type Info struct { + ID string + Name string + Description string + Kind Kind +} + +type Entry struct { + ID EntryID + Track track.Track + Meta track.Meta + By member.Member + Auto bool + Likes int + Round int + Ready bool + Prep track.Prepared + AddedAt time.Time +} + +type NowPlaying struct { + Entry Entry + StartedAt time.Time +} + +func (p NowPlaying) EndsAt() time.Time { + return p.StartedAt.Add(p.Entry.Prep.Duration) +} + +type Session struct { + ID SessionID + Member member.Member +} + +type Snapshot struct { + Info Info + Mode PlayMode + EntrySeq EntryID + Waiting []Entry + Pending *Entry + Current *NowPlaying +} + +type View struct { + Term uint32 + Seq uint64 + Snapshot Snapshot + Sessions []Session +} diff --git a/internal/domain/track/bilibili.go b/internal/domain/track/bilibili.go new file mode 100644 index 0000000..d046226 --- /dev/null +++ b/internal/domain/track/bilibili.go @@ -0,0 +1,38 @@ +package track + +import ( + "regexp" + "strconv" +) + +// 同一个视频只能有一种 ID,否则会重复下载:第 1 P 不带序号,序号不带前导零。 +var bilibiliID = regexp.MustCompile(`^(BV[1-9A-HJ-NP-Za-km-z]{10})(?:/([2-9]|[1-9][0-9]{1,3}))?$`) + +func BilibiliID(bvid string, page int) string { + if page <= 1 { + return bvid + } + return bvid + "/" + strconv.Itoa(page) +} + +func ParseBilibiliID(id string) (bvid string, page int, ok bool) { + m := bilibiliID.FindStringSubmatch(id) + if m == nil { + return "", 0, false + } + if m[2] == "" { + return m[1], 1, true + } + page, _ = strconv.Atoi(m[2]) + return m[1], page, true +} + +// id 必须是 ParseBilibiliID 能解析的 ID。 +func BilibiliPageURL(id string) string { + bvid, page, _ := ParseBilibiliID(id) + u := "https://www.bilibili.com/video/" + bvid + if page > 1 { + u += "?p=" + strconv.Itoa(page) + } + return u +} diff --git a/internal/domain/track/track.go b/internal/domain/track/track.go new file mode 100644 index 0000000..40b5c08 --- /dev/null +++ b/internal/domain/track/track.go @@ -0,0 +1,61 @@ +package track + +import ( + "strings" + "time" +) + +type Source string + +const ( + NetEase Source = "netease" + QQ Source = "qq" + Link Source = "link" + Bilibili Source = "bilibili" +) + +type Track struct { + Source Source + ID string +} + +// LinkID 是链接作为曲目 ID 的写法,对象存储中的对象键也由它得出:只把 scheme 和主机名中的 ASCII 字母转成小写, +// 其余字节一律不变(片段、查询参数的顺序、端口和百分号编码都可能有意义)。非 ASCII 字符不转小写:国际化域名的大小写映射 +// 与 Unicode 的不同。link 必须是经过协议校验的链接。 +func LinkID(link string) string { + scheme, rest, _ := strings.Cut(link, "://") + end := strings.IndexAny(rest, "/?#") + if end < 0 { + end = len(rest) + } + return asciiLower(scheme) + "://" + asciiLower(rest[:end]) + rest[end:] +} + +func asciiLower(s string) string { + return strings.Map(func(r rune) rune { + if 'A' <= r && r <= 'Z' { + return r + 'a' - 'A' + } + return r + }, s) +} + +type Meta struct { + Title string + Artist string + Album string + CoverURL string + // 链接曲目的封面在对象存储中:只存对象键,对外的地址由 serve 拼接,公开地址改变之后,已保存的曲目和条目仍然有效。 + CoverKey ObjectKey + PageURL string + Duration time.Duration +} + +// 对象键不是地址:拼上对象存储的公开地址之后才能交给浏览器。 +type ObjectKey string + +type Prepared struct { + Duration time.Duration + Gain float64 + Lyric string +} diff --git a/internal/lib/chinaip/chinaip.go b/internal/lib/chinaip/chinaip.go new file mode 100644 index 0000000..fe709d2 --- /dev/null +++ b/internal/lib/chinaip/chinaip.go @@ -0,0 +1,28 @@ +// Package chinaip 让发往音源接口的请求带上随机的国内 IP:音源会限制来自国外的请求。 +package chinaip + +import ( + "fmt" + "math/rand/v2" + "net/http" +) + +// 大陆运营商的地址段,音源把来自这些地址的请求当作国内请求。 +var prefixes = [...][2]byte{ + {116, 255}, + {116, 228}, + {218, 192}, + {124, 0}, + {14, 132}, + {183, 14}, + {58, 14}, + {113, 116}, + {120, 230}, +} + +func Set(h http.Header) { + p := prefixes[rand.IntN(len(prefixes))] + ip := fmt.Sprintf("%d.%d.%d.%d", p[0], p[1], rand.IntN(256), rand.IntN(256)) + h.Set("X-Real-IP", ip) + h.Set("X-Forwarded-For", ip) +} diff --git a/internal/lib/logctx/logctx.go b/internal/lib/logctx/logctx.go new file mode 100644 index 0000000..0fe3217 --- /dev/null +++ b/internal/lib/logctx/logctx.go @@ -0,0 +1,102 @@ +package logctx + +import ( + "context" + "log/slog" + "slices" +) + +type key struct{} + +type fields struct { + request, room, member string + job int64 +} + +func with(ctx context.Context, set func(*fields)) context.Context { + f, _ := ctx.Value(key{}).(fields) + set(&f) + return context.WithValue(ctx, key{}, f) +} + +func WithRequest(ctx context.Context, id string) context.Context { + return with(ctx, func(f *fields) { f.request = id }) +} + +func WithRoom(ctx context.Context, id string) context.Context { + return with(ctx, func(f *fields) { f.room = id }) +} + +func WithMember(ctx context.Context, id string) context.Context { + return with(ctx, func(f *fields) { f.member = id }) +} + +func WithJob(ctx context.Context, id int64) context.Context { + return with(ctx, func(f *fields) { f.job = id }) +} + +// 返回的 handler 自己记下 logger 打开的 group,写日志时才把它们交给 next,使关联字段写在日志的顶层而不落进 +// group 中(River 的 logger 带有 river group)。往日志中添加字段的其他 handler 要放在它的内层。 +func NewHandler(next slog.Handler) slog.Handler { + return handler{next: next} +} + +type handler struct { + next slog.Handler + groups []group +} + +type group struct { + name string + attrs []slog.Attr +} + +func (h handler) Enabled(ctx context.Context, level slog.Level) bool { + return h.next.Enabled(ctx, level) +} + +func (h handler) Handle(ctx context.Context, r slog.Record) error { + out := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + f, _ := ctx.Value(key{}).(fields) + if f.request != "" { + out.AddAttrs(slog.String("request_id", f.request)) + } + if f.room != "" { + out.AddAttrs(slog.String("room_id", f.room)) + } + if f.member != "" { + out.AddAttrs(slog.String("member_id", f.member)) + } + if f.job != 0 { + out.AddAttrs(slog.Int64("job_id", f.job)) + } + attrs := make([]slog.Attr, 0, r.NumAttrs()) + r.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, a) + return true + }) + // 同一个 handler 会被并发调用,不能写入 g.attrs 多余的容量。 + for _, g := range slices.Backward(h.groups) { + attrs = []slog.Attr{{Key: g.name, Value: slog.GroupValue(append(slices.Clip(g.attrs), attrs...)...)}} + } + out.AddAttrs(attrs...) + return h.next.Handle(ctx, out) +} + +// 派生的 handler 不与原来的共用切片,因为同一个 logger 可以派生出多个 logger。 +func (h handler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(h.groups) == 0 { + return handler{next: h.next.WithAttrs(attrs)} + } + groups := slices.Clone(h.groups) + last := &groups[len(groups)-1] + last.attrs = append(slices.Clip(last.attrs), attrs...) + return handler{next: h.next, groups: groups} +} + +func (h handler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + return handler{next: h.next, groups: append(slices.Clip(h.groups), group{name: name})} +} diff --git a/internal/lib/loudness/loudness.go b/internal/lib/loudness/loudness.go new file mode 100644 index 0000000..0630417 --- /dev/null +++ b/internal/lib/loudness/loudness.go @@ -0,0 +1,328 @@ +// Package loudness 实现 ITU-R BS.1770(EBU R128)响度分析, +// 并按 Spotify 播放器的响度归一化策略给出平衡增益(ReplayGain 风格)。 +package loudness + +import ( + "errors" + "io" + "math" + "time" + + "github.com/dihemoe/alisten/internal/lib/wav" +) + +// SpotifyTarget 是 Spotify 的 Normal 档目标响度(LUFS)。 +const SpotifyTarget = -14.0 + +// peakCeiling 是提升音量后允许的采样峰值上限(dBFS),用来避免削波。 +const peakCeiling = -1.0 + +type Result struct { + Integrated float64 // 综合响度(LUFS),全静音时为 -Inf + Peak float64 // 采样峰值(dBFS),全静音时为 -Inf + Duration time.Duration +} + +// target 的单位是 LUFS,返回的增益单位是 dB;静音时返回 0。 +func (r *Result) GainTo(target float64) float64 { + if math.IsInf(r.Integrated, -1) { + return 0 + } + return target - r.Integrated +} + +func (r *Result) SpotifyGain() float64 { + g := r.GainTo(SpotifyTarget) + if g > 0 && !math.IsInf(r.Peak, -1) && r.Peak+g > peakCeiling { + g = peakCeiling - r.Peak + } + return g +} + +func (r *Result) Coefficient() float64 { + return GainToCoefficient(r.SpotifyGain()) +} + +func GainToCoefficient(gainDb float64) float64 { + return math.Pow(10, gainDb/20) +} + +func AnalyzeReader(r io.Reader) (*Result, error) { + dec, err := wav.NewDecoder(r) + if err != nil { + return nil, err + } + an := NewAnalyzer(dec.SampleRate(), dec.Channels()) + buf := make([]float64, 32768) + for { + n, err := dec.Read(buf) + an.Write(buf[:n]) + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + } + return an.Result() +} + +// samples 是按声道交错的 PCM 采样,满刻度为 1,可以超出 [-1, 1]。 +func AnalyzeSamples(samples []float64, sampleRate, channels int) (*Result, error) { + if sampleRate <= 0 || channels <= 0 { + return nil, errors.New("loudness: 采样率与声道数必须为正") + } + an := NewAnalyzer(sampleRate, channels) + an.Write(samples[:len(samples)/channels*channels]) + return an.Result() +} + +// blockSegs 是一个 400ms 响度块包含的 100ms 段数(BS.1770:400ms 块,75% 重叠)。 +const blockSegs = 4 + +// Analyzer 是流式的 BS.1770 响度分析器:只保留滤波器状态与滚动的块能量,整段音频无需驻留内存。 +type Analyzer struct { + sampleRate int + channels int + // 浏览器把单声道同时放到左右两个声道,所以单声道按双单声道(libebur128 的 DUAL_MONO)计算: + // 能量是单声道的 2 倍(+3.01 LU),峰值不变。其余声道布局的权重为 1。 + weight float64 + segSize int // 100ms 段的帧数 + filters [][2]biquadState + segSums []float64 + totalSums []float64 // 全部采样的各声道能量(不足 400ms 时兜底) + window [blockSegs][]float64 + wpos, wlen int // 环形窗口写入位置与已填充段数 + segFrames int + nextCh int // 下一个采样所属声道(跨 Write 调用保持帧对齐) + energies []float64 // 每个完整 400ms 块的能量 + peak float64 // 采样峰值(未经过 K 加权滤波) + frames int64 + + simdOK bool +} + +func NewAnalyzer(sampleRate, channels int) *Analyzer { + segSize := int(0.100 * float64(sampleRate)) + if segSize < 1 { + segSize = 1 + } + a := &Analyzer{ + sampleRate: sampleRate, + channels: channels, + weight: 1, + segSize: segSize, + filters: make([][2]biquadState, channels), + segSums: make([]float64, channels), + totalSums: make([]float64, channels), + simdOK: channels == 2 && hasAVX2, + } + if channels == 1 { + a.weight = 2 + } + for c := 0; c < channels; c++ { + shelf, highpass := kWeightingFilters(sampleRate) + a.filters[c] = [2]biquadState{{b: shelf}, {b: highpass}} + } + for i := range a.window { + a.window[i] = make([]float64, channels) + } + return a +} + +// Write 写入一块按声道交错的 PCM 采样,块长度无需按声道或段边界对齐。 +func (a *Analyzer) Write(samples []float64) { + if a.simdOK { + a.writeStereoSIMD(samples) + return + } + for _, v := range samples { + a.stepScalar(v) + } +} + +// writeStereoSIMD 逐段调用汇编内核:IIR 在时间维上必须串行,并行的是 L、R 两个声道。 +func (a *Analyzer) writeStereoSIMD(samples []float64) { + for len(samples) > 0 { + if a.nextCh == 1 || len(samples) < 2 { + // 处在奇数边界时,用标量方式处理一个采样,以保持帧对齐。 + a.stepScalar(samples[0]) + samples = samples[1:] + continue + } + n := a.segSize - a.segFrames + if rem := len(samples) / 2; rem < n { + n = rem + } + before0, before1 := a.segSums[0], a.segSums[1] + stereoProcessAVX2(samples[:2*n], (*[2][2]biquadState)(a.filters), (*[2]float64)(a.segSums), &a.peak) + a.totalSums[0] += a.segSums[0] - before0 + a.totalSums[1] += a.segSums[1] - before1 + a.frames += int64(n) + a.segFrames += n + samples = samples[2*n:] + if a.segFrames == a.segSize { + a.flushSegment() + } + } +} + +func (a *Analyzer) stepScalar(v float64) { + if av := math.Abs(v); av > a.peak { + a.peak = av + } + c := a.nextCh + f := &a.filters[c] + y := f[1].step(f[0].step(v)) + e := y * y + a.segSums[c] += e + a.totalSums[c] += e + if c++; c == a.channels { + c = 0 + a.frames++ + if a.segFrames++; a.segFrames == a.segSize { + a.flushSegment() + } + } + a.nextCh = c +} + +func (a *Analyzer) flushSegment() { + w := a.window[a.wpos] + copy(w, a.segSums) + a.wpos = (a.wpos + 1) % blockSegs + if a.wlen < blockSegs { + a.wlen++ + } + clear(a.segSums) + a.segFrames = 0 + + if a.wlen == blockSegs { + e := 0.0 + for i := 0; i < blockSegs; i++ { + for _, s := range a.window[i] { + e += s + } + } + a.energies = append(a.energies, a.weight*e/float64(blockSegs*a.segSize)) + } +} + +func (a *Analyzer) Result() (*Result, error) { + if a.frames == 0 { + return nil, errors.New("loudness: 没有音频采样") + } + + energies := a.energies + if len(energies) == 0 { + e := 0.0 + for _, s := range a.totalSums { + e += s + } + energies = []float64{a.weight * e / float64(a.frames)} + } + + integrated := math.Inf(-1) + if e, ok := gateBlocks(energies); ok { + integrated = energyToLoudness(e) + } + + peakDb := math.Inf(-1) + if a.peak > 0 { + peakDb = 20 * math.Log10(a.peak) + } + + return &Result{ + Integrated: integrated, + Peak: peakDb, + Duration: time.Duration(a.frames) * time.Second / time.Duration(a.sampleRate), + }, nil +} + +// energyToLoudness 返回 LUFS,包含 BS.1770 的 -0.691 修正项。 +func energyToLoudness(e float64) float64 { + return -0.691 + 10*math.Log10(e) +} + +func gateBlocks(energies []float64) (float64, bool) { + // BS.1770 的 -70 LUFS 绝对门限直接换算到能量域比较,省去逐块的 LUFS 对数换算。 + absGate := math.Pow(10, (-70+0.691)/10) + sum, n := sumAbove(energies, absGate) + if n == 0 { + return 0, false + } + // 相对门限是均值响度以下 10 LU,即均值能量的 1/10;与绝对门限取大,保证两级门限同时生效。 + sum, n = sumAbove(energies, max(absGate, sum/float64(n)/10)) + if n == 0 { + return 0, false + } + return sum / float64(n), true +} + +func sumAbove(energies []float64, threshold float64) (float64, int) { + sum, n := 0.0, 0 + for _, e := range energies { + if e > threshold { + sum += e + n++ + } + } + return sum, n +} + +type biquad struct { + b0, b1, b2, a1, a2 float64 +} + +type biquadState struct { + b biquad + x1, x2, y1, y2 float64 +} + +func (s *biquadState) step(v float64) float64 { + y := s.b.b0*v + s.b.b1*s.x1 + s.b.b2*s.x2 - s.b.a1*s.y1 - s.b.a2*s.y2 + s.x2, s.x1 = s.x1, v + s.y2, s.y1 = s.y1, y + return y +} + +// kWeightingFilters 的系数取自 De Man 对 BS.1770 系数的推导。 +func kWeightingFilters(sampleRate int) (shelf, highpass biquad) { + fs := float64(sampleRate) + + { + const ( + f0 = 1681.974450955533 + G = 3.999843853973347 + Q = 0.7071752369554196 + ) + K := math.Tan(math.Pi * f0 / fs) + Vh := math.Pow(10, G/20) + Vb := math.Pow(Vh, 0.499666774155) + a0 := 1 + K/Q + K*K + shelf = biquad{ + b0: (Vh + Vb*K/Q + K*K) / a0, + b1: 2 * (K*K - Vh) / a0, + b2: (Vh - Vb*K/Q + K*K) / a0, + a1: 2 * (K*K - 1) / a0, + a2: (1 - K/Q + K*K) / a0, + } + } + + { + const ( + f0 = 38.13547087602444 + Q = 0.5003270373238773 + ) + K := math.Tan(math.Pi * f0 / fs) + a0 := 1 + K/Q + K*K + highpass = biquad{ + b0: 1 / a0, + b1: -2 / a0, + b2: 1 / a0, + a1: 2 * (K*K - 1) / a0, + a2: (1 - K/Q + K*K) / a0, + } + } + return shelf, highpass +} diff --git a/internal/lib/loudness/loudness_amd64.go b/internal/lib/loudness/loudness_amd64.go new file mode 100644 index 0000000..be38389 --- /dev/null +++ b/internal/lib/loudness/loudness_amd64.go @@ -0,0 +1,13 @@ +//go:build amd64 + +package loudness + +import "golang.org/x/sys/cpu" + +var hasAVX2 = cpu.X86.HasAVX2 + +// stereoProcessAVX2 处理双声道交错采样:每帧更新 L、R 声道的峰值与两级 K 加权 biquad 状态, +// 滤波后的能量累加进 sums。samples 的长度必须为偶数,st 的内存布局见 loudness_amd64.s。 +// +//go:noescape +func stereoProcessAVX2(samples []float64, st *[2][2]biquadState, sums *[2]float64, peak *float64) diff --git a/internal/lib/loudness/loudness_amd64.s b/internal/lib/loudness/loudness_amd64.s new file mode 100644 index 0000000..1fd6c44 --- /dev/null +++ b/internal/lib/loudness/loudness_amd64.s @@ -0,0 +1,154 @@ +//go:build amd64 + +#include "textflag.h" + +// st(*[2][2]biquadState)的内存布局:每个 biquadState 占 72 字节, +// {b0=0 b1=8 b2=16 a1=24 a2=32 x1=40 x2=48 y1=56 y2=64}; +// L 搁架=0,L 高通=72,R 搁架=144,R 高通=216。 +// L、R 的同名字段相距 144 字节,用 VMOVSD 和 VMOVHPD 组合成 [L, R] 向量。 +// +// 寄存器分配(128 位 VEX,lane0 为 L,lane1 为 R): +// X0 当前帧的输入 x / 收尾时暂存 +// X1 一级输出 y / 暂存 +// X2 二级输出 z / 暂存 +// X3 暂存 +// X4 暂存 +// X5 绝对值掩码 0x7fffffffffffffff +// X6 峰值累加器 +// X7 能量累加器(段内) +// X8-X11 一级状态 x1 x2 y1 y2 +// X12-X15 二级状态 u1 u2 v1 v2 + +TEXT ·stereoProcessAVX2(SB), NOSPLIT, $0-48 + MOVQ samples_base+0(FP), SI + MOVQ samples_len+8(FP), CX + MOVQ st+24(FP), DI + MOVQ sums+32(FP), R8 + MOVQ peak+40(FP), R9 + + // X5 = |x| 的掩码(VPBROADCASTQ 是 AVX2 指令) + MOVQ $0x7fffffffffffffff, AX + MOVQ AX, X5 + VPBROADCASTQ X5, X5 + + MOVSD (R9), X6 + VPBROADCASTQ X6, X6 + + VXORPD X7, X7, X7 + + VMOVSD 40(DI), X8 + VMOVHPD 184(DI), X8, X8 + VMOVSD 48(DI), X9 + VMOVHPD 192(DI), X9, X9 + VMOVSD 56(DI), X10 + VMOVHPD 200(DI), X10, X10 + VMOVSD 64(DI), X11 + VMOVHPD 208(DI), X11, X11 + VMOVSD 112(DI), X12 + VMOVHPD 256(DI), X12, X12 + VMOVSD 120(DI), X13 + VMOVHPD 264(DI), X13, X13 + VMOVSD 128(DI), X14 + VMOVHPD 272(DI), X14, X14 + VMOVSD 136(DI), X15 + VMOVHPD 280(DI), X15, X15 + + TESTQ CX, CX + JEQ done + +loop: + VMOVUPD (SI), X0 + + VANDPD X5, X0, X1 + VMAXPD X1, X6, X6 + + // 一级(高频搁架):y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2 + VMOVSD 0(DI), X1 + VMOVHPD 144(DI), X1, X1 + VMULPD X0, X1, X1 + VMOVSD 8(DI), X2 + VMOVHPD 152(DI), X2, X2 + VMULPD X8, X2, X2 + VADDPD X2, X1, X1 + VMOVSD 16(DI), X3 + VMOVHPD 160(DI), X3, X3 + VMULPD X9, X3, X3 + VADDPD X3, X1, X1 + VMOVSD 24(DI), X4 + VMOVHPD 168(DI), X4, X4 + VMULPD X10, X4, X4 + VSUBPD X4, X1, X1 + VMOVSD 32(DI), X4 + VMOVHPD 176(DI), X4, X4 + VMULPD X11, X4, X4 + VSUBPD X4, X1, X1 + + // 一级状态移位:x2=x1, x1=x; y2=y1, y1=y + VMOVAPS X8, X9 + VMOVAPS X0, X8 + VMOVAPS X10, X11 + VMOVAPS X1, X10 + + // 二级(高通):z = c0*y + c1*u1 + c2*u2 - d1*v1 - d2*v2 + VMOVSD 72(DI), X2 + VMOVHPD 216(DI), X2, X2 + VMULPD X1, X2, X2 + VMOVSD 80(DI), X3 + VMOVHPD 224(DI), X3, X3 + VMULPD X12, X3, X3 + VADDPD X3, X2, X2 + VMOVSD 88(DI), X4 + VMOVHPD 232(DI), X4, X4 + VMULPD X13, X4, X4 + VADDPD X4, X2, X2 + VMOVSD 96(DI), X4 + VMOVHPD 240(DI), X4, X4 + VMULPD X14, X4, X4 + VSUBPD X4, X2, X2 + VMOVSD 104(DI), X4 + VMOVHPD 248(DI), X4, X4 + VMULPD X15, X4, X4 + VSUBPD X4, X2, X2 + + // 二级状态移位:u2=u1, u1=y; v2=v1, v1=z + VMOVAPS X12, X13 + VMOVAPS X1, X12 + VMOVAPS X14, X15 + VMOVAPS X2, X14 + + VMULPD X2, X2, X3 + VADDPD X3, X7, X7 + + ADDQ $16, SI + SUBQ $2, CX + JNE loop + +done: + VMOVSD X8, 40(DI) + VMOVHPD X8, 184(DI) + VMOVSD X9, 48(DI) + VMOVHPD X9, 192(DI) + VMOVSD X10, 56(DI) + VMOVHPD X10, 200(DI) + VMOVSD X11, 64(DI) + VMOVHPD X11, 208(DI) + VMOVSD X12, 112(DI) + VMOVHPD X12, 256(DI) + VMOVSD X13, 120(DI) + VMOVHPD X13, 264(DI) + VMOVSD X14, 128(DI) + VMOVHPD X14, 272(DI) + VMOVSD X15, 136(DI) + VMOVHPD X15, 280(DI) + + // 峰值:两个 lane 取最大值后写回(初值已包含旧的 peak) + VUNPCKHPD X6, X6, X0 + VMAXSD X0, X6, X6 + MOVSD X6, (R9) + + VMOVUPD (R8), X0 + VADDPD X0, X7, X7 + VMOVUPD X7, (R8) + + VZEROUPPER + RET diff --git a/internal/lib/loudness/loudness_noasm.go b/internal/lib/loudness/loudness_noasm.go new file mode 100644 index 0000000..d8f8147 --- /dev/null +++ b/internal/lib/loudness/loudness_noasm.go @@ -0,0 +1,9 @@ +//go:build !amd64 + +package loudness + +const hasAVX2 = false + +func stereoProcessAVX2(samples []float64, st *[2][2]biquadState, sums *[2]float64, peak *float64) { + panic("loudness: AVX2 内核仅在 amd64 可用") +} diff --git a/internal/lib/wav/decode.go b/internal/lib/wav/decode.go new file mode 100644 index 0000000..9aee283 --- /dev/null +++ b/internal/lib/wav/decode.go @@ -0,0 +1,342 @@ +// Package wav 解码 WAV、FLAC 和 MP3,格式由文件头的魔数自动识别。 +package wav + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "time" + + "github.com/mewkiz/flac" +) + +type Audio struct { + SampleRate int + Channels int + Samples []float64 // 按声道交错,满刻度为 1,MP3 和浮点 WAV 的采样可以超出 [-1, 1] +} + +func (a *Audio) Frames() int { + if a.Channels <= 0 { + return 0 + } + return len(a.Samples) / a.Channels +} + +func (a *Audio) Duration() time.Duration { + if a.SampleRate <= 0 { + return 0 + } + return time.Duration(a.Frames()) * time.Second / time.Duration(a.SampleRate) +} + +const ( + formatPCM = 0x0001 + formatFloat = 0x0003 + formatExtensible = 0xFFFE +) + +// Decoder 按块返回按声道交错的 PCM 采样,满刻度为 1;MP3 和浮点 WAV 的采样可以超出 [-1, 1]。 +type Decoder interface { + // Read 解码并填充 p,返回采样个数;音频解码完毕时返回 io.EOF。p 至少要能容纳一个采样帧(Channels() 个采样)。 + Read(p []float64) (int, error) + SampleRate() int + Channels() int +} + +func NewDecoder(r io.Reader) (Decoder, error) { + var head [12]byte + if _, err := io.ReadFull(r, head[:]); err != nil { + return nil, fmt.Errorf("wav: 读取文件头失败: %w", err) + } + r = io.MultiReader(bytes.NewReader(head[:]), r) + switch { + case string(head[0:4]) == "RIFF" && string(head[8:12]) == "WAVE": + return newWAVDecoder(r) + case bytes.HasPrefix(head[:], []byte("fLaC")): + return newFLACDecoder(r) + case bytes.HasPrefix(head[:], []byte("ID3")) || (head[0] == 0xFF && head[1]&0xE0 == 0xE0): + return newMP3Decoder(r) + default: + return nil, errors.New("wav: 无法识别的音频格式 (支持 WAV/FLAC/MP3)") + } +} + +// 大文件建议改用 NewDecoder 流式解码以节约内存。 +func Decode(r io.Reader) (*Audio, error) { + dec, err := NewDecoder(r) + if err != nil { + return nil, err + } + return drainDecoder(dec) +} + +func drainDecoder(dec Decoder) (*Audio, error) { + buf := make([]float64, 8192) + samples := make([]float64, 0, 1<<20) + for { + n, err := dec.Read(buf) + samples = append(samples, buf[:n]...) + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + } + if len(samples) == 0 { + return nil, errors.New("wav: 音频流中没有采样数据") + } + return &Audio{SampleRate: dec.SampleRate(), Channels: dec.Channels(), Samples: samples}, nil +} + +type sampleQueue struct { + buf []float64 + off int +} + +func (q *sampleQueue) read(p []float64) int { + n := copy(p, q.buf[q.off:]) + q.off += n + if q.off >= len(q.buf) { + q.buf = q.buf[:0] + q.off = 0 + } + return n +} + +func (q *sampleQueue) grow(n int) int { + base := len(q.buf) + if base+n > cap(q.buf) { + nb := make([]float64, base+n) + copy(nb, q.buf) + q.buf = nb + } + q.buf = q.buf[:base+n] + return base +} + +type streamDecoder struct { + q sampleQueue + rate int + ch int + fill func() error +} + +func (d *streamDecoder) Read(p []float64) (int, error) { + for d.q.off >= len(d.q.buf) { + if err := d.fill(); err != nil { + return 0, err + } + } + return d.q.read(p), nil +} + +func (d *streamDecoder) SampleRate() int { return d.rate } +func (d *streamDecoder) Channels() int { return d.ch } + +func (d *streamDecoder) appendSamples(format, bits uint16, data []byte) { + bps := int(bits) / 8 + if bps == 0 { + return + } + n := len(data) / bps + out := d.q.buf[d.q.grow(n):] + switch format { + case formatPCM: + switch bits { + case 8: + for i, v := range data { + out[i] = (float64(v) - 128) / 128 + } + case 16: + for i := range out { + out[i] = float64(int16(binary.LittleEndian.Uint16(data[i*2:]))) / 32768 + } + case 24: + for i := range out { + v := int32(data[i*3]) | int32(data[i*3+1])<<8 | int32(data[i*3+2])<<16 + if v&0x800000 != 0 { + v |= ^0xFFFFFF // 符号扩展 + } + out[i] = float64(v) / 8388608 + } + case 32: + for i := range out { + out[i] = float64(int32(binary.LittleEndian.Uint32(data[i*4:]))) / 2147483648 + } + } + case formatFloat: + switch bits { + case 32: + for i := range out { + out[i] = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[i*4:]))) + } + case 64: + for i := range out { + out[i] = math.Float64frombits(binary.LittleEndian.Uint64(data[i*8:])) + } + } + } +} + +type wavHeader struct { + format uint16 + bits uint16 + rate int + channels int + dataSize int64 +} + +func newWAVDecoder(r io.Reader) (Decoder, error) { + h, err := parseWAVHeader(r) + if err != nil { + return nil, err + } + bps := int(h.bits) / 8 + if bps == 0 { + return nil, fmt.Errorf("wav: 不支持的位深 %d", h.bits) + } + if h.format != formatPCM && h.format != formatFloat { + return nil, fmt.Errorf("wav: 不支持的音频格式 0x%x", h.format) + } + d := &streamDecoder{rate: h.rate, ch: h.channels} + w := &wavDataReader{ + r: r, + format: h.format, + bits: h.bits, + bps: int64(bps), + remain: h.dataSize, + raw: make([]byte, 65536), + } + d.fill = func() error { return w.fill(d) } + return d, nil +} + +// 返回时 r 正好定位在 data 块起始处。 +func parseWAVHeader(r io.Reader) (wavHeader, error) { + var hdr [12]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return wavHeader{}, errors.New("wav: 不是合法的 RIFF/WAVE 文件") + } + if string(hdr[0:4]) != "RIFF" || string(hdr[8:12]) != "WAVE" { + return wavHeader{}, errors.New("wav: 不是合法的 RIFF/WAVE 文件") + } + + var h wavHeader + seenFmt := false + for { + var ch [8]byte + if _, err := io.ReadFull(r, ch[:]); err != nil { + return wavHeader{}, errors.New("wav: 缺少 data 块") + } + size := int64(binary.LittleEndian.Uint32(ch[4:])) + id := string(ch[0:4]) + + if id == "data" { + if !seenFmt || h.channels == 0 || h.rate == 0 { + return wavHeader{}, errors.New("wav: 缺少 fmt 块") + } + h.dataSize = size + return h, nil + } + if id != "fmt " { + // 跳过其他块(含 2 字节对齐填充)。 + if _, err := io.CopyN(io.Discard, r, size+size&1); err != nil { + return wavHeader{}, errors.New("wav: 缺少 data 块") + } + continue + } + + if size < 16 { + return wavHeader{}, errors.New("wav: fmt 块过短") + } + // 块的长度来自文件,不能按它分配内存:只读用到的字节(最长是 WAVE_FORMAT_EXTENSIBLE 的 40 字节),其余的丢弃。 + var buf [40]byte + used := min(size, int64(len(buf))) + if _, err := io.ReadFull(r, buf[:used]); err != nil { + return wavHeader{}, errors.New("wav: 读取 fmt 块失败") + } + if _, err := io.CopyN(io.Discard, r, size-used); err != nil { + return wavHeader{}, errors.New("wav: 读取 fmt 块失败") + } + h.format = binary.LittleEndian.Uint16(buf[:]) + h.channels = int(binary.LittleEndian.Uint16(buf[2:])) + h.rate = int(binary.LittleEndian.Uint32(buf[4:])) + h.bits = binary.LittleEndian.Uint16(buf[14:]) + if h.format == formatExtensible && size >= 40 { + h.format = binary.LittleEndian.Uint16(buf[24:]) + } + seenFmt = true + if size&1 == 1 { + io.CopyN(io.Discard, r, 1) // fmt 块的对齐填充 + } + } +} + +type wavDataReader struct { + r io.Reader + format uint16 + bits uint16 + bps int64 + remain int64 + raw []byte +} + +func (w *wavDataReader) fill(d *streamDecoder) error { + if w.remain <= 0 { + return io.EOF + } + n := min(int64(len(w.raw)), w.remain) + n -= n % w.bps + if n == 0 { // 尾部不足一个采样 + w.remain = 0 + return io.EOF + } + nn, err := io.ReadFull(w.r, w.raw[:n]) + w.remain -= int64(nn) + if nn > 0 { + d.appendSamples(w.format, w.bits, w.raw[:nn]) + } + if errors.Is(err, io.ErrUnexpectedEOF) { + err = io.EOF + } + if err != nil && len(d.q.buf) == 0 { + return err + } + return nil +} + +func newFLACDecoder(r io.Reader) (Decoder, error) { + stream, err := flac.New(r) + if err != nil { + return nil, fmt.Errorf("wav: 初始化 FLAC 解码器失败: %w", err) + } + info := stream.Info + divisor := float64(int64(1) << (info.BitsPerSample - 1)) + d := &streamDecoder{rate: int(info.SampleRate), ch: int(info.NChannels)} + d.fill = func() error { + for { + frame, err := stream.ParseNext() + if err != nil { + return err // 含 io.EOF + } + if len(frame.Subframes) == 0 { + continue + } + n := frame.Subframes[0].NSamples + ch := len(frame.Subframes) + out := d.q.buf[d.q.grow(n*ch):] + for c, sub := range frame.Subframes { + for i := 0; i < n; i++ { + out[i*ch+c] = float64(sub.Samples[i]) / divisor + } + } + return nil + } + } + return d, nil +} diff --git a/internal/lib/wav/mp3.go b/internal/lib/wav/mp3.go new file mode 100644 index 0000000..6b13034 --- /dev/null +++ b/internal/lib/wav/mp3.go @@ -0,0 +1,57 @@ +package wav + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/DiheMoe/gl3d" +) + +// gl3d 的 ReadPCM 按采样帧计数,而 Decoder.Read 按采样个数计数。 +type mp3Decoder struct{ *gl3d.Decoder } + +// ctx 传 Background:取消靠 r 在 ctx 结束后读取失败(下载流,以及 prepare 中包了 ctx 的文件),gl3d 会原样返回这个错误; +// WAV 和 FLAC 也是这样取消的,所以 Decoder 不接收 ctx。gl3d 自己的 ctx 只在每帧之前和每次读取之前检查, +// 即使传入真实的 ctx,也至多少解码已缓冲的 64 KiB。 +func newMP3Decoder(r io.Reader) (Decoder, error) { + dec, err := gl3d.NewDecoder(context.Background(), r) + if err != nil { + return nil, mp3Error(err) + } + return mp3Decoder{dec}, nil +} + +func (d mp3Decoder) Read(p []float64) (int, error) { + n, err := d.ReadPCM(context.Background(), p) + return n * d.Channels(), mp3Error(err) +} + +// io.EOF 原样返回,调用方靠它判断读完。 +// 每次 Read 都经过这里:errors.As 的目标变量要在提前返回之后声明,否则它们会逃逸到堆上,使每次 Read 多分配四个对象。 +func mp3Error(err error) error { + if err == nil || err == io.EOF { + return err + } + var ( + unsupported *gl3d.UnsupportedError + truncated *gl3d.TruncatedError + corrupt *gl3d.CorruptError + limit *gl3d.LimitError + ) + switch { + case errors.Is(err, gl3d.ErrNoAudio): + return fmt.Errorf("wav: MP3 中没有音频帧: %w", err) + case errors.As(err, &unsupported): + return fmt.Errorf("wav: 不支持的 MP3 格式: %w", err) + case errors.As(err, &truncated): + return fmt.Errorf("wav: MP3 数据被截断: %w", err) + case errors.As(err, &corrupt): + return fmt.Errorf("wav: MP3 数据损坏: %w", err) + case errors.As(err, &limit): + return fmt.Errorf("wav: MP3 超出解码的资源上限: %w", err) + default: + return fmt.Errorf("wav: 读取或解码 MP3 失败: %w", err) + } +} diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go new file mode 100644 index 0000000..5bc071a --- /dev/null +++ b/protocol/decode.gen.go @@ -0,0 +1,758 @@ +// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. + +package protocol + +import ( + "encoding/json" + "regexp" +) + +var ( + patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) + patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`) + patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`) +) + +func decodeConnectionRequest(typ string, raw json.RawMessage, path string) (any, bool, error) { + switch typ { + case RequestHello: + v, err := decodeHelloRequest(raw, path) + return v, true, err + case RequestPing: + v, err := decodePingRequest(raw, path) + return v, true, err + } + return nil, false, nil +} + +func decodeRoomRequest(typ string, raw json.RawMessage, path string) (any, bool, error) { + switch typ { + case RequestRoomSync: + v, err := decodeRoomSyncRequest(raw, path) + return v, true, err + case RequestQueueAdd: + v, err := decodeQueueAddRequest(raw, path) + return v, true, err + case RequestQueueRemove: + v, err := decodeQueueRemoveRequest(raw, path) + return v, true, err + case RequestQueueLike: + v, err := decodeQueueLikeRequest(raw, path) + return v, true, err + case RequestPlaybackVoteSkip: + v, err := decodePlaybackVoteSkipRequest(raw, path) + return v, true, err + case RequestSettingsSetPlayMode: + v, err := decodeSettingsSetPlayModeRequest(raw, path) + return v, true, err + case RequestChatSend: + v, err := decodeChatSendRequest(raw, path) + return v, true, err + case RequestSearchTracks: + v, err := decodeSearchTracksRequest(raw, path) + return v, true, err + case RequestSearchPlaylists: + v, err := decodeSearchPlaylistsRequest(raw, path) + return v, true, err + case RequestPlaylistTracks: + v, err := decodePlaylistTracksRequest(raw, path) + return v, true, err + case RequestRecommendList: + v, err := decodeRecommendListRequest(raw, path) + return v, true, err + } + return nil, false, nil +} + +func DecodeCreateMemberRequest(body []byte) (CreateMemberRequest, error) { + return decodeCreateMemberRequest(body, "") +} + +func DecodeUpdateMeRequest(body []byte) (UpdateMeRequest, error) { + return decodeUpdateMeRequest(body, "") +} + +func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) { + return decodeCreateRoomRequest(body, "") +} + +func DecodeCreateTicketRequest(body []byte) (CreateTicketRequest, error) { + return decodeCreateTicketRequest(body, "") +} + +func DecodeAdminCreateRoomRequest(body []byte) (AdminCreateRoomRequest, error) { + return decodeAdminCreateRoomRequest(body, "") +} + +func DecodeAdminUpdateRoomRequest(body []byte) (AdminUpdateRoomRequest, error) { + return decodeAdminUpdateRoomRequest(body, "") +} + +func DecodeAdminResetRoomApiKeyRequest(body []byte) (AdminResetRoomApiKeyRequest, error) { + return decodeAdminResetRoomApiKeyRequest(body, "") +} + +func DecodeAdminStartNeteaseLoginRequest(body []byte) (AdminStartNeteaseLoginRequest, error) { + return decodeAdminStartNeteaseLoginRequest(body, "") +} + +func DecodeAdminCheckNeteaseLoginRequest(body []byte) (AdminCheckNeteaseLoginRequest, error) { + return decodeAdminCheckNeteaseLoginRequest(body, "") +} + +func DecodeAdminStartQQLoginRequest(body []byte) (AdminStartQQLoginRequest, error) { + return decodeAdminStartQQLoginRequest(body, "") +} + +func DecodeAdminCheckQQLoginRequest(body []byte) (AdminCheckQQLoginRequest, error) { + return decodeAdminCheckQQLoginRequest(body, "") +} + +func DecodeAdminStartBilibiliLoginRequest(body []byte) (AdminStartBilibiliLoginRequest, error) { + return decodeAdminStartBilibiliLoginRequest(body, "") +} + +func DecodeAdminCheckBilibiliLoginRequest(body []byte) (AdminCheckBilibiliLoginRequest, error) { + return decodeAdminCheckBilibiliLoginRequest(body, "") +} + +func DecodeAdminSetYoutubeCredentialRequest(body []byte) (AdminSetYoutubeCredentialRequest, error) { + return decodeAdminSetYoutubeCredentialRequest(body, "") +} + +func DecodeAdminUpdateSiteRequest(body []byte) (AdminUpdateSiteRequest, error) { + return decodeAdminUpdateSiteRequest(body, "") +} + +func decodeAdminCheckBilibiliLoginRequest(raw json.RawMessage, path string) (AdminCheckBilibiliLoginRequest, error) { + var v AdminCheckBilibiliLoginRequest + m, err := decodeObject(raw, path, "key") + if err != nil { + return v, err + } + if err = requireFields(m, path, "key"); err != nil { + return v, err + } + if v.Key, err = decodeString(m["key"], field(path, "key")); err != nil { + return v, err + } + if err = checkMinLength(v.Key, field(path, "key"), 1); err != nil { + return v, err + } + return v, nil +} + +func decodeAdminCheckNeteaseLoginRequest(raw json.RawMessage, path string) (AdminCheckNeteaseLoginRequest, error) { + var v AdminCheckNeteaseLoginRequest + m, err := decodeObject(raw, path, "key") + if err != nil { + return v, err + } + if err = requireFields(m, path, "key"); err != nil { + return v, err + } + if v.Key, err = decodeString(m["key"], field(path, "key")); err != nil { + return v, err + } + if err = checkMinLength(v.Key, field(path, "key"), 1); err != nil { + return v, err + } + return v, nil +} + +func decodeAdminCheckQQLoginRequest(raw json.RawMessage, path string) (AdminCheckQQLoginRequest, error) { + var v AdminCheckQQLoginRequest + m, err := decodeObject(raw, path, "key", "type") + if err != nil { + return v, err + } + if err = requireFields(m, path, "key", "type"); err != nil { + return v, err + } + if v.Key, err = decodeString(m["key"], field(path, "key")); err != nil { + return v, err + } + if err = checkMinLength(v.Key, field(path, "key"), 1); err != nil { + return v, err + } + if v.Type, err = decodeQQLoginType(m["type"], field(path, "type")); err != nil { + return v, err + } + return v, nil +} + +func decodeAdminCreateRoomRequest(raw json.RawMessage, path string) (AdminCreateRoomRequest, error) { + var v AdminCreateRoomRequest + m, err := decodeObject(raw, path, "description", "name", "password", "slug") + if err != nil { + return v, err + } + if err = requireFields(m, path, "description", "name"); err != nil { + return v, err + } + if v.Description, err = decodeString(m["description"], field(path, "description")); err != nil { + return v, err + } + if err = checkMaxLength(v.Description, field(path, "description"), 500); err != nil { + return v, err + } + if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil { + return v, err + } + if err = checkMinLength(v.Name, field(path, "name"), 1); err != nil { + return v, err + } + if err = checkMaxLength(v.Name, field(path, "name"), 80); err != nil { + return v, err + } + if r, ok := m["password"]; ok { + x, err := decodeString(r, field(path, "password")) + if err != nil { + return v, err + } + v.Password = &x + } + if r, ok := m["slug"]; ok { + x, err := decodeString(r, field(path, "slug")) + if err != nil { + return v, err + } + if err = checkPattern(x, field(path, "slug"), patternAdminCreateRoomRequestSlug); err != nil { + return v, err + } + v.Slug = &x + } + return v, nil +} + +func decodeAdminResetRoomApiKeyRequest(raw json.RawMessage, path string) (AdminResetRoomApiKeyRequest, error) { + var v AdminResetRoomApiKeyRequest + _, err := decodeObject(raw, path) + return v, err +} + +func decodeAdminSetYoutubeCredentialRequest(raw json.RawMessage, path string) (AdminSetYoutubeCredentialRequest, error) { + var v AdminSetYoutubeCredentialRequest + m, err := decodeObject(raw, path, "cookies") + if err != nil { + return v, err + } + if err = requireFields(m, path, "cookies"); err != nil { + return v, err + } + if v.Cookies, err = decodeString(m["cookies"], field(path, "cookies")); err != nil { + return v, err + } + if err = checkMinLength(v.Cookies, field(path, "cookies"), 1); err != nil { + return v, err + } + return v, nil +} + +func decodeAdminStartBilibiliLoginRequest(raw json.RawMessage, path string) (AdminStartBilibiliLoginRequest, error) { + var v AdminStartBilibiliLoginRequest + _, err := decodeObject(raw, path) + return v, err +} + +func decodeAdminStartNeteaseLoginRequest(raw json.RawMessage, path string) (AdminStartNeteaseLoginRequest, error) { + var v AdminStartNeteaseLoginRequest + _, err := decodeObject(raw, path) + return v, err +} + +func decodeAdminStartQQLoginRequest(raw json.RawMessage, path string) (AdminStartQQLoginRequest, error) { + var v AdminStartQQLoginRequest + m, err := decodeObject(raw, path, "type") + if err != nil { + return v, err + } + if err = requireFields(m, path, "type"); err != nil { + return v, err + } + if v.Type, err = decodeQQLoginType(m["type"], field(path, "type")); err != nil { + return v, err + } + return v, nil +} + +func decodeAdminUpdateRoomRequest(raw json.RawMessage, path string) (AdminUpdateRoomRequest, error) { + var v AdminUpdateRoomRequest + m, err := decodeObject(raw, path, "description", "name", "password", "slug") + if err != nil { + return v, err + } + if r, ok := m["description"]; ok { + x, err := decodeString(r, field(path, "description")) + if err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "description"), 500); err != nil { + return v, err + } + v.Description = &x + } + if r, ok := m["name"]; ok { + x, err := decodeString(r, field(path, "name")) + if err != nil { + return v, err + } + if err = checkMinLength(x, field(path, "name"), 1); err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "name"), 80); err != nil { + return v, err + } + v.Name = &x + } + if r, ok := m["password"]; ok { + x, err := decodeString(r, field(path, "password")) + if err != nil { + return v, err + } + v.Password = &x + } + if r, ok := m["slug"]; ok { + x, err := decodeString(r, field(path, "slug")) + if err != nil { + return v, err + } + if err = checkPattern(x, field(path, "slug"), patternAdminUpdateRoomRequestSlug); err != nil { + return v, err + } + v.Slug = &x + } + return v, nil +} + +func decodeAdminUpdateSiteRequest(raw json.RawMessage, path string) (AdminUpdateSiteRequest, error) { + var v AdminUpdateSiteRequest + m, err := decodeObject(raw, path, "brandName") + if err != nil { + return v, err + } + if err = requireFields(m, path, "brandName"); err != nil { + return v, err + } + if v.BrandName, err = decodeString(m["brandName"], field(path, "brandName")); err != nil { + return v, err + } + return v, nil +} + +func decodeChatSendRequest(raw json.RawMessage, path string) (ChatSendRequest, error) { + var v ChatSendRequest + m, err := decodeObject(raw, path, "content") + if err != nil { + return v, err + } + if err = requireFields(m, path, "content"); err != nil { + return v, err + } + if v.Content, err = decodeString(m["content"], field(path, "content")); err != nil { + return v, err + } + return v, nil +} + +func decodeCreateMemberRequest(raw json.RawMessage, path string) (CreateMemberRequest, error) { + var v CreateMemberRequest + _, err := decodeObject(raw, path) + return v, err +} + +func decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) { + var v CreateRoomRequest + m, err := decodeObject(raw, path, "description", "name", "password") + if err != nil { + return v, err + } + if err = requireFields(m, path, "description", "name"); err != nil { + return v, err + } + if v.Description, err = decodeString(m["description"], field(path, "description")); err != nil { + return v, err + } + if err = checkMaxLength(v.Description, field(path, "description"), 500); err != nil { + return v, err + } + if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil { + return v, err + } + if err = checkMinLength(v.Name, field(path, "name"), 1); err != nil { + return v, err + } + if err = checkMaxLength(v.Name, field(path, "name"), 80); err != nil { + return v, err + } + if r, ok := m["password"]; ok { + x, err := decodeString(r, field(path, "password")) + if err != nil { + return v, err + } + v.Password = &x + } + return v, nil +} + +func decodeCreateTicketRequest(raw json.RawMessage, path string) (CreateTicketRequest, error) { + var v CreateTicketRequest + m, err := decodeObject(raw, path, "password") + if err != nil { + return v, err + } + if r, ok := m["password"]; ok { + x, err := decodeString(r, field(path, "password")) + if err != nil { + return v, err + } + v.Password = &x + } + return v, nil +} + +func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error) { + var v HelloRequest + m, err := decodeObject(raw, path, "protocolVersion", "roomId", "ticket") + if err != nil { + return v, err + } + if err = requireFields(m, path, "protocolVersion", "roomId", "ticket"); err != nil { + return v, err + } + if v.ProtocolVersion, err = decodeString(m["protocolVersion"], field(path, "protocolVersion")); err != nil { + return v, err + } + if v.RoomID, err = decodeString(m["roomId"], field(path, "roomId")); err != nil { + return v, err + } + if v.Ticket, err = decodeString(m["ticket"], field(path, "ticket")); err != nil { + return v, err + } + return v, nil +} + +func decodePingRequest(raw json.RawMessage, path string) (PingRequest, error) { + var v PingRequest + m, err := decodeObject(raw, path, "clientTimeMs") + if err != nil { + return v, err + } + if err = requireFields(m, path, "clientTimeMs"); err != nil { + return v, err + } + if v.ClientTimeMs, err = decodeInteger(m["clientTimeMs"], field(path, "clientTimeMs")); err != nil { + return v, err + } + return v, nil +} + +func decodePlayMode(raw json.RawMessage, path string) (PlayMode, error) { + s, err := decodeString(raw, path) + if err != nil { + return "", err + } + switch v := PlayMode(s); v { + case PlayModeSequential, PlayModeRandom, PlayModeRotation: + return v, nil + } + return "", invalid(path, "取值应为 sequential、random、rotation 之一") +} + +func decodePlaybackVoteSkipRequest(raw json.RawMessage, path string) (PlaybackVoteSkipRequest, error) { + var v PlaybackVoteSkipRequest + _, err := decodeObject(raw, path) + return v, err +} + +func decodePlaylistTracksRequest(raw json.RawMessage, path string) (PlaylistTracksRequest, error) { + var v PlaylistTracksRequest + m, err := decodeObject(raw, path, "playlistId", "source") + if err != nil { + return v, err + } + if err = requireFields(m, path, "playlistId", "source"); err != nil { + return v, err + } + if v.PlaylistID, err = decodeString(m["playlistId"], field(path, "playlistId")); err != nil { + return v, err + } + if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil { + return v, err + } + return v, nil +} + +func decodeQQLoginType(raw json.RawMessage, path string) (QQLoginType, error) { + s, err := decodeString(raw, path) + if err != nil { + return "", err + } + switch v := QQLoginType(s); v { + case QQLoginTypeQQ, QQLoginTypeWx: + return v, nil + } + return "", invalid(path, "取值应为 qq、wx 之一") +} + +func decodeQueueAddRequest(raw json.RawMessage, path string) (QueueAddRequest, error) { + var v QueueAddRequest + m, err := decodeObject(raw, path, "keyword", "requester", "source", "track") + if err != nil { + return v, err + } + if err = checkOneOf(m, path, "只能提供以下字段组合之一:track;source、keyword", []string{"requester"}, []string{"track"}, []string{"source", "keyword"}); err != nil { + return v, err + } + if r, ok := m["keyword"]; ok { + x, err := decodeString(r, field(path, "keyword")) + if err != nil { + return v, err + } + if err = checkMinLength(x, field(path, "keyword"), 1); err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "keyword"), 500); err != nil { + return v, err + } + v.Keyword = &x + } + if r, ok := m["requester"]; ok { + x, err := decodeString(r, field(path, "requester")) + if err != nil { + return v, err + } + if err = checkMinLength(x, field(path, "requester"), 1); err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "requester"), 80); err != nil { + return v, err + } + v.Requester = &x + } + if r, ok := m["source"]; ok { + x, err := decodeSource(r, field(path, "source")) + if err != nil { + return v, err + } + v.Source = &x + } + if r, ok := m["track"]; ok { + x, err := decodeTrack(r, field(path, "track")) + if err != nil { + return v, err + } + v.Track = &x + } + return v, nil +} + +func decodeQueueLikeRequest(raw json.RawMessage, path string) (QueueLikeRequest, error) { + var v QueueLikeRequest + m, err := decodeObject(raw, path, "entryId") + if err != nil { + return v, err + } + if err = requireFields(m, path, "entryId"); err != nil { + return v, err + } + if v.EntryID, err = decodeString(m["entryId"], field(path, "entryId")); err != nil { + return v, err + } + return v, nil +} + +func decodeQueueRemoveRequest(raw json.RawMessage, path string) (QueueRemoveRequest, error) { + var v QueueRemoveRequest + m, err := decodeObject(raw, path, "entryId") + if err != nil { + return v, err + } + if err = requireFields(m, path, "entryId"); err != nil { + return v, err + } + if v.EntryID, err = decodeString(m["entryId"], field(path, "entryId")); err != nil { + return v, err + } + return v, nil +} + +func decodeRecommendListRequest(raw json.RawMessage, path string) (RecommendListRequest, error) { + var v RecommendListRequest + _, err := decodeObject(raw, path) + return v, err +} + +func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) { + var v RoomSyncRequest + _, err := decodeObject(raw, path) + return v, err +} + +func decodeSearchPlaylistsRequest(raw json.RawMessage, path string) (SearchPlaylistsRequest, error) { + var v SearchPlaylistsRequest + m, err := decodeObject(raw, path, "keyword", "page", "pageSize", "source") + if err != nil { + return v, err + } + if err = requireFields(m, path, "keyword", "page", "pageSize", "source"); err != nil { + return v, err + } + if v.Keyword, err = decodeString(m["keyword"], field(path, "keyword")); err != nil { + return v, err + } + if err = checkMinLength(v.Keyword, field(path, "keyword"), 1); err != nil { + return v, err + } + if err = checkMaxLength(v.Keyword, field(path, "keyword"), 500); err != nil { + return v, err + } + if v.Page, err = decodeInteger(m["page"], field(path, "page")); err != nil { + return v, err + } + if err = checkMinimum(v.Page, field(path, "page"), 1); err != nil { + return v, err + } + if v.PageSize, err = decodeInteger(m["pageSize"], field(path, "pageSize")); err != nil { + return v, err + } + if err = checkMinimum(v.PageSize, field(path, "pageSize"), 1); err != nil { + return v, err + } + if err = checkMaximum(v.PageSize, field(path, "pageSize"), 100); err != nil { + return v, err + } + if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil { + return v, err + } + return v, nil +} + +func decodeSearchTracksRequest(raw json.RawMessage, path string) (SearchTracksRequest, error) { + var v SearchTracksRequest + m, err := decodeObject(raw, path, "keyword", "page", "pageSize", "source") + if err != nil { + return v, err + } + if err = requireFields(m, path, "keyword", "page", "pageSize", "source"); err != nil { + return v, err + } + if v.Keyword, err = decodeString(m["keyword"], field(path, "keyword")); err != nil { + return v, err + } + if err = checkMinLength(v.Keyword, field(path, "keyword"), 1); err != nil { + return v, err + } + if err = checkMaxLength(v.Keyword, field(path, "keyword"), 500); err != nil { + return v, err + } + if v.Page, err = decodeInteger(m["page"], field(path, "page")); err != nil { + return v, err + } + if err = checkMinimum(v.Page, field(path, "page"), 1); err != nil { + return v, err + } + if v.PageSize, err = decodeInteger(m["pageSize"], field(path, "pageSize")); err != nil { + return v, err + } + if err = checkMinimum(v.PageSize, field(path, "pageSize"), 1); err != nil { + return v, err + } + if err = checkMaximum(v.PageSize, field(path, "pageSize"), 100); err != nil { + return v, err + } + if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil { + return v, err + } + return v, nil +} + +func decodeSettingsSetPlayModeRequest(raw json.RawMessage, path string) (SettingsSetPlayModeRequest, error) { + var v SettingsSetPlayModeRequest + m, err := decodeObject(raw, path, "playMode") + if err != nil { + return v, err + } + if err = requireFields(m, path, "playMode"); err != nil { + return v, err + } + if v.PlayMode, err = decodePlayMode(m["playMode"], field(path, "playMode")); err != nil { + return v, err + } + return v, nil +} + +func decodeSource(raw json.RawMessage, path string) (Source, error) { + s, err := decodeString(raw, path) + if err != nil { + return "", err + } + switch v := Source(s); v { + case SourceNetease, SourceQQ, SourceBilibili, SourceLink: + return v, nil + } + return "", invalid(path, "取值应为 netease、qq、bilibili、link 之一") +} + +func decodeTrack(raw json.RawMessage, path string) (Track, error) { + var v Track + m, err := decodeObject(raw, path, "id", "source") + if err != nil { + return v, err + } + if err = requireFields(m, path, "id", "source"); err != nil { + return v, err + } + if v.ID, err = decodeString(m["id"], field(path, "id")); err != nil { + return v, err + } + if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil { + return v, err + } + if v.Source == SourceLink { + if err = checkMaxBytes(v.ID, field(path, "id"), 2048); err != nil { + return v, err + } + if err = checkURL(v.ID, field(path, "id")); err != nil { + return v, err + } + } + if v.Source == SourceBilibili { + if err = checkPattern(v.ID, field(path, "id"), patternTrackIDWhenBilibili); err != nil { + return v, err + } + } + return v, nil +} + +func decodeUpdateMeRequest(raw json.RawMessage, path string) (UpdateMeRequest, error) { + var v UpdateMeRequest + m, err := decodeObject(raw, path, "email", "name") + if err != nil { + return v, err + } + if r, ok := m["email"]; ok { + x, err := decodeString(r, field(path, "email")) + if err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "email"), 320); err != nil { + return v, err + } + v.Email = &x + } + if r, ok := m["name"]; ok { + x, err := decodeString(r, field(path, "name")) + if err != nil { + return v, err + } + if err = checkMinLength(x, field(path, "name"), 1); err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "name"), 80); err != nil { + return v, err + } + v.Name = &x + } + return v, nil +} diff --git a/protocol/decode.go b/protocol/decode.go new file mode 100644 index 0000000..d866092 --- /dev/null +++ b/protocol/decode.go @@ -0,0 +1,241 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "net/netip" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "unicode/utf8" +) + +// ArgumentError 的 Error() 原样作为 INVALID_ARGUMENT 的 message 返回给客户端。 +type ArgumentError struct { + Path string + Reason string +} + +func (e *ArgumentError) Error() string { + if e.Path == "" { + return e.Reason + } + return e.Path + ":" + e.Reason +} + +func invalid(path, reason string) error { + return &ArgumentError{Path: path, Reason: reason} +} + +func field(path, name string) string { + if path == "" { + return name + } + return path + "." + name +} + +var null = []byte("null") + +func decodeObject(raw json.RawMessage, path string, known ...string) (map[string]json.RawMessage, error) { + var m map[string]json.RawMessage + if json.Unmarshal(raw, &m) != nil || m == nil { + return nil, invalid(path, "应为 JSON 对象") + } + return m, rejectUnknown(m, path, known...) +} + +func rejectUnknown(m map[string]json.RawMessage, path string, known ...string) error { + // 按名称顺序检查,有多个未知字段时报告的总是同一个。 + for _, name := range slices.Sorted(maps.Keys(m)) { + if !slices.Contains(known, name) { + return invalid(field(path, name), "未知字段") + } + } + return nil +} + +func requireFields(m map[string]json.RawMessage, path string, names ...string) error { + for _, name := range names { + if _, ok := m[name]; !ok { + return invalid(field(path, name), "缺少必填字段") + } + } + return nil +} + +// encoding/json 把 null 解码为零值而不报错,所以 null 要单独拒绝。 +func decodeString(raw json.RawMessage, path string) (string, error) { + var s string + if bytes.Equal(raw, null) || json.Unmarshal(raw, &s) != nil { + return "", invalid(path, "应为字符串") + } + // PostgreSQL 的 text 无法存储 U+0000,含有它的文本一旦进入落盘批次就会让落盘失败。 + if strings.ContainsRune(s, 0) { + return "", invalid(path, "不能包含 U+0000") + } + return s, nil +} + +func decodeInteger(raw json.RawMessage, path string) (int64, error) { + var n int64 + if bytes.Equal(raw, null) || json.Unmarshal(raw, &n) != nil { + return 0, invalid(path, "应为整数") + } + return n, nil +} + +func checkMinLength(s, path string, n int) error { + if utf8.RuneCountInString(s) < n { + return invalid(path, fmt.Sprintf("长度不能少于 %d 个字符", n)) + } + return nil +} + +func checkMaxLength(s, path string, n int) error { + if utf8.RuneCountInString(s) > n { + return invalid(path, fmt.Sprintf("长度不能超过 %d 个字符", n)) + } + return nil +} + +func checkMaxBytes(s, path string, n int) error { + if len(s) > n { + return invalid(path, fmt.Sprintf("不能超过 %d 字节", n)) + } + return nil +} + +func checkMinimum(v int64, path string, n int64) error { + if v < n { + return invalid(path, fmt.Sprintf("不能小于 %d", n)) + } + return nil +} + +func checkMaximum(v int64, path string, n int64) error { + if v > n { + return invalid(path, fmt.Sprintf("不能大于 %d", n)) + } + return nil +} + +func checkPattern(s, path string, re *regexp.Regexp) error { + if !re.MatchString(s) { + return invalid(path, "格式不正确") + } + return nil +} + +func checkURL(s, path string) error { + if !validURL(s) { + return invalid(path, "应为 http 或 https 的链接") + } + return nil +} + +var ( + bracketedHost = regexp.MustCompile(`^\[[0-9A-Fa-f:.]+\]$`) + numericLabel = regexp.MustCompile(`^(?i:[0-9]+|0x[0-9a-f]*)$`) +) + +// x-url:scheme 为 http 或 https(不区分大小写)的绝对地址。浏览器的 new URL() 与 url.Parse 对一些写法的判断不同, +// 网页生成的校验函数按同样的规则检查,两边的结果才一致(protocol/testdata 中的用例由两边的测试共用): +// - 不含控制字符、空格和反斜杠(浏览器把 \ 当作 /),不含用户信息; +// - 主机名只由字母、数字、-、.、_、~ 和非 ASCII 字符组成,或者是方括号中的 IPv6 地址(浏览器会解码主机名中的 % 编码); +// - 最后一段是数字(或以 0x 开头)的主机名会被浏览器按 IPv4 地址解析,所以必须是规范的 IPv4 地址; +// - 端口不超过 65535,路径和片段中的 % 后面必须是两位十六进制数(url.Parse 不接受其他写法,浏览器却照样接受)。 +// +// 国际化域名(主机名含非 ASCII 字符,或者有以 xn-- 开头的标签)只由浏览器检查:不合法时浏览器拒绝而这里接受,到准备时才会下载失败。 +func validURL(s string) bool { + if strings.ContainsFunc(s, func(r rune) bool { return r <= ' ' || r == 0x7f || r == '\\' }) { + return false + } + scheme, rest, ok := strings.Cut(s, "://") + if !ok || !strings.EqualFold(scheme, "http") && !strings.EqualFold(scheme, "https") { + return false + } + authority, tail := rest, "" + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + authority, tail = rest[:i], rest[i:] + } + host, port := authority, "" + if strings.HasPrefix(authority, "[") { + end := strings.IndexByte(authority, ']') + 1 + host = authority[:end] + if end == 0 || !bracketedHost.MatchString(host) { + return false + } + if after := authority[end:]; after != "" { + if port, ok = strings.CutPrefix(after, ":"); !ok { + return false + } + } + } else { + if h, p, ok := strings.Cut(authority, ":"); ok { + host, port = h, p + } + if host == "" || strings.ContainsFunc(host, func(r rune) bool { return r < utf8.RuneSelf && !hostChar(r) }) { + return false + } + labels := strings.Split(strings.TrimSuffix(host, "."), ".") + if numericLabel.MatchString(labels[len(labels)-1]) { + if a, err := netip.ParseAddr(host); err != nil || !a.Is4() { + return false + } + } + } + if strings.Trim(port, "0123456789") != "" { + return false + } + if n, err := strconv.Atoi(port); port != "" && (err != nil || n > 65535) { + return false + } + path, fragment, _ := strings.Cut(tail, "#") + path, _, _ = strings.Cut(path, "?") + if !validEscapes(path) || !validEscapes(fragment) { + return false + } + u, err := url.Parse(s) + return err == nil && u.Hostname() != "" +} + +func hostChar(r rune) bool { + return 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' || '0' <= r && r <= '9' || strings.ContainsRune("-._~", r) +} + +func validEscapes(s string) bool { + for { + i := strings.IndexByte(s, '%') + if i < 0 { + return true + } + if i+2 >= len(s) || !isHex(s[i+1]) || !isHex(s[i+2]) { + return false + } + s = s[i+3:] + } +} + +func isHex(c byte) bool { + return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F' +} + +// IDL 中 oneOf 的每个分支都用 additionalProperties: false 限定了字段,所以除去各分支共用的可选字段,出现的字段必须恰好是其中一组。 +func checkOneOf(m map[string]json.RawMessage, path, reason string, optional []string, groups ...[]string) error { + n := len(m) + for _, name := range optional { + if _, ok := m[name]; ok { + n-- + } + } + for _, group := range groups { + if n == len(group) && requireFields(m, path, group...) == nil { + return nil + } + } + return invalid(path, reason) +} diff --git a/protocol/http.go b/protocol/http.go new file mode 100644 index 0000000..c645baa --- /dev/null +++ b/protocol/http.go @@ -0,0 +1,94 @@ +package protocol + +import ( + "encoding/json" + "maps" + "net/url" + "slices" + "strconv" +) + +type Auth uint8 + +const ( + AuthNone Auth = iota + AuthMember + AuthTicket + AuthAdmin +) + +type Route struct { + Pattern string + Auth Auth + Status int + Location bool +} + +// 房间内请求的 HTTP 绑定。路径参数是请求中同名的字段;GET 和 DELETE 的其余字段来自查询串,其他方法的来自 JSON 请求体。 +// 组装出的请求交给与 WebSocket 相同的解码函数校验。 +type RoomRoute struct { + Route + // WebSocket 的请求类型。 + Type string + params []string + query map[string]queryKind + body bool +} + +type queryKind uint8 + +const ( + queryString queryKind = iota + queryInteger +) + +// 空的请求体按 {} 处理:没有字段的请求可以不带请求体。 +func (r RoomRoute) Decode(pathValue func(string) string, rawQuery string, body []byte) (any, error) { + query, err := url.ParseQuery(rawQuery) + if err != nil { + return nil, invalid("", "查询串格式不正确") + } + m := map[string]json.RawMessage{} + for _, name := range slices.Sorted(maps.Keys(query)) { + kind, ok := r.query[name] + switch { + case !ok: + return nil, invalid(name, "未知参数") + case len(query[name]) > 1: + return nil, invalid(name, "参数重复") + case kind == queryInteger: + n, err := strconv.ParseInt(query.Get(name), 10, 64) + if err != nil { + return nil, invalid(name, "应为整数") + } + m[name] = json.RawMessage(strconv.FormatInt(n, 10)) + default: + m[name] = jsonString(query.Get(name)) + } + } + if r.body && len(body) > 0 { + if json.Unmarshal(body, &m) != nil || m == nil { + return nil, invalid("", "应为 JSON 对象") + } + } + for _, name := range r.params { + if _, ok := m[name]; ok { + return nil, invalid(name, "未知字段") + } + m[name] = jsonString(pathValue(name)) + } + raw, err := json.Marshal(m) + if err != nil { + return nil, err + } + data, _, err := decodeRoomRequest(r.Type, raw, "") + if err != nil { + return nil, err + } + return data, nil +} + +func jsonString(s string) json.RawMessage { + b, _ := json.Marshal(s) + return b +} diff --git a/protocol/idl.json b/protocol/idl.json new file mode 100644 index 0000000..bf169f5 --- /dev/null +++ b/protocol/idl.json @@ -0,0 +1,1100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Alisten", + "$defs": { + "Empty": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "ErrorCode": { + "type": "string", + "enum": [ + "INVALID_ARGUMENT", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "QUEUE_FULL", + "NOTHING_PLAYING", + "RATE_LIMITED", + "PROVIDER_ERROR", + "UNAVAILABLE", + "CLIENT_OUTDATED", + "INTERNAL" + ], + "x-httpStatus": { + "INVALID_ARGUMENT": 400, + "UNAUTHORIZED": 401, + "FORBIDDEN": 403, + "NOT_FOUND": 404, + "CONFLICT": 409, + "QUEUE_FULL": 409, + "NOTHING_PLAYING": 409, + "RATE_LIMITED": 429, + "PROVIDER_ERROR": 502, + "UNAVAILABLE": 503, + "INTERNAL": 500 + } + }, + "ErrorInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": {"$ref": "#/$defs/ErrorCode"}, + "message": {"type": "string", "description": "给人看的中文说明,客户端不得据此判断业务分支"} + }, + "required": ["code", "message"] + }, + "Source": { + "type": "string", + "enum": ["netease", "qq", "bilibili", "link"] + }, + "PlayMode": { + "type": "string", + "enum": ["sequential", "random", "rotation"] + }, + "RoomKind": { + "type": "string", + "enum": ["persistent", "ephemeral"] + }, + "EntryStatus": { + "type": "string", + "enum": ["preparing", "ready"] + }, + "ActivityKind": { + "type": "string", + "enum": ["added", "removed", "liked", "voted", "skipped", "failed"] + }, + "Keyword": {"type": "string", "description": "搜索和按关键词点歌的关键词", "minLength": 1, "maxLength": 500}, + "Page": {"type": "integer", "description": "页码,从 1 开始", "minimum": 1}, + "PageSize": {"type": "integer", "description": "每页的条数", "minimum": 1, "maximum": 100}, + "MemberName": {"type": "string", "description": "昵称", "minLength": 1, "maxLength": 80}, + "RoomName": {"type": "string", "description": "房间名称", "minLength": 1, "maxLength": 80}, + "RoomDescription": {"type": "string", "description": "房间描述", "maxLength": 500}, + "Link": {"type": "string", "description": "http 或 https 的绝对地址,scheme 不区分大小写,按 UTF-8 编码不超过 2048 字节", "x-url": true, "x-maxBytes": 2048}, + "Track": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": {"$ref": "#/$defs/Source"}, + "id": {"type": "string", "description": "来源为 link 时是链接,响应和事件中是规范化之后的链接(scheme 和主机名转成小写);来源为 bilibili 时是 BV 号,或从第 2 P 起的 BV 号/分P序号"} + }, + "required": ["source", "id"], + "allOf": [ + { + "if": {"properties": {"source": {"const": "link"}}, "required": ["source"]}, + "then": {"properties": {"id": {"$ref": "#/$defs/Link"}}} + }, + { + "if": {"properties": {"source": {"const": "bilibili"}}, "required": ["source"]}, + "then": {"properties": {"id": {"pattern": "^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$"}}} + } + ] + }, + "TrackMeta": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": {"type": "string"}, + "artist": {"type": "string"}, + "album": {"type": "string"}, + "coverUrl": {"type": "string"}, + "pageUrl": {"type": "string", "description": "这首歌在音源网站上的页面,来源为 link 时是规范化之后的链接(scheme 和主机名转成小写),即曲目 ID"}, + "durationMs": {"type": "integer", "description": "未知时为 0"} + }, + "required": ["title", "artist", "album", "coverUrl", "pageUrl", "durationMs"] + }, + "TrackSummary": { + "type": "object", + "additionalProperties": false, + "properties": { + "track": {"$ref": "#/$defs/Track"}, + "meta": {"$ref": "#/$defs/TrackMeta"} + }, + "required": ["track", "meta"] + }, + "PlaylistSummary": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": {"$ref": "#/$defs/Source"}, + "id": {"type": "string"}, + "name": {"type": "string"}, + "coverUrl": {"type": "string"}, + "creator": {"type": "string"}, + "description": {"type": "string"}, + "trackCount": {"type": "integer"}, + "playCount": {"type": "integer"} + }, + "required": ["source", "id", "name", "coverUrl", "creator", "description", "trackCount", "playCount"] + }, + "Member": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "avatarHash": {"type": "string", "description": "邮箱的 MD5,未设置邮箱时为空字符串"} + }, + "required": ["id", "name", "avatarHash"] + }, + "Members": { + "type": "object", + "additionalProperties": false, + "properties": { + "sessions": {"type": "array", "items": {"$ref": "#/$defs/Member"}, "description": "每个会话一项"}, + "onlineCount": {"type": "integer"} + }, + "required": ["sessions", "onlineCount"] + }, + "Entry": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "track": {"$ref": "#/$defs/Track"}, + "meta": {"$ref": "#/$defs/TrackMeta"}, + "by": {"anyOf": [{"$ref": "#/$defs/Member"}, {"type": "null"}], "description": "AutoDJ 加入的歌为 null"}, + "auto": {"type": "boolean"}, + "likes": {"type": "integer"}, + "status": {"$ref": "#/$defs/EntryStatus"} + }, + "required": ["id", "track", "meta", "by", "auto", "likes", "status"] + }, + "NowPlaying": { + "type": "object", + "additionalProperties": false, + "properties": { + "entry": {"$ref": "#/$defs/Entry"}, + "mediaUrl": {"type": "string"}, + "startedAtMs": {"type": "integer"}, + "durationMs": {"type": "integer", "description": "准备结果中的时长"}, + "gain": {"type": "number", "description": "线性增益系数"}, + "lyric": {"type": "string"} + }, + "required": ["entry", "mediaUrl", "startedAtMs", "durationMs", "gain", "lyric"] + }, + "RoomInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "kind": {"$ref": "#/$defs/RoomKind"} + }, + "required": ["id", "name", "description", "kind"] + }, + "RoomSnapshot": { + "type": "object", + "additionalProperties": false, + "properties": { + "term": {"type": "integer"}, + "seq": {"type": "integer"}, + "room": {"$ref": "#/$defs/RoomInfo"}, + "playMode": {"$ref": "#/$defs/PlayMode"}, + "nowPlaying": {"anyOf": [{"$ref": "#/$defs/NowPlaying"}, {"type": "null"}]}, + "pending": {"anyOf": [{"$ref": "#/$defs/Entry"}, {"type": "null"}]}, + "items": {"type": "array", "items": {"$ref": "#/$defs/Entry"}, "description": "等待队列"}, + "members": {"$ref": "#/$defs/Members"} + }, + "required": ["term", "seq", "room", "playMode", "nowPlaying", "pending", "items", "members"] + }, + "RoomListing": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "slug": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "没有别名时为 null"}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "kind": {"$ref": "#/$defs/RoomKind"}, + "hasPassword": {"type": "boolean"}, + "onlineCount": {"type": "integer"} + }, + "required": ["id", "slug", "name", "description", "kind", "hasPassword", "onlineCount"] + }, + "APIKey": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": {"type": "string", "description": "常驻房间的 API Key,只在生成或重置时返回这一次:bot 用 Authorization: Bearer 调用 /api/room 下的接口"} + }, + "required": ["key"] + }, + "StoredMedia": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": {"type": "string", "description": "曲目 ID 的 SHA-256(十六进制),用于删除;链接曲目的 ID 是规范化之后的链接"}, + "source": {"$ref": "#/$defs/Source"}, + "link": {"type": "string", "description": "这首歌的页面:链接曲目是链接本身,B 站的歌是视频页面"}, + "title": {"type": "string"}, + "size": {"type": "integer", "description": "音频文件的字节数"}, + "storedAtMs": {"type": "integer"}, + "lastPlayedAtMs": {"anyOf": [{"type": "integer"}, {"type": "null"}], "description": "没有播放过时为 null"} + }, + "required": ["key", "source", "link", "title", "size", "storedAtMs", "lastPlayedAtMs"] + }, + "SourceAccount": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string", "description": "音源平台的账号 ID,用字符串避免大整数精度丢失"}, + "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "账号昵称,未知时为 null"}, + "vip": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "description": "会员状态,未知时为 null"}, + "verified": {"type": "boolean", "description": "是否由上游实时查询确认;仅从本地凭据识别时为 false"} + }, + "required": ["id", "name", "vip", "verified"] + }, + "QQLoginType": { + "type": "string", + "description": "qq 是用 QQ 扫码,wx 是用微信扫码", + "enum": ["qq", "wx"] + }, + "QRLogin": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": {"type": "string", "description": "查询扫码结果时原样带上,只放在请求体中:有效期内它相当于这次登录的凭证"}, + "qrImage": {"type": "string", "description": "可以直接显示的二维码图片:data: URI 或 https 地址"}, + "expiresAtMs": {"type": "integer"} + }, + "required": ["key", "qrImage", "expiresAtMs"] + }, + "QRLoginStatus": { + "type": "string", + "description": "success 时服务端已经保存了登录状态", + "enum": ["waiting", "scanned", "success", "expired", "failed"] + }, + "QRLoginResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": {"$ref": "#/$defs/QRLoginStatus"}, + "message": {"type": "string", "description": "平台给出的说明,可能为空"} + }, + "required": ["status", "message"] + } + }, + "x-ws": { + "requests": [ + { + "type": "hello", + "description": "握手,必须是连接的第一帧", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "roomId": {"type": "string"}, + "ticket": {"type": "string"}, + "protocolVersion": {"type": "string"} + }, + "required": ["roomId", "ticket", "protocolVersion"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "member": {"$ref": "#/$defs/Member"}, + "serverTimeMs": {"type": "integer"}, + "room": {"$ref": "#/$defs/RoomSnapshot"} + }, + "required": ["member", "serverTimeMs", "room"] + } + }, + { + "type": "ping", + "description": "心跳,响应用来估计时钟偏移", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "clientTimeMs": {"type": "integer"} + }, + "required": ["clientTimeMs"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "clientTimeMs": {"type": "integer"}, + "serverTimeMs": {"type": "integer"} + }, + "required": ["clientTimeMs", "serverTimeMs"] + } + } + ], + "events": [ + {"type": "error", "description": "无法对应到请求的错误,只发给当前会话", "data": {"$ref": "#/$defs/ErrorInfo"}} + ], + "closeCodes": [ + {"name": "invalidArgument", "code": 1008, "description": "握手报文不合法"}, + {"name": "internal", "code": 1011, "description": "握手时发生内部错误"}, + {"name": "serviceRestart", "code": 1012, "description": "服务端实例退出,或实例与 Redis 的订阅连接中断"}, + {"name": "unavailable", "code": 1013, "description": "握手时服务暂时不可用"}, + {"name": "unauthorized", "code": 4401, "description": "票据无效、用 API Key 握手,或房间设置、修改了密码"}, + {"name": "notFound", "code": 4404, "description": "房间不存在,或房间已关闭、被删除"}, + {"name": "handshakeTimeout", "code": 4408, "description": "握手超时"}, + {"name": "clientOutdated", "code": 4426, "description": "前端与服务端的协议版本不一致"}, + {"name": "sendQueueFull", "code": 4429, "description": "发送队列溢出"} + ] + }, + "x-requests": [ + { + "type": "room.sync", + "description": "获取房间快照", + "http": {"method": "GET", "path": "/api/room"}, + "request": {"type": "object", "additionalProperties": false, "properties": {}}, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "room": {"$ref": "#/$defs/RoomSnapshot"} + }, + "required": ["room"] + } + }, + { + "type": "queue.add", + "description": "点歌:指定曲目,或者按关键词搜索并取第一条结果。来源为 link 的 B 站视频链接转成来源为 bilibili 的曲目", + "http": {"method": "POST", "path": "/api/room/queue", "status": 201, "location": true}, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "track": {"$ref": "#/$defs/Track"}, + "source": {"$ref": "#/$defs/Source"}, + "keyword": {"$ref": "#/$defs/Keyword"}, + "requester": {"$ref": "#/$defs/MemberName", "description": "点歌人的名字,只有用常驻房间的 API Key 调用时才能填写,条目的点歌人显示为“(bot)”"} + }, + "oneOf": [ + {"required": ["track"], "properties": {"track": {}, "requester": {}}, "additionalProperties": false}, + {"required": ["source", "keyword"], "properties": {"source": {}, "keyword": {}, "requester": {}}, "additionalProperties": false} + ] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "entry": {"$ref": "#/$defs/Entry"} + }, + "required": ["entry"] + } + }, + { + "type": "queue.remove", + "description": "删除等待队列中的一首", + "http": {"method": "DELETE", "path": "/api/room/queue/{entryId}", "status": 204}, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "entryId": {"type": "string"} + }, + "required": ["entryId"] + }, + "response": {"$ref": "#/$defs/Empty"} + }, + { + "type": "queue.like", + "description": "给等待队列中的一首点赞", + "http": {"method": "POST", "path": "/api/room/queue/{entryId}/likes"}, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "entryId": {"type": "string"} + }, + "required": ["entryId"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "likes": {"type": "integer"} + }, + "required": ["likes"] + } + }, + { + "type": "playback.voteSkip", + "description": "投票切掉正在播放的歌", + "http": {"method": "POST", "path": "/api/room/skip-votes"}, + "request": {"type": "object", "additionalProperties": false, "properties": {}}, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "votes": {"type": "integer"}, + "required": {"type": "integer"}, + "skipped": {"type": "boolean"} + }, + "required": ["votes", "required", "skipped"] + } + }, + { + "type": "settings.setPlayMode", + "description": "切换播放模式", + "http": {"method": "PATCH", "path": "/api/room/settings", "status": 204}, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "playMode": {"$ref": "#/$defs/PlayMode"} + }, + "required": ["playMode"] + }, + "response": {"$ref": "#/$defs/Empty"} + }, + { + "type": "chat.send", + "description": "发送聊天,超过 500 个字符的部分由服务端截断", + "http": {"method": "POST", "path": "/api/room/messages", "status": 204}, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": {"type": "string"} + }, + "required": ["content"] + }, + "response": {"$ref": "#/$defs/Empty"} + }, + { + "type": "search.tracks", + "description": "搜索歌曲", + "http": {"method": "GET", "path": "/api/room/search/tracks"}, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": {"$ref": "#/$defs/Source"}, + "keyword": {"$ref": "#/$defs/Keyword"}, + "page": {"$ref": "#/$defs/Page"}, + "pageSize": {"$ref": "#/$defs/PageSize"} + }, + "required": ["source", "keyword", "page", "pageSize"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": {"type": "array", "items": {"$ref": "#/$defs/TrackSummary"}}, + "total": {"type": "integer"} + }, + "required": ["items", "total"] + } + }, + { + "type": "search.playlists", + "description": "搜索歌单", + "http": {"method": "GET", "path": "/api/room/search/playlists"}, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": {"$ref": "#/$defs/Source"}, + "keyword": {"$ref": "#/$defs/Keyword"}, + "page": {"$ref": "#/$defs/Page"}, + "pageSize": {"$ref": "#/$defs/PageSize"} + }, + "required": ["source", "keyword", "page", "pageSize"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": {"type": "array", "items": {"$ref": "#/$defs/PlaylistSummary"}}, + "total": {"type": "integer"} + }, + "required": ["items", "total"] + } + }, + { + "type": "playlist.tracks", + "description": "歌单内的歌曲", + "http": {"method": "GET", "path": "/api/room/playlists/{source}/{playlistId}/tracks"}, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": {"$ref": "#/$defs/Source"}, + "playlistId": {"type": "string"} + }, + "required": ["source", "playlistId"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": {"type": "array", "items": {"$ref": "#/$defs/TrackSummary"}}, + "total": {"type": "integer"} + }, + "required": ["items", "total"] + } + }, + { + "type": "recommend.list", + "description": "推荐列表", + "http": {"method": "GET", "path": "/api/room/recommendations"}, + "request": {"type": "object", "additionalProperties": false, "properties": {}}, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": {"type": "array", "items": {"$ref": "#/$defs/TrackSummary"}} + }, + "required": ["items"] + } + } + ], + "x-events": [ + { + "type": "playback.updated", + "description": "没有正在播放的歌时 nowPlaying 为 null,包括待播仍在准备中的时候", + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "nowPlaying": {"anyOf": [{"$ref": "#/$defs/NowPlaying"}, {"type": "null"}]} + }, + "required": ["nowPlaying"] + } + }, + { + "type": "queue.updated", + "description": "待播和等待队列的完整快照", + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "pending": {"anyOf": [{"$ref": "#/$defs/Entry"}, {"type": "null"}]}, + "items": {"type": "array", "items": {"$ref": "#/$defs/Entry"}} + }, + "required": ["pending", "items"] + } + }, + { + "type": "members.updated", + "description": "在线列表", + "data": {"$ref": "#/$defs/Members"} + }, + { + "type": "settings.updated", + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "playMode": {"$ref": "#/$defs/PlayMode"} + }, + "required": ["playMode"] + } + }, + { + "type": "room.updated", + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"} + }, + "required": ["name", "description"] + } + }, + { + "type": "chat.message", + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "member": {"$ref": "#/$defs/Member"}, + "content": {"type": "string"}, + "sentAtMs": {"type": "integer"} + }, + "required": ["member", "content", "sentAtMs"] + } + }, + { + "type": "room.activity", + "description": "点歌、移除、点赞、投票、投票切歌成功和准备失败;不同 kind 只填写相关字段", + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": {"$ref": "#/$defs/ActivityKind"}, + "member": {"$ref": "#/$defs/Member"}, + "entry": {"$ref": "#/$defs/Entry"}, + "votes": {"type": "integer"}, + "required": {"type": "integer"}, + "message": {"type": "string", "description": "给人看的说明"} + }, + "required": ["kind", "message"] + } + } + ], + "x-http": [ + { + "name": "createMember", + "description": "获取成员身份", + "method": "POST", + "path": "/api/members", + "auth": "none", + "status": 201, + "request": {"type": "object", "additionalProperties": false, "properties": {}}, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "memberToken": {"type": "string"}, + "member": {"$ref": "#/$defs/Member"} + }, + "required": ["memberToken", "member"] + } + }, + { + "name": "getMe", + "description": "当前成员的资料", + "method": "GET", + "path": "/api/me", + "auth": "member", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "member": {"$ref": "#/$defs/Member"} + }, + "required": ["member"] + } + }, + { + "name": "updateMe", + "description": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像", + "method": "PATCH", + "path": "/api/me", + "auth": "member", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"$ref": "#/$defs/MemberName"}, + "email": {"type": "string", "maxLength": 320} + } + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "member": {"$ref": "#/$defs/Member"} + }, + "required": ["member"] + } + }, + { + "name": "listRooms", + "description": "房间列表", + "method": "GET", + "path": "/api/rooms", + "auth": "none", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": {"type": "array", "items": {"$ref": "#/$defs/RoomListing"}} + }, + "required": ["items"] + } + }, + { + "name": "getRoom", + "description": "按 UUID 或别名查找房间", + "method": "GET", + "path": "/api/rooms/{ref}", + "auth": "none", + "response": {"$ref": "#/$defs/RoomListing"} + }, + { + "name": "createRoom", + "description": "创建临时房间", + "method": "POST", + "path": "/api/rooms", + "auth": "none", + "status": 201, + "location": true, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"$ref": "#/$defs/RoomName"}, + "description": {"$ref": "#/$defs/RoomDescription"}, + "password": {"type": "string"} + }, + "required": ["name", "description"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"} + }, + "required": ["id"] + } + }, + { + "name": "createTicket", + "description": "换取进房票据;没有密码的房间可以不填 password", + "method": "POST", + "path": "/api/rooms/{id}/tickets", + "auth": "member", + "status": 201, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "password": {"type": "string"} + } + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "ticket": {"type": "string"} + }, + "required": ["ticket"] + } + }, + { + "name": "getSite", + "description": "品牌名;没有设置过时是 Alisten", + "method": "GET", + "path": "/api/site", + "auth": "none", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "brandName": {"type": "string"} + }, + "required": ["brandName"] + } + }, + { + "name": "adminListRooms", + "description": "全部常驻房间", + "method": "GET", + "path": "/api/admin/rooms", + "auth": "admin", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": {"type": "array", "items": {"$ref": "#/$defs/RoomListing"}} + }, + "required": ["items"] + } + }, + { + "name": "adminCreateRoom", + "description": "创建常驻房间", + "method": "POST", + "path": "/api/admin/rooms", + "auth": "admin", + "status": 201, + "location": true, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"$ref": "#/$defs/RoomName"}, + "description": {"$ref": "#/$defs/RoomDescription"}, + "slug": {"type": "string", "pattern": "^[a-z0-9-]{1,32}$"}, + "password": {"type": "string"} + }, + "required": ["name", "description"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"} + }, + "required": ["id"] + } + }, + { + "name": "adminUpdateRoom", + "description": "修改常驻房间;slug 或 password 为空字符串表示取消别名或密码", + "method": "PATCH", + "path": "/api/admin/rooms/{id}", + "auth": "admin", + "status": 204, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"$ref": "#/$defs/RoomName"}, + "description": {"$ref": "#/$defs/RoomDescription"}, + "slug": {"type": "string", "pattern": "^[a-z0-9-]{0,32}$"}, + "password": {"type": "string"} + } + } + }, + { + "name": "adminDeleteRoom", + "description": "删除常驻房间", + "method": "DELETE", + "path": "/api/admin/rooms/{id}", + "auth": "admin", + "status": 204 + }, + { + "name": "adminGetRoomApiKey", + "description": "常驻房间是否已经生成 API Key 及生成的时间,不返回 Key", + "method": "GET", + "path": "/api/admin/rooms/{id}/api-key", + "auth": "admin", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "set": {"type": "boolean", "description": "是否已经生成了 Key"}, + "createdAtMs": {"type": "integer", "description": "Key 的生成时间;还没有 Key 时为 0"} + }, + "required": ["set", "createdAtMs"] + } + }, + { + "name": "adminResetRoomApiKey", + "description": "生成或重置常驻房间的 API Key:旧 Key 立即失效,新 Key 只在这次响应中返回", + "method": "POST", + "path": "/api/admin/rooms/{id}/api-key", + "auth": "admin", + "status": 201, + "location": true, + "request": {"type": "object", "additionalProperties": false, "properties": {}}, + "response": {"$ref": "#/$defs/APIKey"} + }, + { + "name": "adminGetNeteaseCredential", + "description": "网易云登录凭据是否存在及账号资料,不返回 Cookie", + "method": "GET", + "path": "/api/admin/credentials/netease", + "auth": "admin", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "set": {"type": "boolean", "description": "是否保存了登录凭据,不代表凭据仍然有效"}, + "account": {"anyOf": [{"$ref": "#/$defs/SourceAccount"}, {"type": "null"}]} + }, + "required": ["set", "account"] + } + }, + { + "name": "adminGetQqCredential", + "description": "QQ 音乐的登录凭据是否存在及账号资料,不返回 Cookie", + "method": "GET", + "path": "/api/admin/credentials/qq", + "auth": "admin", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "set": {"type": "boolean", "description": "是否保存了登录凭据,不代表凭据仍然有效"}, + "account": {"anyOf": [{"$ref": "#/$defs/SourceAccount"}, {"type": "null"}]} + }, + "required": ["set", "account"] + } + }, + { + "name": "adminStartNeteaseLogin", + "description": "生成网易云音乐的登录二维码", + "method": "POST", + "path": "/api/admin/credentials/netease/qr-logins", + "auth": "admin", + "status": 201, + "request": {"type": "object", "additionalProperties": false, "properties": {}}, + "response": {"$ref": "#/$defs/QRLogin"} + }, + { + "name": "adminCheckNeteaseLogin", + "description": "查询网易云音乐扫码登录的结果", + "method": "POST", + "path": "/api/admin/credentials/netease/qr-logins/status", + "auth": "admin", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": {"type": "string", "minLength": 1} + }, + "required": ["key"] + }, + "response": {"$ref": "#/$defs/QRLoginResult"} + }, + { + "name": "adminStartQqLogin", + "description": "生成 QQ 音乐的登录二维码,用 QQ 或微信扫码", + "method": "POST", + "path": "/api/admin/credentials/qq/qr-logins", + "auth": "admin", + "status": 201, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": {"$ref": "#/$defs/QQLoginType"} + }, + "required": ["type"] + }, + "response": {"$ref": "#/$defs/QRLogin"} + }, + { + "name": "adminCheckQqLogin", + "description": "查询 QQ 音乐扫码登录的结果,type 与生成二维码时相同", + "method": "POST", + "path": "/api/admin/credentials/qq/qr-logins/status", + "auth": "admin", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": {"$ref": "#/$defs/QQLoginType"}, + "key": {"type": "string", "minLength": 1} + }, + "required": ["type", "key"] + }, + "response": {"$ref": "#/$defs/QRLoginResult"} + }, + { + "name": "adminGetBilibiliCredential", + "description": "B 站登录凭据是否存在及账号资料,不返回 Cookie", + "method": "GET", + "path": "/api/admin/credentials/bilibili", + "auth": "admin", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "set": {"type": "boolean", "description": "是否保存了登录凭据,不代表凭据仍然有效"}, + "account": {"anyOf": [{"$ref": "#/$defs/SourceAccount"}, {"type": "null"}]} + }, + "required": ["set", "account"] + } + }, + { + "name": "adminStartBilibiliLogin", + "description": "生成 B 站的登录二维码", + "method": "POST", + "path": "/api/admin/credentials/bilibili/qr-logins", + "auth": "admin", + "status": 201, + "request": {"type": "object", "additionalProperties": false, "properties": {}}, + "response": {"$ref": "#/$defs/QRLogin"} + }, + { + "name": "adminCheckBilibiliLogin", + "description": "查询 B 站扫码登录的结果", + "method": "POST", + "path": "/api/admin/credentials/bilibili/qr-logins/status", + "auth": "admin", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": {"type": "string", "minLength": 1} + }, + "required": ["key"] + }, + "response": {"$ref": "#/$defs/QRLoginResult"} + }, + { + "name": "adminGetYoutubeCredential", + "description": "YouTube cookies 是否已设置及最近一次填写的时间,不返回 cookies", + "method": "GET", + "path": "/api/admin/credentials/youtube", + "auth": "admin", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "set": {"type": "boolean", "description": "是否保存了 cookies"}, + "updatedAtMs": {"type": "integer", "description": "最近一次填写的时间;未设置时为 0"} + }, + "required": ["set", "updatedAtMs"] + } + }, + { + "name": "adminSetYoutubeCredential", + "description": "写入或覆盖 YouTube cookies,不返回 cookies", + "method": "PUT", + "path": "/api/admin/credentials/youtube", + "auth": "admin", + "status": 204, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "cookies": {"type": "string", "minLength": 1} + }, + "required": ["cookies"] + } + }, + { + "name": "adminDeleteYoutubeCredential", + "description": "清除 YouTube cookies", + "method": "DELETE", + "path": "/api/admin/credentials/youtube", + "auth": "admin", + "status": 204 + }, + { + "name": "adminListMedia", + "description": "已存储的链接曲目和 B 站的歌", + "method": "GET", + "path": "/api/admin/media", + "auth": "admin", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": {"type": "array", "items": {"$ref": "#/$defs/StoredMedia"}} + }, + "required": ["items"] + } + }, + { + "name": "adminDeleteMedia", + "description": "删除一首已存储的歌的音频和封面,并清除它的准备结果", + "method": "DELETE", + "path": "/api/admin/media/{key}", + "auth": "admin", + "status": 204 + }, + { + "name": "adminUpdateSite", + "description": "修改品牌名,返回存下的名字:去掉首尾空白后保存,应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符;设成 Alisten 就恢复了默认", + "method": "PATCH", + "path": "/api/admin/site", + "auth": "admin", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "brandName": {"type": "string"} + }, + "required": ["brandName"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "brandName": {"type": "string"} + }, + "required": ["brandName"] + } + } + ] +} diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json new file mode 100644 index 0000000..ff8d3ca --- /dev/null +++ b/protocol/openapi.gen.json @@ -0,0 +1,2998 @@ +{ + "components": { + "schemas": { + "APIKey": { + "additionalProperties": false, + "properties": { + "key": { + "description": "常驻房间的 API Key,只在生成或重置时返回这一次:bot 用 Authorization: Bearer 调用 /api/room 下的接口", + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "ActivityKind": { + "enum": [ + "added", + "removed", + "liked", + "voted", + "skipped", + "failed" + ], + "type": "string" + }, + "AdminCheckBilibiliLoginRequest": { + "additionalProperties": false, + "properties": { + "key": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "AdminCheckNeteaseLoginRequest": { + "additionalProperties": false, + "properties": { + "key": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "AdminCheckQQLoginRequest": { + "additionalProperties": false, + "properties": { + "key": { + "minLength": 1, + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/QQLoginType" + } + }, + "required": [ + "type", + "key" + ], + "type": "object" + }, + "AdminCreateRoomRequest": { + "additionalProperties": false, + "properties": { + "description": { + "description": "房间描述", + "maxLength": 500, + "type": "string" + }, + "name": { + "description": "房间名称", + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "password": { + "type": "string" + }, + "slug": { + "pattern": "^[a-z0-9-]{1,32}$", + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "type": "object" + }, + "AdminCreateRoomResponse": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "AdminGetBilibiliCredentialResponse": { + "additionalProperties": false, + "properties": { + "account": { + "anyOf": [ + { + "$ref": "#/components/schemas/SourceAccount" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "是否保存了登录凭据,不代表凭据仍然有效", + "type": "boolean" + } + }, + "required": [ + "set", + "account" + ], + "type": "object" + }, + "AdminGetNeteaseCredentialResponse": { + "additionalProperties": false, + "properties": { + "account": { + "anyOf": [ + { + "$ref": "#/components/schemas/SourceAccount" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "是否保存了登录凭据,不代表凭据仍然有效", + "type": "boolean" + } + }, + "required": [ + "set", + "account" + ], + "type": "object" + }, + "AdminGetQQCredentialResponse": { + "additionalProperties": false, + "properties": { + "account": { + "anyOf": [ + { + "$ref": "#/components/schemas/SourceAccount" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "是否保存了登录凭据,不代表凭据仍然有效", + "type": "boolean" + } + }, + "required": [ + "set", + "account" + ], + "type": "object" + }, + "AdminGetRoomApiKeyResponse": { + "additionalProperties": false, + "properties": { + "createdAtMs": { + "description": "Key 的生成时间;还没有 Key 时为 0", + "type": "integer" + }, + "set": { + "description": "是否已经生成了 Key", + "type": "boolean" + } + }, + "required": [ + "set", + "createdAtMs" + ], + "type": "object" + }, + "AdminGetYoutubeCredentialResponse": { + "additionalProperties": false, + "properties": { + "set": { + "description": "是否保存了 cookies", + "type": "boolean" + }, + "updatedAtMs": { + "description": "最近一次填写的时间;未设置时为 0", + "type": "integer" + } + }, + "required": [ + "set", + "updatedAtMs" + ], + "type": "object" + }, + "AdminListMediaResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/StoredMedia" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AdminListRoomsResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/RoomListing" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AdminResetRoomApiKeyRequest": { + "additionalProperties": false, + "type": "object" + }, + "AdminSetYoutubeCredentialRequest": { + "additionalProperties": false, + "properties": { + "cookies": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "cookies" + ], + "type": "object" + }, + "AdminStartBilibiliLoginRequest": { + "additionalProperties": false, + "type": "object" + }, + "AdminStartNeteaseLoginRequest": { + "additionalProperties": false, + "type": "object" + }, + "AdminStartQQLoginRequest": { + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/components/schemas/QQLoginType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "AdminUpdateRoomRequest": { + "additionalProperties": false, + "properties": { + "description": { + "description": "房间描述", + "maxLength": 500, + "type": "string" + }, + "name": { + "description": "房间名称", + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "password": { + "type": "string" + }, + "slug": { + "pattern": "^[a-z0-9-]{0,32}$", + "type": "string" + } + }, + "type": "object" + }, + "AdminUpdateSiteRequest": { + "additionalProperties": false, + "properties": { + "brandName": { + "type": "string" + } + }, + "required": [ + "brandName" + ], + "type": "object" + }, + "AdminUpdateSiteResponse": { + "additionalProperties": false, + "properties": { + "brandName": { + "type": "string" + } + }, + "required": [ + "brandName" + ], + "type": "object" + }, + "ChatSendRequest": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "CreateMemberRequest": { + "additionalProperties": false, + "type": "object" + }, + "CreateMemberResponse": { + "additionalProperties": false, + "properties": { + "member": { + "$ref": "#/components/schemas/Member" + }, + "memberToken": { + "type": "string" + } + }, + "required": [ + "memberToken", + "member" + ], + "type": "object" + }, + "CreateRoomRequest": { + "additionalProperties": false, + "properties": { + "description": { + "description": "房间描述", + "maxLength": 500, + "type": "string" + }, + "name": { + "description": "房间名称", + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "type": "object" + }, + "CreateRoomResponse": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "CreateTicketRequest": { + "additionalProperties": false, + "properties": { + "password": { + "type": "string" + } + }, + "type": "object" + }, + "CreateTicketResponse": { + "additionalProperties": false, + "properties": { + "ticket": { + "type": "string" + } + }, + "required": [ + "ticket" + ], + "type": "object" + }, + "Empty": { + "additionalProperties": false, + "type": "object" + }, + "Entry": { + "additionalProperties": false, + "properties": { + "auto": { + "type": "boolean" + }, + "by": { + "anyOf": [ + { + "$ref": "#/components/schemas/Member" + }, + { + "type": "null" + } + ], + "description": "AutoDJ 加入的歌为 null" + }, + "id": { + "type": "string" + }, + "likes": { + "type": "integer" + }, + "meta": { + "$ref": "#/components/schemas/TrackMeta" + }, + "status": { + "$ref": "#/components/schemas/EntryStatus" + }, + "track": { + "$ref": "#/components/schemas/Track" + } + }, + "required": [ + "id", + "track", + "meta", + "by", + "auto", + "likes", + "status" + ], + "type": "object" + }, + "EntryStatus": { + "enum": [ + "preparing", + "ready" + ], + "type": "string" + }, + "ErrorCode": { + "enum": [ + "INVALID_ARGUMENT", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "QUEUE_FULL", + "NOTHING_PLAYING", + "RATE_LIMITED", + "PROVIDER_ERROR", + "UNAVAILABLE", + "CLIENT_OUTDATED", + "INTERNAL" + ], + "type": "string", + "x-httpStatus": { + "CONFLICT": 409, + "FORBIDDEN": 403, + "INTERNAL": 500, + "INVALID_ARGUMENT": 400, + "NOTHING_PLAYING": 409, + "NOT_FOUND": 404, + "PROVIDER_ERROR": 502, + "QUEUE_FULL": 409, + "RATE_LIMITED": 429, + "UNAUTHORIZED": 401, + "UNAVAILABLE": 503 + } + }, + "ErrorInfo": { + "additionalProperties": false, + "properties": { + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "message": { + "description": "给人看的中文说明,客户端不得据此判断业务分支", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "ErrorResponse": { + "additionalProperties": false, + "properties": { + "error": { + "$ref": "#/components/schemas/ErrorInfo" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + "GetMeResponse": { + "additionalProperties": false, + "properties": { + "member": { + "$ref": "#/components/schemas/Member" + } + }, + "required": [ + "member" + ], + "type": "object" + }, + "GetSiteResponse": { + "additionalProperties": false, + "properties": { + "brandName": { + "type": "string" + } + }, + "required": [ + "brandName" + ], + "type": "object" + }, + "ListRoomsResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/RoomListing" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "Member": { + "additionalProperties": false, + "properties": { + "avatarHash": { + "description": "邮箱的 MD5,未设置邮箱时为空字符串", + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "avatarHash" + ], + "type": "object" + }, + "Members": { + "additionalProperties": false, + "properties": { + "onlineCount": { + "type": "integer" + }, + "sessions": { + "description": "每个会话一项", + "items": { + "$ref": "#/components/schemas/Member" + }, + "type": "array" + } + }, + "required": [ + "sessions", + "onlineCount" + ], + "type": "object" + }, + "NowPlaying": { + "additionalProperties": false, + "properties": { + "durationMs": { + "description": "准备结果中的时长", + "type": "integer" + }, + "entry": { + "$ref": "#/components/schemas/Entry" + }, + "gain": { + "description": "线性增益系数", + "type": "number" + }, + "lyric": { + "type": "string" + }, + "mediaUrl": { + "type": "string" + }, + "startedAtMs": { + "type": "integer" + } + }, + "required": [ + "entry", + "mediaUrl", + "startedAtMs", + "durationMs", + "gain", + "lyric" + ], + "type": "object" + }, + "PlayMode": { + "enum": [ + "sequential", + "random", + "rotation" + ], + "type": "string" + }, + "PlaybackVoteSkipRequest": { + "additionalProperties": false, + "type": "object" + }, + "PlaybackVoteSkipResponse": { + "additionalProperties": false, + "properties": { + "required": { + "type": "integer" + }, + "skipped": { + "type": "boolean" + }, + "votes": { + "type": "integer" + } + }, + "required": [ + "votes", + "required", + "skipped" + ], + "type": "object" + }, + "PlaylistSummary": { + "additionalProperties": false, + "properties": { + "coverUrl": { + "type": "string" + }, + "creator": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "playCount": { + "type": "integer" + }, + "source": { + "$ref": "#/components/schemas/Source" + }, + "trackCount": { + "type": "integer" + } + }, + "required": [ + "source", + "id", + "name", + "coverUrl", + "creator", + "description", + "trackCount", + "playCount" + ], + "type": "object" + }, + "PlaylistTracksResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TrackSummary" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "type": "object" + }, + "QQLoginType": { + "description": "qq 是用 QQ 扫码,wx 是用微信扫码", + "enum": [ + "qq", + "wx" + ], + "type": "string" + }, + "QRLogin": { + "additionalProperties": false, + "properties": { + "expiresAtMs": { + "type": "integer" + }, + "key": { + "description": "查询扫码结果时原样带上,只放在请求体中:有效期内它相当于这次登录的凭证", + "type": "string" + }, + "qrImage": { + "description": "可以直接显示的二维码图片:data: URI 或 https 地址", + "type": "string" + } + }, + "required": [ + "key", + "qrImage", + "expiresAtMs" + ], + "type": "object" + }, + "QRLoginResult": { + "additionalProperties": false, + "properties": { + "message": { + "description": "平台给出的说明,可能为空", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/QRLoginStatus" + } + }, + "required": [ + "status", + "message" + ], + "type": "object" + }, + "QRLoginStatus": { + "description": "success 时服务端已经保存了登录状态", + "enum": [ + "waiting", + "scanned", + "success", + "expired", + "failed" + ], + "type": "string" + }, + "QueueAddRequest": { + "additionalProperties": false, + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "requester": {}, + "track": {} + }, + "required": [ + "track" + ] + }, + { + "additionalProperties": false, + "properties": { + "keyword": {}, + "requester": {}, + "source": {} + }, + "required": [ + "source", + "keyword" + ] + } + ], + "properties": { + "keyword": { + "description": "搜索和按关键词点歌的关键词", + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "requester": { + "description": "点歌人的名字,只有用常驻房间的 API Key 调用时才能填写,条目的点歌人显示为“(bot)”", + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Source" + }, + "track": { + "$ref": "#/components/schemas/Track" + } + }, + "type": "object" + }, + "QueueAddResponse": { + "additionalProperties": false, + "properties": { + "entry": { + "$ref": "#/components/schemas/Entry" + } + }, + "required": [ + "entry" + ], + "type": "object" + }, + "QueueLikeResponse": { + "additionalProperties": false, + "properties": { + "likes": { + "type": "integer" + } + }, + "required": [ + "likes" + ], + "type": "object" + }, + "RecommendListResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TrackSummary" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "RoomInfo": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/RoomKind" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "description", + "kind" + ], + "type": "object" + }, + "RoomKind": { + "enum": [ + "persistent", + "ephemeral" + ], + "type": "string" + }, + "RoomListing": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/RoomKind" + }, + "name": { + "type": "string" + }, + "onlineCount": { + "type": "integer" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "没有别名时为 null" + } + }, + "required": [ + "id", + "slug", + "name", + "description", + "kind", + "hasPassword", + "onlineCount" + ], + "type": "object" + }, + "RoomSnapshot": { + "additionalProperties": false, + "properties": { + "items": { + "description": "等待队列", + "items": { + "$ref": "#/components/schemas/Entry" + }, + "type": "array" + }, + "members": { + "$ref": "#/components/schemas/Members" + }, + "nowPlaying": { + "anyOf": [ + { + "$ref": "#/components/schemas/NowPlaying" + }, + { + "type": "null" + } + ] + }, + "pending": { + "anyOf": [ + { + "$ref": "#/components/schemas/Entry" + }, + { + "type": "null" + } + ] + }, + "playMode": { + "$ref": "#/components/schemas/PlayMode" + }, + "room": { + "$ref": "#/components/schemas/RoomInfo" + }, + "seq": { + "type": "integer" + }, + "term": { + "type": "integer" + } + }, + "required": [ + "term", + "seq", + "room", + "playMode", + "nowPlaying", + "pending", + "items", + "members" + ], + "type": "object" + }, + "RoomSyncResponse": { + "additionalProperties": false, + "properties": { + "room": { + "$ref": "#/components/schemas/RoomSnapshot" + } + }, + "required": [ + "room" + ], + "type": "object" + }, + "SearchPlaylistsResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PlaylistSummary" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "type": "object" + }, + "SearchTracksResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TrackSummary" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "type": "object" + }, + "SettingsSetPlayModeRequest": { + "additionalProperties": false, + "properties": { + "playMode": { + "$ref": "#/components/schemas/PlayMode" + } + }, + "required": [ + "playMode" + ], + "type": "object" + }, + "Source": { + "enum": [ + "netease", + "qq", + "bilibili", + "link" + ], + "type": "string" + }, + "SourceAccount": { + "additionalProperties": false, + "properties": { + "id": { + "description": "音源平台的账号 ID,用字符串避免大整数精度丢失", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "账号昵称,未知时为 null" + }, + "verified": { + "description": "是否由上游实时查询确认;仅从本地凭据识别时为 false", + "type": "boolean" + }, + "vip": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "会员状态,未知时为 null" + } + }, + "required": [ + "id", + "name", + "vip", + "verified" + ], + "type": "object" + }, + "StoredMedia": { + "additionalProperties": false, + "properties": { + "key": { + "description": "曲目 ID 的 SHA-256(十六进制),用于删除;链接曲目的 ID 是规范化之后的链接", + "type": "string" + }, + "lastPlayedAtMs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "没有播放过时为 null" + }, + "link": { + "description": "这首歌的页面:链接曲目是链接本身,B 站的歌是视频页面", + "type": "string" + }, + "size": { + "description": "音频文件的字节数", + "type": "integer" + }, + "source": { + "$ref": "#/components/schemas/Source" + }, + "storedAtMs": { + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "key", + "source", + "link", + "title", + "size", + "storedAtMs", + "lastPlayedAtMs" + ], + "type": "object" + }, + "Track": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "source": { + "const": "link" + } + }, + "required": [ + "source" + ] + }, + "then": { + "properties": { + "id": { + "description": "http 或 https 的绝对地址,scheme 不区分大小写,按 UTF-8 编码不超过 2048 字节", + "format": "iri", + "type": "string", + "x-maxBytes": 2048, + "x-url": true + } + } + } + }, + { + "if": { + "properties": { + "source": { + "const": "bilibili" + } + }, + "required": [ + "source" + ] + }, + "then": { + "properties": { + "id": { + "pattern": "^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$" + } + } + } + } + ], + "properties": { + "id": { + "description": "来源为 link 时是链接,响应和事件中是规范化之后的链接(scheme 和主机名转成小写);来源为 bilibili 时是 BV 号,或从第 2 P 起的 BV 号/分P序号", + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Source" + } + }, + "required": [ + "source", + "id" + ], + "type": "object" + }, + "TrackMeta": { + "additionalProperties": false, + "properties": { + "album": { + "type": "string" + }, + "artist": { + "type": "string" + }, + "coverUrl": { + "type": "string" + }, + "durationMs": { + "description": "未知时为 0", + "type": "integer" + }, + "pageUrl": { + "description": "这首歌在音源网站上的页面,来源为 link 时是规范化之后的链接(scheme 和主机名转成小写),即曲目 ID", + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "title", + "artist", + "album", + "coverUrl", + "pageUrl", + "durationMs" + ], + "type": "object" + }, + "TrackSummary": { + "additionalProperties": false, + "properties": { + "meta": { + "$ref": "#/components/schemas/TrackMeta" + }, + "track": { + "$ref": "#/components/schemas/Track" + } + }, + "required": [ + "track", + "meta" + ], + "type": "object" + }, + "UpdateMeRequest": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 320, + "type": "string" + }, + "name": { + "description": "昵称", + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "UpdateMeResponse": { + "additionalProperties": false, + "properties": { + "member": { + "$ref": "#/components/schemas/Member" + } + }, + "required": [ + "member" + ], + "type": "object" + } + }, + "securitySchemes": { + "adminToken": { + "scheme": "bearer", + "type": "http" + }, + "memberToken": { + "scheme": "bearer", + "type": "http" + }, + "ticket": { + "description": "进房票据或常驻房间的 API Key", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "title": "Alisten", + "version": "09dbf7ea495db969" + }, + "openapi": "3.1.0", + "paths": { + "/api/admin/credentials/bilibili": { + "get": { + "operationId": "adminGetBilibiliCredential", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminGetBilibiliCredentialResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "B 站登录凭据是否存在及账号资料,不返回 Cookie" + } + }, + "/api/admin/credentials/bilibili/qr-logins": { + "post": { + "operationId": "adminStartBilibiliLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminStartBilibiliLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QRLogin" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "生成 B 站的登录二维码" + } + }, + "/api/admin/credentials/bilibili/qr-logins/status": { + "post": { + "operationId": "adminCheckBilibiliLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCheckBilibiliLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QRLoginResult" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "查询 B 站扫码登录的结果" + } + }, + "/api/admin/credentials/netease": { + "get": { + "operationId": "adminGetNeteaseCredential", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminGetNeteaseCredentialResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "网易云登录凭据是否存在及账号资料,不返回 Cookie" + } + }, + "/api/admin/credentials/netease/qr-logins": { + "post": { + "operationId": "adminStartNeteaseLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminStartNeteaseLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QRLogin" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "生成网易云音乐的登录二维码" + } + }, + "/api/admin/credentials/netease/qr-logins/status": { + "post": { + "operationId": "adminCheckNeteaseLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCheckNeteaseLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QRLoginResult" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "查询网易云音乐扫码登录的结果" + } + }, + "/api/admin/credentials/qq": { + "get": { + "operationId": "adminGetQqCredential", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminGetQQCredentialResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "QQ 音乐的登录凭据是否存在及账号资料,不返回 Cookie" + } + }, + "/api/admin/credentials/qq/qr-logins": { + "post": { + "operationId": "adminStartQqLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminStartQQLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QRLogin" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "生成 QQ 音乐的登录二维码,用 QQ 或微信扫码" + } + }, + "/api/admin/credentials/qq/qr-logins/status": { + "post": { + "operationId": "adminCheckQqLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCheckQQLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QRLoginResult" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "查询 QQ 音乐扫码登录的结果,type 与生成二维码时相同" + } + }, + "/api/admin/credentials/youtube": { + "delete": { + "operationId": "adminDeleteYoutubeCredential", + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "清除 YouTube cookies" + }, + "get": { + "operationId": "adminGetYoutubeCredential", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminGetYoutubeCredentialResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "YouTube cookies 是否已设置及最近一次填写的时间,不返回 cookies" + }, + "put": { + "operationId": "adminSetYoutubeCredential", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminSetYoutubeCredentialRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "写入或覆盖 YouTube cookies,不返回 cookies" + } + }, + "/api/admin/media": { + "get": { + "operationId": "adminListMedia", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminListMediaResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "已存储的链接曲目和 B 站的歌" + } + }, + "/api/admin/media/{key}": { + "delete": { + "operationId": "adminDeleteMedia", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "删除一首已存储的歌的音频和封面,并清除它的准备结果" + } + }, + "/api/admin/rooms": { + "get": { + "operationId": "adminListRooms", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminListRoomsResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "全部常驻房间" + }, + "post": { + "operationId": "adminCreateRoom", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCreateRoomRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCreateRoomResponse" + } + } + }, + "description": "成功", + "headers": { + "Location": { + "description": "新资源的地址", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "创建常驻房间" + } + }, + "/api/admin/rooms/{id}": { + "delete": { + "operationId": "adminDeleteRoom", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "删除常驻房间" + }, + "patch": { + "operationId": "adminUpdateRoom", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminUpdateRoomRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "修改常驻房间;slug 或 password 为空字符串表示取消别名或密码" + } + }, + "/api/admin/rooms/{id}/api-key": { + "get": { + "operationId": "adminGetRoomApiKey", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminGetRoomApiKeyResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "常驻房间是否已经生成 API Key 及生成的时间,不返回 Key" + }, + "post": { + "operationId": "adminResetRoomApiKey", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminResetRoomApiKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKey" + } + } + }, + "description": "成功", + "headers": { + "Location": { + "description": "新资源的地址", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "生成或重置常驻房间的 API Key:旧 Key 立即失效,新 Key 只在这次响应中返回" + } + }, + "/api/admin/site": { + "patch": { + "operationId": "adminUpdateSite", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminUpdateSiteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminUpdateSiteResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "修改品牌名,返回存下的名字:去掉首尾空白后保存,应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符;设成 Alisten 就恢复了默认" + } + }, + "/api/me": { + "get": { + "operationId": "getMe", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMeResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "当前成员的资料" + }, + "patch": { + "operationId": "updateMe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMeResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像" + } + }, + "/api/members": { + "post": { + "operationId": "createMember", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMemberRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMemberResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [], + "summary": "获取成员身份" + } + }, + "/api/room": { + "get": { + "operationId": "roomSync", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomSyncResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "获取房间快照" + } + }, + "/api/room/messages": { + "post": { + "operationId": "chatSend", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatSendRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "发送聊天,超过 500 个字符的部分由服务端截断" + } + }, + "/api/room/playlists/{source}/{playlistId}/tracks": { + "get": { + "operationId": "playlistTracks", + "parameters": [ + { + "in": "path", + "name": "source", + "required": true, + "schema": { + "$ref": "#/components/schemas/Source" + } + }, + { + "in": "path", + "name": "playlistId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistTracksResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "歌单内的歌曲" + } + }, + "/api/room/queue": { + "post": { + "operationId": "queueAdd", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueAddRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueAddResponse" + } + } + }, + "description": "成功", + "headers": { + "Location": { + "description": "新资源的地址", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "点歌:指定曲目,或者按关键词搜索并取第一条结果。来源为 link 的 B 站视频链接转成来源为 bilibili 的曲目" + } + }, + "/api/room/queue/{entryId}": { + "delete": { + "operationId": "queueRemove", + "parameters": [ + { + "in": "path", + "name": "entryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "删除等待队列中的一首" + } + }, + "/api/room/queue/{entryId}/likes": { + "post": { + "operationId": "queueLike", + "parameters": [ + { + "in": "path", + "name": "entryId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueLikeResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "给等待队列中的一首点赞" + } + }, + "/api/room/recommendations": { + "get": { + "operationId": "recommendList", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendListResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "推荐列表" + } + }, + "/api/room/search/playlists": { + "get": { + "operationId": "searchPlaylists", + "parameters": [ + { + "in": "query", + "name": "keyword", + "required": true, + "schema": { + "description": "搜索和按关键词点歌的关键词", + "maxLength": 500, + "minLength": 1, + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "required": true, + "schema": { + "description": "页码,从 1 开始", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "pageSize", + "required": true, + "schema": { + "description": "每页的条数", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "source", + "required": true, + "schema": { + "$ref": "#/components/schemas/Source" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchPlaylistsResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "搜索歌单" + } + }, + "/api/room/search/tracks": { + "get": { + "operationId": "searchTracks", + "parameters": [ + { + "in": "query", + "name": "keyword", + "required": true, + "schema": { + "description": "搜索和按关键词点歌的关键词", + "maxLength": 500, + "minLength": 1, + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "required": true, + "schema": { + "description": "页码,从 1 开始", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "pageSize", + "required": true, + "schema": { + "description": "每页的条数", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "source", + "required": true, + "schema": { + "$ref": "#/components/schemas/Source" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchTracksResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "搜索歌曲" + } + }, + "/api/room/settings": { + "patch": { + "operationId": "settingsSetPlayMode", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettingsSetPlayModeRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "切换播放模式" + } + }, + "/api/room/skip-votes": { + "post": { + "operationId": "playbackVoteSkip", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaybackVoteSkipRequest" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaybackVoteSkipResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "ticket": [] + } + ], + "summary": "投票切掉正在播放的歌" + } + }, + "/api/rooms": { + "get": { + "operationId": "listRooms", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRoomsResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [], + "summary": "房间列表" + }, + "post": { + "operationId": "createRoom", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRoomRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRoomResponse" + } + } + }, + "description": "成功", + "headers": { + "Location": { + "description": "新资源的地址", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [], + "summary": "创建临时房间" + } + }, + "/api/rooms/{id}/tickets": { + "post": { + "operationId": "createTicket", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTicketResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "换取进房票据;没有密码的房间可以不填 password" + } + }, + "/api/rooms/{ref}": { + "get": { + "operationId": "getRoom", + "parameters": [ + { + "in": "path", + "name": "ref", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomListing" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [], + "summary": "按 UUID 或别名查找房间" + } + }, + "/api/site": { + "get": { + "operationId": "getSite", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSiteResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [], + "summary": "品牌名;没有设置过时是 Alisten" + } + } + } +} diff --git a/protocol/types.gen.go b/protocol/types.gen.go new file mode 100644 index 0000000..401cc5a --- /dev/null +++ b/protocol/types.gen.go @@ -0,0 +1,669 @@ +// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. + +package protocol + +import "encoding/json" + +const Version = "09dbf7ea495db969" + +type APIKey struct { + Key string `json:"key"` +} + +type ActivityKind string + +const ( + ActivityKindAdded ActivityKind = "added" + ActivityKindRemoved ActivityKind = "removed" + ActivityKindLiked ActivityKind = "liked" + ActivityKindVoted ActivityKind = "voted" + ActivityKindSkipped ActivityKind = "skipped" + ActivityKindFailed ActivityKind = "failed" +) + +type AdminCheckBilibiliLoginRequest struct { + Key string `json:"key"` +} + +type AdminCheckNeteaseLoginRequest struct { + Key string `json:"key"` +} + +type AdminCheckQQLoginRequest struct { + Key string `json:"key"` + Type QQLoginType `json:"type"` +} + +type AdminCreateRoomRequest struct { + Description string `json:"description"` + Name string `json:"name"` + Password *string `json:"password,omitempty"` + Slug *string `json:"slug,omitempty"` +} + +type AdminCreateRoomResponse struct { + ID string `json:"id"` +} + +type AdminGetBilibiliCredentialResponse struct { + Account *SourceAccount `json:"account"` + Set bool `json:"set"` +} + +type AdminGetNeteaseCredentialResponse struct { + Account *SourceAccount `json:"account"` + Set bool `json:"set"` +} + +type AdminGetQQCredentialResponse struct { + Account *SourceAccount `json:"account"` + Set bool `json:"set"` +} + +type AdminGetRoomApiKeyResponse struct { + CreatedAtMs int64 `json:"createdAtMs"` + Set bool `json:"set"` +} + +type AdminGetYoutubeCredentialResponse struct { + Set bool `json:"set"` + UpdatedAtMs int64 `json:"updatedAtMs"` +} + +type AdminListMediaResponse struct { + Items []StoredMedia `json:"items"` +} + +func (v AdminListMediaResponse) MarshalJSON() ([]byte, error) { + type plain AdminListMediaResponse + if v.Items == nil { + v.Items = []StoredMedia{} + } + return json.Marshal(plain(v)) +} + +type AdminListRoomsResponse struct { + Items []RoomListing `json:"items"` +} + +func (v AdminListRoomsResponse) MarshalJSON() ([]byte, error) { + type plain AdminListRoomsResponse + if v.Items == nil { + v.Items = []RoomListing{} + } + return json.Marshal(plain(v)) +} + +type AdminResetRoomApiKeyRequest struct{} + +type AdminSetYoutubeCredentialRequest struct { + Cookies string `json:"cookies"` +} + +type AdminStartBilibiliLoginRequest struct{} + +type AdminStartNeteaseLoginRequest struct{} + +type AdminStartQQLoginRequest struct { + Type QQLoginType `json:"type"` +} + +type AdminUpdateRoomRequest struct { + Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + Password *string `json:"password,omitempty"` + Slug *string `json:"slug,omitempty"` +} + +type AdminUpdateSiteRequest struct { + BrandName string `json:"brandName"` +} + +type AdminUpdateSiteResponse struct { + BrandName string `json:"brandName"` +} + +type ChatMessageEvent struct { + Content string `json:"content"` + Member Member `json:"member"` + SentAtMs int64 `json:"sentAtMs"` +} + +type ChatSendRequest struct { + Content string `json:"content"` +} + +type CreateMemberRequest struct{} + +type CreateMemberResponse struct { + Member Member `json:"member"` + MemberToken string `json:"memberToken"` +} + +type CreateRoomRequest struct { + Description string `json:"description"` + Name string `json:"name"` + Password *string `json:"password,omitempty"` +} + +type CreateRoomResponse struct { + ID string `json:"id"` +} + +type CreateTicketRequest struct { + Password *string `json:"password,omitempty"` +} + +type CreateTicketResponse struct { + Ticket string `json:"ticket"` +} + +type Empty struct{} + +type Entry struct { + Auto bool `json:"auto"` + By *Member `json:"by"` + ID string `json:"id"` + Likes int64 `json:"likes"` + Meta TrackMeta `json:"meta"` + Status EntryStatus `json:"status"` + Track Track `json:"track"` +} + +type EntryStatus string + +const ( + EntryStatusPreparing EntryStatus = "preparing" + EntryStatusReady EntryStatus = "ready" +) + +type ErrorCode string + +const ( + ErrorCodeInvalidArgument ErrorCode = "INVALID_ARGUMENT" + ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED" + ErrorCodeForbidden ErrorCode = "FORBIDDEN" + ErrorCodeNotFound ErrorCode = "NOT_FOUND" + ErrorCodeConflict ErrorCode = "CONFLICT" + ErrorCodeQueueFull ErrorCode = "QUEUE_FULL" + ErrorCodeNothingPlaying ErrorCode = "NOTHING_PLAYING" + ErrorCodeRateLimited ErrorCode = "RATE_LIMITED" + ErrorCodeProviderError ErrorCode = "PROVIDER_ERROR" + ErrorCodeUnavailable ErrorCode = "UNAVAILABLE" + ErrorCodeClientOutdated ErrorCode = "CLIENT_OUTDATED" + ErrorCodeInternal ErrorCode = "INTERNAL" +) + +type ErrorInfo struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` +} + +type GetMeResponse struct { + Member Member `json:"member"` +} + +type GetSiteResponse struct { + BrandName string `json:"brandName"` +} + +type HelloRequest struct { + ProtocolVersion string `json:"protocolVersion"` + RoomID string `json:"roomId"` + Ticket string `json:"ticket"` +} + +type HelloResponse struct { + Member Member `json:"member"` + Room RoomSnapshot `json:"room"` + ServerTimeMs int64 `json:"serverTimeMs"` +} + +type ListRoomsResponse struct { + Items []RoomListing `json:"items"` +} + +func (v ListRoomsResponse) MarshalJSON() ([]byte, error) { + type plain ListRoomsResponse + if v.Items == nil { + v.Items = []RoomListing{} + } + return json.Marshal(plain(v)) +} + +type Member struct { + AvatarHash string `json:"avatarHash"` + ID string `json:"id"` + Name string `json:"name"` +} + +type Members struct { + OnlineCount int64 `json:"onlineCount"` + Sessions []Member `json:"sessions"` +} + +func (v Members) MarshalJSON() ([]byte, error) { + type plain Members + if v.Sessions == nil { + v.Sessions = []Member{} + } + return json.Marshal(plain(v)) +} + +type NowPlaying struct { + DurationMs int64 `json:"durationMs"` + Entry Entry `json:"entry"` + Gain float64 `json:"gain"` + Lyric string `json:"lyric"` + MediaURL string `json:"mediaUrl"` + StartedAtMs int64 `json:"startedAtMs"` +} + +type PingRequest struct { + ClientTimeMs int64 `json:"clientTimeMs"` +} + +type PingResponse struct { + ClientTimeMs int64 `json:"clientTimeMs"` + ServerTimeMs int64 `json:"serverTimeMs"` +} + +type PlayMode string + +const ( + PlayModeSequential PlayMode = "sequential" + PlayModeRandom PlayMode = "random" + PlayModeRotation PlayMode = "rotation" +) + +type PlaybackUpdatedEvent struct { + NowPlaying *NowPlaying `json:"nowPlaying"` +} + +type PlaybackVoteSkipRequest struct{} + +type PlaybackVoteSkipResponse struct { + Required int64 `json:"required"` + Skipped bool `json:"skipped"` + Votes int64 `json:"votes"` +} + +type PlaylistSummary struct { + CoverURL string `json:"coverUrl"` + Creator string `json:"creator"` + Description string `json:"description"` + ID string `json:"id"` + Name string `json:"name"` + PlayCount int64 `json:"playCount"` + Source Source `json:"source"` + TrackCount int64 `json:"trackCount"` +} + +type PlaylistTracksRequest struct { + PlaylistID string `json:"playlistId"` + Source Source `json:"source"` +} + +type PlaylistTracksResponse struct { + Items []TrackSummary `json:"items"` + Total int64 `json:"total"` +} + +func (v PlaylistTracksResponse) MarshalJSON() ([]byte, error) { + type plain PlaylistTracksResponse + if v.Items == nil { + v.Items = []TrackSummary{} + } + return json.Marshal(plain(v)) +} + +type QQLoginType string + +const ( + QQLoginTypeQQ QQLoginType = "qq" + QQLoginTypeWx QQLoginType = "wx" +) + +type QRLogin struct { + ExpiresAtMs int64 `json:"expiresAtMs"` + Key string `json:"key"` + QrImage string `json:"qrImage"` +} + +type QRLoginResult struct { + Message string `json:"message"` + Status QRLoginStatus `json:"status"` +} + +type QRLoginStatus string + +const ( + QRLoginStatusWaiting QRLoginStatus = "waiting" + QRLoginStatusScanned QRLoginStatus = "scanned" + QRLoginStatusSuccess QRLoginStatus = "success" + QRLoginStatusExpired QRLoginStatus = "expired" + QRLoginStatusFailed QRLoginStatus = "failed" +) + +type QueueAddRequest struct { + Keyword *string `json:"keyword,omitempty"` + Requester *string `json:"requester,omitempty"` + Source *Source `json:"source,omitempty"` + Track *Track `json:"track,omitempty"` +} + +type QueueAddResponse struct { + Entry Entry `json:"entry"` +} + +type QueueLikeRequest struct { + EntryID string `json:"entryId"` +} + +type QueueLikeResponse struct { + Likes int64 `json:"likes"` +} + +type QueueRemoveRequest struct { + EntryID string `json:"entryId"` +} + +type QueueUpdatedEvent struct { + Items []Entry `json:"items"` + Pending *Entry `json:"pending"` +} + +func (v QueueUpdatedEvent) MarshalJSON() ([]byte, error) { + type plain QueueUpdatedEvent + if v.Items == nil { + v.Items = []Entry{} + } + return json.Marshal(plain(v)) +} + +type RecommendListRequest struct{} + +type RecommendListResponse struct { + Items []TrackSummary `json:"items"` +} + +func (v RecommendListResponse) MarshalJSON() ([]byte, error) { + type plain RecommendListResponse + if v.Items == nil { + v.Items = []TrackSummary{} + } + return json.Marshal(plain(v)) +} + +type RoomActivityEvent struct { + Entry *Entry `json:"entry,omitempty"` + Kind ActivityKind `json:"kind"` + Member *Member `json:"member,omitempty"` + Message string `json:"message"` + Required *int64 `json:"required,omitempty"` + Votes *int64 `json:"votes,omitempty"` +} + +type RoomInfo struct { + Description string `json:"description"` + ID string `json:"id"` + Kind RoomKind `json:"kind"` + Name string `json:"name"` +} + +type RoomKind string + +const ( + RoomKindPersistent RoomKind = "persistent" + RoomKindEphemeral RoomKind = "ephemeral" +) + +type RoomListing struct { + Description string `json:"description"` + HasPassword bool `json:"hasPassword"` + ID string `json:"id"` + Kind RoomKind `json:"kind"` + Name string `json:"name"` + OnlineCount int64 `json:"onlineCount"` + Slug *string `json:"slug"` +} + +type RoomSnapshot struct { + Items []Entry `json:"items"` + Members Members `json:"members"` + NowPlaying *NowPlaying `json:"nowPlaying"` + Pending *Entry `json:"pending"` + PlayMode PlayMode `json:"playMode"` + Room RoomInfo `json:"room"` + Seq int64 `json:"seq"` + Term int64 `json:"term"` +} + +func (v RoomSnapshot) MarshalJSON() ([]byte, error) { + type plain RoomSnapshot + if v.Items == nil { + v.Items = []Entry{} + } + return json.Marshal(plain(v)) +} + +type RoomSyncRequest struct{} + +type RoomSyncResponse struct { + Room RoomSnapshot `json:"room"` +} + +type RoomUpdatedEvent struct { + Description string `json:"description"` + Name string `json:"name"` +} + +type SearchPlaylistsRequest struct { + Keyword string `json:"keyword"` + Page int64 `json:"page"` + PageSize int64 `json:"pageSize"` + Source Source `json:"source"` +} + +type SearchPlaylistsResponse struct { + Items []PlaylistSummary `json:"items"` + Total int64 `json:"total"` +} + +func (v SearchPlaylistsResponse) MarshalJSON() ([]byte, error) { + type plain SearchPlaylistsResponse + if v.Items == nil { + v.Items = []PlaylistSummary{} + } + return json.Marshal(plain(v)) +} + +type SearchTracksRequest struct { + Keyword string `json:"keyword"` + Page int64 `json:"page"` + PageSize int64 `json:"pageSize"` + Source Source `json:"source"` +} + +type SearchTracksResponse struct { + Items []TrackSummary `json:"items"` + Total int64 `json:"total"` +} + +func (v SearchTracksResponse) MarshalJSON() ([]byte, error) { + type plain SearchTracksResponse + if v.Items == nil { + v.Items = []TrackSummary{} + } + return json.Marshal(plain(v)) +} + +type SettingsSetPlayModeRequest struct { + PlayMode PlayMode `json:"playMode"` +} + +type SettingsUpdatedEvent struct { + PlayMode PlayMode `json:"playMode"` +} + +type Source string + +const ( + SourceNetease Source = "netease" + SourceQQ Source = "qq" + SourceBilibili Source = "bilibili" + SourceLink Source = "link" +) + +type SourceAccount struct { + ID string `json:"id"` + Name *string `json:"name"` + Verified bool `json:"verified"` + Vip *bool `json:"vip"` +} + +type StoredMedia struct { + Key string `json:"key"` + LastPlayedAtMs *int64 `json:"lastPlayedAtMs"` + Link string `json:"link"` + Size int64 `json:"size"` + Source Source `json:"source"` + StoredAtMs int64 `json:"storedAtMs"` + Title string `json:"title"` +} + +type Track struct { + ID string `json:"id"` + Source Source `json:"source"` +} + +type TrackMeta struct { + Album string `json:"album"` + Artist string `json:"artist"` + CoverURL string `json:"coverUrl"` + DurationMs int64 `json:"durationMs"` + PageURL string `json:"pageUrl"` + Title string `json:"title"` +} + +type TrackSummary struct { + Meta TrackMeta `json:"meta"` + Track Track `json:"track"` +} + +type UpdateMeRequest struct { + Email *string `json:"email,omitempty"` + Name *string `json:"name,omitempty"` +} + +type UpdateMeResponse struct { + Member Member `json:"member"` +} + +type ErrorResponse struct { + Error ErrorInfo `json:"error"` +} + +const ( + RequestHello = "hello" + RequestPing = "ping" + RequestRoomSync = "room.sync" + RequestQueueAdd = "queue.add" + RequestQueueRemove = "queue.remove" + RequestQueueLike = "queue.like" + RequestPlaybackVoteSkip = "playback.voteSkip" + RequestSettingsSetPlayMode = "settings.setPlayMode" + RequestChatSend = "chat.send" + RequestSearchTracks = "search.tracks" + RequestSearchPlaylists = "search.playlists" + RequestPlaylistTracks = "playlist.tracks" + RequestRecommendList = "recommend.list" +) + +const ( + EventError = "error" + EventPlaybackUpdated = "playback.updated" + EventQueueUpdated = "queue.updated" + EventMembersUpdated = "members.updated" + EventSettingsUpdated = "settings.updated" + EventRoomUpdated = "room.updated" + EventChatMessage = "chat.message" + EventRoomActivity = "room.activity" +) + +const ( + CloseInvalidArgument = 1008 + CloseInternal = 1011 + CloseServiceRestart = 1012 + CloseUnavailable = 1013 + CloseUnauthorized = 4401 + CloseNotFound = 4404 + CloseHandshakeTimeout = 4408 + CloseClientOutdated = 4426 + CloseSendQueueFull = 4429 +) + +var ( + RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201} + RouteGetMe = Route{Pattern: "GET /api/me", Auth: AuthMember, Status: 200} + RouteUpdateMe = Route{Pattern: "PATCH /api/me", Auth: AuthMember, Status: 200} + RouteListRooms = Route{Pattern: "GET /api/rooms", Auth: AuthNone, Status: 200} + RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200} + RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true} + RouteCreateTicket = Route{Pattern: "POST /api/rooms/{id}/tickets", Auth: AuthMember, Status: 201} + RouteGetSite = Route{Pattern: "GET /api/site", Auth: AuthNone, Status: 200} + RouteAdminListRooms = Route{Pattern: "GET /api/admin/rooms", Auth: AuthAdmin, Status: 200} + RouteAdminCreateRoom = Route{Pattern: "POST /api/admin/rooms", Auth: AuthAdmin, Status: 201, Location: true} + RouteAdminUpdateRoom = Route{Pattern: "PATCH /api/admin/rooms/{id}", Auth: AuthAdmin, Status: 204} + RouteAdminDeleteRoom = Route{Pattern: "DELETE /api/admin/rooms/{id}", Auth: AuthAdmin, Status: 204} + RouteAdminGetRoomApiKey = Route{Pattern: "GET /api/admin/rooms/{id}/api-key", Auth: AuthAdmin, Status: 200} + RouteAdminResetRoomApiKey = Route{Pattern: "POST /api/admin/rooms/{id}/api-key", Auth: AuthAdmin, Status: 201, Location: true} + RouteAdminGetNeteaseCredential = Route{Pattern: "GET /api/admin/credentials/netease", Auth: AuthAdmin, Status: 200} + RouteAdminGetQQCredential = Route{Pattern: "GET /api/admin/credentials/qq", Auth: AuthAdmin, Status: 200} + RouteAdminStartNeteaseLogin = Route{Pattern: "POST /api/admin/credentials/netease/qr-logins", Auth: AuthAdmin, Status: 201} + RouteAdminCheckNeteaseLogin = Route{Pattern: "POST /api/admin/credentials/netease/qr-logins/status", Auth: AuthAdmin, Status: 200} + RouteAdminStartQQLogin = Route{Pattern: "POST /api/admin/credentials/qq/qr-logins", Auth: AuthAdmin, Status: 201} + RouteAdminCheckQQLogin = Route{Pattern: "POST /api/admin/credentials/qq/qr-logins/status", Auth: AuthAdmin, Status: 200} + RouteAdminGetBilibiliCredential = Route{Pattern: "GET /api/admin/credentials/bilibili", Auth: AuthAdmin, Status: 200} + RouteAdminStartBilibiliLogin = Route{Pattern: "POST /api/admin/credentials/bilibili/qr-logins", Auth: AuthAdmin, Status: 201} + RouteAdminCheckBilibiliLogin = Route{Pattern: "POST /api/admin/credentials/bilibili/qr-logins/status", Auth: AuthAdmin, Status: 200} + RouteAdminGetYoutubeCredential = Route{Pattern: "GET /api/admin/credentials/youtube", Auth: AuthAdmin, Status: 200} + RouteAdminSetYoutubeCredential = Route{Pattern: "PUT /api/admin/credentials/youtube", Auth: AuthAdmin, Status: 204} + RouteAdminDeleteYoutubeCredential = Route{Pattern: "DELETE /api/admin/credentials/youtube", Auth: AuthAdmin, Status: 204} + RouteAdminListMedia = Route{Pattern: "GET /api/admin/media", Auth: AuthAdmin, Status: 200} + RouteAdminDeleteMedia = Route{Pattern: "DELETE /api/admin/media/{key}", Auth: AuthAdmin, Status: 204} + RouteAdminUpdateSite = Route{Pattern: "PATCH /api/admin/site", Auth: AuthAdmin, Status: 200} +) + +var RoomRoutes = []RoomRoute{ + {Route: Route{Pattern: "GET /api/room", Auth: AuthTicket, Status: 200}, Type: RequestRoomSync}, + {Route: Route{Pattern: "POST /api/room/queue", Auth: AuthTicket, Status: 201, Location: true}, Type: RequestQueueAdd, body: true}, + {Route: Route{Pattern: "DELETE /api/room/queue/{entryId}", Auth: AuthTicket, Status: 204}, Type: RequestQueueRemove, params: []string{"entryId"}}, + {Route: Route{Pattern: "POST /api/room/queue/{entryId}/likes", Auth: AuthTicket, Status: 200}, Type: RequestQueueLike, params: []string{"entryId"}, body: true}, + {Route: Route{Pattern: "POST /api/room/skip-votes", Auth: AuthTicket, Status: 200}, Type: RequestPlaybackVoteSkip, body: true}, + {Route: Route{Pattern: "PATCH /api/room/settings", Auth: AuthTicket, Status: 204}, Type: RequestSettingsSetPlayMode, body: true}, + {Route: Route{Pattern: "POST /api/room/messages", Auth: AuthTicket, Status: 204}, Type: RequestChatSend, body: true}, + {Route: Route{Pattern: "GET /api/room/search/tracks", Auth: AuthTicket, Status: 200}, Type: RequestSearchTracks, query: map[string]queryKind{"keyword": queryString, "page": queryInteger, "pageSize": queryInteger, "source": queryString}}, + {Route: Route{Pattern: "GET /api/room/search/playlists", Auth: AuthTicket, Status: 200}, Type: RequestSearchPlaylists, query: map[string]queryKind{"keyword": queryString, "page": queryInteger, "pageSize": queryInteger, "source": queryString}}, + {Route: Route{Pattern: "GET /api/room/playlists/{source}/{playlistId}/tracks", Auth: AuthTicket, Status: 200}, Type: RequestPlaylistTracks, params: []string{"source", "playlistId"}}, + {Route: Route{Pattern: "GET /api/room/recommendations", Auth: AuthTicket, Status: 200}, Type: RequestRecommendList}, +} + +var errorCodeHTTPStatus = map[ErrorCode]int{ + ErrorCodeInvalidArgument: 400, + ErrorCodeUnauthorized: 401, + ErrorCodeForbidden: 403, + ErrorCodeNotFound: 404, + ErrorCodeConflict: 409, + ErrorCodeQueueFull: 409, + ErrorCodeNothingPlaying: 409, + ErrorCodeRateLimited: 429, + ErrorCodeProviderError: 502, + ErrorCodeUnavailable: 503, + ErrorCodeInternal: 500, +} + +// IDL 没有为某个取值指定 HTTP 状态码时返回 0。 +func (v ErrorCode) HTTPStatus() int { + return errorCodeHTTPStatus[v] +} diff --git a/protocol/ws.go b/protocol/ws.go new file mode 100644 index 0000000..14f18e1 --- /dev/null +++ b/protocol/ws.go @@ -0,0 +1,80 @@ +package protocol + +import ( + "encoding/json" + "strings" + "unicode/utf8" +) + +type Message struct { + Kind string `json:"kind"` + Type string `json:"type"` + ID string `json:"id,omitempty"` + // 房间事件的 term 和 seq 都从 1 开始;响应和 error 事件不带编号,取 0 时不编码。 + Term int64 `json:"term,omitempty"` + Seq int64 `json:"seq,omitempty"` + Data any `json:"data"` + Error *ErrorInfo `json:"error,omitempty"` +} + +func Response(typ, id string, data any) Message { + return Message{Kind: "response", Type: typ, ID: id, Data: data} +} + +func Failure(typ, id string, err ErrorInfo) Message { + return Message{Kind: "response", Type: typ, ID: id, Data: Empty{}, Error: &err} +} + +func RoomEvent(typ string, term, seq int64, data any) Message { + return Message{Kind: "event", Type: typ, Term: term, Seq: seq, Data: data} +} + +func ErrorEvent(err ErrorInfo) Message { + return Message{Kind: "event", Type: EventError, Data: err} +} + +type Request struct { + Type string + ID string + Data any +} + +// DecodeRequest 返回错误时,ID 为空表示这一帧无法对应到请求,应发送 error 事件;否则回复这个请求的失败响应。 +func DecodeRequest(frame []byte) (Request, error) { + var env map[string]json.RawMessage + if json.Unmarshal(frame, &env) != nil || env == nil { + return Request{}, invalid("", "消息必须是 JSON 对象") + } + var kind, id, typ string + if json.Unmarshal(env["kind"], &kind) != nil || kind != "request" { + return Request{}, invalid("kind", `应为 "request"`) + } + if json.Unmarshal(env["id"], &id) != nil || utf8.RuneCountInString(id) < 1 || utf8.RuneCountInString(id) > 128 { + return Request{}, invalid("id", "应为 1–128 个字符的字符串") + } + if strings.ContainsRune(id, 0) { + return Request{}, invalid("id", "不能包含 U+0000") + } + if json.Unmarshal(env["type"], &typ) != nil || typ == "" { + return Request{}, invalid("type", "应为非空字符串") + } + req := Request{Type: typ, ID: id} + if err := rejectUnknown(env, "", "kind", "type", "id", "data"); err != nil { + return req, err + } + if err := requireFields(env, "", "data"); err != nil { + return req, err + } + data, known, err := decodeConnectionRequest(typ, env["data"], "data") + if !known { + data, known, err = decodeRoomRequest(typ, env["data"], "data") + } + if !known { + return req, invalid("type", "未知的请求类型") + } + if err != nil { + return req, err + } + req.Data = data + return req, nil +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..dd9d7ca --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,28 @@ +version: "2" +sql: + - engine: postgresql + schema: db/migrations + queries: db/queries + gen: + go: + package: query + out: internal/adapter/out/postgres/internal/query + sql_package: pgx/v5 + omit_unused_structs: true + emit_pointers_for_null_types: true + initialisms: [id, url] + overrides: + - db_type: uuid + go_type: string + - db_type: uuid + nullable: true + go_type: + type: string + pointer: true + - db_type: timestamptz + go_type: time.Time + - db_type: timestamptz + nullable: true + go_type: + type: time.Time + pointer: true