Compare commits

..

No commits in common. "main" and "v0.1.2" have entirely different histories.
main ... v0.1.2

18 changed files with 3130 additions and 778 deletions

674
LICENSE
View File

@ -1,674 +0,0 @@
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>.

View File

@ -4,13 +4,13 @@
"summary": "网易云音乐遥控器:一句话对上云里的内容(每日推荐 / 歌单 / 榜单 / 收藏 / FM / 最近 / 播客)并放出来;播放侧管好本机队列现场(快照 / back / 追加)", "summary": "网易云音乐遥控器:一句话对上云里的内容(每日推荐 / 歌单 / 榜单 / 收藏 / FM / 最近 / 播客)并放出来;播放侧管好本机队列现场(快照 / back / 追加)",
"tier": 1, "tier": 1,
"status": "stable", "status": "stable",
"version": "d0c7a0e", "version": "c23f1d1",
"source": { "source": {
"path": "skills/netease-music", "path": "skills/netease-music",
"commit": "d0c7a0e", "commit": "c23f1d1",
"describe": "d0c7a0e", "describe": "c23f1d1",
"dirty": false, "dirty": false,
"packed_at": "2026-10-02T06:26:26.757Z" "packed_at": "2026-10-01T23:43:33.462Z"
}, },
"platforms": [ "platforms": [
"darwin" "darwin"
@ -327,7 +327,7 @@
{ {
"path": "VERSION", "path": "VERSION",
"bytes": 6, "bytes": 6,
"sha256": "1f2110568627307b" "sha256": "297b50f08386d352"
}, },
{ {
"path": "contract.lock.json", "path": "contract.lock.json",
@ -336,8 +336,8 @@
}, },
{ {
"path": "interface.json", "path": "interface.json",
"bytes": 9287, "bytes": 9260,
"sha256": "655802a88fa1da5f" "sha256": "ddd1274a65b4dfd3"
}, },
{ {
"path": "scripts/install-cli.sh", "path": "scripts/install-cli.sh",

View File

@ -2,79 +2,13 @@
网易云音乐遥控器:一句话对上云里的内容(每日推荐 / 歌单 / 榜单 / 收藏 / FM / 最近 / 播客)并放出来;播放侧管好本机队列现场(快照 / back / 追加) 网易云音乐遥控器:一句话对上云里的内容(每日推荐 / 歌单 / 榜单 / 收藏 / FM / 最近 / 播客)并放出来;播放侧管好本机队列现场(快照 / back / 追加)
以 Pi package(技能形态)发布;同一个源还有 CLI 出口 `netease-music-cli`。技能本体在 `skills/netease-music/`。 以 Pi package(技能形态)发布;同一个源还有 CLI 出口 `netease-music-cli`。
技能本体在 `skills/netease-music/`,用法见其 `SKILL.md` 与 `REFERENCE.md`。
- 平台:macOS
- 许可:GPL-3.0-or-later(全文见 [LICENSE](LICENSE))
## 安装 ## 安装
```sh ```sh
pi install git:gitea.vhkd.top/pi-hub/netease-music.git@v0.1.6 pi install git:gitea.vhkd.top/geekinney/netease-music.git@v0.1.2
``` ```
装完 `pi-skill list` 能看到 `netease-music`,`pi-skill netease-music check` 会告诉还缺什么。 装完 `pi-skill list` 能看到 `netease-music`,`pi-skill netease-music check` 会告诉还缺什么。
## 先准备什么
- `node`(运行环境),接口客户端与 mpv 控制都是 node 脚本,零 npm 依赖
- `python3`(运行环境)
- `mpv`(命令),安装:brew install mpv;真正的播放器(常驻 + unix socket 控制)
- `qrencode`(命令),安装:brew install qrencode;只在扫码登录时用
## 常用命令
| 命令 | 说明 |
|---|---|
| `daily` | 每日推荐分页入队播放(--limit N / --offset N / --shuffle;JSON 带 total) |
| `play` | 播一首或一整张:关键词→最匹配一首;歌单 id/链接→整张(--shuffle) |
| `play-ids` | play-ids <id,id,...> [--start N]:按输入顺序整列替换,1 基起点默认 1;保留重复项,选中歌不可播则保留旧队列;关闭随机 |
| `search` | 只搜不播:列出 id/歌名/歌手/时长/是否要 VIP(--limit N / --offset N;JSON 带 total) |
| `cover` | cover <歌曲关键词|id> [--download PATH] [--force]:只读查询封面,JSON 为 id/name/artist/cover;下载为 id/cover/file/bytes,最多 10 MB(10000000 字节),覆盖需 --force;所有原语歌曲对象含字符串 cover(缺失为空) |
| `artist` | 歌手热门分页入队播放(名字或 id;--limit N / --offset N;JSON 带 total) |
| `album` | 专辑按顺序分页入队播放(名字、id 或专辑链接;--limit N / --offset N;JSON 带 total) |
| `albums` | 只列不播:歌手的专辑(含老专辑;--limit N / --offset N;JSON 带 total) |
| `liked` | 分页播放「我喜欢的音乐」(--limit N / --offset N;JSON 带 total) |
| `liked-ids` | 只读喜欢 ID 全表;--json 为 {count,total,ids:[数字,...]};歌曲 JSON 透传可用的布尔 liked,缺字段时用全表按 id 判定 |
| `myplaylists` | 我的歌单(自建 + 收藏);--limit N / --offset N 分页,collected/own 筛本页;JSON 带 total |
| `pause` | 暂停 |
| `resume` | 继续播放 |
| `toggle` | 暂停/继续切换 |
| `next` | 下一首 |
| `prev` | 上一首 |
| `stop` | 停止并清空队列 |
| `clear` | 停止并清空 mpv 和本地队列,收掉保活;配置和快照保留,可重复调用 |
| `volume` | 读或设音量(0-130) |
| `seek` | 跳进度(相对秒数,如 +30) |
| `queue` | 看队列(mpv 真值):每首歌名、时长,标出正在放的那首 |
| `jump` | 跳到队列第 N 首(从 1 开始),并回读状态确认 |
| `remove` | remove <index>:按 queue 显示的 1 基序号删除;当前项选下一首,末尾选上一首,删空停止;同步本地文件 |
| `status` | 当前曲目/进度/音量/队列位置(mpv 真值);repeat_mode 为 off/list/single |
| `lyric` | 当前歌曲歌词(默认只显示当前句与下一句,--all 全文) |
| `like` | 把当前这首歌加入「我喜欢的音乐」 |
| `unlike` | 把当前这首歌移出「我喜欢的音乐」 |
| `shuffle` | 队列随机开/关 |
| `repeat` | repeat off|list|single:关闭/整列/单曲循环;on 和无参数 = list;状态追加 repeat_mode,保留旧 repeat 布尔含义 |
| `login` | 登录(--sms 手机号+验证码两步为主力;不带参数出二维码扫码),cookie 存本机 config |
| `setup` | 写配置(--cookie/--quality);不带参数打印登录办法 |
| `check` | 只读自检:依赖、登录态、mpv 可用性、默认音质 |
| `playlist` | 放一张歌单:名字/id/链接都行(--limit N / --offset N;JSON 带 total) |
| `playlists` | 搜歌单(只列不播):id / 名字 / 曲目数 / 作者 / 是否已收藏;--limit N / --offset N,JSON 带 total |
| `charts` | 列排行榜(只列不播):飙升榜 / 新歌榜 / …;--limit N / --offset N,JSON 带 total |
| `chart` | 放某个榜单:名字或 id(--limit N / --offset N;JSON 带 total) |
| `myalbums` | 我收藏的专辑(只列不播;--limit N / --offset N;JSON 带 total;id 可喂 album) |
| `myartists` | 我收藏的歌手(只列不播;--limit N / --offset N;JSON 带 total;id 可喂 artist) |
| `fm` | 私人 FM:没听过的歌,整列入队开播(--limit N) |
| `dislike` | dislike [歌曲id]:私人 FM 不喜欢(fm/trash);省略 id 用当前歌曲,只上报,不自动切歌或改队列 |
| `recent` | 最近播放(只列不播,一周内,含播放次数;--limit N / --offset N;JSON 带 total) |
| `replay` | 再放一次最近第 N 首(默认 1 = 最近那首) |
| `podcasts` | 我的播客订阅(只列不播;--recommend 看推荐的;--limit N / --offset N;JSON 带 total) |
| `podcast` | 放某播客的节目(--limit N 默认 3;--offset N;名字或 id;JSON 带 total) |
| `episode` | 放某一期节目(节目 id 从 podcast 输出里抄) |
| `queues` | 看本地队列快照(每次换队列会自动留一条「上一条」) |
| `queue-save` | 把当前队列存成命名快照(只存歌曲列表,地址回放时重取) |
| `queue-load` | 载入某个命名快照(含当时位置与随机状态) |
| `back` | 切回上一条队列(含位置);再 back 一次切回来 |
| `add` | 把一首加到当前队列尾部,不动正在放的那首 |
用法与对外契约见技能内的 `SKILL.md` 与 `REFERENCE.md`。

333
REFERENCE.md Normal file
View File

@ -0,0 +1,333 @@
# netease-music 参考(架构 / 接口 / 播放器 / 坑)
## 1. 为什么是这套架构
以前的做法是模拟鼠标点官方客户端界面:慢(每次要点窗口、等动画)、脆(页面结构一变就废)、功能受界面限制,
连「是不是在播」都只能靠截屏看进度条,结果误判过(时间读数卡住但仍报 paused)。
现在三层,各自可单独验证:
| 层 | 文件 | 职责 |
|---|---|---|
| 取数 | `scripts/netease-api.mjs` | 直连 `music.163.com` 的 weapi(自写加密,**零 npm 依赖**,不跑后台服务) |
| 播放 | `scripts/mpv-ctl.mjs` | 常驻 mpv + unix socket JSON IPC:播放/暂停/切歌/音量/进度/真状态 |
| 队列 | `scripts/lib/build-queue.py` | 歌曲列表 + 播放地址 → `queue.json`(反查 id)+ `queue.m3u8`(mpv 队列,带 EXTINF 标题) |
| 入口 | `scripts/netease-music.sh` | 用户意图 → 上面三层 |
**为什么不用 NeteaseCloudMusicApi(npm 那个项目)**:要装一大棵依赖树 + 常驻 HTTP 服务,而本机只用到十来个端点;
自写客户端启动 0 开销、不受上游版本变动影响。协议细节见下,改起来有据可依。
## 2. 接口协议(实测)
`POST https://music.163.com/weapi/<路径>?csrf_token=<cookie 里 __csrf 的真值>`,body 是
`params=<密文>&encSecKey=<密文>`,加密前的 JSON 里同样要带 `csrf_token`:
```
params = base64(AES-128-CBC(AES-128-CBC(JSON, '0CoJUm6Qyw8W8jud'), secret))
secret = 16 字符随机串
encSecKey= RSA(secret 字节反转 → 大整数) ** 0x10001 mod <固定 1024 位模数>,补足 256 个 hex
IV = '0102030405060708'
```
**坑**:`aesEncrypt` 输出是 **base64**,写成 hex 会得到「HTTP 200 + 空响应体」这种最难查的失败。
模数、常量都在 `netease-api.mjs` 顶部。
**坑(写操作专用)**:`csrf_token` 必须是 cookie 里 `__csrf` 的真值。填空串时读操作(搜索/推荐/歌单/歌词)**全都正常**,
只有写操作被风控挡下:`{"code":-460,"message":"检测到您的网络环境存在风险,请稍后再试"}`。
所以「读都好好的」不能推出「cookie 拼对了」。
用到的端点:
| 路径 | 用途 | 备注 |
|---|---|---|
| 明文 `POST /api/search/get/web` | 搜歌 | 搜歌**必须走明文**:weapi 版 `cloudsearch/get/web` 对匿名请求回空体 |
| `weapi/cloudsearch/get/web` | 搜歌手(100)/专辑(10) | **登录后可用**(匿名回空体),索引比明文全 → 先走它、空再回退明文(`searchFirst`) |
| `weapi/v1/artist/<id>` | 歌手热门 50 首 | 回 `hotSongs`;老接口 `weapi/artist/top/song` 给的是同一份榜单 |
| `weapi/artist/albums/<id>` | 歌手的专辑列表 | 回 `hotAlbums`(含搜索索引里没有的老专辑);按时间倒序,`limit` 给 60 才够到经典专辑 |
| `weapi/v1/album/<id>` | 专辑曲目 | 回 `songs`,专辑顺序 |
| `weapi/song/enhance/player/url/v1` | 播放地址 | `level`=standard/higher/exhigh/lossless/hires;VIP 无权限时 `url=null` |
| `weapi/v2/discovery/recommend/songs` | 每日推荐 | 未登录报 `code 301` |
| `weapi/v6/playlist/detail` + `weapi/v3/song/detail` | 歌单 | `trackCount` 是总数;按 `trackIds` 截本页,补查内嵌 `tracks` 缺的详情;默认每页 500 首 |
| `weapi/v6/playlist/detail`(specialType=5 歌单,`n=0`)+ `weapi/v3/song/detail` | 我喜欢的音乐 | `trackIds[].at` 是收藏时间戳,按它倒序 = 客户端顺序(最近收藏在前);`song/like/get` 的 ids 顺序与收藏时间无关,只用于单曲判定 |
| `weapi/radio/like` | 喜欢/取消喜欢 | `{alg:'itembased', trackId, like, time:3}`;被拒时回 `-460`(风控)/`405`(限流) |
| `weapi/playlist/manipulate/tracks` | 喜欢/取消喜欢(风控时的回退) | `{op:'add'\|'del', pid:我喜欢的音乐id, trackIds:JSON字符串, imme:'true'}`;`502 歌单内歌曲重复` = 已喜欢;删除不存在的歌也回 200 |
| `weapi/song/like/get` | `liked-ids` / 单曲判定 | 回的是 `{ids:[…]}` **全表**;`liked-ids` 传 `{trackIds:'[]'}`,一次调用就能判 |
| `weapi/radio/trash/add`(API 的 `fm/trash`) | 私人 FM 不喜欢 | `{songId:<id>,time:25,alg:'RT'}`;`dislike [id]` 提交,成功必须 `code=200` |
| `weapi/song/lyric` | 歌词 | `lrc.lyric`(LRC 格式) |
| `weapi/w/nuser/account/get` | 登录态 | `profile.userId` 就是 uid |
| `weapi/user/playlist` | 我的歌单 | 含`specialType` 标记的「我喜欢的音乐」 |
| `weapi/cloudsearch/get/web` type=1000 | 搜歌单(别人的) | 同搜索:登录后可用,空则回退明文 |
| `weapi/cloudsearch/get/web` type=1009 | 搜播客 | 回 `djRadios`(名字/id/期数) |
| `weapi/toplist` | 排行榜列表 | 63 个榜,`id` 就是歌单 id → 直接喂 `playlist/detail` |
| `weapi/album/sublist` | 我收藏的专辑 | `data`:`{id,name,size,artists}` |
| `weapi/artist/sublist` | 我收藏的歌手 | `data`:`{id,name,albumSize}`(`musicSize` 常为 null,别用) |
| `weapi/radio/get` | 私人 FM | **一次固定 3 首、忽略 limit** → 循环凑数去重(`cmdFm`) |
| `weapi/v1/play/record` | 最近播放 | `{uid,type:1}` → `weekData` **100 条** `{playCount,score,song}`,已按最近排 |
| `weapi/djradio/get/subed` | 我的播客订阅 | `djRadios`:`{id,name,programCount,dj.nickname}` |
| `weapi/djradio/recommend/v1` | 推荐播客 | `--recommend` 走它 |
| `weapi/dj/program/byradio` | 播客的节目列表 | `{radioId,limit,offset,asc:false}` → `programs[].mainSong.id` **就是可播的 song id** |
| `weapi/dj/program/detail` | 单集详情 | 参数是 **`id`**(传 `programId` 回 400) |
| `weapi/login/qrcode/unikey` / `client/login` | 扫码登录 | 轮询码:800 过期 / 801 待扫 / 802 待确认 / 803 成功(`Set-Cookie` 带 MUSIC_U) |
| `weapi/sms/captcha/sent` / `weapi/login/cellphone` | 短信验证码登录(扫码不稳时的主力) | 两步:发码 `{cellphone, ctcode:'86'}` → 登录 `{phone, countrycode:'86', captcha, rememberLogin:true}` |
**挑结果的规矩(`pickBest`)**:搜歌手/专辑时先看「完全同名(含别名)」,同名再挑曲目多的。
不挑就会拿到同名翻唱:搜「范特西」第一条是别人的 Type Beat。
**网易搜索索引会缺正版**(实测 2026-09):周杰伦《范特西》在明文和 weapi 两个搜索里**都只有翻唱**,搜不到正版 id。
走得通的路:`albums 周杰伦` → 真 id `18915` → `album 18915` 拿到 10 首正版。但这 10 首全要 VIP,
`url=null` → 队列 0 首,命令按 `error=notfound` 退出(这是版权,不是故障)。
**`song/like/get` 不认 `limit`**:给 `{uid, offset:0, limit:3}` 也回全表,`liked-ids` 直接返回完整 ID 数组供喜欢状态判定。`cmdLiked` 按歌单的 `trackIds[].at` 倒序后,用 `.slice(o.offset, o.offset + o.limit)` 取本页,`total` 始终是完整 ID 数量。
**内容侧命令的实测要点(2026-09)**:
- **收藏的歌单不用另找接口**:`user/playlist` 一次给全(实测 36 张 = 自建 10 + 收藏别人的 26),
用 `subscribed === true` 区分。`playlist/sublist` 那条老路径是 404,别再用。
- **榜单 = 特殊歌单**:`toplist` 给 `{id,name,trackCount,updateFrequency}`,`id` 直接当歌单 id 放,不用单独写取数。
- **私人 FM**:`radio/get` 无视 `limit`,固定 3 首;要 12 首就调 4 次再去重。
- **播客的节目**:`byradio` 的 `mainSong.id` 与歌曲共用取地址接口(实测 `url` 有值、能放),
所以播客走的是**和歌曲完全一样**的队列构建路径,不用第二套播放逻辑。
- 失效路径(实测 404/400,别再试):`playlist/sublist`、`personal_fm`、`djradio/recommend`(旧)、
`dj/program/recommend`、`dj/program/recommend/v2`、`djradio/hot`。
登录态就是一个 `MUSIC_U` cookie,存 `local/skills/netease-music/config.json`(`cookie` 键,secret,不进共享仓库)。
cookie 失效时报 `error=auth`,重跑 `login`。
### 2.1 列表命令 / JSON 契约
下表列表命令的 `scripts/netease-api.mjs <命令> [参数] --limit N --offset N` 是只取数的原语,不建队、不播放。
`--offset` 是从 0 起的非负整数,默认 0;`--limit` 是本页最多取多少条。
先按原有顺序跳过 `offset` 条,再取最多 `limit` 条;查名字用的候选搜索始终从第一页找,不受内容页的 offset 影响。
翻过末尾时退出码 0,列表为 `[]`、`count` 为 0,已有的 `total` 不清零;负数、非整数或缺 offset 值报 `usage`(退出码 2)。
所有下表命令的 JSON 都有数值型 `count` 和 `total`:`count` 只数本次实际返回的行(详情缺失或无可播主歌曲的条目不算)。
`total` 优先用表中的接口总数;接口没提供总数时,用它本次返回、还没本地截取/过滤的条数,不能把这个回退值当成完整云端库大小。
服务端分页接口若只在第一页给总数,后续页会按同一回退规则返回;`total: 0` 是有效值,不当成缺失。
| 命令(默认 limit) | 列表字段 / offset 怎么用 | total 的来源(从左到右回退) |
|---|---|---|
| `playlist`(500)、`chart`(500) | `songs`;按完整 `trackIds` 本地截页,复用 `tracks` 并补查缺的详情,保留歌单顺序;没有 `trackIds` 时截 `tracks` | `playlist.trackCount` → 本次 detail 返回的 `tracks.length` |
| `liked`(1000) | `songs`;`trackIds[].at` 倒序后截页,再查详情 | 完整 `trackIds.length`(包括末尾空页) |
| `album`(200) | `songs`;整张专辑按原顺序本地截页 | `album.size` → `album.songCount` → `songs.length` |
| `artist`(50) | `songs`;`hotSongs` 本地截页,老接口 `artist/top/song` 的 `songs` 作回退 | 热门列表截取前的长度;不是歌手全部作品数 `musicSize` |
| `search`(20) | `songs`;搜歌曲(type=1),向接口传 `limit/offset` | `result.songCount` → 本次 `songs.length` |
| `playlists`(20) | `playlists`;搜歌单(type=1000),向 weapi 或明文回退接口传同一 `limit/offset` | `result.playlistCount` → 本次 `playlists.length` |
| `charts`(100) | `charts`;`toplist` 全表本地截页 | `total` → `count` → `list.length` |
| `myalbums`(60)、`myartists`(60) | `albums` / `artists`;向收藏接口传 `limit/offset` | `count` → `total` → 本次 `data.length` |
| `recent`(30) | `songs`;最近一周记录本地截页,再过滤无 `song` 的记录 | `total` → `count` → 原始记录条数 |
| `podcasts`(50) | `podcasts`;订阅和 `--recommend` 两个接口都忽略 offset,先取 `offset + limit` 条再本地截页 | `count` → `total` → 本次 `djRadios.length` |
| `podcast`(3) | `songs`;`dj/program/byradio` 传 `limit/offset`,最新节目在前 | `count` → `total` → 首个节目 `radio.programCount` → 本次 `programs.length` |
| `myplaylists`(100) | `playlists`;从头取再本地截页。实测 `user/playlist` 忽略 limit,非零 offset 还会多跳过置顶歌单,所以不用它的 offset | `total` → `count` → 本次 `playlist.length` |
| `albums`(60) | `albums`;歌手专辑接口传 `limit/offset` | `artist.albumSize` → `total` → 本次 `hotAlbums.length` |
| `daily`(40) | `songs`;每日推荐本地截页 | 本次推荐列表的完整长度 |
例如 500 首歌单翻过末尾,原语返回 `{"id":123,"name":"示例","count":0,"total":500,"songs":[]}`;
不能再拿 `count` 当整张歌单的大小,也不能只凭 `count < limit` 判断没有后续 ID(本页可能有下架曲目)。
入口 `pi-skill netease-music <命令> --limit N --offset N` 把 offset 转给原语;列表的人读格式不变,
`--json` 摘要额外带 `total`(沿用入口的字符串字段格式,完整数组仍由原语输出)。
`myplaylists collected/own` 仍在本页内筛选,摘要 `count` 是筛后行数,`total` 是未筛选的我的歌单总数。
播放类入口仍会把本页入队;只读翻页用 `myplaylists` / `search` / `charts` 等列举命令,或直接调取数原语。
### 2.2 歌曲封面 / 下载契约
原语的单曲对象(`song`)和所有歌曲列表对象(`songs[]`,含最近播放与播客的主歌曲)统一保留原有字段顺序和含义,追加 `cover`,再按可用性追加 `liked`:
| 字段 | 类型 / 含义 |
|---|---|
| `id` | 歌曲 ID |
| `name` | 歌名 |
| `artist` | 歌手名字符串,多人以 ` / ` 连接 |
| `album` | 专辑名字符串,没有则 `""` |
| `ms` | 时长(毫秒),没有则 0 |
| `vip` | 布尔值,`fee` 为 1 或 4 时为 true |
| `cover` | 封面 URL 字符串;依次取非空的 `al.picUrl` → `album.picUrl` → 歌曲顶层 `picUrl`,均缺失时为 `""` |
| `liked`(可选) | 接口歌曲对象的布尔 `liked` 原样透传(含 `false`);没有明确布尔值时省略。`liked` 命令返回的歌曲均为 `true` |
`recent` 的 `plays`、播客的 `programId/program` 等扩展字段仍保留;字段名统一为 `cover`。
原语列表不因缺封面失败,也不为补齐封面额外查询详情。
入口和原语都支持 `cover <歌曲关键词|id> [--download PATH] [--force]`:
- 关键词按 `song` 的规则取第一首搜索结果;纯数字直接查歌曲详情。关键词搜索没给封面时,再按选中歌曲的 ID 补查详情。
- 不带 `--download` 时,stdout 只输出一个 JSON 对象 `{ "id": 123, "name": "歌名", "artist": "歌手", "cover": "https://…" }`。
- 带 `--download PATH` 时,下载 HTTP(S) 图片,stdout 只输出 `{ "id": 123, "cover": "https://…", "file": "/绝对路径/cover.jpg", "bytes": 12345 }`。`file` 是 PATH 相对当前工作目录解析后的绝对路径;`bytes` 是实际保存的字节数(数字)。不添加扩展名,不改图片编码,不自动创建父目录。
- 始终输出上述 JSON,`--json` / `--quiet` 不改变它。命令只查歌曲和封面,不播放、不改队列、不保存 cookie、不触发登录;下载请求也不携带登录 cookie。
- 最多 **10 MB(10,000,000 字节,恰好上限允许)**。先检查 `Content-Length`,再逐块累计实际响应字节数;没有或虚报长度也不能绕过上限。图片请求最多等待 30 秒;明确的非 `image/*` Content-Type 会被拒绝。图片完整读入并通过检查后才写目标文件。
- PATH 已存在(包括符号链接)时默认报 `usage`,不会覆盖;仅显式 `--force` 允许覆盖。文件创建使用排他模式,因此并发创建也不会意外覆盖。`--force` 必须与 `--download` 一起使用。
- 失败时 stdout 无结果,stderr 为标准 `{ "error": "…", "hint": "…" }`:缺歌曲/封面、图片 HTTP 404/410 或空图片为 `notfound`(退出码 4);缺参数/路径或未授权覆盖为 `usage`(2);超限、非图片响应、其他 HTTP/网络/文件写入失败为 `external`(5);图片请求超时为 `timeout`(5)。下载或大小检查失败时不会创建或截断目标文件。
### 2.3 喜欢 ID 与私人 FM 不喜欢
- `node scripts/netease-api.mjs liked-ids` 或入口 `liked-ids --json` 返回 `{count:3,total:3,ids:[1000,1002,1004]}`;ID 是数字,全表不分页,空表正常返回 `[]`。入口人读输出仍为结论 + `count/ids/source/as_of`,`ids` 是逗号分隔字符串。
- 列表/搜索/详情没有 `liked` 时表示未知;调用方用 `liked-ids` 结果按 ID 判定。普通取数不额外调用喜欢列表;查询失败不会伪造 `liked:false`。喜欢接口返回 `301` 为 `auth`(3),其他拒绝或缺少 `ids` 为 `external`(5)。
- 入口 `dislike [歌曲id]` 可省略 ID,通过 mpv 的实际曲目身份取当前歌;原语 `dislike <歌曲id>` 必须给一个正整数。它提交 `fm/trash` 对应的 `radio/trash/add`,原语成功 JSON 为 `{id,disliked:true,code:200}`。不自动跳歌、不删队列;需要跳歌可再用 `next`。
- 无当前歌报 `notfound`(4);参数非法报 `usage`(2);服务端未登录 `301` 报 `auth`(3),其他非 `200` 报 `external`(5),不会打印成功。接口没有对应的 ban 状态读回,本操作以返回码确认接受,不声称另行验证云端黑名单。
## 3. 播放器(mpv)
常驻方式(`mpv-ctl.mjs` 自动拉起):
```
mpv --idle=yes --no-video --force-window=no --audio-display=no --no-terminal \
--input-ipc-server=<local>/mpv.sock --volume=70
```
- 控制走 socket 上的 **JSON IPC**:一行一个 `{"command":[...],"request_id":n}`,回包按 `request_id` 配对。
- 状态用 `get_property`:`idle-active / pause / time-pos / duration / volume / playlist-pos / media-title`。
- 队列交给 mpv 自己(`loadfile` + `loadlist`),自动连播、`playlist-next/prev`、`loop-playlist`、`shuffle` 都是原生能力,
**不需要守护进程**;`status` 里的曲名来自 m3u 的 `#EXTINF`。
- 音质与延迟:无损 flac 首播要下载几 MB,比 320k 慢一点点;网络卡就 `setup --quality exhigh`。
- 日志在 `local/skills/netease-music/mpv.log`(起不来先看它)。
**三种循环模式**:入口及 `mpv-ctl` 都接受 `repeat off|list|single`,`on` 和无参数兼容为 `list`。每次设置两个属性,清除上一模式:
| 模式 | `loop-playlist` | `loop-file` |
|---|---|---|
| `off` | `no` | `no` |
| `list` / `on` | `inf` | `no` |
| `single` | `no` | `inf` |
`status` 和控制后的状态回复追加 `repeat_mode: off|list|single`;底层 JSON 为字符串,入口人读输出为 `repeat_mode=...`。
既有 `repeat` 布尔字段保持「是否整列循环」的含义(入口 JSON 仍沿用字符串 `"true"/"false"`);`single` 时这个旧字段为 false。状态读取两个属性,若外部设置两者都为 `inf`,模式优先报 `single`。非法模式在 IPC 前报 `usage`(2)。
**批量播放**:`pi-skill netease-music play-ids <id,id,...> [--start N]`,例如 `play-ids 1000,1001,1002 --start 2`。
- ID 列表必须是**一个参数**,逗号分隔、不含空格或空项,均为正的安全整数;保留输入顺序和重复项。`--start` 默认 1,必须是输入列表范围内的 1 基序号。参数错误在请求/队列修改前报 `usage`(2)。
- 按 ID 批量查详情(每批最多 200),再取播放地址。任何 ID 的详情缺失,或 `--start` 指定歌曲无地址,报 `notfound`(4)并保留旧队列;其他无地址歌曲跳过,原输入序号换算为过滤后的位置,`skipped` 沿用原字段。
- 临时构建成功后自动保存上一条快照并替换队列,关闭随机播放;不支持 `--shuffle`。加载时先暂停,定位好再继续,避免先播第一首。
- 只取数原语 `node scripts/netease-api.mjs play-ids <id,id,...> [--start N]` 返回 `{count,total,start,songs:[...]}`,`start` 仍为输入列表的 1 基序号;它不连接 mpv,也不写队列。
**编辑队列**:`remove <index>` 按 `queue` 当前显示顺序使用 1 基序号,随机队列也如此。非当前项移除后继续原曲和进度;当前项移除时选下一首,原本在末尾则选上一首,只有一首时停止清空;保留暂停状态和循环模式。
入口根据 mpv 的实际条目重建 `queue.json / queue.m3u8 / songs.json / urls.txt`,在 IPC 成功后安装,避免重启已在播放的其他歌曲。非法序号报 `usage`(2)、越界报 `notfound`(4);条目无法映射时不修改队列。
`clear` 先停止,再清 mpv 列表,删除本地队列、地址及时间戳并收掉保活;配置与快照保留,空队列重复调用成功。移除/清空后回复仍回读 mpv 状态。
**队列真值**:`get_property playlist` 给整条队列(每项 `title` 来自 m3u 的 `#EXTINF`,`duration` **只探测正在放的那首**,
其余是 0);`playlist-pos` 是当前第几首(0-based)。
**但 shuffle 会重排 mpv 自己的播放顺序,`playlist-pos` 不再对应 `queue.json` 的序号**(实测:status 说在放
《野心家》、实际在放《All Of Me/Say Something》)。所以「哪首 / 第几首」一律按身份认,实现在
`scripts/lib/mpv-map.py`:m3u 标题写成 `[#<id>] 歌手 - 歌名`(`build-queue.py`/`queue-append.py` 都按这个写),
旧队列回退按条目 `filename` 对 `urls.txt`(去 query 后比路径),再回退按「歌手 - 歌名」串。据此:
- `queue`(入口命令)用 mpv 的 playlist 出真值,每条的时长/当前标记逐条按上面的身份规则配 `queue.json`(配不上宁缺勿错);
- `jump N`(N 从 1 数,指 `queue` 列表里的第 N 首)先按歌找到它在 mpv 队列里的实际序号,再 `set_property playlist-pos`;
队列没打乱时序号就是序号;打乱了又定位不到会明确报错,不静默跳错歌;
- `status` / `like` / `lyric` 的「当前歌曲」同样按身份认(`cur_song_json`),不按 `playlist-pos` 索引。
**队列现场(快照 / back / queue-load / add)**:一个 mpv 只有一条队列,任何「放 X」都是整列替换;
但**内容在网易云那边,替换队列不等于丢内容**。所以本技能的做法是:
- `build_queue` 是唯一的建队入口,它在新文件准备好、替换前自动把「当前这条」存成 `local/skills/netease-music/queues/prev.json`
(`SKIP_SNAPSHOT=1` 可跳过——回放快照时必须跳,否则会把要载入的那条覆盖掉);
- 快照**只存歌曲列表**(id/歌名/歌手/时长)+ 位置 + shuffle + 当时在放那首的 `song_id`,**不存播放地址**
(那种 URL 二十分钟就过期),回放时按列表重新取地址;开随机后位置没有意义,回放优先按 `song_id` 回到那首歌,
旧快照(没存 id)才退回位置;
- `back` 的语义是**交换**:当前这条 → prev,prev → 当前,所以连着 back 两次就是「切回去再切回来」;
- `queue-load <名字>` 载入命名快照,当前这条同样留成 prev;命名快照最多留 20 条(`snapshot.py _prune`);
- `add` 走 mpv 的 `loadlist <临时 m3u> append`(`mpv-ctl append`),同时把这一首追加进 `queue.json` 与 `queue.m3u8`,
保证 `queue` 命令的两边首数一致;开着 shuffle 时新歌仍是排在队尾、按随机顺序播到。
- `restore_snapshot` 里的 `play_queue` 输出要静音(`>/dev/null`),否则一条命令会打出两个结果块。
## 4. 踩过的坑
- **`$var` 后面紧跟中文一律写 `${var}`**:bash 会把中文字节的第一个字节当成变量名的一部分,`set -u` 下直接
`unbound variable`。实测 15 处(如 `"开始播放:$now_name - $now_artist(队列 …"`)让 `play` **成功出声却返回退出码 1**,
消费方(应用、脚本)都会以为播放失败。新增文案后自查:
`grep -nP '\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]' scripts/*.sh` 应当无输出。
- **`cycle pause` 在 mpv 里报 invalid parameter**,得用 `set_property pause`;`toggle` 是先 `get_property` 再取反。
- **bash heredoc 与 herestring 抢 stdin**:`python3 - <<'PY' <<<"$json"` 里 Python 读到的是 JSON 而不是脚本
(报 `name 'false' is not defined`)。传 JSON 一律走临时文件或 `python3 -c`。
- **`qrencode -t UTF8`** 能在终端画二维码;`-o x.png` 出图片(`open` 打开给用户扫)。
- 官方客户端与本技能互不干扰(各放各的),别指望它们共享播放状态。
- 搜索接口返回的 `fee`:`1`/`4` 表示要 VIP,`url` 接口会给 `null`,构建队列时会跳过并计入 `skipped`。
- 队列文件在 `local/skills/netease-music/`:`queue.json`(曲目+id+服务端档位,`status/like/lyric` 靠它反查)、`queue.m3u8`、`urls.txt`。
- **别无视返回码**:网易云用 HTTP 200 + `{code:-460}`/`{code:405}` 表达「拒绝」。第一版实现没看 code,
喜欢失败还报「已加入」——写命令一律**校验 code,有查询接口时在变更后回读真值**再回报(`cmd_like` 会回读 `like-state`;FM ban 无对应查询,以接受返回码确认)。
- **连打十几次会触发 405「操作频繁」**:`radio/like` 与 `song/like/get` 会整族限流几分钟(读接口不受影响)。
调参实验要隔开做;撞上就等冷却,别继续重试。
- **`quality=` 报的是服务端实际给的档位**(`url` 接口返回的 `level`),不是请求的档位:不登录(无 VIP)时请求
`lossless` 会拿到 `exhigh`。登录后同一首才是 `lossless`。
- **`play <纯数字>` 要回退**:歌曲 id 与歌单 id 都是纯数字,形状上分不出来——先当歌曲查,查不到再走歌单流程。
- **控制类命令(pause/next/volume/seek…)必须回读状态**:只回「已执行」等于没验证(早期版本就是这样,
让人怀疑命令没生效)。现在统一走 `emit_status` 打印 mpv 真值。
- **新登录的会话可能写不了「喜欢」**:`radio/like` 会回 `-460 检测到您的网络环境存在风险`(换 cookie、补 `_ntes_nuid`、
加浏览器头、走明文 `/api/radio/like` 都不行——是会话/网络层面的风控,不是请求形状)。
回退到 `playlist/manipulate/tracks` 直接改「我喜欢的音乐」歌单**是能通的**(另一套风控),代码里已自动回退。
pid 取 `user/playlist` 里 `specialType === 5` 那张;`502` 当幂等成功;读操作(搜索/推荐/歌单/歌词)全程不受风控影响。
**坑:`die` 在 `$( )` 里杀不掉父脚本。** 症状很吓人:接口报 `error=notfound` 之后脚本继续跑,
拿错误文本当 JSON 喂给 python(一串 traceback),最后**把旧队列重播一遍还报成功**。
规矩:凡是捕获接口输出的地方都写 `x="$(api_out …)" || exit $?`
(`api_out` = 「能让整个脚本停下来的 api_or_die」)。例外只有 `cmd_play` 里那句带 `2>/dev/null` 的容错分支。
**坑:空队列必须报错,不能报成功。** 歌全要 VIP 时 `build-queue.py` 照样把 `#EXTM3U` 头写进 m3u(文件非空!),
所以判据是 `queue.json` 里 `songs` 的条数,不是 m3u 的大小。
**坑:链接要先剥掉 `id=`。** 不剥的话,`play <专辑链接>` 会把整条 URL 当关键词搜,搜到一首毫不相关的歌还播起来。
`artist` / `album` / `albums` / `playlist` 统一走 `.replace(/.*[?&#/]id=/, '')`。
**代理:这个技能不依赖本机代理客户端。** 入口第一件事就是 unset `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY` 并设 `NO_PROXY=*`
(国内服务直连最快;sing-box 关着也照放)。验证用**死代理**,不用真代理:
`HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 pi-skill netease-music play "晴天"` 应当照常成功。
**坑:播放直链是限时签名 URL,约 5 分钟就过期(长队列会「在放但没声音」)。** 实测:同一个 id 现取的地址
`200` 能下 59 MB 完整文件,21:21 取的那条到 21:26 就 `403`(路径里的 14 位时间戳就是取用时刻,`vuutv`/`authSecret`
是绑「这首歌 + 这次生成 + 你的会话」的签名)。症状很误导人:`status` 说 `state=playing`、`pause=false`,
但 `time-pos` 取不到、`audio-codec-name` 是 None、`core-idle=true`,而且 mpv 在两个位置之间**疯狂跳曲**
(实测 4 秒里从第 162 首跳到 173 首)——看起来像"播放器坏了",其实是地址全 403。
所以**不要在建队时预取整条长队列的地址就完事**。现在的做法:
- 建队照旧预取(这样起播零等待),同时把取用时刻写进 `local/skills/netease-music/urls_at`;
- 入口在 `play_queue` 成功后排一个后台保活 `scripts/refresh-urls.sh`(`keepalive.pid` 单实例,`stop` 时收掉);
- 保活每 90 秒一轮,地址超过 4 分钟就把「当前这首之后还没播的」批量重取、**原地替换 m3u 里对应的行**
(mpv 是按需读下一首的,所以换 URL 不影响正在放的那首);
- 诊断一条命令:`mpv-ctl` 层看 `time-pos`/`core-idle`,比看 `state` 靠谱得多。
**本地调试请用 `NETEASE_COOKIE`。** 入口读 `PI_SKILL_NETEASE_MUSIC_COOKIE`,传给 API 层的是 `NETEASE_COOKIE`;
直接调 `node scripts/netease-api.mjs` 时用错名字会得到 `code=301` 的**假故障**(看着像掉登录)。
**坑:别拿 `PY` 当外层 heredoc 定界符。** 补丁脚本里嵌 python heredoc 时,内容里那句 `PY` 会把外层提前掐断,
补丁只落一半。定界符取个不会撞的名字(如 `PATCH_END`)。
## 5. 想加功能往哪加
- **新的取数**:在 `netease-api.mjs` 的 `HANDLERS` 里加一个函数(榜单/收藏/FM/最近/播客都已接好),
入口脚本里加一条命令转发;播放侧不用动。
- **新的播放动作**:mpv 原生就有的(倍速 `speed`、静音 `mute`、跳歌单第 n 首 `playlist-pos`)加在 `mpv-ctl.mjs` 的 switch 里。
- 加完跑 `pi-skill skillcheck netease-music` 和 `skill-contract update netease-music --apply`。
`scripts/lib/` 五个小工具:`build-queue.py`(歌曲列表+地址 → queue.json/m3u8)、
`snapshot.py`(队列快照的存/列/取)、`queue-append.py`(追加一首并写临时 m3u)、
`mpv-map.py`(mpv 队列/当前曲 ↔ queue.json 的身份映射,shuffle 下也准)、`queue-edit.py`(按 mpv 实际顺序准备删除后的本地文件)。
离线门禁(工作目录为本技能目录):`node --check scripts/netease-api.mjs`、`node --test tests/pagination.test.mjs`。
测试沿用 `tests/fixtures/api-fetch.mjs`,并加载 `mpv-ipc.mjs` 替代 socket;禁止真实播放器进程,后台保活也被替代,所有临时文件均在 `tests/` 下并在结束时清理。
加接口前先探:`node scripts/netease-api.mjs raw <路径> '{…}'`(**内部探针**,不在入口命令面里)——
路径以 `api/` 开头走明文 POST,否则走 weapi;返回什么原样打出来,看清结构再写 handler。
加 mpv 动作:`mpv-ctl.mjs` 的 switch 里加一个 `case`,往 `out` 里塞要回读的值(结尾统一 `JSON.stringify`)。
两个都加完跑 `skillcheck netease-music`,再 `skill-contract update netease-music --apply` 刷新契约。
## 6. 对外契约(CLI 消费者)
本技能目录同时是一个可独立使用的 CLI:入口 `scripts/netease-music.sh`,standalone 安装用
`scripts/install-cli.sh`(链接成 `netease-music` 并查 node/mpv/python3),发布用共享的
`skill-release cli|package`(声明在 interface.json 的 `publish`,见 skill-interface 的 REFERENCE §12)。**命名规范**:公开镜像仓叫 `<工具>-cli`(这里是
`netease-music-cli`),装出来的命令仍叫 `<工具>`(`netease-music`)——仓名标交付物类型,
命令名只管好敲。消费者(Emacs 包 etaf-ncm、其他脚本)只依赖本节与 `--json` 输出,
不读实现、不读状态文件。
- **版本**:`netease-music version` / `--version`(读 `VERSION`)。破坏性改动抬高 minor(0.x 期间),并在本节记录。
- **调用**:`netease-music <命令> [参数] [--json] [--quiet]`;完整命令面见 `netease-music help`。
- **JSON**:`--json` 时 stdout 是单个对象。列表命令的字段与分页语义见 §2.1;状态与队列如下表。
| 命令 | 字段 |
|---|---|
| `status --json` | `state`(idle\|playing\|paused) `title` `artist` `song_id` `elapsed` `duration` `volume` `queue_pos`("i/n") `shuffle` `repeat` `vip` `quality` `as_of` |
| `queue --json` | `count` `pos`(1 基) `songs[]`(歌曲对象同 §2.1) |
- **环境变量**:`NETEASE_MUSIC_STATE_DIR`(默认 `<PI_AGENT_DIR 或 ~/.pi/agent>/local/skills/netease-music`)、
`NETEASE_MUSIC_COOKIE`、`NETEASE_MUSIC_QUALITY`;旧名 `PI_SKILL_NETEASE_MUSIC_*` 仍兼容(新名优先)。
- **状态目录**(引擎独占写入):`config.json`(cookie/音质)、`queue.json`/`queue.m3u8`/`songs.json`(队列)、
`urls.txt`/`urls_at`(限时直链缓存)、`mpv.sock`(mpv IPC)。消费者不要直接读这些文件,
队列与状态一律走 `queue --json` / `status --json`。
- **退出码**:0 成功、2 用法、3 依赖缺失或未登录、4 找不到目标、5 播放或队列操作失败;失败时 stderr 给
`error=<码>` / `hint=<下一步>` 两行,消费者应原样透出 hint。

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.1.2

View File

@ -1,26 +1,5 @@
{ {
"name": "netease-music", "name": "netease-music",
"version": "0.1.6", "version": "0.1.2",
"description": "网易云音乐遥控器:一句话对上云里的内容(每日推荐 / 歌单 / 榜单 / 收藏 / FM / 最近 / 播客)并放出来;播放侧管好本机队列现场(快照 / back / 追加)", "description": "网易云音乐遥控器:一句话对上云里的内容(每日推荐 / 歌单 / 榜单 / 收藏 / FM / 最近 / 播客)并放出来;播放侧管好本机队列现场(快照 / back / 追加)"
"license": "GPL-3.0-or-later",
"keywords": [
"pi-package",
"netease-music",
"macos"
],
"repository": {
"type": "git",
"url": "https://gitea.vhkd.top/pi-hub/netease-music.git"
},
"files": [
"skills",
"LICENSE",
"README.md",
"MANIFEST.json"
],
"pi": {
"skills": [
"skills/netease-music"
]
}
} }

28
scripts/install-cli.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/sh
# standalone 安装:把本技能目录当纯 CLI 用(不需要 Pi)。
# 用法:scripts/install-cli.sh [--prefix DIR];默认 ~/.local。
set -eu
here="$(cd "$(dirname "$0")/.." && pwd)"
prefix="$HOME/.local"
while [ $# -gt 0 ]; do
case "$1" in
--prefix) prefix="$2"; shift 2 ;;
-h|--help) printf '用法:%s [--prefix DIR](默认 ~/.local;链接 <prefix>/bin/netease-music)\n' "$0"; exit 0 ;;
*) printf '未知参数:%s\n' "$1" >&2; exit 2 ;;
esac
done
bindir="$prefix/bin"
mkdir -p "$bindir"
ln -sfn "$here/scripts/netease-music.sh" "$bindir/netease-music"
printf 'linked: %s/netease-music -> %s/scripts/netease-music.sh\n' "$bindir" "$here"
missing=""
for tool in node mpv python3; do
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
done
[ -z "$missing" ] || printf '缺少依赖:%s(macOS: brew install node mpv python3)\n' "$missing"
case ":$PATH:" in
*":$bindir:"*) ;;
*) printf '提示:把 %s 加进 PATH,例如 export PATH="%s:$PATH"\n' "$bindir" "$bindir" ;;
esac
printf '下一步:netease-music check(自检);未登录再跑 netease-music login(手机扫码)\n'
printf '状态目录默认 ~/.pi/agent/local/skills/netease-music;不用 Pi 时建议 export NETEASE_MUSIC_STATE_DIR="$HOME/.local/state/netease-music"\n'

View File

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""build-queue — 把歌曲列表 + 播放地址合成 mpv 队列(内部原语,被 netease-music.sh 调用)
用法: build-queue.py <songs.json> <urls.txt> <queue.json> <queue.m3u8> [start,从 1 开始]
songs.json: [{"id","name","artist","ms"}, ...]
urls.txt: 每行 "<id>\t<档位>\t<播放地址>"(取不到地址的歌不写进来)
输出: "picked skipped" 一行;queue.json 按 m3u 顺序存曲目,供 status/like/lyric 反查。
**为什么 m3u 里写的是 URL 而不是 id**:网易云的直链是限时签名 URL,实测取出来约 **5 分钟**就 403。
所以长队列不能靠预取地址(放到后半段全部失效,听感是「歌还在放但没声音」)。
这里的做法是:m3u 里写的仍是取地址那一刻的 URL(保证刚建队就能立刻出声、零等待),
同时 queue.json 记下这批地址的取用时刻;播到过期地址时由入口的 keepalive 重取(见 netease-music.sh)。
"""
import json
import sys
import time
def main():
songs_path, urls_path, queue_path, m3u_path = sys.argv[1:5]
songs = json.load(open(songs_path, encoding="utf-8"))
urls = {}
levels = {}
for line in open(urls_path, encoding="utf-8"):
line = line.strip()
if not line:
continue
parts = line.split("\t")
sid, level, url = (parts[0], parts[1], parts[2]) if len(parts) == 3 else (parts[0], "", parts[-1])
if url:
urls[sid] = url
levels[sid] = level
picked = [s for s in songs if str(s["id"]) in urls]
start = None
if len(sys.argv) > 5:
requested = int(sys.argv[5]) - 1
if not 0 <= requested < len(songs) or str(songs[requested]["id"]) not in urls:
print('error=notfound\nhint=--start 指定的歌曲不可播,队列未替换', file=sys.stderr)
return 4
start = 1 + sum(str(s["id"]) in urls for s in songs[:requested])
for s in picked:
s["level"] = levels.get(str(s["id"]), "") # 服务端实际给的档位(VIP 歌可能被降档)
with open(queue_path, "w", encoding="utf-8") as f:
json.dump(
{
"songs": picked,
"skipped": len(songs) - len(picked),
"as_of": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
**({"start": start} if start is not None else {}),
},
f,
ensure_ascii=False,
indent=1,
)
with open(m3u_path, "w", encoding="utf-8") as f:
f.write("#EXTM3U\n")
for s in picked:
# 标题前缀 [#id]:mpv 开了 shuffle 后顺序和 queue.json 不一致,
# 靠它把「mpv 在放/队列第几项」映射回歌(见 scripts/lib/mpv-map.py)。
f.write("#EXTINF:%d,[#%s] %s - %s\n%s\n" % ((s.get("ms") or 0) // 1000, s["id"], s["artist"], s["name"], urls[str(s["id"])]))
print(len(picked), len(songs) - len(picked))
return 0
if __name__ == "__main__":
if len(sys.argv) < 5 or sys.argv[1] in ("-h", "--help"):
print(__doc__)
sys.exit(0 if len(sys.argv) > 1 else 2)
sys.exit(main())

145
scripts/lib/mpv-map.py Normal file
View File

@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""mpv-map — 把 mpv 的「当前在放哪首 / 队列第几项」映射回 queue.json 里的歌。
为什么需要它:mpv 开着 shuffle 时播放顺序是它自己排的,`playlist-pos`
指的是那条打乱过的顺序,直接拿去索引 queue.json 会报错歌(实测:
status 说在放《野心家》,实际在放《All Of Me/Say Something》)。
每首歌在队列里有两种稳定身份,建队时都会写进去:
1. m3u8 的 #EXTINF 标题写成 "[#<id>] <歌手> - <歌名>",mpv 的 media-title / 条目 title 原样带回;
2. 条目 filename 是取地址那一刻的 URL,可对照 urls.txt(<id>\\t<档位>\\t<url>,按去 query 的路径比)。
旧格式标题(没有 [#id])依次用 URL、标题串「歌手 - 歌名」回退;都对不上退出 3。
用法:
mpv-map.py now <queue.json> <urls.txt> <status.json> # → 当前歌曲 JSON(mpv status 的输出)
mpv-map.py index <queue.json> <urls.txt> <title> <path> # → 当前歌在 queue.json 里的 0 基序号
mpv-map.py find <queue.json> <urls.txt> <song_id> <playlist.json> # → 该歌在 mpv 队列里的 1 基序号
"""
import json
import re
import sys
ID_RE = re.compile(r'^\[#(\d+)\]\s*')
def load_json(path):
with open(path, encoding='utf-8') as f:
return json.load(f)
def load_urls(path):
m = {}
try:
f = open(path, encoding='utf-8')
except OSError:
return m
with f:
for line in f:
parts = line.rstrip('\n').split('\t')
if len(parts) >= 2 and parts[-1]:
m[parts[0]] = parts[-1]
return m
def strip_query(url):
return (url or '').split('?')[0]
def id_of_title(title):
m = ID_RE.match(title or '')
return m.group(1) if m else ''
def id_of_url(url, urls):
if not url:
return ''
u = strip_query(url)
for sid, u2 in urls.items():
if strip_query(u2) == u:
return sid
return ''
def display_title(title):
return ID_RE.sub('', title or '')
def resolve(songs, urls, title, path):
"""按标题里的 [#id] → URL → 「歌手 - 歌名」的顺序找回 queue.json 里的歌。"""
sid = id_of_title(title)
if not sid:
sid = id_of_url(path, urls)
if sid:
for s in songs:
if str(s.get('id')) == str(sid):
return s
t = (title or '').strip()
if t:
for s in songs:
if t == '%s - %s' % (s.get('artist') or '', s.get('name') or ''):
return s
return None
def cmd_now(queue_path, urls_path, status_path):
st = load_json(status_path)
songs = (load_json(queue_path).get('songs') or [])
s = resolve(songs, load_urls(urls_path), st.get('title'), st.get('path'))
if not s:
return 3
print(json.dumps(s, ensure_ascii=False))
return 0
def cmd_index(queue_path, urls_path, title, path):
songs = (load_json(queue_path).get('songs') or [])
s = resolve(songs, load_urls(urls_path), title, path)
if not s:
return 3
print(songs.index(s))
return 0
def cmd_find(queue_path, urls_path, song_id, playlist_path):
songs = (load_json(queue_path).get('songs') or [])
urls = load_urls(urls_path)
entries = load_json(playlist_path).get('entries') or []
target = str(song_id)
for e in entries:
sid = id_of_title(e.get('title')) or id_of_url(e.get('filename'), urls)
if sid and str(sid) == target:
print(e.get('index'))
return 0
# 标题是旧格式时,再用「歌手 - 歌名」对一次
for s in songs:
if str(s.get('id')) != target:
continue
want = '%s - %s' % (s.get('artist') or '', s.get('name') or '')
for e in entries:
if (e.get('title') or '').strip() == want:
print(e.get('index'))
return 0
return 3
def main():
if len(sys.argv) < 3:
print(__doc__.strip().splitlines()[0], file=sys.stderr)
return 2
cmd = sys.argv[1]
args = sys.argv[2:]
try:
if cmd == 'now' and len(args) == 3:
return cmd_now(*args)
if cmd == 'index' and len(args) == 4:
return cmd_index(*args)
if cmd == 'find' and len(args) == 4:
return cmd_find(*args)
except (OSError, ValueError, KeyError):
return 3
print('用法见脚本 docstring', file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""把一首歌追加进队列文件,并写出给 mpv 用的「只有这一首」的临时 m3u。
参数:queue.json queue.m3u8 tmp.m3u id 歌名 歌手 毫秒 url
输出(JSON):{"ok": true, "count": N, "name", "artist", "id"} 或 {"ok": false, "reason": "duplicate", "count": N}
"""
import json
import sys
def main():
if len(sys.argv) < 9:
print('用法:queue-append.py <queue.json> <queue.m3u8> <tmp.m3u> <id> <歌名> <歌手> <毫秒> <url>', file=sys.stderr)
return 2
qpath, mpath, tpath, sid, name, artist, ms, url = sys.argv[1:9]
q = json.load(open(qpath, encoding='utf-8'))
songs = q.get('songs') or []
new = {'id': int(sid), 'name': name, 'artist': artist, 'ms': int(ms or 0)}
if any(s.get('id') == new['id'] for s in songs):
print(json.dumps({'ok': False, 'reason': 'duplicate', 'count': len(songs)}, ensure_ascii=False))
return 0
songs.append(new)
q['songs'] = songs
q['count'] = len(songs)
with open(qpath, 'w', encoding='utf-8') as f:
json.dump(q, f, ensure_ascii=False)
# 标题前缀 [#id] 与 build-queue.py 一致,供 mpv-map.py 映射回歌曲
entry = "#EXTINF:%d,[#%s] %s - %s\n%s\n" % ((new['ms'] or 0) // 1000, new['id'], artist, name, url)
with open(mpath, 'a', encoding='utf-8') as f:
f.write(entry)
with open(tpath, 'w', encoding='utf-8') as f:
f.write('#EXTM3U\n' + entry)
print(json.dumps({'ok': True, 'count': len(songs), 'name': name, 'artist': artist, 'id': new['id']},
ensure_ascii=False))
return 0
if __name__ == '__main__':
sys.exit(main())

55
scripts/lib/queue-edit.py Normal file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""准备 remove 的本地队列文件;按 mpv 的实际顺序映射,IPC 成功后才由入口安装。
用法:queue-edit.py <local-dir> <playlist.json> <1-based index> <stage-dir>
"""
import importlib.util
import json
from pathlib import Path
import sys
import time
spec = importlib.util.spec_from_file_location('mpv_map', Path(__file__).with_name('mpv-map.py'))
mpv_map = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mpv_map)
def main():
local, playlist, index, stage = sys.argv[1:]
local, stage = Path(local), Path(stage)
entries = json.loads(Path(playlist).read_text())['entries']
index = int(index) - 1
if not 0 <= index < len(entries):
print('error=notfound\nhint=队列里没有这个序号', file=sys.stderr)
return 4
queue = json.loads((local / 'queue.json').read_text())
urls = mpv_map.load_urls(local / 'urls.txt')
songs, lines, url_lines = [], ['#EXTM3U'], []
for i, entry in enumerate(entries):
if i == index:
continue
song = mpv_map.resolve(queue['songs'], urls, entry['title'], entry['filename'])
if not song or not entry['filename']:
print('error=notfound\nhint=mpv 条目无法映射到本地歌曲,队列未修改', file=sys.stderr)
return 4
songs.append(song)
lines.extend([
'#EXTINF:%d,[#%s] %s - %s' % ((song.get('ms') or 0) // 1000, song['id'], song['artist'], song['name']),
entry['filename'],
])
url_lines.append('%s\t%s\t%s' % (song['id'], song.get('level', ''), entry['filename']))
queue.update(songs=songs, count=len(songs), as_of=time.strftime('%Y-%m-%dT%H:%M:%S%z'))
queue.pop('start', None)
(stage / 'queue.json').write_text(json.dumps(queue, ensure_ascii=False), encoding='utf-8')
(stage / 'songs.json').write_text(json.dumps(songs, ensure_ascii=False), encoding='utf-8')
(stage / 'queue.m3u8').write_text('\n'.join(lines) + '\n', encoding='utf-8')
(stage / 'urls.txt').write_text('\n'.join(url_lines) + ('\n' if url_lines else ''), encoding='utf-8')
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except (OSError, ValueError, KeyError) as error:
print('error=external\nhint=无法编辑本地队列:%s' % error, file=sys.stderr)
sys.exit(5)

129
scripts/lib/snapshot.py Normal file
View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""队列快照:把「当前队列」存成可回放的文件。
只存歌曲列表(id/歌名/歌手/时长),不存播放地址 —— 那种 URL 二十分钟就过期。
回放时按列表重新取地址,所以快照可以长期留着。
用法:
snapshot.py save <queue.json> <快照文件> <位置> <shuffle:true|false> [名字]
snapshot.py list <快照目录> 列出全部快照(JSON 数组)
snapshot.py songs <快照文件> 只打印歌曲列表(喂给 build_queue)
snapshot.py meta <快照文件> 打印元信息(名字/首数/位置/shuffle/来源/时间)
"""
import datetime
import json
import os
import sys
KEEP_NAMED = 20 # 命名快照最多留 20 条,多的按时间删最旧(prev.json 不受影响)
def _now():
return datetime.datetime.now().astimezone().isoformat(timespec='seconds')
def cmd_save(queue_path, out_path, pos, shuffle, name='', song_id=''):
try:
q = json.load(open(queue_path, encoding='utf-8'))
except Exception:
return 2
songs = q.get('songs') or []
if not songs:
return 2
out = {
'name': name or q.get('source') or 'unnamed',
'saved_at': _now(),
'source': q.get('source') or '',
'pos': int(pos or -1),
'shuffle': str(shuffle).lower() == 'true',
# 开随机后 pos 是打乱顺序里的位置,回放靠 song_id 认「当时在放的那首」
'song_id': int(song_id) if str(song_id).strip().isdigit() else None,
'count': len(songs),
'songs': songs,
}
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(out, f, ensure_ascii=False)
_prune(os.path.dirname(out_path))
print('%s\t%d\t%s' % (out['name'], out['count'], out['saved_at']))
return 0
def _prune(d):
named = []
for fn in os.listdir(d):
if not fn.endswith('.json') or fn.startswith('.') or fn == 'prev.json':
continue
try:
meta = json.load(open(os.path.join(d, fn), encoding='utf-8'))
except Exception:
continue
named.append((meta.get('saved_at') or '', fn))
named.sort()
for _, fn in named[:-KEEP_NAMED] if len(named) > KEEP_NAMED else []:
try:
os.remove(os.path.join(d, fn))
except OSError:
pass
def cmd_list(d):
rows = []
for fn in sorted(os.listdir(d)) if os.path.isdir(d) else []:
if not fn.endswith('.json') or fn.startswith('.'):
continue
try:
meta = json.load(open(os.path.join(d, fn), encoding='utf-8'))
except Exception:
continue
rows.append({
'file': fn,
'name': meta.get('name') or fn[:-5],
'count': meta.get('count') or len(meta.get('songs') or []),
'saved_at': meta.get('saved_at') or '',
'source': meta.get('source') or '',
'pos': meta.get('pos', -1),
})
rows.sort(key=lambda r: (r['file'] != 'prev.json', r['saved_at']), reverse=False)
json.dump(rows, sys.stdout, ensure_ascii=False)
return 0
def cmd_songs(p):
try:
d = json.load(open(p, encoding='utf-8'))
except Exception:
return 4
json.dump(d.get('songs') or [], sys.stdout, ensure_ascii=False)
return 0
def cmd_meta(p):
try:
d = json.load(open(p, encoding='utf-8'))
except Exception:
return 4
json.dump({k: d.get(k) for k in ('name', 'count', 'pos', 'shuffle', 'song_id', 'source', 'saved_at')},
sys.stdout, ensure_ascii=False)
return 0
def main():
if len(sys.argv) < 3:
print(__doc__.strip().splitlines()[0], file=sys.stderr)
return 2
cmd, args = sys.argv[1], sys.argv[2:]
if cmd == 'save':
return cmd_save(*(args + ['', '', '', '', ''])[:6])
if cmd == 'list':
return cmd_list(args[0])
if cmd == 'songs':
return cmd_songs(args[0])
if cmd == 'meta':
return cmd_meta(args[0])
print('未知子命令:%s' % cmd, file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())

309
scripts/mpv-ctl.mjs Normal file
View File

@ -0,0 +1,309 @@
#!/usr/bin/env node
// mpv-ctl — 后台 mpv 的生命周期与 IPC 控制(网易云播放的「播放器层」)
//
// 为什么这样:mpv 常驻(--idle=yes)+ unix socket 上跑 JSON IPC,
// 所以播放/暂停/切歌/音量都是毫秒级命令,状态(时间、进度、曲名)也是真值,
// 不再靠截屏看进度条猜。mpv 自己会接着放歌单,不需要守护进程。
//
// 输出:JSON(内部原语);退出码 0 成功 / 2 用法 / 3 缺依赖 / 5 执行失败
// 用法:
// mpv-ctl play <m3u|url> [--title T] [--index N] 建队列并开播
// mpv-ctl status 当前状态(真值)
// mpv-ctl pause|resume|toggle|next|prev|stop
// mpv-ctl volume [0-130] | seek <秒|+n|-n> | shuffle on|off | repeat off|list|single (on=list)
// mpv-ctl append <m3u|url> | remove <1-based index> | clear
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
const DIR = process.env.NETEASE_MPV_DIR || process.env.NETEASE_MUSIC_STATE_DIR
|| path.join(process.env.PI_AGENT_DIR || path.join(os.homedir(), '.pi/agent'), 'local', 'skills', 'netease-music');
const SOCK = path.join(DIR, 'mpv.sock');
const LOG = path.join(DIR, 'mpv.log');
fs.mkdirSync(DIR, { recursive: true });
const die = (code, hint, rc) => {
console.error(JSON.stringify({ error: code, hint }));
process.exit(rc);
};
function connect(timeoutMs = 1500) {
return new Promise((resolve) => {
const s = net.connect(SOCK);
const t = setTimeout(() => {
s.destroy();
resolve(null);
}, timeoutMs);
s.once('connect', () => {
clearTimeout(t);
resolve(s);
});
s.once('error', () => {
clearTimeout(t);
s.destroy();
resolve(null);
});
});
}
function makeClient(sock) {
let buf = '';
let id = 0;
const pending = new Map();
sock.on('data', (d) => {
buf += d.toString();
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.request_id && pending.has(msg.request_id)) {
const { resolve } = pending.get(msg.request_id);
pending.delete(msg.request_id);
resolve(msg.error === 'success' || msg.error === undefined ? msg.data : { __error: msg.error });
}
}
});
const send = (command, timeoutMs = 4000) =>
new Promise((resolve) => {
const rid = ++id;
const t = setTimeout(() => {
pending.delete(rid);
resolve({ __error: 'timeout' });
}, timeoutMs);
pending.set(rid, {
resolve: (v) => {
clearTimeout(t);
resolve(v);
},
});
sock.write(JSON.stringify({ command, request_id: rid }) + '\n');
});
return { send, close: () => sock.end() };
}
async function ensureMpv() {
let sock = await connect(300);
if (sock) return makeClient(sock);
const out = fs.openSync(LOG, 'a');
const child = spawn(
'mpv',
['--idle=yes', '--no-video', '--force-window=no', '--audio-display=no', '--no-terminal', `--input-ipc-server=${SOCK}`, '--volume=70'],
{ detached: true, stdio: ['ignore', out, out] },
);
child.unref();
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250));
sock = await connect(300);
if (sock) return makeClient(sock);
}
die('deps', `mpv 没起来(日志 ${LOG}):确认 brew install mpv 成功`, 3);
}
const argv = process.argv.slice(2);
const CMD = argv[0];
const opts = {};
const pos = [];
for (let i = 1; i < argv.length; i++) {
if (argv[i] === '--title') opts.title = argv[++i];
else if (argv[i] === '--index') opts.index = +argv[++i];
else pos.push(argv[i]);
}
const GOOD = {
pause: ['set_property', 'pause', true],
resume: ['set_property', 'pause', false],
};
async function main() {
const repeatMode = (pos[0] || 'on') === 'on' ? 'list' : pos[0];
if (CMD === 'repeat' && (pos.length > 1 || !['off', 'list', 'single'].includes(repeatMode))) {
die('usage', '用法:repeat off|list|single(on = list)', 2);
}
if (CMD === 'remove' && (pos.length !== 1 || !/^\d+$/.test(pos[0]) || !Number.isSafeInteger(+pos[0]) || +pos[0] < 1)) {
die('usage', '用法:remove <队列序号,从 1 开始>', 2);
}
if (CMD === 'clear' && pos.length) die('usage', '用法:clear', 2);
if (CMD === 'play' && opts.index !== undefined && (!Number.isSafeInteger(opts.index) || opts.index < 0)) {
die('usage', '--index 需要从 0 开始的序号', 2);
}
const c = await ensureMpv();
const get = (p) => c.send(['get_property', p]);
const checked = async (command) => {
const result = await c.send(command);
if (result && result.__error) die('external', `mpv 报错:${result.__error}`, 5);
return result;
};
let out = { command: CMD };
switch (CMD) {
case 'play': {
const target = pos[0];
if (!target) die('usage', '用法:mpv-ctl play <m3u|url> [--title T] [--index N]', 2);
const isUrl = /^https?:/.test(target);
const isList = /\.(m3u8?|pls)$/i.test(target);
const cmd = isUrl || !isList ? ['loadfile', target, 'replace'] : ['loadlist', target, 'replace'];
if (isUrl && opts.title) cmd.splice(2, 0, `--force-media-title=${opts.title}`);
// Pause first: a load starts the first entry immediately, so an indexed
// start (or any later adjustment) would leak a fraction of the wrong
// song as a click. Silence the swap, position, then resume.
await checked(['set_property', 'pause', true]);
const r = await c.send(cmd);
if (r && r.__error) die('external', `mpv 拒绝播放:${r.__error}`, 5);
if (opts.index !== undefined) await checked(['set_property', 'playlist-pos', opts.index]);
await checked(['set_property', 'pause', false]);
out.playing = true;
break;
}
case 'append': {
const target = pos[0];
if (!target) die('usage', '用法:mpv-ctl append <m3u|url>', 2);
const isUrl = /^https?:/.test(target);
const r = await c.send(isUrl ? ['loadfile', target, 'append'] : ['loadlist', target, 'append']);
if (r && r.__error) die('external', `mpv 拒绝追加:${r.__error}`, 5);
out.count = await get('playlist-count');
break;
}
case 'pause':
case 'resume':
case 'toggle': {
let cmd = GOOD[CMD];
if (CMD === 'toggle') cmd = ['set_property', 'pause', !(await c.send(['get_property', 'pause']))];
const r = await c.send(cmd);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
out.paused = await get('pause');
break;
}
case 'next':
case 'prev': {
const r = await c.send([CMD === 'next' ? 'playlist-next' : 'playlist-prev', 'force']);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
break;
}
case 'stop':
case 'clear': {
await checked(['stop']);
await checked(['playlist-clear']);
out.stopped = true;
break;
}
case 'remove': {
const index = +pos[0] - 1;
const [count, current, paused] = await Promise.all([get('playlist-count'), get('playlist-pos'), get('pause')]);
if (!Number.isInteger(count) || index >= count) die('notfound', `队列里没有第 ${pos[0]} 首`, 4);
if (index === current) {
if (count === 1) {
await checked(['stop']);
await checked(['playlist-clear']);
} else {
// 先选下一首,末尾则选上一首;不受循环模式影响,并保留暂停状态。
await checked(['set_property', 'playlist-pos', index + 1 < count ? index + 1 : index - 1]);
await checked(['playlist-remove', index]);
await checked(['set_property', 'pause', paused === true]);
}
} else await checked(['playlist-remove', index]);
out.removed = index + 1;
out.count = await get('playlist-count');
break;
}
case 'seek': {
const v = pos[0] ?? '0';
const r = await c.send(['seek', v, 'relative']);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
break;
}
case 'volume': {
if (pos[0]) {
await c.send(['set_property', 'volume', Number(pos[0])]);
}
out.volume = await get('volume');
break;
}
case 'shuffle': {
const on = (pos[0] || 'on') !== 'off';
await checked(['set_property', 'shuffle', on]);
out.shuffle = on;
break;
}
case 'repeat': {
// 每次同时设置两个属性,避免从 single 切换后仍单曲循环。
await checked(['set_property', 'loop-file', repeatMode === 'single' ? 'inf' : 'no']);
await checked(['set_property', 'loop-playlist', repeatMode === 'list' ? 'inf' : 'no']);
out.repeat = repeatMode === 'list'; // 保留原字段含义:是否整列循环。
out.repeat_mode = repeatMode;
break;
}
case 'playlist': {
const [ppos, list] = await Promise.all([get('playlist-pos'), get('playlist')]);
const entries = Array.isArray(list) ? list : [];
out = {
command: 'playlist',
pos: Number.isInteger(ppos) ? ppos : -1,
count: entries.length,
entries: entries.map((e, i) => ({
index: i + 1,
title: typeof e.title === 'string' ? e.title : '',
filename: typeof e.filename === 'string' ? e.filename : '',
duration: Math.round(e.duration || 0),
})),
};
break;
}
case 'jump': {
const n = Number(pos[0]);
if (!Number.isInteger(n) || n < 1) die('usage', '用法:mpv-ctl jump <队列第几首,从 1 开始>', 2);
const count = await get('playlist-count');
if (!Number.isFinite(count) || count < 1) die('notfound', '队列是空的(先 play/daily/liked/artist/album 建队列)', 4);
if (n > count) die('notfound', `队列只有 ${count} 首,没有第 ${n} 首`, 4);
const r = await c.send(['set_property', 'playlist-pos', n - 1]);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
await c.send(['set_property', 'pause', false]);
out = { command: 'jump', jumped: n, count };
break;
}
case 'status': {
const [idle, paused, tpos, dur, vol, ppos, pcount, title, path_, loop, fileLoop, shuffle] = await Promise.all([
get('idle-active'),
get('pause'),
get('time-pos'),
get('duration'),
get('volume'),
get('playlist-pos'),
get('playlist-count'),
get('media-title'),
get('path'),
get('loop-playlist'),
get('loop-file'),
get('shuffle'),
]);
out = {
command: 'status',
idle: idle === true,
playing: idle === false && paused === false,
paused: paused === true,
pos: Number.isFinite(ppos) ? ppos : -1,
count: Number.isFinite(pcount) ? pcount : 0,
elapsed: Number.isFinite(tpos) ? Math.round(tpos) : null,
duration: Number.isFinite(dur) ? Math.round(dur) : null,
volume: Number.isFinite(vol) ? vol : null,
title: typeof title === 'string' ? title : '',
path: typeof path_ === 'string' ? path_ : '',
repeat: loop === 'inf',
repeat_mode: fileLoop === 'inf' ? 'single' : loop === 'inf' ? 'list' : 'off',
shuffle: shuffle === true,
};
break;
}
default:
die('usage', `未知命令 ${CMD || '(空)'}:play/append/status/playlist/jump/remove/clear/pause/resume/toggle/next/prev/stop/volume/seek/shuffle/repeat`, 2);
}
console.log(JSON.stringify(out));
c.close();
}
main().catch((e) => die('internal', String(e && e.message ? e.message : e), 5));

747
scripts/netease-api.mjs Normal file
View File

@ -0,0 +1,747 @@
#!/usr/bin/env node
// netease-api — 网易云接口客户端(自包含,零 npm 依赖)
//
// 原理:网页端所有请求都走 /weapi/<路径>,参数用「AES-128-CBC 两次 + RSA 加密密钥」包装,
// 这套加密是固定常量(见下方 NONCE/IV/MODULUS),所以不需要跑任何 API 服务,直接调。
// 登录态就是 MUSIC_U cookie,由调用方从环境变量 NETEASE_COOKIE 传进来。
//
// 输出:统一 JSON 到 stdout(内部原语,不给人读);出错时 status 非 0 + {"error":..,"hint":..}
// 用法:node netease-api.mjs <命令> [参数]
// 列表命令支持 --offset N(默认 0):跳过 N 条;count 是本页条数,total 是分页前总数
// search <关键词> [--limit 20] 搜歌
// song <关键词|id> 解析成一首歌(关键词取最匹配一条)
// play-ids <id,id,...> [--start N] 批量详情(只取数);start 从 1 开始,默认 1
// cover <关键词|id> [--download PATH] [--force] 封面 JSON;可下载图片(最多 10 MB)
// url <id...> [--level lossless] 取播放地址(有 VIP 就取无损)
// daily [--limit 40] 每日推荐
// playlist <id|链接> [--limit 500] 歌单全部歌曲
// artist <关键词|id> [--limit 50] 歌手热门 50 首
// album <关键词|id> [--limit 200] 专辑整张
// albums <歌手|id> [--limit 60] 歌手的专辑列表(含搜索索引里没有的老专辑)
// playlists <关键词> [--limit 20] 搜歌单(别人做的)
// charts [--limit 100] 排行榜列表
// chart <名字|id> [--limit 500] 放某个榜单
// myalbums / myartists [--limit 60] 我收藏的专辑 / 歌手
// fm [--limit 12] 私人 FM
// recent [--limit 30] 最近播放(一周)
// podcasts [--recommend] [--limit 50] 我的播客订阅(或推荐)
// podcast <名字|id> [--limit 3] 某播客最新 N 期
// episode <节目id> 某一期节目
// liked [--limit 1000] 我喜欢的音乐(按收藏时间由近到远)
// liked-ids 我喜欢的歌曲 ID 全表(只读)
// dislike <id> 私人 FM 垃圾桶(fm/trash)
// like <id> [--unlike] 喜欢/取消喜欢
// lyric <id> [--trans] 歌词(--trans 带翻译)
// myplaylists [--limit 100] 我的歌单(含「我喜欢的音乐」)
// session 登录态与账号信息
// uid 当前账号 uid(未登录报错)
// qr-start 生成扫码登录的二维码内容与 key
// qr-poll <key> [--timeout 120] 轮询扫码结果,成功时输出 cookie
import crypto from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const NONCE = '0CoJUm6Qyw8W8jud';
const IV = '0102030405060708';
const PUBKEY = '010001';
const MODULUS =
'00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7';
const UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
const COOKIE = process.env.NETEASE_COOKIE || '';
function aesEncrypt(text, key) {
const c = crypto.createCipheriv('aes-128-cbc', Buffer.from(key), Buffer.from(IV));
return Buffer.concat([c.update(text, 'utf8'), c.final()]).toString('base64');
}
function modPow(b, e, m) {
let r = 1n;
b %= m;
while (e > 0n) {
if (e & 1n) r = (r * b) % m;
b = (b * b) % m;
e >>= 1n;
}
return r;
}
function rsaEncrypt(text) {
const hex = Buffer.from(text).reverse().toString('hex');
return modPow(BigInt('0x' + hex), BigInt('0x' + PUBKEY), BigInt('0x' + MODULUS))
.toString(16)
.padStart(256, '0');
}
function cookieValue(name) {
const m = (COOKIE || '').match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
return m ? m[1] : '';
}
function weapi(data) {
const secret = crypto.randomBytes(8).toString('hex').slice(0, 16);
const plain = JSON.stringify({ ...data, csrf_token: cookieValue('__csrf') });
return {
params: aesEncrypt(aesEncrypt(plain, NONCE), secret),
encSecKey: rsaEncrypt(secret),
};
}
class ApiError extends Error {
constructor(code, hint) {
super(hint);
this.code = code;
this.hint = hint;
}
}
async function plainPost(path, data) {
let res;
try {
res = await fetch('https://music.163.com/' + path, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Referer: 'https://music.163.com/',
'User-Agent': UA,
...(COOKIE ? { Cookie: COOKIE } : {}),
},
body: new URLSearchParams(data).toString(),
});
} catch (e) {
throw new ApiError('external', '连不上 music.163.com:' + e.message);
}
lastSetCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : [];
const text = await res.text();
if (!text) throw new ApiError('external', `接口返回空(HTTP ${res.status})`);
return JSON.parse(text);
}
let lastSetCookie = [];
// 登录会分多步下发 Set-Cookie,同名会有多条;按名字去重(取最后一条),避免服务端看到重复 cookie
function jarToCookie(jar) {
const map = new Map();
for (const c of jar) {
const kv = c.split(';')[0];
const i = kv.indexOf('=');
if (i > 0) map.set(kv.slice(0, i).trim(), kv.slice(i + 1));
}
return [...map].map(([k, v]) => `${k}=${v}`).join('; ');
}
async function weapiPost(path, data = {}) {
const body = new URLSearchParams(weapi(data)).toString();
let res;
try {
res = await fetch('https://music.163.com/weapi/' + path + '?csrf_token=' + encodeURIComponent(cookieValue('__csrf')), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Referer: 'https://music.163.com/',
'User-Agent': UA,
...(COOKIE ? { Cookie: COOKIE } : {}),
},
body,
});
} catch (e) {
throw new ApiError('external', '连不上 music.163.com:' + e.message);
}
lastSetCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : [];
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new ApiError('external', `接口返回的不是 JSON(HTTP ${res.status}):${text.slice(0, 120)}`);
}
return json;
}
const song = (s) => ({
id: s.id,
name: s.name,
artist: (s.ar || s.artists || []).map((a) => a.name).join(' / '),
album: (s.al || s.album || {}).name || '',
ms: s.dt || s.duration || 0,
vip: s.fee === 1 || s.fee === 4,
cover: String(s.al?.picUrl || s.album?.picUrl || s.picUrl || ''),
// 没有明确布尔值就不猜;调用方可用 liked-ids 一次取全表后按 id 补齐。
...(typeof s.liked === 'boolean' ? { liked: s.liked } : {}),
});
const num = (v) => (Number.isFinite(+v) ? +v : 0);
function songId(value) {
if (!/^\d+$/.test(value || '') || !Number.isSafeInteger(+value) || +value < 1) {
throw new ApiError('usage', '歌曲 id 需要正整数');
}
return +value;
}
async function cmdPlayIds(args, o) {
if (args.length !== 1) throw new ApiError('usage', '用法:play-ids <id,id,...> [--start N]');
const ids = args[0].split(',').map(songId);
if (!Number.isSafeInteger(o.start) || o.start < 1 || o.start > ids.length) {
throw new ApiError('usage', '--start 需要输入列表内的序号(从 1 开始)');
}
const songs = [];
for (let i = 0; i < ids.length; i += 200) songs.push(...await songsByIds(ids.slice(i, i + 200)));
if (songs.length !== ids.length) throw new ApiError('notfound', '输入列表中有歌曲详情缺失,队列未替换');
return { count: songs.length, total: ids.length, start: o.start, songs };
}
// 搜索必须走明文接口:weapi 版的 cloudsearch 现在对匿名请求回空体(不报错,就空)
async function cmdSearch(kw, o) {
const j = await plainPost('api/search/get/web', { s: kw, type: 1, limit: o.limit, offset: o.offset, total: true });
const list = (j.result && j.result.songs) || [];
return { kw, count: list.length, total: j.result?.songCount ?? list.length, songs: list.map(song) };
}
async function cmdSong(kw) {
if (/^\d+$/.test(kw)) {
const j = await weapiPost('v3/song/detail', { c: JSON.stringify([{ id: +kw }]) });
const s = (j.songs || []).find((s) => +s.id === +kw);
if (!s) throw new ApiError('notfound', `没有这首歌:id=${kw}`);
return song(s);
}
const r = await cmdSearch(kw, { limit: 5, offset: 0 });
if (!r.songs.length) throw new ApiError('notfound', `搜不到「${kw}」`);
return r.songs[0];
}
const MAX_COVER_BYTES = 10_000_000;
async function downloadCover(cover, file, force) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
let body;
try {
const url = new URL(cover);
if (!['http:', 'https:'].includes(url.protocol)) throw new ApiError('external', '封面地址必须是 HTTP(S) URL');
// 图片请求不携带登录 cookie;先完整读取有上限的响应,再打开目标文件。
const res = await fetch(url, {
headers: { Referer: 'https://music.163.com/', 'User-Agent': UA },
signal: controller.signal,
});
if (!res.ok) throw new ApiError([404, 410].includes(res.status) ? 'notfound' : 'external', `封面下载失败(HTTP ${res.status})`);
const type = res.headers.get('content-type');
if (type && !/^image\//i.test(type)) throw new ApiError('external', `封面响应不是图片:${type}`);
if (Number(res.headers.get('content-length')) > MAX_COVER_BYTES) throw new ApiError('external', '封面超过 10 MB(10000000 字节)');
if (!res.body) throw new ApiError('notfound', '封面图片为空');
const chunks = [];
let bytes = 0;
for await (const chunk of res.body) {
bytes += chunk.byteLength;
if (bytes > MAX_COVER_BYTES) throw new ApiError('external', '封面超过 10 MB(10000000 字节)');
chunks.push(chunk);
}
if (!bytes) throw new ApiError('notfound', '封面图片为空');
body = Buffer.concat(chunks, bytes);
} catch (e) {
if (e instanceof ApiError) throw e;
throw new ApiError(controller.signal.aborted ? 'timeout' : 'external', '封面下载失败:' + e.message);
} finally {
clearTimeout(timer);
controller.abort();
}
try {
await writeFile(file, body, { flag: force ? 'w' : 'wx' });
} catch (e) {
if (e.code === 'EEXIST') throw new ApiError('usage', `文件已存在:${file}(覆盖需 --force)`);
throw new ApiError('external', `无法写入封面:${e.message}`);
}
return body.length;
}
async function cmdCover(kw, o) {
if (!kw.trim() || pos.some((a) => a.startsWith('--'))) throw new ApiError('usage', 'cover 需要歌曲关键词或 id');
if (o.download !== undefined && (!o.download.trim() || o.download.startsWith('--'))) throw new ApiError('usage', '--download 需要文件路径');
if (o.force && o.download === undefined) throw new ApiError('usage', '--force 只能与 --download 一起使用');
let s = await cmdSong(kw);
// 明文搜索可能省略图片 URL;只在缺封面时补查选中歌曲的详情。
if (!s.cover && !/^\d+$/.test(kw)) s = await cmdSong(String(s.id));
const { id, name, artist, cover } = s;
if (!cover) throw new ApiError('notfound', `这首歌没有封面:id=${id}`);
if (o.download === undefined) return { id, name, artist, cover };
const file = resolve(o.download);
const bytes = await downloadCover(cover, file, o.force);
return { id, cover, file, bytes };
}
async function cmdUrl(ids, o) {
const level = o.level;
const j = await weapiPost('song/enhance/player/url/v1', {
ids: JSON.stringify(ids.map(Number)),
level,
encodeType: ['standard', 'higher'].includes(level) ? 'aac' : 'flac',
});
return {
level: j.data && j.data[0] && j.data[0].level,
items: (j.data || []).map((d) => ({ id: d.id, url: d.url, br: d.br, level: d.level, size: d.size, fee: d.fee })),
};
}
async function cmdDaily(o) {
const j = await weapiPost('v2/discovery/recommend/songs', {});
const list = (j.data && j.data.dailySongs) || j.recommend || [];
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('external', '每日推荐返回空:' + JSON.stringify(j).slice(0, 160));
const songs = list.slice(o.offset, o.offset + o.limit).map(song);
return { count: songs.length, total: list.length, songs };
}
async function cmdPlaylist(idOrKw, o) {
let id = String(idOrKw);
if (!/^\d+$/.test(id)) {
const hit = await searchFirst(String(idOrKw), 1000, 'playlists', 'playlist');
id = hit.id;
}
const j = await weapiPost('v6/playlist/detail', { id: +id, n: o.limit, s: 8 });
if (!j.playlist) throw new ApiError('notfound', `没有这个歌单:id=${id}`);
const tracks = j.playlist.tracks || [];
let songs = tracks.slice(o.offset, o.offset + o.limit).map(song);
// detail 不认 offset,内嵌 tracks 也可能截断;按完整 trackIds 取本页,缺的详情才补查。
if (Array.isArray(j.playlist.trackIds)) {
const ids = j.playlist.trackIds.slice(o.offset, o.offset + o.limit).map((t) => t.id);
const byId = new Map(tracks.map((s) => [s.id, song(s)]));
const missing = ids.filter((id) => !byId.has(id));
for (const s of await songsByIds(missing)) byId.set(s.id, s);
songs = ids.map((id) => byId.get(id)).filter(Boolean);
}
return {
id: j.playlist.id,
name: j.playlist.name,
count: songs.length,
total: j.playlist.trackCount ?? tracks.length,
songs,
};
}
const norm = (v) => String(v || '').toLowerCase().replace(/\s+/g, '').replace(/[((].*$/, '');
// 同名/别名优先;专辑再按曲目数排(正版专辑 > 同名单曲/翻唱)
function pickBest(list, kw, kind) {
const want = norm(kw);
const score = (it) => {
const names = [it.name, ...(it.alias || [])].map(norm);
if (want && names.includes(want)) return 2;
if (want && names.some((n) => n && (n.includes(want) || want.includes(n)))) return 1;
return 0;
};
const ranked = list.map((it) => ({ s: score(it), it })).sort((a, b) => b.s - a.s);
let cands = ranked.filter((r) => r.s === ranked[0].s).map((r) => r.it);
if (ranked[0].s === 2 && kind === 'album') cands = cands.slice().sort((a, b) => (b.size || 0) - (a.size || 0));
return cands[0];
}
// 搜歌手/专辑/歌单/播客:先走 weapi cloudsearch(已登录时索引全,明文库里连周杰伦《范特西》都没有),
// 拿不到再回退明文 api/search/get/web
async function searchList(kw, type, key, limit = 10, offset = 0) {
let result;
try {
const j = await weapiPost('cloudsearch/get/web', { s: kw, type, limit, offset, total: true });
result = j.result;
} catch { /* 回退明文 */ }
// 有总数的空页就是到末尾了,不换搜索索引重新找一页。
if (!(result?.[key]?.length) && !(offset > 0 && result?.playlistCount != null)) {
const j = await plainPost('api/search/get/web', { s: kw, type, limit, offset, total: true });
result = j.result;
}
const list = result?.[key] || [];
return { list, total: result?.playlistCount ?? list.length };
}
async function searchFirst(kw, type, key, kind) {
const { list } = await searchList(kw, type, key);
if (!list.length) throw new ApiError('notfound', `搜不到「${kw}」`);
return pickBest(list, kw, kind);
}
// 歌手热门:v1/artist/<id> 的 hotSongs;老接口 artist/top/song 作回退
async function cmdArtist(kw, o) {
const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 100, 'artists', 'artist')).id;
let j = await weapiPost(`v1/artist/${id}`, {});
let list = j.hotSongs || [];
let name = (j.artist && j.artist.name) || '';
if (!list.length) {
j = await weapiPost('artist/top/song', { id });
list = j.songs || [];
}
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这位歌手没有可播的歌:${kw}`);
const songs = list.slice(o.offset, o.offset + o.limit).map(song);
return { id, name: name || kw, count: songs.length, total: list.length, songs };
}
// 歌手的专辑列表(含经典老专辑,搜索索引里没有的也能从这里拿到 id)
async function cmdAlbums(kw, o) {
const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 100, 'artists', 'artist')).id;
const j = await weapiPost(`artist/albums/${id}`, { limit: o.limit, offset: o.offset, total: true });
const list = j.hotAlbums || [];
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这位歌手没有专辑:${kw}`);
return {
id,
name: (list[0] && list[0].artist && list[0].artist.name) || kw,
count: list.length,
total: j.artist?.albumSize ?? j.total ?? list.length,
albums: list.map((a) => ({ id: a.id, name: a.name, size: a.size, year: (a.publishTime && new Date(a.publishTime).getFullYear()) || 0 })),
};
}
// 专辑:v1/album/<id> 的 songs(整张,按专辑顺序)
async function cmdAlbum(kw, o) {
const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 10, 'albums', 'album')).id;
const j = await weapiPost(`v1/album/${id}`, {});
const list = j.songs || [];
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这张专辑没有可播的歌:${kw}`);
const songs = list.slice(o.offset, o.offset + o.limit).map(song);
return { id, name: (j.album && j.album.name) || kw, count: songs.length, total: j.album?.size ?? j.album?.songCount ?? list.length, songs };
}
// ---------- 歌单搜索 / 榜单 / 收藏 / FM / 最近 / 播客 ----------
async function cmdPlaylists(kw, o) {
const { list, total } = await searchList(kw, 1000, 'playlists', o.limit, o.offset);
if (!list.length && !o.offset) throw new ApiError('notfound', `搜不到歌单「${kw}」`);
return {
kw,
count: list.length,
total,
playlists: list.map((p) => ({
id: p.id,
name: p.name,
count: p.trackCount || 0,
creator: (p.creator || {}).nickname || '',
sub: p.subscribed === true,
})),
};
}
async function cmdCharts(o) {
const j = await weapiPost('toplist', {});
const list = j.list || [];
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('external', '榜单接口没返回列表');
const page = list.slice(o.offset, o.offset + o.limit);
return {
count: page.length,
total: j.total ?? j.count ?? list.length,
charts: page.map((c) => ({ id: c.id, name: c.name, count: c.trackCount, freq: c.updateFrequency })),
};
}
async function cmdChart(kw, o) {
let id = /^\d+$/.test(kw) ? +kw : 0;
let name = kw;
if (!id) {
const j = await weapiPost('toplist', {});
const hit = pickBest(j.list || [], kw, 'chart');
if (!hit) throw new ApiError('notfound', `没有这个榜单:${kw}(用 charts 看全部)`);
id = hit.id;
name = hit.name;
}
const r = await cmdPlaylist(String(id), o);
return { ...r, chart_id: id, chart: name };
}
async function cmdMyAlbums(o) {
const j = await weapiPost('album/sublist', { limit: o.limit, offset: o.offset, total: true });
const list = j.data || [];
return {
count: list.length,
total: j.count ?? j.total ?? list.length,
albums: list.map((a) => ({ id: a.id, name: a.name, size: a.size, artist: (a.artists || []).map((x) => x.name).join(' / ') })),
};
}
async function cmdMyArtists(o) {
const j = await weapiPost('artist/sublist', { limit: o.limit, offset: o.offset, total: true });
const list = j.data || [];
return { count: list.length, total: j.count ?? j.total ?? list.length, artists: list.map((a) => ({ id: a.id, name: a.name, albums: a.albumSize, songs: a.musicSize })) };
}
// 私人 FM:接口一次固定给 3 首且不认 limit,多拉几次凑够(去重)
async function cmdFm(o) {
const want = Math.min(o.limit, 30);
const seen = new Set();
const out = [];
for (let i = 0; i < 12 && out.length < want; i++) {
const j = await weapiPost('radio/get', {});
const batch = j.data || [];
if (!batch.length) break;
for (const s of batch) if (!seen.has(s.id)) { seen.add(s.id); out.push(song(s)); }
}
if (!out.length) throw new ApiError('external', '私人 FM 没返回歌曲');
const picked = out.slice(0, want);
return { count: picked.length, songs: picked };
}
async function cmdRecent(o) {
const uid = await currentUid();
const j = await weapiPost('v1/play/record', { uid, type: 1 }); // type=1:最近一周
const list = j.weekData || j.allData || [];
const items = list.slice(o.offset, o.offset + o.limit).filter((x) => x.song).map((x) => ({ ...song(x.song), plays: x.playCount }));
if (!items.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', '最近播放是空的');
return { count: items.length, total: j.total ?? j.count ?? list.length, songs: items };
}
async function cmdPodcasts(o) {
// 两个端点都认 limit、不认 offset;拉到页尾再本地截,避免每页重复第一页。
const j = await weapiPost(o.recommend ? 'djradio/recommend/v1' : 'djradio/get/subed', {
limit: o.offset + o.limit, offset: o.offset,
});
const list = j.djRadios || [];
const page = list.slice(o.offset, o.offset + o.limit);
if (!list.length && (!o.offset || j.code !== 200)) {
throw new ApiError('notfound', o.recommend ? '没有推荐播客' : '没订阅播客(加 --recommend 看推荐的)');
}
return {
kind: o.recommend ? 'recommend' : 'subscribed',
count: page.length,
total: j.count ?? j.total ?? list.length,
podcasts: page.map((r) => ({ id: r.id, name: r.name, episodes: r.programCount, dj: (r.dj || {}).nickname || '' })),
};
}
// 放某播客的最新 N 期:名字先在订阅+推荐里找,找不到再搜播客
async function cmdPodcast(kw, o) {
let id = /^\d+$/.test(kw) ? +kw : 0;
let name = kw;
if (!id) {
const subs = (await weapiPost('djradio/get/subed', { limit: 100, offset: 0 })).djRadios || [];
const rec = (await weapiPost('djradio/recommend/v1', { limit: 100, offset: 0 })).djRadios || [];
const hit = pickBest([...subs, ...rec], kw, 'djradio');
if (hit) {
id = hit.id;
name = hit.name;
} else {
const s = await searchFirst(kw, 1009, 'djRadios', 'djradio');
id = s.id;
name = s.name;
}
}
const j = await weapiPost('dj/program/byradio', { radioId: id, limit: o.limit, offset: o.offset, asc: false });
const progs = j.programs || [];
const songs = progs
.filter((p) => p.mainSong && p.mainSong.id)
.map((p) => ({ ...song(p.mainSong), programId: p.id, program: p.name || '' }));
if (!songs.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这个播客没有可播的节目:${kw}`);
return { id, name, count: songs.length, total: j.count ?? j.total ?? progs[0]?.radio?.programCount ?? progs.length, songs };
}
async function cmdEpisode(pid, o) {
const j = await weapiPost('dj/program/detail', { id: +pid });
const p = j.program || {};
const ms = p.mainSong || {};
if (!ms.id) throw new ApiError('notfound', `没有这期节目:${pid}`);
return {
id: +pid,
name: p.name || ms.name || '',
dj: (p.dj || {}).nickname || '',
count: 1,
songs: [{ ...song(ms), programId: +pid, program: p.name || '' }],
};
}
// 「我喜欢的音乐」顺序 = 收藏时间由近到远(与客户端一致):
// song/like/get 返回的 ids 顺序与收藏时间无关(实测最老收藏的排第 1),
// 真值是 specialType=5 歌单的 trackIds[].at(收藏时间毫秒戳),按它倒序再取详情。
async function cmdLiked(o) {
const pid = await likedPlaylistId();
if (!pid) throw new ApiError('notfound', '找不到「我喜欢的音乐」歌单');
// n=0 省掉内嵌 tracks,trackIds 仍全量返回
const j = await weapiPost('v6/playlist/detail', { id: pid, n: 0, s: 8 });
const tids = ((j.playlist || {}).trackIds || []).slice().sort((a, b) => (b.at || 0) - (a.at || 0));
const ids = tids.slice(o.offset, o.offset + o.limit).map((x) => x.id);
const songs = (await songsByIds(ids)).map((s) => ({ ...s, liked: true }));
return { count: songs.length, total: tids.length, songs };
}
async function songsByIds(ids) {
if (!ids.length) return [];
const det = await weapiPost('v3/song/detail', { c: JSON.stringify(ids.map((id) => ({ id }))) });
const byId = new Map((det.songs || []).map((s) => [s.id, song(s)]));
return ids.map((id) => byId.get(id)).filter(Boolean);
}
async function likedPlaylistId() {
const uid = await currentUid();
const j = await weapiPost('user/playlist', { uid, limit: 1000, offset: 0, includeVideo: true });
const p = (j.playlist || []).find((x) => x.specialType === 5) || (j.playlist || [])[0];
return p && p.id;
}
async function cmdLike(id, o) {
const like = !o.unlike;
const j = await weapiPost('radio/like', { alg: 'itembased', trackId: +id, like, time: 3 });
if (j.code === 200) return { id: +id, liked: like, via: 'radio/like', code: j.code };
const first = `radio/like code=${j.code}:${j.message || j.msg || ''}`;
// 风控(-460)时退一步:直接对「我喜欢的音乐」歌单做增删,走另一套风控
const pid = await likedPlaylistId();
if (pid) {
const j2 = await weapiPost('playlist/manipulate/tracks', {
op: like ? 'add' : 'del',
pid,
trackIds: JSON.stringify([+id]),
imme: 'true',
});
if (j2.code === 200 || (like && j2.code === 502)) {
// 502 = 歌单内歌曲重复 → 目标状态已经达成,按幂等成功处理
return { id: +id, liked: like, via: 'playlist/manipulate/tracks', already: j2.code === 502, fallback_from: first };
}
throw new ApiError('external', `网易云拒绝了这次操作(${first};playlist/manipulate code=${j2.code}:${j2.message || j2.msg || ''})`);
}
throw new ApiError('external', `网易云拒绝了这次操作(${first})`);
}
async function cmdLyric(id, o) {
const j = await weapiPost('song/lyric', { id: +id, lv: -1, kv: -1, tv: -1 });
return { id: +id, lyric: (j.lrc && j.lrc.lyric) || '', trans: o.trans ? (j.tlyric && j.tlyric.lyric) || '' : undefined };
}
async function cmdSession() {
const j = await weapiPost('w/nuser/account/get', {});
return {
loggedIn: !!(j.profile && j.profile.userId),
uid: num(j.profile && j.profile.userId),
nick: (j.profile && j.profile.nickname) || '',
vip: (j.profile && j.profile.vipType) || 0,
};
}
async function currentUid() {
const s = await cmdSession();
if (!s.loggedIn) throw new ApiError('auth', '没登录:先跑 pi-skill netease-music login(手机扫码)');
return s.uid;
}
async function cmdMyPlaylists(o) {
const uid = await currentUid();
// user/playlist 的 limit 不稳定,非零 offset 还会多跳过置顶歌单;从头取再本地分页。
const j = await weapiPost('user/playlist', { uid, limit: o.offset + o.limit, includeVideo: true });
const list = j.playlist || [];
const page = list.slice(o.offset, o.offset + o.limit);
return {
count: page.length,
total: j.total ?? j.count ?? list.length,
playlists: page.map((p) => ({ id: p.id, name: p.name, count: p.trackCount, special: p.specialType || 0, sub: p.subscribed === true })),
};
}
async function cmdLikeState(id) {
// 这个端点回的是「我喜欢的歌曲 id 全表」(key 是 ids),不是单曲对象
const j = await weapiPost('song/like/get', { trackIds: JSON.stringify([Number(id)]) });
if (j.code !== 200) throw new ApiError('external', `查喜欢状态失败(code=${j.code}):${j.message || j.msg || ''}`);
const ids = (Array.isArray(j.ids) ? j.ids : []).map(Number);
return { id: Number(id), liked: ids.includes(Number(id)), liked_total: ids.length, code: j.code };
}
async function cmdLikedIds(args) {
if (args.length) throw new ApiError('usage', '用法:liked-ids');
const j = await weapiPost('song/like/get', { trackIds: '[]' });
if (j.code !== 200) throw new ApiError(j.code === 301 ? 'auth' : 'external', `查喜欢列表失败(code=${j.code}):${j.message || j.msg || ''}`);
if (!Array.isArray(j.ids)) throw new ApiError('external', '喜欢列表缺少 ids,不能当成空列表');
const ids = j.ids.map(Number);
if (ids.some((id) => !Number.isSafeInteger(id) || id < 1)) throw new ApiError('external', '喜欢列表含无效歌曲 id');
return { count: ids.length, total: ids.length, ids };
}
async function cmdDislike(args) {
if (args.length !== 1) throw new ApiError('usage', '用法:dislike <歌曲id>');
const id = songId(args[0]);
// NeteaseCloudMusicApi 的 fm/trash 对应此 weapi 写端点。
const j = await weapiPost('radio/trash/add', { songId: id, time: 25, alg: 'RT' });
if (j.code !== 200) throw new ApiError(j.code === 301 ? 'auth' : 'external', `私人 FM 不喜欢失败(code=${j.code}):${j.message || j.msg || ''}`);
return { id, disliked: true, code: j.code };
}
async function cmdSmsSend(phone) {
const j = await weapiPost('sms/captcha/sent', { cellphone: String(phone), ctcode: '86' });
if (j.code !== 200) throw new ApiError('external', `发验证码没成功(code=${j.code}):${j.message || JSON.stringify(j).slice(0, 120)}`);
return { sent: true };
}
async function cmdSmsLogin(phone, code) {
const j = await weapiPost('login/cellphone', { phone: String(phone), countrycode: '86', captcha: String(code), rememberLogin: true });
const cookie = jarToCookie(lastSetCookie);
if (!/MUSIC_U=/.test(cookie)) throw new ApiError('auth', `登录没成(code=${j.code}):${j.message || JSON.stringify(j).slice(0, 120)}`);
return { state: 'ok', cookie };
}
async function cmdQrStart() {
const j = await weapiPost('login/qrcode/unikey', { type: 1, header: {} });
if (!j.unikey) throw new ApiError('external', '拿不到二维码 key:' + JSON.stringify(j).slice(0, 160));
return { key: j.unikey, url: 'https://music.163.com/login?codekey=' + j.unikey };
}
async function cmdQrPoll(key, o) {
const deadline = Date.now() + o.timeout * 1000;
let state = 'waiting';
while (Date.now() < deadline) {
const j = await weapiPost('login/qrcode/client/login', { key, type: 1 });
if (j.code === 800) return { state: 'expired', code: j.code };
if (j.code === 802 && state !== 'scanned') {
state = 'scanned';
console.error('已扫到,等你在手机上点「确认登录」…');
}
if (j.code === 803) {
const cookie = jarToCookie(lastSetCookie);
if (!/MUSIC_U=/.test(cookie)) throw new ApiError('external', '登录成功了但没拿到 MUSIC_U:' + cookie.slice(0, 120));
return { state: 'ok', cookie };
}
await new Promise((r) => setTimeout(r, 2000));
}
return { state, code: 801 };
}
// ---------- 参数解析 ----------
const argv = process.argv.slice(2);
const CMD = argv[0];
const opts = { limit: 0, offset: 0, start: 1, level: 'lossless', trans: false, unlike: false, timeout: 120 };
const pos = [];
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
if (a === '--limit') opts.limit = +argv[++i];
else if (a === '--offset') opts.offset = +argv[++i];
else if (a === '--start') {
const value = argv[++i];
opts.start = /^\d+$/.test(value || '') ? +value : NaN;
}
else if (a === '--level') opts.level = argv[++i];
else if (a === '--trans') opts.trans = true;
else if (a === '--unlike') opts.unlike = true;
else if (a === '--timeout') opts.timeout = +argv[++i];
else if (a === '--recommend') opts.recommend = true;
else if (a === '--download') opts.download = argv[++i] ?? '';
else if (a === '--force') opts.force = true;
else pos.push(a);
}
const DEFAULTS = { search: 20, daily: 40, playlist: 500, liked: 1000, myplaylists: 100, artist: 50, album: 200, albums: 60,
playlists: 20, charts: 100, myalbums: 60, myartists: 60, fm: 12, recent: 30, podcasts: 50, podcast: 3 };
if (!opts.limit) opts.limit = DEFAULTS[CMD] || 20;
const rawPost = (path, data) => (String(path).startsWith('api/') ? plainPost(path, data) : weapiPost(path, data));
const HANDLERS = {
search: () => cmdSearch(pos.join(' '), opts),
song: () => cmdSong(pos.join(' ')),
'play-ids': () => cmdPlayIds(pos, opts),
cover: () => cmdCover(pos.join(' '), opts),
url: () => cmdUrl(pos, opts),
daily: () => cmdDaily(opts),
playlist: () => cmdPlaylist(String(pos[0]).replace(/.*[?&#/]id=/, ''), opts),
playlists: () => cmdPlaylists(pos.join(' '), opts),
charts: () => cmdCharts(opts),
chart: () => cmdChart(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
myalbums: () => cmdMyAlbums(opts),
myartists: () => cmdMyArtists(opts),
fm: () => cmdFm(opts),
recent: () => cmdRecent(opts),
podcasts: () => cmdPodcasts(opts),
podcast: () => cmdPodcast(String(pos.join(' ')), opts),
episode: () => cmdEpisode(pos[0], opts),
artist: () => cmdArtist(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
album: () => cmdAlbum(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
albums: () => cmdAlbums(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
liked: () => cmdLiked(opts),
'liked-ids': () => cmdLikedIds(pos),
dislike: () => cmdDislike(pos),
like: () => cmdLike(pos[0], opts),
lyric: () => cmdLyric(pos[0], opts),
myplaylists: () => cmdMyPlaylists(opts),
session: () => cmdSession(),
uid: () => currentUid().then((uid) => ({ uid })),
raw: () => rawPost(pos[0], JSON.parse(pos[1] || '{}')),
'like-state': () => cmdLikeState(pos[0]),
'sms-send': () => cmdSmsSend(pos[0]),
'sms-login': () => cmdSmsLogin(pos[0], pos[1]),
'qr-start': () => cmdQrStart(),
'qr-poll': () => cmdQrPoll(pos[0], opts),
};
if (!HANDLERS[CMD]) {
console.error(JSON.stringify({ error: 'usage', hint: `未知命令 ${CMD || '(空)'};可用:${Object.keys(HANDLERS).join(' ')}` }));
process.exit(2);
}
try {
if (!Number.isSafeInteger(opts.offset) || opts.offset < 0) throw new ApiError('usage', '--offset 需要非负整数');
const out = await HANDLERS[CMD]();
console.log(JSON.stringify(out));
} catch (e) {
const code = e instanceof ApiError ? e.code : 'internal';
console.error(JSON.stringify({ error: code, hint: e.hint || String(e) }));
process.exit(code === 'usage' ? 2 : code === 'auth' ? 3 : code === 'notfound' ? 4 : 5);
}

1159
scripts/netease-music.sh Executable file

File diff suppressed because it is too large Load Diff

100
scripts/refresh-urls.sh Executable file
View File

@ -0,0 +1,100 @@
#!/bin/bash
# refresh-urls.sh — 队列地址保活
#
# 网易云的播放直链是限时签名 URL:实测取出来约 5 分钟就 403
# (路径里的时间戳 + vuutv/authSecret 签名,服务端一核,过期就拒绝)。
# 长队列预取一批地址,放到后半段会集体失效,听感是「歌还在放、就是没声音」。
#
# 本脚本由入口的 keepalive_start 拉起(一次只跑一个),每 90 秒看一次:
# 这批地址取出来多久了(urls_at)× mpv 播到第几首(playlist-pos)
# 一到 4 分钟,就把「当前这首之后还没播的」批量重取,原地换掉 m3u 里对应的行。
# mpv 是按需读取下一首的,所以换 URL 不影响正在放的那首。
# 队列被换掉 / 停止 / mpv 退出,脚本自己收工。
set -u
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_DIR="${NETEASE_MUSIC_STATE_DIR:-${PI_AGENT_DIR:-$HOME/.pi/agent}/local/skills/netease-music}"
QUEUE="$LOCAL_DIR/queue.json"
M3U="$LOCAL_DIR/queue.m3u8"
URLS_AT="$LOCAL_DIR/urls_at"
SOCK="$LOCAL_DIR/mpv.sock"
API="$SKILL_DIR/scripts/netease-api.mjs"
QUALITY="$(python3 -c 'import json,sys
try: print(json.load(open(sys.argv[1])).get("quality") or "lossless")
except Exception: print("lossless")' "$LOCAL_DIR/config.json" 2>/dev/null)"
QUALITY="${NETEASE_MUSIC_QUALITY:-${PI_SKILL_NETEASE_MUSIC_QUALITY:-$QUALITY}}"
COOKIE="${NETEASE_MUSIC_COOKIE:-${PI_SKILL_NETEASE_MUSIC_COOKIE:-$(python3 -c 'import json,sys
try: print(json.load(open(sys.argv[1])).get("cookie") or "")
except Exception: print("")' "$LOCAL_DIR/config.json" 2>/dev/null)}}"
log() { printf '%s %s\n' "$(date '+%H:%M:%S')" "$*"; }
mpv_query() { # mpv_query <属性>:拿不到就打印空
python3 - "$SOCK" "$1" <<'PY' 2>/dev/null
import json, socket, sys
try:
s = socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect(sys.argv[1])
s.sendall(json.dumps({"command": ["get_property", sys.argv[2]], "request_id": 1}).encode() + b"\n")
r = json.loads(s.recv(65536).decode())
print(r.get("data") if r.get("error") == "success" else "")
except Exception:
print("")
PY
}
refetch_from() { # refetch_from <起始序号 0-based>:重取这一首之后的地址,原地换 m3u
local from="$1" tmp_ids tmp_urls n
tmp_ids="$(mktemp)"; tmp_urls="$(mktemp)"
python3 - "$QUEUE" "$from" > "$tmp_ids" <<'PY'
import json, sys
q = json.load(open(sys.argv[1]))
for i, s in enumerate(q["songs"]):
if i >= int(sys.argv[2]):
print(s["id"])
PY
[ -s "$tmp_ids" ] || { rm -f "$tmp_ids" "$tmp_urls"; return 1; }
n="$(wc -l < "$tmp_ids" | tr -d ' ')"
log "重取地址:从第 $((from + 1)) 首起共 $n 首"
while read -r chunk; do
NETEASE_COOKIE="$COOKIE" node "$API" url $chunk --level "$QUALITY" 2>/dev/null \
| python3 -c 'import json,sys
for it in (json.load(sys.stdin).get("items") or []):
print("%s\t%s" % (it.get("id"), it.get("url") or ""))' >> "$tmp_urls"
done < <(python3 -c 'import sys
ids = [l.strip() for l in sys.stdin if l.strip()]
for i in range(0, len(ids), 50): print(" ".join(ids[i:i+50]))' < "$tmp_ids")
python3 - "$M3U" "$QUEUE" "$tmp_urls" "$from" <<'PY'
import json, sys
mpath, qpath, upath, start = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
urls = {}
for line in open(upath):
sid, _, url = line.rstrip("\n").partition("\t")
if url:
urls[sid] = url
order = [str(s["id"]) for s in json.load(open(qpath))["songs"]]
lines = open(mpath).read().split("\n")
head = [i for i, l in enumerate(lines) if l.startswith("#EXTINF")]
changed = 0
for pos, i in enumerate(head):
if pos < start or pos >= len(order) or i + 1 >= len(lines):
continue
u = urls.get(order[pos])
if u:
lines[i + 1] = u
changed += 1
open(mpath, "w").write("\n".join(lines))
print("换成新地址的条数:%d/%d" % (changed, len(head) - start))
PY
log "重取完成(从第 $((from + 1)) 首起)"
rm -f "$tmp_ids" "$tmp_urls"
}
log "地址保活启动"
while :; do
sleep 90
[ -n "$(mpv_query idle-active)" ] || { log "mpv 没了,收工"; exit 0; }
[ -s "$QUEUE" ] || { log "队列没了,收工"; exit 0; }
age=$(( $(date +%s) - $(cat "$URLS_AT" 2>/dev/null || echo 0) ))
[ "$age" -lt 240 ] && continue
# 「当前放到第几首」按歌认:mpv 开了 shuffle 时 playlist-pos 不是 queue.json 的序号
title="$(mpv_query media-title)"
curl="$(mpv_query path)"
pos="$(python3 "$SKILL_DIR/scripts/lib/mpv-map.py" index "$QUEUE" "$LOCAL_DIR/urls.txt" "$title" "$curl" 2>/dev/null)"
case "$pos" in ''|*[!0-9]*) pos=0 ;; esac
if refetch_from "$pos"; then date +%s > "$URLS_AT"; else log "重取失败,90 秒后再试"; fi
done

View File

@ -1 +1 @@
0.1.6 0.1.2

View File

@ -281,10 +281,9 @@
} }
], ],
"publish": { "publish": {
"repo": "https://gitea.vhkd.top/pi-hub/netease-music-cli.git", "repo": "https://gitea.vhkd.top/geekinney/netease-music-cli.git",
"mirrors": [ "mirrors": [
"git@gitea.gklazycat.heiyu.space:pi-hub/netease-music-cli.git" "git@gitea.gklazycat.heiyu.space:geekinney/netease-music-cli.git"
] ]
}, }
"license": "GPL-3.0-or-later"
} }