feat: add backend, database, and protocol foundations

This commit is contained in:
DiheChen committed 2026-10-30 01:32:19 +08:00
commit ec08d487e1
179 files changed
+27079

No files matched your search

+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+3
View File
@@ -0,0 +1,3 @@
# Alisten
在线音乐共享房间,支持实时同步播放、共享队列、多音源和管理后台。
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"os"
"github.com/dihemoe/alisten/internal/bootstrap"
)
func main() {
os.Exit(bootstrap.Main(os.Args[1:]))
}
+173
View File
@@ -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 + "`"
}
+199
View File
@@ -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)
}
}
File diff suppressed because it is too large. Load diff
+111
View File
@@ -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)
}
+54
View File
@@ -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:]
}
+252
View File
@@ -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
}
+331
View File
@@ -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 = <T extends RequestType>(type: T, data: RequestDataMap[T]) => Promise<ResponseDataMap[T]>\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<T extends RequestType = RequestType> {
kind: 'request'
type: T
id: string
data: RequestDataMap[T]
}
export type ResponseMessage<T extends RequestType = RequestType> = {
[K in T]: { kind: 'response', type: K, id: string } & (
| { data: ResponseDataMap[K], error?: undefined }
| { data: Empty, error: ErrorInfo }
)
}[T]
export type ConnectionEventMessage<T extends ConnectionEventType = ConnectionEventType> = {
[K in T]: { kind: 'event', type: K, data: ConnectionEventDataMap[K] }
}[T]
export type RoomEventMessage<T extends RoomEventType = RoomEventType> = {
[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 = <T>(request: HttpRequest) => Promise<T>
`
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<string, never>\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)
}
}
+100
View File
@@ -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()
);
+5
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
-- +goose Up
ALTER TABLE tracks DROP COLUMN kuwo_id;
+11
View File
@@ -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
);
+6
View File
@@ -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'));
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
-- 至多一行;没有这一行时品牌名是默认的 Alisten。
CREATE TABLE site (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
brand_name text NOT NULL
);
+6
View File
@@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var FS embed.FS
+14
View File
@@ -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;
+12
View File
@@ -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;
+55
View File
@@ -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);
+11
View File
@@ -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;
+14
View File
@@ -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 *;
+24
View File
@@ -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);
+6
View File
@@ -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;
+40
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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();
+125
View File
@@ -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
)
+303
View File
@@ -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=
+358
View File
@@ -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)
}
+64
View File
@@ -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
}
+255
View File
@@ -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
}
+141
View File
@@ -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
}
+206
View File
@@ -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
}
+75
View File
@@ -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)
}
+101
View File
@@ -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}
}
+98
View File
@@ -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)
}
+27
View File
@@ -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
}
+299
View File
@@ -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)
}
}
}
+19
View File
@@ -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)
}
+25
View File
@@ -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
}
+145
View File
@@ -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: ")
}
+234
View File
@@ -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
}
+211
View File
@@ -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))
}
+171
View File
@@ -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
}
+167
View File
@@ -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
}
+99
View File
@@ -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
}
+142
View File
@@ -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
}
@@ -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
}
+41
View File
@@ -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
}
+220
View File
@@ -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
}
+77
View File
@@ -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)...)
}
+126
View File
@@ -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
}
+355
View File
@@ -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", "")
}
+59
View File
@@ -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
}
@@ -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})
}
+47
View File
@@ -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
}
@@ -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
}
@@ -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})
}
@@ -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
}
@@ -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,
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
+67
View File
@@ -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
}
+49
View File
@@ -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)}
}
+64
View File
@@ -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
}
+54
View File
@@ -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
}
+128
View File
@@ -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
}
+31
View File
@@ -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
}
+243
View File
@@ -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
}
+39
View File
@@ -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
}
+51
View File
@@ -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
}
+64
View File
@@ -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
}
+41
View File
@@ -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 ""
}
+199
View File
@@ -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", "")
}
+821
View File
@@ -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
}
+83
View File
@@ -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 音乐没有提供这首歌的播放地址")}
}
+217
View File
@@ -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,
},
}
}
+113
View File
@@ -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
}
+395
View File
@@ -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)
}
+19
View File
@@ -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
}
+218
View File
@@ -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
}
+135
View File
@@ -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()
}
+50
View File
@@ -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
}
+34
View File
@@ -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
}
+152
View File
@@ -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
}
+91
View File
@@ -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...)
}
}
+147
View File
@@ -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()
}
+277
View File
@@ -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 ""
}
+287
View File
@@ -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
}
+75
View File
@@ -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
}
+281
View File
@@ -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)]
}
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more