From 4319c8b6d7fa081b49fdedf1e1acd9009dd33a53 Mon Sep 17 00:00:00 2001 From: Puqns67 Date: Tue, 24 Sep 2024 01:54:48 +0800 Subject: [PATCH] Initial commit --- .editorconfig | 11 + .gitignore | 5 + LICENSE | 674 ++++++++++++++++++ README.md | 1 + pyproject.toml | 28 + src/ncmlyrics/__init__.py | 3 + src/ncmlyrics/__main__.py | 137 ++++ src/ncmlyrics/api.py | 249 +++++++ src/ncmlyrics/constant.py | 14 + src/ncmlyrics/error.py | 21 + src/ncmlyrics/lrc.py | 158 ++++ src/ncmlyrics/util.py | 103 +++ tests/__init__.py | 3 + .../pickOutput/1/testArtist - testTrack1.mp3 | 0 .../pickOutput/2/testArtist - testTrack2.mp3 | 0 .../FlacArtist1 FlacArtist2 - FlacName.flac | 0 .../Mp3Artist - Mp3Name.mp3 | 0 .../Mp3Artist1,Mp3Artist2 - Mp3Name.mp3 | 0 .../NcmArtist1,NcmArtist2 - NcmName.flac | 0 tests/test_utils.py | 159 +++++ uv.lock | 314 ++++++++ 21 files changed, 1880 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/ncmlyrics/__init__.py create mode 100644 src/ncmlyrics/__main__.py create mode 100644 src/ncmlyrics/api.py create mode 100644 src/ncmlyrics/constant.py create mode 100644 src/ncmlyrics/error.py create mode 100644 src/ncmlyrics/lrc.py create mode 100644 src/ncmlyrics/util.py create mode 100644 tests/__init__.py create mode 100644 tests/resource/util/pickOutput/1/testArtist - testTrack1.mp3 create mode 100644 tests/resource/util/pickOutput/2/testArtist - testTrack2.mp3 create mode 100644 tests/resource/util/testTrackSourceExists/FlacArtist1 FlacArtist2 - FlacName.flac create mode 100644 tests/resource/util/testTrackSourceExists/Mp3Artist - Mp3Name.mp3 create mode 100644 tests/resource/util/testTrackSourceExists/Mp3Artist1,Mp3Artist2 - Mp3Name.mp3 create mode 100644 tests/resource/util/testTrackSourceExists/NcmArtist1,NcmArtist2 - NcmName.flac create mode 100644 tests/test_utils.py create mode 100644 uv.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..83f665c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +insert_final_newline = true +end_of_line = lf + +indent_style = tab +indent_size = 4 + +[*.py] +indent_style = space diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..982fd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +.mypy_cache/ +.ruff_cache/ +.venv/ +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6f6da4 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# ncmlyrics diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..33a6437 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "ncmlyrics" +authors = [{ name = "Puqns67", email = "me@puqns67.icu" }] +dependencies = [ + "httpx>=0.27.2", + "h2>=4.1.0", + "rich>=13.8.1", + "brotli>=1.1.0", + "zstandard>=0.23.0", + "click>=8.1.7", + "platformdirs>=4.3.6", +] +requires-python = ">=3.12" +readme = "README.md" +license = { text = "GPL-3.0-or-later" } +dynamic = ["version"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/ncmlyrics/constant.py" +pattern = "^APP_VERSION = \"(?P[^\"]+)\"$" + +[tool.ruff] +target-version = "py312" +line-length = 120 diff --git a/src/ncmlyrics/__init__.py b/src/ncmlyrics/__init__.py new file mode 100644 index 0000000..97bb3a0 --- /dev/null +++ b/src/ncmlyrics/__init__.py @@ -0,0 +1,3 @@ +from .constant import APP_VERSION as __version__ + +__all__ = ["__version__"] diff --git a/src/ncmlyrics/__main__.py b/src/ncmlyrics/__main__.py new file mode 100644 index 0000000..6eb72f5 --- /dev/null +++ b/src/ncmlyrics/__main__.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass +from pathlib import Path +from click import argument, command, confirm, option, Path as clickPath +from rich.console import Console +from rich.theme import Theme + +from ncmlyrics.error import UnsupportLinkError + +from .api import NCMApi, NCMTrack +from .util import Link, LinkType, parseLink, pickOutput + +NCMLyricsAppTheme = Theme( + { + "songTitle": "bold chartreuse1", + "songArrow": "chartreuse3", + "albumTitle": "bold orchid1", + "albumArrow": "orchid2", + "playListTitle": "bold aquamarine1", + "playListArrow": "aquamarine3", + "warning": "bold red1", + } +) + + +@dataclass +class NCMLyricsApp: + console: Console + + outputs: list[Path] + exist: bool + overwrite: bool + quiet: bool + + tracks: list[tuple[NCMTrack, Path]] + + def addWithPath(self, track: NCMTrack, savePath: Path | None, arrowStyle: str) -> None: + if savePath is None: + if not self.quiet: + self.console.print("--->", style=arrowStyle, end=" ") + self.console.print("未能找到源文件,将跳过此曲目。", style="warning") + elif not self.overwrite and savePath.exists(): + if not self.quiet: + self.console.print("--->", style=arrowStyle, end=" ") + self.console.print("歌词文件已存在,将跳过此曲目。", style="warning") + else: + self.tracks.append((track, savePath)) + + def add(self, track: NCMTrack, arrowStyle: str) -> None: + savePath = pickOutput(track, self.outputs, self.exist) + + if not self.quiet: + self.console.print("-->", style=arrowStyle, end=" ") + self.console.print(f"{"/".join(track.artists)} - {track.name}", style=f"link {track.link()}") + + self.addWithPath(track, savePath, arrowStyle) + + +@command +@option( + "-o", + "--outputs", + type=clickPath(exists=True, file_okay=False, dir_okay=True, writable=True, path_type=Path), + multiple=True, + help="Output paths, repeating multiple times will automatically match existing audio files in input order and use the last input as a fallback output.", +) +@option("-e", "--exist", is_flag=True, help="仅在源文件存在时保存歌词文件。") +@option("-O", "--overwrite", is_flag=True, help="在歌词文件已存在时重新获取歌词并覆盖写入。") +@option("-q", "--quiet", is_flag=True, help="不进行任何提示并跳过所有确认。") +@argument( + "links", + nargs=-1, +) +def main(outputs: list[Path], exist: bool, overwrite: bool, quiet: bool, links: list[str]) -> int: + console = Console(theme=NCMLyricsAppTheme) + + if len(links) == 0: + console.print( + "请输入至少一个链接以解析曲目以获取其歌词!支持输入单曲,专辑与歌单的分享或网页链接。", style="warning" + ) + return 1 + + api = NCMApi() + app = NCMLyricsApp(console=console, outputs=outputs, exist=exist, overwrite=overwrite, quiet=quiet, tracks=[]) + + for link in links: + parsed: Link | None = None + + try: + parsed = parseLink(link) + except UnsupportLinkError: + continue + + match parsed.type: + case LinkType.Song: + newTrack = api.getDetailsForTrack(parsed.id) + savePath = pickOutput(newTrack, outputs, exist) + + if not quiet: + console.print("-- 单曲 -->", style="songTitle", end=" ") + console.print(f"{"/".join(newTrack.artists)} - {newTrack.name}", style=f"link {newTrack.link()}") + + app.addWithPath(newTrack, savePath, "songArrow") + + case LinkType.Album: + newAlbum = api.getDetailsForAlbum(parsed.id) + + if not quiet: + console.print("== 专辑 ==>", style="albumTitle", end=" ") + console.print(newAlbum.name, style=f"link {newAlbum.link()}") + + for newTrack in newAlbum.tracks: + app.add(newTrack, "albumArrow") + + case LinkType.Playlist: + newPlaylist = api.getDetailsForPlaylist(parsed.id) + newPlaylist.fillDetailsOfTracks(api) + + if not quiet: + console.print("== 歌单 ==>", style="playListTitle", end=" ") + console.print(newPlaylist.name, style=f"link {newPlaylist.link()}") + + for newTrack in newPlaylist.tracks: + app.add(newTrack, "playListArrow") + + if len(app.tracks) == 0: + console.print("无曲目的歌词可被获取,请检查上方的输出!", style="warning") + return 1 + + if not quiet: + confirm("继续操作?", default=True, abort=True) + + for track, path in app.tracks: + api.getLyricsByTrack(track.id).lrc().saveAs(path) + + +if __name__ == "__main__": + main() diff --git a/src/ncmlyrics/api.py b/src/ncmlyrics/api.py new file mode 100644 index 0000000..b115d1e --- /dev/null +++ b/src/ncmlyrics/api.py @@ -0,0 +1,249 @@ +from dataclasses import dataclass +from http.cookiejar import MozillaCookieJar +from json import dumps as dumpJson +from typing import Any, Self + +from httpx import Client as HttpClient + +from .constant import CONFIG_API_DETAIL_TRACK_PER_REQUEST, NCM_API_BASE_URL, PLATFORM +from .error import NCMApiParseError, NCMApiRequestError +from .lrc import Lrc, LrcType + +REQUEST_HEADERS = { + "Accept": "application/json", + "Accept-Encoding": "zstd, br, gzip, deflate", + "Connection": "keep-alive", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", +} + + +@dataclass +class NCMTrack: + id: int + name: str + artists: list[str] + + def fromApi(data: dict) -> list[Self]: + if data.get("code") != 200: + raise NCMApiParseError(f"响应码不为 200: {data["code"]}") + + data = data.get("songs") + if data is None: + raise NCMApiParseError("不存在 Track 对应的结构") + + result: list[NCMTrack] = [] + + for track in data: + result.append(NCMTrack.fromData(track)) + + return result + + def fromData(data: dict) -> Self: + try: + return NCMTrack( + id=data["id"], + name=data["name"], + artists=[artist["name"] for artist in data["ar"]], + ) + except KeyError as e: + raise NCMApiParseError(f"需要的键不存在: {e}") + + def link(self) -> str: + return f"https://music.163.com/song?id={self.id}" + + +@dataclass +class NCMAlbum: + id: int + name: str + tracks: list[NCMTrack] + + def fromApi(data: dict) -> Self: + if data.get("code") != 200: + raise NCMApiParseError(f"响应码不为 200: {data["code"]}") + + album = data.get("album") + if album is None: + raise NCMApiParseError("不存在 Album 对应的结构") + + try: + return NCMAlbum( + id=album["id"], + name=album["name"], + tracks=[NCMTrack.fromData(track) for track in data["songs"]], + ) + except KeyError as e: + raise NCMApiParseError(f"需要的键不存在: {e}") + + def link(self) -> str: + return f"https://music.163.com/album?id={self.id}" + + +@dataclass +class NCMPlaylist: + id: int + name: str + tracks: list[NCMTrack] + trackIds: list[int] + + def fromApi(data: dict) -> Self: + if data.get("code") != 200: + raise NCMApiParseError(f"响应码不为 200: {data["code"]}") + + playlist = data.get("playlist") + if playlist is None: + raise NCMApiParseError("不存在 Playlist 对应的结构") + + try: + tracks: list[NCMTrack] = [] + trackIds: list[int] = [track["id"] for track in playlist["trackIds"]] + + for track in playlist["tracks"]: + parsedTrack: NCMTrack = NCMTrack.fromData(track) + trackIds.remove(parsedTrack.id) + tracks.append(parsedTrack) + + return NCMPlaylist( + id=playlist["id"], + name=playlist["name"], + tracks=tracks, + trackIds=trackIds, + ) + except KeyError as e: + raise NCMApiParseError(f"需要的键不存在: {e}") + + def link(self) -> str: + return f"https://music.163.com/playlist?id={self.id}" + + def fillDetailsOfTracks(self, api) -> None: + self.tracks.extend(api.getDetailsForTracks(self.trackIds)) + self.trackIds.clear() + + +@dataclass +class NCMLyrics: + isPureMusic: bool + data: Any | None + + def fromApi(data: dict) -> Self: + if data.get("code") != 200: + raise NCMApiParseError(f"响应码不为 200: {data["code"]}") + + if data.get("pureMusic") is True: + return NCMLyrics(isPureMusic=True, data=None) + + return NCMLyrics(isPureMusic=False, data=data) + + def lrc(self) -> Lrc: + result = Lrc() + + for lrcType in LrcType: + try: + lrcStr = self.data[lrcType.value]["lyric"] + except KeyError: + pass + else: + if lrcStr != "": + result.serializeLyricString(lrcType, lrcStr) + + return result + + +class NCMApi: + _cookieJar: MozillaCookieJar + _httpClient: HttpClient + + def __init__(self, http2: bool = True) -> None: + self._cookieJar = MozillaCookieJar() + + try: + self._cookieJar.load(str(PLATFORM.user_config_path / "cookies.txt")) + except FileNotFoundError: + pass + + self._httpClient = HttpClient( + base_url=NCM_API_BASE_URL, + cookies=self._cookieJar, + headers=REQUEST_HEADERS, + http2=http2, + ) + + def __del__(self) -> None: + self._cookieJar.save(str(PLATFORM.user_config_path / "cookies.txt")) + self._httpClient.close() + + def getDetailsForTrack(self, trackId: int) -> NCMTrack: + params = {"c": f"[{{'id':{trackId}}}]"} + + try: + response = self._httpClient.request("GET", "/v3/song/detail", params=params) + except BaseException: + raise NCMApiRequestError + + return NCMTrack.fromApi(response.json()).pop() + + def getDetailsForTracks(self, trackIds: list[int]) -> list[NCMTrack]: + result: list[NCMTrack] = [] + seek = 0 + + while True: + seekedTrackIds = trackIds[seek : seek + CONFIG_API_DETAIL_TRACK_PER_REQUEST] + + if len(seekedTrackIds) == 0: + break + + params = { + "c": dumpJson( + [{"id": trackId} for trackId in seekedTrackIds], + separators=(",", ":"), + ) + } + + try: + response = self._httpClient.request("GET", "/v3/song/detail", params=params) + except BaseException: + raise NCMApiRequestError + + result.extend(NCMTrack.fromApi(response.json())) + + seek += CONFIG_API_DETAIL_TRACK_PER_REQUEST + + return result + + def getDetailsForAlbum(self, albumId: int) -> NCMAlbum: + try: + response = self._httpClient.request("GET", f"/v1/album/{albumId}") + except BaseException: + raise NCMApiRequestError + + return NCMAlbum.fromApi(response.json()) + + def getDetailsForPlaylist(self, playlistId: int) -> NCMPlaylist: + params = {"id": playlistId} + + try: + response = self._httpClient.request("GET", "/v6/playlist/detail", params=params) + except BaseException: + raise NCMApiRequestError + + return NCMPlaylist.fromApi(response.json()) + + def getLyricsByTrack(self, trackId: int) -> NCMLyrics: + params = { + "id": trackId, + "cp": False, + "lv": 0, + "tv": 0, + "rv": 0, + "kv": 0, + "yv": 0, + "ytv": 0, + "yrv": 0, + } + + try: + response = self._httpClient.request("GET", "/song/lyric/v1", params=params) + except BaseException: + raise NCMApiRequestError + + return NCMLyrics.fromApi(response.json()) diff --git a/src/ncmlyrics/constant.py b/src/ncmlyrics/constant.py new file mode 100644 index 0000000..06137a2 --- /dev/null +++ b/src/ncmlyrics/constant.py @@ -0,0 +1,14 @@ +from httpx import URL +from platformdirs import PlatformDirs + +APP_NAME = "ncmlyrics" +APP_VERSION = "0.1.0" + +NCM_API_BASE_URL = URL("https://interface.music.163.com/api") + +CONFIG_LRC_AUTO_MERGE = True +CONFIG_LRC_AUTO_MERGE_OFFSET = 50 + +CONFIG_API_DETAIL_TRACK_PER_REQUEST = 150 + +PLATFORM = PlatformDirs(appname=APP_NAME, ensure_exists=True) diff --git a/src/ncmlyrics/error.py b/src/ncmlyrics/error.py new file mode 100644 index 0000000..486f0f1 --- /dev/null +++ b/src/ncmlyrics/error.py @@ -0,0 +1,21 @@ +from httpx import RequestError + + +class NCMApiError(Exception): + """使用网易云音乐 API 时出现错误""" + + +class NCMApiRequestError(NCMApiError, RequestError): + """请求网易云音乐 API 时出现错误""" + + +class NCMApiParseError(NCMApiError): + """解析网易云音乐 API 返回的数据时出现错误""" + + +class ParseLinkError(Exception): + """无法解析此分享链接""" + + +class UnsupportLinkError(Exception): + """不支持的分享链接""" diff --git a/src/ncmlyrics/lrc.py b/src/ncmlyrics/lrc.py new file mode 100644 index 0000000..03c9f61 --- /dev/null +++ b/src/ncmlyrics/lrc.py @@ -0,0 +1,158 @@ +from enum import Enum +from pathlib import Path +from re import Match +from re import compile as reCompile + +from .constant import CONFIG_LRC_AUTO_MERGE, CONFIG_LRC_AUTO_MERGE_OFFSET + +LRC_RE_COMMIT = reCompile(r"^\s*#") +LRC_RE_META = reCompile(r"^\s*\[(?Pti|ar|al|au|length|by|offset):\s*(?P.+?)\s*\]\s*$") +LRC_RE_META_NCM_SPICAL = reCompile(r"^\s*\{.*\}\s*$") +LRC_RE_LYRIC = reCompile(r"^\s*(?P(?:\s*\[\d{1,2}:\d{1,2}(?:\.\d{1,3})?\])+)\s*(?P.+?)\s*$") +LRC_RE_LYRIC_TIMELABEL = reCompile(r"\[(?P\d{1,2}):(?P\d{1,2}(?:\.\d{1,3})?)\]") + + +class LrcType(Enum): + Origin = "lrc" + Translation = "tlyric" + Romaji = "romalrc" + + def preety(self) -> str: + match self: + case LrcType.Origin: + return "源" + case LrcType.Translation: + return "译" + case LrcType.Romaji: + return "音" + + +class LrcMetaType(Enum): + Title = "ti" + Artist = "ar" + Album = "al" + Author = "au" + Length = "length" + LrcAuthor = "by" + Offset = "offset" + + +class Lrc: + metadata: dict[LrcMetaType, dict[LrcType, str]] = {} # metaType: lrcType: metaContent + lyrics: dict[int, dict[LrcType, str]] = {} # timestamp: lrcType: lrcContent + + def serializeLyricString(self, lrcType: LrcType, lrcStr: str) -> None: + for line in lrcStr.splitlines(): + # Skip commit lines + if LRC_RE_COMMIT.match(line) is not None: + continue + + # Skip NCM spical metadata lines + if LRC_RE_META_NCM_SPICAL.match(line) is not None: + continue + + matchedMetaDataRow = LRC_RE_META.match(line) + if matchedMetaDataRow is not None: + self.appendMatchedMetaDataRow(lrcType, matchedMetaDataRow) + continue + + matchedLyricRow = LRC_RE_LYRIC.match(line) + if matchedLyricRow is not None: + self.appendMatchedLyricRow(lrcType, matchedLyricRow) + continue + + def appendLyric(self, lrcType: LrcType, timestamps: list[int], lyric: str): + for timestamp in timestamps: + if timestamp in self.lyrics: + self.lyrics[timestamp][lrcType] = lyric + else: + self.lyrics[timestamp] = {lrcType: lyric} + + def appendMatchedMetaDataRow(self, lrcType: LrcType, matchedLine: Match[str]) -> None: + metaType, metaContent = matchedLine.groups() + + try: + metaType = LrcMetaType(metaType) + except ValueError as e: + raise ValueError(f"未知的元数据类型:{e}") + + if metaType in self.metadata: + self.metadata[metaType][lrcType] = metaContent + else: + self.metadata[metaType] = {lrcType: metaContent} + + def appendMatchedLyricRow(self, lrcType: LrcType, matchedLine: Match[str]) -> None: + timelabels, lyric = matchedLine.groups() + timestamps: list[int] = [] + + for timelabel in LRC_RE_LYRIC_TIMELABEL.finditer(timelabels): + timestamps.append(self._timelabel2timestamp(timelabel)) + + if CONFIG_LRC_AUTO_MERGE: + mergedTimestamps: list[int] = [] + + for timestamp in timestamps: + if timestamp in self.lyrics: + mergedTimestamps.append(timestamp) + else: + mergedTimestamps.append(self._mergeOffset(timestamp)) + + timestamps = mergedTimestamps + + self.appendLyric(lrcType, timestamps, lyric) + + def deserializeLyricFile(self) -> str: + return "\n".join(self.deserializeLyricRows()) + + def deserializeLyricRows(self) -> list[str]: + result = [] + + result.extend(self.generateLyricMetaDataRows()) + + for timestamp in sorted(self.lyrics.keys()): + result.extend(self.generateLyricRows(timestamp)) + + return result + + def generateLyricMetaDataRows(self) -> list[str]: + result = [] + + for type in LrcMetaType: + if type in self.metadata: + for lrcType in self.metadata[type].keys(): + result.append(f"[{type.value}: {lrcType.preety()}/{self.metadata[type][lrcType]}]") + + return result + + def generateLyricRows(self, timestamp: int) -> list[str]: + result = [] + + for lrcType in self.lyrics[timestamp].keys(): + result.append(self._timestamp2timelabel(timestamp) + self.lyrics[timestamp][lrcType]) + + return result + + def saveAs(self, path: Path): + with path.open("w+") as fs: + fs.write(self.deserializeLyricFile()) + + def _timelabel2timestamp(self, timelabel: Match[str]) -> int: + minutes, seconds = timelabel.groups() + return round((int(minutes) * 60 + float(seconds)) * 1000) + + def _timestamp2timelabel(self, timestamp: int) -> str: + seconds = timestamp / 1000 + return f"[{seconds//60:02.0f}:{seconds%60:06.3f}]" + + def _mergeOffset(self, timestamp: int) -> int: + result = timestamp + + timestampMin = timestamp - CONFIG_LRC_AUTO_MERGE_OFFSET + timestampMax = timestamp + CONFIG_LRC_AUTO_MERGE_OFFSET + + for existLyric in self.lyrics.keys(): + if timestampMin <= existLyric and existLyric <= timestampMax: + result = existLyric + break + + return result diff --git a/src/ncmlyrics/util.py b/src/ncmlyrics/util.py new file mode 100644 index 0000000..5346e43 --- /dev/null +++ b/src/ncmlyrics/util.py @@ -0,0 +1,103 @@ +from dataclasses import dataclass +from enum import Enum, auto +from pathlib import Path +from re import compile as reCompile +from urllib.parse import parse_qs as parseQuery +from urllib.parse import urlparse as parseUrl +from httpx import get as httpGet + +from .api import NCMTrack +from .error import ParseLinkError, UnsupportLinkError + +RE_ANDROID_ALBUM_SHARE_LINK_PATH = reCompile(r"^/album/(?P\d*)/?$") + + +class LinkType(Enum): + Song = auto() + Album = auto() + Playlist = auto() + + +@dataclass +class Link: + type: LinkType + id: int + + +def parseLink(url: str) -> Link: + parsedUrl = parseUrl(url, allow_fragments=False) + contentType: LinkType | None = None + contentId: int | None = None + + match parsedUrl.netloc: + case "music.163.com": + match parsedUrl.path: + case "/playlist" | "/#/playlist": + contentType = LinkType.Playlist + case "/album" | "/#/album": + contentType = LinkType.Album + case "/song" | "/#/song": + contentType = LinkType.Song + case _: + # Hack for android client shared album link + matchedPath = RE_ANDROID_ALBUM_SHARE_LINK_PATH.match(parsedUrl.path) + if matchedPath is not None: + contentType = LinkType.Album + contentId = int(matchedPath["id"]) + else: + raise UnsupportLinkError(parsedUrl) + case "y.music.163.com": + match parsedUrl.path: + case "/m/playlist": + contentType = LinkType.Playlist + case "/m/song": + contentType = LinkType.Song + case _: + raise UnsupportLinkError(parsedUrl) + case "163cn.tv": + response = httpGet(url) + if response.status_code != 302: + raise ParseLinkError(f"未知的 Api 响应: {response.status_code}") + newUrl = response.headers.get("Location") + if newUrl is None: + raise ParseLinkError("Api 未返回重定向结果") + return parseLink(newUrl) + case _: + raise UnsupportLinkError(parsedUrl) + + if contentId is None: + try: + contentId = int(parseQuery(parsedUrl.query).get("id")[0]) + except Exception: + raise ParseLinkError + + return Link(contentType, contentId) + + +def testTrackSourceExists(track: NCMTrack, path: Path) -> Path | None: + for name in (f"{",".join(track.artists)} - {track.name}", f"{" ".join(track.artists)} - {track.name}"): + for suffix in (".mp3", ".flac", ".ncm"): + result = path.joinpath(name).with_suffix(suffix) + if result.exists(): + return result + return None + + +def pickOutput(track: NCMTrack, outputs: list[Path], forceSourceExists: bool = False) -> Path | None: + match len(outputs): + case 0: + result = testTrackSourceExists(track, Path()) + if result is not None: + return result.with_suffix(".lrc") + return None if forceSourceExists else Path(f"{",".join(track.artists)} - {track.name}.lrc") + case 1: + result = testTrackSourceExists(track, outputs[0]) + if result is not None: + return result.with_suffix(".lrc") + return None if forceSourceExists else outputs[0] / f"{",".join(track.artists)} - {track.name}.lrc" + case _: + for output in outputs: + result = testTrackSourceExists(track, output) + if result is not None: + return result.with_suffix(".lrc") + return None if forceSourceExists else outputs[-1] / f"{",".join(track.artists)} - {track.name}.lrc" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..7a9a217 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +from .test_utils import TestUtils + +__all__ = ["TestUtils"] diff --git a/tests/resource/util/pickOutput/1/testArtist - testTrack1.mp3 b/tests/resource/util/pickOutput/1/testArtist - testTrack1.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/tests/resource/util/pickOutput/2/testArtist - testTrack2.mp3 b/tests/resource/util/pickOutput/2/testArtist - testTrack2.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/tests/resource/util/testTrackSourceExists/FlacArtist1 FlacArtist2 - FlacName.flac b/tests/resource/util/testTrackSourceExists/FlacArtist1 FlacArtist2 - FlacName.flac new file mode 100644 index 0000000..e69de29 diff --git a/tests/resource/util/testTrackSourceExists/Mp3Artist - Mp3Name.mp3 b/tests/resource/util/testTrackSourceExists/Mp3Artist - Mp3Name.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/tests/resource/util/testTrackSourceExists/Mp3Artist1,Mp3Artist2 - Mp3Name.mp3 b/tests/resource/util/testTrackSourceExists/Mp3Artist1,Mp3Artist2 - Mp3Name.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/tests/resource/util/testTrackSourceExists/NcmArtist1,NcmArtist2 - NcmName.flac b/tests/resource/util/testTrackSourceExists/NcmArtist1,NcmArtist2 - NcmName.flac new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..1449bfb --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,159 @@ +from pathlib import Path +from unittest import TestCase + +from ncmlyrics.api import NCMTrack +from ncmlyrics.error import ParseLinkError, UnsupportLinkError +from ncmlyrics.util import Link, LinkType, parseLink, testTrackSourceExists, pickOutput + + +class TestUtils(TestCase): + def test_parseLink_Windows(self): + self.assertEqual( + parseLink("https://music.163.com/playlist?id=444817519"), + Link(LinkType.Playlist, 444817519), + msg="Shared playlist from NCM Windows Client", + ) + + self.assertEqual( + parseLink("https://music.163.com/album?id=34609577"), + Link(LinkType.Album, 34609577), + msg="Shared album from NCM Windows Client", + ) + + self.assertEqual( + parseLink("https://music.163.com/song?id=2621105420"), + Link(LinkType.Song, 2621105420), + msg="Shared song from NCM Windows Client", + ) + + def test_parseLink_Website(self): + self.assertEqual( + parseLink("https://music.163.com/#/playlist?id=444817519"), + Link(LinkType.Playlist, 444817519), + msg="Playlist from NCM Website", + ) + + self.assertEqual( + parseLink("https://music.163.com/#/album?id=34609577"), + Link(LinkType.Album, 34609577), + msg="Album from NCM Website", + ) + + self.assertEqual( + parseLink("https://music.163.com/#/song?id=2621105420"), + Link(LinkType.Song, 2621105420), + msg="Song from NCM Website", + ) + + def test_parseLink_Android(self): + self.assertEqual( + parseLink("https://y.music.163.com/m/playlist?id=2224276126"), + Link(LinkType.Playlist, 2224276126), + msg="Shared playlist from NCM Android Client", + ) + + self.assertEqual( + parseLink("http://music.163.com/album/3139945/"), + Link(LinkType.Album, 3139945), + msg="Shared album from NCM Android Client", + ) + + self.assertEqual( + parseLink("https://y.music.163.com/m/song?id=2604307454"), + Link(LinkType.Song, 2604307454), + msg="Shared song from NCM Android Client", + ) + + def test_parseLink_302(self): + self.assertEqual( + parseLink("http://163cn.tv/xpaQwii"), + Link(LinkType.Song, 413077069), + msg="Shared song from NCM Android Client player", + ) + + def test_parseLink_UnsupportShareLinkError(self): + self.assertRaises( + UnsupportLinkError, + parseLink, + "https://www.google.com/", + ) + + self.assertRaises( + UnsupportLinkError, + parseLink, + "https://music.163.com/unsupport?id=123", + ) + + self.assertRaises( + UnsupportLinkError, + parseLink, + "https://music.163.com/album/123a", + ) + + def test_parseLink_ParseShareLinkError(self): + self.assertRaises( + ParseLinkError, + parseLink, + "https://music.163.com/playlist?id=123a", + ) + + def test_testTrackSourceExists(self): + resources = Path("tests/resource/util/testTrackSourceExists") + + self.assertEqual( + testTrackSourceExists(NCMTrack(0, "Mp3Name", ["Mp3Artist"]), resources), + resources / "Mp3Artist - Mp3Name.mp3", + ) + + self.assertEqual( + testTrackSourceExists(NCMTrack(0, "Mp3Name", ["Mp3Artist1", "Mp3Artist2"]), resources), + resources / "Mp3Artist1,Mp3Artist2 - Mp3Name.mp3", + ) + + self.assertEqual( + testTrackSourceExists(NCMTrack(0, "FlacName", ["FlacArtist1", "FlacArtist2"]), resources), + resources / "FlacArtist1 FlacArtist2 - FlacName.flac", + ) + + self.assertEqual( + testTrackSourceExists(NCMTrack(0, "NcmName", ["NcmArtist1", "NcmArtist2"]), resources), + resources / "NcmArtist1,NcmArtist2 - NcmName.flac", + ) + + def test_pickOutput(self): + resources = Path("tests/resource/util/pickOutput") + + self.assertEqual( + pickOutput(NCMTrack(0, "testTrack", ["testArtist"]), []), + Path("testArtist - testTrack.lrc"), + ) + + self.assertEqual( + pickOutput(NCMTrack(0, "testTrack", ["testArtist"]), [resources / "1"]), + resources / "1" / "testArtist - testTrack.lrc", + ) + + self.assertEqual( + pickOutput(NCMTrack(0, "testTrack1", ["testArtist"]), [resources / "1", resources / "2"]), + resources / "1" / "testArtist - testTrack1.lrc", + ) + + self.assertEqual( + pickOutput(NCMTrack(0, "testTrack2", ["testArtist"]), [resources / "1", resources / "2"]), + resources / "2" / "testArtist - testTrack2.lrc", + ) + + self.assertEqual( + pickOutput(NCMTrack(0, "testTrack1", ["testArtist"]), [resources / "2", resources / "1"]), + resources / "1" / "testArtist - testTrack1.lrc", + ) + + self.assertEqual( + pickOutput(NCMTrack(0, str(), [str()]), [], True), + None, + ) + + self.assertEqual( + pickOutput(NCMTrack(0, "testTrack0", ["testArtist"]), [resources / "1"], True), + None, + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..67d3fe7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,314 @@ +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/49/f3f17ec11c4a91fe79275c426658e509b07547f874b14c1a526d86a83fc8/anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", size = 170983 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/ef/7a4f225581a0d7886ea28359179cb861d7fbcdefad29663fc1167b86f69f/anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a", size = 89631 }, +] + +[[package]] +name = "brotli" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/c2/f9e977608bdf958650638c3f1e28f85a1b075f075ebbe77db8555463787b/Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", size = 7372270 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/88/564958cedce636d0f1bed313381dfc4b4e3d3f6015a63dae6146e1b8c65c/Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", size = 873081 }, + { url = "https://files.pythonhosted.org/packages/58/79/b7026a8bb65da9a6bb7d14329fd2bd48d2b7f86d7329d5cc8ddc6a90526f/Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", size = 446244 }, + { url = "https://files.pythonhosted.org/packages/e5/18/c18c32ecea41b6c0004e15606e274006366fe19436b6adccc1ae7b2e50c2/Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", size = 2906505 }, + { url = "https://files.pythonhosted.org/packages/08/c8/69ec0496b1ada7569b62d85893d928e865df29b90736558d6c98c2031208/Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", size = 2944152 }, + { url = "https://files.pythonhosted.org/packages/ab/fb/0517cea182219d6768113a38167ef6d4eb157a033178cc938033a552ed6d/Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", size = 2919252 }, + { url = "https://files.pythonhosted.org/packages/c7/53/73a3431662e33ae61a5c80b1b9d2d18f58dfa910ae8dd696e57d39f1a2f5/Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", size = 2845955 }, + { url = "https://files.pythonhosted.org/packages/55/ac/bd280708d9c5ebdbf9de01459e625a3e3803cce0784f47d633562cf40e83/Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", size = 2914304 }, + { url = "https://files.pythonhosted.org/packages/76/58/5c391b41ecfc4527d2cc3350719b02e87cb424ef8ba2023fb662f9bf743c/Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", size = 2814452 }, + { url = "https://files.pythonhosted.org/packages/c7/4e/91b8256dfe99c407f174924b65a01f5305e303f486cc7a2e8a5d43c8bec3/Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", size = 2938751 }, + { url = "https://files.pythonhosted.org/packages/5a/a6/e2a39a5d3b412938362bbbeba5af904092bf3f95b867b4a3eb856104074e/Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", size = 2933757 }, + { url = "https://files.pythonhosted.org/packages/5f/3b/4e3fd1893eb3bbfef8e5a80d4508bec17a57bb92d586c85c12d28666bb13/Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", size = 333276 }, + { url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255 }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "h2" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/32/fec683ddd10629ea4ea46d206752a95a2d8a48c22521edd70b142488efe1/h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb", size = 2145593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e5/db6d438da759efbb488c4f3fbdab7764492ff3c3f953132efa6b9f0e9e53/h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d", size = 57488 }, +] + +[[package]] +name = "hpack" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/fda93fb4d957db19b0f6b370e79d586b3e8528b20252c729c476a2c02954/hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095", size = 49117 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/34/e8b383f35b77c402d28563d2b8f83159319b509bc5f760b15d60b0abf165/hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c", size = 32611 }, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/b0/5e8b8674f8d203335a62fdfcfa0d11ebe09e23613c3391033cbba35f7926/httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", size = 83234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/d4/e5d7e4f2174f8a4d63c8897d79eb8fe2503f7ecc03282fee1fa2719c2704/httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5", size = 77926 }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, +] + +[[package]] +name = "hyperframe" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/2a/4747bff0a17f7281abe73e955d60d80aae537a5d203f417fa1c2e7578ebb/hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914", size = 25008 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/de/85a784bcc4a3779d1753a7ec2dee5de90e18c7bcf402e71b51fcf150b129/hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15", size = 12389 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "ncmlyrics" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "brotli" }, + { name = "click" }, + { name = "h2" }, + { name = "httpx" }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "zstandard" }, +] + +[package.metadata] +requires-dist = [ + { name = "brotli", specifier = ">=1.1.0" }, + { name = "click", specifier = ">=8.1.7" }, + { name = "h2", specifier = ">=4.1.0" }, + { name = "httpx", specifier = ">=0.27.2" }, + { name = "platformdirs", specifier = ">=4.3.6" }, + { name = "rich", specifier = ">=13.8.1" }, + { name = "zstandard", specifier = ">=0.23.0" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "rich" +version = "13.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/76/40f084cb7db51c9d1fa29a7120717892aeda9a7711f6225692c957a93535/rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a", size = 222080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/11/dadb85e2bd6b1f1ae56669c3e1f0410797f9605d752d68fb47b77f525b31/rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06", size = 241608 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "zstandard" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/83/f23338c963bd9de687d47bf32efe9fd30164e722ba27fb59df33e6b1719b/zstandard-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094", size = 788713 }, + { url = "https://files.pythonhosted.org/packages/5b/b3/1a028f6750fd9227ee0b937a278a434ab7f7fdc3066c3173f64366fe2466/zstandard-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8", size = 633459 }, + { url = "https://files.pythonhosted.org/packages/26/af/36d89aae0c1f95a0a98e50711bc5d92c144939efc1f81a2fcd3e78d7f4c1/zstandard-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1", size = 4945707 }, + { url = "https://files.pythonhosted.org/packages/cd/2e/2051f5c772f4dfc0aae3741d5fc72c3dcfe3aaeb461cc231668a4db1ce14/zstandard-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072", size = 5306545 }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a11c97b087f89cab030fa71206963090d2fecd8eb83e67bb8f3ffb84c024/zstandard-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20", size = 5337533 }, + { url = "https://files.pythonhosted.org/packages/fc/79/edeb217c57fe1bf16d890aa91a1c2c96b28c07b46afed54a5dcf310c3f6f/zstandard-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373", size = 5436510 }, + { url = "https://files.pythonhosted.org/packages/81/4f/c21383d97cb7a422ddf1ae824b53ce4b51063d0eeb2afa757eb40804a8ef/zstandard-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db", size = 4859973 }, + { url = "https://files.pythonhosted.org/packages/ab/15/08d22e87753304405ccac8be2493a495f529edd81d39a0870621462276ef/zstandard-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772", size = 4936968 }, + { url = "https://files.pythonhosted.org/packages/eb/fa/f3670a597949fe7dcf38119a39f7da49a8a84a6f0b1a2e46b2f71a0ab83f/zstandard-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105", size = 5467179 }, + { url = "https://files.pythonhosted.org/packages/4e/a9/dad2ab22020211e380adc477a1dbf9f109b1f8d94c614944843e20dc2a99/zstandard-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba", size = 4848577 }, + { url = "https://files.pythonhosted.org/packages/08/03/dd28b4484b0770f1e23478413e01bee476ae8227bbc81561f9c329e12564/zstandard-0.23.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd", size = 4693899 }, + { url = "https://files.pythonhosted.org/packages/2b/64/3da7497eb635d025841e958bcd66a86117ae320c3b14b0ae86e9e8627518/zstandard-0.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a", size = 5199964 }, + { url = "https://files.pythonhosted.org/packages/43/a4/d82decbab158a0e8a6ebb7fc98bc4d903266bce85b6e9aaedea1d288338c/zstandard-0.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90", size = 5655398 }, + { url = "https://files.pythonhosted.org/packages/f2/61/ac78a1263bc83a5cf29e7458b77a568eda5a8f81980691bbc6eb6a0d45cc/zstandard-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35", size = 5191313 }, + { url = "https://files.pythonhosted.org/packages/e7/54/967c478314e16af5baf849b6ee9d6ea724ae5b100eb506011f045d3d4e16/zstandard-0.23.0-cp312-cp312-win32.whl", hash = "sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d", size = 430877 }, + { url = "https://files.pythonhosted.org/packages/75/37/872d74bd7739639c4553bf94c84af7d54d8211b626b352bc57f0fd8d1e3f/zstandard-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b", size = 495595 }, + { url = "https://files.pythonhosted.org/packages/80/f1/8386f3f7c10261fe85fbc2c012fdb3d4db793b921c9abcc995d8da1b7a80/zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9", size = 788975 }, + { url = "https://files.pythonhosted.org/packages/16/e8/cbf01077550b3e5dc86089035ff8f6fbbb312bc0983757c2d1117ebba242/zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a", size = 633448 }, + { url = "https://files.pythonhosted.org/packages/06/27/4a1b4c267c29a464a161aeb2589aff212b4db653a1d96bffe3598f3f0d22/zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2", size = 4945269 }, + { url = "https://files.pythonhosted.org/packages/7c/64/d99261cc57afd9ae65b707e38045ed8269fbdae73544fd2e4a4d50d0ed83/zstandard-0.23.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5", size = 5306228 }, + { url = "https://files.pythonhosted.org/packages/7a/cf/27b74c6f22541f0263016a0fd6369b1b7818941de639215c84e4e94b2a1c/zstandard-0.23.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f", size = 5336891 }, + { url = "https://files.pythonhosted.org/packages/fa/18/89ac62eac46b69948bf35fcd90d37103f38722968e2981f752d69081ec4d/zstandard-0.23.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed", size = 5436310 }, + { url = "https://files.pythonhosted.org/packages/a8/a8/5ca5328ee568a873f5118d5b5f70d1f36c6387716efe2e369010289a5738/zstandard-0.23.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea", size = 4859912 }, + { url = "https://files.pythonhosted.org/packages/ea/ca/3781059c95fd0868658b1cf0440edd832b942f84ae60685d0cfdb808bca1/zstandard-0.23.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847", size = 4936946 }, + { url = "https://files.pythonhosted.org/packages/ce/11/41a58986f809532742c2b832c53b74ba0e0a5dae7e8ab4642bf5876f35de/zstandard-0.23.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171", size = 5466994 }, + { url = "https://files.pythonhosted.org/packages/83/e3/97d84fe95edd38d7053af05159465d298c8b20cebe9ccb3d26783faa9094/zstandard-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840", size = 4848681 }, + { url = "https://files.pythonhosted.org/packages/6e/99/cb1e63e931de15c88af26085e3f2d9af9ce53ccafac73b6e48418fd5a6e6/zstandard-0.23.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690", size = 4694239 }, + { url = "https://files.pythonhosted.org/packages/ab/50/b1e703016eebbc6501fc92f34db7b1c68e54e567ef39e6e59cf5fb6f2ec0/zstandard-0.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b", size = 5200149 }, + { url = "https://files.pythonhosted.org/packages/aa/e0/932388630aaba70197c78bdb10cce2c91fae01a7e553b76ce85471aec690/zstandard-0.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057", size = 5655392 }, + { url = "https://files.pythonhosted.org/packages/02/90/2633473864f67a15526324b007a9f96c96f56d5f32ef2a56cc12f9548723/zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33", size = 5191299 }, + { url = "https://files.pythonhosted.org/packages/b0/4c/315ca5c32da7e2dc3455f3b2caee5c8c2246074a61aac6ec3378a97b7136/zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd", size = 430862 }, + { url = "https://files.pythonhosted.org/packages/a2/bf/c6aaba098e2d04781e8f4f7c0ba3c7aa73d00e4c436bcc0cf059a66691d1/zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b", size = 495578 }, +]