diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index a7dc8a2..0000000 --- a/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[run] -omit = **/tests/* - -[html] -directory = tests/coverage/htmlcov \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 8d34ac7..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: continuous integration -on: [push] -env: - DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} - DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} - SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} - SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} - SPOTIFY_SP_DC: ${{ secrets.SPOTIFY_SP_DC }} - TIDAL_CLIENT_ID: ${{ secrets.TIDAL_CLIENT_ID }} - TIDAL_CLIENT_SECRET: ${{ secrets.TIDAL_CLIENT_SECRET }} - TIDAL_PRIVATE_CLIENT_ID: ${{ secrets.TIDAL_PRIVATE_CLIENT_ID }} - TIDAL_PRIVATE_CLIENT_SECRET: ${{ secrets.TIDAL_PRIVATE_CLIENT_SECRET }} -jobs: - build: - name: continuous-integration - runs-on: ubuntu-latest - defaults: - run: - shell: bash -el {0} - timeout-minutes: 60 - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: "3.9" - - uses: FedericoCarboni/setup-ffmpeg@v2 - id: setup-ffmpeg - - name: pip-install-dependencies - run: python3 -m pip install -r requirements_minimal.txt - - name: ruff-lint - run: | - python3 -m pip install ruff - ruff check --target-version=py39 . - continue-on-error: true - - name: pytest-test - run: | - python3 -m pip install pytest - pytest \ No newline at end of file diff --git a/.gitignore b/.gitignore index a95b3b8..03ca24b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ +*.egg-info/ **/__pycache__/ **/.pytest_cache/ +.coverage .ruff_cache/ -_examples/ dist/ -jupyter_execute/ -src/minim.egg-info -.coverage \ No newline at end of file +docs/build/ +docs/source/**/autosummary/ +jupyter_execute/ \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..5e352f0 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 +python: + install: + - method: pip + path: . +conda: + environment: environment.yaml +build: + os: ubuntu-lts-latest + tools: + python: mambaforge-latest +sphinx: + configuration: docs/source/conf.py \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index e72bfdd..0000000 --- a/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - 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 -. \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 50493d2..0000000 --- a/README.md +++ /dev/null @@ -1,604 +0,0 @@ - - - - minim logo - -

- -# Minim - -[![continuous-integration](https://github.com/bbye98/minim/actions/workflows/ci.yml/badge.svg)](https://github.com/bbye98/minim/actions/workflows/ci.yml) - -Minim is a lightweight Python 3 library that can interface with APIs -by popular music services—iTunes, Qobuz, Spotify, and TIDAL—and operate -on audio files, such as updating metadata and converting between audio -formats. - -* **Documentation**: https://bbye98.github.io/minim/ - -## Features - -* [`minim.audio`](https://github.com/bbye98/minim/blob/main/src/minim/audio.py): - Audio file handlers for reading and writing metadata and converting - between audio formats. -* [`minim.itunes`](https://github.com/bbye98/minim/blob/main/src/minim/itunes.py): - A client for the iTunes Search API. -* [`minim.qobuz`](https://github.com/bbye98/minim/blob/main/src/minim/qobuz.py): - A client for the Qobuz API with support for the password grant type - for user authentication and user authentication token caching. -* [`minim.spotify`](https://github.com/bbye98/minim/blob/main/src/minim/spotify.py): - Clients for the Spotify Lyrics service and the Spotify Web API with - support for the authorization code, authorization code with proof key - for code exchange (PKCE), and client credentials grant types, and - access token caching. -* [`minim.tidal`](https://github.com/bbye98/minim/blob/main/src/minim/tidal.py): - Clients for the old and new TIDAL APIs with support for the - authorization code with PKCE and client credentials grant types, and - access token caching. - -## Installation - -Minim requires Python 3.9 or later. - -Clone the repository and install the package using pip: - - git clone https://github.com/bbye98/minim.git - cd minim - python -m pip install -e . - -## Examples - -- Import Minim and create clients for the different APIs. Additional - keyword arguments can be passed to the constructors to control user - authentication. - - >>> from minim import itunes, qobuz, spotify, tidal - >>> client_itunes = itunes.SearchAPI() - >>> client_qobuz = qobuz.PrivateAPI() - >>> client_spotify = spotify.WebAPI(flow="web_player") - >>> client_tidal = tidal.PrivateAPI(client_id=) - -- Search for and retrieve information about an artist, such as the EDM - group Galantis: - - **iTunes Search API** - - >>> client_itunes.search("Galantis", entity="musicArtist", - ... limit=1)["results"][0] - -
- Output - - { - "wrapperType": "artist", - "artistType": "Artist", - "artistName": "Galantis", - "artistLinkUrl": "https://music.apple.com/us/artist/galantis/543322169?uo=4", - "artistId": 543322169, - "amgArtistId": 2616267, - "primaryGenreName": "Dance", - "primaryGenreId": 17 - } - -
- - **Private Qobuz API** - - >>> client_qobuz.search("Galantis", limit=1, - ... strict=True)["artists"]["items"][0] - -
- Output - - { - "picture": "https://static.qobuz.com/images/artists/covers/small/8dcf30e5c8e30281ecbb13b0886426c8.jpg", - "image": { - "small": "https://static.qobuz.com/images/artists/covers/small/8dcf30e5c8e30281ecbb13b0886426c8.jpg", - "medium": "https://static.qobuz.com/images/artists/covers/medium/8dcf30e5c8e30281ecbb13b0886426c8.jpg", - "large": "https://static.qobuz.com/images/artists/covers/large/8dcf30e5c8e30281ecbb13b0886426c8.jpg", - "extralarge": "https://static.qobuz.com/images/artists/covers/large/8dcf30e5c8e30281ecbb13b0886426c8.jpg", - "mega": "https://static.qobuz.com/images/artists/covers/large/8dcf30e5c8e30281ecbb13b0886426c8.jpg" - }, - "name": "Galantis", - "slug": "galantis", - "albums_count": 126, - "id": 865362 - } - -
- - **Spotify Web API** - - >>> client_spotify.search("Galantis", "artist", limit=1)["items"][0] - -
- Output - - { - "external_urls": { - "spotify": "https://open.spotify.com/artist/4sTQVOfp9vEMCemLw50sbu" - }, - "followers": { - "href": null, - "total": 3373205 - }, - "genres": [ - "dance pop", - "edm", - "pop", - "pop dance" - ], - "href": "https://api.spotify.com/v1/artists/4sTQVOfp9vEMCemLw50sbu", - "id": "4sTQVOfp9vEMCemLw50sbu", - "images": [ - { - "height": 640, - "url": "https://i.scdn.co/image/ab6761610000e5eb7bda087d6fb48d481efd3344", - "width": 640 - }, - { - "height": 320, - "url": "https://i.scdn.co/image/ab676161000051747bda087d6fb48d481efd3344", - "width": 320 - }, - { - "height": 160, - "url": "https://i.scdn.co/image/ab6761610000f1787bda087d6fb48d481efd3344", - "width": 160 - } - ], - "name": "Galantis", - "popularity": 67, - "type": "artist", - "uri": "spotify:artist:4sTQVOfp9vEMCemLw50sbu" - } - -
- - **Private TIDAL API** - - >>> client_tidal.search("Galantis", type="artist", limit=1)["items"][0] - -
- Output - - { - "id": 4676988, - "name": "Galantis", - "artistTypes": [ - "ARTIST", - "CONTRIBUTOR" - ], - "url": "http://www.tidal.com/artist/4676988", - "picture": "a627e21c-60f7-4e90-b2bb-e50b178c4f0b", - "popularity": 72, - "artistRoles": [ - { - "categoryId": -1, - "category": "Artist" - }, - { - "categoryId": 11, - "category": "Performer" - }, - { - "categoryId": 3, - "category": "Engineer" - }, - { - "categoryId": 10, - "category": "Production team" - }, - { - "categoryId": 1, - "category": "Producer" - }, - { - "categoryId": 2, - "category": "Songwriter" - } - ], - "mixes": { - "ARTIST_MIX": "000202a7e72fd90d0c0df2ed56ddea" - } - } - -
- -- Search for and retrieve information about a track, such as "Everybody Talks" by Neon Trees: - - **iTunes Search API** - - >>> client_itunes.search("Everybody Talks", media="music", - ... limit=1)["results"][0] - -
- Output - - { - "wrapperType": "track", - "kind": "song", - "artistId": 350172836, - "collectionId": 1443469527, - "trackId": 1443469581, - "artistName": "Neon Trees", - "collectionName": "Picture Show", - "trackName": "Everybody Talks", - "collectionCensoredName": "Picture Show", - "trackCensoredName": "Everybody Talks", - "artistViewUrl": "https://music.apple.com/us/artist/neon-trees/350172836?uo=4", - "collectionViewUrl": "https://music.apple.com/us/album/everybody-talks/1443469527?i=1443469581&uo=4", - "trackViewUrl": "https://music.apple.com/us/album/everybody-talks/1443469527?i=1443469581&uo=4", - "previewUrl": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview122/v4/5c/29/bf/5c29bf6b-ca2c-4e8b-2be6-c51a282c7dae/mzaf_1255557534804450018.plus.aac.p.m4a", - "artworkUrl30": "https://is1-ssl.mzstatic.com/image/thumb/Music115/v4/80/e3/95/80e39565-35f9-2496-c6f8-6572490c4a7b/12UMGIM12509.rgb.jpg/30x30bb.jpg", - "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Music115/v4/80/e3/95/80e39565-35f9-2496-c6f8-6572490c4a7b/12UMGIM12509.rgb.jpg/60x60bb.jpg", - "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Music115/v4/80/e3/95/80e39565-35f9-2496-c6f8-6572490c4a7b/12UMGIM12509.rgb.jpg/100x100bb.jpg", - "collectionPrice": 6.99, - "trackPrice": 1.29, - "releaseDate": "2011-12-19T12:00:00Z", - "collectionExplicitness": "explicit", - "trackExplicitness": "explicit", - "discCount": 1, - "discNumber": 1, - "trackCount": 12, - "trackNumber": 3, - "trackTimeMillis": 177280, - "country": "USA", - "currency": "USD", - "primaryGenreName": "Alternative", - "contentAdvisoryRating": "Explicit", - "isStreamable": true - } - -
- - **Private Qobuz API** - - >>> client_qobuz.search("Everybody Talks", "ReleaseName", limit=1, - ... strict=True)["tracks"]["items"][0] - -
- Output - - { - "maximum_bit_depth": 16, - "copyright": "\u2117 2011 UMG Recordings, Inc.", - "performers": "Justin Meldal-Johnsen, Producer, Guitar, Additional Keyboards, Percussion, Programmer, AssociatedPerformer - Tim Pagnotta, ComposerLyricist - Greg Collins, Engineer, StudioPersonnel - Wesley Seidman, Asst. Recording Engineer, StudioPersonnel - Tyler Glenn, ComposerLyricist - Neon Trees, MainArtist - Matt Wiggers, Asst. Recording Engineer, StudioPersonnel - Bill Bush, Mixer, StudioPersonnel", - "audio_info": { - "replaygain_track_peak": 0.999969, - "replaygain_track_gain": -11.63 - }, - "performer": { - "name": "Neon Trees", - "id": 470727 - }, - "album": { - "image": { - "small": "https://static.qobuz.com/images/covers/42/54/0060252795442_230.jpg", - "thumbnail": "https://static.qobuz.com/images/covers/42/54/0060252795442_50.jpg", - "large": "https://static.qobuz.com/images/covers/42/54/0060252795442_600.jpg" - }, - "maximum_bit_depth": 16, - "media_count": 1, - "artist": { - "image": null, - "name": "Neon Trees", - "id": 470727, - "albums_count": 42, - "slug": "neon-trees", - "picture": null - }, - "upc": "0060252795442", - "released_at": 1325372400, - "label": { - "name": "Mercury Records", - "id": 17487, - "albums_count": 774, - "supplier_id": 1, - "slug": "mercury-records" - }, - "title": "Picture Show", - "qobuz_id": 5653617, - "version": null, - "duration": 2785, - "parental_warning": true, - "tracks_count": 11, - "popularity": 0, - "genre": { - "path": [112, 119, 113], - "color": "#0070ef", - "name": "Alternative & Indie", - "id": 113, - "slug": "alternatif-et-inde" - }, - "maximum_channel_count": 2, - "id": "0060252795442", - "maximum_sampling_rate": 44.1, - "previewable": true, - "sampleable": true, - "displayable": true, - "streamable": true, - "streamable_at": 1683529200, - "downloadable": false, - "purchasable_at": null, - "purchasable": false, - "release_date_original": "2012-01-01", - "release_date_download": "2012-01-01", - "release_date_stream": "2012-01-01", - "release_date_purchase": "2012-01-01", - "hires": false, - "hires_streamable": false - }, - "work": null, - "composer": { - "name": "Tyler Glenn", - "id": 583118 - }, - "isrc": "USUM71119189", - "title": "Everybody Talks", - "version": "Album Version", - "duration": 177, - "parental_warning": true, - "track_number": 3, - "maximum_channel_count": 2, - "id": 5653620, - "media_number": 1, - "maximum_sampling_rate": 44.1, - "release_date_original": null, - "release_date_download": null, - "release_date_stream": null, - "release_date_purchase": null, - "purchasable": true, - "streamable": true, - "previewable": true, - "sampleable": true, - "downloadable": true, - "displayable": true, - "purchasable_at": 1683702000, - "streamable_at": 1683529200, - "hires": false, - "hires_streamable": false - } - -
- - **Spotify Web API** - - >>> client_spotify.search("Everybody Talks", "track", limit=1)["items"][0] - -
- Output - - { - "album": { - "album_type": "album", - "artists": [ - { - "external_urls": { - "spotify": "https://open.spotify.com/artist/0RpddSzUHfncUWNJXKOsjy" - }, - "href": "https://api.spotify.com/v1/artists/0RpddSzUHfncUWNJXKOsjy", - "id": "0RpddSzUHfncUWNJXKOsjy", - "name": "Neon Trees", - "type": "artist", - "uri": "spotify:artist:0RpddSzUHfncUWNJXKOsjy" - } - ], - "available_markets": [ - "AR", "AU", "AT", "BE", "BO", "BR", "BG", - "CA", "CL", "CO", "CR", "CY", "CZ", "DK", - "DO", "DE", "EC", "EE", "SV", "FI", "FR", - "GR", "GT", "HN", "HK", "HU", "IS", "IE", - "IT", "LV", "LT", "LU", "MY", "MT", "NL", - "NZ", "NI", "NO", "PA", "PY", "PE", "PH", - "PL", "PT", "SG", "SK", "ES", "SE", "CH", - "TW", "TR", "UY", "US", "GB", "AD", "LI", - "MC", "ID", "TH", "VN", "RO", "IL", "ZA", - "SA", "AE", "BH", "QA", "OM", "KW", "EG", - "TN", "LB", "JO", "PS", "IN", "BY", "KZ", - "MD", "UA", "AL", "BA", "HR", "ME", "MK", - "RS", "SI", "KR", "BD", "PK", "LK", "GH", - "KE", "NG", "TZ", "UG", "AG", "AM", "BS", - "BB", "BZ", "BT", "BW", "BF", "CV", "CW", - "DM", "FJ", "GM", "GD", "GW", "GY", "HT", - "JM", "KI", "LS", "LR", "MW", "MV", "ML", - "MH", "FM", "NA", "NR", "NE", "PW", "PG", - "WS", "ST", "SN", "SC", "SL", "SB", "KN", - "LC", "VC", "SR", "TL", "TO", "TT", "TV", - "AZ", "BN", "BI", "KH", "CM", "TD", "KM", - "GQ", "SZ", "GA", "GN", "KG", "LA", "MO", - "MR", "MN", "NP", "RW", "TG", "UZ", "ZW", - "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", - "ZM", "CD", "CG", "IQ", "TJ", "VE", "XK" - ], - "external_urls": { - "spotify": "https://open.spotify.com/album/0uRFz92JmjwDbZbB7hEBIr" - }, - "href": "https://api.spotify.com/v1/albums/0uRFz92JmjwDbZbB7hEBIr", - "id": "0uRFz92JmjwDbZbB7hEBIr", - "images": [ - { - "height": 640, - "url": "https://i.scdn.co/image/ab67616d0000b2734a6c0376235e5aa44e59d2c2", - "width": 640 - }, - { - "height": 300, - "url": "https://i.scdn.co/image/ab67616d00001e024a6c0376235e5aa44e59d2c2", - "width": 300 - }, - { - "height": 64, - "url": "https://i.scdn.co/image/ab67616d000048514a6c0376235e5aa44e59d2c2", - "width": 64 - } - ], - "name": "Picture Show", - "release_date": "2012-01-01", - "release_date_precision": "day", - "total_tracks": 11, - "type": "album", - "uri": "spotify:album:0uRFz92JmjwDbZbB7hEBIr" - }, - "artists": [ - { - "external_urls": { - "spotify": "https://open.spotify.com/artist/0RpddSzUHfncUWNJXKOsjy" - }, - "href": "https://api.spotify.com/v1/artists/0RpddSzUHfncUWNJXKOsjy", - "id": "0RpddSzUHfncUWNJXKOsjy", - "name": "Neon Trees", - "type": "artist", - "uri": "spotify:artist:0RpddSzUHfncUWNJXKOsjy" - } - ], - "available_markets": [ - "AR", "AU", "AT", "BE", "BO", "BR", "BG", - "CA", "CL", "CO", "CR", "CY", "CZ", "DK", - "DO", "DE", "EC", "EE", "SV", "FI", "FR", - "GR", "GT", "HN", "HK", "HU", "IS", "IE", - "IT", "LV", "LT", "LU", "MY", "MT", "NL", - "NZ", "NI", "NO", "PA", "PY", "PE", "PH", - "PL", "PT", "SG", "SK", "ES", "SE", "CH", - "TW", "TR", "UY", "US", "GB", "AD", "LI", - "MC", "ID", "TH", "VN", "RO", "IL", "ZA", - "SA", "AE", "BH", "QA", "OM", "KW", "EG", - "TN", "LB", "JO", "PS", "IN", "BY", "KZ", - "MD", "UA", "AL", "BA", "HR", "ME", "MK", - "RS", "SI", "KR", "BD", "PK", "LK", "GH", - "KE", "NG", "TZ", "UG", "AG", "AM", "BS", - "BB", "BZ", "BT", "BW", "BF", "CV", "CW", - "DM", "FJ", "GM", "GD", "GW", "GY", "HT", - "JM", "KI", "LS", "LR", "MW", "MV", "ML", - "MH", "FM", "NA", "NR", "NE", "PW", "PG", - "WS", "ST", "SN", "SC", "SL", "SB", "KN", - "LC", "VC", "SR", "TL", "TO", "TT", "TV", - "AZ", "BN", "BI", "KH", "CM", "TD", "KM", - "GQ", "SZ", "GA", "GN", "KG", "LA", "MO", - "MR", "MN", "NP", "RW", "TG", "UZ", "ZW", - "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", - "ZM", "CD", "CG", "IQ", "TJ", "VE", "XK" - ], - "disc_number": 1, - "duration_ms": 177280, - "explicit": true, - "external_ids": { - "isrc": "USUM71119189" - }, - "external_urls": { - "spotify": "https://open.spotify.com/track/2iUmqdfGZcHIhS3b9E9EWq" - }, - "href": "https://api.spotify.com/v1/tracks/2iUmqdfGZcHIhS3b9E9EWq", - "id": "2iUmqdfGZcHIhS3b9E9EWq", - "is_local": false, - "name": "Everybody Talks", - "popularity": 81, - "preview_url": null, - "track_number": 3, - "type": "track", - "uri": "spotify:track:2iUmqdfGZcHIhS3b9E9EWq" - } - -
- - **Private TIDAL API** - - >>> client_tidal.search("Everybody Talks", type="track", - ... limit=1)["items"][0] - -
- Output - - { - "id": 14492425, - "title": "Everybody Talks", - "duration": 177, - "replayGain": -11.7, - "peak": 0.999969, - "allowStreaming": true, - "streamReady": true, - "adSupportedStreamReady": true, - "djReady": true, - "stemReady": false, - "streamStartDate": "2012-04-17T00:00:00.000+0000", - "premiumStreamingOnly": false, - "trackNumber": 3, - "volumeNumber": 1, - "version": null, - "popularity": 55, - "copyright": "A Mercury Records Release; \u2117 2011 UMG Recordings, Inc.", - "url": "http://www.tidal.com/track/14492425", - "isrc": "USUM71119189", - "editable": false, - "explicit": true, - "audioQuality": "LOSSLESS", - "audioModes": [ - "STEREO" - ], - "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] - }, - "artist": { - "id": 3665225, - "name": "Neon Trees", - "type": "MAIN", - "picture": "e6f17398-759e-45a0-9673-6ded6811e199" - }, - "artists": [ - { - "id": 3665225, - "name": "Neon Trees", - "type": "MAIN", - "picture": "e6f17398-759e-45a0-9673-6ded6811e199" - } - ], - "album": { - "id": 14492422, - "title": "Picture Show", - "cover": "1c2d7c90-034e-485a-be1f-24a669c7e6ee", - "vibrantColor": "#f8af88", - "videoCover": null - }, - "mixes": { - "TRACK_MIX": "0019768c833a193c29829e5bf473fc" - } - } - -
- -- If the clients are authenticated, you can create a user playlist and - add tracks to it. Using the track IDs for "Everybody Talks" by Neon - Trees from the previous example: - - **Private Qobuz API** - - >>> playlist_qobuz = client_qobuz.create_playlist( - ... "Minim", - ... description="A playlist created using Minim." - ... ) - >>> client_qobuz.add_playlist_tracks(playlist_qobuz["id"], 5653620) - - **Spotify Web API** - - >>> playlist_spotify = client_spotify.create_playlist( - ... "Minim", - ... description="A playlist created using Minim." - ... ) - >>> client_spotify.add_playlist_items( - ... playlist_spotify["id"], - ... ["spotify:track:2iUmqdfGZcHIhS3b9E9EWq"] - ... ) - - **Private TIDAL API** - - >>> playlist_tidal = client_tidal.create_playlist( - ... "Minim", - ... description="A playlist created using Minim." - ... ) - >>> client_tidal.add_playlist_items(playlist_tidal["data"]["uuid"], - ... 14492425) \ No newline at end of file diff --git a/assets/gui/icons/back.svg b/assets/gui/icons/back.svg new file mode 100644 index 0000000..14b7029 --- /dev/null +++ b/assets/gui/icons/back.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/gui/icons/bookmarks.svg b/assets/gui/icons/bookmarks.svg new file mode 100644 index 0000000..923298a --- /dev/null +++ b/assets/gui/icons/bookmarks.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/gui/icons/dark_light_mode.svg b/assets/gui/icons/dark_light_mode.svg new file mode 100644 index 0000000..a73e16f --- /dev/null +++ b/assets/gui/icons/dark_light_mode.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/gui/icons/favorite.svg b/assets/gui/icons/favorite.svg new file mode 100644 index 0000000..eb95e0a --- /dev/null +++ b/assets/gui/icons/favorite.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets/gui/icons/favorited.svg b/assets/gui/icons/favorited.svg new file mode 100644 index 0000000..fc7eee6 --- /dev/null +++ b/assets/gui/icons/favorited.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets/gui/icons/forward.svg b/assets/gui/icons/forward.svg new file mode 100644 index 0000000..b901cc4 --- /dev/null +++ b/assets/gui/icons/forward.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/gui/icons/home.svg b/assets/gui/icons/home.svg new file mode 100644 index 0000000..0fee844 --- /dev/null +++ b/assets/gui/icons/home.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/gui/icons/library.svg b/assets/gui/icons/library.svg new file mode 100644 index 0000000..35b0361 --- /dev/null +++ b/assets/gui/icons/library.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/gui/icons/refresh.svg b/assets/gui/icons/refresh.svg new file mode 100644 index 0000000..1ed3d79 --- /dev/null +++ b/assets/gui/icons/refresh.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets/gui/icons/search.svg b/assets/gui/icons/search.svg new file mode 100644 index 0000000..037d48d --- /dev/null +++ b/assets/gui/icons/search.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/gui/icons/settings.svg b/assets/gui/icons/settings.svg new file mode 100644 index 0000000..f643cd2 --- /dev/null +++ b/assets/gui/icons/settings.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/gui/styles/colors.yaml b/assets/gui/styles/colors.yaml new file mode 100644 index 0000000..e2a65ba --- /dev/null +++ b/assets/gui/styles/colors.yaml @@ -0,0 +1,15 @@ +dark: + text: rgb(223, 223, 223) + border: rgb(95, 95, 95) + button: rgb(47, 47, 47) + hover: rgb(63, 63, 63) + main: rgba(31, 31, 31, 0.95) + pressed: rgb(79, 79, 79) + +light: + text: rgb(31, 31, 31) + border: rgb(159, 159, 159) + button: rgb(207, 207, 207) + hover: rgb(191, 191, 191) + main: rgba(223, 223, 223, 0.95) + pressed: rgb(175, 175, 175) \ No newline at end of file diff --git a/assets/gui/styles/template.qss b/assets/gui/styles/template.qss new file mode 100644 index 0000000..dea4e67 --- /dev/null +++ b/assets/gui/styles/template.qss @@ -0,0 +1,131 @@ +#CentralWidget {{ + background-color: {main}; + border: 1px solid {border}; + border-radius: 16px; +}} + +QListView {{ + background-color: {button}; + border: 1px solid {border}; + border-radius: 16px; + color: {text}; +}} + +QFrame[role="navigation"] {{ + background-color: {hover}; + border-radius: 32px; +}} + +QLabel {{ + color: {text}; + font-size: 14px; +}} + +QLabel[role="navigation"] {{ + background-color: rgba(0, 0, 0, 0.0); + font-size: 12px; +}} + +QLineEdit {{ + background-color: {pressed}; + border: 1px solid {border}; + border-radius: 16px; + color: {text}; + padding-left: 12px; + padding-right: 12px; + font-size: 14px; +}} + +QPushButton {{ + background-color: {button}; + border: 1px solid {border}; + border-radius: 8px; + color: {text}; + font-size: 14px; +}} + +QPushButton:hover {{ + background-color: {hover}; +}} + +QPushButton:pressed {{ + background-color: {pressed}; +}} + +QPushButton[role="address"] {{ + background-color: rgba(0, 0, 0, 0.0); + border: none; + border-radius: 16px; +}} + +QPushButton[role="navigation"] {{ + background-color: rgba(0, 0, 0, 0.0); + border: none; +}} + +QPushButton[role="navigation"]:checked {{ + border-radius: 32px; +}} + +QPushButton[role="navigation"]:hover:!checked {{ + background-color: {hover}; + border-radius: 32px; +}} + +QPushButton[role="navigation"]:pressed:!checked {{ + background-color: {pressed}; + border-radius: 32px; +}} + +QPushButton[role="theme"] {{ + background-color: rgba(0, 0, 0, 0.0); + border: none; + border-radius: 12px; + font-size: 18px; +}} + +QPushButton[role="address"]:hover, +QPushButton[role="theme"]:hover {{ + background-color: {hover}; +}} + +QPushButton[role="address"]:pressed, +QPushButton[role="theme"]:pressed {{ + background-color: {pressed}; +}} + +QPushButton[role="minimize"], +QPushButton[role="maximize"], +QPushButton[role="close"] {{ + background-color: {hover}; + border: 1px solid {border}; + border-radius: 8px; +}} + +QPushButton[role="minimize"]:hover {{ + background-color: rgb(255, 189, 68); +}} + +QPushButton[role="minimize"]:pressed {{ + background-color: rgb(255, 166, 3); +}} + +QPushButton[role="maximize"]:hover {{ + background-color: rgb(0, 202, 78); +}} + +QPushButton[role="maximize"]:pressed {{ + background-color: rgb(0, 162, 62); +}} + +QPushButton[role="close"]:hover {{ + background-color: rgb(255, 96, 92); +}} + +QPushButton[role="close"]:pressed {{ + background-color: rgb(255, 28, 23); +}} + +QSvgWidget {{ + color: {text}; +}} \ No newline at end of file diff --git a/assets/icon.svg b/assets/icon.svg index 38d1e85..67e71a6 100644 --- a/assets/icon.svg +++ b/assets/icon.svg @@ -1 +1,63 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/logo_dark.svg b/assets/logo_dark.svg index bec9532..a16efdb 100644 --- a/assets/logo_dark.svg +++ b/assets/logo_dark.svg @@ -1 +1,16 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/logo_light.svg b/assets/logo_light.svg index 4b02a43..918544b 100644 --- a/assets/logo_light.svg +++ b/assets/logo_light.svg @@ -1 +1,16 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/.buildinfo b/docs/.buildinfo deleted file mode 100644 index 6459fea..0000000 --- a/docs/.buildinfo +++ /dev/null @@ -1,4 +0,0 @@ -# Sphinx build info version 1 -# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. -config: 0ca0ad8ef53eb4a89c9c473989a753c6 -tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/.doctrees/api.doctree b/docs/.doctrees/api.doctree deleted file mode 100644 index cc5d927..0000000 Binary files a/docs/.doctrees/api.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.audio.Audio.doctree b/docs/.doctrees/api/minim.audio.Audio.doctree deleted file mode 100644 index a8821cf..0000000 Binary files a/docs/.doctrees/api/minim.audio.Audio.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.audio.FLACAudio.doctree b/docs/.doctrees/api/minim.audio.FLACAudio.doctree deleted file mode 100644 index a46b40f..0000000 Binary files a/docs/.doctrees/api/minim.audio.FLACAudio.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.audio.MP3Audio.doctree b/docs/.doctrees/api/minim.audio.MP3Audio.doctree deleted file mode 100644 index b23b770..0000000 Binary files a/docs/.doctrees/api/minim.audio.MP3Audio.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.audio.MP4Audio.doctree b/docs/.doctrees/api/minim.audio.MP4Audio.doctree deleted file mode 100644 index 4c46b52..0000000 Binary files a/docs/.doctrees/api/minim.audio.MP4Audio.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.audio.OGGAudio.doctree b/docs/.doctrees/api/minim.audio.OGGAudio.doctree deleted file mode 100644 index b0d39dc..0000000 Binary files a/docs/.doctrees/api/minim.audio.OGGAudio.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.audio.WAVEAudio.doctree b/docs/.doctrees/api/minim.audio.WAVEAudio.doctree deleted file mode 100644 index b01ad26..0000000 Binary files a/docs/.doctrees/api/minim.audio.WAVEAudio.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.audio.doctree b/docs/.doctrees/api/minim.audio.doctree deleted file mode 100644 index 143db08..0000000 Binary files a/docs/.doctrees/api/minim.audio.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.discogs.API.doctree b/docs/.doctrees/api/minim.discogs.API.doctree deleted file mode 100644 index 84c7d37..0000000 Binary files a/docs/.doctrees/api/minim.discogs.API.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.discogs.doctree b/docs/.doctrees/api/minim.discogs.doctree deleted file mode 100644 index fd76e43..0000000 Binary files a/docs/.doctrees/api/minim.discogs.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.doctree b/docs/.doctrees/api/minim.doctree deleted file mode 100644 index 1701081..0000000 Binary files a/docs/.doctrees/api/minim.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.itunes.SearchAPI.doctree b/docs/.doctrees/api/minim.itunes.SearchAPI.doctree deleted file mode 100644 index cfe58cb..0000000 Binary files a/docs/.doctrees/api/minim.itunes.SearchAPI.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.itunes.doctree b/docs/.doctrees/api/minim.itunes.doctree deleted file mode 100644 index 600f32c..0000000 Binary files a/docs/.doctrees/api/minim.itunes.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.qobuz.PrivateAPI.doctree b/docs/.doctrees/api/minim.qobuz.PrivateAPI.doctree deleted file mode 100644 index 253cd6d..0000000 Binary files a/docs/.doctrees/api/minim.qobuz.PrivateAPI.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.qobuz.doctree b/docs/.doctrees/api/minim.qobuz.doctree deleted file mode 100644 index 549572b..0000000 Binary files a/docs/.doctrees/api/minim.qobuz.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.spotify.PrivateLyricsService.doctree b/docs/.doctrees/api/minim.spotify.PrivateLyricsService.doctree deleted file mode 100644 index 2cb6ddb..0000000 Binary files a/docs/.doctrees/api/minim.spotify.PrivateLyricsService.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.spotify.WebAPI.doctree b/docs/.doctrees/api/minim.spotify.WebAPI.doctree deleted file mode 100644 index fde02f8..0000000 Binary files a/docs/.doctrees/api/minim.spotify.WebAPI.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.spotify.doctree b/docs/.doctrees/api/minim.spotify.doctree deleted file mode 100644 index 020c990..0000000 Binary files a/docs/.doctrees/api/minim.spotify.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.tidal.API.doctree b/docs/.doctrees/api/minim.tidal.API.doctree deleted file mode 100644 index bf75de1..0000000 Binary files a/docs/.doctrees/api/minim.tidal.API.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.tidal.PrivateAPI.doctree b/docs/.doctrees/api/minim.tidal.PrivateAPI.doctree deleted file mode 100644 index 125a395..0000000 Binary files a/docs/.doctrees/api/minim.tidal.PrivateAPI.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.tidal.doctree b/docs/.doctrees/api/minim.tidal.doctree deleted file mode 100644 index cc51766..0000000 Binary files a/docs/.doctrees/api/minim.tidal.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.utility.doctree b/docs/.doctrees/api/minim.utility.doctree deleted file mode 100644 index 0ad8d0b..0000000 Binary files a/docs/.doctrees/api/minim.utility.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.utility.format_multivalue.doctree b/docs/.doctrees/api/minim.utility.format_multivalue.doctree deleted file mode 100644 index f33b876..0000000 Binary files a/docs/.doctrees/api/minim.utility.format_multivalue.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.utility.gestalt_ratio.doctree b/docs/.doctrees/api/minim.utility.gestalt_ratio.doctree deleted file mode 100644 index aa0b47b..0000000 Binary files a/docs/.doctrees/api/minim.utility.gestalt_ratio.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.utility.gestalt_ratios.doctree b/docs/.doctrees/api/minim.utility.gestalt_ratios.doctree deleted file mode 100644 index b90b314..0000000 Binary files a/docs/.doctrees/api/minim.utility.gestalt_ratios.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.utility.levenshtein_ratio.doctree b/docs/.doctrees/api/minim.utility.levenshtein_ratio.doctree deleted file mode 100644 index 154a828..0000000 Binary files a/docs/.doctrees/api/minim.utility.levenshtein_ratio.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.utility.levenshtein_ratios.doctree b/docs/.doctrees/api/minim.utility.levenshtein_ratios.doctree deleted file mode 100644 index 9d3caab..0000000 Binary files a/docs/.doctrees/api/minim.utility.levenshtein_ratios.doctree and /dev/null differ diff --git a/docs/.doctrees/api/minim.utility.multivalue_formatter.doctree b/docs/.doctrees/api/minim.utility.multivalue_formatter.doctree deleted file mode 100644 index 286c06f..0000000 Binary files a/docs/.doctrees/api/minim.utility.multivalue_formatter.doctree and /dev/null differ diff --git a/docs/.doctrees/environment.pickle b/docs/.doctrees/environment.pickle deleted file mode 100644 index d8932ea..0000000 Binary files a/docs/.doctrees/environment.pickle and /dev/null differ diff --git a/docs/.doctrees/index.doctree b/docs/.doctrees/index.doctree deleted file mode 100644 index adb59d6..0000000 Binary files a/docs/.doctrees/index.doctree and /dev/null differ diff --git a/docs/.doctrees/notebooks/getting_started.doctree b/docs/.doctrees/notebooks/getting_started.doctree deleted file mode 100644 index 3b74665..0000000 Binary files a/docs/.doctrees/notebooks/getting_started.doctree and /dev/null differ diff --git a/docs/.doctrees/notebooks/user_guide/editing_audio_metadata.doctree b/docs/.doctrees/notebooks/user_guide/editing_audio_metadata.doctree deleted file mode 100644 index 9f7955d..0000000 Binary files a/docs/.doctrees/notebooks/user_guide/editing_audio_metadata.doctree and /dev/null differ diff --git a/docs/.doctrees/notebooks/user_guide/getting_recommendations.doctree b/docs/.doctrees/notebooks/user_guide/getting_recommendations.doctree deleted file mode 100644 index 628141d..0000000 Binary files a/docs/.doctrees/notebooks/user_guide/getting_recommendations.doctree and /dev/null differ diff --git a/docs/.doctrees/notebooks/user_guide/transferring_music_libraries.doctree b/docs/.doctrees/notebooks/user_guide/transferring_music_libraries.doctree deleted file mode 100644 index 474b986..0000000 Binary files a/docs/.doctrees/notebooks/user_guide/transferring_music_libraries.doctree and /dev/null differ diff --git a/docs/.doctrees/user_guide.doctree b/docs/.doctrees/user_guide.doctree deleted file mode 100644 index 130a117..0000000 Binary files a/docs/.doctrees/user_guide.doctree and /dev/null differ diff --git a/docs/_modules/index.html b/docs/_modules/index.html deleted file mode 100644 index c33ca2d..0000000 --- a/docs/_modules/index.html +++ /dev/null @@ -1,296 +0,0 @@ - - - - - - - - Overview: module code - Minim 1.0.0 documentation - - - - - - - - - - - - - - - - - - - Contents - - - - - - Menu - - - - - - - - Expand - - - - - - Light mode - - - - - - - - - - - - - - Dark mode - - - - - - - Auto light/dark mode - - - - - - - - - - - - - - - - - - - -
-
-
- -
- -
-
- -
- -
-
- -
-
-
- - - - - Back to top - -
-
- -
- -
- -
-
- - -
-
- - Made with Sphinx and @pradyunsg's - - Furo - -
-
- -
-
- -
-
- -
-
- - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/_modules/minim/audio.html b/docs/_modules/minim/audio.html deleted file mode 100644 index 3c420b8..0000000 --- a/docs/_modules/minim/audio.html +++ /dev/null @@ -1,2052 +0,0 @@ - - - - - - - - minim.audio - Minim 1.0.0 documentation - - - - - - - - - - - - - - - - - - - Contents - - - - - - Menu - - - - - - - - Expand - - - - - - Light mode - - - - - - - - - - - - - - Dark mode - - - - - - - Auto light/dark mode - - - - - - - - - - - - - - - - - - - -
-
-
- -
- -
-
- -
- -
-
- -
-
-
- - - - - Back to top - -
-
- -
- -
-
-

Source code for minim.audio

-"""
-Audio file objects
-==================
-.. moduleauthor:: Benjamin Ye <GitHub: bbye98>
-
-This module provides convenient Python objects to keep track of audio
-file handles and metadata, and convert between different audio formats.
-"""
-
-import base64
-import datetime
-from io import BytesIO
-import logging
-import pathlib
-import re
-import subprocess
-from typing import Any, Union
-import urllib
-import warnings
-
-from mutagen import id3, flac, mp3, mp4, oggflac, oggopus, oggvorbis, wave
-
-from . import utility, FOUND_FFMPEG
-from .qobuz import _parse_performers
-
-if FOUND_FFMPEG:
-    from . import FFMPEG_CODECS
-
-try:
-    from PIL import Image
-    FOUND_PILLOW = True
-except ModuleNotFoundError:
-    FOUND_PILLOW = False
-
-__all__ = ["Audio", "FLACAudio", "MP3Audio", "MP4Audio", "OggAudio",
-           "WAVEAudio"]
-
-class _ID3:
-
-    """
-    ID3 metadata container handler for MP3 and WAVE audio files.
-
-    .. attention::
-
-       This class should *not* be instantiated manually. Instead, use
-       :class:`MP3Audio` or :class:`WAVEAudio` to process metadata for
-       MP3 and WAVE audio files, respectively.
-
-    Parameters
-    ----------
-    filename : `str`
-        Audio filename.
-
-    tags : `mutagen.id3.ID3`
-        ID3 metadata.
-    """
-
-    _FIELDS = {
-        # field: (ID3 frame, base class, typecasting function)
-        "album": ("TALB", "text", None),
-        "album_artist": ("TPE2", "text", None),
-        "artist": ("TPE1", "text", None),
-        "comment": ("COMM", "text", None),
-        "compilation": ("TCMP", "text", lambda x: str(int(x))),
-        "composer": ("TCOM", "text", None),
-        "copyright": ("TCOP", "text", None),
-        "date": ("TDRC", "text", None),
-        "genre": ("TCON", "text", None),
-        "isrc": ("TSRC", "text", None),
-        "lyrics": ("USLT", "text", None),
-        "tempo": ("TBPM", "text", str),
-        "title": ("TIT2", "text", None),
-    }
-
-    def __init__(self, filename: str, tags: id3.ID3) -> None:
-
-        """
-        Create an ID3 tag handler.
-        """
-
-        self._filename = filename
-        self._tags = tags
-        self._from_file()
-
-    def _from_file(self) -> None:
-
-        """
-        Get metadata from the ID3 tags embedded in the audio file.
-        """
-
-        for field, (frame, base, _) in self._FIELDS.items():
-            value = self._tags.getall(frame)
-            if value:
-                value = ([sv for v in value for sv in getattr(v, base)]
-                         if len(value) > 1 else getattr(value[0], base))
-                if list not in self._FIELDS_TYPES[field]:
-                    value = utility.format_multivalue(value, False,
-                                                      primary=True)
-                    if not isinstance(value, self._FIELDS_TYPES[field]):
-                        try:
-                            value = self._FIELDS_TYPES[field][0](value)
-                        except ValueError:
-                            logging.warning()
-                            continue
-                else:
-                    if not isinstance(value[0], self._FIELDS_TYPES[field]):
-                        try:
-                            value = [self._FIELDS_TYPES[field][0](v)
-                                     for v in value]
-                        except ValueError:
-                            continue
-                    if len(value) == 1:
-                        value = value[0]
-            else:
-                value = None
-            setattr(self, field, value)
-
-        if "TPOS" in self._tags:
-            disc_number = getattr(self._tags.get("TPOS"), "text")[0]
-            if "/" in disc_number:
-                self.disc_number, self.disc_count = (
-                    int(d) for d in disc_number.split("/")
-                )
-            else:
-                self.disc_number = int(disc_number)
-                self.disc_count = None
-        else:
-            self.disc_number = self.disc_count = None
-
-        if "TRCK" in self._tags:
-            track_number = getattr(self._tags.get("TRCK"), "text")[0]
-            if "/" in track_number:
-                self.track_number, self.track_count = (
-                    int(t) for t in track_number.split("/")
-                )
-            else:
-                self.track_number = int(track_number)
-                self.track_count = None
-        else:
-            self.track_number = self.track_count = None
-
-        artwork = self._tags.getall("APIC")
-        if artwork:
-            self.artwork = artwork[0]
-            if self.artwork.type != 3 and len(artwork) > 1:
-                for p in artwork:
-                    if p.type == 3:
-                        self.artwork = p
-                        break
-            self._artwork_format = self.artwork.mime.split("/")[1]
-            self.artwork = self.artwork.data
-        else:
-            self.artwork = self._artwork_format = None
-
-    def write_metadata(self) -> None:
-
-        """
-        Write metadata to file.
-        """
-
-        for field, (frame, base, func) in self._FIELDS.items():
-            value = getattr(self, field)
-            if value:
-                value = utility.format_multivalue(
-                    value, self._multivalue, sep=self._sep
-                )
-                self._tags.add(
-                    getattr(id3, frame)(
-                        **{base: func(value) if func else value}
-                    )
-                )
-
-        if "TXXX:comment" in self._tags:
-            self._tags.delall("TXXX:comment")
-
-        if (disc_number := getattr(self, "disc_number", None)):
-            disc = str(disc_number)
-            if (disc_count := getattr(self, "disc_count", None)):
-                disc += f"/{disc_count}"
-            self._tags.add(id3.TPOS(text=disc))
-
-        if (track_number := getattr(self, "track_number", None)):
-            track = str(track_number)
-            if (track_count := getattr(self, "track_count", None)):
-                track += f"/{track_count}"
-            self._tags.add(id3.TRCK(text=track))
-
-        if self.artwork:
-            IMAGE_FORMATS = dict.fromkeys(
-                ["jpg", "jpeg", "jpe", "jif", "jfif", "jfi"], "image/jpeg"
-            ) | {"png": "image/png"}
-
-            if isinstance(self.artwork, str):
-                with urllib.request.urlopen(self.artwork) \
-                        if "http" in self.artwork \
-                        else open(self.artwork, "rb") as f:
-                    self.artwork = f.read()
-            self._tags.add(
-                id3.APIC(data=self.artwork,
-                         mime=IMAGE_FORMATS[self._artwork_format])
-            )
-
-        self._tags.save()
-
-class _VorbisComment:
-
-    """
-    Vorbis comment handler for FLAC and Ogg audio files.
-
-    .. attention::
-
-       This class should *not* be instantiated manually. Instead, use
-       :class:`FLACAudio` or :class:`OggAudio` to process metadata for
-       FLAC and Ogg audio files, respectively.
-
-    Parameters
-    ----------
-    filename : `str`
-        Audio filename.
-
-    tags : `mutagen.id3.ID3`
-        ID3 metadata.
-    """
-
-    _FIELDS = {
-        # field: (Vorbis comment key, typecasting function)
-        "album": ("album", None),
-        "album_artist": ("albumartist", None),
-        "artist": ("artist", None),
-        "comment": ("description", None),
-        "composer": ("composer", None),
-        "copyright": ("copyright", None),
-        "date": ("date", None),
-        "genre": ("genre", None),
-        "isrc": ("isrc", None),
-        "lyrics": ("lyrics", None),
-        "tempo": ("bpm", str),
-        "title": ("title", None),
-    }
-    _FIELDS_SPECIAL = {
-        "compilation": ("compilation", lambda x: str(int(x))),
-        "disc_number": ("discnumber", str),
-        "disc_count": ("disctotal", str),
-        "track_number": ("tracknumber", str),
-        "track_count": ("tracktotal", str)
-    }
-
-    def __init__(self, filename: str, tags: id3.ID3) -> None:
-
-        """
-        Create a Vorbis comment handler.
-        """
-
-        self._filename = filename
-        self._tags = tags
-        self._from_file()
-
-    def _from_file(self) -> None:
-
-        """
-        Get metadata from the tags embedded in the FLAC audio file.
-        """
-
-        for field, (key, _) in self._FIELDS.items():
-            value = self._tags.get(key)
-            if value:
-                if list not in self._FIELDS_TYPES[field]:
-                    value = utility.format_multivalue(value, False,
-                                                      primary=True)
-                    if type(value) not in self._FIELDS_TYPES[field]:
-                        try:
-                            value = self._FIELDS_TYPES[field][0](value)
-                        except ValueError:
-                            continue
-                else:
-                    if type(value[0]) not in self._FIELDS_TYPES[field]:
-                        try:
-                            value = [self._FIELDS_TYPES[field][0](v)
-                                     for v in value]
-                        except ValueError:
-                            continue
-                    if len(value) == 1:
-                        value = value[0]
-            else:
-                value = None
-            setattr(self, field, value)
-
-        self.compilation = bool(int(self._tags.get("compilation")[0])) \
-                           if "compilation" in self._tags else None
-
-        if "discnumber" in self._tags:
-            disc_number = self._tags.get("discnumber")[0]
-            if "/" in disc_number:
-                self.disc_number, self.disc_count = (
-                    int(d) for d in disc_number.split("/")
-                )
-            else:
-                self.disc_number = int(disc_number)
-                self.disc_count = self._tags.get("disctotal")
-                if self.disc_count:
-                    self.disc_count = int(self.disc_count[0])
-        else:
-            self.disc_number = self.disc_count = None
-
-        if "tracknumber" in self._tags:
-            track_number = self._tags.get("tracknumber")[0]
-            if "/" in track_number:
-                self.track_number, self.track_count = (
-                    int(t) for t in track_number.split("/")
-                )
-            else:
-                self.track_number = int(track_number)
-                self.track_count = self._tags.get("tracktotal")
-                if self.track_count:
-                    self.track_count = int(self.track_count[0])
-        else:
-            self.track_number = self.track_count = None
-
-        if hasattr(self._handle, "pictures") and self._handle.pictures:
-            self.artwork = self._handle.pictures[0].data
-            self._artwork_format = self._handle.pictures[0].mime.split("/")[1]
-        elif "metadata_block_picture" in self._tags:
-            IMAGE_FILE_SIGS = {
-                "jpg": b"\xff\xd8\xff\xe0\x00\x10\x4a\x46\x49\x46\x00\x01",
-                "png": b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"
-            }
-            self.artwork = base64.b64decode(
-                self._tags["metadata_block_picture"][0].encode()
-            )
-            for img_fmt, file_sig in IMAGE_FILE_SIGS.items():
-                if file_sig in self.artwork:
-                    self.artwork = self.artwork[
-                        re.search(file_sig, self.artwork).span()[0]:
-                    ]
-                    self._artwork_format = img_fmt
-        else:
-            self.artwork = self._artwork_format = None
-
-    def write_metadata(self) -> None:
-
-        """
-        Write metadata to file.
-        """
-
-        for field, (key, func) in (self._FIELDS | self._FIELDS_SPECIAL).items():
-            value = getattr(self, field)
-            if value:
-                value = utility.format_multivalue(
-                    value, self._multivalue, sep=self._sep
-                )
-                self._tags[key] = func(value) if func else value
-
-        if self.artwork:
-            artwork = flac.Picture()
-            artwork.type = id3.PictureType.COVER_FRONT
-            artwork.mime = f"image/{self._artwork_format}"
-            if isinstance(self.artwork, str):
-                with urllib.request.urlopen(self.artwork) \
-                        if "http" in self.artwork \
-                        else open(self.artwork, "rb") as f:
-                    self.artwork = f.read()
-            artwork.data = self.artwork
-            try:
-                self._handle.clear_pictures()
-                self._handle.add_picture(artwork)
-            except ValueError:
-                self._tags["metadata_block_picture"] = base64.b64encode(
-                    artwork.write()
-                ).decode()
-
-        self._handle.save()
-
-
-[docs] -class Audio: - - r""" - Generic audio file handler. - - Subclasses for specific audio containers or formats include - - * :class:`FLACAudio` for audio encoded using the Free - Lossless Audio Codec (FLAC), - * :class:`MP3Audio` for audio encoded and stored in the MPEG Audio - Layer III (MP3) format, - * :class:`MP4Audio` for audio encoded in the Advanced - Audio Coding (AAC) format, encoded using the Apple Lossless - Audio Codec (ALAC), or stored in a MPEG-4 Part 14 (MP4, M4A) - container, - * :class:`OggAudio` for Opus or Vorbis audio stored in an Ogg file, - and - * :class:`WAVEAudio` for audio encoded using linear pulse-code - modulation (LPCM) and in the Waveform Audio File Format (WAVE). - - .. note:: - - This class can instantiate a specific file handler from the list - above for an audio file by examining its file extension. However, - there may be instances when this detection fails, especially when - the audio codec and format combination is rarely seen. As such, - it is always best to directly use one of the subclasses above to - create a file handler for your audio file when its audio codec - and format are known. - - Parameters - ---------- - file : `str` or `pathlib.Path` - Audio filename or path. - - pattern : `tuple`, keyword-only, optional - Regular expression search pattern and the corresponding metadata - field(s). - - .. container:: - - **Valid values**: - - The supported metadata fields are - - * :code:`"artist"` for the track artist, - * :code:`"title"` for the track title, and - * :code:`"track_number"` for the track number. - - **Examples**: - - * :code:`("(.*) - (.*)", ("artist", "title"))` matches - filenames like "Taylor Swift - Cruel Summer.flac". - * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches - filenames like "04 - The Man.m4a". - * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches - filenames like "13 You Need to Calm Down.mp3". - - multivalue : `bool` - Determines whether multivalue tags are supported. If - :code:`False`, the items in `value` are concatenated using the - separator(s) specified in `sep`. - - sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` - Separator(s) to use to concatenate multivalue tags. If a - :code:`str` is provided, it is used to concatenate all values. - If a :code:`tuple` is provided, the first :code:`str` is used to - concatenate the first :math:`n - 1` values, and the second - :code:`str` is used to append the final value. - - Attributes - ---------- - album : `str` - Album title. - - album_artist : `str` or `list` - Album artist(s). - - artist : `str` or `list` - Artist(s). - - artwork : `bytes` or `str` - Byte-representation of, URL leading to, or filename of file - containing the cover artwork. - - bit_depth : `int` - Bits per sample. - - bitrate : `int` - Bitrate in bytes per second (B/s). - - channel_count : `int` - Number of audio channels. - - codec : `str` - Audio codec. - - comment : `str` - Comment(s). - - compilation : `bool` - Whether the album is a compilation of songs by various artists. - - composer : `str` or `list` - Composers, lyrics, and/or writers. - - copyright : `str` - Copyright information. - - date : `str` - Release date. - - disc_number : `int` - Disc number. - - disc_count : `int` - Total number of discs. - - genre : `str` or `list` - Genre. - - isrc : `str` - International Standard Recording Code (ISRC). - - lyrics : `str` - Lyrics. - - sample_rate : `int` - Sample rate in Hz. - - tempo : `int` - Tempo in beats per minute (bpm). - - title : `str` - Track title. - - track_number : `int` - Track number. - - track_count : `int` - Total number of tracks. - """ - - _FIELDS_TYPES = { - "_artwork_format": (str,), - "album": (str,), - "album_artist": (str, list), - "artist": (str, list), - "artwork": (bytes, str), - "comment": (str,), - "compilation": (bool,), - "composer": (str, list), - "copyright": (str,), - "date": (str,), - "disc_number": (int,), - "disc_count": (int,), - "genre": (str, list), - "isrc": (str,), - "lyrics": (str,), - "tempo": (int,), - "title": (str,), - "track_number": (int,), - "track_count": (int,) - } - - def __init__( - self, file: Union[str, pathlib.Path], *, - pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, - sep: Union[str, list[str]] = (", ", " & ")) -> None: - - """ - Instantiate an audio file handler. - """ - - self._file = pathlib.Path(file).resolve() - self._pattern = pattern - self._multivalue = multivalue - self._sep = sep - - def __new__(cls, *args, **kwargs) -> None: - - """ - Create an audio file handler. - - Parameters - ---------- - file : `str` or `pathlib.Path` - Audio file. - """ - - if cls == Audio: - file = kwargs.get("file") - if file is None: - file = args[0] - file = pathlib.Path(file) - if not file.is_file(): - raise FileNotFoundError(f"'{file}' not found.") - - ext = file.suffix[1:].lower() - for a in Audio.__subclasses__(): - if ext in a._EXTENSIONS: - return a(*args, **kwargs) - raise TypeError(f"'{file}' has an unsupported audio format.") - - return super(Audio, cls).__new__(cls) - - def _from_filename(self) -> None: - - """ - Get track information from the filename. - """ - - if self._pattern: - groups = re.findall(self._pattern[0], self._file.stem) - if groups: - missing = tuple(k in {"artist", "title", "track_number"} - and getattr(self, k) is None - for k in self._pattern[1]) - for flag, attr, val in zip(missing, self._pattern[1], groups[0]): - if flag: - setattr(self, attr, self._FIELDS_TYPES[attr][0](val)) - -
-[docs] - def convert( - self, codec: str, container: str = None, options: str = None, *, - filename: str = None, preserve: bool = True) -> None: - - """ - Convert the current audio file to another format. - - .. admonition:: Software dependency - - Requires `FFmpeg <https://ffmpeg.org/>`_. - - .. note:: - - The audio file handler is automatically updated to reflect - the new audio file format. For example, converting a FLAC - audio file to an ALAC audio file will change the file handler - from a :class:`FLACAudio` object to an :class:`MP4Audio` - object. - - Parameters - ---------- - codec : `str` - New audio codec or coding format. - - .. container:: - - **Valid values**: - - * :code:`"aac"`, :code:`"m4a"`, :code:`"mp4"`, or - :code:`"mp4a"` for lossy AAC audio. - * :code:`"alac"` for lossless ALAC audio. - * :code:`"flac"` for lossless FLAC audio. - * :code:`"mp3"` for lossy MP3 audio. - * :code:`"ogg"` or :code:`"opus"` for lossy Opus audio - * :code:`"vorbis"` for lossy Vorbis audio. - * :code:`"lpcm"`, :code:`"wav"`, or :code:`"wave"` for - lossless LPCM audio. - - container : `str`, optional - New audio file container. If not specified, the best - container is determined based on `codec`. - - .. container:: - - **Valid values**: - - * :code:`"flac"` for a FLAC audio container, which only - supports FLAC audio. - * :code:`"m4a"`, :code:`"mp4"`, or :code:`"mp4a"` for - a MP4 audio container, which supports AAC and ALAC - audio. - * :code:`"mp3"` for a MP3 audio container, which only - supports MP3 audio. - * :code:`"ogg"` for an Ogg audio container, which - supports FLAC, Opus, and Vorbis audio. - * :code:`"wav"` or :code:`"wave"` for an WAVE audio - container, which only supports LPCM audio. - - options : `str`, optional - FFmpeg command-line options, excluding the input and output - files, the :code:`-y` flag (to overwrite files), and the - :code:`-c:v copy` argument (to preserve cover art for - containers that support it). - - .. container:: - - **Defaults**: - - * AAC audio: :code:`"-c:a aac -b:a 256k"` (or - :code:`"-c:a libfdk_aac -b:a 256k"` if FFmpeg was - compiled with :code:`--enable-libfdk-aac`) - * ALAC audio: :code:`"-c:a alac"` - * FLAC audio: :code:`"-c:a flac"` - * MP3 audio: :code:`"-c:a libmp3lame -q:a 0"` - * Opus audio: :code:`"-c:a libopus -b:a 256k -vn"` - * Vorbis audio: - :code:`"-c:a vorbis -strict experimental -vn"` (or - :code:`"-c:a libvorbis -vn"` if FFmpeg was compiled - with :code:`--enable-libvorbis`) - * WAVE audio: :code:`"-c:a pcm_s16le"` or - :code:`"-c:a pcm_s24le"`, depending on the bit depth of - the original audio file. - - filename : `str`, keyword-only, optional - Filename of the converted audio file. If not provided, the - filename of the original audio file, but with the - appropriate new extension appended, is used. - - preserve : `bool`, keyword-only, default: :code:`True` - Determines whether the original audio file is kept. - """ - - if not FOUND_FFMPEG: - emsg = ("Audio conversion is unavailable because FFmpeg " - "was not found.") - raise RuntimeError(emsg) - - _codec = (codec.capitalize() if codec in {"opus", "vorbis"} - else codec.upper()) - codec = codec.lower() - if codec in {"m4a", "mp4", "mp4a"}: - codec = "aac" - elif codec == "ogg": - codec = "opus" - elif codec in "wave": - codec = "lpcm" - - if container: - container = container.lower() - if container == "m4a": - container = "mp4" - elif container == "wave": - container = "wav" - - try: - acls = next(a for a in Audio.__subclasses__() - if codec in a._CODECS - and container in a._EXTENSIONS) - except StopIteration: - emsg = (f"{_codec} audio is incompatible with " - f"the {container.upper()} container.") - raise RuntimeError(emsg) - else: - try: - acls = next(a for a in Audio.__subclasses__() - if codec in a._CODECS) - container = acls._EXTENSIONS[0] - except StopIteration: - raise RuntimeError(f"The '{_codec}' codec is not supported.") - - if ("mp4" if codec == "aac" else codec) in self.codec \ - and isinstance(self, acls): - wmsg = (f"'{self._file}' already has {_codec} " - f"audio in a {container.upper()} container. " - "Re-encoding may lead to quality degradation from " - "generation loss.") - logging.warning(wmsg) - - ext = f".{acls._EXTENSIONS[0]}" - if filename is None: - filename = self._file.with_suffix(ext) - else: - if isinstance(filename, str): - if "/" not in filename: - filename = f"{self._file.parent}/{filename}" - filename = pathlib.Path(filename).resolve() - if filename.suffix != ext: - filename = filename.with_suffix(ext) - filename.parent.mkdir(parents=True, exist_ok=True) - if self._file == filename: - filename = filename.with_stem(f"{filename.stem}_") - - if options is None: - if codec == "lpcm": - options = acls._CODECS[codec]["ffmpeg"].format( - self.bit_depth if hasattr(self, "bit_depth") else 16 - ) - else: - options = acls._CODECS[codec]["ffmpeg"] - - subprocess.run( - f'ffmpeg -y -i "{self._file}" {options} -loglevel error ' - f'-stats "{filename}"', - shell=True - ) - if not preserve: - self._file.unlink() - - obj = acls(filename) - self.__class__ = obj.__class__ - self.__dict__ = obj.__dict__ | { - key: value for (key, value) in self.__dict__.items() - if key in self._FIELDS_TYPES - }
- - -
-[docs] - def set_metadata_using_itunes( - self, data: dict[str, Any], *, album_data: dict[str, Any] = None, - artwork_size: Union[int, str] = 1400, artwork_format: str = "jpg", - overwrite: bool = False) -> None: - - """ - Populate tags using data retrieved from the iTunes Search API. - - Parameters - ---------- - data : `dict` - Information about the track in JSON format obtained using - the iTunes Search API via - :meth:`minim.itunes.SearchAPI.search` or - :meth:`minim.itunes.SearchAPI.lookup`. - - album_data : `dict`, keyword-only, optional - Information about the track's album in JSON format obtained - using the iTunes Search API via - :meth:`minim.itunes.SearchAPI.search` or - :meth:`minim.itunes.SearchAPI.lookup`. If not provided, - album artist and copyright information is unavailable. - - artwork_size : `int` or `str`, keyword-only, default: :code:`1400` - Resized artwork size in pixels. If - :code:`artwork_size="raw"`, the uncompressed high-resolution - image is retrieved, regardless of size. - - artwork_format : `str`, keyword-only, :code:`{"jpg", "png"}` - Artwork file format. If :code:`artwork_size="raw"`, the file - format of the uncompressed high-resolution image takes - precedence. - - overwrite : `bool`, keyword-only, default: :code:`False` - Determines whether existing metadata should be overwritten. - """ - - if self.album is None or overwrite: - self.album = data["collectionName"] - if self.artist is None or overwrite: - self.artist = data["artistName"] - if self.artwork is None or overwrite: - self.artwork = data["artworkUrl100"] - if self.artwork: - if artwork_size == "raw": - if "Feature" in self.artwork: - self.artwork = ( - "https://a5.mzstatic.com/us/r1000/0" - f"/{re.search(r'Feature.*?(jpg|png|tif)(?=/|$)', self.artwork)[0]}" - ) - elif "Music" in self.artwork: - self.artwork = ( - "https://a5.mzstatic.com/" - f"{re.search(r'Music.*?(jpg|png|tif)(?=/|$)', self.artwork)[0]}" - ) - self._artwork_format = pathlib.Path(self.artwork).suffix[1:] - else: - self.artwork = self.artwork.replace( - "100x100bb.jpg", - f"{artwork_size}x{artwork_size}bb.{artwork_format}" - ) - self._artwork_format = artwork_format - with urllib.request.urlopen(self.artwork) as r: - self.artwork = r.read() - if self._artwork_format == "tif": - if FOUND_PILLOW: - with Image.open(BytesIO(self.artwork)) as a: - with BytesIO() as b: - a.save(b, format="png") - self.artwork = b.getvalue() - self._artwork_format = "png" - else: - wmsg = ("The Pillow library is required to process " - "TIFF images, but was not found. No artwork " - "will be embedded for the current track.") - warnings.warn(wmsg) - self.artwork = self._artwork_format = None - if self.compilation is None or overwrite: - self.compilation = self.album_artist == "Various Artists" - if "releaseDate" in data and (self.date is None or overwrite): - self.date = data["releaseDate"] - if self.disc_number is None or overwrite: - self.disc_number = data["discNumber"] - if self.disc_count is None or overwrite: - self.disc_count = data["discCount"] - if self.genre is None or overwrite: - self.genre = data["primaryGenreName"] - if self.title is None or overwrite: - self.title = max(data["trackName"], data["trackCensoredName"]) - if self.track_number is None or overwrite: - self.track_number = data["trackNumber"] - if self.track_count is None or overwrite: - self.track_count = data["trackCount"] - - if album_data: - if self.album_artist is None or overwrite: - self.album_artist = album_data["artistName"] - if self.copyright or overwrite: - self.copyright = album_data["copyright"]
- - -
-[docs] - def set_metadata_using_qobuz( - self, data: dict[str, Any], *, artwork_size: str = "large", - comment: str = None, overwrite: bool = False) -> None: - - """ - Populate tags using data retrieved from the Qobuz API. - - Parameters - ---------- - data : `dict` - Information about the track in JSON format obtained using - the Qobuz API via :meth:`minim.qobuz.PrivateAPI.get_track` - or :meth:`minim.qobuz.PrivateAPI.search`. - - artwork_size : `str`, keyword-only, default: :code:`"large"` - Artwork size. - - **Valid values**: :code:`"large"`, :code:`"small"`, or - :code:`"thumbnail"`. - - comment : `str`, keyword-only, optional - Comment or description. - - overwrite : `bool`, keyword-only, default: :code:`False` - Determines whether existing metadata should be overwritten. - """ - - if self.album is None or overwrite: - self.album = data["album"]["title"] - if (album_artists := data["album"].get("artists")): - album_feat_artist = [a["name"] for a in album_artists - if "featured-artist" in a["roles"]] - if album_feat_artist and "feat." not in self.album: - self.album += ( - " [feat. {}]" if "(" in self.album else " (feat. {})" - ).format( - utility.format_multivalue(album_feat_artist, False) - ) - if data["album"]["version"]: - self.album += ( - " [{}]" if "(" in self.album else " ({})" - ).format(data['album']['version']) - self.album = self.album.replace(" ", " ") - if self.album_artist is None or overwrite: - if (album_artists := data["album"].get("artists")): - album_artist = [a["name"] for a in album_artists - if "main-artist" in a["roles"]] - album_main_artist = data["album"]["artist"]["name"] - if album_main_artist in album_artist: - if (i := album_artist.index(album_main_artist) - if album_main_artist in album_artist else 0) != 0: - album_artist.insert(0, album_artist.pop(i)) - self.album_artist = album_artist - else: - self.album_artist = album_main_artist - else: - self.album_artist = data["album"]["artist"]["name"] - - credits = _parse_performers( - data["performers"], - roles=["MainArtist", "FeaturedArtist", "Composers"] - ) - if self.artist is None or overwrite: - self.artist = credits.get("main_artist") or data["performer"]["name"] - if self.artwork is None or overwrite: - if artwork_size not in \ - (ARTWORK_SIZES := {"large", "small", "thumbnail"}): - emsg = (f"Invalid artwork size '{artwork_size}'. " - f"Valid values: {ARTWORK_SIZES}.") - raise ValueError(emsg) - self.artwork = data["album"]["image"][artwork_size] - self._artwork_format = pathlib.Path(self.artwork).suffix[1:] - if self.comment is None or overwrite: - self.comment = comment - if self.composer is None or overwrite: - self.composer = ( - credits.get("composers") - or (data["composer"]["name"] if hasattr(data, "composer") - else None) - ) - if self.copyright is None or overwrite: - self.copyright = data["album"].get("copyright") - if self.date is None or overwrite: - self.date = min( - datetime.datetime.utcfromtimestamp(dt) if isinstance(dt, int) - else datetime.datetime.strptime(dt, "%Y-%m-%d") if isinstance(dt, str) - else datetime.datetime.max for dt in ( - data.get(k) for k in { - "release_date_original", - "release_date_download", - "release_date_stream", - "release_date_purchase", - "purchasable_at", - "streamable_at" - } - ) - ).strftime('%Y-%m-%dT%H:%M:%SZ') - if self.disc_number is None or overwrite: - self.disc_number = data["media_number"] - if self.disc_count is None or overwrite: - self.disc_count = data["album"]["media_count"] - if self.genre is None or overwrite: - self.genre = data["album"]["genre"]["name"] - if self.isrc is None or overwrite: - self.isrc = data["isrc"] - if self.title is None or overwrite: - self.title = data["title"] - if (feat_artist := credits.get("featured_artist")) \ - and "feat." not in self.title: - self.title += ( - " [feat. {}]" if "(" in self.title else " (feat. {})" - ).format( - utility.format_multivalue(feat_artist, False) - ) - if data["version"]: - self.title += (" [{}]" if "(" in self.title - else " ({})").format(data['version']) - self.title = self.title.replace(" ", " ") - if self.track_number is None or overwrite: - self.track_number = data["track_number"] - if self.track_count is None or overwrite: - self.track_count = data["album"]["tracks_count"] - - if data["album"].get("release_type") == "single" \ - and self.album == self.title: - self.album += " - Single" - self.album_artist = self.artist = max( - self.artist, self.album_artist, key=len - )
- - -
-[docs] - def set_metadata_using_spotify( - self, data: dict[str, Any], *, - audio_features: dict[str, Any] = None, - lyrics: Union[str, dict[str, Any]] = None, overwrite: bool = False - ) -> None: - - """ - Populate tags using data retrieved from the Spotify Web API - and Spotify Lyrics service. - - Parameters - ---------- - data : `dict` - Information about the track in JSON format obtained using - the Spotify Web API via - :meth:`minim.spotify.WebAPI.get_track`. - - audio_features : `dict`, keyword-only, optional - Information about the track's audio features obtained using - the Spotify Web API via - :meth:`minim.spotify.WebAPI.get_track_audio_features`. - If not provided, tempo information is unavailable. - - lyrics : `str` or `dict`, keyword-only - Information about the track's formatted or time-synced - lyrics obtained using the Spotify Lyrics service via - :meth:`minim.spotify.PrivateLyricsService.get_lyrics`. If not - provided, lyrics are unavailable. - - overwrite : `bool`, keyword-only, default: :code:`False` - Determines whether existing metadata should be overwritten. - """ - - if self.album is None or overwrite: - self.album = data["album"]["name"] - if data["album"]["album_type"] == "single": - self.album += " - Single" - if self.album_artist is None or overwrite: - self.album_artist = [a["name"] for a in data["album"]["artists"]] - if self.artist is None or overwrite: - self.artist = [a["name"] for a in data["artists"]] - if self.artwork is None or overwrite: - with urllib.request.urlopen(data["album"]["images"][0]["url"]) as r: - self.artwork = r.read() - self._artwork_format = "jpg" - if self.compilation is None or overwrite: - self.compilation = data["album"]["album_type"] == "compilation" - if self.date is None or overwrite: - self.date = data["album"]["release_date"] - if self.disc_number is None or overwrite: - self.disc_number = data["disc_number"] - if self.isrc is None or overwrite: - self.isrc = data["external_ids"]["isrc"] - if (self.lyrics is None or overwrite) and lyrics: - self.lyrics = lyrics if isinstance(lyrics, str) \ - else "\n".join(line["words"] - for line in lyrics["lyrics"]["lines"]) - if (self.tempo is None or overwrite) and audio_features: - self.tempo = round(audio_features["tempo"]) - if self.title is None or overwrite: - self.title = data["name"] - if self.track_number is None or overwrite: - self.track_number = data["track_number"] - if self.track_count is None or overwrite: - self.track_count = data["album"]["total_tracks"]
- - -
-[docs] - def set_metadata_using_tidal( - self, data: dict[str, Any], *, album_data: dict[str, Any] = None, - artwork_size: int = 1280, - composers: Union[str, list[str], dict[str, Any]] = None, - lyrics: dict[str, Any] = None, comment: str = None, - overwrite: bool = False) -> None: - - """ - Populate tags using data retrieved from the TIDAL API. - - Parameters - ---------- - data : `dict` - Information about the track in JSON format obtained using - the TIDAL API via :meth:`minim.tidal.API.get_track`, - :meth:`minim.tidal.API.search`, - :meth:`minim.tidal.PrivateAPI.get_track`, or - :meth:`minim.tidal.PrivateAPI.search`. - - album_data : `dict`, keyword-only, optional - Information about the track's album in JSON format obtained - using the TIDAL API via :meth:`minim.tidal.API.get_album`, - :meth:`minim.tidal.API.search`, - :meth:`minim.tidal.PrivateAPI.get_album`, or - :meth:`minim.tidal.PrivateAPI.search`. If not provided, - album artist and disc and track numbering information is - unavailable. - - artwork_size : `int`, keyword-only, default: :code:`1280` - Maximum artwork size in pixels. - - **Valid values**: `artwork_size` should be between - :code:`80` and :code:`1280`. - - composers : `str`, `list`, or `dict`, keyword-only, optional - Information about the track's composers in a formatted - `str`, a `list`, or a `dict` obtained using the TIDAL API - via :meth:`minim.tidal.PrivateAPI.get_track_composers`, - :meth:`minim.tidal.PrivateAPI.get_track_contributors`, or - :meth:`minim.tidal.PrivateAPI.get_track_credits`. If not - provided, songwriting credits are unavailable. - - lyrics : `str` or `dict`, keyword-only, optional - The track's lyrics obtained using the TIDAL API via - :meth:`minim.tidal.PrivateAPI.get_track_lyrics`. - - comment : `str`, keyword-only, optional - Comment or description. - - overwrite : `bool`, keyword-only, default: :code:`False` - Determines whether existing metadata should be overwritten. - """ - - if "resource" in data: - data = data["resource"] - if self.album is None or overwrite: - self.album = data["album"]["title"] - if (self.comment is None or overwrite) and comment: - self.comment = comment - if (self.composer is None or overwrite) and composers: - COMPOSER_TYPES = {"Composer", "Lyricist", "Writer"} - if isinstance(composers, dict): - self.composer = sorted({c["name"] for c in composers["items"] - if c["role"] in COMPOSER_TYPES}) - elif isinstance(composers[0], dict): - self.composer = sorted({ - c["name"] for r in composers for c in r["contributors"] - if r["type"] in COMPOSER_TYPES - }) - else: - self.composer = composers - if self.copyright is None or overwrite: - self.copyright = data["copyright"] - if self.disc_number is None or overwrite: - self.disc_number = data["volumeNumber"] - if self.isrc is None or overwrite: - self.isrc = data["isrc"] - if (self.lyrics is None or overwrite) and lyrics: - self.lyrics = lyrics if isinstance(lyrics, str) \ - else lyrics["lyrics"] - if self.title is None or overwrite: - self.title = data["title"] - if self.track_number is None or overwrite: - self.track_number = data["trackNumber"] - - if "artifactType" in data: - if self.artist is None or overwrite: - self.artist = [a["name"] for a in data["artists"] if a["main"]] - if self.artwork is None or overwrite: - image_urls = sorted(data["album"]["imageCover"], - key=lambda x: x["width"], reverse=True) - self.artwork = ( - image_urls[-1]["url"] - if artwork_size < image_urls[-1]["width"] - else next(u["url"] for u in image_urls - if u["width"] <= artwork_size) - ) - self._artwork_format = pathlib.Path(self.artwork).suffix[1:] - else: - if self.artist is None or overwrite: - self.artist = [a["name"] for a in data["artists"] - if a["type"] == "MAIN"] - if self.artwork is None or overwrite: - artwork_size = ( - 80 if artwork_size < 80 - else next(s for s in [1280, 1080, 750, 640, 320, 160, 80] - if s <= artwork_size) - ) - self.artwork = ("https://resources.tidal.com/images" - f"/{data['album']['cover'].replace('-', '/')}" - f"/{artwork_size}x{artwork_size}.jpg") - self._artwork_format = "jpg" - if self.date is None or overwrite: - self.date = f"{data['streamStartDate'].split('.')[0]}Z" - - if album_data: - if self.copyright is None or overwrite: - self.copyright = album_data["copyright"] - if self.disc_count is None or overwrite: - self.disc_count = album_data["numberOfVolumes"] - if self.track_count is None or overwrite: - self.track_count = album_data["numberOfTracks"] - - if "barcodeId" in album_data: - if self.album_artist is None or overwrite: - self.album_artist = [a["name"] for a in album_data["artists"] - if a["main"]] - if self.date is None or overwrite: - self.date = f"{album_data['releaseDate']}T00:00:00Z" - else: - if self.album_artist is None or overwrite: - self.album_artist = [ - a["name"] for a in album_data["artists"] - if a["type"] == "MAIN" - ]
-
- - -
-[docs] -class FLACAudio(Audio, _VorbisComment): - - r""" - FLAC audio file handler. - - .. seealso:: - - For a full list of attributes and their descriptions, see - :class:`Audio`. - - Parameters - ---------- - file : `str` or `pathlib.Path` - FLAC audio filename or path. - - pattern : `tuple`, keyword-only, optional - Regular expression search pattern and the corresponding metadata - field(s). - - .. container:: - - **Valid values**: - - The supported metadata fields are - - * :code:`"artist"` for the track artist, - * :code:`"title"` for the track title, and - * :code:`"track_number"` for the track number. - - **Examples**: - - * :code:`("(.*) - (.*)", ("artist", "title"))` matches - filenames like "Taylor Swift - Fearless.flac". - * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches - filenames like "03 - Love Story.flac". - * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches - filenames like "06 You Belong with Me.flac". - - multivalue : `bool` - Determines whether multivalue tags are supported. If - :code:`False`, the items in `value` are concatenated using the - separator(s) specified in `sep`. - - sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` - Separator(s) to use to concatenate multivalue tags. If a - :code:`str` is provided, it is used to concatenate all values. - If a :code:`tuple` is provided, the first :code:`str` is used to - concatenate the first :math:`n - 1` values, and the second - :code:`str` is used to append the final value. - """ - - _CODECS = {"flac": {"ffmpeg": "-c:a flac -c:v copy"}} - _EXTENSIONS = ["flac"] - - def __init__( - self, file: Union[str, pathlib.Path], *, - pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, - sep: Union[str, list[str]] = (", ", " & ")) -> None: - - """ - Create a FLAC audio file handler. - """ - - Audio.__init__(self, file, pattern=pattern, multivalue=multivalue, - sep=sep) - self._handle = flac.FLAC(file) - if self._handle.tags is None: - self._handle.add_tags() - _VorbisComment.__init__(self, self._file.name, self._handle.tags) - self._from_filename() - - self.bit_depth = self._handle.info.bits_per_sample - self.bitrate = self._handle.info.bitrate - self.channel_count = self._handle.info.channels - self.codec = "flac" - self.sample_rate = self._handle.info.sample_rate
- - -
-[docs] -class MP3Audio(Audio, _ID3): - - r""" - MP3 audio file handler. - - .. seealso:: - - For a full list of attributes and their descriptions, see - :class:`Audio`. - - Parameters - ---------- - file : `str` or `pathlib.Path` - MP3 audio filename or path. - - pattern : `tuple`, keyword-only, optional - Regular expression search pattern and the corresponding metadata - field(s). - - .. container:: - - **Valid values**: - - The supported metadata fields are - - * :code:`"artist"` for the track artist, - * :code:`"title"` for the track title, and - * :code:`"track_number"` for the track number. - - **Examples**: - - * :code:`("(.*) - (.*)", ("artist", "title"))` matches - filenames like "Taylor Swift - Red.mp3". - * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches - filenames like "04 - I Knew You Were Trouble.mp3". - * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches - filenames like "06 22.mp3". - - multivalue : `bool` - Determines whether multivalue tags are supported. If - :code:`False`, the items in `value` are concatenated using the - separator(s) specified in `sep`. - - sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` - Separator(s) to use to concatenate multivalue tags. If a - :code:`str` is provided, it is used to concatenate all values. - If a :code:`tuple` is provided, the first :code:`str` is used to - concatenate the first :math:`n - 1` values, and the second - :code:`str` is used to append the final value. - """ - - _CODECS = {"mp3": {"ffmpeg": "-c:a libmp3lame -q:a 0 -c:v copy"}} - _EXTENSIONS = ["mp3"] - - def __init__( - self, file: Union[str, pathlib.Path], *, - pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, - sep: Union[str, list[str]] = (", ", " & ")) -> None: - - """ - Create a MP3 audio file handler. - """ - - _handle = mp3.MP3(file) - _handle.tags.filename = str(file) - Audio.__init__(self, file, pattern=pattern, multivalue=multivalue, - sep=sep) - _ID3.__init__(self, self._file.name, _handle.tags) - self._from_filename() - - self.bit_depth = None - self.bitrate = _handle.info.bitrate - self.channel_count = _handle.info.channels - self.codec = "mp3" - self.sample_rate = _handle.info.sample_rate
- - -
-[docs] -class MP4Audio(Audio): - - r""" - MP4 audio file handler. - - .. seealso:: - - For a full list of attributes and their descriptions, see - :class:`Audio`. - - Parameters - ---------- - file : `str` or `pathlib.Path` - MP4 audio filename or path. - - pattern : `tuple`, keyword-only, optional - Regular expression search pattern and the corresponding metadata - field(s). - - .. container:: - - **Valid values**: - - The supported metadata fields are - - * :code:`"artist"` for the track artist, - * :code:`"title"` for the track title, and - * :code:`"track_number"` for the track number. - - **Examples**: - - * :code:`("(.*) - (.*)", ("artist", "title"))` matches - filenames like "Taylor Swift - Mine.m4a". - * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches - filenames like "04 - Speak Now.m4a". - * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches - filenames like "07 The Story of Us.m4a". - - multivalue : `bool` - Determines whether multivalue tags are supported. If - :code:`False`, the items in `value` are concatenated using the - separator(s) specified in `sep`. - - sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` - Separator(s) to use to concatenate multivalue tags. If a - :code:`str` is provided, it is used to concatenate all values. - If a :code:`tuple` is provided, the first :code:`str` is used to - concatenate the first :math:`n - 1` values, and the second - :code:`str` is used to append the final value. - """ - - _CODECS = {"aac": {"ffmpeg": f"-b:a 256k -c:a {FFMPEG_CODECS['aac']} " - "-c:v copy"}, - "alac": {"ffmpeg": "-c:a alac -c:v copy"}} - _EXTENSIONS = ["m4a", "aac", "mp4"] - _FIELDS = { - # field: Apple iTunes metadata list key - "album": "\xa9alb", - "album_artist": "aART", - "artist": "\xa9ART", - "comment": "\xa9cmt", - "compilation": "cpil", - "composer": "\xa9wrt", - "copyright": "cprt", - "date": "\xa9day", - "genre": "\xa9gen", - "lyrics": "\xa9lyr", - "tempo": "tmpo", - "title": "\xa9nam", - } - _IMAGE_FORMATS = dict.fromkeys( - ["jpg", "jpeg", "jpe", "jif", "jfif", "jfi", 13], - mp4.MP4Cover.FORMAT_JPEG - ) | dict.fromkeys(["png", 14], mp4.MP4Cover.FORMAT_PNG) - - def __init__( - self, file: Union[str, pathlib.Path], *, - pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, - sep: Union[str, list[str]] = (", ", " & ")) -> None: - - """ - Create a MP4 audio file handler. - """ - - super().__init__(file, pattern=pattern, multivalue=multivalue, sep=sep) - - self._handle = mp4.MP4(file) - self.bit_depth = self._handle.info.bits_per_sample - self.bitrate = self._handle.info.bitrate - self.channel_count = self._handle.info.channels - self.codec = self._handle.info.codec - self.sample_rate = self._handle.info.sample_rate - - self._multivalue = multivalue - self._sep = sep - self._from_file() - self._from_filename() - - def _from_file(self) -> None: - - """ - Get metadata from the tags embedded in the MP4 audio file. - """ - - for field, key in self._FIELDS.items(): - value = self._handle.get(key) - if value: - if list not in self._FIELDS_TYPES[field]: - value = utility.format_multivalue(value, False, - primary=True) - if type(value) not in self._FIELDS_TYPES[field]: - try: - value = self._FIELDS_TYPES[field][0](value) - except ValueError: - continue - else: - if type(value[0]) not in self._FIELDS_TYPES[field]: - try: - value = [self._FIELDS_TYPES[field][0](v) - for v in value] - except ValueError: - continue - if len(value) == 1: - value = value[0] - else: - value = None - setattr(self, field, value) - - self.isrc = (self._handle.get("----:com.apple.iTunes:ISRC")[0].decode() - if "----:com.apple.iTunes:ISRC" in self._handle else None) - - if "disk" in self._handle: - self.disc_number, self.disc_count = self._handle.get("disk")[0] - else: - self.disc_number = self.disc_count = None - - if "trkn" in self._handle: - self.track_number, self.track_count = self._handle.get("trkn")[0] - else: - self.track_number = self.track_count = None - - if "covr" in self._handle: - self.artwork = utility.format_multivalue(self._handle.get("covr"), - False, primary=True) - self._artwork_format = str( - self._IMAGE_FORMATS[self.artwork.imageformat] - ).split(".")[1].lower() - self.artwork = bytes(self.artwork) - else: - self.artwork = self._artwork_format = None - -
-[docs] - def write_metadata(self) -> None: - - """ - Write metadata to file. - """ - - for field, key in self._FIELDS.items(): - value = getattr(self, field) - if value: - value = utility.format_multivalue( - value, self._multivalue, sep=self._sep - ) - try: - self._handle[key] = value - except ValueError: - self._handle[key] = [value] - - if self.isrc: - self._handle["----:com.apple.iTunes:ISRC"] = self.isrc.encode() - - if self.disc_number or self.disc_count: - self._handle["disk"] = [(self.disc_number or 0, - self.disc_count or 0)] - if self.track_number or self.track_count: - self._handle["trkn"] = [(self.track_number or 0, - self.track_count or 0)] - - if self.artwork: - if isinstance(self.artwork, str): - with urllib.request.urlopen(self.artwork) \ - if "http" in self.artwork \ - else open(self.artwork, "rb") as f: - self.artwork = f.read() - self._handle["covr"] = [ - mp4.MP4Cover( - self.artwork, - imageformat=self._IMAGE_FORMATS[self._artwork_format] - ) - ] - - self._handle.save()
-
- - -
-[docs] -class OggAudio(Audio, _VorbisComment): - - r""" - Ogg audio file handler. - - .. seealso:: - - For a full list of attributes and their descriptions, see - :class:`Audio`. - - Parameters - ---------- - file : `str` or `pathlib.Path` - Ogg audio filename or path. - - codec : `str`, optional - Audio codec. If not specified, it will be determined - automatically. - - **Valid values**: :code:`"flac"`, :code:`"opus"`, or - :code:`"vorbis"`. - - pattern : `tuple`, keyword-only, optional - Regular expression search pattern and the corresponding metadata - field(s). - - .. container:: - - **Valid values**: - - The supported metadata fields are - - * :code:`"artist"` for the track artist, - * :code:`"title"` for the track title, and - * :code:`"track_number"` for the track number. - - **Examples**: - - * :code:`("(.*) - (.*)", ("artist", "title"))` matches - filenames like "Taylor Swift - Blank Space.ogg". - * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches - filenames like "03 - Style.ogg". - * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches - filenames like "06 Shake It Off.ogg". - - multivalue : `bool` - Determines whether multivalue tags are supported. If - :code:`False`, the items in `value` are concatenated using the - separator(s) specified in `sep`. - - sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` - Separator(s) to use to concatenate multivalue tags. If a - :code:`str` is provided, it is used to concatenate all values. - If a :code:`tuple` is provided, the first :code:`str` is used to - concatenate the first :math:`n - 1` values, and the second - :code:`str` is used to append the final value. - """ - - _CODECS = {"flac": {"ffmpeg": "-c:a flac", "mutagen": oggflac.OggFLAC}, - "opus": {"ffmpeg": "-b:a 256k -c:a libopus -vn", - "mutagen": oggopus.OggOpus}, - "vorbis": {"ffmpeg": f"-c:a {FFMPEG_CODECS['vorbis']} -vn", - "mutagen": oggvorbis.OggVorbis}} - _EXTENSIONS = ["ogg", "oga", "opus"] - - def __init__( - self, file: Union[str, pathlib.Path], codec: str = None, *, - pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, - sep: Union[str, list[str]] = (", ", " & ")) -> None: - - Audio.__init__(self, file, pattern=pattern, multivalue=multivalue, - sep=sep) - - if codec and codec in self._CODECS: - self.codec = codec - self._handle = self._CODECS[codec]["mutagen"](file) - else: - for codec, options in self._CODECS.items(): - try: - self._handle = options["mutagen"](file) - self.codec = codec - except Exception: - pass - else: - break - if not hasattr(self, "_handle"): - raise RuntimeError(f"'{file}' is not a valid Ogg file.") - _VorbisComment.__init__(self, self._file.name, self._handle.tags) - self._from_filename() - - self.channel_count = self._handle.info.channels - if self.codec == "flac": - self.bit_depth = self._handle.info.bits_per_sample - self.sample_rate = self._handle.info.sample_rate - self.bitrate = self.bit_depth * self.channel_count \ - * self.sample_rate - elif self.codec == "opus": - self.bit_depth = self.bitrate = self.sample_rate = None - elif self.codec == "vorbis": - self.bit_depth = None - self.bitrate = self._handle.info.bitrate - self.sample_rate = self._handle.info.sample_rate
- - -
-[docs] -class WAVEAudio(Audio, _ID3): - - r""" - WAVE audio file handler. - - .. seealso:: - - For a full list of attributes and their descriptions, see - :class:`Audio`. - - Parameters - ---------- - file : `str` or `pathlib.Path` - WAVE audio filename or path. - - pattern : `tuple`, keyword-only, optional - Regular expression search pattern and the corresponding metadata - field(s). - - .. container:: - - **Valid values**: - - The supported metadata fields are - - * :code:`"artist"` for the track artist, - * :code:`"title"` for the track title, and - * :code:`"track_number"` for the track number. - - **Examples**: - - * :code:`("(.*) - (.*)", ("artist", "title"))` matches - filenames like "Taylor Swift - Don't Blame Me.wav". - * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches - filenames like "05 - Delicate.wav". - * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches - filenames like "06 Look What You Made Me Do.wav". - - multivalue : `bool` - Determines whether multivalue tags are supported. If - :code:`False`, the items in `value` are concatenated using the - separator(s) specified in `sep`. - - sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` - Separator(s) to use to concatenate multivalue tags. If a - :code:`str` is provided, it is used to concatenate all values. - If a :code:`tuple` is provided, the first :code:`str` is used to - concatenate the first :math:`n - 1` values, and the second - :code:`str` is used to append the final value. - """ - - _CODECS = {"lpcm": {"ffmpeg": "-c:a pcm_s{0:d}le -c:v copy"}} - _EXTENSIONS = ["wav"] - - def __init__( - self, file: Union[str, pathlib.Path], *, - pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, - sep: Union[str, list[str]] = (", ", " & ")) -> None: - - """ - Create a WAVE audio file handler. - """ - - _handle = wave.WAVE(file) - if _handle.tags is None: - _handle.add_tags() - _handle.tags.filename = str(file) - Audio.__init__(self, file, pattern=pattern, multivalue=multivalue, - sep=sep) - _ID3.__init__(self, self._file.name, _handle.tags) - self._from_filename() - - self.bit_depth = _handle.info.bits_per_sample - self.bitrate = _handle.info.bitrate - self.channel_count = _handle.info.channels - self.codec = "lpcm" - self.sample_rate = _handle.info.sample_rate
- -
-
-
-
- - -
-
- - Made with Sphinx and @pradyunsg's - - Furo - -
-
- -
-
- -
-
- -
-
- - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/_modules/minim/discogs.html b/docs/_modules/minim/discogs.html deleted file mode 100644 index ea8ca21..0000000 --- a/docs/_modules/minim/discogs.html +++ /dev/null @@ -1,5044 +0,0 @@ - - - - - - - - minim.discogs - Minim 1.0.0 documentation - - - - - - - - - - - - - - - - - - - Contents - - - - - - Menu - - - - - - - - Expand - - - - - - Light mode - - - - - - - - - - - - - - Dark mode - - - - - - - Auto light/dark mode - - - - - - - - - - - - - - - - - - - -
-
-
- -
- -
-
- -
- -
-
- -
-
-
- - - - - Back to top - -
-
- -
- -
-
-

Source code for minim.discogs

-"""
-Discogs
-=======
-.. moduleauthor:: Benjamin Ye <GitHub: bbye98>
-
-This module contains a complete implementation of the Discogs API.
-"""
-
-from http.server import HTTPServer, BaseHTTPRequestHandler
-import json
-import logging
-from multiprocessing import Process
-import os
-import re
-import secrets
-import time
-from typing import Any, Union
-import urllib
-import warnings
-import webbrowser
-
-import requests
-
-from . import (FOUND_FLASK, FOUND_PLAYWRIGHT, VERSION, REPOSITORY_URL,
-               DIR_HOME, DIR_TEMP, _config)
-if FOUND_FLASK:
-    from flask import Flask, request
-if FOUND_PLAYWRIGHT:
-    from playwright.sync_api import sync_playwright
-
-__all__ = ["API"]
-
-class _DiscogsRedirectHandler(BaseHTTPRequestHandler):
-
-    """
-    HTTP request handler for the Discogs OAuth 1.0a flow.
-    """
-
-    def do_GET(self):
-
-        """
-        Handles an incoming GET request and parses the query string.
-        """
-
-        self.server.response = dict(
-            urllib.parse.parse_qsl(
-                urllib.parse.urlparse(f"{self.path}").query
-            )
-        )
-        self.send_response(200)
-        self.send_header("Content-Type", "text/html")
-        self.end_headers()
-        status = "denied" if "denied" in self.server.response else "granted"
-        self.wfile.write(
-            f"Access {status}. You may close this page now.".encode()
-        )
-
-
-[docs] -class API: - - """ - Discogs API client. - - The Discogs API lets developers build their own Discogs-powered - applications for the web, desktop, and mobile devices. It is a - RESTful interface to Discogs data and enables accessing JSON- - formatted information about artists, releases, and labels, - managing user collections and wantlists, creating marketplace - listings, and more. - - .. seealso:: - - For more information, see the `Discogs API home page - <https://www.discogs.com/developers>`_. - - The Discogs API can be accessed with or without authentication. - (client credentials, personal access token, or OAuth access token - and access token secret). However, it is recommended that users at - least provide client credentials to enjoy higher rate limits and - access to image URLs. The consumer key and consumer secret can - either be provided to this class's constructor as keyword arguments - or be stored as :code:`DISCOGS_CONSUMER_KEY` and - :code:`DISCOGS_CONSUMER_SECRET` in the operating system's - environment variables. - - .. seealso:: - - To get client credentials, see the Registration section of the - `Authentication page <https://www.discogs.com/developers - /#page:authentication>`_ of the Discogs API website. To take - advantage of Minim's automatic access token retrieval - functionality for the OAuth 1.0a flow, the redirect URI should be - in the form :code:`http://localhost:{port}/callback`, where - :code:`{port}` is an open port on :code:`localhost`. - - To view and make changes to account information and resources, users - must either provide a personal access token to this class's - constructor as a keyword argument or undergo the OAuth 1.0a flow, - which require valid client credentials, using Minim. If an existing - OAuth access token/secret pair is available, it can be provided to - this class's constructor as keyword arguments to bypass the access - token retrieval process. - - .. tip:: - - The authorization flow and access token can be changed or updated - at any time using :meth:`set_flow` and :meth:`set_access_token`, - respectively. - - Minim also stores and manages access tokens and their properties. - When the OAuth 1.0a flow is used to acquire an access token/secret - pair, it is automatically saved to the Minim configuration file to - be loaded on the next instantiation of this class. This behavior can - be disabled if there are any security concerns, like if the computer - being used is a shared device. - - Parameters - ---------- - consumer_key : `str`, keyword-only, optional - Consumer key. Required for the OAuth 1.0a flow, and can be used - in the Discogs authorization flow alongside a consumer secret. - If it is not stored as :code:`DISCOGS_CONSUMER_KEY` in the - operating system's environment variables or found in the Minim - configuration file, it can be provided here. - - consumer_secret : `str`, keyword-only, optional - Consumer secret. Required for the OAuth 1.0a flow, and can be - used in the Discogs authorization flow alongside a consumer key. - If it is not stored as :code:`DISCOGS_CONSUMER_SECRET` in the - operating system's environment variables or found in the Minim - configuration file, it can be provided here. - - flow : `str`, keyword-only, optional - Authorization flow. If :code:`None` and no access token is - provided, no user authentication will be performed and client - credentials will not be attached to requests, even if found or - provided. - - .. container:: - - **Valid values**: - - * :code:`None` for no user authentication. - * :code:`"discogs"` for the Discogs authentication flow. - * :code:`"oauth"` for the OAuth 1.0a flow. - - browser : `bool`, keyword-only, default: :code:`False` - Determines whether a web browser is automatically opened for the - OAuth 1.0a flow. If :code:`False`, users will have to manually - open the authorization URL and provide the full callback URI via - the terminal. - - web_framework : `str`, keyword-only, optional - Determines which web framework to use for the OAuth 1.0a flow. - - .. container:: - - **Valid values**: - - * :code:`"http.server"` for the built-in implementation of - HTTP servers. - * :code:`"flask"` for the Flask framework. - * :code:`"playwright"` for the Playwright framework by - Microsoft. - - port : `int` or `str`, keyword-only, default: :code:`8888` - Port on :code:`localhost` to use for the OAuth 1.0a flow with - the :code:`http.server` and Flask frameworks. Only used if - `redirect_uri` is not specified. - - redirect_uri : `str`, keyword-only, optional - Redirect URI for the OAuth 1.0a flow. If not on - :code:`localhost`, the automatic request access token retrieval - functionality is not available. - - access_token : `str`, keyword-only, optional - Personal or OAuth access token. If provided here or found in the - Minim configuration file, the authentication process is - bypassed. - - access_token_secret : `str`, keyword-only, optional - OAuth access token secret accompanying `access_token`. - - overwrite : `bool`, keyword-only, default: :code:`False` - Determines whether to overwrite an existing access token in the - Minim configuration file. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether newly obtained access tokens and their - associated properties are stored to the Minim configuration - file. - - Attributes - ---------- - API_URL : `str` - Base URL for the Discogs API. - - ACCESS_TOKEN_URL : `str` - URL for the OAuth 1.0a access token endpoint. - - AUTH_URL : `str` - URL for the OAuth 1.0a authorization endpoint. - - REQUEST_TOKEN_URL : `str` - URL for the OAuth 1.0a request token endpoint. - - session : `requests.Session` - Session used to send requests to the Discogs API. - """ - - _FLOWS = {"discogs", "oauth"} - _NAME = f"{__module__}.{__qualname__}" - - API_URL = "https://api.discogs.com" - ACCESS_TOKEN_URL = f"{API_URL}/oauth/access_token" - AUTH_URL = "https://www.discogs.com/oauth/authorize" - REQUEST_TOKEN_URL = f"{API_URL}/oauth/request_token" - - def __init__( - self, *, consumer_key: str = None, consumer_secret: str = None, - flow: str = None, browser: bool = False, web_framework: str = None, - port: Union[int, str] = 8888, redirect_uri: str = None, - access_token: str = None, access_token_secret: str = None, - overwrite: bool = False, save: bool = True) -> None: - - """ - Create a Discogs API client. - """ - - self.session = requests.Session() - self.session.headers["User-Agent"] = f"Minim/{VERSION} +{REPOSITORY_URL}" - - if (access_token is None and _config.has_section(self._NAME) - and not overwrite): - flow = _config.get(self._NAME, "flow") - access_token = _config.get(self._NAME, "access_token") - access_token_secret = _config.get(self._NAME, "access_token_secret") - consumer_key = _config.get(self._NAME, "consumer_key") - consumer_secret = _config.get(self._NAME, "consumer_secret") - elif flow is None and access_token is not None: - flow = "discogs" if access_token_secret is None else "oauth" - - self.set_flow( - flow, consumer_key=consumer_key, consumer_secret=consumer_secret, - browser=browser, web_framework=web_framework, port=port, - redirect_uri=redirect_uri, save=save - ) - self.set_access_token(access_token, access_token_secret) - - def _check_authentication( - self, endpoint: str, token: bool = True) -> None: - - """ - Check if the user is authenticated for the desired endpoint. - - Parameters - ---------- - endpoint : `str` - Discogs API endpoint. - - token : `bool`, default: :code:`True` - Specifies whether a personal access token or OAuth access - token is required for the endpoint. If :code:`False`, only - client credentials are required. - """ - - if token and ( - self._flow != "oauth" - or self._flow == "discogs" - and "token" not in self.session.headers["Authorization"] - ): - emsg = (f"{self._NAME}.{endpoint}() requires user " - "authentication.") - raise RuntimeError(emsg) - elif self._flow is None: - emsg = f"{self._NAME}.{endpoint}() requires client credentials." - raise RuntimeError(emsg) - - def _get_json(self, url: str, **kwargs) -> dict: - - """ - Send a GET request and return the JSON-encoded content of the - response. - - Parameters - ---------- - url : `str` - URL for the GET request. - - **kwargs - Keyword arguments to pass to :meth:`requests.request`. - - Returns - ------- - resp : `dict` - JSON-encoded content of the response. - """ - - return self._request("get", url, **kwargs).json() - - def _request( - self, method: str, url: str, *, oauth: dict[str, Any] = None, - **kwargs) -> requests.Response: - - """ - Construct and send a request with status code checking. - - Parameters - ---------- - method : `str` - Method for the request. - - url : `str` - URL for the request. - - oauth : `dict`, keyword-only, optional - OAuth-related values to be included in the authorization - header. - - **kwargs - Keyword arguments passed to :meth:`requests.request`. - - Returns - ------- - resp : `requests.Response` - Response to the request. - """ - - if "headers" not in kwargs: - kwargs["headers"] = {} - if self._flow == "oauth" and "Authorization" not in kwargs["headers"]: - if oauth is None: - oauth = {} - oauth = self._oauth | { - "oauth_nonce": secrets.token_hex(32), - "oauth_timestamp": f"{time.time():.0f}" - } | oauth - kwargs["headers"]["Authorization"] = "OAuth " + ", ".join( - f'{k}="{v}"' for k, v in oauth.items() - ) - - r = self.session.request(method, url, **kwargs) - if r.status_code not in range(200, 299): - j = r.json() - emsg = f"{r.status_code}: {j['message']}" - if "detail" in j["message"]: - emsg += f"\n{json.dumps(j['detail'], indent=2)}" - raise RuntimeError(emsg) - return r - -
-[docs] - def set_access_token( - self, access_token: str = None, access_token_secret: str = None - ) -> None: - - """ - Set the Discogs API personal or OAuth access token (and secret). - - Parameters - ---------- - access_token : `str`, optional - Personal or OAuth access token. - - access_token_secret : `str`, optional - OAuth access token secret. - """ - - if self._flow == "oauth": - self._oauth = { - "oauth_consumer_key": self._consumer_key, - "oauth_signature_method": "PLAINTEXT" - } - - if access_token is None: - oauth = {"oauth_signature": f"{self._consumer_secret}&"} - if self._redirect_uri is not None: - oauth["oauth_callback"] = self._redirect_uri - r = self._request( - "get", - self.REQUEST_TOKEN_URL, - headers={ - "Content-Type": "application/x-www-form-urlencoded" - }, - oauth=oauth - ) - auth_url = f"{self.AUTH_URL}?{r.text}" - oauth = dict(urllib.parse.parse_qsl(r.text)) - - if self._web_framework == "playwright": - har_file = DIR_TEMP / "minim_discogs.har" - - with sync_playwright() as playwright: - browser = playwright.firefox.launch(headless=False) - context = browser.new_context(record_har_path=har_file) - page = context.new_page() - page.goto(auth_url, timeout=0) - page.wait_for_url(f"{self._redirect_uri}*", - wait_until="commit") - context.close() - browser.close() - - with open(har_file, "r") as f: - oauth |= dict( - urllib.parse.parse_qsl( - urllib.parse.urlparse( - re.search(fr'{self._redirect_uri}\?(.*?)"', - f.read()).group(0) - ).query - ) - ) - har_file.unlink() - - else: - if self._browser: - webbrowser.open(auth_url) - else: - print("To grant Minim access to Discogs data " - "and features, open the following link " - f"in your web browser:\n\n{auth_url}\n") - - if self._web_framework == "http.server": - httpd = HTTPServer(("", self._port), - _DiscogsRedirectHandler) - httpd.handle_request() - oauth |= httpd.response - - elif self._web_framework == "flask": - app = Flask(__name__) - json_file = DIR_TEMP / "minim_discogs.json" - - @app.route("/callback", methods=["GET"]) - def _callback() -> str: - if "error" in request.args: - return ("Access denied. You may close " - "this page now.") - with open(json_file, "w") as f: - json.dump(request.args, f) - return ("Access granted. You may close " - "this page now.") - - server = Process(target=app.run, - args=("0.0.0.0", self._port)) - server.start() - while not json_file.is_file(): - time.sleep(0.1) - server.terminate() - - with open(json_file, "rb") as f: - oauth |= json.load(f) - json_file.unlink() - - else: - oauth["oauth_verifier"] = input( - "After authorizing Minim to access Discogs " - "on your behalf, enter the displayed code " - "below.\n\nCode: " - ) - - if "denied" in oauth: - raise RuntimeError("Authorization failed.") - - oauth["oauth_signature"] = (f"{self._consumer_secret}" - f"&{oauth['oauth_token_secret']}") - r = self._request( - "post", - self.ACCESS_TOKEN_URL, - headers={ - "Content-Type": "application/x-www-form-urlencoded" - }, - oauth=oauth - ) - access_token, access_token_secret = \ - dict(urllib.parse.parse_qsl(r.text)).values() - - if self._save: - _config[self._NAME] = { - "flow": self._flow, - "access_token": access_token, - "access_token_secret": access_token_secret, - "consumer_key": self._consumer_key, - "consumer_secret": self._consumer_secret - } - with open(DIR_HOME / "minim.cfg", "w") as f: - _config.write(f) - - self._oauth |= { - "oauth_token": access_token, - "oauth_signature": self._consumer_secret - + f"&{access_token_secret}" - } - - elif self._flow == "discogs": - if access_token is None: - if self._consumer_key is None or self._consumer_secret is None: - emsg = "Discogs API client credentials not provided." - raise ValueError(emsg) - self.session.headers["Authorization"] = ( - f"Discogs key={self._consumer_key}, " - f"secret={self._consumer_secret}" - ) - else: - self.session.headers["Authorization"] = \ - f"Discogs token={access_token}" - - if (self._flow == "oauth" - or self._flow == "discogs" - and "token" in self.session.headers["Authorization"]): - identity = self.get_identity() - self._username = identity["username"]
- - -
-[docs] - def set_flow( - self, flow: str, *, consumer_key: str = None, - consumer_secret: str = None, browser: bool = False, - web_framework: str = None, port: Union[int, str] = 8888, - redirect_uri: str = None, save: bool = True) -> None: - - """ - Set the authorization flow. - - Parameters - ---------- - flow : `str` - Authorization flow. If :code:`None`, no user authentication - will be performed and client credentials will not be - attached to requests, even if found or provided. - - .. container:: - - **Valid values**: - - * :code:`None` for no user authentication. - * :code:`"discogs"` for the Discogs authentication flow. - * :code:`"oauth"` for the OAuth 1.0a flow. - - consumer_key : `str`, keyword-only, optional - Consumer key. Required for the OAuth 1.0a flow, and can be - used in the Discogs authorization flow alongside a consumer - secret. If it is not stored as :code:`DISCOGS_CONSUMER_KEY` - in the operating system's environment variables or found in - the Minim configuration file, it can be provided here. - - consumer_secret : `str`, keyword-only, optional - Consumer secret. Required for the OAuth 1.0a flow, and can - be used in the Discogs authorization flow alongside a - consumer key. If it is not stored as - :code:`DISCOGS_CONSUMER_SECRET` in the operating system's - environment variables or found in the Minim configuration - file, it can be provided here. - - browser : `bool`, keyword-only, default: :code:`False` - Determines whether a web browser is automatically opened for - the OAuth 1.0a flow. If :code:`False`, users will have to - manually open the authorization URL and provide the full - callback URI via the terminal. - - web_framework : `str`, keyword-only, optional - Determines which web framework to use for the OAuth 1.0a - flow. - - .. container:: - - **Valid values**: - - * :code:`"http.server"` for the built-in implementation - of HTTP servers. - * :code:`"flask"` for the Flask framework. - * :code:`"playwright"` for the Playwright framework by - Microsoft. - - port : `int` or `str`, keyword-only, default: :code:`8888` - Port on :code:`localhost` to use for the OAuth 1.0a flow - with the :code:`http.server` and Flask frameworks. Only used - if `redirect_uri` is not specified. - - redirect_uri : `str`, keyword-only, optional - Redirect URI for the OAuth 1.0a flow. If not on - :code:`localhost`, the automatic request access token - retrieval functionality is not available. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether newly obtained access tokens and their - associated properties are stored to the Minim configuration - file. - """ - - if flow and flow not in self._FLOWS: - emsg = (f"Invalid authorization flow ({flow=}). " - f"Valid values: {', '.join(self._FLOWS)}.") - raise ValueError(emsg) - self._flow = flow - self._save = save - - self._consumer_key = \ - consumer_key or os.environ.get("DISCOGS_CONSUMER_KEY") - self._consumer_secret = \ - consumer_secret or os.environ.get("DISCOGS_CONSUMER_SECRET") - - if flow == "oauth": - self._browser = browser - if redirect_uri: - self._redirect_uri = redirect_uri - if "localhost" in redirect_uri: - self._port = re.search(r"localhost:(\d+)", - redirect_uri).group(1) - elif web_framework: - wmsg = ("The redirect URI is not on localhost, " - "so automatic authorization code " - "retrieval is not available.") - logging.warning(wmsg) - web_framework = None - elif port: - self._port = port - self._redirect_uri = f"http://localhost:{port}/callback" - else: - self._port = self._redirect_uri = None - - self._web_framework = ( - web_framework - if web_framework is None - or web_framework == "http.server" - or globals()[f"FOUND_{web_framework.upper()}"] - else None - ) - if self._web_framework is None and web_framework: - wmsg = (f"The {web_framework.capitalize()} web " - "framework was not found, so automatic " - "authorization code retrieval is not " - "available.") - warnings.warn(wmsg)
- - - ### DATABASE ############################################################## - -
-[docs] - def get_release( - self, release_id: Union[int, str], *, curr_abbr: str = None - ) -> dict[str, Any]: - - """ - `Database > Release <https://www.discogs.com - /developers/#page:database,header:database-release-get>`_: - Get a release (physical or digital object released by one or - more artists). - - Parameters - ---------- - release_id : `int` or `str` - The release ID. - - **Example**: :code:`249504`. - - curr_abbr : `str`, keyword-only, optional - Currency abbreviation for marketplace data. Defaults to the - authenticated user's currency. - - **Valid values**: :code:`"USD"`, :code:`"GBP"`, - :code:`"EUR"`, :code:`"CAD"`, :code:`"AUD"`, :code:`"JPY"`, - :code:`"CHF"`, :code:`"MXN"`, :code:`"BRL"`, :code:`"NZD"`, - :code:`"SEK"`, and :code:`"ZAR"`. - - Returns - ------- - release : `dict` - Discogs database information for a single release. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "title": <str>, - "id": <int>, - "artists": [ - { - "anv": <str>, - "id": <int>, - "join": <str>, - "name": <str>, - "resource_url": <str>, - "role": <str>, - "tracks": <str> - } - ], - "data_quality": <str>, - "thumb": <str>, - "community": { - "contributors": [ - { - "resource_url": <str>, - "username": <str> - } - ], - "data_quality": <str>, - "have": <int>, - "rating": { - "average": <float>, - "count": <int> - }, - "status": <str>, - "submitter": { - "resource_url": <str>, - "username": <str> - }, - "want": <int> - }, - "companies": [ - { - "catno": <str>, - "entity_type": <str>, - "entity_type_name": <str>, - "id": <int>, - "name": <str>, - "resource_url": <str> - } - ], - "country": <str>, - "date_added": <str>, - "date_changed": <str>, - "estimated_weight": <int>, - "extraartists": [ - { - "anv": <str>, - "id": <int>, - "join": <str>, - "name": <str>, - "resource_url": <str>, - "role": <str>, - "tracks": <str> - } - ], - "format_quantity": <int>, - "formats": [ - { - "descriptions": [<str>], - "name": <str>, - "qty": <str> - } - ], - "genres": [<str>], - "identifiers": [ - { - "type": <str>, - "value": <str> - }, - ], - "images": [ - { - "height": <int>, - "resource_url": <str>, - "type": <str>, - "uri": <str>, - "uri150": <str>, - "width": <int> - } - ], - "labels": [ - { - "catno": <str>, - "entity_type": <str>, - "id": <int>, - "name": <str>, - "resource_url": <str> - } - ], - "lowest_price": <float>, - "master_id": <int>, - "master_url": <str>, - "notes": <str>, - "num_for_sale": <int>, - "released": <str>, - "released_formatted": <str>, - "resource_url": <str>, - "series": [], - "status": <str>, - "styles": [<str>], - "tracklist": [ - { - "duration": <str>, - "position": <str>, - "title": <str>, - "type_": <str> - } - ], - "uri": <str>, - "videos": [ - { - "description": <str>, - "duration": <int>, - "embed": <bool>, - "title": <str>, - "uri": <str> - }, - ], - "year": <int> - } - """ - - if curr_abbr and curr_abbr not in ( - CURRENCIES := { - "USD", "GBP", "EUR", "CAD", "AUD", "JPY", - "CHF", "MXN", "BRL", "NZD", "SEK", "ZAR" - } - ): - emsg = (f"Invalid currency abbreviation ({curr_abbr=}). " - f"Valid values: {', '.join(CURRENCIES)}.") - raise ValueError(emsg) - - return self._get_json( - f"{self.API_URL}/releases/{release_id}", - params={"curr_abbr": curr_abbr} - )
- - -
-[docs] - def get_user_release_rating( - self, release_id: Union[int, str], username: str = None - ) -> dict[str, Any]: - - """ - `Database > Release Rating By User > Get Release Rating By User - <https://www.discogs.com/developers - /#page:database,header:database-release-get>`_: Retrieves the - release's rating for a given user. - - Parameters - ---------- - release_id : `int` or `str` - The release ID. - - **Example**: :code:`249504`. - - username : `str`, optional - The username of the user whose rating you are requesting. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"memory"`. - - Returns - ------- - rating : `dict` - Rating for the release by the given user. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "username": <str>, - "release_id": <int>, - "rating": <int> - } - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._get_json( - f"{self.API_URL}/releases/{release_id}/rating/{username}" - )
- - -
-[docs] - def update_user_release_rating( - self, release_id: Union[int, str], rating: int, - username: str = None) -> dict[str, Any]: - - """ - `Database > Release Rating By User > Update Release Rating By - User <https://www.discogs.com/developers - /#page:database,header:database-release-rating-by-user-put>`_: - Updates the release's rating for a given user. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - release_id : `int` or `str` - The release ID. - - **Example**: :code:`249504`. - - rating : `int` - The new rating for a release between :math:`1` and :math:`5`. - - username : `str`, optional - The username of the user whose rating you are requesting. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"memory"`. - - Returns - ------- - rating : `dict` - Updated rating for the release by the given user. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "username": <str>, - "release_id": <int>, - "rating": <int> - } - """ - - self._check_authentication("update_user_release_rating") - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._request( - "put", - f"{self.API_URL}/releases/{release_id}/rating/{username}", - json={"rating": rating} - )
- - -
-[docs] - def delete_user_release_rating( - self, release_id: Union[int, str], username: str = None) -> None: - - """ - `Database > Release Rating By User > Delete Release Rating By - User <https://www.discogs.com/developers - /#page:database,header:database-release-rating-by-user-delete>`_: - Deletes the release's rating for a given user. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - release_id : `int` or `str` - The release ID. - - **Example**: :code:`249504`. - - username : `str`, optional - The username of the user whose rating you are requesting. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"memory"`. - """ - - self._check_authentication("delete_user_release_rating") - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._request( - "delete", - f"{self.API_URL}/releases/{release_id}/rating/{username}" - )
- - -
-[docs] - def get_community_release_rating( - self, release_id: Union[int, str]) -> dict[str, Any]: - - """ - `Database > Community Release Rating <https://www.discogs.com - /developers/#page:database,header - :database-community-release-rating-get>`_: Retrieves the - community release rating average and count. - - Parameters - ---------- - release_id : `int` or `str` - The release ID. - - **Example**: :code:`249504`. - - Returns - ------- - rating : `dict` - Community release rating average and count. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "rating": { - "count": <int>, - "average": <float> - }, - "release_id": <int> - } - """ - - return self._get_json(f"{self.API_URL}/releases/{release_id}/rating")
- - -
-[docs] - def get_release_stats(self, release_id: Union[int, str]) -> dict[str, Any]: - - """ - `Database > Release Stats <https://www.discogs.com/developers - /#page:database,header:database-release-stats-get>`_: Retrieves - the release's "have" and "want" counts. - - .. attention:: - - This endpoint does not appear to be working correctly. - Currently, the response will be of the form - - .. code:: - - { - "is_offense": <bool> - } - - Parameters - ---------- - release_id : `int` or `str` - The release ID. - - **Example**: :code:`249504`. - - Returns - ------- - stats : `dict` - Release "have" and "want" counts. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "num_have": <int>, - "num_want": <int> - } - """ - - return self._get_json(f"{self.API_URL}/releases/{release_id}/stats")
- - -
-[docs] - def get_master_release(self, master_id: Union[int, str]) -> dict[str, Any]: - - """ - `Database > Master Release <https://www.discogs.com/developers - /#page:database,header:database-master-release-get>`_: Get a - master release. - - Parameters - ---------- - master_id : `int` or `str` - The master release ID. - - **Example**: :code:`1000`. - - Returns - ------- - master_release : `dict` - Discogs database information for a single master release. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "styles": [<str>], - "genres": [<str>], - "videos": [ - { - "duration": <int>, - "description": <str>, - "embed": <bool>, - "uri": <str>, - "title": <str> - } - ], - "title": <str>, - "main_release": <int>, - "main_release_url": <str>, - "uri": <str>, - "artists": [ - { - "join": <str>, - "name": <str>, - "anv": <str>, - "tracks": <str>, - "role": <str>, - "resource_url": <str>, - "id": <int> - } - ], - "versions_url": <str>, - "year": <int>, - "images": [ - { - "height": <int>, - "resource_url": <str>, - "type": <str>, - "uri": <str>, - "uri150": <str>, - "width": <int> - } - ], - "resource_url": <str>, - "tracklist": [ - { - "duration": <str>, - "position": <str>, - "type_": <str>, - "extraartists": [ - { - "join": <str>, - "name": <str>, - "anv": <str>, - "tracks": <str>, - "role": <str>, - "resource_url": <str>, - "id": <int> - } - ], - "title": <str> - } - ], - "id": <int>, - "num_for_sale": <int>, - "lowest_price": <float>, - "data_quality": <str> - } - """ - - return self._get_json(f"{self.API_URL}/masters/{master_id}")
- - -
-[docs] - def get_master_release_versions( - self, master_id: Union[int, str], *, country: str = None, - format: str = None, label: str = None, released: str = None, - page: Union[int, str] = None, per_page: Union[int, str] = None, - sort: str = None, sort_order: str = None) -> dict[str, Any]: - - """ - `Database > Master Release Versions <https://www.discogs.com - /developers/#page:database,header - :database-master-release-versions-get>`_: Retrieves a list of - all releases that are versions of this master. - - Parameters - ---------- - master_id : `int` or `str` - The master release ID. - - **Example**: :code:`1000`. - - country : `str`, keyword-only, optional - The country to filter for. - - **Example**: :code:`"Belgium"`. - - format : `str`, keyword-only, optional - The format to filter for. - - **Example**: :code:`"Vinyl"`. - - label : `str`, keyword-only, optional - The label to filter for. - - **Example**: :code:`"Scorpio Music"`. - - released : `str`, keyword-only, optional - The release year to filter for. - - **Example**: :code:`"1992"`. - - page : `int` or `str`, keyword-only, optional - The page you want to request. - - **Example**: :code:`3`. - - per_page : `int` or `str`, keyword-only, optional - The number of items per page. - - **Example**: :code:`25`. - - sort : `str`, keyword-only, optional - Sort items by this field. - - **Valid values**: :code:`"released"`, :code:`"title"`, - :code:`"format"`, :code:`"label"`, :code:`"catno"`, - and :code:`"country"`. - - sort_order : `str`, keyword-only, optional - Sort items in a particular order. - - **Valid values**: :code:`"asc"` and :code:`"desc"`. - - Returns - ------- - versions : `dict` - Discogs database information for all releases that are - versions of the specified master. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "items": <int>, - "page": <int>, - "pages": <int>, - "per_page": <int>, - "urls": { - "last": <str>, - "next": <str> - } - }, - "versions": [ - { - "status": <str>, - "stats": { - "user": { - "in_collection": <int>, - "in_wantlist": <int> - }, - "community": { - "in_collection": <int>, - "in_wantlist": <int> - } - }, - "thumb": <str>, - "format": <str>, - "country": <str>, - "title": <str>, - "label": <str>, - "released": <str>, - "major_formats": [<str>], - "catno": <str>, - "resource_url": <str>, - "id": <int> - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/masters/{master_id}/versions", - params={ - "country": country, - "format": format, - "label": label, - "released": released, - "page": page, - "per_page": per_page, - "sort": sort, - "sort_order": sort_order - }, - )
- - -
-[docs] - def get_artist(self, artist_id: Union[int, str]) -> dict[str, Any]: - - """ - `Database > Artist <https://www.discogs.com/developers - /#page:database,header:database-artist-get>`_: Get an artist. - - Parameters - ---------- - artist_id : `int` or `str` - The artist ID. - - **Example**: :code:`108713`. - - Returns - ------- - artist : `dict` - Discogs database information for a single artist. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "namevariations": [<str>], - "profile": <str>, - "releases_url": <str>, - "resource_url": <str>, - "uri": <str>, - "urls": [<str>], - "data_quality": <str>, - "id": <int>, - "images": [ - { - "height": <int>, - "resource_url": <str>, - "type": <str>, - "uri": <str>, - "uri150": <str>, - "width": <int> - } - ], - "members": [ - { - "active": <bool>, - "id": <int>, - "name": <str>, - "resource_url": <str> - } - ] - } - """ - - return self._get_json(f"{self.API_URL}/artists/{artist_id}")
- - -
-[docs] - def get_artist_releases( - self, artist_id: Union[int, str], *, page: Union[int, str] = None, - per_page: Union[int, str] = None, sort: str = None, - sort_order: str = None) -> dict[str, Any]: - - """ - `Database > Artist Releases <https://www.discogs.com/developers - /#page:database,header:database-artist-releases-get>`_: Get an - artist's releases and masters. - - Parameters - ---------- - artist_id : `int` or `str` - The artist ID. - - **Example**: :code:`108713`. - - page : `int` or `str`, keyword-only, optional - Page of results to fetch. - - per_page : `int` or `str`, keyword-only, optional - Number of results per page. - - sort : `str`, keyword-only, optional - Sort results by this field. - - **Valid values**: :code:`"year"`, :code:`"title"`, and - :code:`"format"`. - - sort_order : `str`, keyword-only, optional - Sort results in a particular order. - - **Valid values**: :code:`"asc"` and :code:`"desc"`. - - Returns - ------- - releases : `dict` - Discogs database information for all releases by the - specified artist. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "items": <int>, - "page": <int>, - "pages": <int>, - "per_page": <int>, - "urls": { - "last": <str>, - "next": <str> - } - }, - "releases": [ - { - "artist": <str>, - "id": <int>, - "main_release": <int>, - "resource_url": <str>, - "role": <str>, - "thumb": <str>, - "title": <str>, - "type": <str>, - "year": <int> - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/artists/{artist_id}/releases", - params={ - "page": page, - "per_page": per_page, - "sort": sort, - "sort_order": sort_order - } - )
- - -
-[docs] - def get_label(self, label_id: Union[int, str]) -> dict[str, Any]: - - """ - `Database > Label <https://www.discogs.com/developers - /#page:database,header:database-label-get>`_: Get a label, - company, recording studio, locxation, or other entity involved - with artists and releases. - - Parameters - ---------- - label_id : `int` or `str` - The label ID. - - **Example**: :code:`1`. - - Returns - ------- - label : `dict` - Discogs database information for a single label. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "profile": <str>, - "releases_url": <str>, - "name": <str>, - "contact_info": <str>, - "uri": <str>, - "sublabels": [ - { - "resource_url": <str>, - "id": <int>, - "name": <str> - } - ], - "urls": [<str>], - "images": [ - { - "height": <int>, - "resource_url": <str>, - "type": <str>, - "uri": <str>, - "uri150": <str>, - "width": <int> - } - ], - "resource_url": <str>, - "id": <int>, - "data_quality": <str> - } - """ - - return self._get_json(f"{self.API_URL}/labels/{label_id}")
- - -
-[docs] - def get_label_releases( - self, label_id: Union[int, str], *, page: Union[int, str] = None, - per_page: Union[int, str] = None) -> dict[str, Any]: - - """ - `Database > Label Releases <https://www.discogs.com/developers - /#page:database,header:database-all-label-releases-get>`_: Get a - list of releases associated with the label. - - Parameters - ---------- - label_id : `int` or `str` - The label ID. - - **Example**: :code:`1`. - - page : `int` or `str`, keyword-only, optional - Page of results to fetch. - - per_page : `int` or `str`, keyword-only, optional - Number of results per page. - - Returns - ------- - releases : `dict` - Discogs database information for all releases by the - specified label. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "items": <int>, - "page": <int>, - "pages": <int>, - "per_page": <int>, - "urls": { - "last": <str>, - "next": <str> - } - }, - "releases": [ - { - "artist": <str>, - "catno": <str>, - "format": <str>, - "id": <int>, - "resource_url": <str>, - "status": <str>, - "thumb": <str>, - "title": <str>, - "year": <int> - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/labels/{label_id}/releases", - params={"page": page, "per_page": per_page} - )
- - -
-[docs] - def search( - self, query: str = None, *, type: str = None, title: str = None, - release_title: str = None, credit: str = None, - artist: str = None, anv: str = None, label: str = None, - genre: str = None, style: str = None, country: str = None, - year: str = None, format: str = None, catno: str = None, - barcode: str = None, track: str = None, submitter: str = None, - contributor: str = None) -> dict[str, Any]: - - """ - `Database > Search <https://www.discogs.com/developers - /#page:database,header:database-search-get>`_: Issue a search - query to the Discogs database. - - .. admonition:: Authentication - :class: warning - - Requires authentication with consumer credentials, with a - personal access token, or via the OAuth 1.0a flow. - - Parameters - ---------- - query : `str`, optional - The search query. - - **Example**: :code:`"Nirvana"`. - - type : `str`, keyword-only, optional - The type of item to search for. - - **Valid values**: :code:`"release"`, :code:`"master"`, - :code:`"artist"`, and :code:`"label"`. - - title : `str`, keyword-only, optional - Search by combined :code:`"<artist name> - <release title>"` - title field. - - **Example**: :code:`"Nirvana - Nevermind"`. - - release_title : `str`, keyword-only, optional - Search release titles. - - **Example**: :code:`"Nevermind"`. - - credit : `str`, keyword-only, optional - Search release credits. - - **Example**: :code:`"Kurt"`. - - artist : `str`, keyword-only, optional - Search artist names. - - **Example**: :code:`"Nirvana"`. - - anv : `str`, keyword-only, optional - Search artist name variations (ANV). - - **Example**: :code:`"Nirvana"`. - - label : `str`, keyword-only, optional - Search labels. - - **Example**: :code:`"DGC"`. - - genre : `str`, keyword-only, optional - Search genres. - - **Example**: :code:`"Rock"`. - - style : `str`, keyword-only, optional - Search styles. - - **Example**: :code:`"Grunge"`. - - country : `str`, keyword-only, optional - Search release country. - - **Example**: :code:`"Canada"`. - - year : `str`, keyword-only, optional - Search release year. - - **Example**: :code:`"1991"`. - - format : `str`, keyword-only, optional - Search formats. - - **Example**: :code:`"Album"`. - - catno : `str`, keyword-only, optional - Search catalog number. - - **Example**: :code:`"DGCD-24425"`. - - barcode : `str`, keyword-only, optional - Search barcode. - - **Example**: :code:`"720642442524"`. - - track : `str`, keyword-only, optional - Search track. - - **Example**: :code:`"Smells Like Teen Spirit"`. - - submitter : `str`, keyword-only, optional - Search submitter username. - - **Example**: :code:`"milKt"`. - - contributor : `str`, keyword-only, optional - Search contributor username. - - **Example**: :code:`"jerome99"`. - - Returns - ------- - results : `dict` - Search results. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "items": <int>, - "page": <int>, - "pages": <int>, - "per_page": <int>, - "urls": { - "last": <str>, - "next": <str> - } - }, - "results": [ - { - "style": [<str>], - "thumb": <str>, - "title": <str>, - "country": <str>, - "format": [<str>], - "uri": <str>, - "community": { - "want": <int>, - "have": <int> - }, - "label": [<str>], - "catno": <str>, - "year": <str>, - "genre": [<str>], - "resource_url": <str>, - "type": <str>, - "id": <int> - } - ] - } - """ - - self._check_authentication("search", False) - - return self._get_json( - f"{self.API_URL}/database/search", - params={ - "q": query, - "type": type, - "title": title, - "release_title": release_title, - "credit": credit, - "artist": artist, - "anv": anv, - "label": label, - "genre": genre, - "style": style, - "country": country, - "year": year, - "format": format, - "catno": catno, - "barcode": barcode, - "track": track, - "submitter": submitter, - "contributor": contributor - } - )
- - - ### MARKETPLACE ########################################################### - -
-[docs] - def get_inventory( - self, username: str = None, *, status: str = None, - page: Union[int, str] = None, per_page: Union[int, str] = None, - sort: str = None, sort_order: str = None) -> dict[str, Any]: - - """ - `Marketplace > Inventory <https://www.discogs.com/developers - /#page:marketplace,header:marketplace-inventory-get>`_: - Get a seller's inventory. - - .. admonition:: User authentication - :class: dropdown warning - - If you are authenticated as the inventory owner, additional - fields will be returned in the response, such as - :code:`"weight"`, :code:`"format_quantity"`, - :code:`"external_id"`, :code:`"location"`, and - :code:`"quantity"`. - - Parameters - ---------- - username : `str` - The username of the inventory owner. If not specified, the - username of the authenticated user is used. - - **Example**: :code:`"360vinyl"`. - - status : `str`, keyword-only, optional - The status of the listings to return. - - **Valid values**: :code:`"For Sale"`, :code:`"Draft"`, - :code:`"Expired"`, :code:`"Sold"`, and :code:`"Deleted"`. - - page : `int` or `str`, keyword-only, optional - The page you want to request. - - **Example**: :code:`3`. - - per_page : `int` or `str`, keyword-only, optional - The number of items per page. - - **Example**: :code:`25`. - - sort : `str`, keyword-only, optional - Sort items by this field. - - **Valid values**: :code:`"listed"`, :code:`"price"`, - :code:`"item"`, :code:`"artist"`, :code:`"label"`, - :code:`"catno"`, :code:`"audio"`, :code:`"status"`, and - :code:`"location"`. - - sort_order : `str`, keyword-only, optional - Sort items in a particular order. - - **Valid values**: :code:`"asc"` and :code:`"desc"`. - - Returns - ------- - inventory : `dict` - The seller's inventory. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "page": <int>, - "pages": <int>, - "per_page": <int>, - "items": <int>, - "urls": {} - }, - "listings": [ - { - "status": <str>, - "price": { - "currency": <str>, - "value": <float> - }, - "allow_offers": <bool>, - "sleeve_condition": <str>, - "id": <int>, - "condition": <str>, - "posted": <str>, - "ships_from": <str>, - "uri": <str>, - "comments": <str>, - "seller": { - "username": <str>, - "resource_url": <str>, - "id": <int> - }, - "release": { - "catalog_number": <str>, - "resource_url": <str>, - "year": <int>, - "id": <int>, - "description": <str>, - "artist": <str>, - "title": <str>, - "format": <str>, - "thumbnail": <str> - }, - "resource_url": <str>, - "audio": <bool> - } - ] - } - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._get_json( - f"{self.API_URL}/users/{username}/inventory", - params={"status": status, "page": page, "per_page": per_page, - "sort": sort, "sort_order": sort_order} - )
- - -
-[docs] - def get_listing( - self, listing_id: Union[int, str], *, curr_abbr: str = None - ) -> dict[str, Any]: - - """ - `Marketplace > Listing <https://www.discogs.com/developers - /#page:marketplace,header:marketplace-listing-get>`_: View - marketplace listings. - - Parameters - ---------- - listing_id : `int` or `str` - The ID of the listing you are fetching. - - **Example**: :code:`172723812`. - - curr_abbr : `str`, keyword-only, optional - Currency abbreviation for marketplace listings. Defaults to - the authenticated user's currency. - - **Valid values**: :code:`"USD"`, :code:`"GBP"`, :code:`"EUR"`, - :code:`"CAD"`, :code:`"AUD"`, :code:`"JPY"`, :code:`"CHF"`, - :code:`"MXN"`, :code:`"BRL"`, :code:`"NZD"`, :code:`"SEK"`, - and :code:`"ZAR"`. - - Returns - ------- - listing : `dict` - The marketplace listing. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "status": <str>, - "price": { - "currency": <str>, - "value": <int> - }, - "original_price": { - "curr_abbr": <str>, - "curr_id": <int>, - "formatted": <str>, - "value": <float> - }, - "allow_offers": <bool>, - "sleeve_condition": <str>, - "id": <int>, - "condition": <str>, - "posted": <str>, - "ships_from": <str>, - "uri": <str>, - "comments": <str>, - "seller": { - "username": <str>, - "avatar_url": <str>, - "resource_url": <str>, - "url": <str>, - "id": <int>, - "shipping": <str>, - "payment": <str>, - "stats": { - "rating": <str>, - "stars": <float>, - "total": <int> - } - }, - "shipping_price": { - "currency": <str>, - "value": <float> - }, - "original_shipping_price": { - "curr_abbr": <str>, - "curr_id": <int>, - "formatted": <str>, - "value": <float> - }, - "release": { - "catalog_number": <str>, - "resource_url": <str>, - "year": <int>, - "id": <int>, - "description": <str>, - "thumbnail": <str>, - }, - "resource_url": <str>, - "audio": <bool> - } - """ - - return self._get_json( - f"{self.API_URL}/marketplace/listings/{listing_id}", - params={"curr_abbr": curr_abbr} - )
- - -
-[docs] - def create_listing( - self, release_id: Union[int, str], condition: str, price: float, - status: str = "For Sale", *, sleeve_condition: str = None, - comments: str = None, allow_offers: bool = None, - external_id: str = None, location: str = None, weight: float = None, - format_quantity: int = None) -> dict[str, Any]: - - """ - `Marketplace > New Listing <https://www.discogs.com/developers - /#page:marketplace,header:marketplace-new-listing>`_: Create a - marketplace listing. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - release_id : `int` or `str` - The ID of the release you are posting. - - **Example**: :code:`249504`. - - condition : `str` - The condition of the release you are posting. - - **Valid values**: :code:`"Mint (M)"`, - :code:`"Near Mint (NM or M-)"`, - :code:`"Very Good Plus (VG+)"`, - :code:`"Very Good (VG)"`, :code:`"Good Plus (G+)"`, - :code:`"Good (G)"`, :code:`"Fair (F)"`, and - :code:`"Poor (P)"`. - - price : `float` - The price of the item (in the seller's currency). - - **Example**: :code:`10.00`. - - status : `str`, default: :code:`"For Sale"` - The status of the listing. - - **Valid values**: :code:`"For Sale"` (the listing is ready - to be shwon on the marketplace) and :code:`"Draft"` (the - listing is not ready for public display). - - sleeve_condition : `str`, optional - The condition of the sleeve of the item you are posting. - - **Valid values**: :code:`"Mint (M)"`, - :code:`"Near Mint (NM or M-)"`, - :code:`"Very Good Plus (VG+)"`, - :code:`"Very Good (VG)"`, :code:`"Good Plus (G+)"`, - :code:`"Good (G)"`, :code:`"Fair (F)"`, and - :code:`"Poor (P)"`. - - comments : `str`, optional - Any remarks about the item that will be displated to buyers. - - allow_offers : `bool`, optional - Whether or not to allow buyers to make offers on the item. - - **Default**: :code:`False`. - - external_id : `str`, optional - A freeform field that can be used for the seller's own - reference. Information stored here will not be displayed to - anyone other than the seller. This field is called “Private - Comments” on the Discogs website. - - location : `str`, optional - A freeform field that is intended to help identify an item's - physical storage location. Information stored here will not - be displayed to anyone other than the seller. This field - will be visible on the inventory management page and will be - available in inventory exports via the website. - - weight : `float`, optional - The weight, in grams, of this listing, for the purpose of - calculating shipping. Set this field to :code:`"auto"` to - have the weight automatically estimated for you. - - format_quantity : `int`, optional - The number of items this listing counts as, for the purpose - of calculating shipping. This field is called "Counts As" on - the Discogs website. Set this field to :code:`"auto"` to - have the quantity automatically estimated for you. - """ - - return self._request( - "post", - f"{self.API_URL}/marketplace/listings", - params={ - "release_id": release_id, - "condition": condition, - "price": price, - "status": status, - "sleeve_condition": sleeve_condition, - "comments": comments, - "allow_offers": allow_offers, - "external_id": external_id, - "location": location, - "weight": weight, - "format_quantity": format_quantity - } - ).json()
- - -
-[docs] - def edit_listing( - self, listing_id: Union[int, str], release_id: Union[int, str], - condition: str, price: float, status: str = "For Sale", *, - sleeve_condition: str = None, comments: str = None, - allow_offers: bool = None, external_id: str = None, - location: str = None, weight: float = None, - format_quantity: int = None) -> None: - - """ - `Marketplace > Listing > Edit Listing <https://www.discogs.com - /developers/#page:marketplace,header:marketplace-listing-post>`_: - Edit the data associated with a listing. - - If the listing's status is not :code:`"For Sale"`, - :code:`"Draft"`, or :code:`"Expired"`, it cannot be - modified—only deleted. To re-list a :code:`"Sold"` listing, a - new listing must be created. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - listing_id : `int` or `str` - The ID of the listing you are fetching. - - **Example**: :code:`172723812`. - - release_id : `int` or `str` - The ID of the release you are posting. - - **Example**: :code:`249504`. - - condition : `str` - The condition of the release you are posting. - - **Valid values**: :code:`"Mint (M)"`, - :code:`"Near Mint (NM or M-)"`, - :code:`"Very Good Plus (VG+)"`, - :code:`"Very Good (VG)"`, :code:`"Good Plus (G+)"`, - :code:`"Good (G)"`, :code:`"Fair (F)"`, and - :code:`"Poor (P)"`. - - price : `float` - The price of the item (in the seller's currency). - - **Example**: :code:`10.00`. - - status : `str`, default: :code:`"For Sale"` - The status of the listing. - - **Valid values**: :code:`"For Sale"` (the listing is ready - to be shwon on the marketplace) and :code:`"Draft"` (the - listing is not ready for public display). - - sleeve_condition : `str`, optional - The condition of the sleeve of the item you are posting. - - **Valid values**: :code:`"Mint (M)"`, - :code:`"Near Mint (NM or M-)"`, - :code:`"Very Good Plus (VG+)"`, - :code:`"Very Good (VG)"`, :code:`"Good Plus (G+)"`, - :code:`"Good (G)"`, :code:`"Fair (F)"`, and - :code:`"Poor (P)"`. - - comments : `str`, optional - Any remarks about the item that will be displated to buyers. - - allow_offers : `bool`, optional - Whether or not to allow buyers to make offers on the item. - - **Default**: :code:`False`. - - external_id : `str`, optional - A freeform field that can be used for the seller's own - reference. Information stored here will not be displayed to - anyone other than the seller. This field is called “Private - Comments” on the Discogs website. - - location : `str`, optional - A freeform field that is intended to help identify an item's - physical storage location. Information stored here will not - be displayed to anyone other than the seller. This field - will be visible on the inventory management page and will be - available in inventory exports via the website. - - weight : `float`, optional - The weight, in grams, of this listing, for the purpose of - calculating shipping. Set this field to :code:`"auto"` to - have the weight automatically estimated for you. - - format_quantity : `int`, optional - The number of items this listing counts as, for the purpose - of calculating shipping. This field is called "Counts As" on - the Discogs website. Set this field to :code:`"auto"` to - have the quantity automatically estimated for you. - """ - - self._check_authentication("edit_listing") - - self._request( - "post", - f"{self.API_URL}/marketplace/listings/{listing_id}", - json={ - "release_id": release_id, - "condition": condition, - "price": price, - "status": status, - "sleeve_condition": sleeve_condition, - "comments": comments, - "allow_offers": allow_offers, - "external_id": external_id, - "location": location, - "weight": weight, - "format_quantity": format_quantity - } - )
- - -
-[docs] - def delete_listing(self, listing_id: Union[int, str]) -> None: - - """ - `Marketplace > Listing > Delete Listing <https://www.discogs.com - /developers/#page:marketplace,header - :marketplace-listing-delete>`_: Permanently remove a listing - from the marketplace. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - listing_id : `int` or `str` - The ID of the listing you are fetching. - - **Example**: :code:`172723812`. - """ - - self._check_authentication("delete_listing") - - self._request("delete", - f"{self.API_URL}/marketplace/listings/{listing_id}")
- - -
-[docs] - def get_order(self, order_id: str) -> dict[str, Any]: - - """ - `Marketplace > Order > Get Order <https://www.discogs.com/developers - #page:marketplace,header:marketplace-order-get>`_: View the data - associated with an order. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - order_id : `str` - The ID of the order you are fetching. - - **Example**: :code:`1-1`. - - Returns - ------- - order : `dict` - The marketplace order. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "id": <str>, - "resource_url": <str>, - "messages_url": <str>, - "uri": <str>, - "status": <str>, - "next_status": [<str>], - "fee": { - "currency": <str>, - "value": <float> - }, - "created": <str>, - "items": [ - { - "release": { - "id": <int>, - "description": <str>, - }, - "price": { - "currency": <str>, - "value": <int> - }, - "media_condition": <str>, - "sleeve_condition": <str>, - "id": <int> - } - ], - "shipping": { - "currency": <str>, - "method": <str>, - "value": <int> - }, - "shipping_address": <str>, - "additional_instructions": <str>, - "archived": <bool>, - "seller": { - "resource_url": <str>, - "username": <str>, - "id": <int> - }, - "last_activity": <str>, - "buyer": { - "resource_url": <str>, - "username": <str>, - "id": <int> - }, - "total": { - "currency": <str>, - "value": <int> - } - } - """ - - self._check_authentication("get_order") - - return self._get_json(f"{self.API_URL}/marketplace/orders/{order_id}")
- - -
-[docs] - def edit_order( - self, order_id: str, status: str, *, shipping: float = None - ) -> dict[str, Any]: - - """ - `Marketplace > Order > Edit Order <https://www.discogs.com/developers - #page:marketplace,header:marketplace-order-post>`_: Edit the data - associated with an order. - - The response contains a :code:`"next_status"` key—an array of - valid next statuses for this order. - - Changing the order status using this resource will always message - the buyer with - - Seller changed status from [...] to [...] - - and does not provide a facility for including a custom message - along with the change. For more fine-grained control, use the - :meth:`add_order_message` method, which allows you to - simultaneously add a message and change the order status. If the - order status is not :code:`"Cancelled"`, - :code:`"Payment Received"`, or :code:`"Shipped"`, you can change - the shipping. Doing so will send an invoice to the buyer and set - the order status to :code:`"Invoice Sent"`. (For that reason, - you cannot set the shipping and the order status in the same - request.) - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - order_id : `str` - The ID of the order you are fetching. - - **Example**: :code:`1-1`. - - status : `str` - The status of the order you are updating. The new status must - be present in the order's :code:`"next_status"` list. - - **Valid values**: :code:`"New Order"`, - :code:`"Buyer Contacted"`, :code:`"Invoice Sent"`, - :code:`"Payment Pending"`, :code:`"Payment Received"`, - :code:`"In Progress"`, :code:`"Shipped"`, - :code:`"Refund Sent"`, :code:`"Cancelled (Non-Paying Buyer)"`, - :code:`"Cancelled (Item Unavailable)"`, and - :code:`"Cancelled (Per Buyer's Request)"`. - - shipping : `float`, optional - The order shipping amount. As a side effect of setting this - value, the buyer is invoiced and the order status is set to - :code:`"Invoice Sent"`. - - **Example**: :code:`5.00`. - - Returns - ------- - order : `dict` - The marketplace order. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "id": <str>, - "resource_url": <str>, - "messages_url": <str>, - "uri": <str>, - "status": <str>, - "next_status": [<str>], - "fee": { - "currency": <str>, - "value": <float> - }, - "created": <str>, - "items": [ - { - "release": { - "id": <int>, - "description": <str>, - }, - "price": { - "currency": <str>, - "value": <int> - }, - "media_condition": <str>, - "sleeve_condition": <str>, - "id": <int> - } - ], - "shipping": { - "currency": <str>, - "method": <str>, - "value": <int> - }, - "shipping_address": <str>, - "additional_instructions": <str>, - "archived": <bool>, - "seller": { - "resource_url": <str>, - "username": <str>, - "id": <int> - }, - "last_activity": <str>, - "buyer": { - "resource_url": <str>, - "username": <str>, - "id": <int> - }, - "total": { - "currency": <str>, - "value": <int> - } - } - """ - - self._check_authentication("edit_order") - - return self._request( - "post", - f"{self.API_URL}/marketplace/orders/{order_id}", - json={"status": status, "shipping": shipping} - ).json()
- - -
-[docs] - def get_user_orders( - self, *, status: str = None, created_after: str = None, - created_before: str = None, archived: bool = None, - page: Union[int, str] = None, per_page: Union[int, str] = None, - sort: str = None, sort_order: str = None) -> dict[str, Any]: - - """ - `Marketplace > List Orders <https://www.discogs.com/developers - /#page:marketplace,header:marketplace-list-orders-get>`_: - Returns a list of the authenticated user's orders. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - status : `str`, keyword-only, optional - Only show orders with this status. - - **Valid values**: :code:`"All"`, :code:`"New Order"`, - :code:`"Buyer Contacted"`, :code:`"Invoice Sent"`, - :code:`"Payment Pending"`, :code:`"Payment Received"`, - :code:`"In Progress"`, :code:`"Shipped"`, - :code:`"Merged"`, :code:`"Order Changed"`, - :code:`"Refund Sent"`, :code:`"Cancelled"`, - :code:`"Cancelled (Non-Paying Buyer)"`, - :code:`"Cancelled (Item Unavailable)"`, - :code:`"Cancelled (Per Buyer's Request)"`, and - :code:`"Cancelled (Refund Received)"`. - - created_after : `str`, keyword-only, optional - Only show orders created after this ISO 8601 timestamp. - - **Example**: :code:`"2019-06-24T20:58:58Z"`. - - created_before : `str`, keyword-only, optional - Only show orders created before this ISO 8601 timestamp. - - **Example**: :code:`"2019-06-24T20:58:58Z"`. - - archived : `bool`, keyword-only, optional - Only show orders with a specific archived status. If no key - is provided, both statuses are returned. - - page : `int` or `str`, keyword-only, optional - The page you want to request. - - **Example**: :code:`3`. - - per_page : `int`, keyword-only, optional - The number of items per page. - - **Example**: :code:`25`. - - sort : `str`, keyword-only, optional - Sort items by this field. - - **Valid values**: :code:`"id"`, :code:`"buyer"`, - :code:`"created"`, :code:`"status"`, and - :code:`"last_activity"`. - - sort_order : `str`, keyword-only, optional - Sort items in a particular order. - - **Valid values**: :code:`"asc"` and :code:`"desc"`. - - Returns - ------- - orders : `dict` - The authenticated user's orders. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "page": <int>, - "pages": <int>, - "per_page": <int>, - "items": <int>, - "urls": {} - }, - "orders": [ - { - "id": <str>, - "resource_url": <str>, - "messages_url": <str>, - "uri": <str>, - "status": <str>, - "next_status": [<str>], - "fee": { - "currency": <str>, - "value": <float> - }, - "created": <str>, - "items": [ - { - "release": { - "id": <int>, - "description": <str>, - }, - "price": { - "currency": <str>, - "value": <int> - }, - "media_condition": <str>, - "sleeve_condition": <str>, - "id": <int> - } - ], - "shipping": { - "currency": <str>, - "method": <str>, - "value": <int> - }, - "shipping_address": <str>, - "additional_instructions": <str>, - "archived": <bool>, - "seller": { - "resource_url": <str>, - "username": <str>, - "id": <int> - }, - "last_activity": <str>, - "buyer": { - "resource_url": <str>, - "username": <str>, - "id": <int> - }, - "total": { - "currency": <str>, - "value": <int> - } - } - ] - } - """ - - self._check_authentication("get_user_orders") - - return self._get_json( - f"{self.API_URL}/marketplace/orders", - params={ - "status": status, - "created_after": created_after, - "created_before": created_before, - "archived": archived, - "page": page, - "per_page": per_page, - "sort": sort, - "sort_order": sort_order, - } - )
- - -
-[docs] - def get_order_messages( - self, order_id: str, *, page: Union[int, str] = None, - per_page: Union[int, str] = None) -> dict[str, Any]: - - """ - `Marketplace > List Order Messages > List Order Messages - <https://www.discogs.com/developers/ - #page:marketplace,header:marketplace-list-order-messages-get>`_: - Returns a list of the order's messages with the most recent - first. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - order_id : `str` - The ID of the order you are fetching. - - **Example**: :code:`1-1`. - - page : `int` or `str`, keyword-only, optional - The page you want to request. - - **Example**: :code:`3`. - - per_page : `int` or `str`, keyword-only, optional - The number of items per page. - - **Example**: :code:`25`. - - Returns - ------- - messages : `dict` - The order's messages. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "per_page": <int>, - "items": <int>, - "page": <int>, - "urls": { - "last": <str>, - "next": <str> - }, - "pages": <int> - }, - "messages": [ - { - "refund": { - "amount": <int>, - "order": { - "resource_url": <str>, - "id": <str> - } - }, - "timestamp": <str>, - "message": <str>, - "type": <str>, - "order": { - "resource_url": <str>, - "id": <str>, - }, - "subject": <str> - } - ] - } - """ - - self._check_authentication("get_order_messages") - - return self._get_json( - f"{self.API_URL}/marketplace/orders/{order_id}/messages", - params={"page": page, "per_page": per_page} - )
- - -
-[docs] - def add_order_message( - self, order_id: str, message: str = None, status: str = None - ) -> dict[str, Any]: - - """ - `Marketplace > List Order Messages > Add New Message - <https://www.discogs.com/developers/ - #page:marketplace,header:marketplace-list-order-messages-post>`_: - Adds a new message to the order's message log. - - When posting a new message, you can simultaneously change the - order status. IF you do, the message will automatically be - prepended with: - - Seller changed status from [...] to [...] - - While `message` and `status` are each optional, one or both - must be present. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - order_id : `str` - The ID of the order you are fetching. - - **Example**: :code:`1-1`. - - message : `str`, optional - The message you are posting. - - **Example**: :code:`"hello world"` - - status : `str`, optional - The status of the order you are updating. - - **Valid values**: :code:`"New Order"`, - :code:`"Buyer Contacted"`, :code:`"Invoice Sent"`, - :code:`"Payment Pending"`, :code:`"Payment Received"`, - :code:`"In Progress"`, :code:`"Shipped"`, - :code:`"Refund Sent"`, :code:`"Cancelled (Non-Paying Buyer)"`, - :code:`"Cancelled (Item Unavailable)"`, and - :code:`"Cancelled (Per Buyer's Request)"`. - - Returns - ------- - message : `dict` - The order's message. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "from": { - "username": <str>, - "resource_url": <str> - }, - "message": <str>, - "order": { - "resource_url": <str>, - "id": <str> - }, - "timestamp": <str>, - "subject": <str> - } - """ - - self._check_authentication("add_order_message") - - if message is None and status is None: - emsg = "Either 'message' or 'status' must be provided." - raise ValueError(emsg) - - return self._request( - "post", - f"{self.API_URL}/marketplace/orders/{order_id}/messages", - json={"message": message, "status": status} - ).json()
- - -
-[docs] - def get_fee(self, price: float, *, currency: str = "USD") -> dict[str, Any]: - - """ - `Marketplace > Fee with currency - <https://www.discogs.com/developers/#page:marketplace,header - :marketplace-fee-with-currency-get>`_: Calculates the fee for - selling an item on the marketplace given a particular currency. - - Parameters - ---------- - price : `float` - The price of the item (in the seller's currency). - - **Example**: :code:`10.00`. - - currency : `str`, keyword-only, default: :code:`"USD"` - The currency abbreviation for the fee calculation. - - **Valid values**: :code:`"USD"`, :code:`"GBP"`, :code:`"EUR"`, - :code:`"CAD"`, :code:`"AUD"`, :code:`"JPY"`, :code:`"CHF"`, - :code:`"MXN"`, :code:`"BRL"`, :code:`"NZD"`, :code:`"SEK"`, - and :code:`"ZAR"`. - - Returns - ------- - fee : `dict` - The fee for selling an item on the marketplace. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "value": <float>, - "currency": <str>, - } - """ - - return self._get_json( - f"{self.API_URL}/marketplace/fee/{price}/{currency}" - )
- - -
-[docs] - def get_price_suggestions( - self, release_id: Union[int, str]) -> dict[str, Any]: - - """ - `Marketplace > Price Suggestions <https://www.discogs.com - /developers/#page:marketplace,header - :marketplace-price-suggestions>`_: Retrieve price suggestions in - the user's selling currency for the provided release ID. - - If no suggestions are available, an empty object will be - returned. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - release_id : `int` or `str` - The ID of the release you are fetching. - - **Example**: :code:`249504`. - - Returns - ------- - prices : `dict` - The price suggestions for the release. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "Very Good (VG)": { - "currency": <str>, - "value": <float> - }, - "Good Plus (G+)": { - "currency": <str>, - "value": <float> - }, - "Near Mint (NM or M-)": { - "currency": <str>, - "value": <float> - }, - "Good (G)": { - "currency": <str>, - "value": <float> - }, - "Very Good Plus (VG+)": { - "currency": <str>, - "value": <float> - }, - "Mint (M)": { - "currency": <str>, - "value": <float> - }, - "Fair (F)": { - "currency": <str>, - "value": <float> - }, - "Poor (P)": { - "currency": <str>, - "value": <float> - } - } - """ - - self._check_authentication("get_price_suggestions") - - return self._get_json( - f"{self.API_URL}/marketplace/price_suggestions/{release_id}" - )
- - -
-[docs] - def get_release_marketplace_stats( - self, release_id: Union[int, str], *, curr_abbr: str = None - ) -> dict[str, Any]: - - """ - `Marketplace > Release Statistics <https://www.discogs.com - /developers/#page:marketplace,header - :marketplace-release-statistics-get>`_: Retrieve marketplace - statistics for the provided release ID. - - These statistics reflect the state of the release in the - marketplace currently, and include the number of items currently - for sale, lowest listed price of any item for sale, and whether - the item is blocked for sale in the marketplace. - - Releases that have no items or are blocked for sale in the - marketplace will return a body with null data in the - :code:`"lowest_price"` and :code:`"num_for_sale"` keys. - - .. admonition:: User authentication - :class: dropdown warning - - Authentication is optional. Authenticated users will by - default have the lowest currency expressed in their own buyer - currency, configurable in buyer settings, in the absence of - the `curr_abbr` query parameter to specify the currency. - Unauthenticated users will have the price expressed in US - Dollars, if no `curr_abbr` is provided. - - Parameters - ---------- - release_id : `int` or `str` - The ID of the release you are fetching. - - **Example**: :code:`249504`. - - curr_abbr : `str`, keyword-only, optional - Currency abbreviation for marketplace data. - - **Valid values**: :code:`"USD"`, :code:`"GBP"`, - :code:`"EUR"`, :code:`"CAD"`, :code:`"AUD"`, :code:`"JPY"`, - :code:`"CHF"`, :code:`"MXN"`, :code:`"BRL"`, :code:`"NZD"`, - :code:`"SEK"`, and :code:`"ZAR"`. - - Returns - ------- - stats : `dict` - The marketplace statistics for the release. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "lowest_price": { - "currency": <str>, - "value": <float> - }, - "num_for_sale": <int>, - "blocked_from_sale": <bool> - } - """ - - return self._get_json( - f"{self.API_URL}/marketplace/stats/{release_id}", - params={"curr_abbr": curr_abbr} - )
- - - ### INVENTORY EXPORT ###################################################### - -
-[docs] - def export_inventory( - self, *, download: bool = True, filename: str = None, - path: str = None) -> str: - - """ - `Inventory Export > Export Your Inventory <https://www.discogs.com - /developers/#page:inventory-export,header - :inventory-export-export-your-inventory-post>`_: Request an - export of your inventory as a CSV. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - download : `bool`, keyword-only, default: :code:`True` - Specifies whether to download the CSV file. If - :code:`False`, the export ID is returned. - - filename : `str`, optional - Filename of the exported CSV file. A :code:`.csv` extension - will be appended if not present. If not specified, the CSV - file is saved as - :code:`<username>-inventory-<date>-<number>.csv`. - - path : `str`, optional - Path to save the exported CSV file. If not specified, the - file is saved in the current working directory. - - Returns - ------- - path_or_id : `str` - Full path to the exported CSV file (:code:`download=True`) - or the export ID (:code:`download=False`). - """ - - self._check_authentication("export_inventory") - - r = self._request("post", f"{self.API_URL}/inventory/export") - if download: - return self.download_inventory_export( - r.headers["Location"].split("/")[-1], - filename=filename, - path=path - ) - return r.headers["Location"]
- - -
-[docs] - def get_inventory_exports( - self, *, page: int = None, per_page: int = None) -> dict[str, Any]: - - """ - `Inventory Export > Get Recent Exports <https://www.discogs.com - /developers/#page:inventory-export,header - :inventory-export-get-recent-exports-get>`_: Get all recent - exports of your inventory. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - page : `int`, keyword-only, optional - The page you want to request. - - **Example**: :code:`3`. - - per_page : `int`, keyword-only, optional - The number of items per page. - - **Example**: :code:`25`. - - Returns - ------- - exports : `dict` - The authenticated user's inventory exports. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "items": [ - { - "status": <str>, - "created_ts": <str>, - "url": <str>, - "finished_ts": <str>, - "download_url": <str>, - "filename": <str>, - "id": <int> - } - ], - "pagination": { - "per_page": <int>, - "items": <int>, - "page": <int>, - "urls": { - "last": <str>, - "next": <str> - }, - "pages": <int> - } - } - """ - - self._check_authentication("get_inventory_exports") - - return self._get_json(f"{self.API_URL}/inventory/export", - json={"page": page, "per_page": per_page})
- - -
-[docs] - def get_inventory_export(self, export_id: int) -> dict[str, Union[int, str]]: - - """ - `Inventory Export > Get An Export <https://www.discogs.com - /developers/#page:inventory-export,header - :inventory-export-get-an-export-get>`_: Get details about the - status of an inventory export. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - export_id : `int` - ID of the export. - - Returns - ------- - export : `dict` - Details about the status of the inventory export. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "status": <str>, - "created_ts": <str>, - "url": <str>, - "finished_ts": <str>, - "download_url": <str>, - "filename": <str>, - "id": <int> - } - """ - - self._check_authentication("get_inventory_export") - - return self._get_json(f"{self.API_URL}/inventory/export/{export_id}")
- - -
-[docs] - def download_inventory_export( - self, export_id: int, *, filename: str = None, path: str = None - ) -> str: - - """ - `Inventory Export > Download An Export <https://www.discogs.com - /developers/#page:inventory-export,header - :inventory-export-download-an-export-get>`_: Download the - results of an inventory export. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - export_id : `int` - ID of the export. - - filename : `str`, optional - Filename of the exported CSV file. A :code:`.csv` extension - will be appended if not present. If not specified, the CSV - file is saved as - :code:`<username>-inventory-<date>-<number>.csv`. - - path : `str`, optional - Path to save the exported CSV file. If not specified, the - file is saved in the current working directory. - - Returns - ------- - path : `str` - Full path to the exported CSV file. - """ - - self._check_authentication("download_inventory_export") - - while True: - r = self.get_inventory_export(export_id) - if r["status"] == "success": - break - time.sleep(1) - - r = self._request( - "get", - f"{self.API_URL}/inventory/export/{export_id}/download" - ) - - if filename is None: - filename = r.headers["Content-Disposition"].split("=")[1] - else: - if not filename.endswith(".csv"): - filename += ".csv" - - with open( - path := os.path.join(path or os.getcwd(), filename), "w" - ) as f: - f.write(r.text) - - return path
- - - ### INVENTORY UPLOAD ###################################################### - - # TODO - - ### USER IDENTITY ######################################################### - -
-[docs] - def get_identity(self) -> dict[str, Any]: - - """ - `User Identity > Identity <https://www.discogs.com/developers - /#page:user-identity,header:user-identity-identity-get>`_: - Retrieve basic information about the authenticated user. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - You can use this resource to find out who you're authenticated - as, and it also doubles as a good sanity check to ensure that - you're using OAuth correctly. - - For more detailed information, make another request for the - user's profile using :meth:`get_profile`. - - Returns - ------- - identity : `dict` - Basic information about the authenticated user. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "id": <int>, - "username": <str>, - "resource_url": <str>, - "consumer_name": <str> - } - """ - - self._check_authentication("get_identity") - - return self._get_json(f"{self.API_URL}/oauth/identity")
- - -
-[docs] - def get_profile(self, username: str = None) -> dict[str, Any]: - - """ - `User Identity > Profile > Get Profile - <https://www.discogs.com/developers - /#page:user-identity,header:user-identity-profile-get>`_: - Retrieve a user by username. - - .. admonition:: User authentication - :class: dropdown warning - - If authenticated as the requested user, the :code:`"email"` - key will be visible, and the :code:`"num_lists"` count will - include the user's private lists. - - If authenticated as the requested user or the user's - collection/wantlist is public, the - :code:`"num_collection"`/:code:`"num_wantlist"` keys will be - visible. - - Parameters - ---------- - username : `str`, optional - The username of whose profile you are requesting. If not - specified, the username of the authenticated user is used. - - **Example**: :code:`"rodneyfool"`. - - Returns - ------- - profile : `dict` - Detailed information about the user. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "profile": <str>, - "wantlist_url": <str>, - "rank": <int>, - "num_pending": <int>, - "id": <int>, - "num_for_sale": <int>, - "home_page": <str>, - "location": <str>, - "collection_folders_url": <str>, - "username": <str>, - "collection_fields_url": <str>, - "releases_contributed": <int>, - "registered": <str>, - "rating_avg": <float>, - "num_collection": <int>, - "releases_rated": <int>, - "num_lists": <int>, - "name": <str>, - "num_wantlist": <int>, - "inventory_url": <str>, - "avatar_url": <str>, - "banner_url": <str>, - "uri": <str>, - "resource_url": <str>, - "buyer_rating": <float>, - "buyer_rating_stars": <int>, - "buyer_num_ratings": <int>, - "seller_rating": <float>, - "seller_rating_stars": <int>, - "seller_num_ratings": <int>, - "curr_abbr": <str>, - } - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - return self._get_json(f"{self.API_URL}/users/{username}")
- - -
-[docs] - def edit_profile( - self, *, name: str = None, home_page: str = None, - location: str = None, profile: str = None, - curr_abbr: str = None) -> dict[str, Any]: - - """ - `User Identity > Profile > Edit Profile - <https://www.discogs.com/developers - /#page:user-identity,header:user-identity-profile-post>`_: - Edit a user's profile data. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - name : `str`, keyword-only, optional - The real name of the user. - - **Example**: :code:`"Nicolas Cage"`. - - home_page : `str`, keyword-only, optional - The user's website. - - **Example**: :code:`"www.discogs.com"`. - - location : `str`, keyword-only, optional - The geographical location of the user. - - **Example**: :code:`"Portland"`. - - profile : `str`, keyword-only, optional - Biological information about the user. - - **Example**: :code:`"I am a Discogs user!"`. - - curr_abbr : `str`, keyword-only, optional - Currency abbreviation for marketplace data. - - **Valid values**: :code:`"USD"`, :code:`"GBP"`, - :code:`"EUR"`, :code:`"CAD"`, :code:`"AUD"`, :code:`"JPY"`, - :code:`"CHF"`, :code:`"MXN"`, :code:`"BRL"`, :code:`"NZD"`, - :code:`"SEK"`, and :code:`"ZAR"`. - - Returns - ------- - profile : `dict` - Updated profile. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "id": <int><int>, - "username": <str>, - "name": <str>, - "email": <str>, - "resource_url": <str>, - "inventory_url": <str>, - "collection_folders_url": <str>, - "collection_fields_url": <str>, - "wantlist_url": <str>, - "uri": <str>, - "profile": <str>, - "home_page": <str>, - "location": <str>, - "registered": <str>, - "num_lists": <int>, - "num_for_sale": <int>, - "num_collection": <int>, - "num_wantlist": <int>, - "num_pending": <int>, - "releases_contributed": <int>, - "rank": <int>, - "releases_rated": <int>, - "rating_avg": <float> - } - """ - - self._check_authentication("edit_profile") - - if name is None and home_page is None and location is None \ - and profile is None and curr_abbr is None: - wmsg = "No changes were specified or made to the user profile." - warnings.warn(wmsg) - return - - if curr_abbr and curr_abbr not in ( - CURRENCIES := { - "USD", "GBP", "EUR", "CAD", "AUD", "JPY", - "CHF", "MXN", "BRL", "NZD", "SEK", "ZAR" - } - ): - emsg = (f"Invalid currency abbreviation ({curr_abbr=}). " - f"Valid values: {', '.join(CURRENCIES)}.") - raise ValueError(emsg) - - return self._request( - "post", - f"{self.API_URL}/users/{self._username}", - json={ - "name": name, - "home_page": home_page, - "location": location, - "profile": profile, - "curr_abbr": curr_abbr - } - ).json()
- - -
-[docs] - def get_user_submissions( - self, username: str = None, *, page: Union[int, str] = None, - per_page: Union[int, str] = None) -> dict[str, Any]: - - """ - `User Identity > User Submissions <https://www.discogs.com - /developers/#page:user-identity,header - :user-identity-user-submissions-get>`_: Retrieve a user's - submissions (edits made to releases, labels, and artists) by - username. - - Parameters - ---------- - username : `str`, optional - The username of the submissions you are trying to fetch. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"shooezgirl"`. - - page : `int` or `str`, keyword-only, optional - Page of results to fetch. - - per_page : `int` or `str`, keyword-only, optional - Number of results per page. - - Returns - ------- - submissions : `dict` - Submissions made by the user. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "items": <int>, - "page": <int>, - "pages": <int>, - "per_page": <int>, - "urls": { - "last": <str>, - "next": <str> - } - }, - "submissions": { - "artists": [ - { - "data_quality": <str>, - "id": <int>, - "name": <str>, - "namevariations": [<str>], - "releases_url": <str>, - "resource_url": <str>, - "uri": <str> - } - ], - "labels": [], - "releases": [ - { - "artists": [ - { - "anv": <str>, - "id": <int>, - "join": <str>, - "name": <str>, - "resource_url": <str>, - "role": <str>, - "tracks": <str> - } - ], - "community": { - "contributors": [ - { - "resource_url": <str>, - "username": <str> - } - ], - "data_quality": <str>, - "have": <int>, - "rating": { - "average": <int>, - "count": <int> - }, - "status": <str>, - "submitter": { - "resource_url": <str>, - "username": <str> - }, - "want": <int> - }, - "companies": [], - "country": <str>, - "data_quality": <str>, - "date_added": <str>, - "date_changed": <str>, - "estimated_weight": <int>, - "format_quantity": <int>, - "formats": [ - { - "descriptions": [<str>], - "name": <str>, - "qty": <str> - } - ], - "genres": [<str>], - "id": <int>, - "images": [ - { - "height": <int>, - "resource_url": <str>, - "type": <str>, - "uri": <str>, - "uri150": <str>, - "width": <int> - } - ], - "labels": [ - { - "catno": <str>, - "entity_type": <str>, - "id": <int>, - "name": <str>, - "resource_url": <str> - } - ], - "master_id": <int>, - "master_url": <str>, - "notes": <str>, - "released": <str>, - "released_formatted": <str>, - "resource_url": <str>, - "series": [], - "status": <str>, - "styles": [<str>], - "thumb": <str>, - "title": <str>, - "uri": <str>, - "videos": [ - { - "description": <str>, - "duration": <int>, - "embed": <bool>, - "title": <str>, - "uri": <str> - } - ], - "year": <int> - } - ] - } - } - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._get_json( - f"{self.API_URL}/users/{username}/submissions", - params={"page": page, "per_page": per_page} - )
- - -
-[docs] - def get_user_contributions( - self, username: str = None, *, page: Union[int, str] = None, - per_page: Union[int, str] = None, sort: str = None, - sort_order: str = None) -> dict[str, Any]: - - """ - `User Identity > User Contributions <https://www.discogs.com - /developers/#page:user-identity,header - :user-identity-user-contributions-get>`_: Retrieve a user's - contributions (releases, labels, artists) by username. - - Parameters - ---------- - username : `str`, optional - The username of the contributions you are trying to fetch. - If not specified, the username of the authenticated user is - used. - - **Example**: :code:`"shooezgirl"`. - - page : `int` or `str`, keyword-only, optional - Page of results to fetch. - - per_page : `int` or `str`, keyword-only, optional - Number of results per page. - - sort : `str`, keyword-only, optional - Sort items by this field. - - **Valid values**: :code:`"label"`, :code:`"artist"`, - :code:`"title"`, :code:`"catno"`, :code:`"format"`, - :code:`"rating"`, :code:`"year"`, and :code:`"added"`. - - sort_order : `str`, keyword-only, optional - Sort items in a particular order. - - **Valid values**: :code:`"asc"` and :code:`"desc"`. - - Returns - ------- - contributions : `dict` - Contributions made by the user. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "items": <int>, - "page": <int>, - "pages": <int>, - "per_page": <int>, - "urls": { - "last": <str>, - "next": <str> - } - }, - "contributions": [ - { - "artists": [ - { - "anv": <str>, - "id": <int>, - "join": <str>, - "name": <str>, - "resource_url": <str>, - "role": <str>, - "tracks": <str> - } - ], - "community": { - "contributors": [ - { - "resource_url": <str>, - "username": <str> - } - ], - "data_quality": <str>, - "have": <int>, - "rating": { - "average": <int>, - "count": <int> - }, - "status": <str>, - "submitter": { - "resource_url": <str>, - "username": <str> - }, - "want": <int> - }, - "companies": [], - "country": <str>, - "data_quality": <str>, - "date_added": <str>, - "date_changed": <str>, - "estimated_weight": <int>, - "format_quantity": <int>, - "formats": [ - { - "descriptions": [<str>], - "name": <str>, - "qty": <str> - } - ], - "genres": [<str>], - "id": <int>, - "images": [ - { - "height": <int>, - "resource_url": <str>, - "type": <str>, - "uri": <str>, - "uri150": <str>, - "width": <int> - } - ], - "labels": [ - { - "catno": <str>, - "entity_type": <str>, - "id": <int>, - "name": <str>, - "resource_url": <str> - } - ], - "master_id": <int>, - "master_url": <str>, - "notes": <str>, - "released": <str>, - "released_formatted": <str>, - "resource_url": <str>, - "series": [], - "status": <str>, - "styles": [<str>], - "thumb": <str>, - "title": <str>, - "uri": <str>, - "videos": [ - { - "description": <str>, - "duration": <int>, - "embed": <bool>, - "title": <str>, - "uri": <str> - } - ], - "year": <int> - } - ] - } - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._get_json( - f"{self.API_URL}/users/{username}/contributions", - params={ - "page": page, - "per_page": per_page, - "sort": sort, - "sort_order": sort_order - } - )
- - - ### USER COLLECTION ####################################################### - -
-[docs] - def get_collection_folders( - self, username: str = None) -> list[dict[str, Any]]: - - """ - `User Collection > Collection > Get Collection Folders - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-collection-get>`_: Retrieve a list of folders - in a user's collection. - - .. admonition:: User authentication - :class: dropdown warning - - If the collection has been made private by its owner, - authentication as the collection owner is required. If you - are not authenticated as the collection owner, only folder ID - :code:`0` (the "All" folder) will be visible (if the - requested user's collection is public). - - Parameters - ---------- - username : `str`, optional - The username of the collection you are trying to fetch. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - - Returns - ------- - folders : `list` - A list of folders in the user's collection. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - [ - { - "id": <int>, - "name": <str>, - "count": <int>, - "resource_url": <str> - } - ] - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._get_json( - f"{self.API_URL}/users/{username}/collection/folders" - )["folders"]
- - -
-[docs] - def create_collection_folder(self, name: str) -> dict[str, Union[int, str]]: - - """ - `User Collection > Collection > Create Folder - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-collection-post>`_: Create a new folder in a - user's collection. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - name : `str` - The name of the newly-created folder. - - **Example**: :code:`"My favorites"`. - - Returns - ------- - folder : `dict` - Information about the newly-created folder. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "count": <int>, - "resource_url": <str> - } - """ - - self._check_authentication("create_collection_folder") - - return self._request( - "post", - f"{self.API_URL}/users/{self._username}/collection/folders", - json={"name": name} - ).json()
- - -
-[docs] - def get_collection_folder( - self, folder_id: int, *, username: str = None - ) -> dict[str, Union[int, str]]: - - """ - `User Collection > Collection Folder > Get Folders - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-collection-folder-get>`_: Retrieve metadata - about a folder in a user's collection. - - .. admonition:: User authentication - :class: dropdown warning - - If `folder_id` is not :code:`0`, authentication as the - collection owner is required. - - Parameters - ---------- - folder_id : `int` - The ID of the folder to request. - - **Example**: :code:`3`. - - username : `str`, keyword-only, optional - The username of the collection you are trying to request. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - - Returns - ------- - folder : `dict` - Metadata about the folder. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "count": <int>, - "resource_url": <str> - } - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - if folder_id != 0: - self._check_authentication("get_collection_folder") - - return self._get_json(f"{self.API_URL}/users/{self._username}" - f"/collection/folders/{folder_id}")
- - -
-[docs] - def rename_collection_folder( - self, folder_id: int, name: str, *, - username: str = None) -> dict[str, Union[int, str]]: - - """ - `User Collection > Collection Folder > Edit Folder - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-collection-folder-post>`_: Rename a folder. - - Folders :code:`0` and :code:`1` cannot be renamed. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - folder_id : `int` - The ID of the folder to modify. - - **Example**: :code:`3`. - - name : `str` - The new name of the folder. - - **Example**: :code:`"My favorites"`. - - username : `str`, keyword-only, optional - The username of the collection you are trying to modify. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - - Returns - ------- - folder : `dict` - Information about the edited folder. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "count": <int>, - "resource_url": <str> - } - """ - - self._check_authentication("rename_collection_folder") - - return self._request( - "post", - f"{self.API_URL}/users/{self._username}" - f"/collection/folders/{folder_id}", - json={"name": name} - ).json()
- - -
-[docs] - def delete_collection_folder( - self, folder_id: int, *, username: str = None) -> None: - - """ - `User Collection > Collection Folder > Delete Folder - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-collection-folder-delete>`_: Delete a folder - from a user's collection. - - A folder must be empty before it can be deleted. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - folder_id : `int` - The ID of the folder to delete. - - **Example**: :code:`3`. - - username : `str`, keyword-only, optional - The username of the collection you are trying to delete. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - """ - - self._check_authentication("delete_collection_folder") - - self._request( - "delete", - f"{self.API_URL}/users/{self._username}" - f"/collection/folders/{folder_id}" - )
- - -
-[docs] - def get_collection_folders_by_release( - self, release_id: Union[int, str], *, username: str = None - ) -> dict[str, Any]: - - """ - `User Collection > Collection Items By Release - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-collection-items-by-release-get>`_: View the - user's collection folders which contain a specified release. - This will also show information about each release instance. - - Parameters - ---------- - release_id : `int` or `str` - The ID of the release to request. - - **Example**: :code:`7781525`. - - username : `str`, keyword-only, optional - The username of the collection you are trying to view. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"susan.salkeld"`. - - Returns - ------- - releases : `list` - A list of releases and their folders. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "per_page": <int>, - "items": <int>, - "page": <int>, - "urls": { - "last": <str>, - "next": <str> - }, - "pages": <int> - }, - "releases": [ - { - "instance_id": <int>, - "rating": <int>, - "basic_information": { - "labels": [ - { - "name": <str>, - "entity_type": <str>, - "catno": <str>, - "resource_url": <str>, - "id": <int>, - "entity_type_name": <str> - } - ], - "formats": [ - { - "descriptions": [<str>], - "name": <str>, - "qty": <str> - } - ], - "thumb": <str>, - "title": <str>, - "artists": [ - { - "join": <str>, - "name": <str>, - "anv": <str>, - "tracks": <str>, - "role": <str>, - "resource_url": <str>, - "id": <int> - } - ], - "resource_url": <str>, - "year": <int>, - "id": <int>, - }, - "folder_id": <int>, - "date_added": <str>, - "id": <int> - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/users/{self._username}/collection" - f"/releases/{release_id}" - )["folders"]
- - -
-[docs] - def get_collection_folder_releases( - self, folder_id: int, *, username: str = None, - page: int = None, per_page: int = None, sort: str = None, - sort_order: str = None) -> dict[str, Any]: - - """ - `User Collection > Collection Items By Folder - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-collection-items-by-folder>`_: Returns the items - in a folder in a user's collection. - - Basic information about each release is provided, suitable for - display in a list. For detailed information, make another call - to fetch the corresponding release. - - .. admonition:: User authentication - :class: dropdown warning - - If `folder_id` is not :code:`0` or the collection has been - made private by its owner, authentication as the collection - owner is required. - - If you are not authenticated as the collection owner, only - the public notes fields will be visible. - - Parameters - ---------- - folder_id : `int` - The ID of the folder to request. - - **Example**: :code:`3`. - - username : `str`, keyword-only, optional - The username of the collection you are trying to request. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - - page : `int`, keyword-only, optional - Page of results to fetch. - - per_page : `int`, keyword-only, optional - Number of results per page. - - sort : `str`, keyword-only, optional - Sort items by this field. - - **Valid values**: :code:`"label"`, :code:`"artist"`, - :code:`"title"`, :code:`"catno"`, :code:`"format"`, - :code:`"rating"`, :code:`"year"`, and :code:`"added"`. - - sort_order : `str`, keyword-only, optional - Sort items in a particular order. - - **Valid values**: :code:`"asc"` and :code:`"desc"`. - - Returns - ------- - items : `dict` - Items in the folder. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "pagination": { - "per_page": <int>, - "items": <int>, - "page": <int>, - "urls": { - "last": <str>, - "next": <str> - }, - "pages": <int> - }, - "releases": [ - { - "instance_id": <int>, - "rating": <int>, - "basic_information": { - "labels": [ - { - "name": <str>, - "entity_type": <str>, - "catno": <str>, - "resource_url": <str>, - "id": <int>, - "entity_type_name": <str> - } - ], - "formats": [ - { - "descriptions": [<str>], - "name": <str>, - "qty": <str> - } - ], - "thumb": <str>, - "title": <str>, - "artists": [ - { - "join": <str>, - "name": <str>, - "anv": <str>, - "tracks": <str>, - "role": <str>, - "resource_url": <str>, - "id": <int> - } - ], - "resource_url": <str>, - "year": <int>, - "id": <int>, - }, - "folder_id": <int>, - "date_added": <str>, - "id": <int> - } - ] - } - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - if folder_id != 0: - self._check_authentication("get_collection_folder_releases") - - return self._get_json( - f"{self.API_URL}/users/{username}/collection" - f"/folders/{folder_id}/releases", - params={"page": page, "per_page": per_page, "sort": sort, - "sort_order": sort_order} - )
- - -
-[docs] - def add_collection_folder_release( - self, folder_id: int, release_id: int, *, username: str = None - ) -> dict[str, Union[int, str]]: - - """ - `User Collection > Add To Collection Folder - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-add-to-collection-folder-post>`_: Add a release - to a folder in a user's collection. - - The `folder_id` must be non-zero. You can use :code:`1` for - "Uncategorized". - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - folder_id : `int` - The ID of the folder to modify. - - **Example**: :code:`3`. - - release_id : `int` - The ID of the release you are adding. - - **Example**: :code:`130076`. - - username : `str`, keyword-only, optional - The username of the collection you are trying to modify. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - - Returns - ------- - folder : `dict` - Information about the folder. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "instance_id": <int>, - "resource_url": <str> - } - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._request( - "post", - f"{self.API_URL}/users/{username}/collection" - f"/folders/{folder_id}/releases/{release_id}" - ).json()
- - -
-[docs] - def edit_collection_folder_release( - self, folder_id: int, release_id: int, instance_id: int, - *, username: str = None, new_folder_id: int, rating: int = None - ) -> None: - - """ - `User Collection > Change Rating Of Release - <https://www.discogs.com/developers#page:user-collection,header - :user-collection-change-rating-of-release-post>`_: Change the - rating on a release and/or move the instance to another folder. - - This endpoint potentially takes two folder ID parameters: - `folder_id` (which is the folder you are requesting, and is - required), and `new_folder_id` (representing the folder you want - to move the instance to, which is optional). - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - folder_id : `int` - The ID of the folder to modify. - - **Example**: :code:`3`. - - release_id : `int` - The ID of the release you are modifying. - - **Example**: :code:`130076`. - - instance_id : `int` - The ID of the instance. - - **Example**: :code:`1`. - - username : `str`, keyword-only, optional - The username of the collection you are trying to modify. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - - new_folder_id : `int` - The ID of the folder to move the instance to. - - **Example**: :code:`4`. - - rating : `int`, keyword-only, optional - The rating of the instance you are supplying. - - **Example**: :code:`5`. - """ - - self._request( - "post", - f"{self.API_URL}/users/{username}/collection/folders" - f"/{folder_id}/releases/{release_id}/instances/{instance_id}", - json={"folder_id": new_folder_id, "rating": rating} - )
- - -
-[docs] - def delete_collection_folder_release( - self, folder_id: int, release_id: int, instance_id: int, - *, username: str = None) -> None: - - """ - `User Collection > Delete Instance From Folder - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-delete-instance-from-folder-delete>`_: Remove an - instance of a release from a user's collection folder. - - To move the release to the "Uncategorized" folder instead, use - the :meth:`edit_collection_folder_release` method. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - folder_id : `int` - The ID of the folder to modify. - - **Example**: :code:`3`. - - release_id : `int` - The ID of the release you are modifying. - - **Example**: :code:`130076`. - - instance_id : `int` - The ID of the instance. - - **Example**: :code:`1`. - - username : `str`, keyword-only, optional - The username of the collection you are trying to modify. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - """ - - self._request( - "delete", - f"{self.API_URL}/users/{username}/collection/folders" - f"/{folder_id}/releases/{release_id}/instances/{instance_id}" - )
- - -
-[docs] - def get_collection_fields( - self, username: str = None) -> list[dict[str, Any]]: - - """ - `User Collection > Collection Fields - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-list-custom-fields-get>`_: Retrieve a list of - user-defined collection notes fields. - - These fields are available on every release in the collection. - - .. admonition:: User authentication - :class: dropdown warning - - If the collection has been made private by its owner, - authentication as the collection owner is required. - - If you are not authenticated as the collection owner, only - fields with public set to true will be visible. - - Parameters - ---------- - username : `str`, optional - The username of the collection you are trying to fetch. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - - Returns - ------- - fields : `list` - A list of user-defined collection fields. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - [ - { - "id": <int>, - "name": <str>, - "options": [<str>], - "public": <bool>, - "position": <int>, - "type": "dropdown" - }, - { - "id": <int>, - "name": <str>, - "lines": <int>, - "public": <bool>, - "position": <int>, - "type": "textarea" - } - ] - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._get_json( - f"{self.API_URL}/users/{username}/collection/fields" - )["fields"]
- - -
-[docs] - def edit_collection_release_field( - self, folder_id: int, release_id: int, instance_id: int, - field_id: int, value: str, *, username: str = None) -> None: - - """ - `User Collection > Edit Fields Instance - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-edit-fields-instance-post>`_: Change the value - of a notes field on a particular instance. - - .. admonition:: User authentication - :class: dropdown warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - folder_id : `int` - The ID of the folder to modify. - - **Example**: :code:`3`. - - release_id : `int` - The ID of the release you are modifying. - - **Example**: :code:`130076`. - - instance_id : `int` - The ID of the instance. - - **Example**: :code:`1`. - - field_id : `int` - The ID of the field you are modifying. - - **Example**: :code:`1`. - - value : `str` - The new value of the field. If the field's type is - :code:`"dropdown"`, `value` must match one of the values in - the field's list of options. - - username : `str`, keyword-only, optional - The username of the collection you are trying to modify. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - """ - - self._check_authentication("edit_collection_fields") - - self._request( - "post", - f"{self.API_URL}/users/{username}/collection/folders" - f"/{folder_id}/releases/{release_id}/instances/{instance_id}" - f"/fields/{field_id}", - params={"value": value} - )
- - -
-[docs] - def get_collection_value(self, username: str = None) -> dict[str, Any]: - - """ - `User Collection > Collection Value - <https://www.discogs.com/developers/#page:user-collection,header - :user-collection-collection-value-get>`_: Returns the minimum, - median, and maximum value of a user's collection. - - .. admonition:: User authentication - :class: warning - - Requires user authentication with a personal access token or - via the OAuth 1.0a flow. - - Parameters - ---------- - username : `str`, optional - The username of the collection you are trying to fetch. If - not specified, the username of the authenticated user is - used. - - **Example**: :code:`"rodneyfool"`. - - Returns - ------- - value : `dict` - The total minimum value of the user's collection. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "maximum": <str>, - "median": <str>, - "minimum": <str> - } - """ - - if username is None: - if hasattr(self, "_username"): - username = self._username - else: - raise ValueError("No username provided.") - - return self._get_json( - f"{self.API_URL}/users/{username}/collection/value" - )
-
- - - ### USER WANTLIST ######################################################### - - # TODO - - ### USER LISTS ############################################################ - - # TODO -
-
-
-
- - -
-
- - Made with Sphinx and @pradyunsg's - - Furo - -
-
- -
-
- -
-
- -
-
- - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/_modules/minim/itunes.html b/docs/_modules/minim/itunes.html deleted file mode 100644 index 63d70b8..0000000 --- a/docs/_modules/minim/itunes.html +++ /dev/null @@ -1,840 +0,0 @@ - - - - - - - - minim.itunes - Minim 1.0.0 documentation - - - - - - - - - - - - - - - - - - - Contents - - - - - - Menu - - - - - - - - Expand - - - - - - Light mode - - - - - - - - - - - - - - Dark mode - - - - - - - Auto light/dark mode - - - - - - - - - - - - - - - - - - - -
-
-
- -
- -
-
- -
- -
-
- -
-
-
- - - - - Back to top - -
-
- -
- -
-
-

Source code for minim.itunes

-"""
-iTunes
-======
-.. moduleauthor:: Benjamin Ye <GitHub: bbye98>
-
-This module contains a complete implementation of all iTunes Search API
-endpoints.
-"""
-
-import requests
-from typing import Any, Union
-
-__all__ = ["SearchAPI"]
-
-
-[docs] -class SearchAPI: - - """ - iTunes Search API client. - - The iTunes Search API allows searching for a variety of content, - including apps, iBooks, movies, podcasts, music, music videos, - audiobooks, and TV shows within the iTunes Store, App Store, - iBooks Store and Mac App Store. It also supports ID-based lookup - requests to create mappings between your content library and the - digital catalog. - - .. seealso:: - - For more information, see the `iTunes Search API - documentation <https://developer.apple.com/library/archive/ - documentation/AudioVideo/Conceptual/iTuneSearchAPI/index.html>`_. - - Attributes - ---------- - API_URL : `str` - Base URL for the iTunes Search API. - """ - - API_URL = "https://itunes.apple.com" - - def __init__(self) -> None: - - """ - Create a iTunes Search API client. - """ - - self.session = requests.Session() - - def _get_json(self, url: str, **kwargs) -> dict: - - """ - Send a GET request and return the JSON-encoded content of the - response. - - Parameters - ---------- - url : `str` - URL for the GET request. - - **kwargs - Keyword arguments to pass to :meth:`requests.request`. - - Returns - ------- - resp : `dict` - JSON-encoded content of the response. - """ - - return self._request("get", url, **kwargs).json() - - def _request( - self, method: str, url: str, **kwargs - ) -> requests.Response: - - """ - Construct and send a request, but with status code checking. - - Parameters - ---------- - method : `str` - Method for the request. - - url : `str` - URL for the request. - - **kwargs - Keyword arguments passed to :meth:`requests.request`. - - Returns - ------- - resp : `requests.Response` - Response to the request. - """ - - r = self.session.request(method, url, **kwargs) - if r.status_code not in range(200, 299): - raise RuntimeError(f"{r.status_code} {r.json()['errorMessage']}") - return r - -
-[docs] - def search( - self, term: str, *, country: str = None, media: str = None, - entity: Union[str, list[str]] = None, attribute: str = None, - limit: Union[int, str] = None, lang: str = None, - version: Union[int, str] = None, explicit: Union[bool, str] = None - ) -> dict[str, Any]: - - """ - Search for content using the iTunes Search API. - - Parameters - ---------- - term : str - The text string to search for. - - .. note:: - - URL encoding replaces spaces with the plus (:code:`+`) - character, and all characters except letters, numbers, - periods (:code:`.`), dashes (:code:`-`), underscores - (:code:`_`), and asterisks (:code:`*`) are encoded. - - **Example**: :code:`"jack+johnson"`. - - country : str, keyword-only, optional - The two-letter country code for the store you want to search. - The search uses the default store front for the specified - country. - - .. seealso:: - - For a list of ISO country codes, see the - `ISO OBP <https://www.iso.org/obp/ui>`_. - - **Default**: :code:`"US"`. - - media : str, keyword-only, optional - The media type you want to search for. - - .. container:: - - **Valid values**: :code:`"movie"`, :code:`"podcast"`, - :code:`"music"`, :code:`"musicVideo"`, :code:`"audioBook"`, - :code:`"shortFilm"`, :code:`"tvShow"`, :code:`"software"`, - and :code:`"ebook"`. - - **Default**: :code:`"all"`. - - entity : `str` or `list`, keyword-only, optional - The type(s) of results you want returned, relative to the - specified media type in `media`. - - .. seealso:: - - For a list of available - entities, see the `iTunes Store API Table 2-1 - <https://developer.apple.com/library/archive - /documentation/AudioVideo/Conceptual/iTuneSearchAPI - /Searching.html#//apple_ref/doc/uid - /TP40017632-CH5-SW2>`_. - - **Default**: The track entity associated with the specified - media type. - - **Example**: :code:`"movieArtist"` for a movie media type - search. - - attribute : `str`, keyword-only, optional - The attribute you want to search for in the stores, relative - to the specified media type (`media`). - - .. seealso:: - - For a list of available - attributes, see the `iTunes Store API Table 2-2 - <https://developer.apple.com/library/archive - /documentation/AudioVideo/Conceptual/iTuneSearchAPI - /Searching.html#//apple_ref/doc/uid - /TP40017632-CH5-SW3>`_. - - **Default**: All attributes associated with the specified - media type. - - **Example**: If you want to search for an artist by name, - specify :code:`entity="allArtist"` and - :code:`attribute="allArtistTerm"`. Then, if you search for - :code:`term="maroon"`, iTunes returns "Maroon 5" in the - search results, instead of all artists who have ever - recorded a song with the word "maroon" in the title. - - limit : `int` or `str`, keyword-only, optional - The number of search results you want the iTunes Store to - return. - - **Valid values**: `limit` must be between 1 and 200. - - **Default**: :code:`50`. - - lang : `str`, keyword-only, optional - The language, English or Japanese, you want to use when - returning search results. Specify the language using the - five-letter codename. - - .. container:: - - **Valid values**: - - * :code:`"en_us"` for English. - * :code:`"ja_jp"` for Japanese. - - **Default**: :code:`"en_us"`. - - version : `int` or `str`, keyword-only, optional - The search result key version you want to receive back from - your search. - - **Valid values**: :code:`1` and :code:`2`. - - **Default**: :code:`2`. - - explicit : `bool` or `str`, keyword-only, optional - A flag indicating whether or not you want to include - explicit content in your search results. - - **Default**: :code:`"Yes"`. - - Returns - ------- - results : `dict` - The search results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "resultCount": <int>, - "results": [ - { - "wrapperType": <str>, - "kind": <str>, - "artistId": <int>, - "collectionId": <int>, - "trackId": <int>, - "artistName": <str>, - "collectionName": <str>, - "trackName": <str>, - "collectionCensoredName": <str>, - "trackCensoredName": <str>, - "collectionArtistId": <int>, - "collectionArtistName": <str>, - "artistViewUrl": <str>, - "collectionViewUrl": <str>, - "trackViewUrl": <str>, - "previewUrl": <str>, - "artworkUrl30": <str>, - "artworkUrl60": <str>, - "artworkUrl100": <str>, - "collectionPrice": <float>, - "trackPrice": <float>, - "releaseDate": <str>, - "collectionExplicitness": <str>, - "trackExplicitness": <str>, - "discCount": <int>, - "discNumber": <int>, - "trackCount": <int>, - "trackNumber": <int>, - "trackTimeMillis": <int>, - "country": <str>, - "currency": <str>, - "primaryGenreName": <str>, - "isStreamable": <bool> - } - ] - } - - Examples - -------- - To search for all Jack Johnson audio and video content (movies, - podcasts, music, music videos, audiobooks, short films, and TV - shows), - - >>> itunes.search("jack johnson") - - To search for all Jack Johnson audio and video content and - return only the first 25 items, - - >>> itunes.search("jack johnson", limit=25) - - To search for only Jack Johnson music videos, - - >>> itunes.search("jack johnson", entity="musicVideo") - - To search for all Jim Jones audio and video content and return - only the results from the Canada iTunes Store, - - >>> itunes.search("jack johnson", country="ca") - - To search for applications titled “Yelp” and return only the - results from the United States iTunes Store, - - >>> itunes.search("yelp", country="us", entity="software") - """ - - return self._get_json( - f"{self.API_URL}/search", - params={ - "term": term, - "country": country, - "media": media, - "entity": entity if entity is None or isinstance(entity, str) - else ",".join(entity), - "attribute": attribute, - "limit": limit, - "lang": lang, - "version": version, - "explicit": ("No", "Yes")[explicit] - if isinstance(explicit, bool) else explicit - } - )
- - -
-[docs] - def lookup( - self, id: Union[int, str, list[Union[int, str]]] = None, *, - amg_artist_id: Union[int, str, list[Union[int, str]]] = None, - amg_album_id: Union[int, str, list[Union[int, str]]] = None, - amg_video_id: Union[int, str, list[Union[int, str]]] = None, - bundle_id: Union[str, list[str]] = None, - upc: Union[int, str, list[Union[int, str]]] = None, - isbn: Union[int, str, list[Union[int, str]]] = None, - entity: Union[str, list[str]] = None, - limit: Union[int, str] = None, sort: str = None - ) -> dict[str, Any]: - - """ - Search for content based on iTunes IDs, AMG IDs, UPCs/EANs, or - ISBNs. ID-based lookups are faster and contain fewer - false-positive results. - - Parameters - ---------- - id : `int`, `str`, or `list`, optional - The iTunes ID(s) to lookup. - - amg_artist_id : `int`, `str`, or `list`, keyword-only, optional - The AMG artist ID(s) to lookup. - - amg_album_id : `int`, `str`, or `list`, keyword-only, optional - The AMG album ID(s) to lookup. - - amg_video_id : `int`, `str`, or `list`, keyword-only, optional - The AMG video ID(s) to lookup. - - bundle_id : `str` or `list`, keyword-only, optional - The Apple bundle ID(s) to lookup. - - upc : `int`, `str`, or `list`, keyword-only, optional - The UPC(s) to lookup. - - isbn : `int`, `str`, or `list`, keyword-only, optional - The 13-digit ISBN(s) to lookup. - - entity : `str` or `list`, keyword-only, optional - The type(s) of results you want returned. - - .. seealso:: - - For a list of available entities, see the `iTunes Store - API Table 2-1 <https://developer.apple.com/library - /archive/documentation/AudioVideo/Conceptual - /iTuneSearchAPI/Searching.html#//apple_ref/doc/uid - /TP40017632-CH5-SW2>`_. - - **Default**: The track entity associated with the specified - media type. - - limit : `int` or `str`, keyword-only, optional - The number of search results you want the iTunes Store to - return. - - **Valid values**: `limit` must be between 1 and 200. - - **Default**: :code:`50`. - - sort : `str`, keyword-only, optional - The sort applied to the search results. - - **Allowed value**: :code:`"recent"`. - - Returns - ------- - results : `dict` - The lookup results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "resultCount": <int>, - "results": [ - { - "wrapperType": <str>, - "kind": <str>, - "artistId": <int>, - "collectionId": <int>, - "trackId": <int>, - "artistName": <str>, - "collectionName": <str>, - "trackName": <str>, - "collectionCensoredName": <str>, - "trackCensoredName": <str>, - "collectionArtistId": <int>, - "collectionArtistName": <str>, - "artistViewUrl": <str>, - "collectionViewUrl": <str>, - "trackViewUrl": <str>, - "previewUrl": <str>, - "artworkUrl30": <str>, - "artworkUrl60": <str>, - "artworkUrl100": <str>, - "collectionPrice": <float>, - "trackPrice": <float>, - "releaseDate": <str>, - "collectionExplicitness": <str>, - "trackExplicitness": <str>, - "discCount": <int>, - "discNumber": <int>, - "trackCount": <int>, - "trackNumber": <int>, - "trackTimeMillis": <int>, - "country": <str>, - "currency": <str>, - "primaryGenreName": <str>, - "isStreamable": <bool> - } - ] - } - - Examples - -------- - Look up Jack Johnson by iTunes artist ID: - - >>> itunes.lookup(909253) - - Look up the Yelp application by iTunes ID: - - >>> itunes.lookup(284910350) - - Look up Jack Johnson by AMG artist ID: - - >>> itunes.lookup(amg_artist_id=468749) - - Look up multiple artists by their AMG artist IDs: - - >>> itunes.lookup(amg_artist_id=[468749, 5723]) - - Look up all albums for Jack Johnson: - - >>> itunes.lookup(909253, entity="album") - - Look up multiple artists by their AMG artist IDs and get each - artist's top 5 albums: - - >>> itunes.lookup(amg_artist_id=[468749, 5723], entity="album", - ... limit=5) - - Look up multiple artists by their AMG artist IDs and get each - artist's 5 most recent songs: - - >>> itunes.lookup(amg_artist_id=[468749, 5723], entity="song", - ... limit=5, sort="recent") - - Look up an album or video by its UPC: - - >>> itunes.lookup(upc=720642462928) - - Look up an album by its UPC, including the tracks on that album: - - >>> itunes.lookup(upc=720642462928, entity="song") - - Look up an album by its AMG Album ID: - - >>> itunes.lookup(amg_album_id=[15175, 15176, 15177, 15178, - ... 15183, 15184, 15187, 15190, - ... 15191, 15195, 15197, 15198]) - - Look up a Movie by AMG Video ID: - - >>> itunes.lookup(amg_video_id=17120) - - Look up a book by its 13-digit ISBN: - - >>> itunes.lookup(isbn=9780316069359) - - Look up the Yelp application by iTunes bundle ID: - - >>> itunes.lookup(bundle_id="com.yelp.yelpiphone") - """ - - return self._get_json( - f"{self.API_URL}/lookup", - params={ - "id": id if id is None or isinstance(id, (int, str)) - else ",".join(id if isinstance(id[0], str) - else (str(i) for i in id)), - "amgArtistId": - amg_artist_id if amg_artist_id is None - or isinstance(amg_artist_id, (int, str)) - else ",".join( - amg_artist_id if isinstance(amg_artist_id[0], str) - else (str(i) for i in amg_artist_id) - ), - "amgAlbumId": - amg_album_id if amg_album_id is None - or isinstance(amg_album_id, (int, str)) - else ",".join( - amg_album_id if isinstance(amg_album_id[0], str) - else (str(i) for i in amg_album_id) - ), - "amgVideoId": - amg_video_id if amg_video_id is None - or isinstance(amg_video_id, (int, str)) - else ",".join( - amg_video_id if isinstance(amg_video_id[0], str) - else (str(i) for i in amg_video_id) - ), - "bundleId": bundle_id - if bundle_id is None or isinstance(bundle_id, str) - else ",".join(bundle_id), - "upc": upc if upc is None or isinstance(upc, (int, str)) - else ",".join(upc if isinstance(upc[0], str) - else (str(u) for u in upc)), - "isbn": isbn if isbn is None or isinstance(isbn, (int, str)) - else ",".join(isbn if isinstance(isbn[0], str) - else (str(i) for i in isbn)), - "entity": entity if entity is None or isinstance(entity, str) - else ",".join(entity), - "limit": limit, - "sort": sort - } - )
-
- -
-
-
-
- - -
-
- - Made with Sphinx and @pradyunsg's - - Furo - -
-
- -
-
- -
-
- -
-
- - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/_modules/minim/qobuz.html b/docs/_modules/minim/qobuz.html deleted file mode 100644 index 4245e01..0000000 --- a/docs/_modules/minim/qobuz.html +++ /dev/null @@ -1,3460 +0,0 @@ - - - - - - - - minim.qobuz - Minim 1.0.0 documentation - - - - - - - - - - - - - - - - - - - Contents - - - - - - Menu - - - - - - - - Expand - - - - - - Light mode - - - - - - - - - - - - - - Dark mode - - - - - - - Auto light/dark mode - - - - - - - - - - - - - - - - - - - -
-
-
- -
- -
-
- -
- -
-
- -
-
-
- - - - - Back to top - -
-
- -
- -
-
-

Source code for minim.qobuz

-"""
-Qobuz
-=====
-.. moduleauthor:: Benjamin Ye <GitHub: bbye98>
-
-This module contains a minimum implementation of the private Qobuz API.
-"""
-
-import base64
-import datetime
-import hashlib
-import logging
-import os
-import re
-from typing import Any, Union
-
-import requests
-
-from . import FOUND_PLAYWRIGHT, DIR_HOME, DIR_TEMP, _config
-if FOUND_PLAYWRIGHT:
-    from playwright.sync_api import sync_playwright
-
-__all__ = ["PrivateAPI"]
-
-def _parse_performers(
-        performers: str, roles: Union[list[str], set[str]] = None
-    ) -> dict[str, list]:
-
-    """
-    Parse a string containing credits for a track.
-
-    Parameters
-    ----------
-    performers : `str`
-        An unformatted string containing the track credits obtained
-        from calling :meth:`get_track`.
-
-    roles : `list` or `set`, keyword-only, optional
-        Role filter. The special :code:`"Composers"` filter will
-        combine the :code:`"Composer"`, :code:`"ComposerLyricist"`,
-        :code:`"Lyricist"`, and :code:`"Writer"` roles.
-
-        **Valid values**: :code:`"MainArtist"`,
-        :code:`"FeaturedArtist"`, :code:`"Producer"`,
-        :code:`"Co-Producer"`, :code:`"Mixer"`,
-        :code:`"Composers"` (:code:`"Composer"`,
-        :code:`"ComposerLyricist"`, :code:`"Lyricist"`,
-        :code:`"Writer"`), :code:`"MusicPublisher"`, etc.
-
-    Returns
-    -------
-    credits : `dict`
-        A dictionary containing the track contributors, with their
-        roles (in snake case) being the keys.
-    """
-
-    people = {}
-    for p in performers.split(" - "):
-        if (regex := re.search(
-            r"(^.*[A-Za-z]\.|^.*&.*|[\d\s\w].*?)(?:, )(.*)",
-            p.rstrip()
-        )):
-            people[regex.groups()[0]] = regex.groups()[1].split(", ")
-
-    credits = {}
-    if roles is None:
-        roles = set(c for r in people.values() for c in r)
-    elif "Composers" in roles:
-        roles.remove("Composers")
-        credits["composers"] = sorted({
-            p for cr in {"Composer", "ComposerLyricist", "Lyricist",
-                         "Writer"}
-            for p, r in people.items()
-            if cr in r
-        })
-    for role in roles:
-        credits[
-            "_".join(
-                re.findall(r"(?:[A-Z][a-z]+)(?:-[A-Z][a-z]+)?", role)
-            ).lower()
-        ] = [p for p, r in people.items() if role in r]
-
-    return credits
-
-
-[docs] -class PrivateAPI: - - """ - Private Qobuz API client. - - The private TIDAL API allows songs, collections (albums, playlists), - and performers to be queried, and information about them to be - retrieved. As there is no available official documentation for the - private Qobuz API, its endpoints have been determined by watching - HTTP network traffic. - - .. attention:: - - As the private Qobuz API is not designed to be publicly - accessible, this class can be disabled or removed at any time to - ensure compliance with the `Qobuz API Terms of Use - <https://static.qobuz.com/apps/api/QobuzAPI-TermsofUse.pdf>`_. - - While authentication is not necessary to search for and retrieve - data from public content, it is required to access personal content - and stream media (with an active Qobuz subscription). In the latter - case, requests to the private Qobuz API endpoints must be - accompanied by a valid user authentication token in the header. - - Minim can obtain user authentication tokens via the password grant, - but it is an inherently unsafe method of authentication since it has - no mechanisms for multifactor authentication or brute force attack - detection. As such, it is highly encouraged that you obtain a user - authentication token yourself through the Qobuz Web Player or the - Android, iOS, macOS, and Windows applications, and then provide it - and its accompanying app ID and secret to this class's constructor - as keyword arguments. The app credentials can also be stored as - :code:`QOBUZ_PRIVATE_APP_ID` and :code:`QOBUZ_PRIVATE_APP_SECRET` - in the operating system's environment variables, and they will - automatically be retrieved. - - .. tip:: - - The app credentials and user authentication token can be changed - or updated at any time using :meth:`set_flow` and - :meth:`set_auth_token`, respectively. - - Minim also stores and manages user authentication tokens and their - properties. When the password grant is used to acquire a user - authentication token, it is automatically saved to the Minim - configuration file to be loaded on the next instantiation of this - class. This behavior can be disabled if there are any security - concerns, like if the computer being used is a shared device. - - Parameters - ---------- - app_id : `str`, keyword-only, optional - App ID. Required if an user authentication token is provided in - `auth_token`. - - app_secret : `str`, keyword-only, optional - App secret. Required if an user authentication token is provided - in `auth_token`. - - flow : `str`, keyword-only, optional - Authorization flow. - - .. container:: - - **Valid values**: - - * :code:`"password"` for the password flow. - * :code:`None` for no authentication. - - browser : `bool`, keyword-only, default: :code:`False` - Determines whether a web browser is opened with the Qobuz login - page using the Playwright framework by Microsoft to complete the - password flow. If :code:`False`, the account email and password - must be provided in `email` and `password`, respectively. - - user_agent : `str`, keyword-only, optional - User agent information to send in the header of HTTP requests. - - email : `str`, keyword-only, optional - Account email address. Required if an user authentication token - is not provided in `auth_token` and :code:`browser=False`. - - password : `str`, keyword-only, optional - Account password. Required if an user authentication token is - not provided in `auth_token` and :code:`browser=False`. - - auth_token : `str`, keyword-only, optional - User authentication token. If provided here or found in the - Minim configuration file, the authentication process is - bypassed. - - overwrite : `bool`, keyword-only, default: :code:`False` - Determines whether to overwrite an existing user authentication - token in the Minim configuration file. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether newly obtained user authentication tokens and - their associated properties are stored to the Minim - configuration file. - - Attributes - ---------- - API_URL : `str` - URL for the Qobuz API. - - WEB_URL : `str` - URL for the Qobuz Web Player. - """ - - _FLOWS = {"password"} - _NAME = f"{__module__}.{__qualname__}" - - API_URL = "https://www.qobuz.com/api.json/0.2" - WEB_URL = "https://play.qobuz.com" - - def __init__( - self, *, app_id: str = None, app_secret: str = None, - flow: str = None, browser: bool = False, user_agent: str = None, - email: str = None, password: str = None, auth_token: str = None, - overwrite: bool = False, save: bool = True) -> None: - - """ - Create a private Qobuz API client. - """ - - self.session = requests.Session() - if user_agent: - self.session.headers["User-Agent"] = user_agent - - if (auth_token is None and _config.has_section(self._NAME) - and not overwrite): - flow = _config.get(self._NAME, "flow") or None - auth_token = _config.get(self._NAME, "auth_token") - app_id = _config.get(self._NAME, "app_id") - app_secret = _config.get(self._NAME, "app_secret") - - self.set_flow(flow, app_id=app_id, app_secret=app_secret, - auth_token=auth_token, browser=browser, save=save) - self.set_auth_token(auth_token, email=email, password=password) - - def _check_authentication(self, endpoint: str) -> None: - - """ - Check if the user is authenticated for the desired endpoint. - - Parameters - ---------- - endpoint : `str` - Private Qobuz API endpoint. - """ - - if not self._flow: - emsg = (f"{self._NAME}.{endpoint}() requires user " - "authentication.") - raise RuntimeError(emsg) - - def _get_json(self, url: str, **kwargs) -> dict: - - """ - Send a GET request and return the JSON-encoded content of the - response. - - Parameters - ---------- - url : `str` - URL for the GET request. - - **kwargs - Keyword arguments to pass to :meth:`requests.request`. - - Returns - ------- - resp : `dict` - JSON-encoded content of the response. - """ - - return self._request("get", url, **kwargs).json() - - def _request(self, method: str, url: str, **kwargs) -> requests.Response: - - """ - Construct and send a request with status code checking. - - Parameters - ---------- - method : `str` - Method for the request. - - url : `str` - URL for the request. - - **kwargs - Keyword arguments passed to :meth:`requests.request`. - - Returns - ------- - resp : `requests.Response` - Response to the request. - """ - - r = self.session.request(method, url, **kwargs) - if r.status_code not in range(200, 299): - error = r.json() - raise RuntimeError(f'{error["code"]} {error["message"]}') - return r - - def _set_app_credentials(self, app_id: str, app_secret: str) -> None: - - """ - Set the Qobuz app ID and secret. - """ - - if not app_id or not app_secret: - js = re.search( - "/resources/.*/bundle.js", - self.session.get(f"{self.WEB_URL}/login").text - ).group(0) - bundle = self.session.get(f"{self.WEB_URL}{js}").text - app_id = re.search( - '(?:production:{api:{appId:")(.*?)(?:",appSecret)', bundle - ).group(1) - app_secret = [ - base64.b64decode("".join((s, *m.groups()))[:-44]).decode() - for s, m in ( - (s, re.search(f'(?:{c.capitalize()}",info:")(.*?)(?:",extras:")' - '(.*?)(?:"},{offset)', - bundle)) - for s, c in re.findall(r'(?:[a-z].initialSeed\(")(.*?)' - r'(?:",window.utimezone.)(.*?)\)', - bundle)) if m - ][1] - - self.session.headers["X-App-Id"] = app_id - self._app_secret = app_secret - -
-[docs] - def set_auth_token( - self, auth_token: str = None, *, email: str = None, - password: str = None) -> None: - - """ - Set the private Qobuz API user authentication token. - - Parameters - ---------- - auth_token : `str`, optional - User authentication token. - - email : `str`, keyword-only, optional - Account email address. - - password : `str`, keyword-only, optional - Account password. - """ - - if auth_token is None: - if not self._flow: - return - - if self._flow == "password": - if email is None or password is None: - if self._browser: - har_file = DIR_TEMP / "minim_qobuz_private.har" - - with sync_playwright() as playwright: - browser = playwright.firefox.launch(headless=False) - context = browser.new_context( - record_har_path=har_file - ) - page = context.new_page() - page.goto(f"{self.WEB_URL}/login", timeout=0) - page.wait_for_url(f"{self.WEB_URL}/featured", - wait_until="commit") - context.close() - browser.close() - - with open(har_file, "r") as f: - regex = re.search( - '(?<=")https://www.qobuz.com/api.json/0.2/oauth/callback?(.*)(?=")', - f.read() - ) - har_file.unlink() - - if regex is None: - raise RuntimeError("Authentication failed.") - auth_token = self._request("get", regex.group(0)).json()["token"] - else: - emsg = ("No account email or password provided " - "for the password flow.") - raise ValueError(emsg) - else: - r = self._request( - "post", f"{self.API_URL}/user/login", - params={"email": email, "password": password} - ).json() - auth_token = r["user_auth_token"] - - if self._save: - _config[self._NAME] = { - "flow": self._flow, - "auth_token": auth_token, - "app_id": self.session.headers["X-App-Id"], - "app_secret": self._app_secret - } - with open(DIR_HOME / "minim.cfg", "w") as f: - _config.write(f) - - self.session.headers["X-User-Auth-Token"] = auth_token - - if self._flow: - me = self.get_profile() - self._user_id = me["id"] - self._sub = ( - me["subscription"] is not None - and datetime.datetime.now() - <= datetime.datetime.strptime( - me["subscription"]["end_date"], "%Y-%m-%d" - ) + datetime.timedelta(days=1) - )
- - -
-[docs] - def set_flow( - self, flow: str, *, app_id: str = None, app_secret: str = None, - auth_token: str = None, browser: bool = False, save: bool = True - ) -> None: - - """ - Set the authorization flow. - - Parameters - ---------- - flow : `str`, keyword-only, optional - Authorization flow. - - .. container:: - - **Valid values**: - - * :code:`"password"` for the password flow. - * :code:`None` for no authentication. - - app_id : `str`, keyword-only, optional - App ID. Required if an user authentication token is provided - in `auth_token`. - - app_secret : `str`, keyword-only, optional - App secret. Required if an user authentication token is - provided in `auth_token`. - - auth_token : `str`, keyword-only, optional - User authentication token. - - browser : `bool`, keyword-only, default: :code:`False` - Determines whether a web browser is opened with the Qobuz login - page using the Playwright framework by Microsoft to complete the - password flow. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether to save the newly obtained access tokens - and their associated properties to the Minim configuration - file. - """ - - if flow and flow not in self._FLOWS: - emsg = (f"Invalid authorization flow ({flow=}). " - f"Valid values: {', '.join(self._FLOWS)}.") - raise ValueError(emsg) - - self._flow = flow - self._save = save - - self._browser = browser and FOUND_PLAYWRIGHT - if self._browser != browser: - logging.warning( - "The Playwright web framework was not found, so " - "user authentication via the Qobuz login page is " - "unavailable." - ) - - app_id = app_id or os.environ.get("QOBUZ_PRIVATE_APP_ID") - app_secret = app_secret or os.environ.get("QOBUZ_PRIVATE_APP_SECRET") - if (app_id is None or app_secret is None) and auth_token is not None: - emsg = ("App credentials are required when an user " - "authentication token is provided.") - - self._set_app_credentials(app_id, app_secret)
- - - ### ALBUMS ################################################################ - -
-[docs] - def get_album(self, album_id: str) -> dict[str, Any]: - - """ - Get Qobuz catalog information for a single album. - - Parameters - ---------- - album_id : `str` - Qobuz album ID. - - **Example**: :code:`"0060254735180"`. - - Returns - ------- - album : `dict` - Qobuz catalog information for a single album. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "maximum_bit_depth": <int>, - "image": { - "small": <str>, - "thumbnail": <str>, - "large": <str>, - "back": <str> - }, - "media_count": <str>, - "artist": { - "image": <str>, - "name": <str>, - "id": <int>, - "albums_count": <int>, - "slug": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "roles": [<str>] - } - ], - "upc": <str>, - "released_at": <int>, - "label": { - "name": <str>, - "id": <int>, - "albums_count": <int>, - "supplier_id": <int>, - "slug": <str> - }, - "title": <str>, - "qobuz_id": <int>, - "version": <str>, - "url": <str>, - "duration": <int>, - "parental_warning": <bool>, - "popularity": <int>, - "tracks_count": <int>, - "genre": { - "path": [<int>], - "color": <str>, - "name": <str>, - "id": <int>, - "slug": <str> - }, - "maximum_channel_count": <int>, - "id": <str>, - "maximum_sampling_rate": <int>, - "articles": <list>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "purchasable": <bool>, - "streamable": <bool>, - "previewable": <bool>, - "sampleable": <bool>, - "downloadable": <bool>, - "displayable": <bool>, - "purchasable_at": <int>, - "streamable_at": <int>, - "hires": <bool>, - "hires_streamable": <bool>, - "awards": <list>, - "description": <str>, - "description_language": <str>, - "goodies": <list>, - "area": <str>, - "catchline": <str>, - "composer": { - "id": <int>, - "name": <str>, - "slug": <str>, - "albums_count": <int>, - "picture": <str>, - "image": <str> - }, - "created_at": <int>, - "genres_list": [<str>], - "period": <str>, - "copyright": <str>, - "is_official": <bool>, - "maximum_technical_specifications": <str>, - "product_sales_factors_monthly": <int>, - "product_sales_factors_weekly": <int>, - "product_sales_factors_yearly": <int>, - "product_type": <str>, - "product_url": <str>, - "recording_information": <str>, - "relative_url": <str>, - "release_tags": <list>, - "release_type": <str>, - "slug": <str>, - "subtitle": <str>, - "tracks": { - "offset": <int>, - "limit": <int>, - "total": <int>, - "items": [ - { - "maximum_bit_depth": <int>, - "copyright": <str>, - "performers": <str>, - "audio_info": { - "replaygain_track_peak": <float>, - "replaygain_track_gain": <float> - }, - "performer": { - "name": <str>, - "id": <int> - }, - "work": <str>, - "composer": { - "name": <str>, - "id": <int> - }, - "isrc": <str>, - "title": <str>, - "version": <str>, - "duration": <int>, - "parental_warning": <bool>, - "track_number": <int>, - "maximum_channel_count": <int>, - "id": <int>, - "media_number": <int>, - "maximum_sampling_rate": <int>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "release_date_purchase": <str>, - "purchasable": <bool>, - "streamable": <bool>, - "previewable": <bool>, - "sampleable": <bool>, - "downloadable": <bool>, - "displayable": <bool>, - "purchasable_at": <int>, - "streamable_at": <int>, - "hires": <bool>, - "hires_streamable": <bool> - } - ] - } - } - """ - - return self._get_json(f"{self.API_URL}/album/get", - params={"album_id": album_id})
- - - - - - ### ARTISTS ############################################################### - -
-[docs] - def get_artist( - self, artist_id: Union[int, str], *, - extras: Union[str, list[str]] = None, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Get Qobuz catalog information for a single artist. - - Parameters - ---------- - artist_id : `int` or `str` - Qobuz artist ID. - - extras : `str` or `list`, keyword-only, optional - Specifies extra information about the artist to return. - - **Valid values**: :code:`"albums"`, :code:`"tracks"`, - :code:`"playlists"`, :code:`"tracks_appears_on"`, and - :code:`"albums_with_last_release"`. - - limit : `int`, keyword-only, optional - The maximum number of extra items to return. Has no effect - if :code:`extras=None`. - - **Default**: :code:`25`. - - offset : `int`, keyword-only, optional - The index of the first extra item to return. Use with - `limit` to get the next page of extra items. Has no effect - if :code:`extras=None`. - - **Default**: :code:`0`. - - Returns - ------- - artist : `dict` - Qobuz catalog information for a single artist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "albums_as_primary_artist_count": <int>, - "albums_as_primary_composer_count": <int>, - "albums_count": <int>, - "slug": <str>, - "picture": <str>, - "image": { - "small": <str>, - "medium": <str>, - "large": <str>, - "extralarge": <str>, - "mega": <str> - }, - "similar_artist_ids": [<int>], - "information": <str>, - "biography": { - "summary": <str>, - "content": <str>, - "source": <str>, - "language": <str> - } - } - """ - - return self._get_json( - f"{self.API_URL}/artist/get", - params={ - "artist_id": artist_id, - "extra": extras if extras is None or isinstance(extras, str) - else ",".join(extras), - "limit": limit, - "offset": offset - } - )
- - - ### LABELS ################################################################ - -
-[docs] - def get_label( - self, label_id: Union[int, str], *, albums: bool = False, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Get Qobuz catalog information for a record label. - - Parameters - ---------- - label_id : `int` or `str` - Qobuz record label ID. - - **Example**: :code:`1153`. - - albums : `bool`, keyword-only, default: :code:`False` - Specifies whether information on the albums released by the - record label is returned. - - limit : `int`, keyword-only, optional - The maximum number of albums to return. Has no effect if - :code:`albums=False`. - - **Default**: :code:`25`. - - offset : `int`, keyword-only, optional - The index of the first album to return. Use with `limit` to - get the next page of albums. Has no effect if - :code:`albums=False`. - - **Default**: :code:`0`. - - Returns - ------- - label : `dict` - Qobuz catalog information for the record label. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "slug": <str>, - "supplier_id": <int>, - "albums_count": <int>, - "image": <str>, - "description": <str> - } - """ - - return self._get_json( - f"{self.API_URL}/label/get", - params={ - "label_id": label_id, - "extra": "albums" if albums else None, - "limit": limit, - "offset": offset - } - )
- - - ### PLAYLISTS ############################################################# - -
-[docs] - def get_playlist( - self, playlist_id: Union[int, str], *, tracks: bool = True, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Get Qobuz catalog information for a playlist. - - Parameters - ---------- - playlist_id : `int` or `str` - Qobuz playlist ID. - - **Example**: :code:`15732665`. - - tracks : `bool`, keyword-only, default: :code:`True` - Specifies whether information on the tracks in the playlist - is returned. - - limit : `int`, keyword-only, optional - The maximum number of tracks to return. Has no effect if - :code:`tracks=False`. - - **Default**: :code:`50`. - - offset : `int`, keyword-only, optional - The index of the first track to return. Use with `limit` to - get the next page of tracks. Has no effect if - :code:`tracks=False`. - - **Default**: :code:`0`. - - Returns - ------- - playlist : `dict` - Qobuz catalog information for the playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "image_rectangle_mini": [<str>], - "featured_artists": <list>, - "description": <str>, - "created_at": <int>, - "timestamp_position": <int>, - "images300": [<str>], - "duration": <int>, - "updated_at": <int>, - "genres": [ - { - "id": <int>, - "color": <str>, - "name": <str>, - "path": [<int>], - "slug": <str>, - "percent": <float> - } - ], - "image_rectangle": [<str>], - "id": <int>, - "slug": <str>, - "owner": { - "id": <int>, - "name": <str> - }, - "users_count": <int>, - "images150": [<str>], - "images": [<str>], - "is_collaborative": <bool>, - "stores": [<str>], - "tags": [ - { - "featured_tag_id": <str>, - "name_json": <str>, - "slug": <str>, - "color": <str>, - "genre_tag": <str>, - "is_discover": <bool> - } - ], - "tracks_count": <int>, - "public_at": <int>, - "name": <str>, - "is_public": <bool>, - "is_featured": <bool>, - "tracks": { - "offset": <int>, - "limit": <int>, - "total": <int>, - "items": [ - { - "maximum_bit_depth": <int>, - "copyright": <str>, - "performers": <str>, - "audio_info": { - "replaygain_track_peak": <float>, - "replaygain_track_gain": <float> - }, - "performer": { - "name": <str>, - "id": <int> - }, - "album": { - "image": { - "small": <str>, - "thumbnail": <str>, - "large": <str> - }, - "maximum_bit_depth": <int>, - "media_count": <int>, - "artist": { - "image": <str>, - "name": <str>, - "id": <int>, - "albums_count": <int>, - "slug": <str>, - "picture": <str> - }, - "upc": <str>, - "released_at": <int>, - "label": { - "name": <str>, - "id": <int>, - "albums_count": <int>, - "supplier_id": <int>, - "slug": <str> - }, - "title": <str>, - "qobuz_id": <int>, - "version": <str>, - "duration": <int>, - "parental_warning": <bool>, - "tracks_count": <int>, - "popularity": <int>, - "genre": { - "path": [<int>], - "color": <str>, - "name": <str>, - "id": <int>, - "slug": <str> - }, - "maximum_channel_count": <int>, - "id": <str>, - "maximum_sampling_rate": <int>, - "previewable": <bool>, - "sampleable": <bool>, - "displayable": <bool>, - "streamable": <bool>, - "streamable_at": <int>, - "downloadable": <bool>, - "purchasable_at": <int>, - "purchasable": <bool>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "release_date_purchase": <str>, - "hires": <bool>, - "hires_streamable": <bool> - }, - "work": <str>, - "isrc": <str>, - "title": <str>, - "version": null, - "duration": <int>, - "parental_warning": <bool>, - "track_number": <int>, - "maximum_channel_count": <int>, - "id": <int>, - "media_number": <int>, - "maximum_sampling_rate": <int>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "release_date_purchase": <str>, - "purchasable": <bool>, - "streamable": <bool>, - "previewable": <bool>, - "sampleable": <bool>, - "downloadable": <bool>, - "displayable": <bool>, - "purchasable_at": <int>, - "streamable_at": <int>, - "hires": <bool>, - "hires_streamable": <bool>, - "position": <int>, - "created_at": <int>, - "playlist_track_id": <int> - } - ] - } - } - """ - - return self._get_json( - f"{self.API_URL}/playlist/get", - params={ - "playlist_id": playlist_id, - "extra": "tracks" if tracks else None, - "limit": limit, - "offset": offset - } - )
- - - - - -
-[docs] - def get_user_playlists( - self, *, limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Get the current user's custom and favorite playlists. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - limit : `int`, keyword-only, optional - The maximum number of playlists to return. - - **Default**: :code:`500`. - - offset : `int`, keyword-only, optional - The index of the first playlist to return. Use with `limit` - to get the next page of playlists. - - **Default**: :code:`0`. - - Returns - ------- - playlists : `dict` - Qobuz catalog information for the current user's custom and - favorite playlists. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "offset": <int>, - "limit": <int>, - "total": <int>, - "items": [ - { - "image_rectangle_mini": [<str>], - "is_published": <bool>, - "featured_artists": <list>, - "description": <str>, - "created_at": <int>, - "timestamp_position": <int>, - "images300": [<str>], - "duration": <int>, - "updated_at": <int>, - "published_to": <int>, - "genres": <list>, - "image_rectangle": [<str>], - "id": <int>, - "slug": <str>, - "owner": { - "id": <int>, - "name": <str> - }, - "users_count": <int>, - "images150": [<str>], - "images": [<str>], - "is_collaborative": <bool>. - "stores": [<str>], - "tracks_count": <int>, - "public_at": <int>, - "name": "Welcome to Qobuz", - "is_public": <bool>, - "published_from": <int>, - "is_featured": <bool>, - "position": <int> - } - ] - } - """ - - self._check_authentication("get_user_playlists") - - return self._get_json( - f"{self.API_URL}/playlist/getUserPlaylists", - params={"limit": limit, "offset": offset} - )["playlists"]
- - -
-[docs] - def create_playlist( - self, name: str, *, description: str = None, public: bool = True, - collaborative: bool = False) -> dict[str, Any]: - - """ - Create a user playlist. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - name : `str` - Qobuz playlist name. - - description : `str`, keyword-only, optional - Brief playlist description. - - public : `bool`, keyword-only, default: :code:`True` - Determines whether the playlist is public (:code:`True`) or - private (:code:`False`). - - collaborative : `bool`, keyword-only, default: :code:`False` - Determines whether the playlist is collaborative. - - Returns - ------- - playlist : `str` - Qobuz catalog information for the newly created playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "description": <str>, - "tracks_count": <int>, - "users_count": <int>, - "duration": <int>, - "public_at": <int>, - "created_at": <int>, - "updated_at": <int>, - "is_public": <bool>, - "is_collaborative": <bool>, - "owner": { - "id": <int>, - "name": <str> - } - } - """ - - self._check_authentication("create_playlist") - - data = {"name": name, "is_public": str(public).lower(), - "is_collaborative": str(collaborative).lower()} - if description: - data["description"] = description - return self._request( - "post", f"{self.API_URL}/playlist/create", data=data - ).json()
- - -
-[docs] - def update_playlist( - self, playlist_id: Union[int, str], *, name: str = None, - description: str = None, public: bool = None, - collaborative: bool = None) -> dict[str, Any]: - - """ - Update the title, description, and/or privacy of a playlist - owned by the current user. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - playlist_id : `int` or `str` - Qobuz user playlist ID. - - **Example**: :code:`17737508`. - - name : `str`, keyword-only, optional - Qobuz playlist name. - - description : `str`, keyword-only, optional - Brief playlist description. - - public : `bool`, keyword-only, optional - Determines whether the playlist is public (:code:`True`) or - private (:code:`False`). - - collaborative : `bool`, keyword-only, optional - Determines whether the playlist is collaborative. - - Returns - ------- - playlist : `str` - Qobuz catalog information for the updated playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "description": <str>, - "tracks_count": <int>, - "users_count": <int>, - "duration": <int>, - "public_at": <int>, - "created_at": <int>, - "updated_at": <int>, - "is_public": <bool>, - "is_collaborative": <bool>, - "owner": { - "id": <int>, - "name": <str> - } - } - """ - - self._check_authentication("update_playlist") - - data = {"playlist_id": playlist_id} - if name: - data["name"] = name - if description: - data["description"] = description - if public is not None: - data["is_public"] = str(public).lower() - if collaborative is not None: - data["is_collaborative"] = str(collaborative).lower() - return self._request("post", f"{self.API_URL}/playlist/update", - data=data).json()
- - -
-[docs] - def update_playlist_position( - self, from_playlist_id: Union[int, str], - to_playlist_id: Union[int, str]) -> None: - - """ - Organize a user's playlists. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - from_playlist_id : `int` or `str` - Qobuz user playlist ID of playlist to move. - - **Example**: :code:`17737508`. - - to_playlist_id : `int` or `str` - Qobuz user playlist ID of playlist to swap with that in - `from_playlist_id`. - - **Example**: :code:`17737509`. - """ - - self._check_authentication("update_playlist_position") - - self._request("post", - f"{self.API_URL}/playlist/updatePlaylistsPosition", - data={"playlist_ids": [from_playlist_id, to_playlist_id]})
- - -
-[docs] - def add_playlist_tracks( - self, playlist_id: Union[int, str], - track_ids: Union[int, str, list[Union[int, str]]], *, - duplicate: bool = False) -> dict[str, Any]: - - """ - Add tracks to a user playlist. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - playlist_id : `int` or `str` - Qobuz user playlist ID. - - **Example**: :code:`17737508`. - - track_ids : `int`, `str`, or `list` - Qobuz track ID(s). - - **Examples**: :code:`"24393122,24393138"` or - :code:`[24393122, 24393138]`. - - duplicate : `bool`, keyword-only, default: :code:`False` - Determines whether duplicate tracks should be added to the - playlist. - - Returns - ------- - playlist : `str` - Qobuz catalog information for the updated playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "description": <str>, - "tracks_count": <int>, - "users_count": <int>, - "duration": <int>, - "public_at": <int>, - "created_at": <int>, - "updated_at": <int>, - "is_public": <bool>, - "is_collaborative": <bool>, - "owner": { - "id": <int>, - "name": <str> - } - } - """ - - self._check_authentication("add_playlist_tracks") - - if isinstance(track_ids, list): - track_ids = ",".join(str(t) for t in track_ids) - return self._request( - "post", f"{self.API_URL}/playlist/addTracks", - data={ - "playlist_id": playlist_id, - "track_ids": track_ids, - "no_duplicate": str(not duplicate).lower() - } - ).json()
- - -
-[docs] - def move_playlist_tracks( - self, playlist_id: Union[int, str], - playlist_track_ids: Union[int, str, list[Union[int, str]]], - insert_before: int) -> dict[str, Any]: - - """ - Move tracks in a user playlist. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - playlist_id : `int` or `str` - Qobuz user playlist ID. - - **Example**: :code:`17737508`. - - playlist_track_ids : `int`, `str`, or `list` - Qobuz playlist track ID(s). - - .. note:: - - Playlist track IDs are not the same as track IDs. To get - playlist track IDs, use :meth:`get_playlist`. - - insert_before : `int` - Position to which to move the tracks specified in - `track_ids`. - - Returns - ------- - playlist : `str` - Qobuz catalog information for the updated playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "description": <str>, - "tracks_count": <int>, - "users_count": <int>, - "duration": <int>, - "public_at": <int>, - "created_at": <int>, - "updated_at": <int>, - "is_public": <bool>, - "is_collaborative": <bool>, - "owner": { - "id": <int>, - "name": <str> - } - } - """ - - self._check_authentication("move_playlist_tracks") - - if isinstance(playlist_track_ids, list): - playlist_track_ids = ",".join(str(t) for t in playlist_track_ids) - return self._request( - "post", f"{self.API_URL}/playlist/updateTracksPosition", - data={ - "playlist_id": playlist_id, - "playlist_track_ids": playlist_track_ids, - "insert_before": insert_before - } - ).json()
- - -
-[docs] - def delete_playlist_tracks( - self, playlist_id: Union[int, str], - playlist_track_ids: Union[int, str, list[Union[int, str]]] - ) -> dict[str, Any]: - - """ - Delete tracks from a user playlist. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - playlist_id : `int` or `str` - Qobuz user playlist ID. - - **Example**: :code:`17737508`. - - playlist_track_ids : `int`, `str`, or `list` - Qobuz playlist track ID(s). - - .. note:: - - Playlist track IDs are not the same as track IDs. To get - playlist track IDs, use :meth:`get_playlist`. - - Returns - ------- - playlist : `str` - Qobuz catalog information for the updated playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "description": <str>, - "tracks_count": <int>, - "users_count": <int>, - "duration": <int>, - "public_at": <int>, - "created_at": <int>, - "updated_at": <int>, - "is_public": <bool>, - "is_collaborative": <bool>, - "owner": { - "id": <int>, - "name": <str> - } - } - """ - - self._check_authentication("delete_playlist_tracks") - - if isinstance(playlist_track_ids, list): - playlist_track_ids = ",".join(str(t) for t in playlist_track_ids) - - return self._request( - "post", f"{self.API_URL}/playlist/deleteTracks", - data={"playlist_id": playlist_id, - "playlist_track_ids": playlist_track_ids} - ).json()
- - -
-[docs] - def delete_playlist(self, playlist_id: Union[int, str]) -> None: - - """ - Delete a user playlist. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - playlist_id : `int` or `str` - Qobuz user playlist ID. - - **Example**: :code:`17737508`. - """ - - self._check_authentication("delete_playlist") - - self._request("post", f"{self.API_URL}/playlist/delete", - data={"playlist_id": playlist_id})
- - -
-[docs] - def favorite_playlist(self, playlist_id: Union[int, str]) -> None: - - """ - Subscribe to a playlist. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - playlist_id : `int` or `str` - Qobuz playlist ID. - - **Example**: :code:`15732665`. - """ - - self._check_authentication("favorite_playlist") - - self._request("post", f"{self.API_URL}/playlist/subscribe", - data={"playlist_id": playlist_id})
- - -
-[docs] - def unfavorite_playlist(self, playlist_id: Union[int, str]) -> None: - - """ - Unsubscribe from a playlist. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - playlist_id : `int` or `str` - Qobuz playlist ID. - - **Example**: :code:`15732665`. - """ - - self._check_authentication("unfavorite_playlist") - - self._request("post", f"{self.API_URL}/playlist/unsubscribe", - data={"playlist_id": playlist_id})
- - - ### SEARCH ################################################################ - -
-[docs] - def search( - self, query: str, type: str = None, *, hi_res: bool = False, - new_release: bool = False, strict: bool = False, limit: int = 10, - offset: int = 0) -> dict[str, Any]: - - """ - Search Qobuz for media and performers. - - Parameters - ---------- - query : `str` - Search query. - - type : `str`, keyword-only, optional - Category to search in. If specified, only matching releases - and tracks will be returned. - - **Valid values**: :code:`"MainArtist"`, :code:`"Composer"`, - :code:`"Performer"`, :code:`"ReleaseName"`, and - :code:`"Label"`. - - hi_res : `bool`, keyword-only, :code:`False` - High-resolution audio only. - - new_release : `bool`, keyword-only, :code:`False` - New releases only. - - strict : `bool`, keyword-only, :code:`False` - Enable exact word or phrase matching. - - limit : `int`, keyword-only, default: :code:`10` - Maximum number of results to return. - - offset : `int`, keyword-only, default: :code:`0` - Index of the first result to return. Use with `limit` to get - the next page of search results. - - Returns - ------- - results : `dict` - Search results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "query": <str>, - "albums": { - "limit": <int>, - "offset": <int>, - "total": <int>, - "items": [ - { - "maximum_bit_depth": <int>, - "image": { - "small": <str>, - "thumbnail": <str>, - "large": <str>, - "back": <str>, - }, - "media_count": <int>, - "artist": { - "image": <str>, - "name": <str>, - "id": <int>, - "albums_count": <int>, - "slug": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "roles": [<str>] - } - ], - "upc": <str>, - "released_at": <int>, - "label": { - "name": <str>, - "id": <int>, - "albums_count": <int>, - "supplier_id": <int>, - "slug": <str> - }, - "title": <str>, - "qobuz_id": <int>, - "version": <str>, - "url": <str>, - "duration": <int>, - "parental_warning": <bool>, - "popularity": <int>, - "tracks_count": <int>, - "genre": { - "path": [<int>], - "color": <str>, - "name": <str>, - "id": <int>, - "slug": <str> - }, - "maximum_channel_count": <int>, - "id": <str>, - "maximum_sampling_rate": <int>, - "articles": <list>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "purchasable": <bool>, - "streamable": <bool>, - "previewable": <bool>, - "sampleable": <bool>, - "downloadable": <bool>, - "displayable": <bool>, - "purchasable_at": <int>, - "streamable_at": <int>, - "hires": <bool>, - "hires_streamable": <bool> - } - ] - }, - "tracks": { - "limit": <int>, - "offset": <int>, - "total": <int>, - "items": [ - { - "maximum_bit_depth": <int>, - "copyright": <str>, - "performers": <str>, - "audio_info": { - "replaygain_track_peak": <float>, - "replaygain_track_gain": <float> - }, - "performer": { - "name": <str>, - "id": <int> - }, - "album": { - "image": { - "small": <str>, - "thumbnail": <str>, - "large": <str> - }, - "maximum_bit_depth": <int>, - "media_count": <int>, - "artist": { - "image": <str>, - "name": <str>, - "id": <int>, - "albums_count": <int>, - "slug":<str>, - "picture": <str> - }, - "upc": <str>, - "released_at": <int>, - "label": { - "name": <str>, - "id": <int>, - "albums_count": <int>, - "supplier_id": <int>, - "slug": <str> - }, - "title": <str>, - "qobuz_id": <int>, - "version": <str>, - "duration": <int>, - "parental_warning": <bool>, - "tracks_count": <int>, - "popularity": <int>, - "genre": { - "path": [<int>], - "color": <str>, - "name": <str>, - "id": <int>, - "slug": <str> - }, - "maximum_channel_count": <int>, - "id": <str>, - "maximum_sampling_rate": <int>, - "previewable": <bool>, - "sampleable": <bool>, - "displayable": <bool>, - "streamable": <bool>, - "streamable_at": <int>, - "downloadable": <bool>, - "purchasable_at": <int>, - "purchasable": <bool>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "release_date_purchase": <str>, - "hires": <bool>, - "hires_streamable": <bool> - }, - "work": <str>, - "composer": { - "name": <str>, - "id": <int> - }, - "isrc": <str>, - "title": <str>, - "version": <str>, - "duration": <int>, - "parental_warning": <bool>, - "track_number": <int>, - "maximum_channel_count": <int>, - "id": <int>, - "media_number": <int>, - "maximum_sampling_rate": <int>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "release_date_purchase": <str>, - "purchasable": <bool>, - "streamable": <bool>, - "previewable": <bool>, - "sampleable": <bool>, - "downloadable": <bool>, - "displayable": <bool>, - "purchasable_at": <int>, - "streamable_at": <int>, - "hires": <bool>, - "hires_streamable": <bool> - } - ] - }, - "artists": { - "limit": <int>, - "offset": <int>, - "total": <int>, - "items": [ - { - "picture": <str>, - "image": { - "small": <str>, - "medium": <str>, - "large": <str>, - "extralarge": <str>, - "mega": <str> - }, - "name": <str>, - "slug": <str>, - "albums_count": <int>, - "id": <int> - } - ] - }, - "playlists": { - "limit": <int>, - "offset": <int>, - "total": <int>, - "items": [ - { - "image_rectangle_mini": [<str>], - "is_published": <bool>, - "featured_artists": <list>, - "description": <str>, - "created_at": <int>, - "timestamp_position": <int>, - "images300": [<str>], - "duration": <int>, - "updated_at": <int>, - "published_to": <int>, - "genres": <list>, - "image_rectangle": [<str>], - "id": <int>, - "slug": <str>, - "owner": { - "id": <int>, - "name": <str> - }, - "users_count": <int>, - "images150": [<str>], - "images": [<str>], - "is_collaborative": <bool>, - "stores": [<str>], - "tags": [ - { - "featured_tag_id": <str>, - "name_json": <str>, - "slug": <str>, - "color": <str>, - "genre_tag": <str>, - "is_discover": <bool> - } - ], - "tracks_count": <int>, - "public_at": <int>, - "name": <str>, - "is_public": <bool>, - "published_from": <int>, - "is_featured": <bool> - } - ] - }, - "focus": { - "limit": <int>, - "offset": <int>, - "total": <int>, - "items": [ - { - "image": <str>, - "name_superbloc": <str>, - "accroche": <str>, - "id": <str>, - "title": <str>, - "genre_ids": [<str>], - "author": <str>, - "date": <str> - } - ] - }, - "articles": { - "limit": <int>, - "offset": <int>, - "total": <int>, - "items": [ - { - "image": <str>, - "thumbnail": <str>, - "root_category": <int>, - "author": <str>, - "abstract": <str>, - "source": <str>, - "title": <str>, - "type": <str>, - "url": <str>, - "image_original": <str>, - "category_id": <int>, - "source_image": <str>, - "id": <int>, - "published_at": <int>, - "category": <str> - } - ] - }, - "stories": { - "limit": <int>, - "offset": <int>, - "total": <int>, - "items": [ - { - "id": <str>, - "section_slugs": [<str>], - "title": <str>, - "description_short": <str>, - "authors": [ - { - "id": <str>, - "name": <str>, - "slug": <str> - } - ], - "image": <str>, - "display_date": <int> - } - ] - } - } - """ - - if type and type not in \ - (SEARCH_TYPES := {"MainArtist", "Composer", "Performer", - "ReleaseName", "Label"}): - emsg = ("Invalid search type. Valid values: " - f"{', '.join(SEARCH_TYPES)}") - raise ValueError(emsg) - - if strict: - query = f'"{query}"' - if type: - query += f" #By{type}" - if hi_res: - query += " #HiRes" - if new_release: - query += " #NewRelease" - - return self._get_json( - f"{self.API_URL}/catalog/search", - params={"query": query, "limit": limit, "offset": offset} - )
- - - ### TRACKS ################################################################ - -
-[docs] - def get_track(self, track_id: Union[int, str]) -> dict[str, Any]: - - """ - Get Qobuz catalog information for a track. - - Parameters - ---------- - track_id : `int` or `str` - Qobuz track ID. - - **Example**: :code:`24393138`. - - Returns - ------- - track : `dict` - Qobuz catalog information for the track. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "maximum_bit_depth": <int>, - "copyright": <str>, - "performers": <str>, - "audio_info": { - "replaygain_track_gain": <float>, - "replaygain_track_peak": <float> - }, - "performer": { - "id": <int>, - "name": <str> - }, - "album": { - "maximum_bit_depth": <int>, - "image": { - "small": <str>, - "thumbnail": <str>, - "large": <str>, - "back": <str> - }, - "media_count": <int>, - "artist": { - "image": <str>, - "name": <str>, - "id": <int>, - "albums_count": <int>, - "slug": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "roles": [<str>] - } - ], - "upc": <str>, - "released_at": <int>, - "label": { - "name": <str>, - "id": <int>, - "albums_count": <int>, - "supplier_id": <int>, - "slug": <str> - }, - "title": <str>, - "qobuz_id": <int>, - "version": <str>, - "url": <str>, - "duration": <int>, - "parental_warning": <bool>, - "popularity": <int>, - "tracks_count": <int>, - "genre": { - "path": [<int>], - "color": <str>, - "name": <str>, - "id": <int>, - "slug": <str> - }, - "maximum_channel_count": <int>, - "id": <str>, - "maximum_sampling_rate": <int>, - "articles": <list>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "purchasable": <bool>, - "streamable": <bool>, - "previewable": <bool>, - "sampleable": <bool>, - "downloadable": <bool>, - "displayable": <bool>, - "purchasable_at": <int>, - "streamable_at": <int>, - "hires": <bool>, - "hires_streamable": <bool>, - "awards": <list>, - "description": <str>, - "description_language": <str>, - "goodies": <list>, - "area": null, - "catchline": <str>, - "composer": { - "id": <int>, - "name": <str>, - "slug": <str>, - "albums_count": <int>, - "picture": <str>, - "image": <str> - }, - "created_at": <int>, - "genres_list": [<str>], - "period": <str>, - "copyright": <str>, - "is_official": <bool>, - "maximum_technical_specifications": <str>, - "product_sales_factors_monthly": <int>, - "product_sales_factors_weekly": <int>, - "product_sales_factors_yearly": <int>, - "product_type": <str>, - "product_url": <str>, - "recording_information": <str>, - "relative_url": <str>, - "release_tags": <list>, - "release_type": <str>, - "slug": <str>, - "subtitle": <str> - }, - "work": <str>, - "composer": { - "id": <int>, - "name": <str> - }, - "isrc": <str>, - "title": <str>, - "version": <str>, - "duration": <int>, - "parental_warning": <bool>, - "track_number": <int>, - "maximum_channel_count": <int>, - "id": <int>, - "media_number": <int>, - "maximum_sampling_rate": <int>, - "articles": <list>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "release_date_purchase": <str>, - "purchasable": <bool>, - "streamable": <bool>, - "previewable": <bool>, - "sampleable": <bool>, - "downloadable": <bool>, - "displayable": <bool>, - "purchasable_at": <int>, - "streamable_at": <int>, - "hires": <bool>, - "hires_streamable": <bool> - } - """ - - return self._get_json(f"{self.API_URL}/track/get", - params={"track_id": track_id})
- - -
-[docs] - def get_track_performers( - self, track_id: Union[int, str] = None, *, performers: str = None, - roles: Union[list[str], set[str]] = None) -> dict[str, list]: - - """ - Get credits for a track. - - .. note:: - - This method is provided for convenience and is not a private - Qobuz API endpoint. - - Parameters - ---------- - track_id : `int` or `str`, optional - Qobuz track ID. Required if `performers` is not provided. - - **Example**: :code:`24393138`. - - performers : `str`, keyword-only, optional - An unformatted string containing the track credits obtained - from calling :meth:`get_track`. - - roles : `list` or `set`, keyword-only, optional - Role filter. The special :code:`"Composers"` filter will - combine the :code:`"Composer"`, :code:`"ComposerLyricist"`, - :code:`"Lyricist"`, and :code:`"Writer"` roles. - - **Valid values**: :code:`"MainArtist"`, - :code:`"FeaturedArtist"`, :code:`"Producer"`, - :code:`"Co-Producer"`, :code:`"Mixer"`, - :code:`"Composers"` (:code:`"Composer"`, - :code:`"ComposerLyricist"`, :code:`"Lyricist"`, - :code:`"Writer"`), :code:`"MusicPublisher"`, etc. - - Returns - ------- - credits : `dict` - A dictionary containing the track contributors, with their - roles (in snake case) being the keys. - """ - - if performers is None: - if track_id is None: - emsg = ("Either a Qobuz track ID or an unformatted " - "string containing the track credits must be " - "provided.") - raise ValueError(emsg) - performers = self.get_track(track_id)["performers"] - - if performers is None: - return {} - - return _parse_performers(performers, roles=roles)
- - -
-[docs] - def get_track_file_url( - self, track_id: Union[int, str], format_id: Union[int, str] = 27 - ) -> dict[str, Any]: - - """ - Get the file URL for a track. - - .. admonition:: Subscription - :class: warning - - Full track playback information and lossless and Hi-Res audio - is only available with an active Qobuz subscription. - - Parameters - ---------- - track_id : `int` or `str` - Qobuz track ID. - - **Example**: :code:`24393138`. - - format_id : `int` or `str`, default: :code:`27` - Audio format ID that determines the maximum audio quality. - - .. container:: - - **Valid values**: - - * :code:`5` for constant bitrate (320 kbps) MP3. - * :code:`6` for CD-quality (16-bit, 44.1 kHz) FLAC. - * :code:`7` for up to 24-bit, 96 kHz Hi-Res FLAC. - * :code:`27` for up to 24-bit, 192 kHz Hi-Res FLAC. - - Returns - ------- - url : `dict` - A dictionary containing the URL and track information, such - as the audio format, bit depth, etc. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "track_id": <int>, - "duration": <int>, - "url": <str>, - "format_id": <int>, - "mime_type": <str>, - "restrictions": [ - { - "code": <str> - } - ], - "sampling_rate": <int>, - "bit_depth": <int> - } - - """ - - if not self._flow or not self._sub: - wmsg = ("No user authentication or Qobuz streaming plan " - "detected. The URL, if available, will lead to a " - "30-second preview of the track.") - logging.warning(wmsg) - - if int(format_id) not in (FORMAT_IDS := {5, 6, 7, 27}): - emsg = ("Invalid format ID. Valid values: " - f"{', '.join(FORMAT_IDS)}.") - raise ValueError(emsg) - - timestamp = datetime.datetime.now().timestamp() - return self._get_json( - f"{self.API_URL}/track/getFileUrl", - params={ - "request_ts": timestamp, - "request_sig": hashlib.md5( - (f"trackgetFileUrlformat_id{format_id}" - f"intentstreamtrack_id{track_id}" - f"{timestamp}{self._app_secret}").encode() - ).hexdigest(), - "track_id": track_id, - "format_id": format_id, - "intent": "stream" - } - )
- - -
-[docs] - def get_curated_tracks(self) -> list[dict[str, Any]]: - - """ - Get weekly curated tracks for the user. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - limit : `int`, keyword-only, optional - The maximum number of tracks to return. - - **Default**: :code:`50`. - - offset : `int`, keyword-only, optional - The index of the first track to return. Use with `limit` - to get the next page of tracks. - - **Default**: :code:`0`. - - Returns - ------- - tracks : `list` - Qobuz catalog information for the curated tracks. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "title": <str>, - "baseline": <str>, - "description": <str>, - "type": "weekly", - "step_pagination": <int>, - "images": { - "small": <str>, - "large": <str> - }, - "graphics": { - "background": <str>, - "foreground": <str> - }, - "duration": <int>, - "generated_at": <int>, - "expires_on": <int>, - "track_count": <int>, - "tracks": { - "offset": <int>, - "limit": <int>, - "items": [ - { - "maximum_bit_depth": <int>, - "copyright": <str>, - "performers": <str>, - "audio_info": { - "replaygain_track_peak": <float>, - "replaygain_track_gain": <float> - }, - "performer": { - "name": <str>, - "id": <int> - }, - "album": { - "image": { - "small": <str>, - "thumbnail": <str>, - "large": <str> - }, - "maximum_bit_depth": <int>, - "media_count": <int>, - "artist": { - "image": <str>, - "name": <str>, - "id": <int>, - "albums_count": <int>, - "slug":<str>, - "picture": <str> - }, - "upc": <str>, - "released_at": <int>, - "label": { - "name": <str>, - "id": <int>, - "albums_count": <int>, - "supplier_id": <int>, - "slug": <str> - }, - "title": <str>, - "qobuz_id": <int>, - "version": <str>, - "duration": <int>, - "parental_warning": <bool>, - "tracks_count": <int>, - "popularity": <int>, - "genre": { - "path": [<int>], - "color": <str>, - "name": <str>, - "id": <int>, - "slug": <str> - }, - "maximum_channel_count": <int>, - "id": <str>, - "maximum_sampling_rate": <int>, - "previewable": <bool>, - "sampleable": <bool>, - "displayable": <bool>, - "streamable": <bool>, - "streamable_at": <int>, - "downloadable": <bool>, - "purchasable_at": <int>, - "purchasable": <bool>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "release_date_purchase": <str>, - "hires": <bool>, - "hires_streamable": <bool> - }, - "work": <str>, - "composer": { - "name": <str>, - "id": <int> - }, - "isrc": <str>, - "title": <str>, - "version": <str>, - "duration": <int>, - "parental_warning": <bool>, - "track_number": <int>, - "maximum_channel_count": <int>, - "id": <int>, - "media_number": <int>, - "maximum_sampling_rate": <int>, - "release_date_original": <str>, - "release_date_download": <str>, - "release_date_stream": <str>, - "release_date_purchase": <str>, - "purchasable": <bool>, - "streamable": <bool>, - "previewable": <bool>, - "sampleable": <bool>, - "downloadable": <bool>, - "displayable": <bool>, - "purchasable_at": <int>, - "streamable_at": <int>, - "hires": <bool>, - "hires_streamable": <bool> - } - ] - } - } - """ - - self._check_authentication("get_curated_tracks") - - return self._get_json(f"{self.API_URL}/dynamic-tracks/get", - params={"type": "weekly"})
- - - ### STREAMS ############################################################### - -
-[docs] - def get_track_stream( - self, track_id: Union[int, str], *, format_id: Union[int, str] = 27 - ) -> tuple[bytes, str]: - - """ - Get the audio stream data for a track. - - .. admonition:: Subscription - :class: warning - - Full track playback information and lossless and Hi-Res audio - is only available with an active Qobuz subscription. - - .. note:: - - This method is provided for convenience and is not a private - Qobuz API endpoint. - - Parameters - ---------- - track_id : `int` or `str` - Qobuz track ID. - - **Example**: :code:`24393138`. - - format_id : `int`, default: :code:`27` - Audio format ID that determines the maximum audio quality. - - .. container:: - - **Valid values**: - - * :code:`5` for constant bitrate (320 kbps) MP3. - * :code:`6` for CD-quality (16-bit, 44.1 kHz) FLAC. - * :code:`7` for up to 24-bit, 96 kHz Hi-Res FLAC. - * :code:`27` for up to 24-bit, 192 kHz Hi-Res FLAC. - - Returns - ------- - stream : `bytes` - Audio stream data. - - mime_type : `str` - Audio stream MIME type. - """ - - file = self.get_track_file_url(track_id, format_id=format_id) - with self.session.get(file["url"]) as r: - return r.content, file["mime_type"]
- - -
-[docs] - def get_collection_streams( - self, id: Union[int, str], type: str, *, - format_id: Union[int, str] = 27) -> list[tuple[bytes, str]]: - - """ - Get audio stream data for all tracks in an album or a playlist. - - .. admonition:: Subscription - :class: warning - - Full track playback information and lossless and Hi-Res audio - is only available with an active Qobuz subscription. - - .. note:: - - This method is provided for convenience and is not a private - Qobuz API endpoint. - - Parameters - ---------- - id : `int` or `str` - Qobuz collection ID. - - type : `str` - Collection type. - - **Valid values**: :code:`"album"` and :code:`"playlist"`. - - format_id : `int`, default: :code:`27` - Audio format ID that determines the maximum audio quality. - - .. container:: - - **Valid values**: - - * :code:`5` for constant bitrate (320 kbps) MP3. - * :code:`6` for CD-quality (16-bit, 44.1 kHz) FLAC. - * :code:`7` for up to 24-bit, 96 kHz Hi-Res FLAC. - * :code:`27` for up to 24-bit, 192 kHz Hi-Res FLAC. - - Returns - ------- - streams : `list` - Audio stream data. - """ - - if type not in (COLLECTION_TYPES := {"album", "playlist"}): - emsg = ("Invalid collection type. Valid values: " - f"{', '.join(COLLECTION_TYPES)}.") - raise ValueError(emsg) - - if type == "album": - data = self.get_album(id) - elif type == "playlist": - data = self.get_playlist(id, limit=500) - return [self.get_track_stream(track["id"], format_id=format_id) - if track["streamable"] else None - for track in data["tracks"]["items"]]
- - - ### USER ################################################################## - -
-[docs] - def get_profile(self) -> dict[str, Any]: - - """ - Get the current user's profile information. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Returns - ------- - profile : `dict` - A dictionary containing the current user's profile - information. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "publicId": <str>, - "email": <str>, - "login": <str>, - "firstname": <str>, - "lastname": <str>, - "display_name": <str>, - "country_code": <str>, - "language_code": <str>, - "zone": <str>, - "store": <str>, - "country": <str>, - "avatar": <str>, - "genre": <str>, - "age": <int>, - "creation_date": <str>, - "subscription": { - "offer": <str>, - "periodicity": <str>, - "start_date": <str>, - "end_date": <str>, - "is_canceled": <bool>, - "household_size_max": <int> - }, - "credential": { - "id": <int>, - "label": <str>, - "description": <str>, - "parameters": { - "lossy_streaming": <bool>, - "lossless_streaming": <bool>, - "hires_streaming": <bool>, - "hires_purchases_streaming": <bool>, - "mobile_streaming": <bool>, - "offline_streaming": <bool>, - "hfp_purchase": <bool>, - "included_format_group_ids": [<int>], - "color_scheme": { - "logo": <str> - }, - "label": <str>, - "short_label": <str>, - "source": <str> - } - }, - "last_update": { - "favorite": <int>, - "favorite_album": <int>, - "favorite_artist": <int>, - "favorite_track": <int>, - "playlist": <int>, - "purchase": <int> - }, - "store_features": { - "download": <bool>, - "streaming": <bool>, - "editorial": <bool>, - "club": <bool>, - "wallet": <bool>, - "weeklyq": <bool>, - "autoplay": <bool>, - "inapp_purchase_subscripton": <bool>, - "opt_in": <bool>, - "music_import": <bool> - } - } - """ - - self._check_authentication("get_profile") - - return self._get_json(f"{self.API_URL}/user/get")
- - -
-[docs] - def get_favorites( - self, type: str = None, *, limit: int = None, - offset: int = None) -> dict[str, dict]: - - """ - Get the current user's favorite albums, artists, and tracks. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - type : `str` - Media type to return. If not specified, all of the user's - favorite items are returned. - - .. container:: - - **Valid values**: :code:`"albums"`, :code:`"artists"`, - and :code:`"tracks"`. - - limit : `int`, keyword-only, optional - The maximum number of favorited items to return. - - **Default**: :code:`50`. - - offset : `int`, keyword-only, optional - The index of the first favorited item to return. Use with - `limit` to get the next page of favorited items. - - **Default**: :code:`0`. - - Returns - ------- - favorites : `dict` - A dictionary containing Qobuz catalog information for the - current user's favorite items and the user's ID and email. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "albums": { - "offset": <int>, - "limit": <int>, - "total": <int>, - "items": <list> - }, - "user": { - "id": <int>, - "login": <str> - } - } - """ - - self._check_authentication("get_favorites") - - if type and type not in (MEDIA_TYPES := {"albums", "artists", "tracks"}): - emsg = ("Invalid media type. Valid values: " - f"{', '.join(MEDIA_TYPES)}.") - raise ValueError(emsg) - - timestamp = datetime.datetime.now().timestamp() - return self._get_json( - f"{self.API_URL}/favorite/getUserFavorites", - params={ - "request_ts": timestamp, - "request_sig": hashlib.md5( - (f"favoritegetUserFavorites{timestamp}" - f"{self._app_secret}").encode() - ).hexdigest(), - "type": type, - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_purchases( - self, type: str = "albums", *, limit: int = None, - offset: int = None) -> dict[str, Any]: - - """ - Get the current user's purchases. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - Parameters - ---------- - type : `str`, default: :code:`"albums"` - Media type. - - **Valid values**: :code:`"albums"` and :code:`"tracks"`. - - limit : `int`, keyword-only, optional - The maximum number of albums or tracks to return. - - **Default**: :code:`50`. - - offset : `int`, keyword-only, optional - The index of the first album or track to return. Use with - `limit` to get the next page of albums or tracks. - - **Default**: :code:`0`. - - Returns - ------- - purchases : `dict` - A dictionary containing Qobuz catalog information for the - current user's purchases. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "offset": <int>, - "limit": <int>, - "total": <int>, - "items": <list> - } - """ - - self._check_authentication("get_purchases") - - if type not in (MEDIA_TYPES := {"albums", "tracks"}): - emsg = ("Invalid media type. Valid values: " - f"{', '.join(MEDIA_TYPES)}.") - raise ValueError(emsg) - - return self._get_json( - f"{self.API_URL}/purchase/getUserPurchases", - params={"type": type, "limit": limit, "offset": offset} - )[type]
- - -
-[docs] - def favorite_items( - self, *, album_ids: Union[str, list[str]] = None, - artist_ids: Union[int, str, list[Union[int, str]]] = None, - track_ids: Union[int, str, list[Union[int, str]]] = None) -> None: - - """ - Favorite albums, artists, and/or tracks. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - .. seealso:: - - For playlists, use :meth:`favorite_playlist`. - - Parameters - ---------- - album_ids : `str` or `list`, keyword-only, optional - Qobuz album ID(s). - - artist_ids : `int`, `str`, or `list`, keyword-only, optional - Qobuz artist ID(s). - - track_ids : `int`, `str`, or `list`, keyword-only, optional - Qobuz track ID(s). - """ - - self._check_authentication("favorite_items") - - data = {} - if album_ids: - data["album_ids"] = ",".join(str(a) for a in album_ids) \ - if isinstance(album_ids, list) \ - else album_ids - if artist_ids: - data["artist_ids"] = ",".join(str(a) for a in artist_ids) \ - if isinstance(artist_ids, list) \ - else artist_ids - if track_ids: - data["track_ids"] = ",".join(str(a) for a in track_ids) \ - if isinstance(track_ids, list) \ - else track_ids - self._request("post", f"{self.API_URL}/favorite/create", data=data)
- - -
-[docs] - def unfavorite_items( - self, *, album_ids: Union[str, list[str]] = None, - artist_ids: Union[int, str, list[Union[int, str]]] = None, - track_ids: Union[int, str, list[Union[int, str]]] = None) -> None: - - """ - Unfavorite albums, artists, and/or tracks. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via the password flow. - - .. seealso:: - - For playlists, use :meth:`unfavorite_playlist`. - - Parameters - ---------- - album_ids : `str` or `list`, keyword-only, optional - Qobuz album ID(s). - - artist_ids : `int`, `str`, or `list`, keyword-only, optional - Qobuz artist ID(s). - - track_ids : `int`, `str`, or `list`, keyword-only, optional - Qobuz track ID(s). - """ - - self._check_authentication("unfavorite_items") - - data = {} - if album_ids: - data["album_ids"] = ",".join(str(a) for a in album_ids) \ - if isinstance(album_ids, list) \ - else album_ids - if artist_ids: - data["artist_ids"] = ",".join(str(a) for a in artist_ids) \ - if isinstance(artist_ids, list) \ - else artist_ids - if track_ids: - data["track_ids"] = ",".join(str(a) for a in track_ids) \ - if isinstance(track_ids, list) \ - else track_ids - self._request("post", f"{self.API_URL}/favorite/delete", data=data)
-
- -
-
-
-
- - -
-
- - Made with Sphinx and @pradyunsg's - - Furo - -
-
- -
-
- -
-
- -
-
- - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/_modules/minim/spotify.html b/docs/_modules/minim/spotify.html deleted file mode 100644 index b84b847..0000000 --- a/docs/_modules/minim/spotify.html +++ /dev/null @@ -1,10164 +0,0 @@ - - - - - - - - minim.spotify - Minim 1.0.0 documentation - - - - - - - - - - - - - - - - - - - Contents - - - - - - Menu - - - - - - - - Expand - - - - - - Light mode - - - - - - - - - - - - - - Dark mode - - - - - - - Auto light/dark mode - - - - - - - - - - - - - - - - - - - -
-
-
- -
- -
-
- -
- -
-
- -
-
-
- - - - - Back to top - -
-
- -
- -
-
-

Source code for minim.spotify

-"""
-Spotify
-=======
-.. moduleauthor:: Benjamin Ye <GitHub: bbye98>
-
-This module contains a complete implementation of all Spotify Web API
-endpoints and a minimal implementation to use the private Spotify Lyrics
-service.
-"""
-
-import base64
-import datetime
-import hashlib
-from http.server import HTTPServer, BaseHTTPRequestHandler
-import json
-import logging
-from multiprocessing import Process
-import os
-import re
-import secrets
-import time
-from typing import Any, Union
-import urllib
-import warnings
-import webbrowser
-
-import requests
-
-from . import FOUND_FLASK, FOUND_PLAYWRIGHT, DIR_HOME, DIR_TEMP, _config
-if FOUND_FLASK:
-    from flask import Flask, request
-if FOUND_PLAYWRIGHT:
-    from playwright.sync_api import sync_playwright
-
-__all__ = ["PrivateLyricsService", "WebAPI"]
-
-class _SpotifyRedirectHandler(BaseHTTPRequestHandler):
-
-    """
-    HTTP request handler for the Spotify authorization code flow.
-    """
-
-    def do_GET(self):
-
-        """
-        Handles an incoming GET request and parses the query string.
-        """
-
-        self.server.response = dict(
-            urllib.parse.parse_qsl(
-                urllib.parse.urlparse(f"{self.path}").query
-            )
-        )
-        self.send_response(200)
-        self.send_header("Content-Type", "text/html")
-        self.end_headers()
-        status = "denied" if "error" in self.server.response else "granted"
-        self.wfile.write(
-            f"Access {status}. You may close this page now.".encode()
-          )
-
-
-[docs] -class PrivateLyricsService: - - """ - Spotify Lyrics service client. - - The Spotify Lyrics service, which is powered by Musixmatch (or - PetitLyrics in Japan), provides line- or word-synced lyrics for - Spotify tracks when available. The Spotify Lyrics interface is not - publicly documented, so its endpoints have been determined by - watching HTTP network traffic. - - .. attention:: - - As the Spotify Lyrics service is not designed to be publicly - accessible, this class can be disabled or removed at any time to - ensure compliance with the `Spotify Developer Terms of Service - <https://developer.spotify.com/terms>`_. - - Requests to the Spotify Lyrics endpoints must be accompanied by a - valid access token in the header. An access token can be obtained - using the Spotify Web Player :code:`sp_dc` cookie, which must either - be provided to this class's constructor as a keyword argument or be - stored as :code:`SPOTIFY_SP_DC` in the operating system's - environment variables. - - .. hint:: - - The :code:`sp_dc` cookie can be extracted from the local storage - of your web browser after you log into Spotify. - - If an existing access token is available, it and its expiry time can - be provided to this class's constructor as keyword arguments to - bypass the access token exchange process. It is recommended that all - other authorization-related keyword arguments be specified so that - a new access token can be obtained when the existing one expires. - - .. tip:: - - The :code:`sp_dc` cookie and access token can be changed or - updated at any time using :meth:`set_sp_dc` and - :meth:`set_access_token`, respectively. - - Minim also stores and manages access tokens and their properties. - When an access token is acquired, it is automatically saved to the - Minim configuration file to be loaded on the next instantiation of - this class. This behavior can be disabled if there are any security - concerns, like if the computer being used is a shared device. - - Parameters - ---------- - sp_dc : `str`, optional - Spotify Web Player :code:`sp_dc` cookie. If it is not stored - as :code:`SPOTIFY_SP_DC` in the operating system's environment - variables or found in the Minim configuration file, it must be - provided here. - - access_token : `str`, keyword-only, optional - Access token. If provided here or found in the Minim - configuration file, the authorization process is bypassed. In - the former case, all other relevant keyword arguments should be - specified to automatically refresh the access token when it - expires. - - expiry : `datetime.datetime` or `str`, keyword-only, optional - Expiry time of `access_token` in the ISO 8601 format - :code:`%Y-%m-%dT%H:%M:%SZ`. If provided, the user will be - reauthenticated when `access_token` expires. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether newly obtained access tokens and their - associated properties are stored to the Minim configuration - file. - - Attributes - ---------- - LYRICS_URL : `str` - Base URL for the Spotify Lyrics service. - - TOKEN_URL : `str` - URL for the Spotify Web Player access token endpoint. - - session : `requests.Session` - Session used to send requests to the Spotify Lyrics service. - """ - - _NAME = f"{__module__}.{__qualname__}" - - LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2" - TOKEN_URL = "https://open.spotify.com/get_access_token" - - def __init__( - self, *, sp_dc: str = None, access_token: str = None, - expiry: Union[datetime.datetime, str] = None, save: bool = True - ) -> None: - - """ - Create a Spotify Lyrics service client. - """ - - self.session = requests.Session() - self.session.headers["App-Platform"] = "WebPlayer" - - if access_token is None and _config.has_section(self._NAME): - sp_dc = _config.get(self._NAME, "sp_dc") - access_token = _config.get(self._NAME, "access_token") - expiry = _config.get(self._NAME, "expiry") - - self.set_sp_dc(sp_dc, save=save) - self.set_access_token(access_token=access_token, expiry=expiry) - - def _get_json(self, url: str, **kwargs) -> dict: - - """ - Send a GET request and return the JSON-encoded content of the - response. - - Parameters - ---------- - url : `str` - URL for the GET request. - - **kwargs - Keyword arguments to pass to :meth:`requests.request`. - - Returns - ------- - resp : `dict` - JSON-encoded content of the response. - """ - - return self._request("get", url, **kwargs).json() - - def _request( - self, method: str, url: str, retry: bool = True, **kwargs - ) -> requests.Response: - - """ - Construct and send a request with status code checking. - - Parameters - ---------- - method : `str` - Method for the request. - - url : `str` - URL for the request. - - retry : `bool` - Specifies whether to retry the request if the response has - a non-2xx status code. - - **kwargs - Keyword arguments passed to :meth:`requests.request`. - - Returns - ------- - resp : `requests.Response` - Response to the request. - """ - - if self._expiry is not None and datetime.datetime.now() > self._expiry: - self.set_access_token() - - r = self.session.request(method, url, **kwargs) - if r.status_code != 200: - emsg = f"{r.status_code} {r.reason}" - if r.status_code == 401 and retry: - logging.warning(emsg) - self.set_access_token() - return self._request(method, url, False, **kwargs) - else: - raise RuntimeError(emsg) - return r - -
-[docs] - def set_sp_dc(self, sp_dc: str = None, *, save: bool = True) -> None: - - """ - Set the Spotify Web Player :code:`sp_dc` cookie. - - Parameters - ---------- - sp_dc : `str`, optional - Spotify Web Player :code:`sp_dc` cookie. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether to save the newly obtained access tokens - and their associated properties to the Minim configuration - file. - """ - - self._sp_dc = sp_dc or os.environ.get("SPOTIFY_SP_DC") - self._save = save
- - -
-[docs] - def set_access_token( - self, access_token: str = None, - expiry: Union[datetime.datetime, str] = None) -> None: - - """ - Set the Spotify Lyrics service access token. - - Parameters - ---------- - access_token : `str`, optional - Access token. If not provided, an access token is obtained - from the Spotify Web Player using the :code:`sp_dc` cookie. - - expiry : `str` or `datetime.datetime`, keyword-only, optional - Access token expiry timestamp in the ISO 8601 format - :code:`%Y-%m-%dT%H:%M:%SZ`. If provided, the user will be - reauthenticated (if `sp_dc` is found or provided) when the - `access_token` expires. - """ - - if access_token is None: - if not self._sp_dc: - raise ValueError("Missing sp_dc cookie.") - - r = requests.get( - self.TOKEN_URL, - headers={"cookie": f"sp_dc={self._sp_dc}"}, - params={"reason": "transport", "productType": "web_player"} - ).json() - if r["isAnonymous"]: - raise ValueError("Invalid sp_dc cookie.") - access_token = r["accessToken"] - expiry = datetime.datetime.fromtimestamp( - r["accessTokenExpirationTimestampMs"] / 1000 - ) - - if self._save: - _config[self._NAME] = { - "sp_dc": self._sp_dc, - "access_token": access_token, - "expiry": expiry.strftime("%Y-%m-%dT%H:%M:%SZ") - } - with open(DIR_HOME / "minim.cfg", "w") as f: - _config.write(f) - - self.session.headers["Authorization"] = f"Bearer {access_token}" - self._expiry = ( - datetime.datetime.strptime(expiry, "%Y-%m-%dT%H:%M:%SZ") - if isinstance(expiry, str) else expiry - )
- - -
-[docs] - def get_lyrics(self, track_id: str) -> dict[str, Any]: - - """ - Get lyrics for a Spotify track. - - Parameters - ---------- - track_id : `str` - The Spotify ID for the track. - - **Example**: :code:`"0VjIjW4GlUZAMYd2vXMi3b"`. - - Returns - ------- - lyrics : `dict` - Formatted or time-synced lyrics. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "lyrics": { - "syncType": <str>, - "lines": [ - { - "startTimeMs": <str>, - "words": <str>, - "syllables": [], - "endTimeMs": <str> - } - ], - "provider": <str>, - "providerLyricsId": <str>, - "providerDisplayName": <str>, - "syncLyricsUri": <str>, - "isDenseTypeface": <bool>, - "alternatives": [], - "language": <str>, - "isRtlLanguage": <bool>, - "fullscreenAction": <str>, - "showUpsell": <bool> - }, - "colors": { - "background": <int>, - "text": <int>, - "highlightText": <int> - }, - "hasVocalRemoval": <bool> - } - """ - - return self._get_json(f"{self.LYRICS_URL}/track/{track_id}", - params={"format": "json", - "market": "from_token"})
-
- - -
-[docs] -class WebAPI: - - """ - Spotify Web API client. - - The Spotify Web API enables the creation of applications that can - interact with Spotify's streaming service, such as retrieving - content metadata, getting recommendations, creating and managing - playlists, or controlling playback. - - .. important:: - - * Spotify content may not be downloaded. - * Keep visual content in its original form. - * Ensure content attribution. - - .. seealso:: - - For more information, see the `Spotify Web API Reference - <https://developer.spotify.com/documentation/web-api>`_. - - Requests to the Spotify Web API endpoints must be accompanied by a - valid access token in the header. An access token can be obtained - with or without user authentication. While authentication is not - necessary to search for and retrieve data from public content, it - is required to access personal content and control playback. - - Minim can obtain client-only access tokens via the `client - credentials <https://developer.spotify.com/documentation/general - /guides/authorization/client-credentials/>`_ flow and user access - tokens via the `authorization code <https://developer.spotify.com - /documentation/web-api/tutorials/code-flow>`_ and `authorization - code with proof key for code exchange (PKCE) - <https://developer.spotify.com/documentation/web-api/tutorials/ - code-pkce-flow>`_ flows. These OAuth 2.0 authorization flows - require valid client credentials (client ID and client secret) to - either be provided to this class's constructor as keyword arguments - or be stored as :code:`SPOTIFY_CLIENT_ID` and - :code:`SPOTIFY_CLIENT_SECRET` in the operating system's environment - variables. - - .. seealso:: - - To get client credentials, see the `guide on how to create a new - Spotify application <https://developer.spotify.com/documentation - /general/guides/authorization/app-settings/>`_. To take advantage - of Minim's automatic authorization code retrieval functionality - for the authorization code (with PKCE) flow, the redirect URI - should be in the form :code:`http://localhost:{port}/callback`, - where :code:`{port}` is an open port on :code:`localhost`. - - Alternatively, a access token can be acquired without client - credentials through the Spotify Web Player, but this approach is not - recommended and should only be used as a last resort since it is not - officially supported and can be deprecated by Spotify at any time. - The access token is client-only unless a Spotify Web Player - :code:`sp_dc` cookie is either provided to this class's constructor - as a keyword argument or be stored as :code:`SPOTIFY_SP_DC` in the - operating system's environment variables, in which case a user - access token with all authorization scopes is granted instead. - - If an existing access token is available, it and its accompanying - information (refresh token and expiry time) can be provided to this - class's constructor as keyword arguments to bypass the access token - retrieval process. It is recommended that all other - authorization-related keyword arguments be specified so that a new - access token can be obtained when the existing one expires. - - .. tip:: - - The authorization flow and access token can be changed or updated - at any time using :meth:`set_flow` and :meth:`set_access_token`, - respectively. - - Minim also stores and manages access tokens and their properties. - When any of the authorization flows above are used to acquire an - access token, it is automatically saved to the Minim configuration - file to be loaded on the next instantiation of this class. This - behavior can be disabled if there are any security concerns, like if - the computer being used is a shared device. - - Parameters - ---------- - client_id : `str`, keyword-only, optional - Client ID. Required for the authorization code and client - credentials flows. If it is not stored as - :code:`SPOTIFY_CLIENT_ID` in the operating system's environment - variables or found in the Minim configuration file, it must be - provided here. - - client_secret : `str`, keyword-only, optional - Client secret. Required for the authorization code and client - credentials flows. If it is not stored as - :code:`SPOTIFY_CLIENT_SECRET` in the operating system's - environment variables or found in the Minim configuration file, - it must be provided here. - - flow : `str`, keyword-only, default: :code:`"web_player"` - Authorization flow. - - .. container:: - - **Valid values**: - - * :code:`"authorization_code"` for the authorization code - flow. - * :code:`"pkce"` for the authorization code with proof - key for code exchange (PKCE) flow. - * :code:`"client_credentials"` for the client credentials - flow. - * :code:`"web_player"` for a Spotify Web Player access - token. - - browser : `bool`, keyword-only, default: :code:`False` - Determines whether a web browser is automatically opened for the - authorization code (with PKCE) flow. If :code:`False`, users - will have to manually open the authorization URL. Not applicable - when `web_framework="playwright"`. - - web_framework : `str`, keyword-only, optional - Determines which web framework to use for the authorization code - (with PKCE) flow. - - .. container:: - - **Valid values**: - - * :code:`"http.server"` for the built-in implementation of - HTTP servers. - * :code:`"flask"` for the Flask framework. - * :code:`"playwright"` for the Playwright framework by - Microsoft. - - port : `int` or `str`, keyword-only, default: :code:`8888` - Port on :code:`localhost` to use for the authorization code - flow with the :code:`http.server` and Flask frameworks. Only - used if `redirect_uri` is not specified. - - redirect_uri : `str`, keyword-only, optional - Redirect URI for the authorization code flow. If not on - :code:`localhost`, the automatic authorization code retrieval - functionality is not available. - - scopes : `str` or `list`, keyword-only, optional - Authorization scopes to request user access for in the - authorization code flow. - - .. seealso:: - - See :meth:`get_scopes` for the complete list of scopes. - - sp_dc : `str`, keyword-only, optional - Spotify Web Player :code:`sp_dc` cookie to send with the access - token request. If provided here, stored as :code:`SPOTIFY_SP_DC` - in the operating system's environment variables, or found in the - Minim configuration file, a user access token with all - authorization scopes is obtained instead of a client-only access - token. - - access_token : `str`, keyword-only, optional - Access token. If provided here or found in the Minim - configuration file, the authorization process is bypassed. In - the former case, all other relevant keyword arguments should be - specified to automatically refresh the access token when it - expires. - - refresh_token : `str`, keyword-only, optional - Refresh token accompanying `access_token`. If not provided, - the user will be reauthenticated using the specified - authorization flow when `access_token` expires. - - expiry : `datetime.datetime` or `str`, keyword-only, optional - Expiry time of `access_token` in the ISO 8601 format - :code:`%Y-%m-%dT%H:%M:%SZ`. If provided, the user will be - reauthenticated using `refresh_token` (if available) or the - specified authorization flow (if possible) when `access_token` - expires. - - overwrite : `bool`, keyword-only, default: :code:`False` - Determines whether to overwrite an existing access token in the - Minim configuration file. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether newly obtained access tokens and their - associated properties are stored to the Minim configuration - file. - - Attributes - ---------- - API_URL : `str` - Base URL for the Spotify Web API. - - AUTH_URL : `str` - URL for Spotify Web API authorization code requests. - - TOKEN_URL : `str` - URL for Spotify Web API access token requests. - - WEB_PLAYER_TOKEN_URL : `str` - URL for Spotify Web Player access token requests. - - session : `requests.Session` - Session used to send requests to the Spotify Web API. - """ - - _FLOWS = {"authorization_code", "pkce", "client_credentials", "web_player"} - _NAME = f"{__module__}.{__qualname__}" - - API_URL = "https://api.spotify.com/v1" - AUTH_URL = "https://accounts.spotify.com/authorize" - TOKEN_URL = "https://accounts.spotify.com/api/token" - WEB_PLAYER_TOKEN_URL = "https://open.spotify.com/get_access_token" - -
-[docs] - @classmethod - def get_scopes(self, categories: Union[str, list[str]]) -> str: - - """ - Get Spotify Web API and Open Access authorization scopes for - the specified categories. - - Parameters - ---------- - categories : `str` or `list` - Categories of authorization scopes to get. - - .. container:: - - **Valid values**: - - * :code:`"images"` for scopes related to custom images, - such as :code:`ugc-image-upload`. - * :code:`"spotify_connect"` for scopes related to Spotify - Connect, such as - - * :code:`user-read-playback-state`, - * :code:`user-modify-playback-state`, and - * :code:`user-read-currently-playing`. - - * :code:`"playback"` for scopes related to playback - control, such as :code:`app-remote-control` and - :code:`streaming`. - * :code:`"playlists"` for scopes related to playlists, - such as - - * :code:`playlist-read-private`, - * :code:`playlist-read-collaborative`, - * :code:`playlist-modify-private`, and - * :code:`playlist-modify-public`. - - * :code:`"follow"` for scopes related to followed artists - and users, such as :code:`user-follow-modify` and - :code:`user-follow-read`. - * :code:`"listening_history"` for scopes related to - playback history, such as - - * :code:`user-read-playback-position`, - * :code:`user-top-read`, and - * :code:`user-read-recently-played`. - - * :code:`"library"` for scopes related to saved content, - such as :code:`user-library-modify` and - :code:`user-library-read`. - * :code:`"users"` for scopes related to user information, - such as :code:`user-read-email` and - :code:`user-read-private`. - * :code:`"all"` for all scopes above. - * A substring to match in the possible scopes, such as - - * :code:`"read"` for all scopes above that grant read - access, i.e., scopes with :code:`read` in the name, - * :code:`"modify"` for all scopes above that grant - modify access, i.e., scopes with :code:`modify` in - the name, or - * :code:`"user"` for all scopes above that grant access - to all user-related information, i.e., scopes with - :code:`user` in the name. - - .. seealso:: - - For the endpoints that the scopes allow access to, see the - `Scopes page of the Spotify Web API Reference - <https://developer.spotify.com/documentation/web-api - /concepts/scopes>`_. - """ - - SCOPES = { - "images": ["ugc-image-upload"], - "spotify_connect": ["user-read-playback-state", - "user-modify-playback-state", - "user-read-currently-playing"], - "playback": ["app-remote-control streaming"], - "playlists": ["playlist-read-private", - "playlist-read-collaborative", - "playlist-modify-private", - "playlist-modify-public"], - "follow": ["user-follow-modify", "user-follow-read"], - "listening_history": ["user-read-playback-position", - "user-top-read", - "user-read-recently-played"], - "library": ["user-library-modify", "user-library-read"], - "users": ["user-read-email", "user-read-private"] - } - - if isinstance(categories, str): - if categories in SCOPES.keys(): - return SCOPES[categories] - if categories == "all": - return " ".join(s for scopes in SCOPES.values() - for s in scopes) - return " ".join(s for scopes in SCOPES.values() - for s in scopes if categories in s) - - return " ".join(s - for scopes in (self.get_scopes[c] for c in categories) - for s in scopes)
- - - def __init__( - self, *, client_id: str = None, client_secret: str = None, - flow: str = "web_player", browser: bool = False, - web_framework: str = None, port: Union[int, str] = 8888, - redirect_uri: str = None, scopes: Union[str, list[str]] = "", - sp_dc: str = None, access_token: str = None, - refresh_token: str = None, - expiry: Union[datetime.datetime, str] = None, - overwrite: bool = False, save: bool = True) -> None: - - """ - Create a Spotify Web API client. - """ - - self.session = requests.Session() - - if (access_token is None and _config.has_section(self._NAME) - and not overwrite): - flow = _config.get(self._NAME, "flow") - access_token = _config.get(self._NAME, "access_token") - refresh_token = _config.get(self._NAME, "refresh_token", - fallback=None) - expiry = _config.get(self._NAME, "expiry", fallback=None) - client_id = _config.get(self._NAME, "client_id") - client_secret = _config.get(self._NAME, "client_secret", - fallback=None) - redirect_uri = _config.get(self._NAME, "redirect_uri", - fallback=None) - scopes = _config.get(self._NAME, "scopes") - sp_dc = _config.get(self._NAME, "sp_dc", fallback=None) - - self.set_flow( - flow, client_id=client_id, client_secret=client_secret, - browser=browser, web_framework=web_framework, port=port, - redirect_uri=redirect_uri, scopes=scopes, sp_dc=sp_dc, save=save - ) - self.set_access_token(access_token, refresh_token=refresh_token, - expiry=expiry) - - def _check_scope(self, endpoint: str, scope: str) -> None: - - """ - Check if the user has granted the appropriate authorization - scope for the desired endpoint. - - Parameters - ---------- - endpoint : `str` - Spotify Web API endpoint. - - scope : `str` - Required scope for `endpoint`. - """ - - if scope not in self._scopes: - emsg = (f"{self._NAME}.{endpoint}() requires the '{scope}' " - "authorization scope.") - raise RuntimeError(emsg) - - def _get_authorization_code(self, code_challenge: str = None) -> str: - - """ - Get an authorization code to be exchanged for an access token in - the authorization code flow. - - Parameters - ---------- - code_challenge : `str`, optional - Code challenge for the authorization code with PKCE flow. - - Returns - ------- - auth_code : `str` - Authorization code. - """ - - params = { - "client_id": self._client_id, - "redirect_uri": self._redirect_uri, - "response_type": "code", - "state": secrets.token_urlsafe() - } - if self._scopes: - params["scope"] = self._scopes - if code_challenge is not None: - params["code_challenge"] = code_challenge - params["code_challenge_method"] = "S256" - auth_url = f"{self.AUTH_URL}?{urllib.parse.urlencode(params)}" - - if self._web_framework == "playwright": - har_file = DIR_TEMP / "minim_spotify.har" - - with sync_playwright() as playwright: - browser = playwright.firefox.launch(headless=False) - context = browser.new_context(record_har_path=har_file) - page = context.new_page() - page.goto(auth_url, timeout=0) - page.wait_for_url(f"{self._redirect_uri}*", - wait_until="commit") - context.close() - browser.close() - - with open(har_file, "r") as f: - queries = dict( - urllib.parse.parse_qsl( - urllib.parse.urlparse( - re.search(f'{self._redirect_uri}\?(.*?)"', - f.read()).group(0) - ).query - ) - ) - har_file.unlink() - - else: - if self._browser: - webbrowser.open(auth_url) - else: - print("To grant Minim access to Spotify data and " - "features, open the following link in your web " - f"browser:\n\n{auth_url}\n") - - if self._web_framework == "http.server": - httpd = HTTPServer(("", self._port), _SpotifyRedirectHandler) - httpd.handle_request() - queries = httpd.response - - elif self._web_framework == "flask": - app = Flask(__name__) - json_file = DIR_TEMP / "minim_spotify.json" - - @app.route("/callback", methods=["GET"]) - def _callback() -> str: - if "error" in request.args: - return "Access denied. You may close this page now." - with open(json_file, "w") as f: - json.dump(request.args, f) - return "Access granted. You may close this page now." - - server = Process(target=app.run, args=("0.0.0.0", self._port)) - server.start() - while not json_file.is_file(): - time.sleep(0.1) - server.terminate() - - with open(json_file, "rb") as f: - queries = json.load(f) - json_file.unlink() - - else: - uri = input("After authorizing Minim to access Spotify on " - "your behalf, copy and paste the URI beginning " - f"with '{self._redirect_uri}' below.\n\nURI: ") - queries = dict( - urllib.parse.parse_qsl(urllib.parse.urlparse(uri).query) - ) - - if "error" in queries: - raise RuntimeError(f"Authorization failed. Error: {queries['error']}") - if params["state"] != queries["state"]: - raise RuntimeError("Authorization failed due to state mismatch.") - return queries["code"] - - def _get_json(self, url: str, **kwargs) -> dict: - - """ - Send a GET request and return the JSON-encoded content of the - response. - - Parameters - ---------- - url : `str` - URL for the GET request. - - **kwargs - Keyword arguments to pass to :meth:`requests.request`. - - Returns - ------- - resp : `dict` - JSON-encoded content of the response. - """ - - return self._request("get", url, **kwargs).json() - - def _refresh_access_token(self) -> None: - - """ - Refresh the expired excess token. - """ - - if self._flow == "web_player" or not self._refresh_token \ - or not self._client_id or not self._client_secret: - self.set_access_token() - else: - client_b64 = base64.urlsafe_b64encode( - f"{self._client_id}:{self._client_secret}".encode() - ).decode() - r = requests.post( - self.TOKEN_URL, - data={ - "grant_type": "refresh_token", - "refresh_token": self._refresh_token - }, - headers={"Authorization": f"Basic {client_b64}"} - ).json() - - self.session.headers["Authorization"] = f"Bearer {r['access_token']}" - self._refresh_token = r["refresh_token"] - self._expiry = (datetime.datetime.now() - + datetime.timedelta(0, r["expires_in"])) - self._scopes = r["scope"] - - if self._save: - _config[self._NAME].update({ - "access_token": r["access_token"], - "refresh_token": self._refresh_token, - "expiry": self._expiry.strftime("%Y-%m-%dT%H:%M:%SZ"), - "scopes": self._scopes - }) - with open(DIR_HOME / "minim.cfg", "w") as f: - _config.write(f) - - def _request( - self, method: str, url: str, retry: bool = True, **kwargs - ) -> requests.Response: - - """ - Construct and send a request with status code checking. - - Parameters - ---------- - method : `str` - Method for the request. - - url : `str` - URL for the request. - - retry : `bool` - Specifies whether to retry the request if the response has - a non-2xx status code. - - **kwargs - Keyword arguments passed to :meth:`requests.request`. - - Returns - ------- - resp : `requests.Response` - Response to the request. - """ - - if self._expiry is not None and datetime.datetime.now() > self._expiry: - self._refresh_access_token() - - r = self.session.request(method, url, **kwargs) - if r.status_code not in range(200, 299): - error = r.json()["error"] - emsg = f"{error['status']} {error['message']}" - if r.status_code == 401 and retry: - logging.warning(emsg) - self._refresh_access_token() - return self._request(method, url, False, **kwargs) - else: - raise RuntimeError(emsg) - return r - -
-[docs] - def set_access_token( - self, access_token: str = None, *, refresh_token: str = None, - expiry: Union[str, datetime.datetime] = None) -> None: - - """ - Set the Spotify Web API access token. - - Parameters - ---------- - access_token : `str`, optional - Access token. If not provided, an access token is obtained - using an OAuth 2.0 authorization flow or from the Spotify - Web Player. - - refresh_token : `str`, keyword-only, optional - Refresh token accompanying `access_token`. - - expiry : `str` or `datetime.datetime`, keyword-only, optional - Access token expiry timestamp in the ISO 8601 format - :code:`%Y-%m-%dT%H:%M:%SZ`. If provided, the user will be - reauthenticated using the refresh token (if available) or - the default authorization flow (if possible) when - `access_token` expires. - """ - - if access_token is None: - if self._flow == "web_player": - headers = ({"cookie": f"sp_dc={self._sp_dc}"} if self._sp_dc - else {}) - r = requests.get(self.WEB_PLAYER_TOKEN_URL, - headers=headers).json() - self._client_id = r["clientId"] - access_token = r["accessToken"] - expiry = datetime.datetime.fromtimestamp( - r["accessTokenExpirationTimestampMs"] / 1000 - ) - if self._sp_dc and r["isAnonymous"]: - wmsg = ("The sp_dc cookie is invalid, so the " - "access token granted is client-only.") - warnings.warn(wmsg) - else: - if not self._client_id or not self._client_secret: - emsg = "Spotify Web API client credentials not provided." - raise ValueError(emsg) - - if self._flow == "client_credentials": - r = requests.post( - self.TOKEN_URL, - data={ - "client_id": self._client_id, - "client_secret": self._client_secret, - "grant_type": "client_credentials" - } - ).json() - else: - client_b64 = base64.urlsafe_b64encode( - f"{self._client_id}:{self._client_secret}".encode() - ).decode() - data = { - "grant_type": "authorization_code", - "redirect_uri": self._redirect_uri - } - if self._flow == "pkce": - data["client_id"] = self._client_id - data["code_verifier"] = secrets.token_urlsafe(96) - data["code"] = self._get_authorization_code( - base64.urlsafe_b64encode( - hashlib.sha256( - data["code_verifier"].encode() - ).digest() - ).decode().replace("=", "") - ) - else: - data["code"] = self._get_authorization_code() - r = requests.post( - self.TOKEN_URL, data=data, - headers={"Authorization": f"Basic {client_b64}"} - ).json() - refresh_token = r["refresh_token"] - access_token = r["access_token"] - expiry = (datetime.datetime.now() - + datetime.timedelta(0, r["expires_in"])) - - if self._save: - _config[self._NAME] = { - "flow": self._flow, - "client_id": self._client_id, - "access_token": access_token, - "expiry": expiry.strftime("%Y-%m-%dT%H:%M:%SZ"), - "scopes": self._scopes - } - if refresh_token: - _config[self._NAME]["refresh_token"] \ - = refresh_token - for attr in ("client_secret", "redirect_uri", "sp_dc"): - if hasattr(self, f"_{attr}"): - _config[self._NAME][attr] \ - = getattr(self, f"_{attr}") or "" - with open(DIR_HOME / "minim.cfg", "w") as f: - _config.write(f) - - self.session.headers["Authorization"] = f"Bearer {access_token}" - self._refresh_token = refresh_token - self._expiry = ( - datetime.datetime.strptime(expiry, "%Y-%m-%dT%H:%M:%SZ") - if isinstance(expiry, str) else expiry - ) - - if self._flow in {"authorization_code", "pkce"} \ - or (self._flow == "web_player" and self._sp_dc): - self._user_id = self.get_profile()["id"]
- - -
-[docs] - def set_flow( - self, flow: str, *, client_id: str = None, - client_secret: str = None, browser: bool = False, - web_framework: str = None, port: Union[int, str] = 8888, - redirect_uri: str = None, scopes: Union[str, list[str]] = "", - sp_dc: str = None, save: bool = True) -> None: - - """ - Set the authorization flow. - - Parameters - ---------- - flow : `str` - Authorization flow. - - .. container:: - - **Valid values**: - - * :code:`"authorization_code"` for the authorization code - flow. - * :code:`"pkce"` for the authorization code with proof - key for code exchange (PKCE) flow. - * :code:`"client_credentials"` for the client credentials - flow. - * :code:`"web_player"` for a Spotify Web Player access - token. - - client_id : `str`, keyword-only, optional - Client ID. Required for all OAuth 2.0 authorization flows. - - client_secret : `str`, keyword-only, optional - Client secret. Required for all OAuth 2.0 authorization - flows. - - browser : `bool`, keyword-only, default: :code:`False` - Determines whether a web browser is automatically opened for - the authorization code (with PKCE) flow. If :code:`False`, - users will have to manually open the authorization URL. - Not applicable when `web_framework="playwright"`. - - web_framework : `str`, keyword-only, optional - Web framework used to automatically complete the - authorization code (with PKCE) flow. - - .. container:: - - **Valid values**: - - * :code:`"http.server"` for the built-in implementation of - HTTP servers. - * :code:`"flask"` for the Flask framework. - * :code:`"playwright"` for the Playwright framework. - - port : `int` or `str`, keyword-only, default: :code:`8888` - Port on :code:`localhost` to use for the authorization code - flow with the :code:`http.server` and Flask frameworks. - - redirect_uri : `str`, keyword-only, optional - Redirect URI for the authorization code flow. If not - specified, an open port on :code:`localhost` will be used. - - scopes : `str` or `list`, keyword-only, optional - Authorization scopes to request access to in the - authorization code flow. - - sp_dc : `str`, keyword-only, optional - Spotify Web Player :code:`sp_dc` cookie. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether to save the newly obtained access tokens - and their associated properties to the Minim configuration - file. - """ - - if flow not in self._FLOWS: - emsg = (f"Invalid authorization flow ({flow=}). " - f"Valid values: {', '.join(self._FLOWS)}.") - raise ValueError(emsg) - self._flow = flow - self._save = save - - if flow == "web_player": - self._sp_dc = sp_dc or os.environ.get("SPOTIFY_SP_DC") - self._scopes = self.get_scopes("all") if self._sp_dc else "" - else: - self._client_id = client_id or os.environ.get("SPOTIFY_CLIENT_ID") - self._client_secret = \ - client_secret or os.environ.get("SPOTIFY_CLIENT_SECRET") - if flow in {"authorization_code", "pkce"}: - self._browser = browser - self._scopes = " ".join(scopes) if isinstance(scopes, list) \ - else scopes - - if redirect_uri: - self._redirect_uri = redirect_uri - if "localhost" in redirect_uri: - self._port = re.search("localhost:(\d+)", - redirect_uri).group(1) - elif web_framework: - wmsg = ("The redirect URI is not on localhost, " - "so automatic authorization code " - "retrieval is not available.") - logging.warning(wmsg) - web_framework = None - elif port: - self._port = port - self._redirect_uri = f"http://localhost:{port}/callback" - else: - self._port = self._redirect_uri = None - - self._web_framework = ( - web_framework - if web_framework in {None, "http.server"} - or globals()[f"FOUND_{web_framework.upper()}"] - else None - ) - if self._web_framework is None and web_framework: - wmsg = (f"The {web_framework.capitalize()} web " - "framework was not found, so automatic " - "authorization code retrieval is not " - "available.") - warnings.warn(wmsg) - - elif flow == "client_credentials": - self._scopes = ""
- - - ### ALBUMS ################################################################ - -
-[docs] - def get_album(self, id: str, *, market: str = None) -> dict: - - """ - `Albums > Get Album <https://developer.spotify.com/ - documentation/web-api/reference/get-an-album>`_: - Get Spotify catalog information for a single album. - - Parameters - ---------- - id : `str` - The Spotify ID of the album. - - **Example**: :code:`"4aawyAB9vmqN3uQ7FjRGTy"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - album : `dict` - Spotify catalog information for a single album. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "tracks": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "type": <str>, - "uri": <str> - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "preview_url": <str>, - "track_number": <int>, - "type": <str>, - "uri": <str>, - "is_local": <bool> - } - ] - } - } - """ - - return self._get_json(f"{self.API_URL}/albums/{id}", - params={"market": market})
- - -
-[docs] - def get_albums( - self, ids: Union[str, list[str]], *, market: str = None - ) -> dict[str, Any]: - - """ - `Albums > Get Several Albums <https://developer.spotify.com/ - documentation/web-api/reference/ - get-multiple-albums>`_: Get Spotify catalog information for - albums identified by their Spotify IDs. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the albums. - - **Maximum**: 20 IDs. - - **Example**: :code:`"382ObEPsp2rxGrnsizN5TX, - 1A2GTWGtFfWp7KSQTwWOyo, 2noRn2Aes5aoNVsU6iWThc"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - albums : `list` - A list containing Spotify catalog information for multiple - albums. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "tracks": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "type": <str>, - "uri": <str> - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "preview_url": <str>, - "track_number": <int>, - "type": <str>, - "uri": <str>, - "is_local": <bool> - } - ] - } - } - ] - """ - - return self._get_json( - f"{self.API_URL}/albums", - params={"ids": ids if isinstance(ids, str) else ",".join(ids), - "market": market} - )["albums"]
- - -
-[docs] - def get_album_tracks( - self, id: str, *, limit: int = None, market: str = None, - offset: int = None) -> dict[str, Any]: - - """ - `Albums > Get Album Tracks <https://developer.spotify.com/ - documentation/web-api/reference/ - get-an-albums-tracks>`_: Get Spotify catalog information for an - album's tracks. Optional parameters can be used to limit the - number of tracks returned. - - Parameters - ---------- - id : `str` - The Spotify ID of the album. - - **Example**: :code:`"4aawyAB9vmqN3uQ7FjRGTy"`. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Valid values**: `offset` must be between 0 and 1,000. - - **Default**: :code:`0`. - - Returns - ------- - tracks : `dict` - A dictionary containing Spotify catalog information for an - album's tracks and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "type": <str>, - "uri": <str> - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "preview_url": <str>, - "track_number": <int>, - "type": <str>, - "uri": <str>, - "is_local": <bool> - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/albums/{id}/tracks", - params={"limit": limit, "market": market, "offset": offset} - )
- - -
-[docs] - def get_saved_albums( - self, *, limit: int = None, market: str = None, offset: int = None - ) -> dict[str, Any]: - - """ - `Albums > Get User's Saved Albums <https://developer.spotify.com/ - documentation/web-api/reference/ - get-users-saved-albums>`_: Get a list of the albums saved in the - current Spotify user's 'Your Music' library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Valid values**: `offset` must be between 0 and 1,000. - - **Default**: :code:`0`. - - Returns - ------- - albums : `dict` - A dictionary containing Spotify catalog information for a - user's saved albums and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "added_at": <str>, - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "tracks": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "type": <str>, - "uri": <str> - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "preview_url": <str>, - "track_number": <int>, - "type": <str>, - "uri": <str>, - "is_local": <bool> - } - ] - } - } - } - ] - } - """ - - self._check_scope("get_saved_albums", "user-library-read") - - return self._get_json( - f"{self.API_URL}/me/albums", - params={"limit": limit, "market": market, "offset": offset} - )
- - -
-[docs] - def save_albums(self, ids: Union[str, list[str]]) -> None: - - """ - `Albums > Save Albums for Current User - <https://developer.spotify.com/documentation/web-api/reference/ - save-albums-user>`_: Save one or more albums to the - current user's 'Your Music' library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the albums. - - **Maximum**: 20 (`str`) or 50 (`list`) IDs. - - **Example**: :code:`"382ObEPsp2rxGrnsizN5TX, - 1A2GTWGtFfWp7KSQTwWOyo, 2noRn2Aes5aoNVsU6iWThc"`. - """ - - self._check_scope("save_albums", "user-library-modify") - - if isinstance(ids, str): - self._request("put", f"{self.API_URL}/me/albums", - params={"ids": ids}) - elif isinstance(ids, list): - self._request("put", f"{self.API_URL}/me/albums", - json={"ids": ids})
- - -
-[docs] - def remove_saved_albums(self, ids: Union[str, list[str]]) -> None: - - """ - `Albums > Remove Users' Saved Albums - <https://developer.spotify.com/documentation/web-api/reference/ - remove-albums-user>`_: Remove one or more albums - from the current user's 'Your Music' library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the albums. - - **Maximum**: 20 (`str`) or 50 (`list`) IDs. - - **Example**: :code:`"382ObEPsp2rxGrnsizN5TX, - 1A2GTWGtFfWp7KSQTwWOyo, 2noRn2Aes5aoNVsU6iWThc"`. - """ - - self._check_scope("remove_saved_albums", "user-library-modify") - - if isinstance(ids, str): - self._request("delete", f"{self.API_URL}/me/albums", - params={"ids": ids}) - elif isinstance(ids, list): - self._request("delete", f"{self.API_URL}/me/albums", - json={"ids": ids})
- - -
-[docs] - def check_saved_albums(self, ids: Union[str, list[str]]) -> list[bool]: - - """ - `Albums > Check User's Saved Albums - <https://developer.spotify.com/documentation/web-api/reference/ - check-users-saved-albums>`_: Check if one or more - albums is already saved in the current Spotify user's 'Your - Music' library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the albums. - - **Maximum**: 20 IDs. - - Returns - ------- - contains : `list` - Array of booleans specifying whether the albums are found in - the user's 'Your Library > Albums'. - - **Example**: :code:`[False, True]`. - """ - - self._check_scope("check_saved_albums", "user-library-read") - - return self._get_json( - f"{self.API_URL}/me/albums/contains", - params={"ids": ids if isinstance(ids, str) else ",".join(ids)} - )
- - -
-[docs] - def get_new_albums( - self, *, country: str = None, limit: int = None, offset: int = None - ) -> list[dict[str, Any]]: - - """ - `Albums > Get New Releases <https://developer.spotify.com/ - documentation/web-api/reference/ - get-new-releases>`_: Get a list of new album releases featured - in Spotify (shown, for example, on a Spotify player's "Browse" - tab). - - Parameters - ---------- - country : `str`, keyword-only, optional - A country: an ISO 3166-1 alpha-2 country code. Provide this - parameter if you want the list of returned items to be - relevant to a particular country. If omitted, the returned - items will be relevant to all countries. - - **Example**: :code:`"SE"`. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Valid values**: `offset` must be between 0 and 1,000. - - **Default**: :code:`0`. - - Returns - ------- - albums : `list` - A list containing Spotify catalog information for - newly-released albums. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/browse/new-releases", - params={"country": country, "limit": limit, "offset": offset} - )["albums"]
- - - ### ARTISTS ############################################################### - -
-[docs] - def get_artist(self, id: str) -> dict[str, Any]: - - """ - `Artists > Get Artist <https://developer.spotify.com/ - documentation/web-api/reference/get-an-artist>`_: - Get Spotify catalog information for a single artist identified - by their unique Spotify ID. - - Parameters - ---------- - id : `str` - The Spotify ID of the artist. - - **Example**: :code:`"0TnOYISbd1XYRBk9myaseg"`. - - Returns - ------- - artist : `dict` - Spotify catalog information for a single artist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - """ - - return self._get_json(f"{self.API_URL}/artists/{id}")
- - -
-[docs] - def get_artists( - self, ids: Union[int, str, list[Union[int, str]]] - ) -> list[dict[str, Any]]: - - """ - `Artists > Get Several Artists <https://developer.spotify.com/ - documentation/web-api/reference/ - get-multiple-artists>`_: Get Spotify catalog information for - several artists based on their Spotify IDs. - - Parameters - ---------- - ids : `str` - A (comma-separated) list of the Spotify IDs for the artists. - - **Maximum**: 50 IDs. - - **Example**: :code:`"2CIMQHirSU0MQqyYHq0eOx, - 57dN52uHvrHOxijzpIgu3E, 1vCWHaC5f2uS3yhpwWbIA6"`. - - Returns - ------- - artists : `list` - A list containing Spotify catalog information for multiple - artists. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ] - """ - - return self._get_json( - f"{self.API_URL}/artists", - params={"ids": ids if isinstance(ids, str) else ",".join(ids)} - )["artists"]
- - -
-[docs] - def get_artist_albums( - self, id: str, *, include_groups: Union[str, list[str]] = None, - limit: int = None, market: str = None, offset: int = None - ) -> list[dict[str, Any]]: - - """ - `Artist > Get Artist's Albums <https://developer.spotify.com/ - documentation/web-api/reference/ - get-an-artists-albums>`_: Get Spotify catalog information about - an artist's albums. - - Parameters - ---------- - id : `str` - The Spotify ID of the artist. - - **Example**: :code:`"0TnOYISbd1XYRBk9myaseg"`. - - include_groups : `str` or `list`, keyword-only, optional - A comma-separated list of keywords that will be used to - filter the response. If not supplied, all album types will - be returned. - - .. container:: - - **Valid values**: - - * :code:`"album"` for albums. - * :code:`"single"` for singles or promotional releases. - * :code:`"appears_on"` for albums that `artist` appears - on as a featured artist. - * :code:`"compilation"` for compilations. - - **Examples**: - - * :code:`"album,single"` for albums and singles where - `artist` is the main album artist. - * :code:`"single,appears_on"` for singles and albums that - `artist` appears on. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Default**: :code:`0`. - - Returns - ------- - albums : `list` - A list containing Spotify catalog information for the - artist's albums. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/artists/{id}/albums", - params={"include_groups": include_groups, "limit": limit, - "market": market, "offset": offset} - )
- - -
-[docs] - def get_artist_top_tracks( - self, id: str, *, market: str = "US") -> list[dict[str, Any]]: - - """ - `Artist > Get Artist's Top Tracks - <https://developer.spotify.com/documentation/web-api/reference/ - get-an-artists-top-tracks>`_: Get Spotify catalog - information about an artist's top tracks by country. - - Parameters - ---------- - id : `str` - The Spotify ID of the artist. - - **Example**: :code:`"0TnOYISbd1XYRBk9myaseg"`. - - market : `str`, keyword-only, default: :code:`"US"` - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - tracks : `list` - A list containing Spotify catalog information for the - artist's top tracks. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool>, - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - ] - """ - - return self._get_json(f"{self.API_URL}/artists/{id}/top-tracks", - params={"market": market})["tracks"]
- - - - - - ### AUDIOBOOKS ############################################################ - -
-[docs] - def get_audiobook(self, id: str, *, market: str = None) -> dict[str, Any]: - - """ - `Audiobooks > Get an Audiobook - <https://developer.spotify.com/documentation/web-api/reference/ - get-an-audiobook>`_: Get Spotify catalog information for a - single audiobook. - - .. note:: - - Audiobooks are only available for the US, UK, Ireland, New - Zealand, and Australia markets. - - Parameters - ---------- - id : `str` - The Spotify ID for the audiobook. - - **Example**: :code:`"7iHfbu1YPACw6oZPAFJtqe"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - audiobook : `dict` - Spotify catalog information for a single audiobook. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "authors": [ - { - "name": <str> - } - ], - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "edition": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "narrators": [ - { - "name": <str> - } - ], - "publisher": <str>, - "type": "audiobook", - "uri": <str>, - "total_chapters": <int>, - "chapters": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "audio_preview_url": <str>, - "available_markets": [<str>], - "chapter_number": <int>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_playable": <bool> - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - } - } - ] - } - } - """ - - return self._get_json(f"{self.API_URL}/audiobooks/{id}", - params={"market": market})
- - -
-[docs] - def get_audiobooks( - self, ids: Union[int, str, list[Union[int, str]]], *, - market: str = None) -> list[dict[str, Any]]: - - """ - `Audiobooks > Get Several Audiobooks - <https://developer.spotify.com/documentation/web-api/reference/ - get-multiple-audiobooks>`_: Get Spotify catalog - information for several audiobooks identified by their Spotify - IDs. - - .. note:: - - Audiobooks are only available for the US, UK, Ireland, New - Zealand, and Australia markets. - - Parameters - ---------- - ids : `int`, `str`, or `list` - A (comma-separated) list of the Spotify IDs for the - audiobooks. - - **Maximum**: 50 IDs. - - **Example**: :code:`"18yVqkdbdRvS24c0Ilj2ci, - 1HGw3J3NxZO1TP1BTtVhpZ, 7iHfbu1YPACw6oZPAFJtqe"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - audiobooks : `dict` or `list` - A list containing Spotify catalog information for multiple - audiobooks. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "authors": [ - { - "name": <str> - } - ], - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "edition": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "narrators": [ - { - "name": <str> - } - ], - "publisher": <str>, - "type": "audiobook", - "uri": <str>, - "total_chapters": <int>, - "chapters": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "audio_preview_url": <str>, - "available_markets": [<str>], - "chapter_number": <int>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_playable": <bool> - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - } - } - ] - } - } - ] - """ - - return self._get_json( - f"{self.API_URL}/audiobooks", - params={"ids": ids if isinstance(ids, str) else ",".join(ids), - "market": market} - )["audiobooks"]
- - -
-[docs] - def get_audiobook_chapters( - self, id: str, *, limit: int = None, market: str = None, - offset: int = None) -> dict[str, Any]: - - """ - `Audiobooks > Get Audiobook Chapters - <https://developer.spotify.com/documentation/web-api/reference/ - get-audiobook-chapters>`_: Get Spotify catalog - information about an audiobook's chapters. - - .. note:: - - Audiobooks are only available for the US, UK, Ireland, New - Zealand, and Australia markets. - - Parameters - ---------- - id : `str` - The Spotify ID for the audiobook. - - **Example**: :code:`"7iHfbu1YPACw6oZPAFJtqe"`. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Valid values**: `offset` must be between 0 and 1,000. - - **Default**: :code:`0`. - - Returns - ------- - audiobooks : `dict` - A dictionary containing Spotify catalog information for an - audiobook's chapters and the number of results returned. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "audio_preview_url": <str>, - "available_markets": [<str>], - "chapter_number": <int>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_playable": <bool> - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - } - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/audiobooks/{id}/chapters", - params={"limit": limit, "market": market, "offset": offset} - )
- - -
-[docs] - def get_saved_audiobooks( - self, *, limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - `Audiobooks > Get User's Saved Audiobooks - <https://developer.spotify.com/documentation/web-api/reference/ - get-users-saved-audiobooks>`_: Get a list of the - albums saved in the current Spotify user's audiobooks library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Default**: :code:`0`. - - Returns - ------- - audiobooks : `dict` - A dictionary containing Spotify catalog information for a - user's saved audiobooks and the number of results - returned. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "authors": [ - { - "name": <str> - } - ], - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "edition": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "narrators": [ - { - "name": <str> - } - ], - "publisher": <str>, - "type": "audiobook", - "uri": <str>, - "total_chapters": <int> - } - ] - } - """ - - self._check_scope("get_saved_audiobooks", "user-library-read") - - return self._get_json(f"{self.API_URL}/me/audiobooks", - params={"limit": limit, "offset": offset})
- - -
-[docs] - def save_audiobooks(self, ids: Union[str, list[str]]) -> None: - - """ - `Audiobooks > Save Audiobooks for Current User - <https://developer.spotify.com/documentation/web-api/reference/ - save-audiobooks-user>`_: Save one or more - audiobooks to current Spotify user's library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the - audiobooks. - - **Maximum**: 50 IDs. - - **Example**: :code:`"18yVqkdbdRvS24c0Ilj2ci, - 1HGw3J3NxZO1TP1BTtVhpZ, 7iHfbu1YPACw6oZPAFJtqe"`. - """ - - self._check_scope("save_audiobooks", "user-library-modify") - - self._request( - "put", f"{self.API_URL}/me/audiobooks", - params={"ids": f"{ids if isinstance(ids, str) else ','.join(ids)}"} - )
- - -
-[docs] - def remove_saved_audiobooks(self, ids: Union[str, list[str]]) -> None: - - """ - `Audiobooks > Remove User's Saved Audiobooks - <https://developer.spotify.com/documentation/web-api/reference/ - remove-audiobooks-user>`_: Delete one or more - audiobooks from current Spotify user's library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the - audiobooks. - - **Maximum**: 50 IDs. - - **Example**: :code:`"18yVqkdbdRvS24c0Ilj2ci, - 1HGw3J3NxZO1TP1BTtVhpZ, 7iHfbu1YPACw6oZPAFJtqe"`. - """ - - self._check_scope("remove_saved_audiobooks", "user-library-modify") - - self._request( - "delete", f"{self.API_URL}/me/audiobooks", - params={"ids": f"{ids if isinstance(ids, str) else ','.join(ids)}"} - )
- - -
-[docs] - def check_saved_audiobooks(self, ids: Union[str, list[str]]) -> list[bool]: - - """ - `Audiobooks > Check User's Saved Audiobooks - <https://developer.spotify.com/documentation/web-api/reference/ - check-users-saved-audiobooks>`_: Check if one or - more audiobooks are already saved in the current Spotify user's - library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the - audiobooks. - - **Maximum**: 50 IDs. - - **Example**: :code:`"18yVqkdbdRvS24c0Ilj2ci, - 1HGw3J3NxZO1TP1BTtVhpZ, 7iHfbu1YPACw6oZPAFJtqe"`. - - Returns - ------- - contains : `list` - Array of booleans specifying whether the audiobooks are - found in the user's saved audiobooks. - - **Example**: :code:`[False, True]`. - """ - - self._check_scope("check_saved_audiobooks", "user-library-read") - - return self._get_json( - f"{self.API_URL}/me/audiobooks/contains", - params={"ids": ids if isinstance(ids, str) else ",".join(ids)} - )
- - - ### CATEGORIES ############################################################ - -
-[docs] - def get_category( - self, category_id: str, *, country: str = None, locale: str = None - ) -> dict[str, Any]: - - """ - `Categories > Get Single Browse Category - <https://developer.spotify.com/documentation/web-api/reference/ - get-a-category>`_: Get a single category used to - tag items in Spotify (on, for example, the Spotify player's - "Browse" tab). - - Parameters - ---------- - category_id : `str` - The Spotify category ID for the category. - - **Example**: :code:`"dinner"`. - - country : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. Provide this parameter - to ensure that the category exists for a particular country. - - **Example**: :code:`"SE"`. - - locale : `str`, keyword-only, optional - The desired language, consisting of an ISO 639-1 language - code and an ISO 3166-1 alpha-2 country code, joined by an - underscore. Provide this parameter if you want the category - strings returned in a particular language. - - .. note:: - - If `locale` is not supplied, or if the specified language - is not available, the category strings returned will be - in the Spotify default language (American English). - - **Example**: :code:`"es_MX"` for "Spanish (Mexico)". - - Returns - ------- - category : `dict` - Information for a single browse category. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "icons": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "id": <str>, - "name": <str> - } - """ - - return self._get_json( - f"{self.API_URL}/browse/categories/{category_id}", - params={"country": country, "locale": locale} - )
- - -
-[docs] - def get_categories( - self, *, country: str = None, limit: int = None, - locale: str = None, offset: int = None) -> dict[str, Any]: - - """ - `Categories > Get Several Browse Categories - <https://developer.spotify.com/documentation/web-api/reference/ - get-categories>`_: Get a list of categories used to - tag items in Spotify (on, for example, the Spotify player's - "Browse" tab). - - Parameters - ---------- - country : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. Provide this parameter - to ensure that the category exists for a particular country. - - **Example**: :code:`"SE"`. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - locale : `str`, keyword-only, optional - The desired language, consisting of an ISO 639-1 language - code and an ISO 3166-1 alpha-2 country code, joined by an - underscore. Provide this parameter if you want the category - strings returned in a particular language. - - .. note:: - - If locale is not supplied, or if the specified language - is not available, the category strings returned will be - in the Spotify default language (American English). - - **Example**: :code:`"es_MX"` for "Spanish (Mexico)". - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Default**: :code:`0`. - - Returns - ------- - categories : `dict` - A dictionary containing nformation for the browse categories - and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "items": [ - { - "href": <str>, - "icons": [ - { - "height": <int>, - "url": <str>, - "width": <int> - } - ], - "id": <str>, - "name": <str> - } - ], - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int> - } - """ - - return self._get_json( - f"{self.API_URL}/browse/categories", - params={"country": country, "limit": limit, "locale": locale, - "offset": offset} - )["categories"]
- - - ### CHAPTERS ############################################################## - -
-[docs] - def get_chapter(self, id: str, *, market: str = None) -> dict[str, Any]: - - """ - `Chapters > Get a Chapter <https://developer.spotify.com/ - documentation/web-api/reference/get-a-chapter>`_: - Get Spotify catalog information for a single chapter. - - .. note:: - Chapters are only available for the US, UK, Ireland, New - Zealand, and Australia markets. - - Parameters - ---------- - id : `str` - The Spotify ID for the chapter. - - **Example**: :code:`"0D5wENdkdwbqlrHoaJ9g29"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - chapter : `dict` - Spotify catalog information for a single chapter. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "audio_preview_url": <str>, - "available_markets": [<str>], - "chapter_number": <int>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_playable": <bool> - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - }, - "audiobook": { - "authors": [ - { - "name": <str> - } - ], - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "edition": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "narrators": [ - { - "name": <str> - } - ], - "publisher": <str>, - "type": "audiobook", - "uri": <str>, - "total_chapters": <int> - } - } - """ - - return self._get_json(f"{self.API_URL}/chapters/{id}", - params={"market": market})
- - -
-[docs] - def get_chapters( - self, ids: Union[int, str, list[Union[int, str]]], *, - market: str = None) -> list[dict[str, Any]]: - - """ - `Chapters > Get Several Chapters <https://developer.spotify.com/ - documentation/web-api/reference/ - get-several-chapters>`_: Get Spotify catalog information for - several chapters identified by their Spotify IDs. - - .. note:: - Chapters are only available for the US, UK, Ireland, New - Zealand, and Australia markets. - - Parameters - ---------- - ids : `int`, `str`, or `list` - A (comma-separated) list of the Spotify IDs for the - chapters. - - **Maximum**: 50 IDs. - - **Example**: :code:`"0IsXVP0JmcB2adSE338GkK, - 3ZXb8FKZGU0EHALYX6uCzU, 0D5wENdkdwbqlrHoaJ9g29"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - chapters : `list` - A list containing Spotify catalog information for multiple - chapters. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "audio_preview_url": <str>, - "available_markets": [<str>], - "chapter_number": <int>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_playable": <bool> - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - }, - "audiobook": { - "authors": [ - { - "name": <str> - } - ], - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "edition": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "narrators": [ - { - "name": <str> - } - ], - "publisher": <str>, - "type": "audiobook", - "uri": <str>, - "total_chapters": <int> - } - } - ] - """ - - return self._get_json( - f"{self.API_URL}/chapters", - params={"ids": ids if isinstance(ids, str) else ",".join(ids), - "market": market} - )["chapters"]
- - - ### EPISODES ############################################################## - -
-[docs] - def get_episode(self, id: str, *, market: str = None) -> dict[str, Any]: - - """ - `Episodes > Get Episode <https://developer.spotify.com/ - documentation/web-api/reference/ - get-an-episode>`_: Get Spotify catalog information for a single - episode identified by its unique Spotify ID. - - Parameters - ---------- - id : `str` - The Spotify ID for the episode. - - **Example**: :code:`"512ojhOuo1ktJprKbVcKyQ"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - episode : `dict` - Spotify catalog information for a single episode. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "audio_preview_url": <str>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "is_playable": <bool> - "language": <str>, - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - }, - "show": { - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "publisher": <str>, - "type": "show", - "uri": <str>, - "total_episodes": <int> - } - } - """ - - return self._get_json(f"{self.API_URL}/episodes/{id}", - params={"market": market})
- - -
-[docs] - def get_episodes( - self, ids: Union[int, str, list[Union[int, str]]], *, - market: str = None) -> list[dict[str, Any]]: - - """ - `Episodes > Get Several Episodes - <https://developer.spotify.com/documentation/web-api/reference/ - get-multiple-episodes>`_: Get Spotify catalog - information for several episodes based on their Spotify IDs. - - Parameters - ---------- - ids : `int`, `str`, or `list` - A (comma-separated) list of the Spotify IDs for the episodes. - - **Maximum**: 50 IDs. - - **Example**: - :code:`"77o6BIVlYM3msb4MMIL1jH,0Q86acNRm6V9GYx55SXKwf"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - episodes : `list` - A list containing Spotify catalog information for multiple - episodes. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - [ - { - "audio_preview_url": <str>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "is_playable": <bool> - "language": <str>, - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - }, - "show": { - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "publisher": <str>, - "type": "show", - "uri": <str>, - "total_episodes": <int> - } - } - ] - """ - - return self._get_json( - f"{self.API_URL}/episodes", - params={"ids": ids if isinstance(ids, str) else ",".join(ids), - "market": market} - )["episodes"]
- - -
-[docs] - def get_saved_episodes( - self, *, limit: int = None, market: str = None, offset: int = None - ) -> dict[str, Any]: - - """ - `Episodes > Get User's Saved Episodes - <https://developer.spotify.com/documentation/web-api/reference/ - get-users-saved-episodes>`_: Get a list of the - episodes saved in the current Spotify user's library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Default**: :code:`0`. - - Returns - ------- - episodes : `dict` - A dictionary containing Spotify catalog information for a - user's saved episodes and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "added_at": <str>, - "episode": { - "audio_preview_url": <str>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "is_playable": <bool> - "language": <str>, - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - }, - "show": { - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "publisher": <str>, - "type": "show", - "uri": <str>, - "total_episodes": <int> - } - } - } - ] - } - """ - - self._check_scope("get_saved_episodes", "user-library-read") - - return self._get_json( - f"{self.API_URL}/me/episodes", - params={"limit": limit, "market": market, "offset": offset} - )
- - -
-[docs] - def save_episodes(self, ids: Union[str, list[str]]) -> None: - - """ - `Episodes > Save Episodes for Current User - <https://developer.spotify.com/documentation/web-api/reference/ - save-episodes-user>`_: Save one or more episodes to - the current user's library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the shows. - - **Maximum**: 50 IDs. - - **Example**: - :code:`"77o6BIVlYM3msb4MMIL1jH,0Q86acNRm6V9GYx55SXKwf"`. - """ - - self._check_scope("save_episodes", "user-library-modify") - - if isinstance(ids, str): - self._request("put", f"{self.API_URL}/me/episodes", - params={"ids": ids}) - elif isinstance(ids, list): - self._request("put", f"{self.API_URL}/me/episodes", - json={"ids": ids})
- - -
-[docs] - def remove_saved_episodes(self, ids: Union[str, list[str]]) -> None: - - """ - `Episodes > Remove User's Saved Episodes - <https://developer.spotify.com/documentation/web-api/reference/ - remove-episodes-user>`_: Remove one or more - episodes from the current user's library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the episodes. - - **Maximum**: 50 IDs. - - **Example**: - :code:`"77o6BIVlYM3msb4MMIL1jH,0Q86acNRm6V9GYx55SXKwf"`. - """ - - self._check_scope("remove_saved_episodes", "user-library-modify") - - if isinstance(ids, str): - self._request("delete", f"{self.API_URL}/me/episodes", - params={"ids": ids}) - elif isinstance(ids, list): - self._request("delete", f"{self.API_URL}/me/episodes", - json={"ids": ids})
- - -
-[docs] - def check_saved_episodes(self, ids: Union[str, list[str]]) -> list[bool]: - - """ - `Episodes > Check User's Saved Episodes - <https://developer.spotify.com/documentation/web-api/reference/ - check-users-saved-episodes>`_: Check if one or more - episodes is already saved in the current Spotify user's 'Your - Episodes' library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the - episodes. - - **Maximum**: 50 IDs. - - **Example**: - :code:`"77o6BIVlYM3msb4MMIL1jH,0Q86acNRm6V9GYx55SXKwf"`. - - Returns - ------- - contains : `list` - Array of booleans specifying whether the episodes are found - in the user's 'Liked Songs'. - - **Example**: :code:`[False, True]`. - """ - - self._check_scope("check_saved_episodes", "user-library-read") - - return self._get_json( - f"{self.API_URL}/me/episodes/contains", - params={"ids": ids if isinstance(ids, str) else ",".join(ids)} - )
- - - ### GENRES ################################################################ - -
-[docs] - def get_genre_seeds(self) -> list[str]: - - """ - `Genres > Get Available Genre Seeds - <https://developer.spotify.com/documentation/web-api/reference/ - get-recommendation-genres>`_: Retrieve a list of - available genres seed parameter values for use in - :meth:`get_recommendations`. - - Returns - ------- - genres : `list` - Array of genres. - - **Example**: :code:`["acoustic", "afrobeat", ...]`. - """ - - return self._get_json( - f"{self.API_URL}/recommendations/available-genre-seeds" - )["genres"]
- - - ### MARKETS ############################################################### - -
-[docs] - def get_markets(self) -> list[str]: - - """ - `Markets > Get Available Markets <https://developer.spotify.com/ - documentation/web-api/reference/ - get-available-markets>`_: Get the list of markets where Spotify - is available. - - Returns - ------- - markets : `list` - Array of country codes. - - **Example**: :code:`["CA", "BR", "IT"]`. - """ - - return self._get_json(f"{self.API_URL}/markets")["markets"]
- - - ### PLAYER ################################################################ - -
-[docs] - def get_playback_state( - self, *, market: str = None, additional_types: str = None - ) -> dict[str, Any]: - - """ - `Player > Get Playback State <https://developer.spotify.com/ - documentation/web-api/reference/ - get-information-about-the-users-current-playback>`_: Get - information about the user's current playback state, including - track or episode, progress, and active device. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-read-playback-state` scope. - - Parameters - ---------- - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - additional_types : `str`, keyword-only, optional - A comma-separated list of item types that your client - supports besides the default track type. - - .. note:: - - This parameter was introduced to allow existing clients - to maintain their current behavior and might be - deprecated in the future. - - **Valid**: :code:`"track"` and :code:`"episode"`. - - Returns - ------- - state : `dict` - Information about playback state. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "device": { - "id": <str>, - "is_active": <bool>, - "is_private_session": <bool>, - "is_restricted": <bool>, - "name": <str>, - "type": <str>, - "volume_percent": <int> - }, - "repeat_state": <str>, - "shuffle_state": <bool>, - "context": { - "type": <str>, - "href": <str>, - "external_urls": { - "spotify": <str> - }, - "uri": <str> - }, - "timestamp": <int>, - "progress_ms": <int>, - "is_playing": <bool>, - "item": { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - }, - "currently_playing_type": <str>, - "actions": { - "interrupting_playback": <bool>, - "pausing": <bool>, - "resuming": <bool>, - "seeking": <bool>, - "skipping_next": <bool>, - "skipping_prev": <bool>, - "toggling_repeat_context": <bool>, - "toggling_shuffle": <bool>, - "toggling_repeat_track": <bool>, - "transferring_playback": <bool> - } - } - """ - - self._check_scope("get_playback_state", "user-read-playback-state") - - return self._get_json(f"{self.API_URL}/me/player", - params={"market": market, - "additional_types": additional_types})
- - -
-[docs] - def transfer_playback( - self, device_ids: Union[str, list[str]], *, play: bool = None - ) -> None: - - """ - `Player > Transfer Playback <https://developer.spotify.com/ - documentation/web-api/reference/transfer-a-users-playback>`_: - Transfer playback to a new device and determine if it should - start playing. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - device_ids : `str` or `list` - The ID of the device on which playback should be started or - transferred. - - .. note:: - Although an array is accepted, only a single device ID is - currently supported. Supplying more than one will return - :code:`400 Bad Request`. - - **Example**: :code:`["74ASZWbe4lXaubB36ztrGX"]`. - - play : `bool` - If :code:`True`, playback happens on the new device; if - :code:`False` or not provided, the current playback state is - kept. - """ - - self._check_scope("transfer_playback", "user-modify-playback-state") - - json = {"device_ids": [device_ids] if isinstance(device_ids, str) - else device_ids} - if play is not None: - json["play"] = play - self._request("put", f"{self.API_URL}/me/player", json=json)
- - -
-[docs] - def get_devices(self) -> list[dict[str, Any]]: - - """ - `Player > Get Available Devices <https://developer.spotify.com/ - documentation/web-api/reference/ - get-a-users-available-devices>`_: Get information about a user's - available devices. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-read-playback-state` scope. - - Returns - ------- - devices : `list` - A list containing information about the available devices. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "devices": [ - { - "id": <str>, - "is_active": <bool>, - "is_private_session": <bool>, - "is_restricted": <bool>, - "name": <str>, - "type": <str>, - "volume_percent": <int> - } - ] - } - ] - """ - - self._check_scope("get_available_devices", "user-read-playback-state") - - self._get_json(f"{self.API_URL}/me/player/devices")
- - -
-[docs] - def get_currently_playing( - self, *, market: str = None, additional_types: str = None - ) -> dict[str, Any]: - - """ - `Player > Get Currently Playing Track - <https://developer.spotify.com/documentation/web-api/reference/ - get-the-users-currently-playing-track>`_: Get the object - currently being played on the user's Spotify account. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-read-currently-playing` scope. - - Parameters - ---------- - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - additional_types : `str`, keyword-only, optional - A comma-separated list of item types that your client - supports besides the default track type. - - .. note:: - - This parameter was introduced to allow existing clients - to maintain their current behavior and might be - deprecated in the future. - - **Valid**: :code:`"track"` and :code:`"episode"`. - - Returns - ------- - item : `dict` - Information about the object currently being played. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "device": { - "id": <str>, - "is_active": <bool>, - "is_private_session": <bool>, - "is_restricted": <bool>, - "name": <str>, - "type": <str>, - "volume_percent": <int> - }, - "repeat_state": <str>, - "shuffle_state": <bool>, - "context": { - "type": <str>, - "href": <str>, - "external_urls": { - "spotify": <str> - }, - "uri": <str> - }, - "timestamp": <int>, - "progress_ms": <int>, - "is_playing": <bool>, - "item": { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - }, - "currently_playing_type": <str>, - "actions": { - "interrupting_playback": <bool>, - "pausing": <bool>, - "resuming": <bool>, - "seeking": <bool>, - "skipping_next": <bool>, - "skipping_prev": <bool>, - "toggling_repeat_context": <bool>, - "toggling_shuffle": <bool>, - "toggling_repeat_track": <bool>, - "transferring_playback": <bool> - } - } - """ - - self._check_scope("get_currently_playing_item", - "user-read-currently-playing") - - self._get_json(f"{self.API_URL}/me/player/currently-playing", - params={"market": market, - "additional_types": additional_types})
- - -
-[docs] - def start_playback( - self, *, device_id: str = None, context_uri: str = None, - uris: list[str] = None, offset: dict[str, Any], - position_ms: int = None) -> None: - - """ - `Player > Start/Resume Playback <https://developer.spotify.com/ - documentation/web-api/reference/start-a-users-playback>`_: Start - a new context or resume current playback on the user's active - device. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - device_id : `str`, keyword-only, optional - The ID of the device this method is targeting. If not - supplied, the user's currently active device is the target. - - **Example**: - :code:`"0d1841b0976bae2a3a310dd74c0f3df354899bc8"`. - - context_uri : `str`, keyword-only, optional - Spotify URI of the context to play. Only album, artist, and - playlist contexts are valid. - - **Example**: :code:`"spotify:album:1Je1IMUlBXcx1Fz0WE7oPT"`. - - uris : `list`, keyword-only, optional - A JSON array of the Spotify track URIs to play. - - **Example**: :code:`["spotify:track:4iV5W9uYEdYUVa79Axb7Rh", - "spotify:track:1301WleyT98MSxVHPZCA6M"]`. - - offset : `dict`, keyword-only, optional - Indicates from where in the context playback should start. - Only available when `context_uri` corresponds to an album or - a playlist. - - .. container:: - - **Valid values**: - - * The value corresponding to the :code:`"position"` key - is zero-based and can't be negative. - * The value corresponding to the :code:`"uri"` key is a - string representing the URI of the item to start at. - - **Examples**: - - * :code:`{"position": 5}` to start playback at the sixth - item of the collection specified in `context_uri`. - * :code:`{"uri": <str>}` - to start playback at the item designated by the URI. - - position_ms : `int`, keyword-only, optional - The position in milliseconds to seek to. Passing in a - position that is greater than the length of the track will - cause the player to start playing the next song. - - **Valid values**: `position_ms` must be a positive number. - """ - - self._check_scope("start_playback", "user-modify-playback-state") - - json = {} - if context_uri is not None: - json["context_uri"] = context_uri - if uris is not None: - json["uris"] = uris - if offset is not None: - json["offset"] = offset - if position_ms is not None: - json["position_ms"] = position_ms - - self._request("put", f"{self.API_URL}/me/player/play", - params={"device_id": device_id}, json=json)
- - -
-[docs] - def pause_playback(self, *, device_id: str = None) -> None: - - """ - `Player > Pause Playback <https://developer.spotify.com/ - documentation/web-api/reference/pause-a-users-playback>`_: Pause - playback on the user's account. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - device_id : `str`, keyword-only, optional - The ID of the device this method is targeting. If not - supplied, the user's currently active device is the target. - - **Example**: - :code:`"0d1841b0976bae2a3a310dd74c0f3df354899bc8"`. - """ - - self._check_scope("pause_playback", "user-modify-playback-state") - - self._request("put", f"{self.API_URL}/me/player/pause", - params={"device_id": device_id})
- - -
-[docs] - def skip_to_next(self, *, device_id: str = None) -> None: - - """ - `Player > Skip To Next <https://developer.spotify.com/ - documentation/web-api/reference/ - skip-users-playback-to-next-track>`_: Skips to next track in the - user's queue. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - device_id : `str`, keyword-only, optional - The ID of the device this method is targeting. If not - supplied, the user's currently active device is the target. - - **Example**: - :code:`"0d1841b0976bae2a3a310dd74c0f3df354899bc8"`. - """ - - self._check_scope("skip_to_next", "user-modify-playback-state") - - self._request("post", f"{self.API_URL}/me/player/next", - params={"device_id": device_id})
- - -
-[docs] - def skip_to_previous(self, *, device_id: str = None) -> None: - - """ - `Player > Skip To Previous <https://developer.spotify.com/ - documentation/web-api/reference/ - skip-users-playback-to-previous-track>`_: Skips to previous - track in the user's queue. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - device_id : `str`, keyword-only, optional - The ID of the device this method is targeting. If not - supplied, the user's currently active device is the target. - - **Example**: - :code:`"0d1841b0976bae2a3a310dd74c0f3df354899bc8"`. - """ - - self._check_scope("skip_to_previous", "user-modify-playback-state") - - self._request("post", f"{self.API_URL}/me/player/previous", - params={"device_id": device_id})
- - -
-[docs] - def seek_to_position( - self, position_ms: int, *, device_id: str = None) -> None: - - """ - `Player > Seek To Position <https://developer.spotify.com/ - documentation/web-api/reference/ - seek-to-position-in-currently-playing-track>`_: Seeks to the - given position in the user's currently playing track. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - position_ms : `int` - The position in milliseconds to seek to. Passing in a - position that is greater than the length of the track will - cause the player to start playing the next song. - - **Valid values**: `position_ms` must be a positive number. - - **Example**: :code:`25000`. - - device_id : `str`, keyword-only, optional - The ID of the device this method is targeting. If not - supplied, the user's currently active device is the target. - - **Example**: - :code:`"0d1841b0976bae2a3a310dd74c0f3df354899bc8"`. - """ - - self._check_scope("seek_to_position", "user-modify-playback-state") - - self._request("put", f"{self.API_URL}/me/player/seek", - params={"position_ms": position_ms, - "device_id": device_id})
- - -
-[docs] - def set_repeat_mode(self, state: str, *, device_id: str = None) -> None: - - """ - `Player > Set Repeat Mode <https://developer.spotify.com/ - documentation/web-api/reference/ - set-repeat-mode-on-users-playback>`_: Set the repeat mode for - the user's playback. Options are repeat-track, repeat-context, - and off. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - state : `str` - Repeat mode. - - .. container:: - - **Valid values**: - - * :code:`"track"` will repeat the current track. - * :code:`"context"` will repeat the current context. - * :code:`"off"` will turn repeat off. - - device_id : `str`, keyword-only, optional - The ID of the device this method is targeting. If not - supplied, the user's currently active device is the target. - - **Example**: - :code:`"0d1841b0976bae2a3a310dd74c0f3df354899bc8"`. - """ - - self._check_scope("set_repeat_mode", "user-modify-playback-state") - - self._request("put", f"{self.API_URL}/me/player/repeat", - params={"state": state, "device_id": device_id})
- - -
-[docs] - def set_playback_volume( - self, volume_percent: int, *, device_id: str = None) -> None: - - """ - `Player > Set Playback Volume <https://developer.spotify.com/ - documentation/web-api/reference/ - set-volume-for-users-playback>`_: Set the volume for the user's - current playback device. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - volume_percent : `int` - The volume to set. - - **Valid values**: `volume_percent` must be a value from 0 to - 100, inclusive. - - **Example**: :code:`50`. - - device_id : `str`, keyword-only, optional - The ID of the device this method is targeting. If not - supplied, the user's currently active device is the target. - - **Example**: - :code:`"0d1841b0976bae2a3a310dd74c0f3df354899bc8"`. - """ - - self._check_scope("set_playback_volume", "user-modify-playback-state") - - self._request("put", f"{self.API_URL}/me/player/volume", - params={"volume_percent": volume_percent, - "device_id": device_id})
- - -
-[docs] - def toggle_playback_shuffle( - self, state: bool, *, device_id: str = None) -> None: - - """ - `Player > Toggle Playback Shuffle - <https://developer.spotify.com/documentation/web-api/reference/ - toggle-shuffle-for-users-playback>`_: Toggle shuffle on or off - for user's playback. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - state : `bool` - Shuffle mode. If :code:`True`, shuffle the user's playback. - - device_id : `str`, keyword-only, optional - The ID of the device this method is targeting. If not - supplied, the user's currently active device is the target. - - **Example**: - :code:`"0d1841b0976bae2a3a310dd74c0f3df354899bc8"`. - """ - - self._check_scope("toggle_playback_shuffle", - "user-modify-playback-state") - - self._request("put", f"{self.API_URL}/me/player/shuffle", - params={"state": state, "device_id": device_id})
- - -
-[docs] - def get_recently_played( - self, *, limit: int = None, after: int = None, before: int = None - ) -> dict[str, Any]: - - """ - `Player > Get Recently Played Tracks - <https://developer.spotify.com/documentation/web-api/reference/ - get-recently-played>`_: Get tracks from the current user's - recently played tracks. - - .. note:: - - Currently doesn't support podcast episodes. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-read-recently-played` scope. - - Parameters - ---------- - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - after : `int`, keyword-only, optional - A Unix timestamp in milliseconds. Returns all items after - (but not including) this cursor position. If `after` is - specified, `before` must not be specified. - - **Example**: :code:`1484811043508`. - - before : `int`, keyword-only, optional - A Unix timestamp in milliseconds. Returns all items before - (but not including) this cursor position. If `before` is - specified, `after` must not be specified. - - Returns - ------- - tracks : `dict` - A dictionary containing Spotify catalog information for - the recently played tracks and the number of results - returned. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "cursors": { - "after": <str>, - "before": <str> - }, - "total": <int>, - "items": [ - { - "track": { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - }, - "played_at": <str>, - "context": { - "type": <str>, - "href": <str>, - "external_urls": { - "spotify": <str> - }, - "uri": <str> - } - } - ] - } - """ - - self._check_scope("get_recently_played_tracks", - "user-read-recently-played") - - return self._get_json(f"{self.API_URL}/me/player/recently-played", - params={"limit": limit, "after": after, - "before": before})
- - -
-[docs] - def get_queue(self) -> dict[str, Any]: - - """ - `Player > Get the User's Queue <https://developer.spotify.com/ - documentation/web-api/reference/get-queue>`_: Get the list of - objects that make up the user's queue. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-read-playback-state` scope. - - Returns - ------- - queue : `dict` - Information about the user's queue, such as the currently - playing item and items in the queue. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "currently_playing": { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - }, - "queue": [ - { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - ] - } - """ - - self._check_scope("get_user_queue", "user-read-playback-state") - - return self._get_json(f"{self.API_URL}/me/player/queue")
- - -
-[docs] - def add_to_queue(self, uri: str, *, device_id: str = None) -> None: - - """ - `Player > Add Item to Playback Queue - <https://developer.spotify.com/documentation/web-api/reference/ - add-to-queue>`_: Add an item to the end of the user's current - playback queue. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-modify-playback-state` scope. - - Parameters - ---------- - uri : `str` - The URI of the item to add to the queue. Must be a track or - an episode URL. - - device_id : `str`, keyword-only, optional - The ID of the device this method is targeting. If not - supplied, the user's currently active device is the target. - - **Example**: - :code:`"0d1841b0976bae2a3a310dd74c0f3df354899bc8"`. - """ - - self._check_scope("add_queue_item", "user-modify-playback-state") - - self._request("post", f"{self.API_URL}/me/player/queue", - params={"uri": uri, "device_id": device_id})
- - - ### PLAYLISTS ############################################################# - -
-[docs] - def get_playlist( - self, playlist_id: str, *, - additional_types: Union[str, list[str]] = None, - fields: str = None, market: str = None) -> dict[str, Any]: - - """ - `Playlists > Get Playlist <https://developer.spotify.com/ - documentation/web-api/reference/get-playlist>`_: - Get a playlist owned by a Spotify user. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"`. - - additional_types : `str` or `list`, keyword-only, optional - A (comma-separated) list of item types besides the default - track type. - - .. note:: - - This parameter was introduced to allow existing clients - to maintain their current behavior and might be - deprecated in the future. - - **Valid values**: :code:`"track"` and :code:`"episode"`. - - fields : `str` or `list`, keyword-only, optional - Filters for the query: a (comma-separated) list of the - fields to return. If omitted, all fields are returned. - A dot separator can be used to specify non-reoccurring - fields, while parentheses can be used to specify reoccurring - fields within objects. Use multiple parentheses to drill - down into nested objects. Fields can be excluded by - prefixing them with an exclamation mark. - - .. container:: - - **Examples**: - - * :code:`"description,uri"` to get just the playlist's - description and URI, - * :code:`"tracks.items(added_at,added_by.id)"` to get just - the added date and user ID of the adder, - * :code:`"tracks.items(track(name,href,album(name,href)))"` - to drill down into the album, and - * :code:`"tracks.items(track(name,href,album(!name,href)))"` - to exclude the album name. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - playlist : `dict` - Spotify catalog information for a single playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "collaborative": <bool>, - "description": <str>, - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "owner": { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "type": "user", - "uri": <str>, - "display_name": <str> - }, - "public": <bool>, - "snapshot_id": <str>, - "tracks": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "added_at": <str>, - "added_by": { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "type": "user", - "uri": <str> - }, - "is_local": <bool>, - "track": { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - } - ] - }, - "type": <str>, - "uri": <str> - } - """ - - return self._get_json( - f"{self.API_URL}/playlists/{playlist_id}", - params={ - "additional_types": additional_types - if additional_types is None - or isinstance(additional_types, str) - else ",".join(additional_types), - "fields": fields if fields is None or isinstance(fields, str) - else ",".join(fields), - "market": market - } - )
- - -
-[docs] - def change_playlist_details( - self, playlist_id: str, *, name: str = None, public: bool = None, - collaborative: bool = None, description: str = None) -> None: - - """ - `Playlists > Change Playlist Details - <https://developer.spotify.com/documentation/web-api/reference/ - change-playlist-details>`_: Change a playlist's - name and public/private state. (The user must, of course, own - the playlist.) - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-modify-public` or the - :code:`playlist-modify-private` scope. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"`. - - name : `str`, keyword-only, optional - The new name for the playlist. - - **Example**: :code:`"My New Playlist Title"`. - - public : `bool`, keyword-only, optional - If :code:`True`, the playlist will be public. If - :code:`False`, it will be private. - - collaborative : `bool`, keyword-only, optional - If :code:`True`, the playlist will become collaborative and - other users will be able to modify the playlist in their - Spotify client. - - .. note:: - - You can only set :code:`collaborative=True` on non-public - playlists. - - description : `str`, keyword-only, optional - Value for playlist description as displayed in Spotify - clients and in the Web API. - """ - - self._check_scope("change_playlist_details", - "playlist-modify-" + - ("public" if self.get_playlist(playlist_id)["public"] - else "private")) - - json = {} - if name is not None: - json["name"] = name - if public is not None: - json["public"] = public - if collaborative is not None: - json["collaborative"] = collaborative - if description is not None: - json["description"] = description - self._request("put", f"{self.API_URL}/playlists/{playlist_id}", - json=json)
- - -
-[docs] - def get_playlist_items( - self, playlist_id: str, *, - additional_types: Union[str, list[str]] = None, - fields: str = None, limit: int = None, market: str = None, - offset: int = None) -> dict[str, Any]: - - """ - `Playlists > Get Playlist Items <https://developer.spotify.com/ - documentation/web-api/reference/ - get-playlists-tracks>`_: Get full details of the items of a - playlist owned by a Spotify user. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-modify-private` scope. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"`. - - additional_types : `str` or `list`, keyword-only, optional - A (comma-separated) list of item types besides the default - track type. - - .. note:: - - This parameter was introduced to allow existing clients - to maintain their current behavior and might be - deprecated in the future. - - **Valid values**: :code:`"track"` and :code:`"episode"`. - - fields : `str` or `list`, keyword-only, optional - Filters for the query: a (comma-separated) list of the - fields to return. If omitted, all fields are returned. - A dot separator can be used to specify non-reoccurring - fields, while parentheses can be used to specify reoccurring - fields within objects. Use multiple parentheses to drill - down into nested objects. Fields can be excluded by - prefixing them with an exclamation mark. - - .. container:: - - **Examples**: - - * :code:`"description,uri"` to get just the playlist's - description and URI, - * :code:`"tracks.items(added_at,added_by.id)"` to get just - the added date and user ID of the adder, - * :code:`"tracks.items(track(name,href,album(name,href)))"` - to drill down into the album, and - * :code:`"tracks.items(track(name,href,album(!name,href)))"` - to exclude the album name. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Default**: :code:`0`. - - Returns - ------- - items : `dict` - A dictionary containing Spotify catalog information for the - playlist items and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "added_at": <str>, - "added_by": { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "type": "user", - "uri": <str> - }, - "is_local": <bool>, - "track": { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - } - ] - } - """ - - self._check_scope("get_playlist_item", "playlist-modify-private") - - return self._get_json( - f"{self.API_URL}/playlists/{playlist_id}/tracks", - params={ - "additional_types": additional_types - if additional_types is None - or isinstance(additional_types, str) - else ",".join(additional_types), - "fields": fields if fields is None or isinstance(fields, str) - else ",".join(fields), - "limit": limit, - "market": market, - "offset": offset - } - )
- - -
-[docs] - def add_playlist_items( - self, playlist_id: str, uris: Union[str, list[str]], *, - position: int = None) -> str: - - """ - `Playlists > Add Items to Playlist - <https://developer.spotify.com/documentation/web-api/reference/ - add-tracks-to-playlist>`_: Add one or more items to - a user's playlist. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-modify-public` or the - :code:`playlist-modify-private` scope. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"`. - - uris : `str` or `list`, keyword-only, optional - A (comma-separated) list of Spotify URIs to add; can be - track or episode URIs. A maximum of 100 items can be added - in one request. - - .. note:: - - It is likely that passing a large number of item URIs as - a query parameter will exceed the maximum length of the - request URI. When adding a large number of items, it is - recommended to pass them in the request body (as a - `list`). - - **Example**: :code:`"spotify:track:4iV5W9uYEdYUVa79Axb7Rh, - spotify:track:1301WleyT98MSxVHPZCA6M, - spotify:episode:512ojhOuo1ktJprKbVcKyQ"`. - - position : `int`, keyword-only, optional - The position to insert the items, a zero-based index. If - omitted, the items will be appended to the playlist. Items - are added in the order they are listed in the query string - or request body. - - .. container:: - - **Examples**: - - * :code:`0` to insert the items in the first position. - * :code:`2` to insert the items in the third position. - - Returns - ------- - snapshot_id : `str` - The updated playlist's snapshot ID. - """ - - self._check_scope("add_playlist_details", - "playlist-modify-" + - ("public" if self.get_playlist(playlist_id)["public"] - else "private")) - - if isinstance(uris, str): - url = f"{self.API_URL}/playlists/{playlist_id}/tracks?{uris=}" - if position is not None: - url += f"{position=}" - return self._request("post", url).json()["snapshot_id"] - - elif isinstance(uris, list): - json = {"uris": uris} - if position is not None: - json["position"] = position - self._request("post", - f"{self.API_URL}/playlists/{playlist_id}/tracks", - json=json).json()["snapshot_id"]
- - -
-[docs] - def update_playlist_items( - self, playlist_id: str, *, uris: Union[str, list[str]] = None, - range_start: int = None, insert_before: int = None, - range_length: int = 1, snapshot_id: str = None) -> str: - - """ - `Playlists > Update Playlist Items - <https://developer.spotify.com/documentation/web-api/reference/ - reorder-or-replace-playlists-tracks>`_: Either reorder or - replace items in a playlist depending on the request's - parameters. - - To reorder items, include `range_start`, `insert_before`, - `range_length`, and `snapshot_id` as keyword arguments. To - replace items, include `uris` as a keyword argument. Replacing - items in a playlist will overwrite its existing items. This - operation can be used for replacing or clearing items in a - playlist. - - .. note:: - - Replace and reorder are mutually exclusive operations which - share the same endpoint, but have different parameters. These - operations can't be applied together in a single request. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-modify-public` or the - :code:`playlist-modify-private` scope. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"`. - - uris : `str` or `list`, keyword-only, optional - A (comma-separated) list of Spotify URIs to add; can be - track or episode URIs. A maximum of 100 items can be added - in one request. - - .. note:: - - It is likely that passing a large number of item URIs as - a query parameter will exceed the maximum length of the - request URI. When adding a large number of items, it is - recommended to pass them in the request body (as a - `list`). - - **Example**: :code:`"spotify:track:4iV5W9uYEdYUVa79Axb7Rh, - spotify:track:1301WleyT98MSxVHPZCA6M, - spotify:episode:512ojhOuo1ktJprKbVcKyQ"`. - - range_start : `int`, keyword-only, optional - The position of the first item to be reordered. - - insert_before : `int`, keyword-only, optional - The position where the items should be inserted. To reorder - the items to the end of the playlist, simply set - `insert_before` to the position after the last item. - - .. container:: - - **Examples**: - - * :code:`range_start=0, insert_before=10` to reorder the - first item to the last position in a playlist with 10 - items, and - * :code:`range_start=9, insert_before=0` to reorder the - last item in a playlist with 10 items to the start of - the playlist. - - range_length : `int`, keyword-only, default: :code:`1` - The amount of items to be reordered. The range of items to - be reordered begins from the `range_start` position, and - includes the `range_length` subsequent items. - - **Example**: :code:`range_start=9, range_length=2` to move - the items at indices 9–10 to the start of the playlist. - - snapshot_id : `str`, keyword-only, optional - The playlist's snapshot ID against which you want to make - the changes. - - Returns - ------- - snapshot_id : `str` - The updated playlist's snapshot ID. - """ - - self._check_scope("update_playlist_details", - "playlist-modify-" + - ("public" if self.get_playlist(playlist_id)["public"] - else "private")) - - json = {} - if snapshot_id is not None: - json["snapshot_id"] = snapshot_id - - if uris is None: - if range_start is not None: - json["range_start"] = range_start - if insert_before is not None: - json["insert_before"] = insert_before - if range_length is not None: - json["range_length"] = range_length - return self._request( - "put", - f"{self.API_URL}/playlists/{playlist_id}/tracks", - json=json - ).json()["snapshot_id"] - - elif isinstance(uris, str): - return self._request( - "put", - f"{self.API_URL}/playlists/{playlist_id}/tracks?uris={uris}", - json=json - ).json()["snapshot_id"] - - elif isinstance(uris, list): - return self._request( - "put", - f"{self.API_URL}/playlists/{playlist_id}/tracks", - json={"uris": uris} | json - ).json()["snapshot_id"]
- - -
-[docs] - def remove_playlist_items( - self, playlist_id: str, tracks: list[str], *, - snapshot_id: str = None) -> str: - - """ - `Playlists > Remove Playlist Items - <https://developer.spotify.com/documentation/web-api/reference/ - remove-tracks-playlist>`_: Remove one or more items from a - user's playlist. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-modify-public` or the - :code:`playlist-modify-private` scope. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"`. - - tracks : `list` - A (comma-separated) list containing Spotify URIs of the - tracks or episodes to remove. - - **Maximum**: 100 items can be added in one request. - - **Example**: :code:`"spotify:track:4iV5W9uYEdYUVa79Axb7Rh, - spotify:track:1301WleyT98MSxVHPZCA6M, - spotify:episode:512ojhOuo1ktJprKbVcKyQ"`. - - snapshot_id : `str`, keyword-only, optional - The playlist's snapshot ID against which you want to make - the changes. The API will validate that the specified items - exist and in the specified positions and make the changes, - even if more recent changes have been made to the playlist. - - Returns - ------- - snapshot_id : `str` - The updated playlist's snapshot ID. - """ - - self._check_scope("remove_playlist_items", - "playlist-modify-" + - ("public" if self.get_playlist(playlist_id)["public"] - else "private")) - - json = {"tracks": tracks} - if snapshot_id is not None: - json["snapshot_id"] = snapshot_id - return self._request("delete", - f"{self.API_URL}/playlists/{playlist_id}/tracks", - json=json).json()["snapshot_id"]
- - -
-[docs] - def get_personal_playlists( - self, *, limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - `Playlist > Get Current User's Playlists - <https://developer.spotify.com/documentation/web-api/reference/ - get-a-list-of-current-users-playlists>`_: Get a list of the - playlists owned or followed by the current Spotify user. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-read-private` and the - :code:`playlist-read-collaborative` scopes. - - Parameters - ---------- - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Default**: :code:`0`. - - Returns - ------- - playlists : `dict` - A dictionary containing the current user's playlists and the - number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "collaborative": <bool>, - "description": <str>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "owner": { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "type": "user", - "uri": <str>, - "display_name": <str> - }, - "public": <bool>, - "snapshot_id": <str>, - "tracks": { - "href": <str>, - "total": <int> - }, - "type": <str>, - "uri": <str> - } - ] - } - """ - - self._check_scope("get_current_user_playlists", - "playlist-read-private") - self._check_scope("get_current_user_playlists", - "playlist-read-collaborative") - - return self._get_json(f"{self.API_URL}/me/playlists", - params={"limit": limit, "offset": offset})
- - -
-[docs] - def get_user_playlists( - self, user_id: str, *, limit: int = None, offset: int = None - ) -> dict[str, Any]: - - """ - `Playlist > Get User's Playlists - <https://developer.spotify.com/documentation/web-api/reference/ - get-list-users-playlists>`_: Get a list of the playlists owned - or followed by a Spotify user. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-read-private` and the - :code:`playlist-read-collaborative` scopes. - - Parameters - ---------- - user_id : `str` - The user's Spotify user ID. - - **Example**: :code:`"smedjan"`. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Default**: :code:`0`. - - Returns - ------- - playlists : `dict` - A dictionary containing the user's playlists and the number - of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "collaborative": <bool>, - "description": <str>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "owner": { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "type": "user", - "uri": <str>, - "display_name": <str> - }, - "public": <bool>, - "snapshot_id": <str>, - "tracks": { - "href": <str>, - "total": <int> - }, - "type": <str>, - "uri": <str> - } - ] - } - """ - - self._check_scope("get_user_playlists", "playlist-read-private") - self._check_scope("get_user_playlists", "playlist-read-collaborative") - - return self._get_json(f"{self.API_URL}/users/{user_id}/playlists", - params={"limit": limit, "offset": offset})
- - -
-[docs] - def create_playlist( - self, name: str, *, public: bool = True, collaborative: bool = None, - description: str = None) -> dict[str, Any]: - - """ - `Playlists > Create Playlist <https://developer.spotify.com/ - documentation/web-api/reference/create-playlist>`_: Create a - playlist for a Spotify user. (The playlist will be empty until - you add tracks.) - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-modify-public` or the - :code:`playlist-modify-private` scope. - - Parameters - ---------- - name : `str` - The name for the new playlist. This name does not need to be - unique; a user may have several playlists with the same - name. - - **Example**: :code:`"Your Coolest Playlist"`. - - public : `bool`, keyword-only, default: `True` - If :code:`True`, the playlist will be public; if - :code:`False`, it will be private. - - .. note:: - - To be able to create private playlists, the user must - have granted the :code:`playlist-modify-private` scope. - - collaborative : `bool`, keyword-only, optional - If :code:`True`, the playlist will be collaborative. - - .. note:: - - To create a collaborative playlist, you must also set - `public` to :code:`False`. To create collaborative - playlists, you must have granted the - :code:`playlist-modify-private` and - :code:`playlist-modify-public` scopes. - - **Default**: :code:`False`. - - description : `str`, keyword-only, optional - The playlist description, as displayed in Spotify Clients - and in the Web API. - - Returns - ------- - playlist : `dict` - Spotify catalog information for the newly created playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "collaborative": <bool>, - "description": <str>, - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "owner": { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "type": "user", - "uri": <str>, - "display_name": <str> - }, - "public": <bool>, - "snapshot_id": <str>, - "tracks": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "added_at": <str>, - "added_by": { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "type": "user", - "uri": <str> - }, - "is_local": <bool>, - "track": { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - } - ] - }, - "type": <str>, - "uri": <str> - } - """ - - self._check_scope( - "create_playlist", - "playlist-modify-" + ("public" if public else "private") - ) - - json = {"name": name, "public": public} - if collaborative is not None: - json["collaborative"] = collaborative - if description is not None: - json["description"] = description - - return self._request("post", - f"{self.API_URL}/users/{self._user_id}/playlists", - json=json).json()
- - - - - -
-[docs] - def get_category_playlists( - self, category_id: str, *, country: str = None, limit: int = None, - offset: int = None) -> dict[str, Any]: - - """ - `Playlists > Get Category's Playlists - <https://developer.spotify.com/documentation/web-api/reference/ - get-a-categories-playlists>`_: Get a list of Spotify playlists - tagged with a particular category. - - Parameters - ---------- - category_id : `str` - The Spotify category ID for the category. - - **Example**: :code:`"dinner"`. - - country : `str`, keyword-only, optional - A country: an ISO 3166-1 alpha-2 country code. Provide this - parameter to ensure that the category exists for a - particular country. - - **Example**: :code:`"SE"`. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Valid values**: `offset` must be between 0 and 1,000. - - **Default**: :code:`0`. - - Returns - ------- - playlists : `dict` - A dictionary containing a message and a list of playlists in - a particular category. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "message": <str>, - "playlists": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "collaborative": <bool>, - "description": <str>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "owner": { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "type": "user", - "uri": <str>, - "display_name": <str> - }, - "public": <bool>, - "snapshot_id": <str>, - "tracks": { - "href": <str>, - "total": <int> - }, - "type": <str>, - "uri": <str> - } - ] - } - } - """ - - return self._get_json( - f"{self.API_URL}/browse/categories/{category_id}/playlists", - params={"country": country, "limit": limit, "offset": offset} - )
- - -
-[docs] - def get_playlist_cover_image(self, playlist_id: str) -> dict[str, Any]: - - """ - `Playlists > Get Playlist Cover Image - <https://developer.spotify.com/documentation/web-api/reference/ - get-playlist-cover>`_: Get the current image associated with a - specific playlist. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"`. - - Returns - ------- - image : `dict` - A dictionary containing the URL to and the dimensions of - the playlist cover image. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "url": <str>, - "height": <int>, - "width": <int> - } - """ - - return self._get_json(f"{self.API_URL}/playlists/{playlist_id}/images")[0]
- - -
-[docs] - def add_playlist_cover_image(self, playlist_id: str, image: bytes) -> None: - - """ - `Playlists > Add Custom Playlist Cover Image - <https://developer.spotify.com/documentation/web-api/reference/ - upload-custom-playlist-cover>`_: Replace the image used to - represent a specific playlist. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`ugc-image-upload` and the - :code:`playlist-modify-public` or - :code:`playlist-modify-private` scope. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"`. - - image : `bytes` - Base64-encoded JPEG image data. The maximum payload size is - 256 KB. - """ - - self._check_scope("get_categories", "ugc-image-upload") - self._check_scope("get_categories", - "playlist-modify-" + - ("public" if self.get_playlist(playlist_id)["public"] - else "private")) - - self._request("put", f"{self.API_URL}/playlists/{playlist_id}/images", - data=image, headers={"Content-Type": "image/jpeg"})
- - - ### SEARCH ################################################################ - -
-[docs] - def search( - self, q: str, type: Union[str, list[str]], *, - limit: int = None, market: str = None, offset: int = None - ) -> dict[str, Any]: - - """ - `Search > Search for Item <https://developer.spotify.com/ - documentation/web-api/reference/search>`_: Get - Spotify catalog information about albums, artists, playlists, - tracks, shows, episodes or audiobooks that match a keyword - string. - - Parameters - ---------- - q : `str` - Your search query. - - .. note:: - - You can narrow down your search using field filters. The - available filters are :code:`album`, :code:`artist`, - :code:`track`, :code:`year`, :code:`upc`, - :code:`tag:hipster`, :code:`tag:new`, :code:`isrc`, and - :code:`genre`. Each field filter only applies to certain - result types. - - The :code:`artist` and :code:`year` filters can be used - while searching albums, artists and tracks. You can - filter on a single :code:`year` or a range (e.g. - 1955-1960). - - The :code:`album` filter can be used while searching - albums and tracks. - - The :code:`genre` filter can be used while searching - artists and tracks. - - The :code:`isrc` and :code:`track` filters can be used - while searching tracks. - - The :code:`upc`, :code:`tag:new` and :code:`tag:hipster` - filters can only be used while searching albums. The - :code:`tag:new` filter will return albums released in the - past two weeks and :code:`tag:hipster` can be used to - return only albums with the lowest 10% popularity. - - **Example**: - :code:`"remaster track:Doxy artist:Miles Davis"`. - - type : `str` or `list` - A comma-separated list of item types to search across. - Search results include hits from all the specified item - types. - - **Valid values**: :code:`"album"`, :code:`"artist"`, - :code:`"audiobook"`, :code:`"episode"`, :code:`"playlist"`, - :code:`"show"`, and :code:`"track"`. - - .. container:: - - **Example**: - - * :code:`"track,artist"` returns both tracks and artists - matching `query`. - * :code:`type=album,track` returns both albums and tracks - matching `query`. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Valid values**: `offset` must be between 0 and 1,000. - - **Default**: :code:`0`. - - Returns - ------- - results : `dict` - The search results. - - .. admonition:: Sample - :class: dropdown - - .. code:: - - { - "tracks": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - ] - }, - "artists": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ] - }, - "albums": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - } - ] - }, - "playlists": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "collaborative": <bool>, - "description": <str>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "owner": { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "type": "user", - "uri": <str>, - "display_name": <str> - }, - "public": <bool>, - "snapshot_id": <str>, - "tracks": { - "href": <str>, - "total": <int> - }, - "type": <str>, - "uri": <str> - } - ] - }, - "shows": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "publisher": <str>, - "type": "show", - "uri": <str>, - "total_episodes": <int> - } - ] - }, - "episodes": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "audio_preview_url": <str>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "is_playable": <bool> - "language": <str>, - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - } - } - ] - }, - "audiobooks": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "authors": [ - { - "name": <str> - } - ], - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "edition": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "narrators": [ - { - "name": <str> - } - ], - "publisher": <str>, - "type": "audiobook", - "uri": <str>, - "total_chapters": <int> - } - ] - } - } - """ - - return self._get_json( - f"{self.API_URL}/search?q={urllib.parse.quote(q)}", - params={ - "type": type if isinstance(type, str) else ",".join(type), - "limit": limit, - "market": market, - "offset": offset - } - )[f"{type}s"]
- - - ### SHOWS ################################################################# - -
-[docs] - def get_show(self, id: str, *, market: str = None) -> dict[str, Any]: - - """ - `Shows > Get Show <https://developer.spotify.com/documentation/ - web-api/reference/get-a-show>`_: Get Spotify - catalog information for a single show identified by its unique - Spotify ID. - - Parameters - ---------- - id : `str` - The Spotify ID for the show. - - **Example**: :code:`"38bS44xjbVVZ3No3ByF1dJ"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - show : `dict` - Spotify catalog information for a single show. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "publisher": <str>, - "type": "show", - "uri": <str>, - "total_episodes": <int>, - "episodes": { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "audio_preview_url": <str>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "is_playable": <bool> - "language": <str>, - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - } - } - ] - } - } - """ - - return self._get_json(f"{self.API_URL}/shows/{id}", - params={"market": market})
- - -
-[docs] - def get_shows( - self, ids: Union[str, list[str]], *, market: str = None - ) -> list[dict[str, Any]]: - - """ - `Shows > Get Several Shows <https://developer.spotify.com/ - documentation/web-api/reference/ - get-multiple-shows>`_: Get Spotify catalog information for - several shows based on their Spotify IDs. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the shows. - - **Maximum**: 50 IDs. - - **Example**: - :code:`"5CfCWKI5pZ28U0uOzXkDHe,5as3aKmN2k11yfDDDSrvaZ"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - shows : `list` - A list containing Spotify catalog information for multiple - shows. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "available_markets": [<str>], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "languages": [<str>], - "media_type": <str>, - "name": <str>, - "publisher": <str>, - "type": "show", - "uri": <str>, - "total_episodes": <int> - } - ] - """ - - return self._get_json( - f"{self.API_URL}/shows", - params={"ids": ids if isinstance(ids, str) else ",".join(ids), - "market": market} - )["shows"]
- - -
-[docs] - def get_show_episodes( - self, id: str, *, limit: int = None, market: str = None, - offset: int = None) -> dict[str, Any]: - - """ - `Shows > Get Show Episodes <https://developer.spotify.com/ - documentation/web-api/reference/ - get-a-shows-episodes>`_: Get Spotify catalog information about - an show's episodes. Optional parameters can be used to limit the - number of episodes returned. - - Parameters - ---------- - id : `str` - The Spotify ID for the show. - - **Example**: :code:`"38bS44xjbVVZ3No3ByF1dJ"`. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Default**: :code:`0`. - - Returns - ------- - episodes : `dict` - A dictionary containing Spotify catalog information for a - show's episodes and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "audio_preview_url": <str>, - "description": <str>, - "html_description": <str>, - "duration_ms": <int>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "is_playable": <bool> - "language": <str>, - "languages": [<str>], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "resume_point": { - "fully_played": <bool>, - "resume_position_ms": <int> - }, - "type": "episode", - "uri": <str>, - "restrictions": { - "reason": <str> - } - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/shows/{id}/episodes", - params={"limit": limit, "market": market, "offset": offset} - )
- - -
-[docs] - def get_saved_shows( - self, *, limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - `Shows > Get User's Saved Shows <https://developer.spotify.com/ - documentation/web-api/reference/ - get-users-saved-shows>`_: Get a list of shows saved in the - current Spotify user's library. Optional parameters can be used - to limit the number of shows returned. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Valid values**: `offset` must be between 0 and 1,000. - - **Default**: :code:`0`. - - Returns - ------- - shows : `dict` - A dictionary containing Spotify catalog information for a - user's saved shows and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "added_at": <str>, - "show": { - "available_markets": [ - <str> - ], - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "description": <str>, - "html_description": <str>, - "explicit": <bool>, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "is_externally_hosted": <bool>, - "languages": [ - <str> - ], - "media_type": <str>, - "name": <str>, - "publisher": <str>, - "type": "show", - "uri": <str>, - "total_episodes": <int> - } - } - ] - } - """ - - self._check_scope("get_saved_shows", "user-library-read") - - return self._get_json(f"{self.API_URL}/me/shows", - params={"limit": limit, "offset": offset})
- - -
-[docs] - def save_shows(self, ids: Union[str, list[str]]) -> None: - - """ - `Shows > Save Shows for Current User - <https://developer.spotify.com/documentation/web-api/reference/ - save-shows-user>`_: Save one or more shows to - current Spotify user's library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the shows. - Maximum: 50 IDs. - - **Example**: - :code:`"5CfCWKI5pZ28U0uOzXkDHe,5as3aKmN2k11yfDDDSrvaZ"`. - """ - - self._check_scope("save_shows", "user-library-modify") - - self._request( - "put", f"{self.API_URL}/me/shows", - params={"ids": f"{ids if isinstance(ids, str) else ','.join(ids)}"} - )
- - -
-[docs] - def remove_saved_shows( - self, ids: Union[str, list[str]], *, market: str = None) -> None: - - """ - `Shows > Remove User's Saved Shows - <https://developer.spotify.com/documentation/web-api/reference/ - remove-shows-user>`_: Delete one or more shows from - current Spotify user's library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the shows. - Maximum: 50 IDs. - - **Example**: - :code:`"5CfCWKI5pZ28U0uOzXkDHe,5as3aKmN2k11yfDDDSrvaZ"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - """ - - self._check_scope("remove_saved_shows", "user-library-modify") - - self._request("delete", f"{self.API_URL}/me/shows", - params={"ids": ids if isinstance(ids, str) - else ",".join(ids), - "market": market})
- - -
-[docs] - def check_saved_shows(self, ids: Union[str, list[str]]) -> list[bool]: - - """ - `Shows > Check User's Saved Shows - <https://developer.spotify.com/documentation/web-api/reference/ - check-users-saved-shows>`_: Check if one or more - shows is already saved in the current Spotify user's library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the shows. - - **Maximum**: 50 IDs. - - **Example**: - :code:`"5CfCWKI5pZ28U0uOzXkDHe,5as3aKmN2k11yfDDDSrvaZ"`. - - Returns - ------- - contains : `list` - Array of booleans specifying whether the shows are found in - the user's saved shows. - - **Example**: :code:`[False, True]`. - """ - - self._check_scope("check_saved_shows", "user-library-read") - - return self._get_json( - f"{self.API_URL}/me/shows/contains", - params={"ids": ids if isinstance(ids, str) else ",".join(ids)} - )
- - - ### TRACKS ################################################################ - -
-[docs] - def get_track(self, id: str, *, market: str = None) -> dict[str, Any]: - - """ - `Tracks > Get Track <https://developer.spotify.com/ - documentation/web-api/reference/get-track>`_: Get - Spotify catalog information for a single track identified by its - unique Spotify ID. - - Parameters - ---------- - id : `str` - The Spotify ID for the track. - - **Example**: :code:`"11dFghVXANMlKmJXsNCbNl"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - track : `dict` - Spotify catalog information for a single track. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - """ - - return self._get_json(f"{self.API_URL}/tracks/{id}", - params={"market": market})
- - -
-[docs] - def get_tracks( - self, ids: Union[int, str, list[Union[int, str]]], *, - market: str = None) -> list[dict[str, Any]]: - - """ - `Tracks > Get Several Tracks <https://developer.spotify.com/ - documentation/web-api/reference/ - get-several-tracks>`_: Get Spotify catalog information for - multiple tracks based on their Spotify IDs. - - Parameters - ---------- - ids : `int`, `str`, or `list` - A (comma-separated) list of the Spotify IDs for the tracks. - - **Maximum**: 50 IDs. - - **Example**: :code:`"7ouMYWpwJ422jRcDASZB7P, - 4VqPOruhp5EdPBeR92t6lQ, 2takcwOaAZWiXQijPHIx7B"`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - Returns - ------- - tracks : `dict` or `list` - A list containing Spotify catalog information for multiple - tracks. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - ] - """ - - return self._get_json( - f"{self.API_URL}/tracks", - params={"ids": ids if isinstance(ids, str) else ",".join(ids), - "market": market} - )["tracks"]
- - -
-[docs] - def get_saved_tracks( - self, *, limit: int = None, market: str = None, offset: int = None - ) -> dict[str, Any]: - - """ - `Tracks > Get User's Saved Tracks - <https://developer.spotify.com/documentation/web-api/reference/ - get-users-saved-tracks>`_: Get a list of the songs - saved in the current Spotify user's 'Your Music' library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Valid values**: `offset` must be between 0 and 1,000. - - **Default**: :code:`0`. - - Returns - ------- - tracks : `dict` - A dictionary containing Spotify catalog information for a - user's saved tracks and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "added_at": <str>, - "track": { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - } - ] - } - """ - - self._check_scope("get_saved_tracks", "user-library-read") - - return self._get_json( - f"{self.API_URL}/me/tracks", - params={"limit": limit, "market": market, "offset": offset} - )
- - -
-[docs] - def save_tracks(self, ids: Union[str, list[str]]) -> None: - - """ - `Tracks > Save Track for Current User - <https://developer.spotify.com/documentation/web-api/reference/ - save-tracks-user>`_: Save one or more tracks to the - current user's 'Your Music' library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the tracks. - - **Maximum**: 50 IDs. - - **Example**: :code:`"7ouMYWpwJ422jRcDASZB7P, - 4VqPOruhp5EdPBeR92t6lQ, 2takcwOaAZWiXQijPHIx7B"`. - """ - - self._check_scope("save_tracks", "user-library-modify") - - if isinstance(ids, str): - self._request("put", f"{self.API_URL}/me/tracks", - params={"ids": ids}) - elif isinstance(ids, list): - self._request("put", f"{self.API_URL}/me/tracks", - json={"ids": ids})
- - -
-[docs] - def remove_saved_tracks(self, ids: Union[str, list[str]]) -> None: - - """ - `Tracks > Remove User's Saved Tracks - <https://developer.spotify.com/documentation/web-api/reference/ - remove-tracks-user>`_: Remove one or more tracks - from the current user's 'Your Music' library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the tracks. - - **Maximum**: 50 IDs. - - **Example**: :code:`"7ouMYWpwJ422jRcDASZB7P, - 4VqPOruhp5EdPBeR92t6lQ, 2takcwOaAZWiXQijPHIx7B"`. - """ - - self._check_scope("remove_saved_tracks", "user-library-modify") - - if isinstance(ids, str): - self._request("delete", f"{self.API_URL}/me/tracks", - params={"ids": ids}) - elif isinstance(ids, list): - self._request("delete", f"{self.API_URL}/me/tracks", - json={"ids": ids})
- - -
-[docs] - def check_saved_tracks(self, ids: Union[str, list[str]]) -> list[bool]: - - """ - `Tracks > Check User's Saved Tracks - <https://developer.spotify.com/documentation/web-api/reference/ - check-users-saved-tracks>`_: Check if one or more - tracks is already saved in the current Spotify user's 'Your - Music' library. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-library-read` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the tracks. - - **Maximum**: 50 IDs. - - **Example**: :code:`"7ouMYWpwJ422jRcDASZB7P, - 4VqPOruhp5EdPBeR92t6lQ, 2takcwOaAZWiXQijPHIx7B"`. - - Returns - ------- - contains : `list` - Array of booleans specifying whether the tracks are found in - the user's 'Liked Songs'. - - **Example**: :code:`[False, True]`. - """ - - self._check_scope("check_saved_tracks", "user-library-read") - - return self._get_json( - f"{self.API_URL}/me/tracks/contains", - params={"ids": ids if isinstance(ids, str) else ",".join(ids)} - )
- - -
-[docs] - def get_track_audio_features(self, id: str) -> dict[str, Any]: - - """ - `Tracks > Get Track's Audio Features - <https://developer.spotify.com/documentation/web-api/reference/ - get-audio-features>`_: Get audio feature information for a - single track identified by its unique Spotify ID. - - Parameters - ---------- - id : `str` - The Spotify ID of the track. - - **Example**: :code:`"11dFghVXANMlKmJXsNCbNl"`. - - Returns - ------- - audio_features : `dict` - The track's audio features. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "acousticness": <float>, - "analysis_url": <str>, - "danceability": <float>, - "duration_ms": <int>, - "energy": <float>, - "id": <str>, - "instrumentalness": <float>, - "key": <int>, - "liveness": <float>, - "loudness": <float>, - "mode": <int>, - "speechiness": <float>, - "tempo": <float>, - "time_signature": <int>, - "track_href": <str>, - "type": "audio_features", - "uri": <str>, - "valence": <float>, - } - """ - - return self._get_json(f"{self.API_URL}/audio-features/{id}")
- - -
-[docs] - def get_tracks_audio_features( - self, ids: Union[str, list[str]]) -> list[dict[str, Any]]: - - """ - `Tracks > Get Tracks' Audio Features - <https://developer.spotify.com/documentation/web-api/reference/ - get-several-audio-features>`_: Get audio features - for multiple tracks based on their Spotify IDs. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the Spotify IDs for the tracks. - - **Maximum**: 100 IDs. - - **Example**: :code:`"7ouMYWpwJ422jRcDASZB7P, - 4VqPOruhp5EdPBeR92t6lQ, 2takcwOaAZWiXQijPHIx7B"`. - - Returns - ------- - audio_features : `dict` or `list` - A list containing audio features for multiple tracks. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "acousticness": <float>, - "analysis_url": <str>, - "danceability": <float>, - "duration_ms": <int>, - "energy": <float>, - "id": <str>, - "instrumentalness": <float>, - "key": <int>, - "liveness": <float>, - "loudness": <float>, - "mode": <int>, - "speechiness": <float>, - "tempo": <float>, - "time_signature": <int>, - "track_href": <str>, - "type": "audio_features", - "uri": <str>, - "valence": <float>, - } - ] - """ - - return self._get_json( - f"{self.API_URL}/audio-features", - params={"ids": ids if isinstance(ids, str) else ",".join(ids)} - )["audio_features"]
- - -
-[docs] - def get_track_audio_analysis(self, id: str) -> dict[str, Any]: - - """ - `Tracks > Get Track's Audio Analysis - <https://developer.spotify.com/documentation/web-api/reference/ - get-audio-analysis>`_: Get a low-level audio - analysis for a track in the Spotify catalog. The audio analysis - describes the track's structure and musical content, including - rhythm, pitch, and timbre. - - Parameters - ---------- - id : `str` - The Spotify ID of the track. - - **Example**: :code:`"11dFghVXANMlKmJXsNCbNl"`. - - Returns - ------- - audio_analysis : `dict` - The track's audio analysis. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "meta": { - "analyzer_version": <str>, - "platform": <str>, - "detailed_status": <str>, - "status_code": <int>, - "timestamp": <int>, - "analysis_time": <float>, - "input_process": <str> - }, - "track": { - "num_samples": <int>, - "duration": <float>, - "sample_md5": <str>, - "offset_seconds": <int>, - "window_seconds": <int>, - "analysis_sample_rate": <int>, - "analysis_channels": <int>, - "end_of_fade_in": <int>, - "start_of_fade_out": <float>, - "loudness": <float>, - "tempo": <float>, - "tempo_confidence": <float>, - "time_signature": <int>, - "time_signature_confidence": <float>, - "key": <int>, - "key_confidence": <float>, - "mode": <int>, - "mode_confidence": <float>, - "codestring": <str>, - "code_version": <float>, - "echoprintstring": <str>, - "echoprint_version": <float>, - "synchstring": <str>, - "synch_version": <int>, - "rhythmstring": <str>, - "rhythm_version": <int> - }, - "bars": [ - { - "start": <float>, - "duration": <float>, - "confidence": <float> - } - ], - "beats": [ - { - "start": <float>, - "duration": <float>, - "confidence": <float> - } - ], - "sections": [ - { - "start": <float>, - "duration": <float>, - "confidence": <float>, - "loudness": <float>, - "tempo": <float>, - "tempo_confidence": <float>, - "key": <int>, - "key_confidence": <float>, - "mode": <int>, - "mode_confidence": <float>, - "time_signature": <int>, - "time_signature_confidence": <float> - } - ], - "segments": [ - { - "start": <float>, - "duration": <float>, - "confidence": <float>, - "loudness_start": <float>, - "loudness_max": <float>, - "loudness_max_time": <float>, - "loudness_end": <int>, - "pitches": [<float>], - "timbre": [<float>] - } - ], - "tatums": [ - { - "start": <float>, - "duration": <float>, - "confidence": <float> - } - ] - } - """ - - return self._get_json(f"{self.API_URL}/audio-analysis/{id}")
- - -
-[docs] - def get_recommendations( - self, seed_artists: Union[str, list[str]] = None, - seed_genres: Union[str, list[str]] = None, - seed_tracks: Union[str, list[str]] = None, *, limit: int = None, - market: str = None, **kwargs) -> list[dict[str, Any]]: - - """ - `Tracks > Get Recommendations <https://developer.spotify.com/ - documentation/web-api/reference/ - get-recommendations>`_: Recommendations are generated based on - the available information for a given seed entity and matched - against similar artists and tracks. If there is sufficient - information about the provided seeds, a list of tracks will be - returned together with pool size details. - - For artists and tracks that are very new or obscure, there might - not be enough data to generate a list of tracks. - - .. important:: - - Spotify content may not be used to train machine learning or - AI models. - - Parameters - ---------- - seed_artists : `str`, optional - A comma separated list of Spotify IDs for seed artists. - - **Maximum**: Up to 5 seed values may be provided in any - combination of `seed_artists`, `seed_tracks`, and - `seed_genres`. - - **Example**: :code:`"4NHQUGzhtTLFvgF5SZesLK"`. - - seed_genres : `str`, optional - A comma separated list of any genres in the set of available - genre seeds. - - **Maximum**: Up to 5 seed values may be provided in any - combination of `seed_artists`, `seed_tracks`, and - `seed_genres`. - - **Example**: :code:`"classical,country"`. - - seed_tracks : `str`, optional - A comma separated list of Spotify IDs for a seed track. - - **Maximum**: Up to 5 seed values may be provided in any - combination of `seed_artists`, `seed_tracks`, and - `seed_genres`. - - **Example**: :code:`"0c6xIDDpzE81m2q797ordA"`. - - limit : `int`, keyword-only, optional - The target size of the list of recommended tracks. For seeds - with unusually small pools or when highly restrictive - filtering is applied, it may be impossible to generate the - requested number of recommended tracks. Debugging - information for such cases is available in the response. - - **Minimum**: :code:`1`. - - **Maximum**: :code:`100`. - - **Default**: :code:`20`. - - market : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If a country code is - specified, only content that is available in that market - will be returned. If a valid user access token is specified - in the request header, the country associated with the user - account will take priority over this parameter. - - .. note:: - - If neither market or user country are provided, the - content is considered unavailable for the client. - - **Example**: :code:`"ES"`. - - **kwargs - Tunable track attributes. For a list of available options, - see the `Spotify Web API Reference page for this endpoint - <https://developer.spotify.com/documentation/web-api/ - reference/get-recommendations>`_. - - Returns - ------- - tracks : `dict` - A dictionary containing Spotify catalog information for the - recommended tracks. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "seeds": [ - { - "afterFilteringSize": <int>, - "afterRelinkingSize": <int>, - "href": <str>, - "id": <str>, - "initialPoolSize": <int>, - "type": <str> - } - ], - "tracks": [ - { - "album": { - "album_type": <str>, - "total_tracks": <int>, - "available_markets": [<str>], - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "release_date": <str>, - "release_date_precision": <str>, - "restrictions": { - "reason": <str> - }, - "type": "album", - "uri": <str>, - "copyrights": [ - { - "text": <str>, - "type": <str> - } - ], - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "genres": [<str>], - "label": <str>, - "popularity": <int>, - "album_group": <str>, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "name": <str>, - "type": "artist", - "uri": <str> - } - ] - }, - "artists": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ], - "available_markets": [<str>], - "disc_number": <int>, - "duration_ms": <int>, - "explicit": <bool>, - "external_ids": { - "isrc": <str>, - "ean": <str>, - "upc": <str> - }, - "external_urls": { - "spotify": <str> - }, - "href": <str>, - "id": <str>, - "is_playable": <bool> - "linked_from": { - }, - "restrictions": { - "reason": <str> - }, - "name": <str>, - "popularity": <int>, - "preview_url": <str>, - "track_number": <int>, - "type": "track", - "uri": <str>, - "is_local": <bool> - } - ] - } - """ - - return self._get_json( - f"{self.API_URL}/recommendations", - params={ - "seed_artists": seed_artists if seed_artists is None - or isinstance(seed_artists, str) - else ",".join(seed_artists), - "seed_genres": seed_genres if seed_genres is None - or isinstance(seed_genres, str) - else ",".join(seed_genres), - "seed_tracks": seed_tracks if seed_tracks is None - or isinstance(seed_tracks, str) - else ",".join(seed_tracks), - "limit": limit, - "market": market, - **kwargs - } - )
- - - ### USERS ################################################################# - -
-[docs] - def get_profile(self) -> dict[str, Any]: - - """ - `Users > Get Current User's Profile - <https://developer.spotify.com/documentation/web-api/reference/ - get-current-users-profile>`_: Get detailed profile - information about the current user (including the current user's - username). - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-read-private` scope. - - Returns - ------- - user : `dict` - A dictionary containing the current user's information. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "country": <str>, - "display_name": <str>, - "email": <str>, - "explicit_content": { - "filter_enabled": <bool>, - "filter_locked": <bool> - }, - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "product": <str>, - "type": <str>, - "uri": <str> - } - """ - - self._check_scope("get_profile", "user-read-private") - - return self._get_json(f"{self.API_URL}/me")
- - -
-[docs] - def get_top_items( - self, type: str, *, limit: int = None, offset: int = None, - time_range: str = None) -> dict[str, Any]: - - """ - `Users > Get User's Top Items <https://developer.spotify.com/ - documentation/web-api/reference/ - get-users-top-artists-and-tracks>`_: Get the current user's top - artists or tracks based on calculated affinity. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-top-read` scope. - - Parameters - ---------- - type : `str` - The type of entity to return. - - **Valid values**: :code:`"artists"` and :code:`"tracks"`. - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - offset : `int`, keyword-only, optional - The index of the first result to return. Use with `limit` to - get the next page of search results. - - **Default**: :code:`0`. - - time_range : `str`, keyword-only, optional - Over what time frame the affinities are computed. - - .. container:: - - **Valid values**: - - * :code:`"long_term"` (calculated from several years of - data and including all new data as it becomes - available). - * :code:`"medium_term"` (approximately last 6 months). - * :code:`"short_term"` (approximately last 4 weeks). - - **Default**: :code:`"medium_term"`. - - Returns - ------- - items : `dict` - A dictionary containing Spotify catalog information for a - user's top items and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "offset": <int>, - "previous": <str>, - "total": <int>, - "items": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": <str>, - "uri": <str> - } - ] - } - """ - - if type not in (TYPES := {"artists", "tracks"}): - raise ValueError(f"Invalid entity type ({type=}). " - f"Valid values: {', '.join(TYPES)}.") - - self._check_scope("get_top_items", "user-top-read") - - return self._get_json( - f"{self.API_URL}/me/top/{type}", - params={"limit": limit, "offset": offset, "time_range": time_range} - )
- - -
-[docs] - def get_user_profile(self, user_id: str) -> dict[str, Any]: - - """ - `Users > Get User's Profile <https://developer.spotify.com/ - documentation/web-api/reference/ - get-users-profile>`_: Get public profile information about a - Spotify user. - - Parameters - ---------- - user_id : `str` - The user's Spotify user ID. - - **Example**: :code:`"smedjan"` - - Returns - ------- - user : `dict` - A dictionary containing the user's information. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "display_name": <str>, - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "type": "user", - "uri": <str> - } - """ - - return self._get_json(f"{self.API_URL}/users/{user_id}")
- - -
-[docs] - def follow_playlist(self, playlist_id: str) -> None: - - """ - `Users > Follow Playlist <https://developer.spotify.com/ - documentation/web-api/reference/follow-playlist>`_: - Add the current user as a follower of a playlist. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-modify-private` scope. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"` - """ - - self._check_scope("follow_playlist", "playlist-modify-private") - - self._request("put", - f"{self.API_URL}/playlists/{playlist_id}/followers")
- - -
-[docs] - def unfollow_playlist(self, playlist_id: str) -> None: - - """ - `Users > Unfollow Playlist <https://developer.spotify.com/ - documentation/web-api/reference/ - unfollow-playlist>`_: Remove the current user as a follower of a - playlist. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`playlist-modify-private` scope. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"` - """ - - self._check_scope("unfollow_playlist", "playlist-modify-private") - - self._request("delete", - f"{self.API_URL}/playlists/{playlist_id}/followers")
- - -
-[docs] - def get_followed_artists( - self, *, after: str = None, limit: int = None) -> dict[str, Any]: - - """ - `Users > Get Followed Artists <https://developer.spotify.com/ - documentation/web-api/reference/get-followed>`_: - Get the current user's followed artists. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-follow-read` scope. - - Parameters - ---------- - after : `str`, keyword-only, optional - The last artist ID retrieved from the previous request. - - **Example**: :code:`"0I2XqVXqHScXjHhk6AYYRe"` - - limit : `int`, keyword-only, optional - The maximum number of results to return in each item type. - - **Valid values**: `limit` must be between 0 and 50. - - **Default**: :code:`20`. - - Returns - ------- - artists : `dict` - A dictionary containing Spotify catalog information for a - user's followed artists and the number of results returned. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "href": <str>, - "limit": <int>, - "next": <str>, - "cursors": { - "after": <str>, - "before": <str> - }, - "total": <int>, - "items": [ - { - "external_urls": { - "spotify": <str> - }, - "followers": { - "href": <str>, - "total": <int> - }, - "genres": [<str>], - "href": <str>, - "id": <str>, - "images": [ - { - "url": <str>, - "height": <int>, - "width": <int> - } - ], - "name": <str>, - "popularity": <int>, - "type": "artist", - "uri": <str> - } - ] - } - """ - - self._check_scope("get_followed_artists", "user-follow-read") - - return self._get_json( - f"{self.API_URL}/me/following", - params={"type": "artist", "after": after, "limit": limit} - )["artists"]
- - -
-[docs] - def follow_people(self, ids: Union[str, list[str]], type: str) -> None: - - """ - `Users > Follow Artists or Users <https://developer.spotify.com/ - documentation/web-api/reference/ - follow-artists-users>`_: Add the current user as a follower of - one or more artists or other Spotify users. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-follow-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the artist or user Spotify IDs. - - **Maximum**: Up to 50 IDs can be sent in one request. - - **Example**: :code:`"2CIMQHirSU0MQqyYHq0eOx, - 57dN52uHvrHOxijzpIgu3E, 1vCWHaC5f2uS3yhpwWbIA6"`. - - type : `str` - The ID type. - - **Valid values**: :code:`"artist"` and :code:`"user"`. - """ - - self._check_scope("follow_people", "user-follow-modify") - - if isinstance(ids, str): - self._request("put", f"{self.API_URL}/me/following", - params={"ids": ids, "type": type}) - elif isinstance(ids, list): - self._request("put", f"{self.API_URL}/me/following", - json={"ids": ids}, params={"type": type})
- - -
-[docs] - def unfollow_people(self, ids: Union[str, list[str]], type: str) -> None: - - """ - `Users > Unfollow Artists or Users - <https://developer.spotify.com/documentation/web-api/reference/ - unfollow-artists-users>`_: Remove the current user - as a follower of one or more artists or other Spotify users. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-follow-modify` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the artist or user Spotify IDs. - - **Maximum**: Up to 50 IDs can be sent in one request. - - **Example**: :code:`"2CIMQHirSU0MQqyYHq0eOx, - 57dN52uHvrHOxijzpIgu3E, 1vCWHaC5f2uS3yhpwWbIA6"`. - - type : `str` - The ID type. - - **Valid values**: :code:`"artist"` and :code:`"user"`. - """ - - self._check_scope("unfollow_people", "user-follow-modify") - - if isinstance(ids, str): - self._request("delete", f"{self.API_URL}/me/following", - params={"ids": ids, "type": type}) - elif isinstance(ids, list): - self._request("delete", f"{self.API_URL}/me/following", - json={"ids": ids}, params={"type": type})
- - -
-[docs] - def check_followed_people( - self, ids: Union[str, list[str]], type: str) -> list[bool]: - - """ - `Users > Check If User Follows Artists or Users - <https://developer.spotify.com/documentation/web-api/reference/ - check-current-user-follows>`_: Check to see if the - current user is following one or more artists or other Spotify - users. - - .. admonition:: Authorization scope - :class: warning - - Requires the :code:`user-follow-read` scope. - - Parameters - ---------- - ids : `str` or `list` - A (comma-separated) list of the artist or user Spotify IDs. - - **Maximum**: Up to 50 IDs can be sent in one request. - - **Example**: :code:`"2CIMQHirSU0MQqyYHq0eOx, - 57dN52uHvrHOxijzpIgu3E, 1vCWHaC5f2uS3yhpwWbIA6"`. - - type : `str` - The ID type. - - **Valid values**: :code:`"artist"` and :code:`"user"`. - - Returns - ------- - contains : `list` - Array of booleans specifying whether the user follows the - specified artists or Spotify users. - - **Example**: :code:`[False, True]`. - """ - - self._check_scope("check_followed_people", "user-follow-read") - - return self._get_json( - f"{self.API_URL}/me/following/contains", - params={"ids": ids if isinstance(ids, str) else ",".join(ids), - "type": type} - )
- - -
-[docs] - def check_playlist_followers( - self, playlist_id: str, ids: Union[str, list[str]]) -> list[bool]: - - """ - `Users > Check If Users Follow Playlist - <https://developer.spotify.com/documentation/web-api/reference/ - check-if-user-follows-playlist>`_: Check to see if - one or more Spotify users are following a specified playlist. - - Parameters - ---------- - playlist_id : `str` - The Spotify ID of the playlist. - - **Example**: :code:`"3cEYpjA9oz9GiPac4AsH4n"`. - - ids : `str` or `list` - A (comma-separated) list of Spotify user IDs; the IDs of the - users that you want to check to see if they follow the - playlist. - - **Maximum**: 5 IDs. - - **Example**: :code:`"jmperezperez,thelinmichael,wizzler"`. - - Returns - ------- - follows : `list` - Array of booleans specifying whether the users follow the - playlist. - - **Example**: :code:`[False, True]`. - """ - - return self._get_json( - f"{self.API_URL}/playlists/{playlist_id}/followers/contains", - params={"ids": ids if isinstance(ids, str) else ",".join(ids)} - )
-
- -
-
-
-
- - -
-
- - Made with Sphinx and @pradyunsg's - - Furo - -
-
- -
-
- -
-
- -
-
- - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/_modules/minim/tidal.html b/docs/_modules/minim/tidal.html deleted file mode 100644 index ff34df3..0000000 --- a/docs/_modules/minim/tidal.html +++ /dev/null @@ -1,9210 +0,0 @@ - - - - - - - - minim.tidal - Minim 1.0.0 documentation - - - - - - - - - - - - - - - - - - - Contents - - - - - - Menu - - - - - - - - Expand - - - - - - Light mode - - - - - - - - - - - - - - Dark mode - - - - - - - Auto light/dark mode - - - - - - - - - - - - - - - - - - - -
-
-
- -
- -
-
- -
- -
-
- -
-
-
- - - - - Back to top - -
-
- -
- -
-
-

Source code for minim.tidal

-"""
-TIDAL
-=====
-.. moduleauthor:: Benjamin Ye <GitHub: bbye98>
-
-This module contains a complete implementation of all public TIDAL API
-endpoints and a minimum implementation of the more robust but private
-TIDAL API.
-"""
-
-import base64
-import datetime
-import hashlib
-import json
-import logging
-import os
-import pathlib
-import re
-import secrets
-import time
-from typing import Any, Union
-import urllib
-import warnings
-import webbrowser
-from xml.dom import minidom
-
-from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
-import requests
-
-from . import FOUND_PLAYWRIGHT, DIR_HOME, DIR_TEMP, _config
-if FOUND_PLAYWRIGHT:
-    from playwright.sync_api import sync_playwright
-
-__all__ = ["API", "PrivateAPI"]
-
-
-[docs] -class API: - - """ - TIDAL API client. - - The TIDAL API exposes TIDAL functionality and data, making it - possible to build applications that can search for and retrieve - metadata from the TIDAL catalog. - - .. seealso:: - - For more information, see the `TIDAL API Reference - <https://developer.tidal.com/apiref>`_. - - Requests to the TIDAL API endpoints must be accompanied by a valid - access token in the header. Minim can obtain client-only access - tokens via the client credentials flow, which requires valid client - credentials (client ID and client secret) to either be provided to - this class's constructor as keyword arguments or be stored as - :code:`TIDAL_CLIENT_ID` and :code:`TIDAL_CLIENT_SECRET` in the - operating system's environment variables. - - .. seealso:: - - To get client credentials, see the `guide on how to register a new - TIDAL application <https://developer.tidal.com/documentation - /dashboard/dashboard-client-credentials>`_. - - If an existing access token is available, it and its expiry time can - be provided to this class's constructor as keyword arguments to - bypass the access token retrieval process. It is recommended that - all other authorization-related keyword arguments be specified so - that a new access token can be obtained when the existing one - expires. - - .. tip:: - - The authorization flow and access token can be changed or updated - at any time using :meth:`set_auflow` and :meth:`set_access_token`, - respectively. - - Minim also stores and manages access tokens and their properties. - When an access token is acquired, it is automatically saved to the - Minim configuration file to be loaded on the next instantiation of - this class. This behavior can be disabled if there are any security - concerns, like if the computer being used is a shared device. - - Parameters - ---------- - client_id : `str`, keyword-only, optional - Client ID. Required for the client credentials flow. If it is - not stored as :code:`TIDAL_CLIENT_ID` in the operating system's - environment variables or found in the Minim configuration file, - it must be provided here. - - client_secret : `str`, keyword-only, optional - Client secret. Required for the client credentials flow. If it - is not stored as :code:`TIDAL_CLIENT_SECRET` in the operating - system's environment variables or found in the Minim - configuration file, it must be provided here. - - flow : `str`, keyword-only, optional - Authorization flow. - - .. container:: - - **Valid values**: - - * :code:`"client_credentials"` for the client credentials - flow. - - access_token : `str`, keyword-only, optional - Access token. If provided here or found in the Minim - configuration file, the authorization process is bypassed. In - the former case, all other relevant keyword arguments should be - specified to automatically refresh the access token when it - expires. - - expiry : `datetime.datetime` or `str`, keyword-only, optional - Expiry time of `access_token` in the ISO 8601 format - :code:`%Y-%m-%dT%H:%M:%SZ`. If provided, the user will be - reauthenticated using the specified authorization flow (if - possible) when `access_token` expires. - - overwrite : `bool`, keyword-only, default: :code:`False` - Determines whether to overwrite an existing access token in the - Minim configuration file. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether newly obtained access tokens and their - associated properties are stored to the Minim configuration - file. - - Attributes - ---------- - session : `requests.Session` - Session used to send requests to the TIDAL API. - - API_URL : `str` - Base URL for the TIDAL API. - - TOKEN_URL : `str` - URL for the TIDAL API token endpoint. - """ - - _FLOWS = {"client_credentials"} - _NAME = f"{__module__}.{__qualname__}" - API_URL = "https://openapi.tidal.com" - TOKEN_URL = "https://auth.tidal.com/v1/oauth2/token" - - def __init__( - self, *, client_id: str = None, client_secret: str = None, - flow: str = "client_credentials", access_token: str = None, - expiry: Union[datetime.datetime, str] = None, - overwrite: bool = False, save: bool = True) -> None: - - """ - Create a TIDAL API client. - """ - - self.session = requests.Session() - self.session.headers["Content-Type"] = "application/vnd.tidal.v1+json" - - if (access_token is None and _config.has_section(self._NAME) - and not overwrite): - flow = _config.get(self._NAME, "flow") - access_token = _config.get(self._NAME, "access_token") - expiry = _config.get(self._NAME, "expiry") - client_id = _config.get(self._NAME, "client_id") - client_secret = _config.get(self._NAME, "client_secret") - - self.set_flow(flow, client_id=client_id, client_secret=client_secret, - save=save) - self.set_access_token(access_token, expiry=expiry) - - def _get_json(self, url: str, **kwargs) -> dict: - - """ - Send a GET request and return the JSON-encoded content of the - response. - - Parameters - ---------- - url : `str` - URL for the GET request. - - **kwargs - Keyword arguments to pass to :meth:`requests.request`. - - Returns - ------- - resp : `dict` - JSON-encoded content of the response. - """ - - return self._request("get", url, **kwargs).json() - - def _request(self, method: str, url: str, **kwargs) -> requests.Response: - - """ - Construct and send a request with status code checking. - - Parameters - ---------- - method : `str` - Method for the request. - - url : `str` - URL for the request. - - **kwargs - Keyword arguments passed to :meth:`requests.request`. - - Returns - ------- - resp : `requests.Response` - Response to the request. - """ - - if self._expiry is not None and datetime.datetime.now() > self._expiry: - self.set_access_token() - - r = self.session.request(method, url, **kwargs) - if r.status_code not in range(200, 299): - try: - error = r.json()["errors"][0] - emsg = f"{r.status_code} {error['code']}: {error['detail']}" - except requests.exceptions.JSONDecodeError: - emsg = f"{r.status_code} {r.reason}" - raise RuntimeError(emsg) - return r - -
-[docs] - def set_access_token( - self, access_token: str = None, *, - expiry: Union[str, datetime.datetime] = None) -> None: - - """ - Set the TIDAL API access token. - - Parameters - ---------- - access_token : `str`, optional - Access token. If not provided, an access token is obtained - using an OAuth 2.0 authorization flow. - - expiry : `str` or `datetime.datetime`, keyword-only, optional - Access token expiry timestamp in the ISO 8601 format - :code:`%Y-%m-%dT%H:%M:%SZ`. If provided, the user will be - reauthenticated using the default authorization flow (if - possible) when `access_token` expires. - """ - - if access_token is None: - if not self._client_id or not self._client_secret: - raise ValueError("TIDAL API client credentials not provided.") - - if self._flow == "client_credentials": - client_b64 = base64.urlsafe_b64encode( - f"{self._client_id}:{self._client_secret}".encode() - ).decode() - r = requests.post( - self.TOKEN_URL, - data={"grant_type": "client_credentials"}, - headers={"Authorization": f"Basic {client_b64}"} - ).json() - access_token = r["access_token"] - expiry = (datetime.datetime.now() - + datetime.timedelta(0, r["expires_in"])) - - if self._save: - _config[self._NAME] = { - "flow": self._flow, - "client_id": self._client_id, - "client_secret": self._client_secret, - "access_token": access_token, - "expiry": expiry.strftime("%Y-%m-%dT%H:%M:%SZ") - } - with open(DIR_HOME / "minim.cfg", "w") as f: - _config.write(f) - - self.session.headers["Authorization"] = f"Bearer {access_token}" - self._expiry = ( - datetime.datetime.strptime(expiry, "%Y-%m-%dT%H:%M:%SZ") - if isinstance(expiry, str) else expiry - )
- - -
-[docs] - def set_flow( - self, flow: str, *, client_id: str = None, - client_secret: str = None, save: bool = True) -> None: - - """ - Set the authorization flow. - - Parameters - ---------- - flow : `str` - Authorization flow. - - .. container:: - - **Valid values**: - - * :code:`"client_credentials"` for the client credentials - flow. - - client_id : `str`, keyword-only, optional - Client ID. Required for all authorization flows. - - client_secret : `str`, keyword-only, optional - Client secret. Required for all authorization flows. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether to save the newly obtained access tokens - and their associated properties to the Minim configuration - file. - """ - - if flow not in self._FLOWS: - emsg = (f"Invalid authorization flow ({flow=}). " - f"Valid values: {', '.join(self._FLOWS)}.") - raise ValueError(emsg) - - self._flow = flow - self._save = save - - if flow == "client_credentials": - self._client_id = client_id or os.environ.get("TIDAL_CLIENT_ID") - self._client_secret = (client_secret - or os.environ.get("TIDAL_CLIENT_SECRET"))
- - - ### ALBUM API ############################################################# - -
-[docs] - def get_album( - self, album_id: Union[int, str], country_code: str - ) -> dict[str, Any]: - - """ - `Album API > Get single album - <https://developer.tidal.com/apiref?ref=get-album>`_: Retrieve - album details by TIDAL album ID. - - Parameters - ---------- - album_id : `int` or `str` - TIDAL album ID. - - **Example**: :code:`251380836`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - Returns - ------- - album : `dict` - TIDAL catalog information for a single album. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <str>, - "barcodeId": <str>, - "title": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "duration": <int>, - "releaseDate": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "numberOfVolumes": <int>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "type": "ALBUM", - "copyright": <str>, - "mediaMetadata": { - "tags": [<str>] - }, - "properties": { - "content": [<str>] - } - } - """ - - return self._get_json(f"{self.API_URL}/albums/{album_id}", - params={"countryCode": country_code})["resource"]
- - -
-[docs] - def get_albums( - self, album_ids: Union[int, str, list[Union[int, str]]], - country_code: str) -> list[dict[str, Any]]: - - """ - `Album API > Get multiple albums - <https://developer.tidal.com/apiref?ref=get-albums-by-ids>`_: - Retrieve a list of album details by TIDAL album IDs. - - Parameters - ---------- - album_ids : `int`, `str`, or `list` - TIDAL album ID(s). - - **Examples**: :code:`"251380836,275646830"` or - :code:`[251380836, 275646830]`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - Returns - ------- - albums : `dict` - A dictionary containing TIDAL catalog information for - multiple albums and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "id": <str>, - "barcodeId": <str>, - "title": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "duration": <int>, - "releaseDate": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "numberOfVolumes": <int>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "type": "ALBUM", - "copyright": <str>, - "mediaMetadata": { - "tags": [<str>] - }, - "properties": { - "content": [<str>] - } - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "metadata": { - "requested": <int>, - "success": <int>, - "failure": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/albums/byIds", - params={"ids": album_ids, "countryCode": country_code} - )
- - -
-[docs] - def get_album_items( - self, album_id: Union[int, str], country_code: str, *, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - `Album API > Get album items - <https://developer.tidal.com/apiref?ref=get-album-items>`_: - Retrieve a list of album items (tracks and videos) by TIDAL - album ID. - - Parameters - ---------- - album_id : `int` or `str` - TIDAL album ID. - - **Examples**: :code:`251380836`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - items : `dict` - A dictionary containing TIDAL catalog information for - tracks and videos in the specified album and metadata for - the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "artifactType": <str>, - "id": <str>, - "title": str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "album": { - "id": <str>, - "title": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [] - }, - "duration": <int>, - "trackNumber": <int>, - "volumeNumber": <int>, - "isrc": <str>, - "copyright": <str>, - "mediaMetadata": { - "tags": [<str>] - }, - "properties": { - "content": [<str>] - } - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "metadata": { - "total": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/albums/{album_id}/items", - params={ - "countryCode": country_code, - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_album_by_barcode_id( - self, barcode_id: Union[int, str], country_code: str - ) -> dict[str, Any]: - - """ - `Album API > Get album by barcode ID - <https://developer.tidal.com - /apiref?ref=get-albums-by-barcode-id>`_: Retrieve a list of album - details by barcode ID. - - Parameters - ---------- - barcode_id : `int` or `str` - Barcode ID in EAN-13 or UPC-A format. - - **Example**: :code:`196589525444`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - Returns - ------- - album : `dict` - TIDAL catalog information for a single album. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "id": <str>, - "barcodeId": <str>, - "title": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "duration": <int>, - "releaseDate": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "numberOfVolumes": <int>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "type": "ALBUM", - "copyright": <str>, - "mediaMetadata": { - "tags": [<str>] - }, - "properties": { - "content": [<str>] - } - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "metadata": { - "requested": 1, - "success": 1, - "failure": 0 - } - } - - """ - - return self._get_json( - f"{self.API_URL}/albums/byBarcodeId", - params={"barcodeId": barcode_id, "countryCode": country_code} - )
- - -
-[docs] - def get_similar_albums( - self, album_id: Union[int, str], country_code: str, *, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - `Album API > Get similar albums for the given album - <https://developer.tidal.com/apiref?ref=get-similar-albums>`_: - Retrieve a list of albums similar to the given album. - - Parameters - ---------- - album_id : `int` or `str` - TIDAL album ID. - - **Examples**: :code:`251380836`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - album_ids : `dict` - A dictionary containing TIDAL album IDs for similar albums - and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "id": <str> - } - } - ], - "metadata": { - "total": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/albums/{album_id}/similar", - params={ - "countryCode": country_code, - "limit": limit, - "offset": offset - } - )
- - - ### ARTIST API ############################################################ - -
-[docs] - def get_artist( - self, artist_id: Union[int, str], country_code: str - ) -> dict[str, Any]: - - """ - `Artist API > Get single artist - <https://developer.tidal.com/apiref?ref=get-artist>`_: Retrieve - artist details by TIDAL artist ID. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - Returns - ------- - artist : `dict` - TIDAL catalog information for a single artist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ] - } - """ - - return self._get_json(f"{self.API_URL}/artists/{artist_id}", - params={"countryCode": country_code})["resource"]
- - -
-[docs] - def get_artists( - self, artist_ids: Union[int, str, list[Union[int, str]]], - country_code: str) -> dict[str, Any]: - - """ - `Artist API > Get multiple artists - <https://developer.tidal.com/apiref?ref=get-artists-by-ids>`_: - Retrieve a list of artist details by TIDAL artist IDs. - - Parameters - ---------- - artist_ids : `int`, `str`, or `list` - TIDAL artist ID(s). - - **Examples**: :code:`"1566,7804"` or :code:`[1566, 7804]`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - Returns - ------- - artists : `dict` - A dictionary containing TIDAL catalog information for - multiple artists and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ] - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "metadata": { - "requested": <int>, - "success": <int>, - "failure": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/artists", - params={"ids": artist_ids, "countryCode": country_code} - )
- - -
-[docs] - def get_artist_albums( - self, artist_id: Union[int, str], country_code: str, *, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - `Artist API > Get albums by artist - <https://developer.tidal.com/apiref?ref=get-artist-albums>`_: - Retrieve a list of albums by TIDAL artist ID. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - albums : `dict` - A dictionary containing TIDAL catalog information for - albums by the specified artist and metadata for the - returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "id": <str>, - "barcodeId": <str>, - "title": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "duration": <int>, - "releaseDate": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "numberOfVolumes": <int>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "type": "ALBUM", - "copyright": <str>, - "mediaMetadata": { - "tags": <str> - }, - "properties": { - "content": <str> - } - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "metadata": { - "total": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/artists/{artist_id}/albums", - params={ - "countryCode": country_code, - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_artist_tracks( - self, artist_id: Union[int, str], country_code: str, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - `Track API > Get tracks by artist - <https://developer.tidal.com/apiref?ref=get-tracks-by-artist>`_: - Retrieve a list of tracks made by the specified artist. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - tracks : `dict` - A dictionary containing TIDAL catalog information for tracks - by the specified artist and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "properties": { - "content": <str> - }, - "id": <str>, - "version": <str>, - "duration": <int>, - "album": { - "id": <str>, - "title": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ] - }, - "title": <str>, - "copyright": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "popularity": <float>, - "isrc": <str>, - "trackNumber": <int>, - "volumeNumber": <int>, - "tidalUrl": <str>, - "providerInfo": { - "providerId": <str>, - "providerName": <str> - }, - "artifactType": <str>, - "mediaMetadata": { - "tags": <str> - } - }, - "id": <str>, - "status": <int>, - "message": <str> - } - ], - "metadata": { - "total": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/artists/{artist_id}/tracks", - params={ - "countryCode": country_code, - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_similar_artists( - self, artist_id: Union[int, str], country_code: str, *, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - `Artist API > Get similar artists for the given artist - <https://developer.tidal.com/apiref?ref=get-similar-artists>`_: - Retrieve a list of artists similar to the given artist. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - artist_ids : `dict` - A dictionary containing TIDAL artist IDs for similar albums - and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "id": <str> - } - } - ], - "metadata": { - "total": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/artists/{artist_id}/similar", - params={ - "countryCode": country_code, - "limit": limit, - "offset": offset - } - )
- - - ### TRACK API ############################################################# - -
-[docs] - def get_track( - self, track_id: Union[int, str], country_code: str - ) -> dict[str, Any]: - - """ - `Track API > Get single track - <https://developer.tidal.com/apiref?ref=get-track>`_: Retrieve - track details by TIDAL track ID. - - Parameters - ---------- - track_id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - Returns - ------- - track : `dict` - TIDAL catalog information for a single track. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "artifactType": "track", - "id": <str>, - "title": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "album": { - "id": <str>, - "title": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ] - }, - "duration": <int>, - "trackNumber": <int>, - "volumeNumber": <int>, - "isrc": <int>, - "copyright": <int>, - "mediaMetadata": { - "tags": [<str>] - }, - "properties": { - "content": [<str>] - } - } - """ - - return self._get_json(f"{self.API_URL}/tracks/{track_id}", - params={"countryCode": country_code})["resource"]
- - -
-[docs] - def get_tracks( - self, track_ids: Union[int, str, list[Union[int, str]]], - country_code: str) -> dict[str, Any]: - - """ - `Album API > Get multiple tracks - <https://developer.tidal.com/apiref?ref=get-tracks-by-ids>`_: - Retrieve a list of track details by TIDAL track IDs. - - Parameters - ---------- - track_ids : `int`, `str`, or `list` - TIDAL track ID(s). - - **Examples**: :code:`"251380837,251380838"` or - :code:`[251380837, 251380838]`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - Returns - ------- - tracks : `dict` - A dictionary containing TIDAL catalog information for - multiple tracks and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "artifactType": "track", - "id": <str>, - "title": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "album": { - "id": <str>, - "title": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ] - }, - "duration": <int>, - "trackNumber": <int>, - "volumeNumber": <int>, - "isrc": <int>, - "copyright": <int>, - "mediaMetadata": { - "tags": [<str>] - }, - "properties": { - "content": [<str>] - } - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "metadata": { - "requested": <int>, - "success": <int>, - "failure": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/tracks", - params={"ids": track_ids, "countryCode": country_code} - )
- - -
-[docs] - def get_tracks_by_isrc( - self, isrc: str, country_code: str, limit: int = None, - offset: int = None) -> dict[str, Any]: - - """ - `Track API > Get tracks by ISRC - <https://developer.tidal.com/apiref?ref=get-tracks-by-isrc>`_: - Retrieve a list of track details by ISRC. - - Parameters - ---------- - isrc : `str` - Valid ISRC code (usually comprises 12 alphanumeric - characters). - - **Example**: :code:`"USSM12209515"`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - limit : `int`, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - tracks : `dict` - A dictionary containing TIDAL catalog information for - tracks with the specified ISRC and metadata for the - returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "artifactType": "track", - "id": <str>, - "title": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "album": { - "id": <str>, - "title": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ] - }, - "duration": <int>, - "trackNumber": <int>, - "volumeNumber": <int>, - "isrc": <int>, - "copyright": <int>, - "mediaMetadata": { - "tags": [<str>] - }, - "properties": { - "content": [<str>] - } - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "metadata": { - "requested": <int>, - "success": <int>, - "failure": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/tracks/byIsrc", - params={ - "isrc": isrc, - "countryCode": country_code, - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_similar_tracks( - self, track_id: Union[int, str], country_code: str, *, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - `Track API > Get similar tracks for the given track - <https://developer.tidal.com/apiref?ref=get-similar-tracks>`_: - Retrieve a list of tracks similar to the given track. - - Parameters - ---------- - track_id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - track_ids : `dict` - A dictionary containing TIDAL track IDs for similar albums - and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "resource": { - "id": <str> - } - } - ], - "metadata": { - "total": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/tracks/{track_id}/similar", - params={ - "countryCode": country_code, - "limit": limit, - "offset": offset - } - )
- - - ### VIDEO API ############################################################# - -
-[docs] - def get_video( - self, video_id: Union[int, str], country_code: str - ) -> dict[str, Any]: - - """ - `Video API > Get single video - <https://developer.tidal.com/apiref?ref=get-video>`_: Retrieve - video details by TIDAL video ID. - - Parameters - ---------- - video_id : `int` or `str` - TIDAL video ID. - - **Example**: :code:`75623239`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - Returns - ------- - video : `dict` - TIDAL catalog information for a single video. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "artifactType": "video", - "id": <str>, - "title": <str>, - "image": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "releaseDate": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "duration": <int>, - "trackNumber": <int>, - "volumeNumber": <int>, - "isrc": <str>, - "properties": { - "content": [<str>] - } - } - """ - - return self._get_json(f"{self.API_URL}/videos/{video_id}", - params={"countryCode": country_code})["resource"]
- - -
-[docs] - def get_videos( - self, video_ids: Union[int, str, list[Union[int, str]]], - country_code: str) -> list[dict[str, Any]]: - - """ - `Album API > Get multiple videos - <https://developer.tidal.com/apiref?ref=get-videos-by-ids>`_: - Retrieve a list of video details by TIDAL video IDs. - - Parameters - ---------- - video_ids : `int`, `str`, or `list` - TIDAL video ID(s). - - **Examples**: :code:`"59727844,75623239"` or - :code:`[59727844, 75623239]`. - - country_code : `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - Returns - ------- - videos : `dict` - A dictionary containing TIDAL catalog information for - multiple videos and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "data": [ - { - "artifactType": "video", - "id": <str>, - "title": <str>, - "image": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "releaseDate": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "duration": <int>, - "trackNumber": <int>, - "volumeNumber": <int>, - "isrc": <str>, - "properties": { - "content": [<str>] - } - } - ], - "metadata": { - "requested": <int>, - "success": <int>, - "failure": <int> - } - } - """ - - return self._get_json( - f"{self.API_URL}/videos", - params={"ids": video_ids, "countryCode": country_code} - )
- - - ### SEARCH API ############################################################ - -
-[docs] - def search( - self, query: str, country_code: str, *, type: str = None, - limit: int = None, offset: int = None, popularity: str = None - ) -> dict[str, list[dict[str, Any]]]: - - """ - `Search API > Search for catalog items - <https://developer.tidal.com/apiref?ref=search>`_: Search for - albums, artists, tracks, and videos. - - Parameters - ---------- - query : `str` - Search query. - - **Example**: :code:`"Beyoncé"`. - - country_code: `str` - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - - type : `str`, keyword-only, optional - Target search type. Searches for all types if not specified. - - **Valid values**: :code:`"ALBUMS"`, :code:`"ARTISTS"`, - :code:`"TRACKS"`, :code:`"VIDEOS"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - popularity : `str`, keyword-only, optional - Specify which popularity type to apply for query result. - :code:`"WORLDWIDE"` is used if not specified. - - **Valid values**: :code:`"WORLDWIDE"` or :code:`"COUNTRY"`. - - Returns - ------- - results : `dict` - A dictionary containing TIDAL catalog information for - albums, artists, tracks, and videos matching the search - query, and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "albums": [ - { - "resource": { - "id": <str>, - "barcodeId": <str>, - "title": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "duration": <int>, - "releaseDate": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "numberOfVolumes": <int>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "type": "ALBUM", - "copyright": <str>, - "mediaMetadata": { - "tags": [<str>] - }, - "properties": { - "content": [<str>] - } - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "artists": [ - { - "resource": { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ] - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "tracks": [ - { - "resource": { - "artifactType": "track", - "id": <str>, - "title": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "album": { - "id": <str>, - "title": <str>, - "imageCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "videoCover": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ] - }, - "duration": <int>, - "trackNumber": <int>, - "volumeNumber": <int>, - "isrc": <int>, - "copyright": <int>, - "mediaMetadata": { - "tags": [<str>] - }, - "properties": { - "content": [<str>] - } - }, - "id": <str>, - "status": 200, - "message": "success" - } - ], - "videos": [ - { - "artifactType": "video", - "id": <str>, - "title": <str>, - "image": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "releaseDate": <str>, - "artists": [ - { - "id": <str>, - "name": <str>, - "picture": [ - { - "url": <str>, - "width": <int>, - "height": <int> - } - ], - "main": <bool> - } - ], - "duration": <int>, - "trackNumber": <int>, - "volumeNumber": <int>, - "isrc": <str>, - "properties": { - "content": [<str>] - } - } - ] - } - """ - - if type and type not in \ - (TYPES := {"ALBUMS", "ARTISTS", "TRACKS", "VIDEOS"}): - emsg = ("Invalid target search type. Valid values: " - f"{', '.join(TYPES)}.") - raise ValueError(emsg) - - return self._get_json( - f"{self.API_URL}/search", - params={ - "query": query, - "countryCode": country_code, - "type": type, - "limit": limit, - "offset": offset, - "popularity": popularity - } - )
-
- - -
-[docs] -class PrivateAPI: - - """ - Private TIDAL API client. - - The private TIDAL API allows media (tracks, videos), collections - (albums, playlists), and performers to be queried, and information - about them to be retrieved. As there is no available official - documentation for the private TIDAL API, its endpoints have been - determined by watching HTTP network traffic. - - .. attention:: - - As the private TIDAL API is not designed to be publicly - accessible, this class can be disabled or removed at any time to - ensure compliance with the `TIDAL Developer Terms of Service - <https://developer.tidal.com/documentation/guidelines - /guidelines-developer-terms>`_. - - While authentication is not necessary to search for and retrieve - data from public content, it is required to access personal content - and stream media (with an active TIDAL subscription). In the latter - case, requests to the private TIDAL API endpoints must be - accompanied by a valid user access token in the header. - - Minim can obtain user access tokens via the authorization code with - proof key for code exchange (PKCE) and device code flows. These - OAuth 2.0 authorization flows require valid client credentials - (client ID and client secret) to either be provided to this class's - constructor as keyword arguments or be stored as - :code:`TIDAL_PRIVATE_CLIENT_ID` and - :code:`TIDAL_PRIVATE_CLIENT_SECRET` in the operating system's - environment variables. - - .. hint:: - - Client credentials can be extracted from the software you use to - access TIDAL, including but not limited to the TIDAL Web Player - and the Android, iOS, macOS, and Windows applications. Only the - TIDAL Web Player and desktop application client credentials can - be used without authorization. - - If an existing access token is available, it and its accompanying - information (refresh token and expiry time) can be provided to this - class's constructor as keyword arguments to bypass the access token - retrieval process. It is recommended that all other - authorization-related keyword arguments be specified so that a new - access token can be obtained when the existing one expires. - - .. tip:: - - The authorization flow and access token can be changed or updated - at any time using :meth:`set_flow` and :meth:`set_access_token`, - respectively. - - Minim also stores and manages access tokens and their properties. - When an access token is acquired, it is automatically saved to the - Minim configuration file to be loaded on the next instantiation of - this class. This behavior can be disabled if there are any security - concerns, like if the computer being used is a shared device. - - Parameters - ---------- - client_id : `str`, keyword-only, optional - Client ID. If it is not stored as - :code:`TIDAL_PRIVATE_CLIENT_ID` in the operating system's - environment variables or found in the Minim configuration file, - it must be provided here. - - client_secret : `str`, keyword-only, optional - Client secret. Required for the authorization code and device - code flows. If it is not stored as - :code:`TIDAL_PRIVATE_CLIENT_SECRET` in the operating system's - environment variables or found in the Minim configuration file, - it must be provided here. - - flow : `str`, keyword-only, optional - Authorization flow. If not specified, no user authorization - will be performed. - - .. container:: - - **Valid values**: - - * :code:`"pkce"` for the authorization code with proof key - for code exchange (PKCE) flow. - * :code:`"device_code"` for the device code flow. - - browser : `bool`, keyword-only, default: :code:`False` - Determines whether a web browser is automatically opened for the - authorization code with PKCE or device code flows. If - :code:`False`, users will have to manually open the - authorization URL, and for the authorization code flow, provide - the full callback URI via the terminal. For the authorization - code with PKCE flow, the Playwright framework by Microsoft is - used. - - scopes : `str` or `list`, keyword-only, default: :code:`"r_usr"` - Authorization scopes to request user access for in the OAuth 2.0 - flows. - - **Valid values**: :code:`"r_usr"`, :code:`"w_usr"`, and - :code:`"w_sub"` (device code flow only). - - user_agent : `str`, keyword-only, optional - User agent information to send in the header of HTTP requests. - - .. note:: - - If not specified, TIDAL may temporarily block your IP address - if you are making requests too quickly. - - access_token : `str`, keyword-only, optional - Access token. If provided here or found in the Minim - configuration file, the authorization process is bypassed. In - the former case, all other relevant keyword arguments should be - specified to automatically refresh the access token when it - expires. - - refresh_token : `str`, keyword-only, optional - Refresh token accompanying `access_token`. If not provided, - the user will be reauthenticated using the specified - authorization flow when `access_token` expires. - - expiry : `datetime.datetime` or `str`, keyword-only, optional - Expiry time of `access_token` in the ISO 8601 format - :code:`%Y-%m-%dT%H:%M:%SZ`. If provided, the user will be - reauthenticated using `refresh_token` (if available) or the - specified authorization flow (if possible) when `access_token` - expires. - - overwrite : `bool`, keyword-only, default: :code:`False` - Determines whether to overwrite an existing access token in the - Minim configuration file. - - save : `bool`, keyword-only, default: :code:`True` - Determines whether newly obtained access tokens and their - associated properties are stored to the Minim configuration - file. - - Attributes - ---------- - API_URL : `str` - Base URL for the private TIDAL API. - - AUTH_URL : `str` - URL for device code requests. - - LOGIN_URL : `str` - URL for authorization code requests. - - REDIRECT_URL : `str` - URL for authorization code callbacks. - - RESOURCES_URL : `str` - URL for cover art and image requests. - - TOKEN_URL : `str` - URL for access token requests. - - WEB_URL : `str` - URL for the TIDAL Web Player. - - session : `requests.Session` - Session used to send requests to the private TIDAL API. - """ - - _FLOWS = {"pkce", "device_code"} - _NAME = f"{__module__}.{__qualname__}" - - API_URL = "https://api.tidal.com" - AUTH_URL = "https://auth.tidal.com/v1/oauth2" - LOGIN_URL = "https://login.tidal.com" - REDIRECT_URI = "tidal://login/auth" - RESOURCES_URL = "http://resources.tidal.com" - WEB_URL = "https://listen.tidal.com" - - def __init__( - self, *, client_id: str = None, client_secret: str = None, - flow: str = None, browser: bool = False, - scopes: Union[str, list[str]] = "r_usr", user_agent: str = None, - access_token: str = None, refresh_token: str = None, - expiry: datetime.datetime = None, overwrite: bool = False, - save: bool = True) -> None: - - """ - Create a private TIDAL API client. - """ - - self.session = requests.Session() - if user_agent: - self.session.headers["User-Agent"] = user_agent - - if (access_token is None and _config.has_section(self._NAME) - and not overwrite): - flow = _config.get(self._NAME, "flow") - access_token = _config.get(self._NAME, "access_token") - refresh_token = _config.get(self._NAME, "refresh_token") - expiry = _config.get(self._NAME, "expiry") - client_id = _config.get(self._NAME, "client_id") - client_secret = _config.get(self._NAME, "client_secret") - scopes = _config.get(self._NAME, "scopes") - - self.set_flow(flow, client_id=client_id, client_secret=client_secret, - browser=browser, scopes=scopes, save=save) - self.set_access_token(access_token, refresh_token=refresh_token, - expiry=expiry) - - def _check_scope( - self, endpoint: str, scope: str = None, *, - flows: Union[str, list[set], set[str]] = None, - require_authentication: bool = True) -> None: - - """ - Check if the user has granted the appropriate authorization - scope for the desired endpoint. - - Parameters - ---------- - endpoint : `str` - Private TIDAL API endpoint. - - scope : `str`, optional - Required scope for `endpoint`. - - flows : `str`, `list`, or `set`, keyword-only, optional - Authorization flows for which `scope` is required. If not - specified, `flows` defaults to all supported authorization - flows. - - require_authentication : `bool`, keyword-only, default: :code:`True` - Specifies whether the endpoint requires user authentication. - Some endpoints can be used without authentication but require - specific scopes when user authentication has been performed. - """ - - if flows is None: - flows = self._FLOWS - - if require_authentication: - if self._flow is None: - emsg = (f"{self._NAME}.{endpoint}() requires user " - "authentication.") - elif self._flow in flows and scope and scope not in self._scopes: - emsg = (f"{self._NAME}.{endpoint}() requires the '{scope}' " - "authorization scope.") - else: - return - elif self._flow in flows and scope and scope not in self._scopes: - emsg = (f"{self._NAME}.{endpoint}() requires the '{scope}' " - "authorization scope when user authentication has " - f"been performed via the '{self._flow}' " - "authorization flow.") - else: - return - raise RuntimeError(emsg) - - def _get_authorization_code(self, code_challenge: str) -> str: - - """ - Get an authorization code to be exchanged for an access token in - the authorization code flow. - - Parameters - ---------- - code_challenge : `str`, optional - Code challenge for the authorization code with PKCE flow. - - Returns - ------- - auth_code : `str` - Authorization code. - """ - - params = { - "client_id": self._client_id, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - "redirect_uri": self.REDIRECT_URI, - "response_type": "code", - } - if self._scopes: - params["scope"] = self._scopes - auth_url = (f"{self.LOGIN_URL}/authorize?" - f"{urllib.parse.urlencode(params)}") - - if self._browser: - har_file = DIR_TEMP / "minim_tidal_private.har" - - with sync_playwright() as playwright: - browser = playwright.firefox.launch(headless=False) - context = browser.new_context( - locale="en-US", - timezone_id="America/Los_Angeles", - record_har_path=har_file, - **playwright.devices["Desktop Firefox HiDPI"] - ) - page = context.new_page() - page.goto(auth_url, timeout=0) - page.wait_for_url(f"{self.REDIRECT_URI}*", - wait_until="commit") - context.close() - browser.close() - - with open(har_file, "r") as f: - queries = dict( - urllib.parse.parse_qsl( - urllib.parse.urlparse( - re.search(fr'{self.REDIRECT_URI}\?(.*?)"', - f.read()).group(0) - ).query - ) - ) - har_file.unlink() - - else: - print("To grant Minim access to TIDAL data and features, " - "open the following link in your web browser:\n\n" - f"{auth_url}\n") - uri = input("After authorizing Minim to access TIDAL on " - "your behalf, copy and paste the URI beginning " - f"with '{self.REDIRECT_URI}' below.\n\nURI: ") - queries = dict( - urllib.parse.parse_qsl(urllib.parse.urlparse(uri).query) - ) - - if "code" not in queries: - raise RuntimeError("Authorization failed.") - return queries["code"] - - def _get_country_code(self, country_code: str = None) -> str: - - """ - Get the ISO 3166-1 alpha-2 country code to use for requests. - - Parameters - ---------- - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - country_code : `str` - ISO 3166-1 alpha-2 country code. - """ - - return country_code or getattr(self, "_country_code", None) \ - or self.get_country_code() - - def _get_json(self, url: str, **kwargs) -> dict: - - """ - Send a GET request and return the JSON-encoded content of the - response. - - Parameters - ---------- - url : `str` - URL for the GET request. - - **kwargs - Keyword arguments to pass to :meth:`requests.request`. - - Returns - ------- - resp : `dict` - JSON-encoded content of the response. - """ - - return self._request("get", url, **kwargs).json() - - def _refresh_access_token(self) -> None: - - """ - Refresh the expired excess token. - """ - - if self._flow is None or not self._refresh_token \ - or not self._client_id \ - or (self._flow == "device_code" and not self._client_secret): - self.set_access_token() - else: - r = requests.post( - f"{self.LOGIN_URL}/oauth2/token", - data={ - "client_id": self._client_id, - "client_secret": self._client_secret, - "grant_type": "refresh_token", - "refresh_token": self._refresh_token - }, - ).json() - - self.session.headers["Authorization"] = f"Bearer {r['access_token']}" - self._expiry = (datetime.datetime.now() - + datetime.timedelta(0, r["expires_in"])) - self._scopes = r["scope"] - - if self._save: - _config[self._NAME].update({ - "access_token": r["access_token"], - "expiry": self._expiry.strftime("%Y-%m-%dT%H:%M:%SZ"), - "scopes": self._scopes - }) - with open(DIR_HOME / "minim.cfg", "w") as f: - _config.write(f) - - def _request( - self, method: str, url: str, retry: bool = True, **kwargs - ) -> requests.Response: - - """ - Construct and send a request with status code checking. - - Parameters - ---------- - method : `str` - Method for the request. - - url : `str` - URL for the request. - - retry : `bool` - Specifies whether to retry the request if the response has - a non-2xx status code. - - **kwargs - Keyword arguments passed to :meth:`requests.request`. - - Returns - ------- - resp : `requests.Response` - Response to the request. - """ - - if self._expiry is not None and datetime.datetime.now() > self._expiry: - self._refresh_access_token() - - r = self.session.request(method, url, **kwargs) - if r.status_code not in range(200, 299): - if r.text: - error = r.json() - substatus = (error["subStatus"] if "subStatus" in error - else error["sub_status"] if "sub_status" in error - else "") - description = (error["userMessage"] if "userMessage" in error - else error["description"] if "description" in error - else error["error_description"] if "error_description" in error - else "") - emsg = f"{r.status_code}" - if substatus: - emsg += f".{substatus}" - emsg += f" {description}" - else: - emsg = f"{r.status_code} {r.reason}" - if r.status_code == 401 and substatus == 11003 and retry: - logging.warning(emsg) - self._refresh_access_token() - return self._request(method, url, False, **kwargs) - else: - raise RuntimeError(emsg) - return r - -
-[docs] - def set_access_token( - self, access_token: str = None, *, refresh_token: str = None, - expiry: Union[str, datetime.datetime] = None) -> None: - - """ - Set the private TIDAL API access token. - - Parameters - ---------- - access_token : `str`, optional - Access token. If not provided, an access token is obtained - using an OAuth 2.0 authorization flow or from the Spotify - Web Player. - - refresh_token : `str`, keyword-only, optional - Refresh token accompanying `access_token`. - - expiry : `str` or `datetime.datetime`, keyword-only, optional - Access token expiry timestamp in the ISO 8601 format - :code:`%Y-%m-%dT%H:%M:%SZ`. If provided, the user will be - reauthenticated using the refresh token (if available) or - the default authorization flow (if possible) when - `access_token` expires. - """ - - if access_token is None: - if self._flow is None: - self._expiry = datetime.datetime.max - return - else: - if not self._client_id: - emsg = "Private TIDAL API client ID not provided." - raise ValueError(emsg) - - if self._flow == "pkce": - data = { - "client_id": self._client_id, - "code_verifier": secrets.token_urlsafe(32), - "grant_type": "authorization_code", - "redirect_uri": self.REDIRECT_URI, - "scope": self._scopes - } - data["code"] = self._get_authorization_code( - base64.urlsafe_b64encode( - hashlib.sha256( - data["code_verifier"].encode() - ).digest() - ).decode().rstrip("=") - ) - r = requests.post(f"{self.LOGIN_URL}/oauth2/token", - json=data).json() - elif self._flow == "device_code": - if not self._client_id: - emsg = "Private TIDAL API client secret not provided." - raise ValueError(emsg) - - data = {"client_id": self._client_id} - if self._scopes: - data["scope"] = self._scopes - r = requests.post(f"{self.AUTH_URL}/device_authorization", - data=data).json() - if "error" in r: - emsg = (f"{r['status']}.{r['sub_status']} " - f"{r['error_description']}") - raise ValueError(emsg) - data["device_code"] = r["deviceCode"] - data["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code" - - verification_uri = f"http://{r['verificationUriComplete']}" - if self._browser: - webbrowser.open(verification_uri) - else: - print("To grant Minim access to TIDAL data and " - "features, open the following link in " - f"your web browser:\n\n{verification_uri}\n") - while True: - time.sleep(2) - r = requests.post( - f"{self.AUTH_URL}/token", - auth=(self._client_id, self._client_secret), - data=data - ).json() - if "error" not in r: - break - elif r["error"] != "authorization_pending": - raise RuntimeError(f"{r['status']}.{r['sub_status']} " - f"{r['error_description']}") - access_token = r["access_token"] - refresh_token = r["refresh_token"] - expiry = (datetime.datetime.now() - + datetime.timedelta(0, r["expires_in"])) - - if self._save: - _config[self._NAME] = { - "flow": self._flow, - "client_id": self._client_id, - "access_token": access_token, - "refresh_token": refresh_token, - "expiry": expiry.strftime("%Y-%m-%dT%H:%M:%SZ"), - "scopes": self._scopes - } - if hasattr(self, "_client_secret"): - _config[self._NAME]["client_secret"] \ - = self._client_secret - with open(DIR_HOME / "minim.cfg", "w") as f: - _config.write(f) - - if len(access_token) == 16: - self.session.headers["x-tidal-token"] = access_token - self._refresh_token = self._expiry = None - else: - self.session.headers["Authorization"] = f"Bearer {access_token}" - self._refresh_token = refresh_token - self._expiry = ( - datetime.datetime.strptime(expiry, "%Y-%m-%dT%H:%M:%SZ") - if isinstance(expiry, str) else expiry - ) - - if self._flow is not None: - me = self.get_profile() - self._country_code = me["countryCode"] - self._user_id = me["userId"]
- - -
-[docs] - def set_flow( - self, flow: str, client_id: str, *, client_secret: str = None, - browser: bool = False, scopes: Union[str, list[str]] = "", - save: bool = True) -> None: - - """ - Set the authorization flow. - - Parameters - ---------- - flow : `str` - Authorization flow. If not specified, no user authentication - will be performed. - - .. container:: - - **Valid values**: - - * :code:`"pkce"` for the authorization code with proof - key for code exchange (PKCE) flow. - * :code:`"client_credentials"` for the client credentials - flow. - - client_id : `str` - Client ID. - - client_secret : `str`, keyword-only, optional - Client secret. Required for all OAuth 2.0 authorization - flows. - - browser : `bool`, keyword-only, default: :code:`False` - Determines whether a web browser is automatically opened for - the authorization code with PKCE or device code flows. If - :code:`False`, users will have to manually open the - authorization URL, and for the authorization code flow, - provide the full callback URI via the terminal. For the - authorization code with PKCE flow, the Playwright framework - by Microsoft is used. - - scopes : `str` or `list`, keyword-only, optional - Authorization scopes to request user access for in the OAuth - 2.0 flows. - - **Valid values**: :code:`"r_usr"`, :code:`"w_usr"`, and - :code:`"w_sub"` (device code flow only). - - save : `bool`, keyword-only, default: :code:`True` - Determines whether to save the newly obtained access tokens - and their associated properties to the Minim configuration - file. - """ - - if flow and flow not in self._FLOWS: - emsg = (f"Invalid authorization flow ({flow=}). " - f"Valid values: {', '.join(self._FLOWS)}.") - raise ValueError(emsg) - - self._flow = flow - self._save = save - self._client_id = client_id or os.environ.get("TIDAL_PRIVATE_CLIENT_ID") - - if flow: - if "x-tidal-token" in self.session.headers: - del self.session.headers["x-tidal-token"] - - self._browser = browser - if flow == "pkce" and browser and not FOUND_PLAYWRIGHT: - self._browser = False - wmsg = ("The Playwright web framework was not found, " - "so automatic authorization code retrieval is " - "not available.") - warnings.warn(wmsg) - - self._client_secret = (client_secret - or os.environ.get("TIDAL_PRIVATE_CLIENT_SECRET")) - self._scopes = " ".join(scopes) if isinstance(scopes, list) \ - else scopes - else: - self.session.headers["x-tidal-token"] = self._client_id - self._scopes = ""
- - - ### ALBUMS ################################################################ - -
-[docs] - def get_album( - self, album_id: Union[int, str], country_code: str = None - ) -> dict[str, Any]: - - """ - Get TIDAL catalog information for an album. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - album_id : `int` or `str` - TIDAL album ID. - - **Example**: :code:`251380836`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - album : `dict` - TIDAL catalog information for an album. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "title": <str>, - "duration": <int>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "allowStreaming": <bool>, - "premiumStreamingOnly": <bool>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "numberOfVolumes": <int>, - "releaseDate": <str>, - "copyright": <str>, - "type": "ALBUM", - "version": <str>, - "url": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str>, - "explicit": <bool>, - "upc": <str>, - "popularity": <int>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ] - } - """ - - self._check_scope("get_album", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/albums/{album_id}", - params={"countryCode": self._get_country_code(country_code)} - )
- - -
-[docs] - def get_album_items( - self, album_id: Union[int, str], country_code: str = None, *, - limit: int = 100, offset: int = None, credits: bool = False - ) -> dict[str, Any]: - - """ - Get TIDAL catalog information for items (tracks and videos) in - an album. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - album_id : `int` or `str` - TIDAL album ID. - - **Examples**: :code:`251380836`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`100` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - credits : `bool`, keyword-only, default: :code:`False` - Determines whether credits for each item is returned. - - Returns - ------- - items : `dict` - A dictionary containing TIDAL catalog information for - tracks and videos in the specified album and metadata for - the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "item": { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": >int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - }, - "type": "track" - } - ] - } - """ - - self._check_scope("get_album_items", "r_usr", flows={"device_code"}, - require_authentication=False) - - url = f"{self.API_URL}/v1/albums/{album_id}/items" - if credits: - url += "/credits" - return self._get_json( - url, - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_album_credits( - self, album_id: Union[int, str], country_code: str = None - ) -> dict[str, Any]: - - """ - Get credits for an album. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - album_id : `int` or `str` - TIDAL album ID. - - **Example**: :code:`251380836`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - credits : `dict` - A dictionary containing TIDAL catalog information for the - album contributors. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "type": <str>, - "contributors": [ - { - "name": <str> - } - ] - } - ] - """ - - self._check_scope("get_album_credits", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/albums/{album_id}/credits", - params={"countryCode": self._get_country_code(country_code)} - )
- - -
-[docs] - def get_album_review( - self, album_id: Union[int, str], country_code: str = None - ) -> dict[str, str]: - - """ - Get a review of or a synopsis for an album. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - album_id : `int` or `str` - TIDAL album ID. - - **Example**: :code:`251380836`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - review : `dict` - A dictionary containing a review of or a synopsis for an - album and its source. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "source": <str>, - "lastUpdated": <str>, - "text": <str>, - "summary": <str> - } - """ - - self._check_scope("get_album_review", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/albums/{album_id}/review", - params={"countryCode": self._get_country_code(country_code)} - )
- - -
-[docs] - def get_similar_albums( - self, album_id: Union[int, str], country_code: str = None - ) -> dict[str, Any]: - - """ - Get TIDAL catalog information for albums similar to the - specified album. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - album_id : `int` or `str` - TIDAL album ID. - - **Example**: :code:`251380836`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - album : `dict` - TIDAL catalog information for an album. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "title": <str>, - "duration": <int>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "allowStreaming": <bool>, - "premiumStreamingOnly": <bool>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "numberOfVolumes": <int>, - "releaseDate": <str>, - "copyright": <str>, - "type": "ALBUM", - "version": <str>, - "url": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str>, - "explicit": <bool>, - "upc": <str>, - "popularity": <int>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ] - } - ], - "source": <str> - } - """ - - self._check_scope("get_similar_albums", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/albums/{album_id}/similar", - params={"countryCode": self._get_country_code(country_code)} - )
- - -
-[docs] - def get_favorite_albums( - self, country_code: str = None, *, limit: int = 50, - offset: int = None, order: str = "DATE", - order_direction: str = "DESC") -> None: - - """ - Get TIDAL catalog information for albums in the current user's - collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`50` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - order : `str`, keyword-only, default: :code:`"DATE"` - Sorting order. - - **Valid values**: :code:`"DATE"` and :code:`"NAME"`. - - order_direction : `str`, keyword-only, default: :code:`"DESC"` - Sorting order direction. - - **Valid values**: :code:`"DESC"` and :code:`"ASC"`. - - Returns - ------- - albums : `dict` - A dictionary containing TIDAL catalog information for albums - in the current user's collection and metadata for the - returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "created": <str>, - "item": { - "id": <int>, - "title": <str>, - "duration": <int>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "allowStreaming": <bool>, - "premiumStreamingOnly": <bool>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "numberOfVolumes": <int>, - "releaseDate": <str>, - "copyright": <str>, - "type": "ALBUM", - "version": <str>, - "url": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str>, - "explicit": <bool>, - "upc": <str>, - "popularity": <int>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ] - } - } - ] - } - """ - - self._check_scope("get_favorite_albums", "r_usr", - flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v1/users/{self._user_id}/favorites/albums", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset, - "order": order, - "orderDirection": order_direction, - } - )
- - -
-[docs] - def favorite_albums( - self, album_ids: Union[int, str, list[Union[int, str]]], - country_code: str = None, *, on_artifact_not_found: str = "FAIL" - ) -> None: - - """ - Add albums to the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - album_ids : `int`, `str`, or `list` - TIDAL album ID(s). - - **Examples**: :code:`"251380836,275646830"` or - :code:`[251380836, 275646830]`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - on_artifact_not_found : `str`, keyword-only, default: :code:`"FAIL"` - Behavior when the item to be added does not exist. - - **Valid values**: :code:`"FAIL"` or :code:`"SKIP"`. - """ - - self._check_scope("favorite_albums", "r_usr", flows={"device_code"}) - - self._request( - "post", - f"{self.API_URL}/v1/users/{self._user_id}/favorites/albums", - params={"countryCode": self._get_country_code(country_code)}, - data={ - "albumIds": ",".join(map(str, album_ids)) - if isinstance(album_ids, list) else album_ids, - "onArtifactNotFound": on_artifact_not_found - } - )
- - -
-[docs] - def unfavorite_albums( - self, album_ids: Union[int, str, list[Union[int, str]]]) -> None: - - """ - Remove albums from the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - album_ids : `int`, `str`, or `list` - TIDAL album ID(s). - - **Examples**: :code:`"251380836,275646830"` or - :code:`[251380836, 275646830]`. - """ - - self._check_scope("unfavorite_albums", "r_usr", flows={"device_code"}) - - if isinstance(album_ids, list): - album_ids = ",".join(map(str, album_ids)) - self._request("delete", - f"{self.API_URL}/v1/users/{self._user_id}" - f"/favorites/albums/{album_ids}")
- - - ### ARTISTS ############################################################### - -
-[docs] - def get_artist( - self, artist_id: Union[int, str], country_code: str = None - ) -> dict[str, Any]: - - """ - Get TIDAL catalog information for an artist. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - artist : `dict` - TIDAL catalog information for an artist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "name": <str>, - "artistTypes": [<str>], - "url": <str>, - "picture": <str>, - "popularity": <int>, - "artistRoles": [ - { - "categoryId": <int>, - "category": <str> - } - ], - "mixes": { - "ARTIST_MIX": <str> - } - } - """ - - self._check_scope("get_artist", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/artists/{artist_id}", - params={"countryCode": self._get_country_code(country_code)} - )
- - -
-[docs] - def get_artist_albums( - self, artist_id: Union[int, str], country_code: str = None, *, - filter: str = None, limit: int = 100, offset: int = None - ) -> dict[str, Any]: - - """ - Get TIDAL catalog information for albums by an artist. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - filter : `str`, keyword-only, optional - Subset of albums to retrieve. - - **Valid values**: :code:`"EPSANDSINGLES"` and - :code:`"COMPILATIONS"`. - - limit : `int`, keyword-only, default: :code:`100` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - albums : `dict` - A dictionary containing TIDAL catalog information for - albums by the specified artist and metadata for the - returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "title": <str>, - "duration": <int>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "allowStreaming": <bool>, - "premiumStreamingOnly": <bool>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "numberOfVolumes": <int>, - "releaseDate": <str>, - "copyright": <str>, - "type": "ALBUM", - "version": <str>, - "url": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str>, - "explicit": <bool>, - "upc": <str>, - "popularity": <int>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ] - } - ] - } - """ - - self._check_scope("get_artist_albums", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/artists/{artist_id}/albums", - params={ - "countryCode": self._get_country_code(country_code), - "filter": filter, - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_artist_top_tracks( - self, artist_id: Union[int, str], country_code: str = None, *, - limit: int = 100, offset: int = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for an artist's top tracks. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`100` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - tracks : `dict` - A dictionary containing TIDAL catalog information for the - artist's top tracks and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": <int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - } - ] - } - """ - - self._check_scope("get_artist_top_tracks", "r_usr", - flows={"device_code"}, require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/artists/{artist_id}/toptracks", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_artist_videos( - self, artist_id: Union[int, str], country_code: str = None, *, - limit: int = 100, offset: int = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for an artist's videos. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`100` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - videos : `dict` - A dictionary containing TIDAL catalog information for the - artist's videos and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "title": <str>, - "volumeNumber": <int>, - "trackNumber": <int>, - "releaseDate": <str>, - "imagePath": <str>, - "imageId": <str>, - "vibrantColor": <str>, - "duration": <int>, - "quality": <str>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "allowStreaming": <bool>, - "explicit": <bool>, - "popularity": <int>, - "type": "Music Video", - "adsUrl": <str>, - "adsPrePaywallOnly": <bool>, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": <dict> - } - ] - } - """ - - self._check_scope("get_artist_videos", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/artists/{artist_id}/videos", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_artist_mix_id( - self, artist_id: Union[int, str], country_code: str = None) -> str: - - """ - Get the ID of a curated mix of tracks based on an artist's - works. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - mix_id : `str` - TIDAL mix ID. - - **Example**: :code:`"000ec0b01da1ddd752ec5dee553d48"`. - """ - - self._check_scope("get_artist_mix_id", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/artists/{artist_id}/mix", - params={"countryCode": self._get_country_code(country_code)} - )["id"]
- - -
-[docs] - def get_artist_radio( - self, artist_id: Union[int, str], country_code: str = None, *, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for tracks inspired by an artist's - works. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - .. note:: - - This method is functionally identical to first getting the - artist mix ID using :meth:`get_artist_mix_id` and then - retrieving TIDAL catalog information for the items in the mix - using :meth:`get_mix_items`. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, optional - Page size. - - **Default**: :code:`100`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - tracks : `dict` - A dictionary containing TIDAL catalog information for tracks - inspired by an artist's works and metadata for the returned - results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": <int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - } - ] - } - """ - - self._check_scope("get_artist_radio", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/artists/{artist_id}/radio", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_artist_biography( - self, artist_id: Union[int, str], country_code: str = None - ) -> dict[str, str]: - - """ - Get an artist's biographical information. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - biography : `dict` - A dictionary containing an artist's biographical information - and its source. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "source": <str>, - "lastUpdated": <str>, - "text": <str>, - "summary": <str> - } - """ - - self._check_scope("get_artist_biography", "r_usr", - flows={"device_code"}, require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/artists/{artist_id}/bio", - params={"countryCode": self._get_country_code(country_code)} - )
- - - - - -
-[docs] - def get_similar_artists( - self, artist_id: str, country_code: str = None, *, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for artists similar to a specified - artist. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`100` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - artists : `dict` - A dictionary containing TIDAL catalog information for - artists similar to the specified artist and metadata for the - returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "name": <str>, - "type": None, - "artistTypes": [<str>], - "url": <str>, - "picture": <str>, - "popularity": <int>, - "banner": <str>, - "artistRoles": <list>, - "mixes": <dict>, - "relationType": "SIMILAR_ARTIST" - } - ], - "source": "TIDAL" - } - """ - - self._check_scope("get_similar_artists", "r_usr", - flows={"device_code"}, require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/artists/{artist_id}/similar", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_favorite_artists( - self, country_code: str = None, *, limit: int = 50, - offset: int = None, order: str = "DATE", - order_direction: str = "DESC") -> None: - - """ - Get TIDAL catalog information for artists in the current user's - collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`50` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - order : `str`, keyword-only, default: :code:`"DATE"` - Sorting order. - - **Valid values**: :code:`"DATE"` and :code:`"NAME"`. - - order_direction : `str`, keyword-only, default: :code:`"DESC"` - Sorting order direction. - - **Valid values**: :code:`"DESC"` and :code:`"ASC"`. - - Returns - ------- - artists : `dict` - A dictionary containing TIDAL catalog information for - artists in the current user's collection and metadata for - the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "created": <str>, - "item": { - "id": <int>, - "name": <str>, - "artistTypes": [<str>], - "url": <str>, - "picture": <str>, - "popularity": <int>, - "artistRoles": [ - { - "categoryId": <int>, - "category": <str> - } - ], - "mixes": { - "ARTIST_MIX": <str> - } - } - } - ] - } - """ - - self._check_scope("get_favorite_artists", "r_usr", - flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v1/users/{self._user_id}/favorites/artists", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset, - "order": order, - "orderDirection": order_direction - } - )
- - -
-[docs] - def favorite_artists( - self, artist_ids: Union[int, str, list[Union[int, str]]], - country_code: str = None, *, on_artifact_not_found: str = "FAIL" - ) -> None: - - """ - Add artists to the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - artist_ids : `int`, `str`, or `list` - TIDAL artist ID(s). - - **Examples**: :code:`"1566,7804"` or :code:`[1566, 7804]`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - on_artifact_not_found : `str`, keyword-only, default: :code:`"FAIL"` - Behavior when the item to be added does not exist. - - **Valid values**: :code:`"FAIL"` or :code:`"SKIP"`. - """ - - self._check_scope("favorite_artists", "r_usr", flows={"device_code"}) - - self._request( - "post", - f"{self.API_URL}/v1/users/{self._user_id}/favorites/artists", - params={"countryCode": self._get_country_code(country_code)}, - data={ - "artistIds": ",".join(map(str, artist_ids)) - if isinstance(artist_ids, list) else artist_ids, - "onArtifactNotFound": on_artifact_not_found - } - )
- - -
-[docs] - def unfavorite_artists( - self, artist_ids: Union[int, str, list[Union[int, str]]]) -> None: - - """ - Remove artists from the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - artist_ids : `int`, `str`, or `list` - TIDAL artist ID(s). - - **Examples**: :code:`"1566,7804"` or :code:`[1566, 7804]`. - """ - - self._check_scope("unfavorite_artists", "r_usr", flows={"device_code"}) - - if isinstance(artist_ids, list): - artist_ids = ",".join(map(str, artist_ids)) - self._request("delete", - f"{self.API_URL}/v1/users/{self._user_id}" - f"/favorites/artists/{artist_ids}")
- - -
-[docs] - def get_blocked_artists( - self, *, limit: int = 50, offset: int = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for the current user's blocked - artists. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - limit : `int`, keyword-only, default: :code:`50` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - artists : `dict` - A dictionary containing TIDAL catalog information for the - the current user's blocked artists and metadata for the - returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "item": { - "id": <int>, - "name": <str>, - "type": <str>, - "artistTypes": [<str>], - "url": <str>, - "picture": <str>, - "popularity": <int>, - "banner": <str>, - "artistRoles": [ - { - "categoryId": <int>, - "category": <str> - } - ], - "mixes": { - "ARTIST_MIX": <str> - } - }, - "created": <str>, - "type": "ARTIST" - } - ] - } - """ - - self._check_scope("get_blocked_artists", "r_usr", - flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v1/users/{self._user_id}/blocks/artists", - params={"limit": limit, "offset": offset} - )
- - -
-[docs] - def block_artist(self, artist_id: Union[int, str]) -> None: - - """ - Block an artist from appearing in mixes and the radio. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - """ - - self._check_scope("block_artist", "r_usr", flows={"device_code"}) - - self._request( - "post", - f"{self.API_URL}/v1/users/{self._user_id}/blocks/artists", - data={"artistId": artist_id} - )
- - -
-[docs] - def unblock_artist(self, artist_id: Union[int, str]) -> None: - - """ - Unblock an artist from appearing in mixes and the radio. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - """ - - self._check_scope("unblock_artist", "r_usr", flows={"device_code"}) - - self._request("delete", - f"{self.API_URL}/v1/users/{self._user_id}" - f"/blocks/artists/{artist_id}")
- - - ### COUNTRY ############################################################### - -
-[docs] - def get_country_code(self) -> str: - - """ - Get the country code based on the current IP address. - - Returns - ------- - country : `str`, keyword-only, optional - ISO 3166-1 alpha-2 country code. - - **Example**: :code:`"US"`. - """ - - return self._get_json(f"{self.API_URL}/v1/country")["countryCode"]
- - - ### IMAGES ################################################################ - -
-[docs] - def get_image( - self, uuid: str, type: str = None, animated: bool = False, *, - width: int = None, height: int = None, - filename: Union[str, pathlib.Path] = None) -> bytes: - - """ - Get (animated) cover art or image for a TIDAL item. - - .. note:: - - This method is provided for convenience and is not a private - TIDAL API endpoint. - - Parameters - ---------- - uuid : `str` - Image UUID. - - **Example**: :code:`"d3c4372b-a652-40e0-bdb1-fc8d032708f6"`. - - type : `str` - Item type. - - **Valid values**: :code:`"artist"`, :code:`"album"`, - :code:`"playlist"`, :code:`"track"`, :code:`"userProfile"`, - and :code:`"video"`. - - animated : `bool`, default: :code:`False` - Specifies whether the image is animated. - - width : `int`, keyword-only, optional - Valid image width for the item type. If not specified, the - default size for the item type is used. - - height : `int`, keyword-only, optional - Valid image height for the item type. If not specified, the - default size for the item type is used. - - filename : `str` or `pathlib.Path`, keyword-only, optional - Filename with the :code:`.jpg` or :code:`.mp4` extension. If - specified, the image is saved to a file instead. - - Returns - ------- - image : `bytes` - Image data. If :code:`save=True`, the stream data is saved - to an image or video file and its filename is returned - instead. - """ - - IMAGE_SIZES = { - "artist": (750, 750), - "album": (1280, 1280), - "playlist": (1080, 1080), - "track": (1280, 1280), - "userProfile": (1080, 1080), - "video": (640, 360) - } - - if width is None or height is None: - if type and type in IMAGE_SIZES.keys(): - width, height = IMAGE_SIZES[type.lower()] - else: - emsg = ("Either the image dimensions or a valid item " - "type must be specified.") - raise ValueError(emsg) - - if animated: - extension = ".mp4" - media_type = "videos" - else: - extension = ".jpg" - media_type = "images" - - with self.session.get(f"{self.RESOURCES_URL}/{media_type}" - f"/{uuid.replace('-', '/')}" - f"/{width}x{height}.{extension}") as r: - image = r.content - - if filename: - if not isinstance(filename, pathlib.Path): - filename = pathlib.Path(filename) - if not filename.name.endswith(extension): - filename += extension - with open(filename, "wb") as f: - f.write(image) - else: - return image
- - - ### MIXES ################################################################# - -
-[docs] - def get_mix_items( - self, mix_id: str, country_code: str = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for items (tracks and videos) in - a mix. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - mix_id : `str` - TIDAL mix ID. - - **Example**: :code:`"000ec0b01da1ddd752ec5dee553d48"`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - items : `dict` - A dictionary containing TIDAL catalog information for - tracks and videos in the specified mix and metadata for - the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "item": { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": >int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - }, - "type": "track" - } - ] - } - """ - - self._check_scope("get_mix_items", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/mixes/{mix_id}/items", - params={"countryCode": self._get_country_code(country_code)})
- - -
-[docs] - def get_favorite_mixes( - self, *, ids: bool = False, limit: int = 50, cursor: str = None - ) -> dict[str, Any]: - - """ - Get TIDAL catalog information for or IDs of mixes in the - current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - ids : `bool`, keyword-only, default: :code:`False` - Determine whether TIDAL catalog information about the mixes - (:code:`False`) or the mix IDs (:code:`True`) are - returned. - - limit : `int`, keyword-only, default: :code:`50` - Page size. - - **Example**: :code:`10`. - - cursor : `str`, keyword-only, optional - Cursor position of the last item in previous search results. - Use with `limit` to get the next page of search results. - - Returns - ------- - mixes : `dict` - A dictionary containing the TIDAL catalog information for or - IDs of the mixes in the current user's collection and the - cursor position. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "items": [ - { - "dateAdded": <str>, - "title": <str>, - "id": <str>, - "mixType": <str>, - "updated": <str>, - "subTitleTextInfo": { - "text": <str>, - "color": <str> - }, - "images": { - "SMALL": { - "width": <int>, - "height": <int>, - "url": <str> - }, - "MEDIUM": { - "width": <int>, - "height": <int>, - "url": <str> - }, - "LARGE": { - "width": <int>, - "height": <int>, - "url": <str> - }, - }, - "detailImages": { - "SMALL": { - "width": <int>, - "height": <int>, - "url": <str> - }, - "MEDIUM": { - "width": <int>, - "height": <int>, - "url": <str> - }, - "LARGE": { - "width": <int>, - "height": <int>, - "url": <str> - } - }, - "master": <bool>, - "subTitle": <str>, - "titleTextInfo": { - "text": <str>, - "color": <str> - } - } - ], - "cursor": <str>, - "lastModifiedAt": <str> - } - """ - - self._check_scope("get_favorite_mixes", "r_usr", flows={"device_code"}) - - url = f"{self.API_URL}/v2/favorites/mixes" - if ids: - url += "/ids" - return self._get_json(url, params={"limit": limit, "cursor": cursor})
- - -
-[docs] - def favorite_mixes( - self, mix_ids: Union[str, list[str]], *, - on_artifact_not_found: str = "FAIL") -> None: - - """ - Add mixes to the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - mix_ids : `str` or `list` - TIDAL mix ID(s). - - **Examples**: :code:`"000ec0b01da1ddd752ec5dee553d48,\ - 000dd748ceabd5508947c6a5d3880a"` or - :code:`["000ec0b01da1ddd752ec5dee553d48", - "000dd748ceabd5508947c6a5d3880a"]` - - on_artifact_not_found : `str`, keyword-only, default: :code:`"FAIL"` - Behavior when the item to be added does not exist. - - **Valid values**: :code:`"FAIL"` or :code:`"SKIP"`. - """ - - self._check_scope("favorite_mixes", "r_usr", flows={"device_code"}) - - self._request( - "put", - f"{self.API_URL}/v2/favorites/mixes/add", - data={ - "mixIds": mix_ids, - "onArtifactNotFound": on_artifact_not_found - } - )
- - -
-[docs] - def unfavorite_mixes(self, mix_ids: Union[str, list[str]]) -> None: - - """ - Remove mixes from the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - mix_ids : `str` or `list` - TIDAL mix ID(s). - - **Examples**: :code:`"000ec0b01da1ddd752ec5dee553d48,\ - 000dd748ceabd5508947c6a5d3880a"` or - :code:`["000ec0b01da1ddd752ec5dee553d48", - "000dd748ceabd5508947c6a5d3880a"]` - """ - - self._check_scope("unfavorite_mixes", "r_usr", flows={"device_code"}) - - self._request("put", f"{self.API_URL}/v2/favorites/mixes/remove", - data={"mixIds": mix_ids})
- - - ### PAGES ################################################################# - -
-[docs] - def get_album_page( - self, album_id: Union[int, str], country_code: str = None, - *, device_type: str = "BROWSER") -> dict[str, Any]: - - """ - Get the TIDAL page for an album. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - album_id : `int` or `str` - TIDAL album ID. - - **Example**: :code:`251380836`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - device_type : `str`, keyword-only, default: :code:`"BROWSER"` - Device type. - - .. container:: - - **Valid values**: - - * :code:`"BROWSER"` for a web browser. - * :code:`"DESKTOP"` for the desktop TIDAL application. - * :code:`"PHONE"` for the mobile TIDAL application. - * :code:`"TV"` for the smart TV TIDAL application. - - Returns - ------- - page : `dict` - A dictionary containing the page ID, title, and submodules. - """ - - self._check_scope("get_album_page", "r_usr", flows={"device_code"}, - require_authentication=False) - - if device_type not in \ - (DEVICE_TYPES := {"BROWSER", "DESKTOP", "PHONE", "TV"}): - emsg = ("Invalid device type. Valid values: " - f"{', '.join(DEVICE_TYPES)}.") - raise ValueError(emsg) - - return self._get_json( - f"{self.API_URL}/v1/pages/album", - params={ - "albumId": album_id, - "countryCode": self._get_country_code(country_code), - "deviceType": device_type, - } - )
- - -
-[docs] - def get_artist_page( - self, artist_id: Union[int, str], country_code: str = None, - *, device_type: str = "BROWSER") -> dict[str, Any]: - - """ - Get the TIDAL page for an artist. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - artist_id : `int` or `str` - TIDAL artist ID. - - **Example**: :code:`1566`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - device_type : `str`, keyword-only, default: :code:`"BROWSER"` - Device type. - - .. container:: - - **Valid values**: - - * :code:`"BROWSER"` for a web browser. - * :code:`"DESKTOP"` for the desktop TIDAL application. - * :code:`"PHONE"` for the mobile TIDAL application. - * :code:`"TV"` for the smart TV TIDAL application. - - Returns - ------- - page : `dict` - A dictionary containing the page ID, title, and submodules. - """ - - self._check_scope("get_artist_page", "r_usr", flows={"device_code"}, - require_authentication=False) - - if device_type not in \ - (DEVICE_TYPES := {"BROWSER", "DESKTOP", "PHONE", "TV"}): - emsg = ("Invalid device type. Valid values: " - f"{', '.join(DEVICE_TYPES)}.") - raise ValueError(emsg) - - return self._get_json( - f"{self.API_URL}/v1/pages/artist", - params={ - "artistID": artist_id, - "countryCode": self._get_country_code(country_code), - "deviceType": device_type - } - )
- - -
-[docs] - def get_mix_page( - self, mix_id: str, country_code: str = None, - *, device_type: str = "BROWSER") -> dict[str, Any]: - - """ - Get the TIDAL page for a mix. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - mix_id : `str` - TIDAL mix ID. - - **Example**: :code:`"000ec0b01da1ddd752ec5dee553d48"`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - device_type : `str`, keyword-only, default: :code:`"BROWSER"` - Device type. - - .. container:: - - **Valid values**: - - * :code:`"BROWSER"` for a web browser. - * :code:`"DESKTOP"` for the desktop TIDAL application. - * :code:`"PHONE"` for the mobile TIDAL application. - * :code:`"TV"` for the smart TV TIDAL application. - - Returns - ------- - page : `dict` - A dictionary containing the page ID, title, and submodules. - """ - - self._check_scope("get_mix_page", "r_usr", flows={"device_code"}, - require_authentication=False) - - if device_type not in \ - (DEVICE_TYPES := {"BROWSER", "DESKTOP", "PHONE", "TV"}): - emsg = ("Invalid device type. Valid values: " - f"{', '.join(DEVICE_TYPES)}.") - raise ValueError(emsg) - - return self._get_json( - f"{self.API_URL}/v1/pages/mix", - params={ - "mixId": mix_id, - "countryCode": self._get_country_code(country_code), - "deviceType": device_type, - } - )
- - -
-[docs] - def get_video_page( - self, video_id: Union[int, str], country_code: str = None, - *, device_type: str = "BROWSER") -> dict[str, Any]: - - """ - Get the TIDAL page for a video. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - video_id : `int` or `str` - TIDAL video ID. - - **Example**: :code:`75623239`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - device_type : `str`, keyword-only, default: :code:`"BROWSER"` - Device type. - - .. container:: - - **Valid values**: - - * :code:`"BROWSER"` for a web browser. - * :code:`"DESKTOP"` for the desktop TIDAL application. - * :code:`"PHONE"` for the mobile TIDAL application. - * :code:`"TV"` for the smart TV TIDAL application. - - Returns - ------- - page : `dict` - A dictionary containing the page ID, title, and submodules. - """ - - self._check_scope("get_video_page", "r_usr", flows={"device_code"}, - require_authentication=False) - - if device_type not in \ - (DEVICE_TYPES := {"BROWSER", "DESKTOP", "PHONE", "TV"}): - emsg = ("Invalid device type. Valid values: " - f"{', '.join(DEVICE_TYPES)}.") - raise ValueError(emsg) - - return self._get_json( - f"{self.API_URL}/v1/pages/videos", - params={ - "videoId": video_id, - "countryCode": self._get_country_code(country_code), - "deviceType": device_type - } - )
- - - ### PLAYLISTS ############################################################# - -
-[docs] - def get_playlist( - self, playlist_uuid: str, country_code: str = None - ) -> dict[str, Any]: - - """ - Get TIDAL catalog information for a playlist. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"36ea71a8-445e-41a4-82ab-6628c581535d"`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - playlist : `dict` - TIDAL catalog information for a playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "uuid": <str>, - "title": <str>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "creator": { - "id": <int> - }, - "description": <str>, - "duration": <int>, - "lastUpdated": <str>, - "created": <str>, - "type": <str>, - "publicPlaylist": <bool>, - "url": <str>, - "image": <str>, - "popularity": <int>, - "squareImage": <str>, - "promotedArtists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str>, - } - ], - "lastItemAddedAt": <str> - } - """ - - self._check_scope("get_playlist", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/playlists/{playlist_uuid}", - params={"countryCode": self._get_country_code(country_code)} - )
- - -
-[docs] - def get_playlist_etag( - self, playlist_uuid: str, country_code: str = None) -> str: - - """ - Get the entity tag (ETag) for a playlist. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - .. note:: - - This method is provided for convenience and is not a private - TIDAL API endpoint. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"36ea71a8-445e-41a4-82ab-6628c581535d"`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - etag : `str` - ETag for a playlist. - - **Example**: :code:`"1698984074453"`. - """ - - self._check_scope("get_playlist_etag", "r_usr", flows={"device_code"}, - require_authentication=False) - - r = self._request( - "get", - f"{self.API_URL}/v1/playlists/{playlist_uuid}", - params={"countryCode": self._get_country_code(country_code)} - ) - return r.headers["ETag"].replace('"', "")
- - -
-[docs] - def get_playlist_items( - self, playlist_uuid: str, country_code: str = None, *, - limit: int = 100, offset: int = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for items (tracks and videos) in - a playlist. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"36ea71a8-445e-41a4-82ab-6628c581535d"`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`100` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - items : `dict` - A dictionary containing TIDAL catalog information for - tracks and videos in the specified playlist and metadata for - the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "item": { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": >int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - }, - "type": "track" - } - ] - } - """ - - self._check_scope("get_playlist_items", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/playlists/{playlist_uuid}/items", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_playlist_recommendations( - self, playlist_uuid: str, country_code: str = None, *, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for recommended tracks based on a - playlist's items. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"36ea71a8-445e-41a4-82ab-6628c581535d"`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - items : `dict` - A dictionary containing TIDAL catalog information for - recommended tracks and videos and metadata for the returned - results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "item": { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": >int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - }, - "type": "track" - } - ] - } - """ - - self._check_scope("get_playlist_recommendations", "r_usr", - flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v1/playlists/{playlist_uuid}" - "/recommendations/items", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def favorite_playlists( - self, playlist_uuids: Union[str, list[str]], *, - folder_id: str = "root") -> None: - - """ - Add playlists to the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuids : `str` or `list` - TIDAL playlist UUID(s). - - **Example**: :code:`["36ea71a8-445e-41a4-82ab-6628c581535d", - "4261748a-4287-4758-aaab-6d5be3e99e52"]`. - - folder_id : `str`, keyword-only, default: :code:`"root"` - ID of the folder to move the playlist into. To place a - playlist directly under "My Playlists", use - :code:`folder_id="root"`. - """ - - self._check_scope("favorite_playlists", "r_usr", flow={"device_code"}) - - self._request( - "put", - f"{self.API_URL}/v2/my-collection/playlists/folders/add-favorites", - params={"uuids": playlist_uuids, "folderId": folder_id} - )
- - -
-[docs] - def move_playlist(self, playlist_uuid: str, folder_id: str) -> None: - - """ - Move a playlist in the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"36ea71a8-445e-41a4-82ab-6628c581535d"`. - - folder_id : `str` - ID of the folder to move the playlist into. To place a - playlist directly under "My Playlists", use - :code:`folder_id="root"`. - """ - - self._check_scope("move_playlist", "r_usr", flows={"device_code"}) - - self._request( - "put", - f"{self.API_URL}/v2/my-collection/playlists/folders/move", - params={ - "folderId": folder_id, - "trns": f"trn:playlist:{playlist_uuid}" - } - )
- - -
-[docs] - def unfavorite_playlist(self, playlist_uuid: str) -> None: - - """ - Remove a playlist from the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"36ea71a8-445e-41a4-82ab-6628c581535d"`. - """ - - self._check_scope("unfavorite_playlist", "r_usr", - flows={"device_code"}) - - self._request( - "put", - f"{self.API_URL}/v2/my-collection/playlists/folders/remove", - params={"trns": f"trn:playlist:{playlist_uuid}"} - )
- - -
-[docs] - def get_user_playlist(self, playlist_uuid: str) -> dict[str, Any]: - - """ - Get TIDAL catalog information for a user playlist. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL user playlist UUID. - - **Example**: :code:`"e09ab9ce-2e87-41b8-b404-3cd712bf706e"`. - - Returns - ------- - playlist : `dict` - TIDAL catalog information for a user playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "playlist": { - "uuid": <str>, - "type": "USER", - "creator": { - "id": <int>, - "name": <str>, - "picture": <str>, - "type": "USER" - }, - "contentBehavior": <str>, - "sharingLevel": <str>, - "status": <str>, - "source": <str>, - "title": <str>, - "description": <str>, - "image": <str>, - "squareImage": <str>, - "url": <str>, - "created": <str>, - "lastUpdated": <str>, - "lastItemAddedAt": <str>, - "duration": <int>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "promotedArtists": [], - "trn": <str>, - }, - "followInfo": { - "nrOfFollowers": <int>, - "tidalResourceName": <str>, - "followed": <bool>, - "followType": "PLAYLIST" - }, - "profile": { - "userId": <int>, - "name": <str>, - "color": [<str>] - } - } - """ - - self._check_scope("get_user_playlist", "r_usr", flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v2/user-playlists/{playlist_uuid}" - )
- - -
-[docs] - def get_user_playlists( - self, user_id: Union[int, str] = None, *, limit: int = 50, - cursor: str = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for playlists created by a TIDAL - user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - user_id : `str` - TIDAL user ID. If not specified, the ID associated with the - user account in the current session is used. - - limit : `int`, keyword-only, default: :code:`50` - Page size. - - **Example**: :code:`10`. - - cursor : `str`, keyword-only, optional - Cursor position of the last item in previous search results. - Use with `limit` to get the next page of search results. - - Returns - ------- - playlists : `dict` - A dictionary containing the user's playlists and the cursor - position. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "items": [ - { - "playlist": { - "uuid": <str>, - "type": "USER", - "creator": { - "id": <int>, - "name": <str>, - "picture": <str>, - "type": "USER" - }, - "contentBehavior": <str>, - "sharingLevel": <str>, - "status": <str>, - "source": <str>, - "title": <str>, - "description": <str>, - "image": <str>, - "squareImage": <str>, - "url": <str>, - "created": <str>, - "lastUpdated": <str>, - "lastItemAddedAt": <str>, - "duration": <int>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "promotedArtists": [], - "trn": <str>, - }, - "followInfo": { - "nrOfFollowers": <int>, - "tidalResourceName": <str>, - "followed": <bool>, - "followType": "PLAYLIST" - }, - "profile": { - "userId": <int>, - "name": <str>, - "color": [<str>] - } - } - ], - "cursor": <str> - } - """ - - self._check_scope("get_user_playlists", "r_usr", flows={"device_code"}) - - if user_id is None: - user_id = self._user_id - return self._get_json( - f"{self.API_URL}/v2/user-playlists/{user_id}/public", - params={"limit": limit, "cursor": cursor} - )
- - -
-[docs] - def get_personal_playlists( - self, country_code: str = None, *, limit: int = 50, - offset: int = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for playlists created by the - current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`50` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - playlists : `dict` - TIDAL catalog information for a user playlists created by - the current user and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "uuid": <str>", - "title": <str>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "creator": { - "id": <int> - }, - "description": <str>, - "duration": <int>, - "lastUpdated": <str>, - "created": <str>, - "type": "USER", - "publicPlaylist": <bool>, - "url": <str>, - "image": <str>, - "popularity": <int>, - "squareImage": <str>, - "promotedArtists": [], - "lastItemAddedAt": <str> - } - ] - } - """ - - self._check_scope("get_personal_playlists", "r_usr", - flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v1/users/{self._user_id}/playlists", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def create_playlist( - self, name: str, *, description: str = None, - folder_uuid: str = "root", public: bool = None) -> dict[str, Any]: - - """ - Create a user playlist. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - name : `str` - Playlist name. - - description : `str`, keyword-only, optional - Brief playlist description. - - folder_uuid : `str`, keyword-only, default: :code:`"root"` - UUID of the folder the new playlist will be placed in. To - place a playlist directly under "My Playlists", use - :code:`folder_id="root"`. - - public : `bool`, keyword-only, optional - Determines whether the playlist is public (:code:`True`) or - private (:code:`False`). - - Returns - ------- - playlist : `dict` - TIDAL catalog information for the newly created playlist. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "trn": <str>, - "itemType": "PLAYLIST", - "addedAt": <str>, - "lastModifiedAt": <str>, - "name": <str>, - "parent": <str>, - "data": { - "uuid": <str>, - "type": "USER", - "creator": { - "id": <int>, - "name": <str>, - "picture": <str>, - "type": "USER" - }, - "contentBehavior": <str>, - "sharingLevel": <str>, - "status": "READY", - "source": <str>, - "title": <str>, - "description": <str>, - "image": <str>, - "squareImage": <str>, - "url": <str>, - "created": <str>, - "lastUpdated": <str>, - "lastItemAddedAt": <str>, - "duration": <int>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "promotedArtists": <list>, - "trn": <str>, - "itemType": "PLAYLIST" - } - } - """ - - self._check_scope("create_playlist", "r_usr", flows={"device_code"}) - - return self._request( - "put", - f"{self.API_URL}/v2/my-collection/playlists/folders/create-playlist", - params={ - "name": name, - "description": description, - "folderId": folder_uuid, - "isPublic": public - } - ).json()
- - -
-[docs] - def update_playlist( - self, playlist_uuid: str, *, title: str = None, - description: str = None) -> None: - - """ - Update the title or description of a playlist owned by the - current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"e09ab9ce-2e87-41b8-b404-3cd712bf706e"`. - - title : `str`, keyword-only, optional - New playlist title. - - description : `str`, keyword-only, optional - New playlist description. - """ - - self._check_scope("update_playlist", "r_usr", flows={"device_code"}) - - if title is None and description is None: - wmsg = "No changes were specified or made to the playlist." - warnings.warn(wmsg) - return - - data = {} - if title is not None: - data["title"] = title - if description is not None: - data["description"] = description - self._request("post", f"{self.API_URL}/v1/playlists/{playlist_uuid}", - data=data)
- - -
-[docs] - def set_playlist_privacy(self, playlist_uuid: str, public: bool) -> None: - - """ - Set the privacy of a playlist owned by the current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"e09ab9ce-2e87-41b8-b404-3cd712bf706e"`. - - public : `bool` - Determines whether the playlist is public (:code:`True`) or - private (:code:`False`). - """ - - self._check_scope("set_playlist_privacy", "r_usr", - flows={"device_code"}) - - self._request( - "put", - f"{self.API_URL}/v2/playlists/{playlist_uuid}/set-" - f"{'public' if public else 'private'}" - )
- - -
-[docs] - def add_playlist_items( - self, playlist_uuid: str, - items: Union[int, str, list[Union[int, str]]] = None, *, - from_playlist_uuid: str = None, on_duplicate: str = "FAIL", - on_artifact_not_found: str = "FAIL") -> None: - - """ - Add items to a playlist owned by the current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"e09ab9ce-2e87-41b8-b404-3cd712bf706e"`. - - items : `int`, `str`, or `list`, optional - Items to add to the playlist. If not specified, - `from_playlist_uuid` must be provided. - - .. note:: - - If both `items` and `from_playlist_uuid` are specified, - only the items in `items` will be added to the playlist. - - from_playlist_uuid : `str`, keyword-only, optional - TIDAL playlist from which to copy items. - - on_duplicate : `str`, keyword-only, default: :code:`"FAIL"` - Behavior when the item to be added is already in the - playlist. - - **Valid values**: :code:`"ADD"`, :code:`"SKIP"`, and - :code:`"FAIL"`. - - on_artifact_not_found : `str`, keyword-only, default: :code:`"FAIL"` - Behavior when the item to be added does not exist. - - **Valid values**: :code:`"FAIL"`. - """ - - self._check_scope("add_playlist_items", "r_usr", flows={"device_code"}) - - if items is None and from_playlist_uuid is None: - wmsg = "No changes were specified or made to the playlist." - warnings.warn(wmsg) - return - - data = { - "onArtifactNotFound": on_artifact_not_found, - "onDuplicate": on_duplicate - } - if items: - data |= {"trackIds": items} - else: - data |= {"fromPlaylistUuid": from_playlist_uuid} - self._request( - "post", - f"{self.API_URL}/v1/playlists/{playlist_uuid}/items", - data=data, - headers={"If-None-Match": self.get_playlist_etag(playlist_uuid)} - )
- - -
-[docs] - def move_playlist_item( - self, playlist_uuid: str, from_index: Union[int, str], - to_index: Union[int, str]) -> None: - - """ - Move an item in a playlist owned by the current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"e09ab9ce-2e87-41b8-b404-3cd712bf706e"`. - - from_index : `int` or `str` - Current item index. - - to_index : `int` or `str` - Desired item index. - """ - - self._check_scope("move_playlist_item", "r_usr", flows={"device_code"}) - - self._request( - "post", - f"{self.API_URL}/v1/playlists/{playlist_uuid}/items/{from_index}", - params={"toIndex": to_index}, - headers={"If-None-Match": self.get_playlist_etag(playlist_uuid)} - )
- - -
-[docs] - def delete_playlist_item( - self, playlist_uuid: str, index: Union[int, str]) -> None: - - """ - Delete an item from a playlist owned by the current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"e09ab9ce-2e87-41b8-b404-3cd712bf706e"`. - - index : `int` or `str` - Item index. - """ - - self._check_scope("delete_playlist_item", "r_usr", - flows={"device_code"}) - - self._request( - "delete", - f"{self.API_URL}/v1/playlists/{playlist_uuid}/items/{index}", - headers={"If-None-Match": self.get_playlist_etag(playlist_uuid)} - )
- - -
-[docs] - def delete_playlist(self, playlist_uuid: str) -> None: - - """ - Delete a playlist owned by the current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - playlist_uuid : `str` - TIDAL playlist UUID. - - **Example**: :code:`"e09ab9ce-2e87-41b8-b404-3cd712bf706e"`. - """ - - self._check_scope("delete_playlist", "r_usr", flows={"device_code"}) - - self._request( - "put", - f"{self.API_URL}/v2/my-collection/playlists/folders/remove", - params={"trns": f"trn:playlist:{playlist_uuid}"} - )
- - -
-[docs] - def get_personal_playlist_folders( - self, folder_uuid: str = None, *, flattened: bool = False, - include_only: str = None, limit: int = 50, order: str = "DATE", - order_direction: str = "DESC") -> dict[str, Any]: - - """ - Get TIDAL catalog information for a playlist folder (and - optionally, playlists and other playlist folders in it) created - by the current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - folder_uuid : `str`, optional - UUID of the folder in which to look for playlists and other - folders. If not specified, all folders and playlists in "My - Playlists" are returned. - - flattened : `bool`, keyword-only, default: :code:`False` - Determines whether the results are flattened into a list. - - include_only : `str`, keyword-only, optional - Type of playlist-related item to return. - - **Valid values**: :code:`"FAVORITE_PLAYLIST"`, - :code:`"FOLDER"`, and :code:`"PLAYLIST"`. - - limit : `int`, keyword-only, default: :code:`50` - Page size. - - **Example**: :code:`10`. - - order : `str`, keyword-only, default: :code:`"DATE"` - Sorting order. - - **Valid values**: :code:`"DATE"`, :code:`"DATE_UPDATED"`, - and :code:`"NAME"`. - - order_direction : `str`, keyword-only, default: :code:`"DESC"` - Sorting order direction. - - **Valid values**: :code:`"DESC"` and :code:`"ASC"`. - - Returns - ------- - items : `dict` - A dictionary containing playlist-related items and the total - number of items available. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "lastModifiedAt": <str>, - "items": [ - { - "trn": <str>, - "itemType": "FOLDER", - "addedAt": <str>, - "lastModifiedAt": <str>, - "name": <str>, - "parent": <str>, - "data": { - "trn": <str>, - "name": <str>, - "createdAt": <str>, - "lastModifiedAt": <str>, - "totalNumberOfItems": <int>, - "id": <str>, - "itemType": "FOLDER" - } - } - ], - "totalNumberOfItems": <int>, - "cursor": <str> - } - """ - - self._check_scope("get_personal_playlist_folders", "r_usr", - flows={"device_code"}) - - if include_only and include_only not in \ - (ALLOWED_INCLUDES := {"FAVORITE_PLAYLIST", "FOLDER", - "PLAYLIST"}): - emsg = ("Invalid include type. Valid values: " - f"{', '.join(ALLOWED_INCLUDES)}.") - raise ValueError(emsg) - - url = f"{self.API_URL}/v2/my-collection/playlists/folders" - if flattened: - url += "/flattened" - return self._get_json( - url, - params={ - "folderId": folder_uuid if folder_uuid else "root", - "limit": limit, - "includeOnly": include_only, - "order": order, - "orderDirection": order_direction - } - )
- - -
-[docs] - def create_playlist_folder( - self, name: str, *, folder_uuid: str = "root") -> None: - - """ - Create a user playlist folder. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - name : `str` - Playlist folder name. - - folder_uuid : `str`, keyword-only, default: :code:`"root"` - UUID of the folder in which the new playlist folder should - be created in. To create a folder directly under "My - Playlists", use :code:`folder_id="root"`. - """ - - self._check_scope("create_playlist_folder", "r_usr", - flows={"device_code"}) - - self._request( - "put", - f"{self.API_URL}/v2/my-collection/playlists/folders/create-folder", - params={"name": name, "folderId": folder_uuid} - )
- - -
-[docs] - def delete_playlist_folder(self, folder_uuid: str) -> None: - - """ - Delete a playlist folder owned by the current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - folder_uuid : `str` - TIDAL playlist folder UUID. - - **Example**: :code:`"92b3c1ea-245a-4e5a-a5a4-c215f7a65b9f"`. - """ - - self._check_scope("delete_playlist_folder", "r_usr", - flows={"device_code"}) - - self._request( - "put", - f"{self.API_URL}/v2/my-collection/playlists/folders/remove", - params={"trns": f"trn:folder:{folder_uuid}"} - )
- - - ### SEARCH ################################################################ - -
-[docs] - def search( - self, query: str, country_code: str = None, *, type: str = None, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Search for albums, artists, tracks, and videos. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - query : `str` - Search query. - - **Example**: :code:`"Beyoncé"`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - type : `str`, keyword-only, optional - Target search type. Searches for all types if not specified. - - **Valid values**: :code:`"ALBUMS"`, :code:`"ARTISTS"`, - :code:`"TRACKS"`, :code:`"VIDEOS"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - results : `dict` - A dictionary containing TIDAL catalog information for - albums, artists, tracks, and videos matching the search - query, and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "artists": { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "name": <str>, - "artistTypes": [<str>], - "url": <str>, - "picture": <str>, - "popularity": <int>, - "artistRoles": [ - { - "categoryId": <int>, - "category": <str> - } - ], - "mixes": { - "ARTIST_MIX": <str> - } - } - ] - }, - "albums": { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "title": <str>, - "duration": <int>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "allowStreaming": <bool>, - "premiumStreamingOnly": <bool>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "numberOfVolumes": <int>, - "releaseDate": <str>, - "copyright": <str>, - "type": "ALBUM", - "version": <str>, - "url": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str>, - "explicit": <bool>, - "upc": <str>, - "popularity": <int>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ] - } - ] - }, - "playlists": { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "uuid": <str>, - "title": <str>, - "numberOfTracks": <int>, - "numberOfVideos": <int>, - "creator": { - "id": <int> - }, - "description": <str>, - "duration": <int>, - "lastUpdated": <str>, - "created": <str>, - "type": <str>, - "publicPlaylist": <bool>, - "url": <str>, - "image": <str>, - "popularity": <int>, - "squareImage": <str>, - "promotedArtists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str>, - } - ], - "lastItemAddedAt": <str> - } - ] - }, - "tracks": { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": <int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - } - ] - }, - "videos": { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "title": <str>, - "volumeNumber": <int>, - "trackNumber": <int>, - "releaseDate": <str>, - "imagePath": <str>, - "imageId": <str>, - "vibrantColor": <str>, - "duration": <int>, - "quality": <str>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "allowStreaming": <bool>, - "explicit": <bool>, - "popularity": <int>, - "type": <str>, - "adsUrl": <str>, - "adsPrePaywallOnly": <bool>, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str>, - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str>, - } - ], - "album": <dict> - } - ] - }, - "topHit": { - "value": <dict>, - "type": <str> - } - } - """ - - self._check_scope("search", "r_usr", flows={"device_code"}, - require_authentication=False) - - url = f"{self.API_URL}/v1/search" - if type: - if type not in \ - (TYPES := {"artist", "album", "playlist", "track", - "userProfile", "video"}): - emsg = ("Invalid target search type. Valid values: " - f"{', '.join(TYPES)}.") - raise ValueError(emsg) - url += f"/{type}s" - - return self._get_json( - url, - params={ - "query": query, - "type": type, - "limit": limit, - "offset": offset, - "countryCode": self._get_country_code(country_code) - } - )
- - - ### STREAMS ############################################################### - -
-[docs] - def get_collection_streams( - self, collection_id: Union[int, str], type: str, *, - audio_quality: str = "HI_RES", video_quality: str = "HIGH", - max_resolution: int = 2160, playback_mode: str = "STREAM", - asset_presentation: str = "FULL", streaming_session_id: str = None - ) -> list[tuple[bytes, str]]: - - """ - Get audio and video stream data for items (tracks and videos) in - an album, mix, or playlist. - - .. admonition:: User authentication, authorization scope, and - subscription - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Full track and video playback information and lossless audio - is only available with user authentication and an active - TIDAL subscription. - - High-resolution and immersive audio is only available with - the HiFi Plus plan and when the current client credentials - are from a supported device. - - .. seealso:: - - For more information on audio quality availability, see - the `Download TIDAL <https://offer.tidal.com/download>`_, - `TIDAL Pricing <https://tidal.com/pricing>`_, and - `Dolby Atmos <https://support.tidal.com/hc/en-us/articles - /360004255778-Dolby-Atmos>`_ web pages. - - .. note:: - - This method is provided for convenience and is not a private - TIDAL API endpoint. - - Parameters - ---------- - collection_id : `int` or `str` - TIDAL collection ID or UUID. - - type : `str` - Collection type. - - **Valid values**: :code:`"album"`, :code:`"mix"`, and - :code:`"playlist"`. - - audio_quality : `str`, keyword-only, default: :code:`"HI-RES"` - Audio quality. - - .. container:: - - **Valid values**: - - * :code:`"LOW"` for 64 kbps (22.05 kHz) MP3 without user - authentication or 96 kbps AAC with user authentication. - * :code:`"HIGH"` for 320 kbps AAC. - * :code:`"LOSSLESS"` for 1411 kbps (16-bit, 44.1 kHz) ALAC - or FLAC. - * :code:`"HI_RES"` for up to 9216 kbps (24-bit, 96 kHz) - MQA-encoded FLAC. - - video_quality : `str`, keyword-only, default: :code:`"HIGH"` - Video quality. - - **Valid values**: :code:`"AUDIO_ONLY"`, :code:`"LOW"`, - :code:`"MEDIUM"`, and :code:`"HIGH"`. - - max_resolution : `int`, keyword-only, default: :code:`2160` - Maximum video resolution (number of vertical pixels). - - playback_mode : `str`, keyword-only, default: :code:`"STREAM"` - Playback mode. - - **Valid values**: :code:`"STREAM"` and :code:`"OFFLINE"`. - - asset_presentation : `str`, keyword-only, default: :code:`"FULL"` - Asset presentation. - - .. container:: - - **Valid values**: - - * :code:`"FULL"`: Full track or video. - * :code:`"PREVIEW"`: 30-second preview of the track or - video. - - streaming_session_id : `str`, keyword-only, optional - Streaming session ID. - - Returns - ------- - streams : `list` - Audio and video stream data and their MIME types. - """ - - if type not in (COLLECTION_TYPES := {"album", "mix", "playlist"}): - emsg = ("Invalid collection type. Valid values: " - f"{', '.join(COLLECTION_TYPES)}.") - raise ValueError(emsg) - - if type == "album": - items = self.get_album_items(collection_id)["items"] - elif type == "mix": - items = self.get_mix_items(collection_id)["items"] - elif type == "playlist": - items = self.get_playlist_items(collection_id)["items"] - - streams = [] - for item in items: - if item["type"] == "track": - stream = self.get_track_stream( - item["item"]["id"], - audio_quality=audio_quality, - playback_mode=playback_mode, - asset_presentation=asset_presentation, - streaming_session_id=streaming_session_id - ) - elif item["type"] == "video": - stream = self.get_video_stream( - item["item"]["id"], - video_quality=video_quality, - max_resolution=max_resolution, - playback_mode=playback_mode, - asset_presentation=asset_presentation, - streaming_session_id=streaming_session_id - ) - streams.append(stream) - return streams
- - -
-[docs] - def get_track_stream( - self, track_id: Union[int, str], *, audio_quality: str = "HI_RES", - playback_mode: str = "STREAM", asset_presentation: str = "FULL", - streaming_session_id: str = None) -> Union[bytes, str]: - - """ - Get the audio stream data for a track. - - .. admonition:: User authentication, authorization scope, and - subscription - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Full track playback information and lossless audio is only - available with user authentication and an active TIDAL - subscription. - - High-resolution and immersive audio is only available with - the HiFi Plus plan and when the current client credentials - are from a supported device. - - .. seealso:: - - For more information on audio quality availability, see - the `Download TIDAL <https://offer.tidal.com/download>`_, - `TIDAL Pricing <https://tidal.com/pricing>`_, and - `Dolby Atmos <https://support.tidal.com/hc/en-us/articles - /360004255778-Dolby-Atmos>`_ web pages. - - .. note:: - - This method is provided for convenience and is not a private - TIDAL API endpoint. - - Parameters - ---------- - track_id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - audio_quality : `str`, keyword-only, default: :code:`"HI-RES"` - Audio quality. - - .. container:: - - **Valid values**: - - * :code:`"LOW"` for 64 kbps (22.05 kHz) MP3 without user - authentication or 96 kbps AAC with user authentication. - * :code:`"HIGH"` for 320 kbps AAC. - * :code:`"LOSSLESS"` for 1411 kbps (16-bit, 44.1 kHz) ALAC - or FLAC. - * :code:`"HI_RES"` for up to 9216 kbps (24-bit, 96 kHz) - MQA-encoded FLAC. - - playback_mode : `str`, keyword-only, default: :code:`"STREAM"` - Playback mode. - - **Valid values**: :code:`"STREAM"` and :code:`"OFFLINE"`. - - asset_presentation : `str`, keyword-only, default: :code:`"FULL"` - Asset presentation. - - .. container:: - - **Valid values**: - - * :code:`"FULL"`: Full track. - * :code:`"PREVIEW"`: 30-second preview of the track. - - streaming_session_id : `str`, keyword-only, optional - Streaming session ID. - - Returns - ------- - stream : `bytes` - Audio stream data. - - codec : `str` - Audio codec. - """ - - manifest = base64.b64decode( - self.get_track_playback_info( - track_id, - audio_quality=audio_quality, - playback_mode=playback_mode, - asset_presentation=asset_presentation, - streaming_session_id=streaming_session_id - )["manifest"] - ) - - if b"urn:mpeg:dash" in manifest: - manifest = minidom.parseString(manifest) - codec = (manifest.getElementsByTagName("Representation")[0] - .getAttribute("codecs")) - segment = manifest.getElementsByTagName("SegmentTemplate")[0] - stream = bytearray() - with self.session.get( - segment.getAttribute("initialization") - ) as r: - stream.extend(r.content) - for i in range(1, sum(int(tl.getAttribute("r") or 1) - for tl in - segment.getElementsByTagName("S")) + 2): - with self.session.get( - segment.getAttribute("media").replace( - "$Number$", str(i) - ) - ) as r: - stream.extend(r.content) - else: - manifest = json.loads(manifest) - codec = manifest["codecs"] - with self.session.get(manifest["urls"][0]) as r: - stream = r.content - if manifest["encryptionType"] == "OLD_AES": - key_id = base64.b64decode(manifest["keyId"]) - key_nonce = Cipher( - algorithms.AES(b"P\x89SLC&\x98\xb7\xc6\xa3\n?P.\xb4\xc7" - b"a\xf8\xe5n\x8cth\x13E\xfa?\xbah8\xef\x9e"), - modes.CBC(key_id[:16]) - ).decryptor().update(key_id[16:]) - stream = Cipher( - algorithms.AES(key_nonce[:16]), - modes.CTR(key_nonce[16:32]) - ).decryptor().update(stream) - elif manifest["encryptionType"] != "NONE": - raise NotImplementedError("Unsupported encryption type.") - return stream, codec
- - -
-[docs] - def get_video_stream( - self, video_id: Union[int, str], *, video_quality: str = "HIGH", - max_resolution: int = 2160, playback_mode: str = "STREAM", - asset_presentation: str = "FULL", streaming_session_id: str = None - ) -> tuple[bytes, str]: - - """ - Get the video stream data for a music video. - - .. admonition:: User authentication, authorization scope, and - subscription - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Full video playback information is only available with user - authentication and an active TIDAL subscription. - - .. note:: - - This method is provided for convenience and is not a private - TIDAL API endpoint. - - Parameters - ---------- - video_id : `int` or `str` - TIDAL video ID. - - **Example**: :code:`59727844`. - - video_quality : `str`, keyword-only, default: :code:`"HIGH"` - Video quality. - - **Valid values**: :code:`"AUDIO_ONLY"`, :code:`"LOW"`, - :code:`"MEDIUM"`, and :code:`"HIGH"`. - - max_resolution : `int`, keyword-only, default: :code:`2160` - Maximum video resolution (number of vertical pixels). - - playback_mode : `str`, keyword-only, default: :code:`"STREAM"` - Playback mode. - - **Valid values**: :code:`"STREAM"` and :code:`"OFFLINE"`. - - asset_presentation : `str`, keyword-only, default: :code:`"FULL"` - Asset presentation. - - .. container:: - - **Valid values**: - - * :code:`"FULL"`: Full video. - * :code:`"PREVIEW"`: 30-second preview of the video. - - streaming_session_id : `str`, keyword-only, optional - Streaming session ID. - - Returns - ------- - stream : `bytes` - Video stream data. - - codec : `str` - Video codec. - """ - - manifest = base64.b64decode( - self.get_video_playback_info( - video_id, - video_quality=video_quality, - playback_mode=playback_mode, - asset_presentation=asset_presentation, - streaming_session_id=streaming_session_id - )["manifest"] - ) - - codec, playlist = next( - (c, pl) for c, res, pl in re.findall( - r'(?<=CODECS=")(.*)",(?:RESOLUTION=)\d+x(\d+)\n(http.*)', - self.session.get( - json.loads(manifest)["urls"][0] - ).content.decode("utf-8") - )[::-1] if int(res) < max_resolution - ) - - stream = bytearray() - for ts in re.findall( - "(?<=\n).*(http.*)", - self.session.get(playlist).content.decode("utf-8") - ): - with self.session.get(ts) as r: - stream.extend(r.content) - return stream, codec
- - - ### TRACKS ################################################################ - -
-[docs] - def get_track( - self, track_id: Union[int, str], country_code: str = None - ) -> dict[str, Any]: - - """ - Get TIDAL catalog information for a track. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - track_id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - track : `dict` - TIDAL catalog information for a track. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": <int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - } - """ - - self._check_scope("get_track", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/tracks/{track_id}", - params={"countryCode": self._get_country_code(country_code)} - )
- - -
-[docs] - def get_track_contributors( - self, track_id: Union[int, str], country_code: str = None, *, - limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Get the contributors to a track and their roles. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - track_id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`100` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - contributors : `dict` - A dictionary containing a track's contributors and their - roles, and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "name": <str>, - "role": <str> - } - ] - } - """ - - self._check_scope("get_track_contributors", "r_usr", - flows={"device_code"}, require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/tracks/{track_id}/contributors", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_track_credits( - self, track_id: Union[int, str], country_code: str = None - ) -> list[dict[str, Any]]: - - """ - Get credits for a track. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - track_id : `int` or `str` - TIDAL track ID. - - country : `str`, keyword-only, optional - An ISO 3166-1 alpha-2 country code. If not specified, the - country associated with the user account will be used. - - **Example**: :code:`"US"`. - - Returns - ------- - credits : `list` - A list of roles and their associated contributors. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - [ - { - "type": <str>, - "contributors": [ - { - "name": <str>, - "id": <int> - } - ] - } - ] - """ - - self._check_scope("get_track_credits", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/tracks/{track_id}/credits", - params={"countryCode": self._get_country_code(country_code)} - )
- - -
-[docs] - def get_track_composers(self, track_id: Union[int, str]) -> list[str]: - - """ - Get the composers, lyricists, and/or songwriters of a track. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - .. note:: - - This method is provided for convenience and is not a private - TIDAL API endpoint. - - Parameters - ---------- - track_id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - Returns - ------- - composers : `list` - Composers, lyricists, and/or songwriters of the track. - - **Example**: :code:`['Tommy Wright III', 'Beyoncé', - 'Kelman Duran', 'Terius "The-Dream" G...de-Diamant', - 'Mike Dean']` - """ - - return sorted({c["name"] - for c in self.get_track_contributors(track_id)["items"] - if c["role"] in {"Composer", "Lyricist", "Writer"}})
- - -
-[docs] - def get_track_lyrics( - self, id: Union[int, str], country_code: str = None - ) -> dict[str, Any]: - - """ - Get lyrics for a track. - - .. admonition:: User authentication and subscription - :class: warning - - Requires user authentication via an OAuth 2.0 authorization - flow and an active TIDAL subscription. - - Parameters - ---------- - id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - lyrics : `dict` - A dictionary containing formatted and time-synced lyrics (if - available) in the `"lyrics"` and `"subtitles"` keys, - respectively. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "trackId": <int>, - "lyricsProvider": <str>, - "providerCommontrackId": <str>, - "providerLyricsId": <str>, - "lyrics": <str>, - "subtitles": <str>, - "isRightToLeft": <bool> - } - """ - - self._check_scope("get_track_lyrics") - - try: - return self._get_json( - f"{self.WEB_URL}/v1/tracks/{id}/lyrics", - params={"countryCode": self._get_country_code(country_code)} - ) - except RuntimeError: - logging.warning("Either lyrics are not available for this track " - "or the current account does not have an active " - "TIDAL subscription.")
- - -
-[docs] - def get_track_mix_id( - self, tidal_id: Union[int, str], country_code: str = None) -> str: - - """ - Get the curated mix of tracks based on a track. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - tidal_id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - mix_id : `str` - TIDAL mix ID. - - **Example**: :code:`"0017159e6a1f34ae3d981792d72ecf"`. - """ - - self._check_scope("get_track_mix_id", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/tracks/{tidal_id}/mix", - params={"countryCode": self._get_country_code(country_code)} - )["id"]
- - -
-[docs] - def get_track_playback_info( - self, track_id: Union[int, str], *, audio_quality: str = "HI_RES", - playback_mode: str = "STREAM", asset_presentation: str = "FULL", - streaming_session_id: str = None) -> dict[str, Any]: - - """ - Get playback information for a track. - - .. admonition:: User authentication, authorization scope, and - subscription - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Full track playback information and lossless audio is only - available with user authentication and an active TIDAL - subscription. - - High-resolution and immersive audio is only available with - the HiFi Plus plan and when the current client credentials - are from a supported device. - - .. seealso:: - - For more information on audio quality availability, see - the `Download TIDAL <https://offer.tidal.com/download>`_, - `TIDAL Pricing <https://tidal.com/pricing>`_, and - `Dolby Atmos <https://support.tidal.com/hc/en-us/articles - /360004255778-Dolby-Atmos>` web pages. - - Parameters - ---------- - track_id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - audio_quality : `str`, keyword-only, default: :code:`"HI-RES"` - Audio quality. - - .. container:: - - **Valid values**: - - * :code:`"LOW"` for 64 kbps (22.05 kHz) MP3 without user - authentication or 96 kbps AAC with user authentication. - * :code:`"HIGH"` for 320 kbps AAC. - * :code:`"LOSSLESS"` for 1411 kbps (16-bit, 44.1 kHz) ALAC - or FLAC. - * :code:`"HI_RES"` for up to 9216 kbps (24-bit, 96 kHz) - MQA-encoded FLAC. - - playback_mode : `str`, keyword-only, default: :code:`"STREAM"` - Playback mode. - - **Valid values**: :code:`"STREAM"` and :code:`"OFFLINE"`. - - asset_presentation : `str`, keyword-only, default: :code:`"FULL"` - Asset presentation. - - .. container:: - - **Valid values**: - - * :code:`"FULL"`: Full track. - * :code:`"PREVIEW"`: 30-second preview of the track. - - streaming_session_id : `str`, keyword-only, optional - Streaming session ID. - - Returns - ------- - info : `dict` - Track playback information. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "trackId": <int>, - "assetPresentation": <str>, - "audioMode": <str>, - "audioQuality": <str>, - "manifestMimeType": <str>, - "manifestHash": <str>, - "manifest": <str>, - "albumReplayGain": <float>, - "albumPeakAmplitude": <float>, - "trackReplayGain": <float>, - "trackPeakAmplitude": <float> - } - """ - - self._check_scope("get_track_playback_info", "r_usr", - flows={"device_code"}, require_authentication=False) - - if audio_quality not in \ - (AUDIO_QUALITIES := {"LOW", "HIGH", "LOSSLESS", "HI_RES"}): - emsg = ("Invalid audio quality. Valid values: " - f"are{', '.join(AUDIO_QUALITIES)}.") - raise ValueError(emsg) - if playback_mode not in \ - (PLAYBACK_MODES := {"STREAM", "OFFLINE"}): - emsg = ("Invalid playback mode. Valid values: " - f"modes are {', '.join(PLAYBACK_MODES)}.") - raise ValueError(emsg) - if asset_presentation not in \ - (ASSET_PRESENTATIONS := {"FULL", "PREVIEW"}): - emsg = ("Invalid asset presentation. Valid values: " - "presentations are " - f"{', '.join(ASSET_PRESENTATIONS)}.") - raise ValueError(emsg) - - url = f"{self.API_URL}/v1/tracks/{track_id}/playbackinfo" - # if self._flow: - # url += "postpaywall" - url += "postpaywall" if self._flow else "prepaywall" - return self._get_json( - url, - params={ - "audioquality": audio_quality, - "assetpresentation": asset_presentation, - "playbackmode": playback_mode, - "streamingsessionid": streaming_session_id - } - )
- - -
-[docs] - def get_track_recommendations( - self, track_id: Union[int, str], country_code: str = None, *, - limit: int = None, offset = None) -> dict[str, Any]: - - """ - Get TIDAL catalog information for a track's recommended - tracks and videos. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - track_id : `int` or `str` - TIDAL track ID. - - **Example**: :code:`251380837`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - recommendations : `dict` - A dictionary containing TIDAL catalog information for the - recommended tracks and metadata for the returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": <int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - }, - "sources": [ - "SUGGESTED_TRACKS" - ] - ] - } - """ - - self._check_scope("get_track_recommendations", "r_usr", - flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v1/tracks/{track_id}/recommendations", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset - } - )
- - -
-[docs] - def get_favorite_tracks( - self, country_code: str = None, *, limit: int = 50, - offset: int = None, order: str = "DATE", - order_direction: str = "DESC"): - - """ - Get TIDAL catalog information for tracks in the current user's - collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`50` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - order : `str`, keyword-only, default: :code:`"DATE"` - Sorting order. - - **Valid values**: :code:`"DATE"` and :code:`"NAME"`. - - order_direction : `str`, keyword-only, default: :code:`"DESC"` - Sorting order direction. - - **Valid values**: :code:`"DESC"` and :code:`"ASC"`. - - Returns - ------- - tracks : `dict` - A dictionary containing TIDAL catalog information for tracks - in the current user's collection and metadata for the - returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "created": <str>, - "item": { - "id": <int>, - "title": <str>, - "duration": <int>, - "replayGain": <float>, - "peak": <float>, - "allowStreaming": <bool>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "premiumStreamingOnly": <bool>, - "trackNumber": <int>, - "volumeNumber": <int>, - "version": <str>, - "popularity": <int>, - "copyright": <str>, - "url": <str>, - "isrc": <str>, - "editable": <bool>, - "explicit": <bool>, - "audioQuality": <str>, - "audioModes": [<str>], - "mediaMetadata": { - "tags": [<str>] - }, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - } - ], - "album": { - "id": <int>, - "title": <str>, - "cover": <str>, - "vibrantColor": <str>, - "videoCover": <str> - }, - "mixes": { - "TRACK_MIX": <str> - } - } - } - ] - } - """ - - self._check_scope("get_favorite_tracks", "r_usr", - flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v1/users/{self._user_id}/favorites/tracks", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset, - "order": order, - "orderDirection": order_direction - } - )
- - -
-[docs] - def favorite_tracks( - self, track_ids: Union[int, str, list[Union[int, str]]], - country_code: str = None, *, on_artifact_not_found: str = "FAIL" - ) -> None: - - """ - Add tracks to the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - track_ids : `int`, `str`, or `list` - TIDAL track ID(s). - - **Examples**: :code:`"251380837,251380838"` or - :code:`[251380837, 251380838]`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - on_artifact_not_found : `str`, keyword-only, default: :code:`"FAIL"` - Behavior when the item to be added does not exist. - - **Valid values**: :code:`"FAIL"` or :code:`"SKIP"`. - """ - - self._check_scope("favorite_tracks", "r_usr", flows={"device_code"}) - - self._request( - "post", - f"{self.API_URL}/v1/users/{self._user_id}/favorites/tracks", - params={"countryCode": self._get_country_code(country_code)}, - data={ - "trackIds": ",".join(map(str, track_ids)) - if isinstance(track_ids, list) else track_ids, - "onArtifactNotFound": on_artifact_not_found - } - )
- - -
-[docs] - def unfavorite_tracks( - self, track_ids: Union[int, str, list[Union[int, str]]]) -> None: - - """ - Remove tracks from the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - track_ids : `int`, `str`, or `list` - TIDAL track ID(s). - - **Examples**: :code:`"251380837,251380838"` or - :code:`[251380837, 251380838]`. - """ - - self._check_scope("unfavorite_tracks", "r_usr", flows={"device_code"}) - - if isinstance(track_ids, list): - track_ids = ",".join(map(str, track_ids)) - self._request("delete", - f"{self.API_URL}/v1/users/{self._user_id}" - f"/favorites/tracks/{track_ids}")
- - - ### USERS ################################################################# - -
-[docs] - def get_profile(self) -> dict[str, Any]: - - """ - Get the current user's profile information. - - .. admonition:: User authentication - :class: warning - - Requires user authentication via an OAuth 2.0 authorization - flow. - - Returns - ------- - profile : `dict` - A dictionary containing the current user's profile - information. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "userId": <int>, - "email": <str>, - "countryCode": <str>, - "fullName": <str>, - "firstName": <str>, - "lastName": <str>, - "nickname": <str>, - "username": <str>, - "address": <str>, - "city": <str>, - "postalcode": <str>, - "usState": <str>, - "phoneNumber": <int>, - "birthday": <int>, - "channelId": <int>, - "parentId": <int>, - "acceptedEULA": <bool>, - "created": <int>, - "updated": <int>, - "facebookUid": <int>, - "appleUid": <int>, - "googleUid": <int>, - "accountLinkCreated": <bool>, - "emailVerified": <bool>, - "newUser": <bool> - } - """ - - self._check_scope("get_profile") - - return self._get_json(f"{self.LOGIN_URL}/oauth2/me")
- - -
-[docs] - def get_session(self) -> dict[str, Any]: - - """ - Get information about the current private TIDAL API session. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Returns - ------- - session : `dict` - Information about the current private TIDAL API session. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "sessionId": <str>, - "userId": <int>, - "countryCode": <str>, - "channelId": <int>, - "partnerId": <int>, - "client": { - "id": <int>, - "name": <str>, - "authorizedForOffline": <bool>, - "authorizedForOfflineDate": <str> - } - } - - """ - - self._check_scope("get_session", "r_usr", flows={"device_code"}) - - return self._get_json(f"{self.API_URL}/v1/sessions")
- - -
-[docs] - def get_favorite_ids(self) -> dict[str, list[str]]: - - """ - Get TIDAL IDs or UUIDs of the albums, artists, playlists, - tracks, and videos in the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Returns - ------- - ids : `dict` - A dictionary containing the IDs or UUIDs of the items in the - current user's collection. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "ARTIST": [<str>], - "ALBUM": [<str>], - "VIDEO": [<str>], - "PLAYLIST": [<str>], - "TRACK": [<str>] - } - """ - - self._check_scope("get_favorite_ids", "r_usr", flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v1/users/{self._user_id}/favorites/ids" - )
- - -
-[docs] - def get_user_profile(self, user_id: Union[int, str]) -> dict[str, Any]: - - """ - Get a TIDAL user's profile information. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - user_id : `int` or `str` - TIDAL user ID. - - **Example**: :code:`172311284`. - - Returns - ------- - profile : `dict` - A dictionary containing the user's profile information. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "userId": <int>, - "name": <str>, - "color": [<str>], - "picture": <str>, - "numberOfFollowers": <int>, - "numberOfFollows": <int>, - "prompts": [ - { - "id": <int>, - "title": <str>, - "description": <str>, - "colors": { - "primary": <str>, - "secondary": <str>, - }, - "trn": <str>, - "data": <str>, - "updatedTime": <str>, - "supportedContentType": "TRACK" - } - ], - "profileType": <str> - } - """ - - self._check_scope("get_user_profile", "r_usr", flows={"device_code"}) - - return self._get_json(f"{self.API_URL}/v2/profiles/{user_id}")
- - -
-[docs] - def get_user_followers( - self, user_id: Union[int, str] = None, *, limit: int = 500, - cursor: str = None) -> dict[str, Any]: - - """ - Get a TIDAL user's followers. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - user_id : `str` - TIDAL user ID. If not specified, the ID associated with the - user account in the current session is used. - - **Example**: :code:`172311284`. - - limit : `int`, keyword-only, default: :code:`500` - Page size. - - **Example**: :code:`10`. - - cursor : `str`, keyword-only, optional - Cursor position of the last item in previous search results. - Use with `limit` to get the next page of search results. - - Returns - ------- - followers : `dict` - A dictionary containing the user's followers and the cursor - position. - """ - - self._check_scope("get_user_followers", "r_usr", flows={"device_code"}) - - if user_id is None: - user_id = self._user_id - return self._get_json(f"{self.API_URL}/v2/profiles/{user_id}/followers", - params={"limit": limit, "cursor": cursor})
- - -
-[docs] - def get_user_following( - self, user_id: Union[int, str] = None, *, include_only: str = None, - limit: int = 500, cursor: str = None): - - """ - Get the people (artists, users, etc.) a TIDAL user follows. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - user_id : `str` - TIDAL user ID. If not specified, the ID associated with the - user account in the current session is used. - - **Example**: :code:`172311284`. - - include_only : `str`, keyword-only, optional - Type of people to return. - - **Valid values**: :code:`"ARTIST"` and :code:`"USER"`. - - limit : `int`, keyword-only, default: :code:`500` - Page size. - - **Example**: :code:`10`. - - cursor : `str`, keyword-only, optional - Cursor position of the last item in previous search results. - Use with `limit` to get the next page of search results. - - Returns - ------- - following : `dict` - A dictionary containing the people following the user and - the cursor position. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "items": [ - { - "id": <int>, - "name": <str>, - "picture": <str>, - "imFollowing": <bool>, - "trn": <str>, - "followType": <str> - } - ], - "cursor": <str> - } - """ - - self._check_scope("get_user_following", "r_usr", flows={"device_code"}) - - if include_only and include_only not in \ - (ALLOWED_INCLUDES := {"ARTIST", "USER"}): - emsg = ("Invalid include type. Valid values: " - f"{', '.join(ALLOWED_INCLUDES)}.") - raise ValueError(emsg) - - if user_id is None: - user_id = self._user_id - return self._get_json( - f"{self.API_URL}/v2/profiles/{user_id}/following", - params={ - "includeOnly": include_only, - "limit": limit, - "cursor": cursor - } - )
- - -
-[docs] - def follow_user(self, user_id: Union[int, str]) -> None: - - """ - Follow a user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - user_id : `int` or `str` - TIDAL user ID. - - **Example**: :code:`172311284`. - """ - - self._check_scope("follow_user", "r_usr", flows={"device_code"}) - - self._request("put", f"{self.API_URL}/v2/follow", - params={"trn": f"trn:user:{user_id}"})
- - -
-[docs] - def unfollow_user(self, user_id: Union[int, str]) -> None: - - """ - Unfollow a user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - user_id : `int` or `str` - TIDAL user ID. - - **Example**: :code:`172311284`. - """ - - self._check_scope("unfollow_user", "r_usr", flows={"device_code"}) - - self._request("delete", f"{self.API_URL}/v2/follow", - params={"trn": f"trn:user:{user_id}"})
- - -
-[docs] - def get_blocked_users( - self, *, limit: int = None, offset: int = None) -> dict[str, Any]: - - """ - Get users blocked by the current user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - limit : `int`, keyword-only, optional - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - Returns - ------- - users : `dict` - A dictionary containing the users blocked by the current - user and the number of results. - """ - - self._check_scope("get_blocked_users", "r_usr", flows={"device_code"}) - - return self._get_json(f"{self.API_URL}/v2/profiles/blocked-profiles", - params={"limit": limit, "offset": offset})
- - -
-[docs] - def block_user(self, user_id: Union[int, str]) -> None: - - """ - Block a user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - user_id : `int` or `str` - TIDAL user ID. - - **Example**: :code:`172311284`. - """ - - self._check_scope("block_user", "r_usr", flows={"device_code"}) - - self._request("put", f"{self.API_URL}/v2/profiles/block/{user_id}")
- - -
-[docs] - def unblock_user(self, user_id: Union[int, str]) -> None: - - """ - Unblock a user. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - user_id : `int` or `str` - TIDAL user ID. - - **Example**: :code:`172311284`. - """ - - self._check_scope("unblock_user", "r_usr", flows={"device_code"}) - - self._request("delete", f"{self.API_URL}/v2/profiles/block/{user_id}")
- - - ### VIDEOS ################################################################ - -
-[docs] - def get_video( - self, video_id: Union[int, str], country_code: str = None - ) -> dict[str, Any]: - - """ - Get TIDAL catalog information for a video. - - .. admonition:: Authorization scope - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Parameters - ---------- - video_id : `int` or `str` - TIDAL video ID. - - **Example**: :code:`59727844`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - Returns - ------- - video : `dict` - TIDAL catalog information for a video. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "id": <int>, - "title": <str>, - "volumeNumber": <int>, - "trackNumber": <int>, - "releaseDate": <str>, - "imagePath": <str>, - "imageId": <str>, - "vibrantColor": <str>, - "duration": <int>, - "quality": <str>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "allowStreaming": <bool>, - "explicit": <bool>, - "popularity": <int>, - "type": <str>, - "adsUrl": <str>, - "adsPrePaywallOnly": <bool>, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str>, - }, - "artists": [ - { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str>, - } - ], - "album": <dict> - } - """ - - self._check_scope("get_video", "r_usr", flows={"device_code"}, - require_authentication=False) - - return self._get_json( - f"{self.API_URL}/v1/videos/{video_id}", - params={"countryCode": self._get_country_code(country_code)} - )
- - -
-[docs] - def get_video_playback_info( - self, video_id: Union[int, str], *, video_quality: str = "HIGH", - playback_mode: str = "STREAM", asset_presentation: str = "FULL", - streaming_session_id: str = None) -> dict[str, Any]: - - """ - Get playback information for a video. - - .. admonition:: User authentication, authorization scope, and - subscription - :class: dropdown warning - - Requires the :code:`r_usr` authorization scope if the device - code flow was used. - - Full video playback information is only available with user - authentication and an active TIDAL subscription. - - Parameters - ---------- - video_id : `int` or `str` - TIDAL video ID. - - **Example**: :code:`59727844`. - - video_quality : `str`, keyword-only, default: :code:`"HIGH"` - Video quality. - - **Valid values**: :code:`"AUDIO_ONLY"`, :code:`"LOW"`, - :code:`"MEDIUM"`, and :code:`"HIGH"`. - - playback_mode : `str`, keyword-only, default: :code:`"STREAM"` - Playback mode. - - **Valid values**: :code:`"STREAM"` and :code:`"OFFLINE"`. - - asset_presentation : `str`, keyword-only, default: :code:`"FULL"` - Asset presentation. - - .. container:: - - **Valid values**: - - * :code:`"FULL"`: Full video. - * :code:`"PREVIEW"`: 30-second preview of the video. - - streaming_session_id : `str`, keyword-only, optional - Streaming session ID. - - Returns - ------- - info : `dict` - Video playback information. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "videoId": <int>, - "streamType": <str>, - "assetPresentation": <str>, - "videoQuality": <str>, - "manifestMimeType": <str>, - "manifestHash": <str>, - "manifest": <str> - } - """ - - self._check_scope("get_video_playback_info", "r_usr", - flows={"device_code"}, require_authentication=False) - - if video_quality not in \ - (VIDEO_QUALITIES := {"AUDIO_ONLY", "LOW", "MEDIUM", "HIGH"}): - emsg = ("Invalid video quality. Valid values: " - f"are{', '.join(VIDEO_QUALITIES)}.") - raise ValueError(emsg) - if playback_mode not in (PLAYBACK_MODES := {"STREAM", "OFFLINE"}): - emsg = ("Invalid playback mode. Valid values: " - f"modes are {', '.join(PLAYBACK_MODES)}.") - raise ValueError(emsg) - if asset_presentation not in \ - (ASSET_PRESENTATIONS := {"FULL", "PREVIEW"}): - emsg = ("Invalid asset presentation. Valid values: " - "presentations are " - f"{', '.join(ASSET_PRESENTATIONS)}.") - raise ValueError(emsg) - - url = f"{self.API_URL}/v1/videos/{video_id}/playbackinfo" - url += "postpaywall" if self._flow else "prepaywall" - return self._get_json( - url, - params={ - "videoquality": video_quality, - "assetpresentation": asset_presentation, - "playbackmode": playback_mode, - "streamingsessionid": streaming_session_id - } - )
- - -
-[docs] - def get_favorite_videos( - self, country_code: str = None, *, limit: int = 50, - offset: int = None, order: str = "DATE", - order_direction: str = "DESC"): - - """ - Get TIDAL catalog information for videos in the current user's - collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - limit : `int`, keyword-only, default: :code:`50` - Page size. - - **Example**: :code:`10`. - - offset : `int`, keyword-only, optional - Pagination offset (in number of items). - - **Example**: :code:`0`. - - order : `str`, keyword-only, default: :code:`"DATE"` - Sorting order. - - **Valid values**: :code:`"DATE"` and :code:`"NAME"`. - - order_direction : `str`, keyword-only, default: :code:`"DESC"` - Sorting order direction. - - **Valid values**: :code:`"DESC"` and :code:`"ASC"`. - - Returns - ------- - videos : `dict` - A dictionary containing TIDAL catalog information for videos - in the current user's collection and metadata for the - returned results. - - .. admonition:: Sample response - :class: dropdown - - .. code:: - - { - "limit": <int>, - "offset": <int>, - "totalNumberOfItems": <int>, - "items": [ - { - "created": <str>, - "item": { - "id": <int>, - "title": <str>, - "volumeNumber": <int>, - "trackNumber": <int>, - "releaseDate": <str>, - "imagePath": <str>, - "imageId": <str>, - "vibrantColor": <str>, - "duration": <int>, - "quality": <str>, - "streamReady": <bool>, - "adSupportedStreamReady": <bool>, - "djReady": <bool>, - "stemReady": <bool>, - "streamStartDate": <str>, - "allowStreaming": <bool>, - "explicit": <bool>, - "popularity": <int>, - "type": <str>, - "adsUrl": <str>, - "adsPrePaywallOnly": <bool>, - "artist": { - "id": <int>, - "name": <str>, - "type": <str>, - "picture": <str> - }, - "artists": [ - { - "id": <int>, - "name": "<str>, - "type": <str>, - "picture": <str> - } - ], - "album": <dict> - } - } - ] - } - """ - - self._check_scope("get_favorite_videos", "r_usr", - flows={"device_code"}) - - return self._get_json( - f"{self.API_URL}/v1/users/{self._user_id}/favorites/videos", - params={ - "countryCode": self._get_country_code(country_code), - "limit": limit, - "offset": offset, - "order": order, - "orderDirection": order_direction - } - )
- - -
-[docs] - def favorite_videos( - self, video_ids: Union[int, str, list[Union[int, str]]], - country_code: str = None, *, on_artifact_not_found: str = "FAIL" - ) -> None: - - """ - Add videos to the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - video_ids : `int`, `str`, or `list` - TIDAL video ID(s). - - **Examples**: :code:`"59727844,75623239"` or - :code:`[59727844, 75623239]`. - - country_code : `str`, optional - ISO 3166-1 alpha-2 country code. If not provided, the - country code associated with the user account in the current - session or the current IP address will be used instead. - - **Example**: :code:`"US"`. - - on_artifact_not_found : `str`, keyword-only, default: :code:`"FAIL"` - Behavior when the item to be added does not exist. - - **Valid values**: :code:`"FAIL"` or :code:`"SKIP"`. - """ - - self._check_scope("favorite_videos", "r_usr", flows={"device_code"}) - - self._request( - "post", - f"{self.API_URL}/v1/users/{self._user_id}/favorites/videos", - params={"countryCode": self._get_country_code(country_code)}, - data={ - "videoIds": ",".join(map(str, video_ids)) - if isinstance(video_ids, list) else video_ids, - "onArtifactNotFound": on_artifact_not_found - } - )
- - -
-[docs] - def unfavorite_videos( - self, video_ids: Union[int, str, list[Union[int, str]]]) -> None: - - """ - Remove videos from the current user's collection. - - .. admonition:: User authentication and authorization scope - :class: warning - - Requires user authentication and the :code:`r_usr` - authorization scope if the device code flow was used. - - Parameters - ---------- - video_ids : `int`, `str`, or `list` - TIDAL video ID(s). - - **Examples**: :code:`"59727844,75623239"` or - :code:`[59727844, 75623239]`. - """ - - self._check_scope("unfavorite_videos", "r_usr", flows={"device_code"}) - - if isinstance(video_ids, list): - video_ids = ",".join(map(str, video_ids)) - self._request("delete", - f"{self.API_URL}/v1/users/{self._user_id}" - f"/favorites/videos/{video_ids}")
-
- -
-
-
-
- - -
-
- - Made with Sphinx and @pradyunsg's - - Furo - -
-
- -
-
- -
-
- -
-
- - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/_modules/minim/utility.html b/docs/_modules/minim/utility.html deleted file mode 100644 index ef91e0a..0000000 --- a/docs/_modules/minim/utility.html +++ /dev/null @@ -1,436 +0,0 @@ - - - - - - - - minim.utility - Minim 1.0.0 documentation - - - - - - - - - - - - - - - - - - - Contents - - - - - - Menu - - - - - - - - Expand - - - - - - Light mode - - - - - - - - - - - - - - Dark mode - - - - - - - Auto light/dark mode - - - - - - - - - - - - - - - - - - - -
-
-
- -
- -
-
- -
- -
-
- -
-
-
- - - - - Back to top - -
-
- -
- -
-
-

Source code for minim.utility

-"""
-Utility functions
-=================
-.. moduleauthor:: Benjamin Ye <GitHub: bbye98>
-
-This module contains a collection of utility functions.
-"""
-
-from difflib import SequenceMatcher
-from typing import Any, Union
-
-try:
-    import Levenshtein
-    FOUND_LEVENSHTEIN = True
-except ModuleNotFoundError:
-    FOUND_LEVENSHTEIN = False
-try:
-    import numpy as np
-    FOUND_NUMPY = True
-except ModuleNotFoundError:
-    FOUND_NUMPY = False
-
-__all__ = ["format_multivalue", "gestalt_ratio", "levenshtein_ratio"]
-
-
-[docs] -def format_multivalue( - value: Any, multivalue: bool, *, primary: bool = False, - sep: Union[str, tuple[str]] = (", ", " & ")) -> Union[str, list[Any]]: - - """ - Format a field value based on whether multivalue for that field is - supported. - - Parameters - ---------- - value : `Any` - Field value to format. - - multivalue : `bool` - Determines whether multivalue tags are supported. If - :code:`False`, the items in `value` are concatenated using the - separator(s) specified in `sep`. - - primary : `bool`, keyword-only, default: :code:`False` - Specifies whether the first item in `value` should be used when - `value` is a `list` and :code:`multivalue=False`. - - sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` - Separator(s) to use to concatenate multivalue tags. If a - :code:`str` is provided, it is used to concatenate all values. - If a :code:`tuple` is provided, the first :code:`str` is used to - concatenate all values except the last, and the second - :code:`str` is used to append the final value. - - Returns - ------- - value : `Any` - Formatted field value. - """ - - if isinstance(value, list): - if not multivalue: - if len(value) == 1 or primary: - return value[0] - elif isinstance(value[0], str): - if isinstance(sep, str): - return sep.join(value) - return f"{sep[0].join(value[:-1])}{sep[1]}{value[-1]}" - elif multivalue: - return [value] - return value
- - -
-[docs] -def gestalt_ratio( - reference: str, strings: Union[str, list[str]] - ) -> Union[float, list[float], "np.ndarray[float]"]: - - """ - Compute the Gestalt or Ratcliff–Obershelp ratios, a measure of - similarity, for strings with respect to a reference string. - - Parameters - ---------- - reference : `str` - Reference string. - - strings : `str` or `list` - Strings to compare with `reference`. - - Returns - ------- - ratios : `float`, `list`, or `numpy.ndarray` - Gestalt or Ratcliff–Obershelp ratios. If `strings` is a `str`, a - `float` is returned. If `strings` is a `list`, a `numpy.ndarray` - is returned if NumPy is installed; otherwise, a `list` is - returned. - """ - - if isinstance(strings, str): - return SequenceMatcher(None, reference, strings).ratio() - gen = (SequenceMatcher(None, reference, s).ratio() for s in strings) - if FOUND_NUMPY: - return np.fromiter(gen, dtype=float, count=len(strings)) - return list(gen)
- - -
-[docs] -def levenshtein_ratio( - reference: str, strings: Union[str, list[str]] - ) -> Union[float, list[float], "np.ndarray[float]"]: - - """ - Compute the Levenshtein ratios, a measure of similarity, for - strings with respect to a reference string. - - Parameters - ---------- - reference : `str` - Reference string. - - strings : `str` or `list` - Strings to compare with `reference`. - - Returns - ------- - ratios : `float`, `list`, or `numpy.ndarray` - Levenshtein ratios. If `strings` is a `str`, a `float` is - returned. If `strings` is a `list`, a `numpy.ndarray` is - returned if NumPy is installed; otherwise, a `list` is returned. - """ - - if not FOUND_LEVENSHTEIN: - emsg = ("The Levenshtein module was not found, so " - "minim.utility.levenshtein_ratio() is unavailable.") - raise ImportError(emsg) - - if isinstance(strings, str): - return Levenshtein.ratio(reference, strings) - gen = (Levenshtein.ratio(reference, s) for s in strings) - if FOUND_NUMPY: - return np.fromiter(gen, dtype=float, count=len(strings)) - return list(gen)
- -
-
-
-
- - -
-
- - Made with Sphinx and @pradyunsg's - - Furo - -
-
- -
-
- -
-
- -
-
- - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/_sources/api.rst.txt b/docs/_sources/api.rst.txt deleted file mode 100644 index 173a857..0000000 --- a/docs/_sources/api.rst.txt +++ /dev/null @@ -1,6 +0,0 @@ -.. autosummary:: - :recursive: - :template: autosummary/module.rst - :toctree: api - - minim \ No newline at end of file diff --git a/docs/_sources/api/minim.audio.Audio.rst.txt b/docs/_sources/api/minim.audio.Audio.rst.txt deleted file mode 100644 index 8b3c7b8..0000000 --- a/docs/_sources/api/minim.audio.Audio.rst.txt +++ /dev/null @@ -1,25 +0,0 @@ -Audio -===== - -.. currentmodule:: minim.audio - -.. autoclass:: Audio - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~Audio.convert - ~Audio.set_metadata_using_itunes - ~Audio.set_metadata_using_qobuz - ~Audio.set_metadata_using_spotify - ~Audio.set_metadata_using_tidal - - \ No newline at end of file diff --git a/docs/_sources/api/minim.audio.FLACAudio.rst.txt b/docs/_sources/api/minim.audio.FLACAudio.rst.txt deleted file mode 100644 index 796183a..0000000 --- a/docs/_sources/api/minim.audio.FLACAudio.rst.txt +++ /dev/null @@ -1,26 +0,0 @@ -FLACAudio -========= - -.. currentmodule:: minim.audio - -.. autoclass:: FLACAudio - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~FLACAudio.convert - ~FLACAudio.set_metadata_using_itunes - ~FLACAudio.set_metadata_using_qobuz - ~FLACAudio.set_metadata_using_spotify - ~FLACAudio.set_metadata_using_tidal - ~FLACAudio.write_metadata - - \ No newline at end of file diff --git a/docs/_sources/api/minim.audio.MP3Audio.rst.txt b/docs/_sources/api/minim.audio.MP3Audio.rst.txt deleted file mode 100644 index 13dc5ba..0000000 --- a/docs/_sources/api/minim.audio.MP3Audio.rst.txt +++ /dev/null @@ -1,26 +0,0 @@ -MP3Audio -======== - -.. currentmodule:: minim.audio - -.. autoclass:: MP3Audio - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~MP3Audio.convert - ~MP3Audio.set_metadata_using_itunes - ~MP3Audio.set_metadata_using_qobuz - ~MP3Audio.set_metadata_using_spotify - ~MP3Audio.set_metadata_using_tidal - ~MP3Audio.write_metadata - - \ No newline at end of file diff --git a/docs/_sources/api/minim.audio.MP4Audio.rst.txt b/docs/_sources/api/minim.audio.MP4Audio.rst.txt deleted file mode 100644 index ea3fbe1..0000000 --- a/docs/_sources/api/minim.audio.MP4Audio.rst.txt +++ /dev/null @@ -1,26 +0,0 @@ -MP4Audio -======== - -.. currentmodule:: minim.audio - -.. autoclass:: MP4Audio - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~MP4Audio.convert - ~MP4Audio.set_metadata_using_itunes - ~MP4Audio.set_metadata_using_qobuz - ~MP4Audio.set_metadata_using_spotify - ~MP4Audio.set_metadata_using_tidal - ~MP4Audio.write_metadata - - \ No newline at end of file diff --git a/docs/_sources/api/minim.audio.OGGAudio.rst.txt b/docs/_sources/api/minim.audio.OGGAudio.rst.txt deleted file mode 100644 index 4f2e636..0000000 --- a/docs/_sources/api/minim.audio.OGGAudio.rst.txt +++ /dev/null @@ -1,26 +0,0 @@ -OggAudio -======== - -.. currentmodule:: minim.audio - -.. autoclass:: OggAudio - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~OggAudio.convert - ~OggAudio.set_metadata_using_itunes - ~OggAudio.set_metadata_using_qobuz - ~OggAudio.set_metadata_using_spotify - ~OggAudio.set_metadata_using_tidal - ~OggAudio.write_metadata - - \ No newline at end of file diff --git a/docs/_sources/api/minim.audio.WAVEAudio.rst.txt b/docs/_sources/api/minim.audio.WAVEAudio.rst.txt deleted file mode 100644 index 6a23a08..0000000 --- a/docs/_sources/api/minim.audio.WAVEAudio.rst.txt +++ /dev/null @@ -1,26 +0,0 @@ -WAVEAudio -========= - -.. currentmodule:: minim.audio - -.. autoclass:: WAVEAudio - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~WAVEAudio.convert - ~WAVEAudio.set_metadata_using_itunes - ~WAVEAudio.set_metadata_using_qobuz - ~WAVEAudio.set_metadata_using_spotify - ~WAVEAudio.set_metadata_using_tidal - ~WAVEAudio.write_metadata - - \ No newline at end of file diff --git a/docs/_sources/api/minim.audio.rst.txt b/docs/_sources/api/minim.audio.rst.txt deleted file mode 100644 index e74fc2e..0000000 --- a/docs/_sources/api/minim.audio.rst.txt +++ /dev/null @@ -1,37 +0,0 @@ -audio -===== - -.. automodule:: minim.audio - - - - - - - - - - - - .. rubric:: Classes - - .. autosummary:: - :toctree: - :template: autosummary/class.rst - :nosignatures: - - Audio - FLACAudio - MP3Audio - MP4Audio - OggAudio - WAVEAudio - - - - - - - - - diff --git a/docs/_sources/api/minim.discogs.API.rst.txt b/docs/_sources/api/minim.discogs.API.rst.txt deleted file mode 100644 index 60ec4a5..0000000 --- a/docs/_sources/api/minim.discogs.API.rst.txt +++ /dev/null @@ -1,70 +0,0 @@ -API -=== - -.. currentmodule:: minim.discogs - -.. autoclass:: API - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~API.add_collection_folder_release - ~API.add_order_message - ~API.create_collection_folder - ~API.create_listing - ~API.delete_collection_folder - ~API.delete_collection_folder_release - ~API.delete_listing - ~API.delete_user_release_rating - ~API.download_inventory_export - ~API.edit_collection_folder_release - ~API.edit_collection_release_field - ~API.edit_listing - ~API.edit_order - ~API.edit_profile - ~API.export_inventory - ~API.get_artist - ~API.get_artist_releases - ~API.get_collection_fields - ~API.get_collection_folder - ~API.get_collection_folder_releases - ~API.get_collection_folders - ~API.get_collection_folders_by_release - ~API.get_collection_value - ~API.get_community_release_rating - ~API.get_fee - ~API.get_identity - ~API.get_inventory - ~API.get_inventory_export - ~API.get_inventory_exports - ~API.get_label - ~API.get_label_releases - ~API.get_listing - ~API.get_master_release - ~API.get_master_release_versions - ~API.get_order - ~API.get_order_messages - ~API.get_price_suggestions - ~API.get_profile - ~API.get_release - ~API.get_release_marketplace_stats - ~API.get_release_stats - ~API.get_user_contributions - ~API.get_user_orders - ~API.get_user_release_rating - ~API.get_user_submissions - ~API.rename_collection_folder - ~API.search - ~API.set_access_token - ~API.set_flow - ~API.update_user_release_rating - - \ No newline at end of file diff --git a/docs/_sources/api/minim.discogs.rst.txt b/docs/_sources/api/minim.discogs.rst.txt deleted file mode 100644 index bfb5edd..0000000 --- a/docs/_sources/api/minim.discogs.rst.txt +++ /dev/null @@ -1,32 +0,0 @@ -discogs -======= - -.. automodule:: minim.discogs - - - - - - - - - - - - .. rubric:: Classes - - .. autosummary:: - :toctree: - :template: autosummary/class.rst - :nosignatures: - - API - - - - - - - - - diff --git a/docs/_sources/api/minim.itunes.SearchAPI.rst.txt b/docs/_sources/api/minim.itunes.SearchAPI.rst.txt deleted file mode 100644 index e114376..0000000 --- a/docs/_sources/api/minim.itunes.SearchAPI.rst.txt +++ /dev/null @@ -1,22 +0,0 @@ -SearchAPI -========= - -.. currentmodule:: minim.itunes - -.. autoclass:: SearchAPI - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~SearchAPI.lookup - ~SearchAPI.search - - \ No newline at end of file diff --git a/docs/_sources/api/minim.itunes.rst.txt b/docs/_sources/api/minim.itunes.rst.txt deleted file mode 100644 index e7a936c..0000000 --- a/docs/_sources/api/minim.itunes.rst.txt +++ /dev/null @@ -1,32 +0,0 @@ -itunes -====== - -.. automodule:: minim.itunes - - - - - - - - - - - - .. rubric:: Classes - - .. autosummary:: - :toctree: - :template: autosummary/class.rst - :nosignatures: - - SearchAPI - - - - - - - - - diff --git a/docs/_sources/api/minim.qobuz.PrivateAPI.rst.txt b/docs/_sources/api/minim.qobuz.PrivateAPI.rst.txt deleted file mode 100644 index 1638259..0000000 --- a/docs/_sources/api/minim.qobuz.PrivateAPI.rst.txt +++ /dev/null @@ -1,50 +0,0 @@ -PrivateAPI -========== - -.. currentmodule:: minim.qobuz - -.. autoclass:: PrivateAPI - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~PrivateAPI.add_playlist_tracks - ~PrivateAPI.create_playlist - ~PrivateAPI.delete_playlist - ~PrivateAPI.delete_playlist_tracks - ~PrivateAPI.favorite_items - ~PrivateAPI.favorite_playlist - ~PrivateAPI.get_album - ~PrivateAPI.get_artist - ~PrivateAPI.get_collection_streams - ~PrivateAPI.get_curated_tracks - ~PrivateAPI.get_favorites - ~PrivateAPI.get_featured_albums - ~PrivateAPI.get_featured_playlists - ~PrivateAPI.get_label - ~PrivateAPI.get_playlist - ~PrivateAPI.get_profile - ~PrivateAPI.get_purchases - ~PrivateAPI.get_track - ~PrivateAPI.get_track_file_url - ~PrivateAPI.get_track_performers - ~PrivateAPI.get_track_stream - ~PrivateAPI.get_user_playlists - ~PrivateAPI.move_playlist_tracks - ~PrivateAPI.search - ~PrivateAPI.set_auth_token - ~PrivateAPI.set_flow - ~PrivateAPI.unfavorite_items - ~PrivateAPI.unfavorite_playlist - ~PrivateAPI.update_playlist - ~PrivateAPI.update_playlist_position - - \ No newline at end of file diff --git a/docs/_sources/api/minim.qobuz.rst.txt b/docs/_sources/api/minim.qobuz.rst.txt deleted file mode 100644 index eed554f..0000000 --- a/docs/_sources/api/minim.qobuz.rst.txt +++ /dev/null @@ -1,32 +0,0 @@ -qobuz -===== - -.. automodule:: minim.qobuz - - - - - - - - - - - - .. rubric:: Classes - - .. autosummary:: - :toctree: - :template: autosummary/class.rst - :nosignatures: - - PrivateAPI - - - - - - - - - diff --git a/docs/_sources/api/minim.rst.txt b/docs/_sources/api/minim.rst.txt deleted file mode 100644 index bcd91e2..0000000 --- a/docs/_sources/api/minim.rst.txt +++ /dev/null @@ -1,36 +0,0 @@ -minim -===== - -.. automodule:: minim - - - - - - - - - - - - - - - - - - - -.. autosummary:: - :toctree: - :template: autosummary/module.rst - :recursive: - - minim.audio - minim.discogs - minim.itunes - minim.qobuz - minim.spotify - minim.tidal - minim.utility - diff --git a/docs/_sources/api/minim.spotify.PrivateLyricsService.rst.txt b/docs/_sources/api/minim.spotify.PrivateLyricsService.rst.txt deleted file mode 100644 index 2b2d728..0000000 --- a/docs/_sources/api/minim.spotify.PrivateLyricsService.rst.txt +++ /dev/null @@ -1,23 +0,0 @@ -PrivateLyricsService -==================== - -.. currentmodule:: minim.spotify - -.. autoclass:: PrivateLyricsService - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~PrivateLyricsService.get_lyrics - ~PrivateLyricsService.set_access_token - ~PrivateLyricsService.set_sp_dc - - \ No newline at end of file diff --git a/docs/_sources/api/minim.spotify.WebAPI.rst.txt b/docs/_sources/api/minim.spotify.WebAPI.rst.txt deleted file mode 100644 index 72aba01..0000000 --- a/docs/_sources/api/minim.spotify.WebAPI.rst.txt +++ /dev/null @@ -1,111 +0,0 @@ -WebAPI -====== - -.. currentmodule:: minim.spotify - -.. autoclass:: WebAPI - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~WebAPI.add_playlist_cover_image - ~WebAPI.add_playlist_items - ~WebAPI.add_to_queue - ~WebAPI.change_playlist_details - ~WebAPI.check_followed_people - ~WebAPI.check_playlist_followers - ~WebAPI.check_saved_albums - ~WebAPI.check_saved_audiobooks - ~WebAPI.check_saved_episodes - ~WebAPI.check_saved_shows - ~WebAPI.check_saved_tracks - ~WebAPI.create_playlist - ~WebAPI.follow_people - ~WebAPI.follow_playlist - ~WebAPI.get_album - ~WebAPI.get_album_tracks - ~WebAPI.get_albums - ~WebAPI.get_artist - ~WebAPI.get_artist_albums - ~WebAPI.get_artist_top_tracks - ~WebAPI.get_artists - ~WebAPI.get_audiobook - ~WebAPI.get_audiobook_chapters - ~WebAPI.get_audiobooks - ~WebAPI.get_categories - ~WebAPI.get_category - ~WebAPI.get_category_playlists - ~WebAPI.get_chapter - ~WebAPI.get_chapters - ~WebAPI.get_currently_playing - ~WebAPI.get_devices - ~WebAPI.get_episode - ~WebAPI.get_episodes - ~WebAPI.get_featured_playlists - ~WebAPI.get_followed_artists - ~WebAPI.get_genre_seeds - ~WebAPI.get_markets - ~WebAPI.get_new_albums - ~WebAPI.get_personal_playlists - ~WebAPI.get_playback_state - ~WebAPI.get_playlist - ~WebAPI.get_playlist_cover_image - ~WebAPI.get_playlist_items - ~WebAPI.get_profile - ~WebAPI.get_queue - ~WebAPI.get_recently_played - ~WebAPI.get_recommendations - ~WebAPI.get_related_artists - ~WebAPI.get_saved_albums - ~WebAPI.get_saved_audiobooks - ~WebAPI.get_saved_episodes - ~WebAPI.get_saved_shows - ~WebAPI.get_saved_tracks - ~WebAPI.get_scopes - ~WebAPI.get_show - ~WebAPI.get_show_episodes - ~WebAPI.get_shows - ~WebAPI.get_top_items - ~WebAPI.get_track - ~WebAPI.get_track_audio_analysis - ~WebAPI.get_track_audio_features - ~WebAPI.get_tracks - ~WebAPI.get_tracks_audio_features - ~WebAPI.get_user_playlists - ~WebAPI.get_user_profile - ~WebAPI.pause_playback - ~WebAPI.remove_playlist_items - ~WebAPI.remove_saved_albums - ~WebAPI.remove_saved_audiobooks - ~WebAPI.remove_saved_episodes - ~WebAPI.remove_saved_shows - ~WebAPI.remove_saved_tracks - ~WebAPI.save_albums - ~WebAPI.save_audiobooks - ~WebAPI.save_episodes - ~WebAPI.save_shows - ~WebAPI.save_tracks - ~WebAPI.search - ~WebAPI.seek_to_position - ~WebAPI.set_access_token - ~WebAPI.set_flow - ~WebAPI.set_playback_volume - ~WebAPI.set_repeat_mode - ~WebAPI.skip_to_next - ~WebAPI.skip_to_previous - ~WebAPI.start_playback - ~WebAPI.toggle_playback_shuffle - ~WebAPI.transfer_playback - ~WebAPI.unfollow_people - ~WebAPI.unfollow_playlist - ~WebAPI.update_playlist_items - - \ No newline at end of file diff --git a/docs/_sources/api/minim.spotify.rst.txt b/docs/_sources/api/minim.spotify.rst.txt deleted file mode 100644 index d75021d..0000000 --- a/docs/_sources/api/minim.spotify.rst.txt +++ /dev/null @@ -1,33 +0,0 @@ -spotify -======= - -.. automodule:: minim.spotify - - - - - - - - - - - - .. rubric:: Classes - - .. autosummary:: - :toctree: - :template: autosummary/class.rst - :nosignatures: - - PrivateLyricsService - WebAPI - - - - - - - - - diff --git a/docs/_sources/api/minim.tidal.API.rst.txt b/docs/_sources/api/minim.tidal.API.rst.txt deleted file mode 100644 index cdb523b..0000000 --- a/docs/_sources/api/minim.tidal.API.rst.txt +++ /dev/null @@ -1,39 +0,0 @@ -API -=== - -.. currentmodule:: minim.tidal - -.. autoclass:: API - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~API.get_album - ~API.get_album_by_barcode_id - ~API.get_album_items - ~API.get_albums - ~API.get_artist - ~API.get_artist_albums - ~API.get_artist_tracks - ~API.get_artists - ~API.get_similar_albums - ~API.get_similar_artists - ~API.get_similar_tracks - ~API.get_track - ~API.get_tracks - ~API.get_tracks_by_isrc - ~API.get_video - ~API.get_videos - ~API.search - ~API.set_access_token - ~API.set_flow - - \ No newline at end of file diff --git a/docs/_sources/api/minim.tidal.PrivateAPI.rst.txt b/docs/_sources/api/minim.tidal.PrivateAPI.rst.txt deleted file mode 100644 index 0c9c167..0000000 --- a/docs/_sources/api/minim.tidal.PrivateAPI.rst.txt +++ /dev/null @@ -1,106 +0,0 @@ -PrivateAPI -========== - -.. currentmodule:: minim.tidal - -.. autoclass:: PrivateAPI - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - - - .. rubric:: Methods - - .. autosummary:: - :nosignatures: - - ~PrivateAPI.add_playlist_items - ~PrivateAPI.block_artist - ~PrivateAPI.block_user - ~PrivateAPI.create_playlist - ~PrivateAPI.create_playlist_folder - ~PrivateAPI.delete_playlist - ~PrivateAPI.delete_playlist_folder - ~PrivateAPI.delete_playlist_item - ~PrivateAPI.favorite_albums - ~PrivateAPI.favorite_artists - ~PrivateAPI.favorite_mixes - ~PrivateAPI.favorite_playlists - ~PrivateAPI.favorite_tracks - ~PrivateAPI.favorite_videos - ~PrivateAPI.follow_user - ~PrivateAPI.get_album - ~PrivateAPI.get_album_credits - ~PrivateAPI.get_album_items - ~PrivateAPI.get_album_page - ~PrivateAPI.get_album_review - ~PrivateAPI.get_artist - ~PrivateAPI.get_artist_albums - ~PrivateAPI.get_artist_biography - ~PrivateAPI.get_artist_links - ~PrivateAPI.get_artist_mix_id - ~PrivateAPI.get_artist_page - ~PrivateAPI.get_artist_radio - ~PrivateAPI.get_artist_top_tracks - ~PrivateAPI.get_artist_videos - ~PrivateAPI.get_blocked_artists - ~PrivateAPI.get_blocked_users - ~PrivateAPI.get_collection_streams - ~PrivateAPI.get_country_code - ~PrivateAPI.get_favorite_albums - ~PrivateAPI.get_favorite_artists - ~PrivateAPI.get_favorite_ids - ~PrivateAPI.get_favorite_mixes - ~PrivateAPI.get_favorite_tracks - ~PrivateAPI.get_favorite_videos - ~PrivateAPI.get_image - ~PrivateAPI.get_mix_items - ~PrivateAPI.get_mix_page - ~PrivateAPI.get_personal_playlist_folders - ~PrivateAPI.get_personal_playlists - ~PrivateAPI.get_playlist - ~PrivateAPI.get_playlist_etag - ~PrivateAPI.get_playlist_items - ~PrivateAPI.get_playlist_recommendations - ~PrivateAPI.get_profile - ~PrivateAPI.get_session - ~PrivateAPI.get_similar_albums - ~PrivateAPI.get_similar_artists - ~PrivateAPI.get_track - ~PrivateAPI.get_track_composers - ~PrivateAPI.get_track_contributors - ~PrivateAPI.get_track_credits - ~PrivateAPI.get_track_lyrics - ~PrivateAPI.get_track_mix_id - ~PrivateAPI.get_track_playback_info - ~PrivateAPI.get_track_recommendations - ~PrivateAPI.get_track_stream - ~PrivateAPI.get_user_followers - ~PrivateAPI.get_user_following - ~PrivateAPI.get_user_playlist - ~PrivateAPI.get_user_playlists - ~PrivateAPI.get_user_profile - ~PrivateAPI.get_video - ~PrivateAPI.get_video_page - ~PrivateAPI.get_video_playback_info - ~PrivateAPI.get_video_stream - ~PrivateAPI.move_playlist - ~PrivateAPI.move_playlist_item - ~PrivateAPI.search - ~PrivateAPI.set_access_token - ~PrivateAPI.set_flow - ~PrivateAPI.set_playlist_privacy - ~PrivateAPI.unblock_artist - ~PrivateAPI.unblock_user - ~PrivateAPI.unfavorite_albums - ~PrivateAPI.unfavorite_artists - ~PrivateAPI.unfavorite_mixes - ~PrivateAPI.unfavorite_playlist - ~PrivateAPI.unfavorite_tracks - ~PrivateAPI.unfavorite_videos - ~PrivateAPI.unfollow_user - ~PrivateAPI.update_playlist - - \ No newline at end of file diff --git a/docs/_sources/api/minim.tidal.rst.txt b/docs/_sources/api/minim.tidal.rst.txt deleted file mode 100644 index 12394af..0000000 --- a/docs/_sources/api/minim.tidal.rst.txt +++ /dev/null @@ -1,33 +0,0 @@ -tidal -===== - -.. automodule:: minim.tidal - - - - - - - - - - - - .. rubric:: Classes - - .. autosummary:: - :toctree: - :template: autosummary/class.rst - :nosignatures: - - API - PrivateAPI - - - - - - - - - diff --git a/docs/_sources/api/minim.utility.format_multivalue.rst.txt b/docs/_sources/api/minim.utility.format_multivalue.rst.txt deleted file mode 100644 index d8c7901..0000000 --- a/docs/_sources/api/minim.utility.format_multivalue.rst.txt +++ /dev/null @@ -1,6 +0,0 @@ -format\_multivalue -================== - -.. currentmodule:: minim.utility - -.. autofunction:: format_multivalue \ No newline at end of file diff --git a/docs/_sources/api/minim.utility.gestalt_ratio.rst.txt b/docs/_sources/api/minim.utility.gestalt_ratio.rst.txt deleted file mode 100644 index 4611a99..0000000 --- a/docs/_sources/api/minim.utility.gestalt_ratio.rst.txt +++ /dev/null @@ -1,6 +0,0 @@ -gestalt\_ratio -============== - -.. currentmodule:: minim.utility - -.. autofunction:: gestalt_ratio \ No newline at end of file diff --git a/docs/_sources/api/minim.utility.gestalt_ratios.rst.txt b/docs/_sources/api/minim.utility.gestalt_ratios.rst.txt deleted file mode 100644 index 8d88773..0000000 --- a/docs/_sources/api/minim.utility.gestalt_ratios.rst.txt +++ /dev/null @@ -1,6 +0,0 @@ -gestalt\_ratios -=============== - -.. currentmodule:: minim.utility - -.. autofunction:: gestalt_ratios \ No newline at end of file diff --git a/docs/_sources/api/minim.utility.levenshtein_ratio.rst.txt b/docs/_sources/api/minim.utility.levenshtein_ratio.rst.txt deleted file mode 100644 index 528af40..0000000 --- a/docs/_sources/api/minim.utility.levenshtein_ratio.rst.txt +++ /dev/null @@ -1,6 +0,0 @@ -levenshtein\_ratio -================== - -.. currentmodule:: minim.utility - -.. autofunction:: levenshtein_ratio \ No newline at end of file diff --git a/docs/_sources/api/minim.utility.levenshtein_ratios.rst.txt b/docs/_sources/api/minim.utility.levenshtein_ratios.rst.txt deleted file mode 100644 index 9dc9176..0000000 --- a/docs/_sources/api/minim.utility.levenshtein_ratios.rst.txt +++ /dev/null @@ -1,6 +0,0 @@ -levenshtein\_ratios -=================== - -.. currentmodule:: minim.utility - -.. autofunction:: levenshtein_ratios \ No newline at end of file diff --git a/docs/_sources/api/minim.utility.multivalue_formatter.rst.txt b/docs/_sources/api/minim.utility.multivalue_formatter.rst.txt deleted file mode 100644 index 62bbc35..0000000 --- a/docs/_sources/api/minim.utility.multivalue_formatter.rst.txt +++ /dev/null @@ -1,6 +0,0 @@ -multivalue\_formatter -===================== - -.. currentmodule:: minim.utility - -.. autofunction:: multivalue_formatter \ No newline at end of file diff --git a/docs/_sources/api/minim.utility.rst.txt b/docs/_sources/api/minim.utility.rst.txt deleted file mode 100644 index 62e359e..0000000 --- a/docs/_sources/api/minim.utility.rst.txt +++ /dev/null @@ -1,33 +0,0 @@ -utility -======= - -.. automodule:: minim.utility - - - - - - - - .. rubric:: Functions - - .. autosummary:: - :toctree: - :nosignatures: - - format_multivalue - gestalt_ratio - levenshtein_ratio - - - - - - - - - - - - - diff --git a/docs/_sources/index.rst.txt b/docs/_sources/index.rst.txt deleted file mode 100644 index fb4c42b..0000000 --- a/docs/_sources/index.rst.txt +++ /dev/null @@ -1,15 +0,0 @@ -Minim -===== - -Minim is a lightweight Python 3 library that can interface with APIs -by popular music services—iTunes, Qobuz, Spotify, and TIDAL—and operate -on audio files, such as updating metadata and converting between audio -formats. - -.. toctree:: - :hidden: - - Home - Getting Started - User Guide - API Reference \ No newline at end of file diff --git a/docs/_sources/notebooks/getting_started.ipynb.txt b/docs/_sources/notebooks/getting_started.ipynb.txt deleted file mode 100644 index 50eae34..0000000 --- a/docs/_sources/notebooks/getting_started.ipynb.txt +++ /dev/null @@ -1,2280 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Getting Started\n", - "\n", - "## Installation\n", - "\n", - "Minim is a Python package and can be installed from source using pip, the package installer for Python.\n", - "\n", - ":::{note}\n", - "Minim will be coming to PyPI and conda-forge once the [PEP 541 request](https://github.com/pypi/support/issues/3068) is resolved!\n", - ":::\n", - "\n", - "1. Grab a copy of the Minim repository:\n", - "\n", - " git clone https://github.com/bbye98/minim.git\n", - "\n", - "2. Enter the repository directory:\n", - "\n", - " cd minim\n", - "\n", - "3. *Optional*: Create a virtual environment to prevent dependency conflicts. \n", - "\n", - " **Conda**\n", - "\n", - " - Create an environment named `minim` and install the required dependencies using one of the following commands:\n", - "\n", - " conda create -n minim --file requirements_minimal.txt # required dependencies only\n", - " conda env create -f environment.yml # all dependencies\n", - "\n", - " - Activate the environment:\n", - "\n", - " conda activate minim\n", - "\n", - " **venv**\n", - "\n", - " - Create an environment named `minim`:\n", - "\n", - " python -m venv minim\n", - "\n", - " - Activate the environment using one of the following commands:\n", - "\n", - " source minim/bin/activate # POSIX: bash or zsh\n", - " minim\\Scripts\\activate.bat # Windows: cmd.exe\n", - " minim\\Scripts\\Activate.ps1 # Windows: PowerShell\n", - "\n", - " - The required dependencies will be installed automatically alongside Minim in the next step. To install all dependencies instead:\n", - "\n", - " python -m pip install -r requirements.txt\n", - "\n", - " **virtualenv**\n", - "\n", - " - Create an environment named `minim`:\n", - "\n", - " virtualenv minim\n", - "\n", - " - Activate the environment using one of the following commands:\n", - "\n", - " source minim/bin/activate # Linux or macOS\n", - " .\\minim\\Scripts\\activate # Windows\n", - "\n", - " - The required dependencies will be installed automatically alongside Minim in the next step. To install all dependencies instead:\n", - "\n", - " python -m pip install -r requirements.txt\n", - "\n", - "4. Install Minim (and required dependencies, if you have not already done so) using pip:\n", - "\n", - " python -m pip install -e .\n", - "\n", - "5. Try importing Minim in Python:\n", - "\n", - " python -c \"import minim\"\n", - "\n", - " If no errors like `ModuleNotFoundError: No module named 'minim'` are raised, you have successfully installed Minim!\n", - "\n", - "## Usage\n", - "\n", - "### Music service APIs" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "from minim import itunes, qobuz, spotify, tidal" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Currently, clients for iTunes Search API, Qobuz API, Spotify Web API, and TIDAL APIs have been implemented. Other than the iTunes Search API, which does not require client credentials or support user authentication and can be used out of the box, the other APIs have a few additional prerequisite steps before they can be used. If you authenticate via Minim, the tokens and their related information will be cached and updated automatically as they expire and are refreshed.\n", - "\n", - "#### iTunes Search API (`minim.itunes.SearchAPI`)\n", - "\n", - "To use the iTunes Search API, simply create a client by instantiating a `minim.itunes.SearchAPI` object with no arguments:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "client_itunes = itunes.SearchAPI()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Private Qobuz API (`minim.qobuz.PrivateAPI`)\n", - "\n", - "If you already have a user authentication token, you can provide it and its accompanying app credentials to the client as keyword arguments `auth_token`, `app_id`, and `app_secret`, respectively, and skip this section.\n", - "\n", - "To use the Qobuz API without user authentication, simply create a client by instantiating a `minim.qobuz.PrivateAPI` object with no arguments:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "client_qobuz = qobuz.PrivateAPI()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To use the Qobuz API with user authentication and get access to all public and protected endpoints, you can pass `flow=\"password\"` and provide your Qobuz email and password as keyword arguments `email` and `password` to the constructor:\n", - "\n", - "```python\n", - "client_qobuz = qobuz.PrivateAPI(flow=\"password\", email=, password=)\n", - "```\n", - "\n", - "which will authenticate you via a POST request to and retrieve the user authentication token from the Qobuz Web Player, or specify `browser=True` to have Minim spawn a web browser with the Qobuz Web Player login page:\n", - "\n", - "```python\n", - "client_qobuz = qobuz.PrivateAPI(flow=\"password\", browser=True)\n", - "```\n", - "\n", - "which you can use to log in normally.\n", - "\n", - "#### Private Spotify Lyrics Service (`minim.spotify.PrivateLyricsService`)\n", - "\n", - "If you already have a user access token, you can provide it and optionally its accompanying expiry time and `sp_dc` cookie to the client as keyword arguments `access_token`, `expiry`, and `sp_dc`, respectively, and skip this section.\n", - "\n", - "To use the Spotify Lyrics service,\n", - "\n", - "1. Launch a web browser and log into the [Spotify Web Player](https://accounts.spotify.com/en/login?continue=https%3A%2F%2Fopen.spotify.com%2F).\n", - "\n", - "2. Find the `sp_dc` cookie in your web browser's storage.\n", - "\n", - " - For Chromium-based browsers, press `F12` to open DevTools and navigate to `Application > Storage > Cookies > https://open.spotify.com`.\n", - " \n", - " - For Firefox, press `Shift` + `F9` to open Storage Inspector and nagivate to `Storage > Cookies > https://open.spotify.com`.\n", - " \n", - "3. Create a client by instantiating a `minim.spotify.WebAPI` object with the `sp_dc` cookie as a keyword argument:\n", - "\n", - " ```python\n", - " client_spotify_lyrics = spotify.PrivateLyricsService(sp_dc=)\n", - " ```\n", - "\n", - " or store the `sp_dc` cookie as an environment variable `SPOTIFY_SP_DC` and call the constructor with no arguments:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "client_spotify_lyrics = spotify.PrivateLyricsService()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Spotify Web API (`minim.spotify.WebAPI`)\n", - "\n", - "If you already have an access token, you can provide it and optionally its accompanying refresh token, expiry time, and client credentials to the client as keyword arguments `access_token`, `refresh_token`, `expiry`, `client_id`, and `client_secret`, respectively, and skip this section.\n", - "\n", - "First, register a Spotify application [here](https://developer.spotify.com/documentation/general/guides/authorization/app-settings/) and grab its client credentials. For the redirect URI, use `http://localhost:8888/callback`. You can replace `8888` with an open port of your choice, but you will need to pass `port=` when you create a client.\n", - "\n", - "To use the Spotify Web API without user authentication, you can provide the client credentials as keyword arguments `client_id` and `client_secret` to the constructor:\n", - "\n", - "```python\n", - "client_spotify = spotify.WebAPI(client_id=, \n", - " client_secret=)\n", - "```\n", - "\n", - "or store the client credentials as environment variables `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` and call the constructor with no arguments:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "client_spotify = spotify.WebAPI()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To use the Spotify Web API with user authentication, \n", - "\n", - "1. Get the necessary authorization scopes using `spotify.WebAPI.get_scopes()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "scopes = spotify.WebAPI.get_scopes(\"all\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "2. Create a client with `flow=\"pkce\"`, the client credentials in `client_id` and `client_secret`, the authorization scopes in `scopes`, and optionally `framework=\"http.server\"` to automate the authorization code retrieval process:\n", - "\n", - " ```python\n", - " client_spotify = spotify.WebAPI(client_id=, \n", - " client_secret=,\n", - " flow=\"pkce\", scopes=scopes, framework=\"http.server\")\n", - " ```\n", - "\n", - "3. If `framework=None`, open the authorization URL in a web browser.\n", - "\n", - "4. Log into your Spotify account and authorize Minim by clicking `Agree`.\n", - "\n", - "5. If `framework=None`, copy and paste the redirect URI into the prompt." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### TIDAL API (`minim.tidal.API`)\n", - "\n", - "If you already have a client-only access token, you can provide it and optionally its accompanying refresh token, expiry time, and client credentials to the client as keyword arguments `access_token`, `refresh_token`, `expiry`, `client_id`, and `client_secret`, respectively, and skip this section.\n", - "\n", - "First, register a TIDAL application [here](https://developer.tidal.com/documentation/dashboard/dashboard-client-credentials) and jot down the client credentials.\n", - "\n", - "To use the TIDAL API, you can provide the client credentials as keyword arguments `client_id` and `client_secret` to the `minim.tidal.API` constructor:\n", - "\n", - "```python\n", - "client_tidal = tidal.API(client_id=, client_secret=)\n", - "```\n", - "\n", - "or store the client credentials as environment variables `TIDAL_CLIENT_ID` and `TIDAL_CLIENT_SECRET` and create a client with no arguments:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "client_tidal = tidal.API()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Private TIDAL API (`minim.tidal.PrivateAPI`)\n", - "\n", - "If you already have an access token, you can provide it and optionally its accompanying refresh token, expiry time, and client credentials to the client as keyword arguments `access_token`, `refresh_token`, `expiry`, `client_id`, and `client_secret`, respectively, and skip this section.\n", - "\n", - "To use the TIDAL API without user authentication, simply create a client by instantiating a `minim.tidal.PrivateAPI` object with no arguments:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "client_tidal_private = tidal.PrivateAPI()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To use the TIDAL API with user authentication,\n", - "\n", - "1. Get client credentials from the TIDAL Web Player or the Android, iOS, macOS, and Windows applications by using a web debugging proxy tool to intercept web traffic.\n", - "\n", - "2. Create a client with the client credentials in `client_id` and `client_secret` and optionally `browser=True` to automatically open a web browser for the authorization flow. Use the authorization code with PKCE flow:\n", - "\n", - " ```python\n", - " client_tidal_private = tidal.PrivateAPI(client_id=, \n", - " client_secret=,\n", - " flow=\"pkce\", browser=True)\n", - " ```\n", - " \n", - " if you obtained client credentials from the TIDAL Web Player or the desktop applications, or the device code flow:\n", - "\n", - " ```python\n", - " client_tidal_private = tidal.PrivateAPI(client_id=, \n", - " client_secret=,\n", - " flow=\"device\", browser=True)\n", - " ```\n", - "\n", - " if you obtained client credentials from the Android or iOS applications.\n", - " \n", - "3. Follow the instructions in the console (`browser=False`) or the web browser (`browser=True`) to log into your TIDAL account and authorize Minim.\n", - "\n", - "#### Examples\n", - "\n", - "##### Searching for artists\n", - "\n", - "Each of the APIs has a `search()` method that can be used to search for and retrieve information about an artist, such as the EDM group Galantis:\n", - "\n", - "###### iTunes Search API" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'wrapperType': 'artist',\n", - " 'artistType': 'Artist',\n", - " 'artistName': 'Galantis',\n", - " 'artistLinkUrl': 'https://music.apple.com/us/artist/galantis/543322169?uo=4',\n", - " 'artistId': 543322169,\n", - " 'amgArtistId': 2616267,\n", - " 'primaryGenreName': 'Dance',\n", - " 'primaryGenreId': 17}" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client_itunes.search(\"Galantis\", entity=\"musicArtist\", limit=1)[\"results\"][0]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### Private Qobuz API" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'picture': 'https://static.qobuz.com/images/artists/covers/small/8dcf30e5c8e30281ecbb13b0886426c8.jpg',\n", - " 'image': {'small': 'https://static.qobuz.com/images/artists/covers/small/8dcf30e5c8e30281ecbb13b0886426c8.jpg',\n", - " 'medium': 'https://static.qobuz.com/images/artists/covers/medium/8dcf30e5c8e30281ecbb13b0886426c8.jpg',\n", - " 'large': 'https://static.qobuz.com/images/artists/covers/large/8dcf30e5c8e30281ecbb13b0886426c8.jpg',\n", - " 'extralarge': 'https://static.qobuz.com/images/artists/covers/large/8dcf30e5c8e30281ecbb13b0886426c8.jpg',\n", - " 'mega': 'https://static.qobuz.com/images/artists/covers/large/8dcf30e5c8e30281ecbb13b0886426c8.jpg'},\n", - " 'name': 'Galantis',\n", - " 'slug': 'galantis',\n", - " 'albums_count': 143,\n", - " 'id': 865362}" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client_qobuz.search(\"Galantis\", limit=1, strict=True)[\"artists\"][\"items\"][0]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### Spotify Web API" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'external_urls': {'spotify': 'https://open.spotify.com/artist/4sTQVOfp9vEMCemLw50sbu'},\n", - " 'followers': {'href': None, 'total': 3343551},\n", - " 'genres': ['dance pop', 'edm', 'pop', 'pop dance'],\n", - " 'href': 'https://api.spotify.com/v1/artists/4sTQVOfp9vEMCemLw50sbu',\n", - " 'id': '4sTQVOfp9vEMCemLw50sbu',\n", - " 'images': [{'height': 640,\n", - " 'url': 'https://i.scdn.co/image/ab6761610000e5eb7bda087d6fb48d481efd3344',\n", - " 'width': 640},\n", - " {'height': 320,\n", - " 'url': 'https://i.scdn.co/image/ab676161000051747bda087d6fb48d481efd3344',\n", - " 'width': 320},\n", - " {'height': 160,\n", - " 'url': 'https://i.scdn.co/image/ab6761610000f1787bda087d6fb48d481efd3344',\n", - " 'width': 160}],\n", - " 'name': 'Galantis',\n", - " 'popularity': 70,\n", - " 'type': 'artist',\n", - " 'uri': 'spotify:artist:4sTQVOfp9vEMCemLw50sbu'}" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client_spotify.search(\"Galantis\", \"artist\", limit=1)[\"items\"][0]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### TIDAL API" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'resource': {'id': '4676988',\n", - " 'name': 'Galantis',\n", - " 'picture': [{'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/1024x256.jpg',\n", - " 'width': 1024,\n", - " 'height': 256},\n", - " {'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/1080x720.jpg',\n", - " 'width': 1080,\n", - " 'height': 720},\n", - " {'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/160x107.jpg',\n", - " 'width': 160,\n", - " 'height': 107},\n", - " {'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/160x160.jpg',\n", - " 'width': 160,\n", - " 'height': 160},\n", - " {'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/320x214.jpg',\n", - " 'width': 320,\n", - " 'height': 214},\n", - " {'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/320x320.jpg',\n", - " 'width': 320,\n", - " 'height': 320},\n", - " {'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/480x480.jpg',\n", - " 'width': 480,\n", - " 'height': 480},\n", - " {'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/640x428.jpg',\n", - " 'width': 640,\n", - " 'height': 428},\n", - " {'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/750x500.jpg',\n", - " 'width': 750,\n", - " 'height': 500},\n", - " {'url': 'https://resources.tidal.com/images/a627e21c/60f7/4e90/b2bb/e50b178c4f0b/750x750.jpg',\n", - " 'width': 750,\n", - " 'height': 750}],\n", - " 'tidalUrl': 'https://tidal.com/browse/artist/4676988'},\n", - " 'id': '4676988',\n", - " 'status': 200,\n", - " 'message': 'success'}" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client_tidal.search(\"Galantis\", \"US\", type=\"ARTISTS\", limit=1)[\"artists\"][0]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### Private TIDAL API" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'id': 4676988,\n", - " 'name': 'Galantis',\n", - " 'artistTypes': ['ARTIST', 'CONTRIBUTOR'],\n", - " 'url': 'http://www.tidal.com/artist/4676988',\n", - " 'picture': 'a627e21c-60f7-4e90-b2bb-e50b178c4f0b',\n", - " 'popularity': 72,\n", - " 'artistRoles': [{'categoryId': -1, 'category': 'Artist'},\n", - " {'categoryId': 3, 'category': 'Engineer'},\n", - " {'categoryId': 11, 'category': 'Performer'},\n", - " {'categoryId': 10, 'category': 'Production team'},\n", - " {'categoryId': 1, 'category': 'Producer'},\n", - " {'categoryId': 2, 'category': 'Songwriter'}],\n", - " 'mixes': {'ARTIST_MIX': '000202a7e72fd90d0c0df2ed56ddea'}}" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client_tidal_private.search(\"Galantis\", type=\"artist\", limit=1)[\"items\"][0]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "##### Searching for tracks\n", - "\n", - "The `search()` methods can also be used to search for and retrieve information about a track, such as \"Everybody Talks\" by Neon Trees:\n", - "\n", - "###### iTunes Search API" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'wrapperType': 'track',\n", - " 'kind': 'song',\n", - " 'artistId': 350172836,\n", - " 'collectionId': 1443469527,\n", - " 'trackId': 1443469581,\n", - " 'artistName': 'Neon Trees',\n", - " 'collectionName': 'Picture Show',\n", - " 'trackName': 'Everybody Talks',\n", - " 'collectionCensoredName': 'Picture Show',\n", - " 'trackCensoredName': 'Everybody Talks',\n", - " 'artistViewUrl': 'https://music.apple.com/us/artist/neon-trees/350172836?uo=4',\n", - " 'collectionViewUrl': 'https://music.apple.com/us/album/everybody-talks/1443469527?i=1443469581&uo=4',\n", - " 'trackViewUrl': 'https://music.apple.com/us/album/everybody-talks/1443469527?i=1443469581&uo=4',\n", - " 'previewUrl': 'https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview122/v4/5c/29/bf/5c29bf6b-ca2c-4e8b-2be6-c51a282c7dae/mzaf_1255557534804450018.plus.aac.p.m4a',\n", - " 'artworkUrl30': 'https://is1-ssl.mzstatic.com/image/thumb/Music115/v4/80/e3/95/80e39565-35f9-2496-c6f8-6572490c4a7b/12UMGIM12509.rgb.jpg/30x30bb.jpg',\n", - " 'artworkUrl60': 'https://is1-ssl.mzstatic.com/image/thumb/Music115/v4/80/e3/95/80e39565-35f9-2496-c6f8-6572490c4a7b/12UMGIM12509.rgb.jpg/60x60bb.jpg',\n", - " 'artworkUrl100': 'https://is1-ssl.mzstatic.com/image/thumb/Music115/v4/80/e3/95/80e39565-35f9-2496-c6f8-6572490c4a7b/12UMGIM12509.rgb.jpg/100x100bb.jpg',\n", - " 'collectionPrice': 6.99,\n", - " 'trackPrice': 1.29,\n", - " 'releaseDate': '2012-01-01T12:00:00Z',\n", - " 'collectionExplicitness': 'explicit',\n", - " 'trackExplicitness': 'explicit',\n", - " 'discCount': 1,\n", - " 'discNumber': 1,\n", - " 'trackCount': 12,\n", - " 'trackNumber': 3,\n", - " 'trackTimeMillis': 177280,\n", - " 'country': 'USA',\n", - " 'currency': 'USD',\n", - " 'primaryGenreName': 'Alternative',\n", - " 'contentAdvisoryRating': 'Explicit',\n", - " 'isStreamable': True}" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client_itunes.search(\"Everybody Talks\", media=\"music\", limit=1)[\"results\"][0]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### Private Qobuz API" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'maximum_bit_depth': 16,\n", - " 'copyright': '2022 Arko Boom 2022 Arko Boom',\n", - " 'performers': 'Arko Boom, MainArtist - Arkos Todd, Songwriter, ComposerLyricist',\n", - " 'audio_info': {'replaygain_track_peak': 1, 'replaygain_track_gain': -3.06},\n", - " 'performer': {'name': 'Arko Boom', 'id': 15899504},\n", - " 'album': {'image': {'small': 'https://static.qobuz.com/images/covers/fc/7v/ilfmuz10e7vfc_230.jpg',\n", - " 'thumbnail': 'https://static.qobuz.com/images/covers/fc/7v/ilfmuz10e7vfc_50.jpg',\n", - " 'large': 'https://static.qobuz.com/images/covers/fc/7v/ilfmuz10e7vfc_600.jpg'},\n", - " 'maximum_bit_depth': 16,\n", - " 'media_count': 1,\n", - " 'artist': {'image': None,\n", - " 'name': 'Arko Boom',\n", - " 'id': 15899504,\n", - " 'albums_count': 1,\n", - " 'slug': 'arko-boom',\n", - " 'picture': None},\n", - " 'upc': '0859766309663',\n", - " 'released_at': 1665180000,\n", - " 'label': {'name': 'Arko Boom',\n", - " 'id': 4026379,\n", - " 'albums_count': 1,\n", - " 'supplier_id': 95,\n", - " 'slug': 'arko-boom'},\n", - " 'title': 'Speedy',\n", - " 'qobuz_id': 178369185,\n", - " 'version': None,\n", - " 'duration': 536,\n", - " 'parental_warning': False,\n", - " 'tracks_count': 4,\n", - " 'popularity': 0,\n", - " 'genre': {'path': [133],\n", - " 'color': '#5eabc1',\n", - " 'name': 'Hip-Hop/Rap',\n", - " 'id': 133,\n", - " 'slug': 'rap-hip-hop'},\n", - " 'maximum_channel_count': 2,\n", - " 'id': 'ilfmuz10e7vfc',\n", - " 'maximum_sampling_rate': 44.1,\n", - " 'previewable': True,\n", - " 'sampleable': True,\n", - " 'displayable': True,\n", - " 'streamable': True,\n", - " 'streamable_at': 1711522800,\n", - " 'downloadable': False,\n", - " 'purchasable_at': None,\n", - " 'purchasable': False,\n", - " 'release_date_original': '2022-10-08',\n", - " 'release_date_download': '2022-10-08',\n", - " 'release_date_stream': '2022-10-08',\n", - " 'release_date_purchase': '2022-10-08',\n", - " 'hires': False,\n", - " 'hires_streamable': False},\n", - " 'work': None,\n", - " 'composer': {'name': 'Arkos Todd', 'id': 15899505},\n", - " 'isrc': 'TCAGM2280786',\n", - " 'title': 'Everybody Talks',\n", - " 'version': None,\n", - " 'duration': 127,\n", - " 'parental_warning': False,\n", - " 'track_number': 2,\n", - " 'maximum_channel_count': 2,\n", - " 'id': 178369187,\n", - " 'media_number': 1,\n", - " 'maximum_sampling_rate': 44.1,\n", - " 'release_date_original': '2022-10-08',\n", - " 'release_date_download': '2022-10-08',\n", - " 'release_date_stream': '2022-10-08',\n", - " 'release_date_purchase': '2022-10-08',\n", - " 'purchasable': True,\n", - " 'streamable': True,\n", - " 'previewable': True,\n", - " 'sampleable': True,\n", - " 'downloadable': True,\n", - " 'displayable': True,\n", - " 'purchasable_at': 1711522800,\n", - " 'streamable_at': 1711522800,\n", - " 'hires': False,\n", - " 'hires_streamable': False}" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "track_qobuz = client_qobuz.search(\"Everybody Talks\", \"ReleaseName\", limit=1,\n", - " strict=True)[\"tracks\"][\"items\"][0]\n", - "track_qobuz" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### Spotify Web API" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'album': {'album_type': 'album',\n", - " 'artists': [{'external_urls': {'spotify': 'https://open.spotify.com/artist/0RpddSzUHfncUWNJXKOsjy'},\n", - " 'href': 'https://api.spotify.com/v1/artists/0RpddSzUHfncUWNJXKOsjy',\n", - " 'id': '0RpddSzUHfncUWNJXKOsjy',\n", - " 'name': 'Neon Trees',\n", - " 'type': 'artist',\n", - " 'uri': 'spotify:artist:0RpddSzUHfncUWNJXKOsjy'}],\n", - " 'available_markets': ['AR',\n", - " 'AU',\n", - " 'AT',\n", - " 'BE',\n", - " 'BO',\n", - " 'BR',\n", - " 'BG',\n", - " 'CA',\n", - " 'CL',\n", - " 'CO',\n", - " 'CR',\n", - " 'CY',\n", - " 'CZ',\n", - " 'DK',\n", - " 'DO',\n", - " 'DE',\n", - " 'EC',\n", - " 'EE',\n", - " 'SV',\n", - " 'FI',\n", - " 'FR',\n", - " 'GR',\n", - " 'GT',\n", - " 'HN',\n", - " 'HK',\n", - " 'HU',\n", - " 'IS',\n", - " 'IE',\n", - " 'IT',\n", - " 'LV',\n", - " 'LT',\n", - " 'LU',\n", - " 'MY',\n", - " 'MT',\n", - " 'NL',\n", - " 'NZ',\n", - " 'NI',\n", - " 'NO',\n", - " 'PA',\n", - " 'PY',\n", - " 'PE',\n", - " 'PH',\n", - " 'PL',\n", - " 'PT',\n", - " 'SG',\n", - " 'SK',\n", - " 'ES',\n", - " 'SE',\n", - " 'CH',\n", - " 'TW',\n", - " 'TR',\n", - " 'UY',\n", - " 'US',\n", - " 'GB',\n", - " 'AD',\n", - " 'LI',\n", - " 'MC',\n", - " 'ID',\n", - " 'TH',\n", - " 'VN',\n", - " 'RO',\n", - " 'IL',\n", - " 'ZA',\n", - " 'SA',\n", - " 'AE',\n", - " 'BH',\n", - " 'QA',\n", - " 'OM',\n", - " 'KW',\n", - " 'EG',\n", - " 'TN',\n", - " 'LB',\n", - " 'JO',\n", - " 'PS',\n", - " 'IN',\n", - " 'BY',\n", - " 'KZ',\n", - " 'MD',\n", - " 'UA',\n", - " 'AL',\n", - " 'BA',\n", - " 'HR',\n", - " 'ME',\n", - " 'MK',\n", - " 'RS',\n", - " 'SI',\n", - " 'KR',\n", - " 'BD',\n", - " 'PK',\n", - " 'LK',\n", - " 'GH',\n", - " 'KE',\n", - " 'NG',\n", - " 'TZ',\n", - " 'UG',\n", - " 'AG',\n", - " 'AM',\n", - " 'BS',\n", - " 'BB',\n", - " 'BZ',\n", - " 'BT',\n", - " 'BW',\n", - " 'BF',\n", - " 'CV',\n", - " 'CW',\n", - " 'DM',\n", - " 'FJ',\n", - " 'GM',\n", - " 'GD',\n", - " 'GW',\n", - " 'GY',\n", - " 'HT',\n", - " 'JM',\n", - " 'KI',\n", - " 'LS',\n", - " 'LR',\n", - " 'MW',\n", - " 'MV',\n", - " 'ML',\n", - " 'MH',\n", - " 'FM',\n", - " 'NA',\n", - " 'NR',\n", - " 'NE',\n", - " 'PW',\n", - " 'PG',\n", - " 'WS',\n", - " 'ST',\n", - " 'SN',\n", - " 'SC',\n", - " 'SL',\n", - " 'SB',\n", - " 'KN',\n", - " 'LC',\n", - " 'VC',\n", - " 'SR',\n", - " 'TL',\n", - " 'TO',\n", - " 'TT',\n", - " 'TV',\n", - " 'AZ',\n", - " 'BN',\n", - " 'BI',\n", - " 'KH',\n", - " 'CM',\n", - " 'TD',\n", - " 'KM',\n", - " 'GQ',\n", - " 'SZ',\n", - " 'GA',\n", - " 'GN',\n", - " 'KG',\n", - " 'LA',\n", - " 'MO',\n", - " 'MR',\n", - " 'MN',\n", - " 'NP',\n", - " 'RW',\n", - " 'TG',\n", - " 'UZ',\n", - " 'ZW',\n", - " 'BJ',\n", - " 'MG',\n", - " 'MU',\n", - " 'MZ',\n", - " 'AO',\n", - " 'CI',\n", - " 'DJ',\n", - " 'ZM',\n", - " 'CD',\n", - " 'CG',\n", - " 'IQ',\n", - " 'TJ',\n", - " 'VE',\n", - " 'XK'],\n", - " 'external_urls': {'spotify': 'https://open.spotify.com/album/0uRFz92JmjwDbZbB7hEBIr'},\n", - " 'href': 'https://api.spotify.com/v1/albums/0uRFz92JmjwDbZbB7hEBIr',\n", - " 'id': '0uRFz92JmjwDbZbB7hEBIr',\n", - " 'images': [{'height': 640,\n", - " 'url': 'https://i.scdn.co/image/ab67616d0000b2734a6c0376235e5aa44e59d2c2',\n", - " 'width': 640},\n", - " {'height': 300,\n", - " 'url': 'https://i.scdn.co/image/ab67616d00001e024a6c0376235e5aa44e59d2c2',\n", - " 'width': 300},\n", - " {'height': 64,\n", - " 'url': 'https://i.scdn.co/image/ab67616d000048514a6c0376235e5aa44e59d2c2',\n", - " 'width': 64}],\n", - " 'name': 'Picture Show',\n", - " 'release_date': '2012-01-01',\n", - " 'release_date_precision': 'day',\n", - " 'total_tracks': 11,\n", - " 'type': 'album',\n", - " 'uri': 'spotify:album:0uRFz92JmjwDbZbB7hEBIr'},\n", - " 'artists': [{'external_urls': {'spotify': 'https://open.spotify.com/artist/0RpddSzUHfncUWNJXKOsjy'},\n", - " 'href': 'https://api.spotify.com/v1/artists/0RpddSzUHfncUWNJXKOsjy',\n", - " 'id': '0RpddSzUHfncUWNJXKOsjy',\n", - " 'name': 'Neon Trees',\n", - " 'type': 'artist',\n", - " 'uri': 'spotify:artist:0RpddSzUHfncUWNJXKOsjy'}],\n", - " 'available_markets': ['AR',\n", - " 'AU',\n", - " 'AT',\n", - " 'BE',\n", - " 'BO',\n", - " 'BR',\n", - " 'BG',\n", - " 'CA',\n", - " 'CL',\n", - " 'CO',\n", - " 'CR',\n", - " 'CY',\n", - " 'CZ',\n", - " 'DK',\n", - " 'DO',\n", - " 'DE',\n", - " 'EC',\n", - " 'EE',\n", - " 'SV',\n", - " 'FI',\n", - " 'FR',\n", - " 'GR',\n", - " 'GT',\n", - " 'HN',\n", - " 'HK',\n", - " 'HU',\n", - " 'IS',\n", - " 'IE',\n", - " 'IT',\n", - " 'LV',\n", - " 'LT',\n", - " 'LU',\n", - " 'MY',\n", - " 'MT',\n", - " 'NL',\n", - " 'NZ',\n", - " 'NI',\n", - " 'NO',\n", - " 'PA',\n", - " 'PY',\n", - " 'PE',\n", - " 'PH',\n", - " 'PL',\n", - " 'PT',\n", - " 'SG',\n", - " 'SK',\n", - " 'ES',\n", - " 'SE',\n", - " 'CH',\n", - " 'TW',\n", - " 'TR',\n", - " 'UY',\n", - " 'US',\n", - " 'GB',\n", - " 'AD',\n", - " 'LI',\n", - " 'MC',\n", - " 'ID',\n", - " 'TH',\n", - " 'VN',\n", - " 'RO',\n", - " 'IL',\n", - " 'ZA',\n", - " 'SA',\n", - " 'AE',\n", - " 'BH',\n", - " 'QA',\n", - " 'OM',\n", - " 'KW',\n", - " 'EG',\n", - " 'TN',\n", - " 'LB',\n", - " 'JO',\n", - " 'PS',\n", - " 'IN',\n", - " 'BY',\n", - " 'KZ',\n", - " 'MD',\n", - " 'UA',\n", - " 'AL',\n", - " 'BA',\n", - " 'HR',\n", - " 'ME',\n", - " 'MK',\n", - " 'RS',\n", - " 'SI',\n", - " 'KR',\n", - " 'BD',\n", - " 'PK',\n", - " 'LK',\n", - " 'GH',\n", - " 'KE',\n", - " 'NG',\n", - " 'TZ',\n", - " 'UG',\n", - " 'AG',\n", - " 'AM',\n", - " 'BS',\n", - " 'BB',\n", - " 'BZ',\n", - " 'BT',\n", - " 'BW',\n", - " 'BF',\n", - " 'CV',\n", - " 'CW',\n", - " 'DM',\n", - " 'FJ',\n", - " 'GM',\n", - " 'GD',\n", - " 'GW',\n", - " 'GY',\n", - " 'HT',\n", - " 'JM',\n", - " 'KI',\n", - " 'LS',\n", - " 'LR',\n", - " 'MW',\n", - " 'MV',\n", - " 'ML',\n", - " 'MH',\n", - " 'FM',\n", - " 'NA',\n", - " 'NR',\n", - " 'NE',\n", - " 'PW',\n", - " 'PG',\n", - " 'WS',\n", - " 'ST',\n", - " 'SN',\n", - " 'SC',\n", - " 'SL',\n", - " 'SB',\n", - " 'KN',\n", - " 'LC',\n", - " 'VC',\n", - " 'SR',\n", - " 'TL',\n", - " 'TO',\n", - " 'TT',\n", - " 'TV',\n", - " 'AZ',\n", - " 'BN',\n", - " 'BI',\n", - " 'KH',\n", - " 'CM',\n", - " 'TD',\n", - " 'KM',\n", - " 'GQ',\n", - " 'SZ',\n", - " 'GA',\n", - " 'GN',\n", - " 'KG',\n", - " 'LA',\n", - " 'MO',\n", - " 'MR',\n", - " 'MN',\n", - " 'NP',\n", - " 'RW',\n", - " 'TG',\n", - " 'UZ',\n", - " 'ZW',\n", - " 'BJ',\n", - " 'MG',\n", - " 'MU',\n", - " 'MZ',\n", - " 'AO',\n", - " 'CI',\n", - " 'DJ',\n", - " 'ZM',\n", - " 'CD',\n", - " 'CG',\n", - " 'IQ',\n", - " 'TJ',\n", - " 'VE',\n", - " 'XK'],\n", - " 'disc_number': 1,\n", - " 'duration_ms': 177280,\n", - " 'explicit': True,\n", - " 'external_ids': {'isrc': 'USUM71119189'},\n", - " 'external_urls': {'spotify': 'https://open.spotify.com/track/2iUmqdfGZcHIhS3b9E9EWq'},\n", - " 'href': 'https://api.spotify.com/v1/tracks/2iUmqdfGZcHIhS3b9E9EWq',\n", - " 'id': '2iUmqdfGZcHIhS3b9E9EWq',\n", - " 'is_local': False,\n", - " 'name': 'Everybody Talks',\n", - " 'popularity': 80,\n", - " 'preview_url': None,\n", - " 'track_number': 3,\n", - " 'type': 'track',\n", - " 'uri': 'spotify:track:2iUmqdfGZcHIhS3b9E9EWq'}" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "track_spotify = client_spotify.search(\"Everybody Talks\", \"track\",\n", - " limit=1)[\"items\"][0]\n", - "track_spotify" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### TIDAL API" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'resource': {'artifactType': 'track',\n", - " 'id': '14492425',\n", - " 'title': 'Everybody Talks',\n", - " 'artists': [{'id': '3665225',\n", - " 'name': 'Neon Trees',\n", - " 'picture': [{'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/1024x256.jpg',\n", - " 'width': 1024,\n", - " 'height': 256},\n", - " {'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/1080x720.jpg',\n", - " 'width': 1080,\n", - " 'height': 720},\n", - " {'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/160x107.jpg',\n", - " 'width': 160,\n", - " 'height': 107},\n", - " {'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/160x160.jpg',\n", - " 'width': 160,\n", - " 'height': 160},\n", - " {'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/320x214.jpg',\n", - " 'width': 320,\n", - " 'height': 214},\n", - " {'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/320x320.jpg',\n", - " 'width': 320,\n", - " 'height': 320},\n", - " {'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/480x480.jpg',\n", - " 'width': 480,\n", - " 'height': 480},\n", - " {'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/640x428.jpg',\n", - " 'width': 640,\n", - " 'height': 428},\n", - " {'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/750x500.jpg',\n", - " 'width': 750,\n", - " 'height': 500},\n", - " {'url': 'https://resources.tidal.com/images/e6f17398/759e/45a0/9673/6ded6811e199/750x750.jpg',\n", - " 'width': 750,\n", - " 'height': 750}],\n", - " 'main': True}],\n", - " 'album': {'id': '14492422',\n", - " 'title': 'Picture Show',\n", - " 'imageCover': [{'url': 'https://resources.tidal.com/images/1c2d7c90/034e/485a/be1f/24a669c7e6ee/1080x1080.jpg',\n", - " 'width': 1080,\n", - " 'height': 1080},\n", - " {'url': 'https://resources.tidal.com/images/1c2d7c90/034e/485a/be1f/24a669c7e6ee/1280x1280.jpg',\n", - " 'width': 1280,\n", - " 'height': 1280},\n", - " {'url': 'https://resources.tidal.com/images/1c2d7c90/034e/485a/be1f/24a669c7e6ee/160x160.jpg',\n", - " 'width': 160,\n", - " 'height': 160},\n", - " {'url': 'https://resources.tidal.com/images/1c2d7c90/034e/485a/be1f/24a669c7e6ee/320x320.jpg',\n", - " 'width': 320,\n", - " 'height': 320},\n", - " {'url': 'https://resources.tidal.com/images/1c2d7c90/034e/485a/be1f/24a669c7e6ee/640x640.jpg',\n", - " 'width': 640,\n", - " 'height': 640},\n", - " {'url': 'https://resources.tidal.com/images/1c2d7c90/034e/485a/be1f/24a669c7e6ee/750x750.jpg',\n", - " 'width': 750,\n", - " 'height': 750},\n", - " {'url': 'https://resources.tidal.com/images/1c2d7c90/034e/485a/be1f/24a669c7e6ee/80x80.jpg',\n", - " 'width': 80,\n", - " 'height': 80}],\n", - " 'videoCover': []},\n", - " 'duration': 177,\n", - " 'trackNumber': 3,\n", - " 'volumeNumber': 1,\n", - " 'isrc': 'USUM71119189',\n", - " 'copyright': 'A Mercury Records Release; ℗ 2011 UMG Recordings, Inc.',\n", - " 'mediaMetadata': {'tags': ['LOSSLESS']},\n", - " 'properties': {'content': ['explicit']},\n", - " 'tidalUrl': 'https://tidal.com/browse/track/14492425'},\n", - " 'id': '14492425',\n", - " 'status': 200,\n", - " 'message': 'success'}" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client_tidal.search(\"Everybody Talks\", \"US\", type=\"TRACKS\", limit=1)[\"tracks\"][0]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### Private TIDAL API" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'id': 14492425,\n", - " 'title': 'Everybody Talks',\n", - " 'duration': 177,\n", - " 'replayGain': -11.7,\n", - " 'peak': 0.999969,\n", - " 'allowStreaming': True,\n", - " 'streamReady': True,\n", - " 'adSupportedStreamReady': True,\n", - " 'djReady': True,\n", - " 'stemReady': False,\n", - " 'streamStartDate': '2012-04-17T00:00:00.000+0000',\n", - " 'premiumStreamingOnly': False,\n", - " 'trackNumber': 3,\n", - " 'volumeNumber': 1,\n", - " 'version': None,\n", - " 'popularity': 60,\n", - " 'copyright': 'A Mercury Records Release; ℗ 2011 UMG Recordings, Inc.',\n", - " 'bpm': 155,\n", - " 'url': 'http://www.tidal.com/track/14492425',\n", - " 'isrc': 'USUM71119189',\n", - " 'editable': False,\n", - " 'explicit': True,\n", - " 'audioQuality': 'LOSSLESS',\n", - " 'audioModes': ['STEREO'],\n", - " 'mediaMetadata': {'tags': ['LOSSLESS']},\n", - " 'artist': {'id': 3665225,\n", - " 'name': 'Neon Trees',\n", - " 'type': 'MAIN',\n", - " 'picture': 'e6f17398-759e-45a0-9673-6ded6811e199'},\n", - " 'artists': [{'id': 3665225,\n", - " 'name': 'Neon Trees',\n", - " 'type': 'MAIN',\n", - " 'picture': 'e6f17398-759e-45a0-9673-6ded6811e199'}],\n", - " 'album': {'id': 14492422,\n", - " 'title': 'Picture Show',\n", - " 'cover': '1c2d7c90-034e-485a-be1f-24a669c7e6ee',\n", - " 'vibrantColor': '#f8af88',\n", - " 'videoCover': None},\n", - " 'mixes': {'TRACK_MIX': '0019768c833a193c29829e5bf473fc'}}" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "track_tidal_private = client_tidal_private.search(\"Everybody Talks\",\n", - " type=\"track\",\n", - " limit=1)[\"items\"][0]\n", - "track_tidal_private" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "##### Creating, modifying, and deleting a personal playlist\n", - "\n", - "If the clients are authenticated, you can create and modify user playlists. As an example, we will create a private playlist named \"Minim\", make it public, add \"Everybody Talks\" by Neon Trees to it, and then delete it.\n", - "\n", - "###### Private Qobuz API" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'owner': None,\n", - " 'users_count': 0,\n", - " 'images150': ['https://static.qobuz.com/images/covers/fc/7v/ilfmuz10e7vfc_150.jpg'],\n", - " 'images': ['https://static.qobuz.com/images/covers/fc/7v/ilfmuz10e7vfc_50.jpg'],\n", - " 'is_collaborative': False,\n", - " 'description': 'A playlist created using Minim.',\n", - " 'created_at': 1716795945,\n", - " 'images300': ['https://static.qobuz.com/images/covers/fc/7v/ilfmuz10e7vfc_300.jpg'],\n", - " 'duration': 127,\n", - " 'updated_at': 1716795946,\n", - " 'published_to': None,\n", - " 'genres': [],\n", - " 'tracks_count': 1,\n", - " 'public_at': 1716795945,\n", - " 'name': 'Minim',\n", - " 'is_public': True,\n", - " 'published_from': None,\n", - " 'id': 21785610,\n", - " 'slug': 'minim-11',\n", - " 'is_featured': False,\n", - " 'tracks': {'offset': 0,\n", - " 'limit': 50,\n", - " 'total': 1,\n", - " 'items': [{'maximum_bit_depth': 16,\n", - " 'copyright': '2022 Arko Boom 2022 Arko Boom',\n", - " 'performers': 'Arko Boom, MainArtist - Arkos Todd, Songwriter, ComposerLyricist',\n", - " 'audio_info': {'replaygain_track_peak': 1, 'replaygain_track_gain': -3.06},\n", - " 'performer': {'name': 'Arko Boom', 'id': 15899504},\n", - " 'album': {'image': {'small': 'https://static.qobuz.com/images/covers/fc/7v/ilfmuz10e7vfc_230.jpg',\n", - " 'thumbnail': 'https://static.qobuz.com/images/covers/fc/7v/ilfmuz10e7vfc_50.jpg',\n", - " 'large': 'https://static.qobuz.com/images/covers/fc/7v/ilfmuz10e7vfc_600.jpg'},\n", - " 'maximum_bit_depth': 16,\n", - " 'media_count': 1,\n", - " 'artist': {'image': None,\n", - " 'name': 'Arko Boom',\n", - " 'id': 15899504,\n", - " 'albums_count': 1,\n", - " 'slug': 'arko-boom',\n", - " 'picture': None},\n", - " 'upc': '0859766309663',\n", - " 'released_at': 1665180000,\n", - " 'label': {'name': 'Arko Boom',\n", - " 'id': 4026379,\n", - " 'albums_count': 1,\n", - " 'supplier_id': 95,\n", - " 'slug': 'arko-boom'},\n", - " 'title': 'Speedy',\n", - " 'qobuz_id': 178369185,\n", - " 'version': None,\n", - " 'duration': 536,\n", - " 'parental_warning': False,\n", - " 'tracks_count': 4,\n", - " 'popularity': 0,\n", - " 'genre': {'path': [133],\n", - " 'color': '#5eabc1',\n", - " 'name': 'Hip-Hop/Rap',\n", - " 'id': 133,\n", - " 'slug': 'rap-hip-hop'},\n", - " 'maximum_channel_count': 2,\n", - " 'id': 'ilfmuz10e7vfc',\n", - " 'maximum_sampling_rate': 44.1,\n", - " 'previewable': True,\n", - " 'sampleable': True,\n", - " 'displayable': True,\n", - " 'streamable': True,\n", - " 'streamable_at': 1711522800,\n", - " 'downloadable': False,\n", - " 'purchasable_at': None,\n", - " 'purchasable': False,\n", - " 'release_date_original': '2022-10-08',\n", - " 'release_date_download': '2022-10-08',\n", - " 'release_date_stream': '2022-10-08',\n", - " 'release_date_purchase': '2022-10-08',\n", - " 'hires': False,\n", - " 'hires_streamable': False},\n", - " 'work': None,\n", - " 'composer': {'name': 'Arkos Todd', 'id': 15899505},\n", - " 'isrc': 'TCAGM2280786',\n", - " 'title': 'Everybody Talks',\n", - " 'version': None,\n", - " 'duration': 127,\n", - " 'parental_warning': False,\n", - " 'track_number': 2,\n", - " 'maximum_channel_count': 2,\n", - " 'id': 178369187,\n", - " 'media_number': 1,\n", - " 'maximum_sampling_rate': 44.1,\n", - " 'release_date_original': '2022-10-08',\n", - " 'release_date_download': '2022-10-08',\n", - " 'release_date_stream': '2022-10-08',\n", - " 'release_date_purchase': '2022-10-08',\n", - " 'purchasable': True,\n", - " 'streamable': True,\n", - " 'previewable': True,\n", - " 'sampleable': True,\n", - " 'downloadable': True,\n", - " 'displayable': True,\n", - " 'purchasable_at': 1711522800,\n", - " 'streamable_at': 1711522800,\n", - " 'hires': False,\n", - " 'hires_streamable': False,\n", - " 'position': 1,\n", - " 'created_at': 1716795946,\n", - " 'playlist_track_id': 4800399261}]}}" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "playlist_qobuz = client_qobuz.create_playlist(\n", - " \"Minim\",\n", - " description=\"A playlist created using Minim.\",\n", - " public=False\n", - ")\n", - "client_qobuz.update_playlist(playlist_qobuz[\"id\"], public=True)\n", - "client_qobuz.add_playlist_tracks(playlist_qobuz[\"id\"], track_qobuz[\"id\"])\n", - "playlist_qobuz = client_qobuz.get_playlist(playlist_qobuz[\"id\"])\n", - "playlist_qobuz[\"owner\"] = None # remove personal identifying information\n", - "playlist_qobuz" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": {}, - "outputs": [], - "source": [ - "client_qobuz.delete_playlist(playlist_qobuz[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### Spotify Web API" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'collaborative': False,\n", - " 'description': 'A playlist created using Minim.',\n", - " 'external_urls': {'spotify': 'https://open.spotify.com/playlist/2AUucoDJJUNxt6LMqVXfaD'},\n", - " 'followers': {'href': None, 'total': 0},\n", - " 'href': 'https://api.spotify.com/v1/playlists/2AUucoDJJUNxt6LMqVXfaD',\n", - " 'id': '2AUucoDJJUNxt6LMqVXfaD',\n", - " 'images': [{'height': None,\n", - " 'url': 'https://i.scdn.co/image/ab67616d00001e024a6c0376235e5aa44e59d2c2',\n", - " 'width': None}],\n", - " 'name': 'Minim',\n", - " 'owner': None,\n", - " 'primary_color': None,\n", - " 'public': True,\n", - " 'snapshot_id': 'AAAAAX2rci5DFConNf6qeyL9q9Vo7wd7',\n", - " 'tracks': {'href': 'https://api.spotify.com/v1/playlists/2AUucoDJJUNxt6LMqVXfaD/tracks?offset=0&limit=100',\n", - " 'items': [{'added_at': '2024-05-27T07:45:48Z',\n", - " 'added_by': None,\n", - " 'is_local': False,\n", - " 'primary_color': None,\n", - " 'track': {'preview_url': None,\n", - " 'available_markets': ['AR',\n", - " 'AU',\n", - " 'AT',\n", - " 'BE',\n", - " 'BO',\n", - " 'BR',\n", - " 'BG',\n", - " 'CA',\n", - " 'CL',\n", - " 'CO',\n", - " 'CR',\n", - " 'CY',\n", - " 'CZ',\n", - " 'DK',\n", - " 'DO',\n", - " 'DE',\n", - " 'EC',\n", - " 'EE',\n", - " 'SV',\n", - " 'FI',\n", - " 'FR',\n", - " 'GR',\n", - " 'GT',\n", - " 'HN',\n", - " 'HK',\n", - " 'HU',\n", - " 'IS',\n", - " 'IE',\n", - " 'IT',\n", - " 'LV',\n", - " 'LT',\n", - " 'LU',\n", - " 'MY',\n", - " 'MT',\n", - " 'NL',\n", - " 'NZ',\n", - " 'NI',\n", - " 'NO',\n", - " 'PA',\n", - " 'PY',\n", - " 'PE',\n", - " 'PH',\n", - " 'PL',\n", - " 'PT',\n", - " 'SG',\n", - " 'SK',\n", - " 'ES',\n", - " 'SE',\n", - " 'CH',\n", - " 'TW',\n", - " 'TR',\n", - " 'UY',\n", - " 'US',\n", - " 'GB',\n", - " 'AD',\n", - " 'LI',\n", - " 'MC',\n", - " 'ID',\n", - " 'TH',\n", - " 'VN',\n", - " 'RO',\n", - " 'IL',\n", - " 'ZA',\n", - " 'SA',\n", - " 'AE',\n", - " 'BH',\n", - " 'QA',\n", - " 'OM',\n", - " 'KW',\n", - " 'EG',\n", - " 'TN',\n", - " 'LB',\n", - " 'JO',\n", - " 'PS',\n", - " 'IN',\n", - " 'BY',\n", - " 'KZ',\n", - " 'MD',\n", - " 'UA',\n", - " 'AL',\n", - " 'BA',\n", - " 'HR',\n", - " 'ME',\n", - " 'MK',\n", - " 'RS',\n", - " 'SI',\n", - " 'KR',\n", - " 'BD',\n", - " 'PK',\n", - " 'LK',\n", - " 'GH',\n", - " 'KE',\n", - " 'NG',\n", - " 'TZ',\n", - " 'UG',\n", - " 'AG',\n", - " 'AM',\n", - " 'BS',\n", - " 'BB',\n", - " 'BZ',\n", - " 'BT',\n", - " 'BW',\n", - " 'BF',\n", - " 'CV',\n", - " 'CW',\n", - " 'DM',\n", - " 'FJ',\n", - " 'GM',\n", - " 'GD',\n", - " 'GW',\n", - " 'GY',\n", - " 'HT',\n", - " 'JM',\n", - " 'KI',\n", - " 'LS',\n", - " 'LR',\n", - " 'MW',\n", - " 'MV',\n", - " 'ML',\n", - " 'MH',\n", - " 'FM',\n", - " 'NA',\n", - " 'NR',\n", - " 'NE',\n", - " 'PW',\n", - " 'PG',\n", - " 'WS',\n", - " 'ST',\n", - " 'SN',\n", - " 'SC',\n", - " 'SL',\n", - " 'SB',\n", - " 'KN',\n", - " 'LC',\n", - " 'VC',\n", - " 'SR',\n", - " 'TL',\n", - " 'TO',\n", - " 'TT',\n", - " 'TV',\n", - " 'AZ',\n", - " 'BN',\n", - " 'BI',\n", - " 'KH',\n", - " 'CM',\n", - " 'TD',\n", - " 'KM',\n", - " 'GQ',\n", - " 'SZ',\n", - " 'GA',\n", - " 'GN',\n", - " 'KG',\n", - " 'LA',\n", - " 'MO',\n", - " 'MR',\n", - " 'MN',\n", - " 'NP',\n", - " 'RW',\n", - " 'TG',\n", - " 'UZ',\n", - " 'ZW',\n", - " 'BJ',\n", - " 'MG',\n", - " 'MU',\n", - " 'MZ',\n", - " 'AO',\n", - " 'CI',\n", - " 'DJ',\n", - " 'ZM',\n", - " 'CD',\n", - " 'CG',\n", - " 'IQ',\n", - " 'TJ',\n", - " 'VE',\n", - " 'XK'],\n", - " 'explicit': True,\n", - " 'type': 'track',\n", - " 'episode': False,\n", - " 'track': True,\n", - " 'album': {'available_markets': ['AR',\n", - " 'AU',\n", - " 'AT',\n", - " 'BE',\n", - " 'BO',\n", - " 'BR',\n", - " 'BG',\n", - " 'CA',\n", - " 'CL',\n", - " 'CO',\n", - " 'CR',\n", - " 'CY',\n", - " 'CZ',\n", - " 'DK',\n", - " 'DO',\n", - " 'DE',\n", - " 'EC',\n", - " 'EE',\n", - " 'SV',\n", - " 'FI',\n", - " 'FR',\n", - " 'GR',\n", - " 'GT',\n", - " 'HN',\n", - " 'HK',\n", - " 'HU',\n", - " 'IS',\n", - " 'IE',\n", - " 'IT',\n", - " 'LV',\n", - " 'LT',\n", - " 'LU',\n", - " 'MY',\n", - " 'MT',\n", - " 'NL',\n", - " 'NZ',\n", - " 'NI',\n", - " 'NO',\n", - " 'PA',\n", - " 'PY',\n", - " 'PE',\n", - " 'PH',\n", - " 'PL',\n", - " 'PT',\n", - " 'SG',\n", - " 'SK',\n", - " 'ES',\n", - " 'SE',\n", - " 'CH',\n", - " 'TW',\n", - " 'TR',\n", - " 'UY',\n", - " 'US',\n", - " 'GB',\n", - " 'AD',\n", - " 'LI',\n", - " 'MC',\n", - " 'ID',\n", - " 'TH',\n", - " 'VN',\n", - " 'RO',\n", - " 'IL',\n", - " 'ZA',\n", - " 'SA',\n", - " 'AE',\n", - " 'BH',\n", - " 'QA',\n", - " 'OM',\n", - " 'KW',\n", - " 'EG',\n", - " 'TN',\n", - " 'LB',\n", - " 'JO',\n", - " 'PS',\n", - " 'IN',\n", - " 'BY',\n", - " 'KZ',\n", - " 'MD',\n", - " 'UA',\n", - " 'AL',\n", - " 'BA',\n", - " 'HR',\n", - " 'ME',\n", - " 'MK',\n", - " 'RS',\n", - " 'SI',\n", - " 'KR',\n", - " 'BD',\n", - " 'PK',\n", - " 'LK',\n", - " 'GH',\n", - " 'KE',\n", - " 'NG',\n", - " 'TZ',\n", - " 'UG',\n", - " 'AG',\n", - " 'AM',\n", - " 'BS',\n", - " 'BB',\n", - " 'BZ',\n", - " 'BT',\n", - " 'BW',\n", - " 'BF',\n", - " 'CV',\n", - " 'CW',\n", - " 'DM',\n", - " 'FJ',\n", - " 'GM',\n", - " 'GD',\n", - " 'GW',\n", - " 'GY',\n", - " 'HT',\n", - " 'JM',\n", - " 'KI',\n", - " 'LS',\n", - " 'LR',\n", - " 'MW',\n", - " 'MV',\n", - " 'ML',\n", - " 'MH',\n", - " 'FM',\n", - " 'NA',\n", - " 'NR',\n", - " 'NE',\n", - " 'PW',\n", - " 'PG',\n", - " 'WS',\n", - " 'ST',\n", - " 'SN',\n", - " 'SC',\n", - " 'SL',\n", - " 'SB',\n", - " 'KN',\n", - " 'LC',\n", - " 'VC',\n", - " 'SR',\n", - " 'TL',\n", - " 'TO',\n", - " 'TT',\n", - " 'TV',\n", - " 'AZ',\n", - " 'BN',\n", - " 'BI',\n", - " 'KH',\n", - " 'CM',\n", - " 'TD',\n", - " 'KM',\n", - " 'GQ',\n", - " 'SZ',\n", - " 'GA',\n", - " 'GN',\n", - " 'KG',\n", - " 'LA',\n", - " 'MO',\n", - " 'MR',\n", - " 'MN',\n", - " 'NP',\n", - " 'RW',\n", - " 'TG',\n", - " 'UZ',\n", - " 'ZW',\n", - " 'BJ',\n", - " 'MG',\n", - " 'MU',\n", - " 'MZ',\n", - " 'AO',\n", - " 'CI',\n", - " 'DJ',\n", - " 'ZM',\n", - " 'CD',\n", - " 'CG',\n", - " 'IQ',\n", - " 'TJ',\n", - " 'VE',\n", - " 'XK'],\n", - " 'type': 'album',\n", - " 'album_type': 'album',\n", - " 'href': 'https://api.spotify.com/v1/albums/0uRFz92JmjwDbZbB7hEBIr',\n", - " 'id': '0uRFz92JmjwDbZbB7hEBIr',\n", - " 'images': [{'url': 'https://i.scdn.co/image/ab67616d0000b2734a6c0376235e5aa44e59d2c2',\n", - " 'width': 640,\n", - " 'height': 640},\n", - " {'url': 'https://i.scdn.co/image/ab67616d00001e024a6c0376235e5aa44e59d2c2',\n", - " 'width': 300,\n", - " 'height': 300},\n", - " {'url': 'https://i.scdn.co/image/ab67616d000048514a6c0376235e5aa44e59d2c2',\n", - " 'width': 64,\n", - " 'height': 64}],\n", - " 'name': 'Picture Show',\n", - " 'release_date': '2012-01-01',\n", - " 'release_date_precision': 'day',\n", - " 'uri': 'spotify:album:0uRFz92JmjwDbZbB7hEBIr',\n", - " 'artists': [{'external_urls': {'spotify': 'https://open.spotify.com/artist/0RpddSzUHfncUWNJXKOsjy'},\n", - " 'href': 'https://api.spotify.com/v1/artists/0RpddSzUHfncUWNJXKOsjy',\n", - " 'id': '0RpddSzUHfncUWNJXKOsjy',\n", - " 'name': 'Neon Trees',\n", - " 'type': 'artist',\n", - " 'uri': 'spotify:artist:0RpddSzUHfncUWNJXKOsjy'}],\n", - " 'external_urls': {'spotify': 'https://open.spotify.com/album/0uRFz92JmjwDbZbB7hEBIr'},\n", - " 'total_tracks': 11},\n", - " 'artists': [{'external_urls': {'spotify': 'https://open.spotify.com/artist/0RpddSzUHfncUWNJXKOsjy'},\n", - " 'href': 'https://api.spotify.com/v1/artists/0RpddSzUHfncUWNJXKOsjy',\n", - " 'id': '0RpddSzUHfncUWNJXKOsjy',\n", - " 'name': 'Neon Trees',\n", - " 'type': 'artist',\n", - " 'uri': 'spotify:artist:0RpddSzUHfncUWNJXKOsjy'}],\n", - " 'disc_number': 1,\n", - " 'track_number': 3,\n", - " 'duration_ms': 177280,\n", - " 'external_ids': {'isrc': 'USUM71119189'},\n", - " 'external_urls': {'spotify': 'https://open.spotify.com/track/2iUmqdfGZcHIhS3b9E9EWq'},\n", - " 'href': 'https://api.spotify.com/v1/tracks/2iUmqdfGZcHIhS3b9E9EWq',\n", - " 'id': '2iUmqdfGZcHIhS3b9E9EWq',\n", - " 'name': 'Everybody Talks',\n", - " 'popularity': 80,\n", - " 'uri': 'spotify:track:2iUmqdfGZcHIhS3b9E9EWq',\n", - " 'is_local': False},\n", - " 'video_thumbnail': {'url': None}}],\n", - " 'limit': 100,\n", - " 'next': None,\n", - " 'offset': 0,\n", - " 'previous': None,\n", - " 'total': 1},\n", - " 'type': 'playlist',\n", - " 'uri': 'spotify:playlist:2AUucoDJJUNxt6LMqVXfaD'}" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "playlist_spotify = client_spotify.create_playlist(\n", - " \"Minim\",\n", - " description=\"A playlist created using Minim.\",\n", - " public=False\n", - ")\n", - "client_spotify.change_playlist_details(playlist_spotify[\"id\"], public=True)\n", - "client_spotify.add_playlist_items(playlist_spotify[\"id\"],\n", - " [f\"spotify:track:{track_spotify['id']}\"])\n", - "playlist_spotify = client_spotify.get_playlist(playlist_spotify[\"id\"])\n", - "# remove personal identifying information\n", - "playlist_spotify[\"owner\"] = playlist_spotify[\"tracks\"][\"items\"][0][\"added_by\"] = None\n", - "playlist_spotify" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": {}, - "outputs": [], - "source": [ - "client_spotify.unfollow_playlist(playlist_spotify[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "###### Private TIDAL API" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'playlist': {'uuid': 'dff69073-b51d-42e9-9539-08958ffeade1',\n", - " 'type': 'USER',\n", - " 'creator': None,\n", - " 'contentBehavior': 'UNRESTRICTED',\n", - " 'sharingLevel': 'PUBLIC',\n", - " 'status': 'READY',\n", - " 'source': 'DEFAULT',\n", - " 'title': 'Minim',\n", - " 'description': 'A playlist created using Minim.',\n", - " 'image': 'ddd25f62-cd42-4555-9dd8-e1e030b42c92',\n", - " 'squareImage': 'b87dda6e-612c-460c-994e-759e153b905e',\n", - " 'url': 'http://www.tidal.com/playlist/dff69073-b51d-42e9-9539-08958ffeade1',\n", - " 'created': '2024-05-27T07:45:48.572+0000',\n", - " 'lastUpdated': '2024-05-27T07:45:48.970+0000',\n", - " 'lastItemAddedAt': '2024-05-27T07:45:48.970+0000',\n", - " 'duration': 177,\n", - " 'numberOfTracks': 1,\n", - " 'numberOfVideos': 0,\n", - " 'promotedArtists': [],\n", - " 'trn': 'trn:playlist:dff69073-b51d-42e9-9539-08958ffeade1'},\n", - " 'followInfo': {'nrOfFollowers': 0,\n", - " 'tidalResourceName': 'trn:playlist:dff69073-b51d-42e9-9539-08958ffeade1',\n", - " 'followed': True,\n", - " 'followType': 'PLAYLIST'},\n", - " 'profile': None}" - ] - }, - "execution_count": 24, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "playlist_tidal_private = client_tidal_private.create_playlist(\n", - " \"Minim\",\n", - " description=\"A playlist created using Minim.\",\n", - " public=False\n", - ")\n", - "client_tidal_private.set_playlist_privacy(playlist_tidal_private[\"data\"][\"uuid\"],\n", - " True)\n", - "client_tidal_private.add_playlist_items(playlist_tidal_private[\"data\"][\"uuid\"],\n", - " track_tidal_private[\"id\"])\n", - "playlist_tidal_private = client_tidal_private.get_user_playlist(\n", - " playlist_tidal_private[\"data\"][\"uuid\"]\n", - ")\n", - "# remove personal identifying information\n", - "playlist_tidal_private[\"playlist\"][\"creator\"] = playlist_tidal_private[\"profile\"] = None\n", - "playlist_tidal_private" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": {}, - "outputs": [], - "source": [ - "client_tidal_private.delete_playlist(playlist_tidal_private[\"playlist\"][\"uuid\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Audio file handlers" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "from minim.audio import Audio, FLACAudio, MP3Audio, MP4Audio, OggAudio, WAVEAudio" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Minim uses [Mutagen](https://mutagen.readthedocs.io/en/latest/) to load and edit audio files and [FFmpeg](https://ffmpeg.org/) to convert between different audio formats. Currently, the most common audio formats, such as AAC, ALAC, FLAC, MP3, Opus, Vorbis, and WAVE, are supported.\n", - "\n", - "#### Examples\n", - "\n", - "##### Loading and editing audio files\n", - "\n", - "To load an audio file, pass the filename as a `str` or a `pathlib.Path` object either to the `minim.audio.Audio` constructor for Minim to automatically detect the audio format:" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": {}, - "outputs": [], - "source": [ - "file = Path().resolve().parents[2] / \"tests/data/samples/middle_c.wav\"\n", - "middle_c = Audio(file)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "or the specific class for the audio format if known (in this case, `minim.audio.WAVEAudio`):\n", - "\n", - "```python\n", - "middle_c = WAVEAudio(Path().resolve().parents[2] / \"tests/data/samples/middle_c.wav\")\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For this example, both approaches return a `minim.audio.WAVEAudio` object:" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "minim.audio.WAVEAudio" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "type(middle_c)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The metadata stored in the audio file can be accessed using dot notation or `getattr()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Title: Middle C\n", - "Album: Minim\n", - "Artist: Square Wave\n", - "Genre: Game\n", - "Codec: lpcm\n", - "Bit depth: 24\n" - ] - } - ], - "source": [ - "for attr in [\"title\", \"album\", \"artist\", \"genre\", \"codec\", \"bit_depth\"]:\n", - " print(f\"{attr.capitalize().replace('_', ' ')}: {getattr(middle_c, attr)}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "and edited similarly using dot notation or `setattr()`:\n", - "\n", - "```python\n", - "middle_c.title = \"Middle C (261.63 Hz)\"\n", - "middle_c.write_metadata()\n", - "```\n", - "\n", - "If changes are made, don't forget to write them to file using `minim.audio.Audio.write_metadata()`.\n", - "\n", - "##### Converting between audio formats\n", - "\n", - "Conversion between the supported audio formats is powered by FFmpeg.\n", - "\n", - "To re-encode the previous WAVE audio using the ALAC codec and store it in a MP4 container, use the `minim.audio.Audio.convert()` method:" - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "size= 116kB time=00:00:01.02 bitrate= 930.3kbits/s speed= 135x \n" - ] - } - ], - "source": [ - "middle_c.convert(\"alac\", filename=\"middle_c_alac\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The call above not only converts the WAVE audio into ALAC audio, but also updates the variable `middle_c` to now point to a `minim.audio.MP4Audio` file handler for the new file `middle_c.m4a` and maintains the metadata:" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "minim.audio.MP4Audio" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "type(middle_c)" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Title: Middle C\n", - "Album: Minim\n", - "Artist: Square Wave\n", - "Genre: Game\n", - "Codec: alac\n", - "Bit depth: 24\n" - ] - } - ], - "source": [ - "for attr in [\"title\", \"album\", \"artist\", \"genre\", \"codec\", \"bit_depth\"]:\n", - " print(f\"{attr.capitalize().replace('_', ' ')}: {getattr(middle_c, attr)}\")" - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "middle_c._file.unlink()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "base", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/_sources/notebooks/user_guide/editing_audio_metadata.ipynb.txt b/docs/_sources/notebooks/user_guide/editing_audio_metadata.ipynb.txt deleted file mode 100644 index 39d0ab3..0000000 --- a/docs/_sources/notebooks/user_guide/editing_audio_metadata.ipynb.txt +++ /dev/null @@ -1,759 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Editing Audio Metadata\n", - "\n", - "**Last updated**: November 25, 2023\n", - "\n", - "Minim can organize your local music library by tagging audio files with metadata retrieved from popular music services, such as iTunes, Spotify, and TIDAL." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "\n", - "from minim import audio, itunes, spotify, tidal, utility\n", - "import numpy as np" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup\n", - "\n", - "### Instantiating API clients\n", - "\n", - "To get started, we will need to create API clients for the music services that we want to query for album and track information:" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "client_itunes = itunes.SearchAPI()\n", - "client_spotify = spotify.WebAPI()\n", - "client_tidal = tidal.PrivateAPI()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Finding audio files\n", - "\n", - "To find all audio files in a specified directory, we use the `pathlib.Path.glob()` method:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "audio_files = [f for f in (Path().resolve().parents[3]\n", - " / \"tests/data/previews\").glob(\"**/*\")\n", - " if f.suffix == \".flac\"]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Defining helper functions\n", - "\n", - "Before diving into the examples, we define a helper function that will print out the metadata of an audio file:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "def print_metadata(audio_file):\n", - " for field, value in audio_file.__dict__.items():\n", - " if not field.startswith(\"_\"):\n", - " if field in {\"artwork\", \"lyrics\"}:\n", - " if value:\n", - " value = type(value)\n", - " field = (field.upper() if field == \"isrc\"\n", - " else field.replace(\"_\", \" \").capitalize())\n", - " print(f\"{field}: {value}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The two examples below highlight the utility of the `minim.audio.*Audio` classes. The first example involves an audio with no metadata other than that stored in its filename, and the second example shows how to update the tags of an audio file without overwriting existing metadata." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Converting and tagging an audio file with no metadata\n", - "\n", - "First, we load the audio file into a file handler by passing its filename and its corresponding regular expression and metadata fields to the `minim.audio.Audio` constructor:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "('spektrem_shine.flac', )" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "audio_file = audio.Audio(audio_files[0], pattern=(\"(.*)_(.*)\", (\"artist\", \"title\")))\n", - "audio_files[0].name, audio_file" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "A `minim.audio.FLACAudio` object is returned, as the `minim.audio.Audio` constructor has automatically determined the audio format. Let's take a look at the file's metadata:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Album: None\n", - "Album artist: None\n", - "Artist: spektrem\n", - "Comment: None\n", - "Composer: None\n", - "Copyright: None\n", - "Date: None\n", - "Genre: None\n", - "ISRC: None\n", - "Lyrics: None\n", - "Tempo: None\n", - "Title: shine\n", - "Compilation: None\n", - "Disc number: None\n", - "Disc count: None\n", - "Track number: None\n", - "Track count: None\n", - "Artwork: None\n", - "Bit depth: 16\n", - "Bitrate: 1030107\n", - "Channel count: 2\n", - "Codec: flac\n", - "Sample rate: 44100\n" - ] - } - ], - "source": [ - "print_metadata(audio_file)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "While the file originally had no artist or title information, the search pattern we provided to the `minim.audio.Audio` constructor has allowed it to pull the information from the filename. At this point, however, the artist and title information have not yet been written to file.\n", - "\n", - "If we wanted compatibility with most music players, we can convert the FLAC file to a MP3 file using `minim.audio.FLACAudio.convert()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "size= 1032kB time=00:00:30.09 bitrate= 280.9kbits/s speed=67.7x \n" - ] - }, - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "audio_file.convert(\"mp3\")\n", - "audio_file" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "With the file conversion, the `audio_file` object is automatically updated to a `minim.audio.MP3Audio` object. Let's take a look at the new file's metadata:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Album: None\n", - "Album artist: None\n", - "Artist: spektrem\n", - "Comment: None\n", - "Compilation: None\n", - "Composer: None\n", - "Copyright: None\n", - "Date: None\n", - "Genre: None\n", - "ISRC: None\n", - "Lyrics: None\n", - "Tempo: None\n", - "Title: shine\n", - "Disc number: None\n", - "Disc count: None\n", - "Track number: None\n", - "Track count: None\n", - "Artwork: None\n", - "Bit depth: None\n", - "Bitrate: 280593\n", - "Channel count: 2\n", - "Codec: mp3\n", - "Sample rate: 44100\n" - ] - } - ], - "source": [ - "print_metadata(audio_file)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The metadata persisted—even the artist and title, which has not been written to the FLAC file—with the exception of format-specific properties, like the bitrate and codec.\n", - "\n", - "Now, we start populating the file's metadata. The Apple Music/iTunes catalog typically contains the most complete and accurate information about a track, so it is generally a good idea to start there. As such, we \n", - "\n", - "* build a query using the only information available to us, namely, the artist and title,\n", - "* search for the track on iTunes via `minim.itunes.SearchAPI.search()`, \n", - "* select the closest match out of the results by choosing the one with the lowest Levenshtein distance/ratio for the artist and title (available via `minim.utility.levenshtein_ratio()`),\n", - "* separately get the track's album information using `minim.itunes.SearchAPI.lookup()`, and\n", - "* populate the file handler's metadata with the JSON results using `minim.audio.FLACAudio.set_metadata_using_itunes()`." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Album: Enter the Spektrem - Single\n", - "Album artist: Spektrem\n", - "Artist: Spektrem\n", - "Comment: None\n", - "Compilation: False\n", - "Composer: None\n", - "Copyright: ℗ 2013 GFTED\n", - "Date: 2013-03-06T12:00:00Z\n", - "Genre: Electronic\n", - "ISRC: None\n", - "Lyrics: None\n", - "Tempo: None\n", - "Title: Shine\n", - "Disc number: 1\n", - "Disc count: 1\n", - "Track number: 2\n", - "Track count: 3\n", - "Artwork: \n", - "Bit depth: None\n", - "Bitrate: 280593\n", - "Channel count: 2\n", - "Codec: mp3\n", - "Sample rate: 44100\n" - ] - } - ], - "source": [ - "query = f\"{audio_file.artist} {audio_file.title}\".lower()\n", - "itunes_results = client_itunes.search(query)[\"results\"]\n", - "itunes_track = itunes_results[\n", - " np.argmax(\n", - " utility.levenshtein_ratio(\n", - " query,\n", - " [f\"{r['artistName']} {r['trackName']}\".lower()\n", - " for r in itunes_results]\n", - " )\n", - " )\n", - "]\n", - "itunes_album = client_itunes.lookup(itunes_track[\"collectionId\"])[\"results\"][0]\n", - "audio_file.set_metadata_using_itunes(itunes_track, album_data=itunes_album,\n", - " overwrite=True)\n", - "print_metadata(audio_file)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can see that most of the fields have now been filled out. The iTunes Search API does not return composers, ISRC, lyrics, or tempo information, so we will have to use the Spotify Web API and the TIDAL API to complete the metadata.\n", - "\n", - "The Spotify catalog contains ISRCs for tracks. Conveniently, the Spotify Web API also has a `minim.spotify.WebAPI.get_track_audio_features()` endpoint that returns a `dict` of audio features, including the track's tempo. \n", - "\n", - "Like before for the iTunes Search API, we\n", - "\n", - "* search for the track on Spotify via `minim.spotify.WebAPI.search()`,\n", - "* select the closest match out of the results by choosing the one with the lowest Levenshtein distance/ratio for the artist and title,\n", - "* get the track's audio features using `minim.spotify.WebAPI.get_track_audio_features()`, and\n", - "* populate file handler's metadata with the JSON results using `minim.audio.FLACAudio.set_metadata_using_spotify()`.\n", - "\n", - ":::{note}\n", - "By default, the `minim.audio.FLACAudio.set_metadata_using*()` methods do not overwrite existing metadata. To change this behavior, pass `overwrite=True` as a keyword argument.\n", - ":::" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Album: Enter the Spektrem - Single\n", - "Album artist: Spektrem\n", - "Artist: Spektrem\n", - "Comment: None\n", - "Compilation: False\n", - "Composer: None\n", - "Copyright: ℗ 2013 GFTED\n", - "Date: 2013-03-06T12:00:00Z\n", - "Genre: Electronic\n", - "ISRC: GB2LD0901581\n", - "Lyrics: None\n", - "Tempo: 128\n", - "Title: Shine\n", - "Disc number: 1\n", - "Disc count: 1\n", - "Track number: 2\n", - "Track count: 3\n", - "Artwork: \n", - "Bit depth: None\n", - "Bitrate: 280593\n", - "Channel count: 2\n", - "Codec: mp3\n", - "Sample rate: 44100\n" - ] - } - ], - "source": [ - "spotify_results = client_spotify.search(query, type=\"track\")[\"items\"]\n", - "spotify_track = spotify_results[\n", - " np.argmax(\n", - " utility.levenshtein_ratio(\n", - " query,\n", - " [f\"{r['artists'][0]['name']} {r['name']}\".lower()\n", - " for r in spotify_results]\n", - " )\n", - " )\n", - "]\n", - "audio_file.set_metadata_using_spotify(\n", - " spotify_track,\n", - " audio_features=client_spotify.get_track_audio_features(spotify_track[\"id\"])\n", - ")\n", - "print_metadata(audio_file)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, we repeat the process above using the TIDAL API to get the composers and lyrics by\n", - "\n", - "* searching for the track on TIDAL via `minim.tidal.PrivateAPI.search()`,\n", - "* selecting the correct result by matching the ISRC,\n", - "* getting the track's composers using `minim.tidal.PrivateAPI.get_track_composers()`, and\n", - "* populating the file handler's metadata with the JSON results using `minim.audio.FLACAudio.set_metadata_using_tidal()`." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Album: Enter the Spektrem - Single\n", - "Album artist: Spektrem\n", - "Artist: Spektrem\n", - "Comment: None\n", - "Compilation: False\n", - "Composer: None\n", - "Copyright: ℗ 2013 GFTED\n", - "Date: 2013-03-06T12:00:00Z\n", - "Genre: Electronic\n", - "ISRC: GB2LD0901581\n", - "Lyrics: \n", - "Tempo: 128\n", - "Title: Shine\n", - "Disc number: 1\n", - "Disc count: 1\n", - "Track number: 2\n", - "Track count: 3\n", - "Artwork: \n", - "Bit depth: None\n", - "Bitrate: 280593\n", - "Channel count: 2\n", - "Codec: mp3\n", - "Sample rate: 44100\n" - ] - } - ], - "source": [ - "tidal_results = client_tidal.search(query)[\"tracks\"][\"items\"]\n", - "tidal_track = next((r for r in tidal_results if r[\"isrc\"] == audio_file.isrc), None)\n", - "tidal_composers = client_tidal.get_track_composers(tidal_track[\"id\"])\n", - "audio_file.set_metadata_using_tidal(tidal_track, composers=tidal_composers)\n", - "print_metadata(audio_file)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The metadata for the track is now practically complete. Lyrics are available through either `minim.spotify.PrivateLyricsService.get_lyrics()` or `minim.tidal.PrivateAPI.get_track_lyrics()` with active subscriptions. (For this example, TIDAL did not have songwriting credits for the track. This happens sometimes when the track is not very popular.)\n", - "\n", - "Don't forget to write the changes to file using `minim.audio.FLACAudio.write()`!" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [], - "source": [ - "audio_file.write_metadata()" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "audio_file._file.unlink()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Tagging an audio file with existing metadata\n", - "\n", - "Now, we will process an audio file that already has most of the metadata fields populated. As before, we load the file, but this time using the `minim.audio.FLACAudio` constructor directly:" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "('tobu_back_to_you.flac', )" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "audio_file = audio.FLACAudio(audio_files[1])\n", - "audio_files[1].name, audio_file" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "_audio_file = audio_files[1].with_stem(f\"{audio_files[1].stem}_copy\")\n", - "_audio_file.write_bytes(audio_files[1].read_bytes())\n", - "audio_file = audio.FLACAudio(_audio_file)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's take a look at the file's metadata:" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Album: Back To You - Single\n", - "Album artist: Tobu\n", - "Artist: Tobu\n", - "Comment: None\n", - "Composer: Tobu & Toms Burkovskis\n", - "Copyright: 2022 NCS 2022 NCS\n", - "Date: 2023-07-06T07:00:00Z\n", - "Genre: House\n", - "ISRC: GB2LD2210368\n", - "Lyrics: None\n", - "Tempo: None\n", - "Title: Back To You\n", - "Compilation: None\n", - "Disc number: 1\n", - "Disc count: 1\n", - "Track number: 1\n", - "Track count: 1\n", - "Artwork: \n", - "Bit depth: 16\n", - "Bitrate: 1104053\n", - "Channel count: 2\n", - "Codec: flac\n", - "Sample rate: 44100\n" - ] - } - ], - "source": [ - "print_metadata(audio_file)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The file has a poorly formatted copyright string and is missing tempo and cover art information. We can fix this by querying the three APIs as we did in the previous example, and overwrite the existing metadata:" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": {}, - "outputs": [], - "source": [ - "query = f\"{audio_file.artist} {audio_file.title}\".lower()\n", - "\n", - "# iTunes Search API\n", - "itunes_results = client_itunes.search(query)[\"results\"]\n", - "itunes_track = itunes_results[\n", - " np.argmax(\n", - " utility.levenshtein_ratio(\n", - " query,\n", - " [f\"{r['artistName']} {r['trackName']}\".lower()\n", - " for r in itunes_results]\n", - " )\n", - " )\n", - "]\n", - "itunes_album = client_itunes.lookup(itunes_track[\"collectionId\"])[\"results\"][0]\n", - "audio_file.set_metadata_using_itunes(itunes_track, album_data=itunes_album,\n", - " overwrite=True)\n", - "\n", - "# Spotify Web API\n", - "spotify_results = client_spotify.search(query, type=\"track\")[\"items\"]\n", - "spotify_track = spotify_results[\n", - " np.argmax(\n", - " utility.levenshtein_ratio(\n", - " query,\n", - " [f\"{r['artists'][0]['name']} {r['name']}\".lower()\n", - " for r in spotify_results]\n", - " )\n", - " )\n", - "]\n", - "audio_file.set_metadata_using_spotify(\n", - " spotify_track,\n", - " audio_features=client_spotify.get_track_audio_features(spotify_track[\"id\"])\n", - ")\n", - "\n", - "# Private TIDAL API\n", - "tidal_results = client_tidal.search(query)[\"tracks\"][\"items\"]\n", - "tidal_track = next((r for r in tidal_results if r[\"isrc\"] == audio_file.isrc),\n", - " None)\n", - "tidal_composers = client_tidal.get_track_composers(tidal_track[\"id\"])\n", - "audio_file.set_metadata_using_tidal(tidal_track, composers=tidal_composers)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's take another look at the file's metadata:" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": { - "tags": [ - "hide-output" - ] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Album: Back to You - Single\n", - "Album artist: Tobu\n", - "Artist: Tobu\n", - "Comment: None\n", - "Composer: Tobu & Toms Burkovskis\n", - "Copyright: ℗ 2022 NCS\n", - "Date: 2022-11-25T12:00:00Z\n", - "Genre: House\n", - "ISRC: GB2LD2210368\n", - "Lyrics: \n", - "Tempo: 98\n", - "Title: Back to You\n", - "Compilation: False\n", - "Disc number: 1\n", - "Disc count: 1\n", - "Track number: 1\n", - "Track count: 1\n", - "Artwork: \n", - "Bit depth: 16\n", - "Bitrate: 1104053\n", - "Channel count: 2\n", - "Codec: flac\n", - "Sample rate: 44100\n" - ] - } - ], - "source": [ - "print_metadata(audio_file)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Voilà! The metadata has been updated and is now complete. (Toms Burkovskis, otherwise known as Tobu, appears twice in the composer field because of the unique names. There is no elegant solution to this problem, unfortunately.)\n", - "\n", - "As always, don't forget to write the changes to the file:" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [], - "source": [ - "audio_file.write_metadata()" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "audio_file._file.unlink()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "minim", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.6" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/_sources/notebooks/user_guide/getting_recommendations.ipynb.txt b/docs/_sources/notebooks/user_guide/getting_recommendations.ipynb.txt deleted file mode 100644 index 8c6cbbc..0000000 --- a/docs/_sources/notebooks/user_guide/getting_recommendations.ipynb.txt +++ /dev/null @@ -1,289 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "%%html\n", - "" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Getting Recommendations\n", - "\n", - "**Last updated**: November 19, 2023\n", - "\n", - "Minim can help you discover new artists and music by leveraging the Spotify and TIDAL recommender systems and suggesting tracks based on your music libraries." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from base64 import b64encode\n", - "import random\n", - "\n", - "from IPython.display import HTML, IFrame, display\n", - "from ipywidgets import Output, GridspecLayout\n", - "from minim import spotify, tidal" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Spotify\n", - "\n", - "In the Spotify Web API, you can use `minim.spotify.WebAPI.get_recommendations()` to generate track recommendations based on your favorite artists, genres, and/or tracks, and a number of tunable track attributes.\n", - "\n", - "In the following example, we will generate recommendations using only seed tracks. \n", - "\n", - "First, we create a Spotify Web API client by instantiating a `minim.spotify.WebAPI` object:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "client_spotify = spotify.WebAPI()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If you want to access your Spotify library for seed artists and/or tracks, make sure that the appropriate credentials and scopes are passed to the constructor above, stored in environment variables, or available in the Minim configuration file.\n", - "\n", - ":::{seealso}\n", - "See [Getting Started](../getting_started.ipynb) for more information about setting up clients with user authentication.\n", - ":::\n", - "\n", - "The seed tracks can either come from your favorite Spotify tracks:\n", - "\n", - "```python\n", - "seed_tracks = [track[\"track\"][\"id\"] for track in client_spotify.get_saved_tracks(limit=50)[\"items\"]]\n", - "```\n", - "\n", - "or be specified manually:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "seed_tracks = [\n", - " \"0JZ9TvOLtZJaGqIyC4hYZX\", # Avicii - Trouble\n", - " \"0bmB3nzQuHBfI6nM4SETVu\", # Cash Cash - Surrender\n", - " \"1PQ8ywTy9V2iVZWJ7Gyxxb\", # Mako - Our Story\n", - " \"70IFLb5egLA8WUFWgxBoRz\", # Mike Williams - Fallin' In\n", - " \"6jSPbxZLd2yemJTjz2gqOT\", # Passion Pit & Galantis - I Found U\n", - " \"76B6LjxTolaSGXLANjNndR\", # Sick Individuals - Made for This\n", - " \"2V65y3PX4DkRhy1djlxd9p\", # Swedish House Mafia - Don't You Worry Child (feat. John Martin)\n", - " \"1gpF8IwQQj8qOeVjHfIIDU\" # Zedd - Find You (feat. Matthew Koma & Miriam Bryant)\n", - "]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Since we are limited to 5 seed tracks, we will randomly select 5 tracks from our `list` of seed tracks and pass them as a keyword argument to `minim.spotify.WebAPI.get_recommendations()`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "recommended_tracks = client_spotify.get_recommendations(\n", - " seed_tracks=random.choices(seed_tracks, k=5)\n", - ")[\"tracks\"]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, we can add the recommended tracks to a new private Spotify playlist so that they can be accessed from another device:\n", - "\n", - "```python\n", - "spotify_playlist = client_spotify.create_playlist(\"Minim Mix\", public=False)\n", - "client_spotify.add_playlist_items(\n", - " spotify_playlist[\"id\"], \n", - " [f\"spotify:track:{track['id']}\" for track in recommended_tracks]\n", - ")\n", - "with open(globals()[\"_dh\"][0].parents[3] / \"assets/minim_mix_small.jpg\", \"rb\") as f:\n", - " client_spotify.add_playlist_cover_image(spotify_playlist[\"id\"], b64encode(f.read()))\n", - "```\n", - "\n", - "The last two lines above add a nifty custom cover art for mixes created with Minim:\n", - "\n", - "![Minim Mix cover art](https://raw.githubusercontent.com/bbye98/minim/main/assets/minim_mix_small.jpg)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If you are building an interactive or web application, you can instead visualize the recommended tracks using embeds:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "grid = GridspecLayout(len(recommended_tracks), 1)\n", - "for i, track in enumerate(recommended_tracks):\n", - " out = Output()\n", - " with out:\n", - " display(IFrame(f\"https://open.spotify.com/embed/track/{track['id']}\", \n", - " frameBorder=0, loading=\"lazy\", height=152, width=510))\n", - " grid[*divmod(i, 1)] = out\n", - "grid" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## TIDAL\n", - "\n", - "In the TIDAL API, you can use `minim.tidal.API.get_similar_albums()`, `minim.tidal.API.get_similar_artists()`, and `minim.tidal.API.get_similar_tracks()` to generate recommendations based on your favorite albums, artists, and tracks, respectively.\n", - "\n", - "In the following example, we will discover tracks similar to our favorite tracks only since the procedure for generating album and artist recommendations is similar.\n", - "\n", - "First, we create a TIDAL API client by instantiating a `minim.tidal.API` object:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "client_tidal = tidal.API()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "and specify the tracks for which to find similar tracks:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "favorite_tracks = [\n", - " 51073951, # Avicii - Trouble\n", - " 62082351, # Cash Cash - Surrender\n", - " 32553484, # Mako - Our Story\n", - " 147258423, # Mike Williams - Fallin' In\n", - " 109273852, # Passion Pit & Galantis - I Found U\n", - " 237059212, # Sick Individuals - Made for This\n", - " 17271290, # Swedish House Mafia - Don't You Worry Child (feat. John Martin)\n", - " 27171015 # Zedd - Find You (feat. Matthew Koma & Miriam Bryant)\n", - "]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Then, we randomly select a track from our `list` of favorite tracks and pass it to `minim.tidal.API.get_similar_tracks()`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "similar_tracks = client_tidal.get_similar_tracks(random.choice(favorite_tracks), \n", - " \"US\")[\"data\"]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, we can display the similar tracks interactively using embeds:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "grid = GridspecLayout(len(similar_tracks) // 2, 2)\n", - "for i, track in enumerate(similar_tracks):\n", - " out = Output()\n", - " with out:\n", - " display(\n", - " HTML('
'\n", - " '
')\n", - " )\n", - " grid[*divmod(i, 2)] = out\n", - "grid" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.6" - }, - "orig_nbformat": 4 - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/_sources/notebooks/user_guide/transferring_music_libraries.ipynb.txt b/docs/_sources/notebooks/user_guide/transferring_music_libraries.ipynb.txt deleted file mode 100644 index 71b63d4..0000000 --- a/docs/_sources/notebooks/user_guide/transferring_music_libraries.ipynb.txt +++ /dev/null @@ -1,1095 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Transferring Music Libraries\n", - "\n", - "**Last updated**: November 19, 2023\n", - "\n", - "Minim can be used as a free, open-source alternative to services like [TuneMyMusic](https://www.tunemymusic.com/) for moving playlists and synchronizing libraries between the supported streaming services." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from minim import qobuz, spotify, tidal" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Prerequisites\n", - "\n", - "All clients must be authenticated to access private user information. Assuming the relevant client credentials are stored as environment variables, the recommended client instantiation is as follows:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "client_qobuz = qobuz.PrivateAPI(flow=\"password\", browser=True)\n", - "client_spotify = spotify.WebAPI(flow=\"pkce\",\n", - " scopes=spotify.WebAPI.get_scopes(\"all\"),\n", - " web_framework=\"http.server\")\n", - "client_tidal = tidal.PrivateAPI(flow=\"device_code\", browser=True)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - ":::{seealso}\n", - "See [Getting Started](../getting_started.ipynb) for more information about setting up clients with user authentication.\n", - ":::\n", - "\n", - "## Moving playlists\n", - "\n", - "The general process is to\n", - "\n", - "1. get information about the tracks in the source playlist,\n", - "2. create a new playlist in the destination service, and\n", - "3. find and add the corresponding tracks to the newly-created playlist.\n", - "\n", - "The challenge often lies in the third step. The tracks in the source playlist may not be available in the destination service or it may be difficult finding the matching track in the destination service, especially if its catalog lookup does not support searching by ISRC or UPC.\n", - "\n", - "The following examples provide barebones implementations of the process above for various service pairs. Additional fine-tuning is likely necessary to handle tracks with complex metadata, such as those with multiple or featured artists, remixes, etc.\n", - "\n", - "### From Qobuz\n", - "\n", - "We start with a Qobuz playlist with 5 tracks:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "QOBUZ_PLAYLIST_ID = 17865119" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can get the playlist information and the items in the playlist using `minim.qobuz.PrivateAPI.get_playlist()`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "qobuz_playlist = client_qobuz.get_playlist(QOBUZ_PLAYLIST_ID)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To Spotify\n", - "\n", - "First, we create a new playlist on Spotify with the same details as the Qobuz playlist using `minim.spotify.WebAPI.create_playlist()`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "new_spotify_playlist = client_spotify.create_playlist(\n", - " qobuz_playlist[\"name\"],\n", - " description=qobuz_playlist[\"description\"],\n", - " public=qobuz_playlist[\"is_public\"],\n", - " collaborative=qobuz_playlist[\"is_collaborative\"],\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Then, we get the Spotify tracks equivalent to those in the Qobuz playlist. This is a simple process as Spotify allows looking up tracks by their ISRCs with its best-in-class API:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "spotify_track_uris = []\n", - "for qobuz_track in qobuz_playlist[\"tracks\"][\"items\"]:\n", - " spotify_track = client_spotify.search(f'isrc:{qobuz_track[\"isrc\"]}', type=\"track\", limit=1)[\"items\"][0]\n", - " spotify_track_uris.append(f\"spotify:track:{spotify_track['id']}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, we add the tracks to the Spotify playlist using `minim.spotify.WebAPI.add_playlist_items()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "client_spotify.add_playlist_items(new_spotify_playlist[\"id\"], spotify_track_uris)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "client_spotify.unfollow_playlist(new_spotify_playlist[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To TIDAL\n", - "\n", - "First, we create a new playlist on TIDAL with the same details as the Qobuz playlist using `minim.tidal.PrivateAPI.create_playlist()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "new_tidal_playlist = client_tidal.create_playlist(\n", - " qobuz_playlist[\"name\"],\n", - " description=qobuz_playlist[\"description\"],\n", - " public=qobuz_playlist[\"is_public\"]\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Then, we try to find TIDAL tracks equivalent to those in the Qobuz playlist. Unfortunately, TIDAL does not support searching by ISRCs, so we have to look up the tracks using their titles and artists. The TIDAL API does, however, return ISRCs so we can confirm that we have the right tracks before adding them to the TIDAL playlist." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [], - "source": [ - "tidal_track_ids = []\n", - "for qobuz_track in qobuz_playlist[\"tracks\"][\"items\"]:\n", - " title = qobuz_track[\"title\"]\n", - " if qobuz_track[\"version\"]:\n", - " title += f' {qobuz_track[\"version\"]}'\n", - " tidal_track = client_tidal.search(\n", - " f'{qobuz_track[\"performer\"][\"name\"]} {title}',\n", - " type=\"track\",\n", - " limit=1\n", - " )[\"items\"][0]\n", - " if qobuz_track[\"isrc\"] == tidal_track[\"isrc\"]:\n", - " tidal_track_ids.append(tidal_track[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, we add the tracks to the TIDAL playlist using `minim.tidal.PrivateAPI.add_playlist_items()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "client_tidal.add_playlist_items(new_tidal_playlist[\"data\"][\"uuid\"], tidal_track_ids)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "client_tidal.delete_playlist(new_tidal_playlist[\"data\"][\"uuid\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### From Spotify\n", - "\n", - "We start with a Spotify playlist with 5 tracks:" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [], - "source": [ - "SPOTIFY_PLAYLIST_ID = \"3rw9qY60CEh6dfJauWdxMh\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can get the playlist information and the items in the playlist using `minim.spotify.WebAPI.get_playlist()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [], - "source": [ - "spotify_playlist = client_spotify.get_playlist(SPOTIFY_PLAYLIST_ID)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To Qobuz\n", - "\n", - "First, we create a new playlist on Qobuz with the same details as the Spotify playlist using `minim.qobuz.PrivateAPI.create_playlist()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [], - "source": [ - "new_qobuz_playlist = client_qobuz.create_playlist(\n", - " spotify_playlist[\"name\"],\n", - " description=spotify_playlist[\"description\"],\n", - " public=spotify_playlist[\"public\"],\n", - " collaborative=spotify_playlist[\"collaborative\"],\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Then, we get the Qobuz tracks equivalent to those in the Spotify playlist. Thankfully, we can search by ISRC on Qobuz, so we can get the correct Qobuz tracks directly if they are available in the Qobuz catalog:" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [], - "source": [ - "qobuz_track_ids = []\n", - "for spotify_track in spotify_playlist[\"tracks\"][\"items\"]:\n", - " qobuz_track = client_qobuz.search(\n", - " spotify_track[\"track\"][\"external_ids\"][\"isrc\"],\n", - " limit=1\n", - " )[\"tracks\"][\"items\"][0]\n", - " qobuz_track_ids.append(qobuz_track[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, we add the tracks to the Qobuz playlist using `minim.qobuz.PrivateAPI.add_playlist_tracks()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": { - "tags": [ - "remove-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'id': 21738651,\n", - " 'name': 'Minim Example',\n", - " 'description': 'Moving playlists between music services.',\n", - " 'tracks_count': 5,\n", - " 'users_count': 0,\n", - " 'duration': 1056,\n", - " 'public_at': 1716156000,\n", - " 'created_at': 1716183363,\n", - " 'updated_at': 1716183364,\n", - " 'is_public': True,\n", - " 'is_collaborative': False,\n", - " 'owner': {'id': 3060762, 'name': 'foreign arcanine'}}" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client_qobuz.add_playlist_tracks(new_qobuz_playlist[\"id\"], qobuz_track_ids)" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "client_qobuz.delete_playlist(new_qobuz_playlist[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To TIDAL\n", - "\n", - "First, we create a new playlist on TIDAL with the same details as the Spotify playlist:" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [], - "source": [ - "new_tidal_playlist = client_tidal.create_playlist(\n", - " spotify_playlist[\"name\"],\n", - " description=spotify_playlist[\"description\"],\n", - " public=spotify_playlist[\"public\"]\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Then, we try to find TIDAL tracks equivalent to those in the Spotify playlist:" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": {}, - "outputs": [], - "source": [ - "tidal_track_ids = []\n", - "for spotify_track in spotify_playlist[\"tracks\"][\"items\"]:\n", - " tidal_track = client_tidal.search(\n", - " f'{spotify_track[\"track\"][\"artists\"][0][\"name\"]} '\n", - " f'{spotify_track[\"track\"][\"name\"]}',\n", - " type=\"track\",\n", - " limit=1\n", - " )[\"items\"][0]\n", - " if spotify_track[\"track\"][\"external_ids\"][\"isrc\"] == tidal_track[\"isrc\"]:\n", - " tidal_track_ids.append(tidal_track[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, we add the tracks to the TIDAL playlist:" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": {}, - "outputs": [], - "source": [ - "client_tidal.add_playlist_items(new_tidal_playlist[\"data\"][\"uuid\"], tidal_track_ids)" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "client_tidal.delete_playlist(new_tidal_playlist[\"data\"][\"uuid\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### From TIDAL\n", - "\n", - "We start with a TIDAL playlist with 5 tracks:" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": {}, - "outputs": [], - "source": [ - "TIDAL_PLAYLIST_UUID = \"40052e73-58d4-4abb-bc1c-abace76d2f15\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can get the playlist information using `minim.tidal.PrivateAPI.get_user_playlist()` and the items in the playlist using `minim.tidal.PrivateAPI.get_playlist_items()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": {}, - "outputs": [], - "source": [ - "tidal_playlist = client_tidal.get_user_playlist(TIDAL_PLAYLIST_UUID)\n", - "tidal_playlist_items = client_tidal.get_playlist_items(TIDAL_PLAYLIST_UUID)[\"items\"]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To Qobuz\n", - "\n", - "First, we create a new playlist on Qobuz with the same details as the TIDAL playlist:" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": {}, - "outputs": [], - "source": [ - "new_qobuz_playlist = client_qobuz.create_playlist(\n", - " spotify_playlist[\"name\"],\n", - " description=spotify_playlist[\"description\"],\n", - " public=spotify_playlist[\"public\"],\n", - " collaborative=spotify_playlist[\"collaborative\"],\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Then, we get the Qobuz tracks equivalent to those in the TIDAL playlist:" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, - "outputs": [], - "source": [ - "qobuz_track_ids = []\n", - "for tidal_track in tidal_playlist_items:\n", - " qobuz_track = client_qobuz.search(\n", - " tidal_track[\"item\"][\"isrc\"],\n", - " limit=1\n", - " )[\"tracks\"][\"items\"][0]\n", - " qobuz_track_ids.append(qobuz_track[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, we add the tracks to the Qobuz playlist:" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": { - "tags": [ - "remove-output" - ] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'id': 21738652,\n", - " 'name': 'Minim Example',\n", - " 'description': 'Moving playlists between music services.',\n", - " 'tracks_count': 5,\n", - " 'users_count': 0,\n", - " 'duration': 1056,\n", - " 'public_at': 1716156000,\n", - " 'created_at': 1716183367,\n", - " 'updated_at': 1716183368,\n", - " 'is_public': True,\n", - " 'is_collaborative': False,\n", - " 'owner': {'id': 3060762, 'name': 'foreign arcanine'}}" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client_qobuz.add_playlist_tracks(new_qobuz_playlist[\"id\"], qobuz_track_ids)" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "client_qobuz.delete_playlist(new_qobuz_playlist[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To Spotify\n", - "\n", - "First, we create a new playlist on Spotify with the same details as the TIDAL playlist:" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": {}, - "outputs": [], - "source": [ - "new_spotify_playlist = client_spotify.create_playlist(\n", - " qobuz_playlist[\"name\"],\n", - " description=qobuz_playlist[\"description\"],\n", - " public=qobuz_playlist[\"is_public\"],\n", - " collaborative=qobuz_playlist[\"is_collaborative\"],\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Then, we get the Spotify tracks equivalent to those in the TIDAL playlist:" - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "metadata": {}, - "outputs": [], - "source": [ - "spotify_track_uris = []\n", - "for tidal_track in tidal_playlist_items:\n", - " spotify_track = client_spotify.search(f'isrc:{tidal_track[\"item\"][\"isrc\"]}',\n", - " type=\"track\", limit=1)[\"items\"][0]\n", - " spotify_track_uris.append(f\"spotify:track:{spotify_track['id']}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, we add the tracks to the Spotify playlist:" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": {}, - "outputs": [], - "source": [ - "client_spotify.add_playlist_items(new_spotify_playlist[\"id\"],\n", - " spotify_track_uris)" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "client_spotify.unfollow_playlist(new_spotify_playlist[\"id\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Synchronizing favorites\n", - "\n", - "Synchronizing favorite albums, artists, tracks, etc. across services follows a similar procedure as above; we first get information about the entities in the source service and then try to find the corresponding media or people in the destination service. For albums and tracks, we can search using their UPCs and ISRCs, respectively, when available, or their titles and the main artist names. For artists, we can only search using their names. \n", - "\n", - "Sample implementations for synchronizing albums and artists are available below for various service pairs.\n", - "\n", - "### From Qobuz\n", - "\n", - "We start by getting the current user's favorite albums and artists using `minim.qobuz.PrivateAPI.get_favorites()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 33, - "metadata": {}, - "outputs": [], - "source": [ - "qobuz_favorites = client_qobuz.get_favorites()\n", - "qobuz_favorite_albums = qobuz_favorites[\"albums\"][\"items\"]\n", - "qobuz_favorite_artists = qobuz_favorites[\"artists\"][\"items\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "if len(qobuz_favorite_albums) == 0:\n", - " qobuz_favorite_albums.append(\"0075679933652\")\n", - "\n", - "if len(qobuz_favorite_artists) == 0:\n", - " qobuz_favorite_artists.append(\"865362\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To Spotify\n", - "\n", - "The Spotify Web API supports searching for albums by UPC, but sometimes the UPCs returned by Qobuz do not align with those in the Spotify catalog. In those cases, we can search for the albums using their titles and the main artist names. Then, we select the correct album from the search results by matching the album title, main artists, and number of tracks. Finally, we add the albums to the user's Spotify library using their Spotify album IDs and `minim.spotify.WebAPI.save_albums()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 34, - "metadata": {}, - "outputs": [], - "source": [ - "spotify_album_ids = []\n", - "for qobuz_album in qobuz_favorite_albums:\n", - " try:\n", - " spotify_album = client_spotify.search(f'upc:{qobuz_album[\"upc\"][1:]}',\n", - " \"album\")[\"items\"][0]\n", - " except IndexError:\n", - " spotify_albums = client_spotify.search(\n", - " f'{qobuz_album[\"artist\"][\"name\"]} {qobuz_album[\"title\"]}', \"album\"\n", - " )[\"items\"]\n", - " for spotify_album in spotify_albums:\n", - " if (spotify_album[\"name\"] == qobuz_album[\"title\"]\n", - " and spotify_album[\"artists\"][0][\"name\"]\n", - " == qobuz_album[\"artist\"][\"name\"]\n", - " and spotify_album[\"total_tracks\"]\n", - " == qobuz_album[\"tracks_count\"]):\n", - " break\n", - " spotify_album_ids.append(spotify_album[\"id\"])\n", - "client_spotify.save_albums(spotify_album_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For artists, we can search for them using their names and add them to the user's Spotify library using their Spotify artist IDs and `minim.spotify.WebAPI.follow_artists()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "metadata": {}, - "outputs": [], - "source": [ - "spotify_artist_ids = []\n", - "for qobuz_artist in qobuz_favorite_artists:\n", - " spotify_artist = client_spotify.search(qobuz_artist[\"name\"], \"artist\")[\"items\"][0]\n", - " spotify_artist_ids.append(spotify_artist[\"id\"])\n", - "client_spotify.follow_people(spotify_artist_ids, \"artist\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To TIDAL\n", - "\n", - "The private TIDAL API does not support searching for albums by UPC, so we have to search for them using their titles and the main artist names. Then, we select the correct albums by matching UPCs. Finally, we add the albums to the user's TIDAL library using their TIDAL album IDs and `minim.tidal.PrivateAPI.favorite_albums()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "metadata": {}, - "outputs": [], - "source": [ - "tidal_album_ids = []\n", - "for qobuz_album in qobuz_favorite_albums:\n", - " tidal_albums = client_tidal.search(\n", - " f'{qobuz_album[\"artist\"][\"name\"]} {qobuz_album[\"title\"]}', type=\"album\"\n", - " )[\"items\"]\n", - " for tidal_album in tidal_albums:\n", - " if tidal_album[\"upc\"].lstrip(\"0\") == qobuz_album[\"upc\"].lstrip(\"0\"):\n", - " tidal_album_ids.append(tidal_album[\"id\"])\n", - " break\n", - "client_tidal.favorite_albums(tidal_album_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For artists, we can search for them using their names and add them to the user's TIDAL library using their TIDAL artist IDs and `minim.tidal.PrivateAPI.favorite_artists()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "metadata": {}, - "outputs": [], - "source": [ - "tidal_artist_ids = []\n", - "for qobuz_artist in qobuz_favorite_artists:\n", - " tidal_artist = client_tidal.search(qobuz_artist[\"name\"],\n", - " type=\"artist\")[\"items\"][0]\n", - " tidal_artist_ids.append(tidal_artist[\"id\"])\n", - "client_tidal.favorite_artists(tidal_artist_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### From Spotify\n", - "\n", - "We start by getting the current user's favorite albums and artists using `minim.spotify.WebAPI.get_saved_albums()` and `minim.spotify.WebAPI.get_followed_artists()`, respectively:" - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "metadata": {}, - "outputs": [], - "source": [ - "spotify_favorite_albums = client_spotify.get_saved_albums()[\"items\"]\n", - "spotify_favorite_artists = client_spotify.get_followed_artists()[\"items\"]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To Qobuz\n", - "\n", - "The private Qobuz API does not support searching for albums by UPC, so we have to search for them using their titles and the main artist names. Then, we select the correct albums by matching UPCs or the album title, main artists, and number of tracks. Finally, we add the albums to the user's Qobuz library using their Qobuz album IDs and `minim.qobuz.PrivateAPI.favorite_items()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "metadata": {}, - "outputs": [], - "source": [ - "qobuz_album_ids = []\n", - "for spotify_album in spotify_favorite_albums:\n", - " qobuz_albums = client_qobuz.search(\n", - " f'{spotify_album[\"album\"][\"artists\"][0][\"name\"]} '\n", - " f'{spotify_album[\"album\"][\"name\"]}'\n", - " )[\"albums\"][\"items\"]\n", - " for qobuz_album in qobuz_albums:\n", - " if (spotify_album[\"album\"][\"external_ids\"][\"upc\"].lstrip(\"0\")\n", - " == qobuz_albums[0][\"upc\"].lstrip(\"0\")\n", - " or (spotify_album[\"album\"][\"name\"] == qobuz_album[\"title\"]\n", - " and spotify_album[\"album\"][\"artists\"][0][\"name\"]\n", - " == qobuz_album[\"artist\"][\"name\"]\n", - " and spotify_album[\"album\"][\"tracks\"][\"total\"]\n", - " == qobuz_album[\"tracks_count\"])):\n", - " qobuz_album_ids.append(qobuz_album[\"id\"])\n", - " break\n", - "client_qobuz.favorite_items(album_ids=qobuz_album_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For artists, we can search for them using their names and add them to the user's Qobuz library using their Qobuz artist IDs and `minim.qobuz.PrivateAPI.favorite_items()`:" - ] - }, - { - "cell_type": "code", - "execution_count": 40, - "metadata": {}, - "outputs": [], - "source": [ - "qobuz_artist_ids = []\n", - "for spotify_artist in spotify_favorite_artists:\n", - " qobuz_artist = client_qobuz.search(\n", - " spotify_artist[\"name\"]\n", - " )[\"artists\"][\"items\"][0]\n", - " qobuz_artist_ids.append(qobuz_artist[\"id\"])\n", - "client_qobuz.favorite_items(artist_ids=qobuz_artist_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To TIDAL\n", - "\n", - "To search for albums using their titles and the main artist names, select the correct albums by matching UPCs or the album title, main artists, and number of tracks, and add the albums to the user's TIDAL library," - ] - }, - { - "cell_type": "code", - "execution_count": 41, - "metadata": {}, - "outputs": [], - "source": [ - "tidal_album_ids = []\n", - "for spotify_album in spotify_favorite_albums:\n", - " tidal_albums = client_tidal.search(\n", - " f'{spotify_album[\"album\"][\"artists\"][0][\"name\"]} '\n", - " f'{spotify_album[\"album\"][\"name\"]}',\n", - " type=\"album\"\n", - " )[\"items\"]\n", - " for tidal_album in tidal_albums:\n", - " if (tidal_album[\"upc\"].lstrip(\"0\")\n", - " == spotify_album[\"album\"][\"external_ids\"][\"upc\"].lstrip(\"0\")\n", - " or (tidal_album[\"title\"] == spotify_album[\"album\"][\"name\"]\n", - " and tidal_album[\"artists\"][0][\"name\"]\n", - " == spotify_album[\"album\"][\"artists\"][0][\"name\"]\n", - " and tidal_album[\"numberOfTracks\"]\n", - " == spotify_album[\"album\"][\"tracks\"][\"total\"])):\n", - " tidal_album_ids.append(tidal_album[\"id\"])\n", - " break\n", - "client_tidal.favorite_albums(tidal_album_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To search for artists using their names and add them to the user's TIDAL library," - ] - }, - { - "cell_type": "code", - "execution_count": 42, - "metadata": {}, - "outputs": [], - "source": [ - "tidal_artist_ids = []\n", - "for spotify_artist in spotify_favorite_artists:\n", - " tidal_artist = client_tidal.search(spotify_artist[\"name\"],\n", - " type=\"artist\")[\"items\"][0]\n", - " tidal_artist_ids.append(tidal_artist[\"id\"])\n", - "client_tidal.favorite_artists(tidal_artist_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### From TIDAL\n", - "\n", - "We start by getting the current user's favorite albums and artists using `minim.tidal.PrivateAPI.get_favorite_albums()` and `minim.tidal.PrivateAPI.get_favorite_artists()`, respectively:" - ] - }, - { - "cell_type": "code", - "execution_count": 43, - "metadata": {}, - "outputs": [], - "source": [ - "tidal_favorite_albums = client_tidal.get_favorite_albums()[\"items\"]\n", - "tidal_favorite_artists = client_tidal.get_favorite_artists()[\"items\"]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To Qobuz\n", - "\n", - "To search for albums using their titles and the main artist names, select the correct albums by matching UPCs or the album title, main artists, and number of tracks, and add the albums to the user's Qobuz library," - ] - }, - { - "cell_type": "code", - "execution_count": 44, - "metadata": {}, - "outputs": [], - "source": [ - "qobuz_album_ids = []\n", - "for tidal_album in tidal_favorite_albums:\n", - " qobuz_albums = client_qobuz.search(\n", - " f'{tidal_album[\"item\"][\"artist\"][\"name\"]} {tidal_album[\"item\"][\"title\"]}'\n", - " )[\"albums\"][\"items\"]\n", - " for qobuz_album in qobuz_albums:\n", - " if (tidal_album[\"item\"][\"upc\"].lstrip(\"0\")\n", - " == qobuz_album[\"upc\"].lstrip(\"0\")\n", - " or (tidal_album[\"item\"][\"title\"] == qobuz_album[\"title\"]\n", - " and tidal_album[\"item\"][\"artist\"][\"name\"]\n", - " == qobuz_album[\"artist\"][\"name\"]\n", - " and tidal_album[\"item\"][\"numberOfTracks\"]\n", - " == qobuz_album[\"tracks_count\"])):\n", - " qobuz_album_ids.append(qobuz_album[\"id\"])\n", - " break\n", - "client_qobuz.favorite_items(album_ids=qobuz_album_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To search for artists using their names and add them to the user's Qobuz library," - ] - }, - { - "cell_type": "code", - "execution_count": 45, - "metadata": {}, - "outputs": [], - "source": [ - "qobuz_artist_ids = []\n", - "for tidal_artist in tidal_favorite_artists:\n", - " qobuz_artist = client_qobuz.search(\n", - " tidal_artist[\"item\"][\"name\"]\n", - " )[\"artists\"][\"items\"][0]\n", - " qobuz_artist_ids.append(qobuz_artist[\"id\"])\n", - "client_qobuz.favorite_items(artist_ids=qobuz_artist_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### To Spotify\n", - "\n", - "To search for albums using their UPCs or titles and the main artist names, select the correct albums by matching the album title, main artists, and number of tracks, and add the albums to the user's Spotify library, " - ] - }, - { - "cell_type": "code", - "execution_count": 46, - "metadata": {}, - "outputs": [], - "source": [ - "spotify_album_ids = []\n", - "for tidal_album in tidal_favorite_albums:\n", - " try:\n", - " spotify_album = client_spotify.search(\n", - " f'upc:{tidal_album[\"item\"][\"upc\"]}',\n", - " \"album\"\n", - " )[\"items\"][0]\n", - " except IndexError:\n", - " spotify_albums = client_spotify.search(\n", - " f'{tidal_album[\"item\"][\"artist\"][\"name\"]} '\n", - " f'{tidal_album[\"item\"][\"title\"]}',\n", - " \"album\"\n", - " )[\"items\"]\n", - " for spotify_album in spotify_albums:\n", - " if (spotify_album[\"name\"] == tidal_album[\"item\"][\"title\"]\n", - " and spotify_album[\"artists\"][0][\"name\"]\n", - " == tidal_album[\"item\"][\"artist\"][\"name\"]\n", - " and spotify_album[\"total_tracks\"]\n", - " == tidal_album[\"item\"][\"numberOfTracks\"]):\n", - " break\n", - " spotify_album_ids.append(spotify_album[\"id\"])\n", - "client_spotify.save_albums(spotify_album_ids)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To search for artists using their names and add them to the user's Spotify library," - ] - }, - { - "cell_type": "code", - "execution_count": 47, - "metadata": {}, - "outputs": [], - "source": [ - "spotify_artist_ids = []\n", - "for tidal_artist in tidal_favorite_artists:\n", - " spotify_artist = client_spotify.search(tidal_artist[\"item\"][\"name\"],\n", - " \"artist\")[\"items\"][0]\n", - " spotify_artist_ids.append(spotify_artist[\"id\"])\n", - "client_spotify.follow_people(spotify_artist_ids, \"artist\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "minim", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/_sources/user_guide.rst.txt b/docs/_sources/user_guide.rst.txt deleted file mode 100644 index ae10fcc..0000000 --- a/docs/_sources/user_guide.rst.txt +++ /dev/null @@ -1,8 +0,0 @@ -User Guide -========== - -.. toctree:: - :glob: - :maxdepth: 1 - - notebooks/user_guide/* \ No newline at end of file diff --git a/docs/_static/basic.css b/docs/_static/basic.css deleted file mode 100644 index 30fee9d..0000000 --- a/docs/_static/basic.css +++ /dev/null @@ -1,925 +0,0 @@ -/* - * basic.css - * ~~~~~~~~~ - * - * Sphinx stylesheet -- basic theme. - * - * :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS. - * :license: BSD, see LICENSE for details. - * - */ - -/* -- main layout ----------------------------------------------------------- */ - -div.clearer { - clear: both; -} - -div.section::after { - display: block; - content: ''; - clear: left; -} - -/* -- relbar ---------------------------------------------------------------- */ - -div.related { - width: 100%; - font-size: 90%; -} - -div.related h3 { - display: none; -} - -div.related ul { - margin: 0; - padding: 0 0 0 10px; - list-style: none; -} - -div.related li { - display: inline; -} - -div.related li.right { - float: right; - margin-right: 5px; -} - -/* -- sidebar --------------------------------------------------------------- */ - -div.sphinxsidebarwrapper { - padding: 10px 5px 0 10px; -} - -div.sphinxsidebar { - float: left; - width: 230px; - margin-left: -100%; - font-size: 90%; - word-wrap: break-word; - overflow-wrap : break-word; -} - -div.sphinxsidebar ul { - list-style: none; -} - -div.sphinxsidebar ul ul, -div.sphinxsidebar ul.want-points { - margin-left: 20px; - list-style: square; -} - -div.sphinxsidebar ul ul { - margin-top: 0; - margin-bottom: 0; -} - -div.sphinxsidebar form { - margin-top: 10px; -} - -div.sphinxsidebar input { - border: 1px solid #98dbcc; - font-family: sans-serif; - font-size: 1em; -} - -div.sphinxsidebar #searchbox form.search { - overflow: hidden; -} - -div.sphinxsidebar #searchbox input[type="text"] { - float: left; - width: 80%; - padding: 0.25em; - box-sizing: border-box; -} - -div.sphinxsidebar #searchbox input[type="submit"] { - float: left; - width: 20%; - border-left: none; - padding: 0.25em; - box-sizing: border-box; -} - - -img { - border: 0; - max-width: 100%; -} - -/* -- search page ----------------------------------------------------------- */ - -ul.search { - margin: 10px 0 0 20px; - padding: 0; -} - -ul.search li { - padding: 5px 0 5px 20px; - background-image: url(file.png); - background-repeat: no-repeat; - background-position: 0 7px; -} - -ul.search li a { - font-weight: bold; -} - -ul.search li p.context { - color: #888; - margin: 2px 0 0 30px; - text-align: left; -} - -ul.keywordmatches li.goodmatch a { - font-weight: bold; -} - -/* -- index page ------------------------------------------------------------ */ - -table.contentstable { - width: 90%; - margin-left: auto; - margin-right: auto; -} - -table.contentstable p.biglink { - line-height: 150%; -} - -a.biglink { - font-size: 1.3em; -} - -span.linkdescr { - font-style: italic; - padding-top: 5px; - font-size: 90%; -} - -/* -- general index --------------------------------------------------------- */ - -table.indextable { - width: 100%; -} - -table.indextable td { - text-align: left; - vertical-align: top; -} - -table.indextable ul { - margin-top: 0; - margin-bottom: 0; - list-style-type: none; -} - -table.indextable > tbody > tr > td > ul { - padding-left: 0em; -} - -table.indextable tr.pcap { - height: 10px; -} - -table.indextable tr.cap { - margin-top: 10px; - background-color: #f2f2f2; -} - -img.toggler { - margin-right: 3px; - margin-top: 3px; - cursor: pointer; -} - -div.modindex-jumpbox { - border-top: 1px solid #ddd; - border-bottom: 1px solid #ddd; - margin: 1em 0 1em 0; - padding: 0.4em; -} - -div.genindex-jumpbox { - border-top: 1px solid #ddd; - border-bottom: 1px solid #ddd; - margin: 1em 0 1em 0; - padding: 0.4em; -} - -/* -- domain module index --------------------------------------------------- */ - -table.modindextable td { - padding: 2px; - border-collapse: collapse; -} - -/* -- general body styles --------------------------------------------------- */ - -div.body { - min-width: 360px; - max-width: 800px; -} - -div.body p, div.body dd, div.body li, div.body blockquote { - -moz-hyphens: auto; - -ms-hyphens: auto; - -webkit-hyphens: auto; - hyphens: auto; -} - -a.headerlink { - visibility: hidden; -} - -a:visited { - color: #551A8B; -} - -h1:hover > a.headerlink, -h2:hover > a.headerlink, -h3:hover > a.headerlink, -h4:hover > a.headerlink, -h5:hover > a.headerlink, -h6:hover > a.headerlink, -dt:hover > a.headerlink, -caption:hover > a.headerlink, -p.caption:hover > a.headerlink, -div.code-block-caption:hover > a.headerlink { - visibility: visible; -} - -div.body p.caption { - text-align: inherit; -} - -div.body td { - text-align: left; -} - -.first { - margin-top: 0 !important; -} - -p.rubric { - margin-top: 30px; - font-weight: bold; -} - -img.align-left, figure.align-left, .figure.align-left, object.align-left { - clear: left; - float: left; - margin-right: 1em; -} - -img.align-right, figure.align-right, .figure.align-right, object.align-right { - clear: right; - float: right; - margin-left: 1em; -} - -img.align-center, figure.align-center, .figure.align-center, object.align-center { - display: block; - margin-left: auto; - margin-right: auto; -} - -img.align-default, figure.align-default, .figure.align-default { - display: block; - margin-left: auto; - margin-right: auto; -} - -.align-left { - text-align: left; -} - -.align-center { - text-align: center; -} - -.align-default { - text-align: center; -} - -.align-right { - text-align: right; -} - -/* -- sidebars -------------------------------------------------------------- */ - -div.sidebar, -aside.sidebar { - margin: 0 0 0.5em 1em; - border: 1px solid #ddb; - padding: 7px; - background-color: #ffe; - width: 40%; - float: right; - clear: right; - overflow-x: auto; -} - -p.sidebar-title { - font-weight: bold; -} - -nav.contents, -aside.topic, -div.admonition, div.topic, blockquote { - clear: left; -} - -/* -- topics ---------------------------------------------------------------- */ - -nav.contents, -aside.topic, -div.topic { - border: 1px solid #ccc; - padding: 7px; - margin: 10px 0 10px 0; -} - -p.topic-title { - font-size: 1.1em; - font-weight: bold; - margin-top: 10px; -} - -/* -- admonitions ----------------------------------------------------------- */ - -div.admonition { - margin-top: 10px; - margin-bottom: 10px; - padding: 7px; -} - -div.admonition dt { - font-weight: bold; -} - -p.admonition-title { - margin: 0px 10px 5px 0px; - font-weight: bold; -} - -div.body p.centered { - text-align: center; - margin-top: 25px; -} - -/* -- content of sidebars/topics/admonitions -------------------------------- */ - -div.sidebar > :last-child, -aside.sidebar > :last-child, -nav.contents > :last-child, -aside.topic > :last-child, -div.topic > :last-child, -div.admonition > :last-child { - margin-bottom: 0; -} - -div.sidebar::after, -aside.sidebar::after, -nav.contents::after, -aside.topic::after, -div.topic::after, -div.admonition::after, -blockquote::after { - display: block; - content: ''; - clear: both; -} - -/* -- tables ---------------------------------------------------------------- */ - -table.docutils { - margin-top: 10px; - margin-bottom: 10px; - border: 0; - border-collapse: collapse; -} - -table.align-center { - margin-left: auto; - margin-right: auto; -} - -table.align-default { - margin-left: auto; - margin-right: auto; -} - -table caption span.caption-number { - font-style: italic; -} - -table caption span.caption-text { -} - -table.docutils td, table.docutils th { - padding: 1px 8px 1px 5px; - border-top: 0; - border-left: 0; - border-right: 0; - border-bottom: 1px solid #aaa; -} - -th { - text-align: left; - padding-right: 5px; -} - -table.citation { - border-left: solid 1px gray; - margin-left: 1px; -} - -table.citation td { - border-bottom: none; -} - -th > :first-child, -td > :first-child { - margin-top: 0px; -} - -th > :last-child, -td > :last-child { - margin-bottom: 0px; -} - -/* -- figures --------------------------------------------------------------- */ - -div.figure, figure { - margin: 0.5em; - padding: 0.5em; -} - -div.figure p.caption, figcaption { - padding: 0.3em; -} - -div.figure p.caption span.caption-number, -figcaption span.caption-number { - font-style: italic; -} - -div.figure p.caption span.caption-text, -figcaption span.caption-text { -} - -/* -- field list styles ----------------------------------------------------- */ - -table.field-list td, table.field-list th { - border: 0 !important; -} - -.field-list ul { - margin: 0; - padding-left: 1em; -} - -.field-list p { - margin: 0; -} - -.field-name { - -moz-hyphens: manual; - -ms-hyphens: manual; - -webkit-hyphens: manual; - hyphens: manual; -} - -/* -- hlist styles ---------------------------------------------------------- */ - -table.hlist { - margin: 1em 0; -} - -table.hlist td { - vertical-align: top; -} - -/* -- object description styles --------------------------------------------- */ - -.sig { - font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; -} - -.sig-name, code.descname { - background-color: transparent; - font-weight: bold; -} - -.sig-name { - font-size: 1.1em; -} - -code.descname { - font-size: 1.2em; -} - -.sig-prename, code.descclassname { - background-color: transparent; -} - -.optional { - font-size: 1.3em; -} - -.sig-paren { - font-size: larger; -} - -.sig-param.n { - font-style: italic; -} - -/* C++ specific styling */ - -.sig-inline.c-texpr, -.sig-inline.cpp-texpr { - font-family: unset; -} - -.sig.c .k, .sig.c .kt, -.sig.cpp .k, .sig.cpp .kt { - color: #0033B3; -} - -.sig.c .m, -.sig.cpp .m { - color: #1750EB; -} - -.sig.c .s, .sig.c .sc, -.sig.cpp .s, .sig.cpp .sc { - color: #067D17; -} - - -/* -- other body styles ----------------------------------------------------- */ - -ol.arabic { - list-style: decimal; -} - -ol.loweralpha { - list-style: lower-alpha; -} - -ol.upperalpha { - list-style: upper-alpha; -} - -ol.lowerroman { - list-style: lower-roman; -} - -ol.upperroman { - list-style: upper-roman; -} - -:not(li) > ol > li:first-child > :first-child, -:not(li) > ul > li:first-child > :first-child { - margin-top: 0px; -} - -:not(li) > ol > li:last-child > :last-child, -:not(li) > ul > li:last-child > :last-child { - margin-bottom: 0px; -} - -ol.simple ol p, -ol.simple ul p, -ul.simple ol p, -ul.simple ul p { - margin-top: 0; -} - -ol.simple > li:not(:first-child) > p, -ul.simple > li:not(:first-child) > p { - margin-top: 0; -} - -ol.simple p, -ul.simple p { - margin-bottom: 0; -} - -aside.footnote > span, -div.citation > span { - float: left; -} -aside.footnote > span:last-of-type, -div.citation > span:last-of-type { - padding-right: 0.5em; -} -aside.footnote > p { - margin-left: 2em; -} -div.citation > p { - margin-left: 4em; -} -aside.footnote > p:last-of-type, -div.citation > p:last-of-type { - margin-bottom: 0em; -} -aside.footnote > p:last-of-type:after, -div.citation > p:last-of-type:after { - content: ""; - clear: both; -} - -dl.field-list { - display: grid; - grid-template-columns: fit-content(30%) auto; -} - -dl.field-list > dt { - font-weight: bold; - word-break: break-word; - padding-left: 0.5em; - padding-right: 5px; -} - -dl.field-list > dd { - padding-left: 0.5em; - margin-top: 0em; - margin-left: 0em; - margin-bottom: 0em; -} - -dl { - margin-bottom: 15px; -} - -dd > :first-child { - margin-top: 0px; -} - -dd ul, dd table { - margin-bottom: 10px; -} - -dd { - margin-top: 3px; - margin-bottom: 10px; - margin-left: 30px; -} - -.sig dd { - margin-top: 0px; - margin-bottom: 0px; -} - -.sig dl { - margin-top: 0px; - margin-bottom: 0px; -} - -dl > dd:last-child, -dl > dd:last-child > :last-child { - margin-bottom: 0; -} - -dt:target, span.highlighted { - background-color: #fbe54e; -} - -rect.highlighted { - fill: #fbe54e; -} - -dl.glossary dt { - font-weight: bold; - font-size: 1.1em; -} - -.versionmodified { - font-style: italic; -} - -.system-message { - background-color: #fda; - padding: 5px; - border: 3px solid red; -} - -.footnote:target { - background-color: #ffa; -} - -.line-block { - display: block; - margin-top: 1em; - margin-bottom: 1em; -} - -.line-block .line-block { - margin-top: 0; - margin-bottom: 0; - margin-left: 1.5em; -} - -.guilabel, .menuselection { - font-family: sans-serif; -} - -.accelerator { - text-decoration: underline; -} - -.classifier { - font-style: oblique; -} - -.classifier:before { - font-style: normal; - margin: 0 0.5em; - content: ":"; - display: inline-block; -} - -abbr, acronym { - border-bottom: dotted 1px; - cursor: help; -} - -.translated { - background-color: rgba(207, 255, 207, 0.2) -} - -.untranslated { - background-color: rgba(255, 207, 207, 0.2) -} - -/* -- code displays --------------------------------------------------------- */ - -pre { - overflow: auto; - overflow-y: hidden; /* fixes display issues on Chrome browsers */ -} - -pre, div[class*="highlight-"] { - clear: both; -} - -span.pre { - -moz-hyphens: none; - -ms-hyphens: none; - -webkit-hyphens: none; - hyphens: none; - white-space: nowrap; -} - -div[class*="highlight-"] { - margin: 1em 0; -} - -td.linenos pre { - border: 0; - background-color: transparent; - color: #aaa; -} - -table.highlighttable { - display: block; -} - -table.highlighttable tbody { - display: block; -} - -table.highlighttable tr { - display: flex; -} - -table.highlighttable td { - margin: 0; - padding: 0; -} - -table.highlighttable td.linenos { - padding-right: 0.5em; -} - -table.highlighttable td.code { - flex: 1; - overflow: hidden; -} - -.highlight .hll { - display: block; -} - -div.highlight pre, -table.highlighttable pre { - margin: 0; -} - -div.code-block-caption + div { - margin-top: 0; -} - -div.code-block-caption { - margin-top: 1em; - padding: 2px 5px; - font-size: small; -} - -div.code-block-caption code { - background-color: transparent; -} - -table.highlighttable td.linenos, -span.linenos, -div.highlight span.gp { /* gp: Generic.Prompt */ - user-select: none; - -webkit-user-select: text; /* Safari fallback only */ - -webkit-user-select: none; /* Chrome/Safari */ - -moz-user-select: none; /* Firefox */ - -ms-user-select: none; /* IE10+ */ -} - -div.code-block-caption span.caption-number { - padding: 0.1em 0.3em; - font-style: italic; -} - -div.code-block-caption span.caption-text { -} - -div.literal-block-wrapper { - margin: 1em 0; -} - -code.xref, a code { - background-color: transparent; - font-weight: bold; -} - -h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { - background-color: transparent; -} - -.viewcode-link { - float: right; -} - -.viewcode-back { - float: right; - font-family: sans-serif; -} - -div.viewcode-block:target { - margin: -1px -10px; - padding: 0 10px; -} - -/* -- math display ---------------------------------------------------------- */ - -img.math { - vertical-align: middle; -} - -div.body div.math p { - text-align: center; -} - -span.eqno { - float: right; -} - -span.eqno a.headerlink { - position: absolute; - z-index: 1; -} - -div.math:hover a.headerlink { - visibility: visible; -} - -/* -- printout stylesheet --------------------------------------------------- */ - -@media print { - div.document, - div.documentwrapper, - div.bodywrapper { - margin: 0 !important; - width: 100%; - } - - div.sphinxsidebar, - div.related, - div.footer, - #top-link { - display: none; - } -} \ No newline at end of file diff --git a/docs/_static/check-solid.svg b/docs/_static/check-solid.svg deleted file mode 100644 index 92fad4b..0000000 --- a/docs/_static/check-solid.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/_static/clipboard.min.js b/docs/_static/clipboard.min.js deleted file mode 100644 index 54b3c46..0000000 --- a/docs/_static/clipboard.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * clipboard.js v2.0.8 - * https://clipboardjs.com/ - * - * Licensed MIT © Zeno Rocha - */ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={686:function(t,e,n){"use strict";n.d(e,{default:function(){return o}});var e=n(279),i=n.n(e),e=n(370),u=n.n(e),e=n(817),c=n.n(e);function a(t){try{return document.execCommand(t)}catch(t){return}}var f=function(t){t=c()(t);return a("cut"),t};var l=function(t){var e,n,o,r=1 - - - - diff --git a/docs/_static/copybutton.css b/docs/_static/copybutton.css deleted file mode 100644 index f1916ec..0000000 --- a/docs/_static/copybutton.css +++ /dev/null @@ -1,94 +0,0 @@ -/* Copy buttons */ -button.copybtn { - position: absolute; - display: flex; - top: .3em; - right: .3em; - width: 1.7em; - height: 1.7em; - opacity: 0; - transition: opacity 0.3s, border .3s, background-color .3s; - user-select: none; - padding: 0; - border: none; - outline: none; - border-radius: 0.4em; - /* The colors that GitHub uses */ - border: #1b1f2426 1px solid; - background-color: #f6f8fa; - color: #57606a; -} - -button.copybtn.success { - border-color: #22863a; - color: #22863a; -} - -button.copybtn svg { - stroke: currentColor; - width: 1.5em; - height: 1.5em; - padding: 0.1em; -} - -div.highlight { - position: relative; -} - -/* Show the copybutton */ -.highlight:hover button.copybtn, button.copybtn.success { - opacity: 1; -} - -.highlight button.copybtn:hover { - background-color: rgb(235, 235, 235); -} - -.highlight button.copybtn:active { - background-color: rgb(187, 187, 187); -} - -/** - * A minimal CSS-only tooltip copied from: - * https://codepen.io/mildrenben/pen/rVBrpK - * - * To use, write HTML like the following: - * - *

Short

- */ - .o-tooltip--left { - position: relative; - } - - .o-tooltip--left:after { - opacity: 0; - visibility: hidden; - position: absolute; - content: attr(data-tooltip); - padding: .2em; - font-size: .8em; - left: -.2em; - background: grey; - color: white; - white-space: nowrap; - z-index: 2; - border-radius: 2px; - transform: translateX(-102%) translateY(0); - transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); -} - -.o-tooltip--left:hover:after { - display: block; - opacity: 1; - visibility: visible; - transform: translateX(-100%) translateY(0); - transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); - transition-delay: .5s; -} - -/* By default the copy button shouldn't show up when printing a page */ -@media print { - button.copybtn { - display: none; - } -} diff --git a/docs/_static/copybutton.js b/docs/_static/copybutton.js deleted file mode 100644 index 2ea7ff3..0000000 --- a/docs/_static/copybutton.js +++ /dev/null @@ -1,248 +0,0 @@ -// Localization support -const messages = { - 'en': { - 'copy': 'Copy', - 'copy_to_clipboard': 'Copy to clipboard', - 'copy_success': 'Copied!', - 'copy_failure': 'Failed to copy', - }, - 'es' : { - 'copy': 'Copiar', - 'copy_to_clipboard': 'Copiar al portapapeles', - 'copy_success': '¡Copiado!', - 'copy_failure': 'Error al copiar', - }, - 'de' : { - 'copy': 'Kopieren', - 'copy_to_clipboard': 'In die Zwischenablage kopieren', - 'copy_success': 'Kopiert!', - 'copy_failure': 'Fehler beim Kopieren', - }, - 'fr' : { - 'copy': 'Copier', - 'copy_to_clipboard': 'Copier dans le presse-papier', - 'copy_success': 'Copié !', - 'copy_failure': 'Échec de la copie', - }, - 'ru': { - 'copy': 'Скопировать', - 'copy_to_clipboard': 'Скопировать в буфер', - 'copy_success': 'Скопировано!', - 'copy_failure': 'Не удалось скопировать', - }, - 'zh-CN': { - 'copy': '复制', - 'copy_to_clipboard': '复制到剪贴板', - 'copy_success': '复制成功!', - 'copy_failure': '复制失败', - }, - 'it' : { - 'copy': 'Copiare', - 'copy_to_clipboard': 'Copiato negli appunti', - 'copy_success': 'Copiato!', - 'copy_failure': 'Errore durante la copia', - } -} - -let locale = 'en' -if( document.documentElement.lang !== undefined - && messages[document.documentElement.lang] !== undefined ) { - locale = document.documentElement.lang -} - -let doc_url_root = DOCUMENTATION_OPTIONS.URL_ROOT; -if (doc_url_root == '#') { - doc_url_root = ''; -} - -/** - * SVG files for our copy buttons - */ -let iconCheck = ` - ${messages[locale]['copy_success']} - - -` - -// If the user specified their own SVG use that, otherwise use the default -let iconCopy = ``; -if (!iconCopy) { - iconCopy = ` - ${messages[locale]['copy_to_clipboard']} - - - -` -} - -/** - * Set up copy/paste for code blocks - */ - -const runWhenDOMLoaded = cb => { - if (document.readyState != 'loading') { - cb() - } else if (document.addEventListener) { - document.addEventListener('DOMContentLoaded', cb) - } else { - document.attachEvent('onreadystatechange', function() { - if (document.readyState == 'complete') cb() - }) - } -} - -const codeCellId = index => `codecell${index}` - -// Clears selected text since ClipboardJS will select the text when copying -const clearSelection = () => { - if (window.getSelection) { - window.getSelection().removeAllRanges() - } else if (document.selection) { - document.selection.empty() - } -} - -// Changes tooltip text for a moment, then changes it back -// We want the timeout of our `success` class to be a bit shorter than the -// tooltip and icon change, so that we can hide the icon before changing back. -var timeoutIcon = 2000; -var timeoutSuccessClass = 1500; - -const temporarilyChangeTooltip = (el, oldText, newText) => { - el.setAttribute('data-tooltip', newText) - el.classList.add('success') - // Remove success a little bit sooner than we change the tooltip - // So that we can use CSS to hide the copybutton first - setTimeout(() => el.classList.remove('success'), timeoutSuccessClass) - setTimeout(() => el.setAttribute('data-tooltip', oldText), timeoutIcon) -} - -// Changes the copy button icon for two seconds, then changes it back -const temporarilyChangeIcon = (el) => { - el.innerHTML = iconCheck; - setTimeout(() => {el.innerHTML = iconCopy}, timeoutIcon) -} - -const addCopyButtonToCodeCells = () => { - // If ClipboardJS hasn't loaded, wait a bit and try again. This - // happens because we load ClipboardJS asynchronously. - if (window.ClipboardJS === undefined) { - setTimeout(addCopyButtonToCodeCells, 250) - return - } - - // Add copybuttons to all of our code cells - const COPYBUTTON_SELECTOR = 'div.highlight pre'; - const codeCells = document.querySelectorAll(COPYBUTTON_SELECTOR) - codeCells.forEach((codeCell, index) => { - const id = codeCellId(index) - codeCell.setAttribute('id', id) - - const clipboardButton = id => - `` - codeCell.insertAdjacentHTML('afterend', clipboardButton(id)) - }) - -function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -} - -/** - * Removes excluded text from a Node. - * - * @param {Node} target Node to filter. - * @param {string} exclude CSS selector of nodes to exclude. - * @returns {DOMString} Text from `target` with text removed. - */ -function filterText(target, exclude) { - const clone = target.cloneNode(true); // clone as to not modify the live DOM - if (exclude) { - // remove excluded nodes - clone.querySelectorAll(exclude).forEach(node => node.remove()); - } - return clone.innerText; -} - -// Callback when a copy button is clicked. Will be passed the node that was clicked -// should then grab the text and replace pieces of text that shouldn't be used in output -function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onlyCopyPromptLines = true, removePrompts = true, copyEmptyLines = true, lineContinuationChar = "", hereDocDelim = "") { - var regexp; - var match; - - // Do we check for line continuation characters and "HERE-documents"? - var useLineCont = !!lineContinuationChar - var useHereDoc = !!hereDocDelim - - // create regexp to capture prompt and remaining line - if (isRegexp) { - regexp = new RegExp('^(' + copybuttonPromptText + ')(.*)') - } else { - regexp = new RegExp('^(' + escapeRegExp(copybuttonPromptText) + ')(.*)') - } - - const outputLines = []; - var promptFound = false; - var gotLineCont = false; - var gotHereDoc = false; - const lineGotPrompt = []; - for (const line of textContent.split('\n')) { - match = line.match(regexp) - if (match || gotLineCont || gotHereDoc) { - promptFound = regexp.test(line) - lineGotPrompt.push(promptFound) - if (removePrompts && promptFound) { - outputLines.push(match[2]) - } else { - outputLines.push(line) - } - gotLineCont = line.endsWith(lineContinuationChar) & useLineCont - if (line.includes(hereDocDelim) & useHereDoc) - gotHereDoc = !gotHereDoc - } else if (!onlyCopyPromptLines) { - outputLines.push(line) - } else if (copyEmptyLines && line.trim() === '') { - outputLines.push(line) - } - } - - // If no lines with the prompt were found then just use original lines - if (lineGotPrompt.some(v => v === true)) { - textContent = outputLines.join('\n'); - } - - // Remove a trailing newline to avoid auto-running when pasting - if (textContent.endsWith("\n")) { - textContent = textContent.slice(0, -1) - } - return textContent -} - - -var copyTargetText = (trigger) => { - var target = document.querySelector(trigger.attributes['data-clipboard-target'].value); - - // get filtered text - let exclude = '.linenos'; - - let text = filterText(target, exclude); - return formatCopyText(text, '', false, true, true, true, '', '') -} - - // Initialize with a callback so we can modify the text before copy - const clipboard = new ClipboardJS('.copybtn', {text: copyTargetText}) - - // Update UI with error/success messages - clipboard.on('success', event => { - clearSelection() - temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_success']) - temporarilyChangeIcon(event.trigger) - }) - - clipboard.on('error', event => { - temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_failure']) - }) -} - -runWhenDOMLoaded(addCopyButtonToCodeCells) \ No newline at end of file diff --git a/docs/_static/copybutton_funcs.js b/docs/_static/copybutton_funcs.js deleted file mode 100644 index dbe1aaa..0000000 --- a/docs/_static/copybutton_funcs.js +++ /dev/null @@ -1,73 +0,0 @@ -function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -} - -/** - * Removes excluded text from a Node. - * - * @param {Node} target Node to filter. - * @param {string} exclude CSS selector of nodes to exclude. - * @returns {DOMString} Text from `target` with text removed. - */ -export function filterText(target, exclude) { - const clone = target.cloneNode(true); // clone as to not modify the live DOM - if (exclude) { - // remove excluded nodes - clone.querySelectorAll(exclude).forEach(node => node.remove()); - } - return clone.innerText; -} - -// Callback when a copy button is clicked. Will be passed the node that was clicked -// should then grab the text and replace pieces of text that shouldn't be used in output -export function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onlyCopyPromptLines = true, removePrompts = true, copyEmptyLines = true, lineContinuationChar = "", hereDocDelim = "") { - var regexp; - var match; - - // Do we check for line continuation characters and "HERE-documents"? - var useLineCont = !!lineContinuationChar - var useHereDoc = !!hereDocDelim - - // create regexp to capture prompt and remaining line - if (isRegexp) { - regexp = new RegExp('^(' + copybuttonPromptText + ')(.*)') - } else { - regexp = new RegExp('^(' + escapeRegExp(copybuttonPromptText) + ')(.*)') - } - - const outputLines = []; - var promptFound = false; - var gotLineCont = false; - var gotHereDoc = false; - const lineGotPrompt = []; - for (const line of textContent.split('\n')) { - match = line.match(regexp) - if (match || gotLineCont || gotHereDoc) { - promptFound = regexp.test(line) - lineGotPrompt.push(promptFound) - if (removePrompts && promptFound) { - outputLines.push(match[2]) - } else { - outputLines.push(line) - } - gotLineCont = line.endsWith(lineContinuationChar) & useLineCont - if (line.includes(hereDocDelim) & useHereDoc) - gotHereDoc = !gotHereDoc - } else if (!onlyCopyPromptLines) { - outputLines.push(line) - } else if (copyEmptyLines && line.trim() === '') { - outputLines.push(line) - } - } - - // If no lines with the prompt were found then just use original lines - if (lineGotPrompt.some(v => v === true)) { - textContent = outputLines.join('\n'); - } - - // Remove a trailing newline to avoid auto-running when pasting - if (textContent.endsWith("\n")) { - textContent = textContent.slice(0, -1) - } - return textContent -} diff --git a/docs/_static/debug.css b/docs/_static/debug.css deleted file mode 100644 index 74d4aec..0000000 --- a/docs/_static/debug.css +++ /dev/null @@ -1,69 +0,0 @@ -/* - This CSS file should be overridden by the theme authors. It's - meant for debugging and developing the skeleton that this theme provides. -*/ -body { - font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji"; - background: lavender; -} -.sb-announcement { - background: rgb(131, 131, 131); -} -.sb-announcement__inner { - background: black; - color: white; -} -.sb-header { - background: lightskyblue; -} -.sb-header__inner { - background: royalblue; - color: white; -} -.sb-header-secondary { - background: lightcyan; -} -.sb-header-secondary__inner { - background: cornflowerblue; - color: white; -} -.sb-sidebar-primary { - background: lightgreen; -} -.sb-main { - background: blanchedalmond; -} -.sb-main__inner { - background: antiquewhite; -} -.sb-header-article { - background: lightsteelblue; -} -.sb-article-container { - background: snow; -} -.sb-article-main { - background: white; -} -.sb-footer-article { - background: lightpink; -} -.sb-sidebar-secondary { - background: lightgoldenrodyellow; -} -.sb-footer-content { - background: plum; -} -.sb-footer-content__inner { - background: palevioletred; -} -.sb-footer { - background: pink; -} -.sb-footer__inner { - background: salmon; -} -.sb-article { - background: white; -} diff --git a/docs/_static/doctools.js b/docs/_static/doctools.js deleted file mode 100644 index d06a71d..0000000 --- a/docs/_static/doctools.js +++ /dev/null @@ -1,156 +0,0 @@ -/* - * doctools.js - * ~~~~~~~~~~~ - * - * Base JavaScript utilities for all Sphinx HTML documentation. - * - * :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS. - * :license: BSD, see LICENSE for details. - * - */ -"use strict"; - -const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([ - "TEXTAREA", - "INPUT", - "SELECT", - "BUTTON", -]); - -const _ready = (callback) => { - if (document.readyState !== "loading") { - callback(); - } else { - document.addEventListener("DOMContentLoaded", callback); - } -}; - -/** - * Small JavaScript module for the documentation. - */ -const Documentation = { - init: () => { - Documentation.initDomainIndexTable(); - Documentation.initOnKeyListeners(); - }, - - /** - * i18n support - */ - TRANSLATIONS: {}, - PLURAL_EXPR: (n) => (n === 1 ? 0 : 1), - LOCALE: "unknown", - - // gettext and ngettext don't access this so that the functions - // can safely bound to a different name (_ = Documentation.gettext) - gettext: (string) => { - const translated = Documentation.TRANSLATIONS[string]; - switch (typeof translated) { - case "undefined": - return string; // no translation - case "string": - return translated; // translation exists - default: - return translated[0]; // (singular, plural) translation tuple exists - } - }, - - ngettext: (singular, plural, n) => { - const translated = Documentation.TRANSLATIONS[singular]; - if (typeof translated !== "undefined") - return translated[Documentation.PLURAL_EXPR(n)]; - return n === 1 ? singular : plural; - }, - - addTranslations: (catalog) => { - Object.assign(Documentation.TRANSLATIONS, catalog.messages); - Documentation.PLURAL_EXPR = new Function( - "n", - `return (${catalog.plural_expr})` - ); - Documentation.LOCALE = catalog.locale; - }, - - /** - * helper function to focus on search bar - */ - focusSearchBar: () => { - document.querySelectorAll("input[name=q]")[0]?.focus(); - }, - - /** - * Initialise the domain index toggle buttons - */ - initDomainIndexTable: () => { - const toggler = (el) => { - const idNumber = el.id.substr(7); - const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`); - if (el.src.substr(-9) === "minus.png") { - el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`; - toggledRows.forEach((el) => (el.style.display = "none")); - } else { - el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`; - toggledRows.forEach((el) => (el.style.display = "")); - } - }; - - const togglerElements = document.querySelectorAll("img.toggler"); - togglerElements.forEach((el) => - el.addEventListener("click", (event) => toggler(event.currentTarget)) - ); - togglerElements.forEach((el) => (el.style.display = "")); - if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler); - }, - - initOnKeyListeners: () => { - // only install a listener if it is really needed - if ( - !DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS && - !DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS - ) - return; - - document.addEventListener("keydown", (event) => { - // bail for input elements - if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; - // bail with special keys - if (event.altKey || event.ctrlKey || event.metaKey) return; - - if (!event.shiftKey) { - switch (event.key) { - case "ArrowLeft": - if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; - - const prevLink = document.querySelector('link[rel="prev"]'); - if (prevLink && prevLink.href) { - window.location.href = prevLink.href; - event.preventDefault(); - } - break; - case "ArrowRight": - if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; - - const nextLink = document.querySelector('link[rel="next"]'); - if (nextLink && nextLink.href) { - window.location.href = nextLink.href; - event.preventDefault(); - } - break; - } - } - - // some keyboard layouts may need Shift to get / - switch (event.key) { - case "/": - if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break; - Documentation.focusSearchBar(); - event.preventDefault(); - } - }); - }, -}; - -// quick alias for translations -const _ = Documentation.gettext; - -_ready(Documentation.init); diff --git a/docs/_static/documentation_options.js b/docs/_static/documentation_options.js deleted file mode 100644 index 89435bb..0000000 --- a/docs/_static/documentation_options.js +++ /dev/null @@ -1,13 +0,0 @@ -const DOCUMENTATION_OPTIONS = { - VERSION: '1.0.0', - LANGUAGE: 'en', - COLLAPSE_INDEX: false, - BUILDER: 'html', - FILE_SUFFIX: '.html', - LINK_SUFFIX: '.html', - HAS_SOURCE: true, - SOURCELINK_SUFFIX: '.txt', - NAVIGATION_WITH_KEYS: false, - SHOW_SEARCH_SUMMARY: true, - ENABLE_SEARCH_SHORTCUTS: true, -}; \ No newline at end of file diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico deleted file mode 100644 index 81b3c04..0000000 Binary files a/docs/_static/favicon.ico and /dev/null differ diff --git a/docs/_static/file.png b/docs/_static/file.png deleted file mode 100644 index a858a41..0000000 Binary files a/docs/_static/file.png and /dev/null differ diff --git a/docs/_static/icon.svg b/docs/_static/icon.svg deleted file mode 100644 index 38d1e85..0000000 --- a/docs/_static/icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/_static/language_data.js b/docs/_static/language_data.js deleted file mode 100644 index 250f566..0000000 --- a/docs/_static/language_data.js +++ /dev/null @@ -1,199 +0,0 @@ -/* - * language_data.js - * ~~~~~~~~~~~~~~~~ - * - * This script contains the language-specific data used by searchtools.js, - * namely the list of stopwords, stemmer, scorer and splitter. - * - * :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS. - * :license: BSD, see LICENSE for details. - * - */ - -var stopwords = ["a", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it", "near", "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these", "they", "this", "to", "was", "will", "with"]; - - -/* Non-minified version is copied as a separate JS file, is available */ - -/** - * Porter Stemmer - */ -var Stemmer = function() { - - var step2list = { - ational: 'ate', - tional: 'tion', - enci: 'ence', - anci: 'ance', - izer: 'ize', - bli: 'ble', - alli: 'al', - entli: 'ent', - eli: 'e', - ousli: 'ous', - ization: 'ize', - ation: 'ate', - ator: 'ate', - alism: 'al', - iveness: 'ive', - fulness: 'ful', - ousness: 'ous', - aliti: 'al', - iviti: 'ive', - biliti: 'ble', - logi: 'log' - }; - - var step3list = { - icate: 'ic', - ative: '', - alize: 'al', - iciti: 'ic', - ical: 'ic', - ful: '', - ness: '' - }; - - var c = "[^aeiou]"; // consonant - var v = "[aeiouy]"; // vowel - var C = c + "[^aeiouy]*"; // consonant sequence - var V = v + "[aeiou]*"; // vowel sequence - - var mgr0 = "^(" + C + ")?" + V + C; // [C]VC... is m>0 - var meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$"; // [C]VC[V] is m=1 - var mgr1 = "^(" + C + ")?" + V + C + V + C; // [C]VCVC... is m>1 - var s_v = "^(" + C + ")?" + v; // vowel in stem - - this.stemWord = function (w) { - var stem; - var suffix; - var firstch; - var origword = w; - - if (w.length < 3) - return w; - - var re; - var re2; - var re3; - var re4; - - firstch = w.substr(0,1); - if (firstch == "y") - w = firstch.toUpperCase() + w.substr(1); - - // Step 1a - re = /^(.+?)(ss|i)es$/; - re2 = /^(.+?)([^s])s$/; - - if (re.test(w)) - w = w.replace(re,"$1$2"); - else if (re2.test(w)) - w = w.replace(re2,"$1$2"); - - // Step 1b - re = /^(.+?)eed$/; - re2 = /^(.+?)(ed|ing)$/; - if (re.test(w)) { - var fp = re.exec(w); - re = new RegExp(mgr0); - if (re.test(fp[1])) { - re = /.$/; - w = w.replace(re,""); - } - } - else if (re2.test(w)) { - var fp = re2.exec(w); - stem = fp[1]; - re2 = new RegExp(s_v); - if (re2.test(stem)) { - w = stem; - re2 = /(at|bl|iz)$/; - re3 = new RegExp("([^aeiouylsz])\\1$"); - re4 = new RegExp("^" + C + v + "[^aeiouwxy]$"); - if (re2.test(w)) - w = w + "e"; - else if (re3.test(w)) { - re = /.$/; - w = w.replace(re,""); - } - else if (re4.test(w)) - w = w + "e"; - } - } - - // Step 1c - re = /^(.+?)y$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - re = new RegExp(s_v); - if (re.test(stem)) - w = stem + "i"; - } - - // Step 2 - re = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - suffix = fp[2]; - re = new RegExp(mgr0); - if (re.test(stem)) - w = stem + step2list[suffix]; - } - - // Step 3 - re = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - suffix = fp[2]; - re = new RegExp(mgr0); - if (re.test(stem)) - w = stem + step3list[suffix]; - } - - // Step 4 - re = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/; - re2 = /^(.+?)(s|t)(ion)$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - re = new RegExp(mgr1); - if (re.test(stem)) - w = stem; - } - else if (re2.test(w)) { - var fp = re2.exec(w); - stem = fp[1] + fp[2]; - re2 = new RegExp(mgr1); - if (re2.test(stem)) - w = stem; - } - - // Step 5 - re = /^(.+?)e$/; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - re = new RegExp(mgr1); - re2 = new RegExp(meq1); - re3 = new RegExp("^" + C + v + "[^aeiouwxy]$"); - if (re.test(stem) || (re2.test(stem) && !(re3.test(stem)))) - w = stem; - } - re = /ll$/; - re2 = new RegExp(mgr1); - if (re.test(w) && re2.test(w)) { - re = /.$/; - w = w.replace(re,""); - } - - // and turn initial Y back to y - if (firstch == "y") - w = firstch.toLowerCase() + w.substr(1); - return w; - } -} - diff --git a/docs/_static/minus.png b/docs/_static/minus.png deleted file mode 100644 index d96755f..0000000 Binary files a/docs/_static/minus.png and /dev/null differ diff --git a/docs/_static/mystnb.4510f1fc1dee50b3e5859aac5469c37c29e427902b24a333a5f9fcb2f0b3ac41.css b/docs/_static/mystnb.4510f1fc1dee50b3e5859aac5469c37c29e427902b24a333a5f9fcb2f0b3ac41.css deleted file mode 100644 index 3356631..0000000 --- a/docs/_static/mystnb.4510f1fc1dee50b3e5859aac5469c37c29e427902b24a333a5f9fcb2f0b3ac41.css +++ /dev/null @@ -1,2342 +0,0 @@ -/* Variables */ -:root { - --mystnb-source-bg-color: #f7f7f7; - --mystnb-stdout-bg-color: #fcfcfc; - --mystnb-stderr-bg-color: #fdd; - --mystnb-traceback-bg-color: #fcfcfc; - --mystnb-source-border-color: #ccc; - --mystnb-source-margin-color: green; - --mystnb-stdout-border-color: #f7f7f7; - --mystnb-stderr-border-color: #f7f7f7; - --mystnb-traceback-border-color: #ffd6d6; - --mystnb-hide-prompt-opacity: 70%; - --mystnb-source-border-radius: .4em; - --mystnb-source-border-width: 1px; -} - -/* Whole cell */ -div.container.cell { - padding-left: 0; - margin-bottom: 1em; -} - -/* Removing all background formatting so we can control at the div level */ -.cell_input div.highlight, -.cell_output pre, -.cell_input pre, -.cell_output .output { - border: none; - box-shadow: none; -} - -.cell_output .output pre, -.cell_input pre { - margin: 0px; -} - -/* Input cells */ -div.cell div.cell_input, -div.cell details.above-input>summary { - padding-left: 0em; - padding-right: 0em; - border: var(--mystnb-source-border-width) var(--mystnb-source-border-color) solid; - background-color: var(--mystnb-source-bg-color); - border-left-color: var(--mystnb-source-margin-color); - border-left-width: medium; - border-radius: var(--mystnb-source-border-radius); -} - -div.cell_input>div, -div.cell_output div.output>div.highlight { - margin: 0em !important; - border: none !important; -} - -/* All cell outputs */ -.cell_output { - padding-left: 1em; - padding-right: 0em; - margin-top: 1em; -} - -/* Text outputs from cells */ -.cell_output .output.text_plain, -.cell_output .output.traceback, -.cell_output .output.stream, -.cell_output .output.stderr { - margin-top: 1em; - margin-bottom: 0em; - box-shadow: none; -} - -.cell_output .output.text_plain, -.cell_output .output.stream { - background: var(--mystnb-stdout-bg-color); - border: 1px solid var(--mystnb-stdout-border-color); -} - -.cell_output .output.stderr { - background: var(--mystnb-stderr-bg-color); - border: 1px solid var(--mystnb-stderr-border-color); -} - -.cell_output .output.traceback { - background: var(--mystnb-traceback-bg-color); - border: 1px solid var(--mystnb-traceback-border-color); -} - -/* Collapsible cell content */ -div.cell details.above-input div.cell_input { - border-top-left-radius: 0; - border-top-right-radius: 0; - border-top: var(--mystnb-source-border-width) var(--mystnb-source-border-color) dashed; -} - -div.cell div.cell_input.above-output-prompt { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - -div.cell details.above-input>summary { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-bottom: var(--mystnb-source-border-width) var(--mystnb-source-border-color) dashed; - padding-left: 1em; - margin-bottom: 0; -} - -div.cell details.above-output>summary { - background-color: var(--mystnb-source-bg-color); - padding-left: 1em; - padding-right: 0em; - border: var(--mystnb-source-border-width) var(--mystnb-source-border-color) solid; - border-radius: var(--mystnb-source-border-radius); - border-left-color: var(--mystnb-source-margin-color); - border-left-width: medium; -} - -div.cell details.below-input>summary { - background-color: var(--mystnb-source-bg-color); - padding-left: 1em; - padding-right: 0em; - border: var(--mystnb-source-border-width) var(--mystnb-source-border-color) solid; - border-top: none; - border-bottom-left-radius: var(--mystnb-source-border-radius); - border-bottom-right-radius: var(--mystnb-source-border-radius); - border-left-color: var(--mystnb-source-margin-color); - border-left-width: medium; -} - -div.cell details.hide>summary>span { - opacity: var(--mystnb-hide-prompt-opacity); -} - -div.cell details.hide[open]>summary>span.collapsed { - display: none; -} - -div.cell details.hide:not([open])>summary>span.expanded { - display: none; -} - -@keyframes collapsed-fade-in { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} -div.cell details.hide[open]>summary~* { - -moz-animation: collapsed-fade-in 0.3s ease-in-out; - -webkit-animation: collapsed-fade-in 0.3s ease-in-out; - animation: collapsed-fade-in 0.3s ease-in-out; -} - -/* Math align to the left */ -.cell_output .MathJax_Display { - text-align: left !important; -} - -/* Pandas tables. Pulled from the Jupyter / nbsphinx CSS */ -div.cell_output table { - border: none; - border-collapse: collapse; - border-spacing: 0; - color: black; - font-size: 1em; - table-layout: fixed; -} - -div.cell_output thead { - border-bottom: 1px solid black; - vertical-align: bottom; -} - -div.cell_output tr, -div.cell_output th, -div.cell_output td { - text-align: right; - vertical-align: middle; - padding: 0.5em 0.5em; - line-height: normal; - white-space: normal; - max-width: none; - border: none; -} - -div.cell_output th { - font-weight: bold; -} - -div.cell_output tbody tr:nth-child(odd) { - background: #f5f5f5; -} - -div.cell_output tbody tr:hover { - background: rgba(66, 165, 245, 0.2); -} - -/** source code line numbers **/ -span.linenos { - opacity: 0.5; -} - -/* Inline text from `paste` operation */ - -span.pasted-text { - font-weight: bold; -} - -span.pasted-inline img { - max-height: 2em; -} - -tbody span.pasted-inline img { - max-height: none; -} - -/* Font colors for translated ANSI escape sequences -Color values are copied from Jupyter Notebook -https://github.com/jupyter/notebook/blob/52581f8eda9b319eb0390ac77fe5903c38f81e3e/notebook/static/notebook/less/ansicolors.less#L14-L21 -Background colors from -https://nbsphinx.readthedocs.io/en/latest/code-cells.html#ANSI-Colors -*/ -div.highlight .-Color-Bold { - font-weight: bold; -} - -div.highlight .-Color[class*=-Black] { - color: #3E424D -} - -div.highlight .-Color[class*=-Red] { - color: #E75C58 -} - -div.highlight .-Color[class*=-Green] { - color: #00A250 -} - -div.highlight .-Color[class*=-Yellow] { - color: #DDB62B -} - -div.highlight .-Color[class*=-Blue] { - color: #208FFB -} - -div.highlight .-Color[class*=-Magenta] { - color: #D160C4 -} - -div.highlight .-Color[class*=-Cyan] { - color: #60C6C8 -} - -div.highlight .-Color[class*=-White] { - color: #C5C1B4 -} - -div.highlight .-Color[class*=-BGBlack] { - background-color: #3E424D -} - -div.highlight .-Color[class*=-BGRed] { - background-color: #E75C58 -} - -div.highlight .-Color[class*=-BGGreen] { - background-color: #00A250 -} - -div.highlight .-Color[class*=-BGYellow] { - background-color: #DDB62B -} - -div.highlight .-Color[class*=-BGBlue] { - background-color: #208FFB -} - -div.highlight .-Color[class*=-BGMagenta] { - background-color: #D160C4 -} - -div.highlight .-Color[class*=-BGCyan] { - background-color: #60C6C8 -} - -div.highlight .-Color[class*=-BGWhite] { - background-color: #C5C1B4 -} - -/* Font colors for 8-bit ANSI */ - -div.highlight .-Color[class*=-C0] { - color: #000000 -} - -div.highlight .-Color[class*=-BGC0] { - background-color: #000000 -} - -div.highlight .-Color[class*=-C1] { - color: #800000 -} - -div.highlight .-Color[class*=-BGC1] { - background-color: #800000 -} - -div.highlight .-Color[class*=-C2] { - color: #008000 -} - -div.highlight .-Color[class*=-BGC2] { - background-color: #008000 -} - -div.highlight .-Color[class*=-C3] { - color: #808000 -} - -div.highlight .-Color[class*=-BGC3] { - background-color: #808000 -} - -div.highlight .-Color[class*=-C4] { - color: #000080 -} - -div.highlight .-Color[class*=-BGC4] { - background-color: #000080 -} - -div.highlight .-Color[class*=-C5] { - color: #800080 -} - -div.highlight .-Color[class*=-BGC5] { - background-color: #800080 -} - -div.highlight .-Color[class*=-C6] { - color: #008080 -} - -div.highlight .-Color[class*=-BGC6] { - background-color: #008080 -} - -div.highlight .-Color[class*=-C7] { - color: #C0C0C0 -} - -div.highlight .-Color[class*=-BGC7] { - background-color: #C0C0C0 -} - -div.highlight .-Color[class*=-C8] { - color: #808080 -} - -div.highlight .-Color[class*=-BGC8] { - background-color: #808080 -} - -div.highlight .-Color[class*=-C9] { - color: #FF0000 -} - -div.highlight .-Color[class*=-BGC9] { - background-color: #FF0000 -} - -div.highlight .-Color[class*=-C10] { - color: #00FF00 -} - -div.highlight .-Color[class*=-BGC10] { - background-color: #00FF00 -} - -div.highlight .-Color[class*=-C11] { - color: #FFFF00 -} - -div.highlight .-Color[class*=-BGC11] { - background-color: #FFFF00 -} - -div.highlight .-Color[class*=-C12] { - color: #0000FF -} - -div.highlight .-Color[class*=-BGC12] { - background-color: #0000FF -} - -div.highlight .-Color[class*=-C13] { - color: #FF00FF -} - -div.highlight .-Color[class*=-BGC13] { - background-color: #FF00FF -} - -div.highlight .-Color[class*=-C14] { - color: #00FFFF -} - -div.highlight .-Color[class*=-BGC14] { - background-color: #00FFFF -} - -div.highlight .-Color[class*=-C15] { - color: #FFFFFF -} - -div.highlight .-Color[class*=-BGC15] { - background-color: #FFFFFF -} - -div.highlight .-Color[class*=-C16] { - color: #000000 -} - -div.highlight .-Color[class*=-BGC16] { - background-color: #000000 -} - -div.highlight .-Color[class*=-C17] { - color: #00005F -} - -div.highlight .-Color[class*=-BGC17] { - background-color: #00005F -} - -div.highlight .-Color[class*=-C18] { - color: #000087 -} - -div.highlight .-Color[class*=-BGC18] { - background-color: #000087 -} - -div.highlight .-Color[class*=-C19] { - color: #0000AF -} - -div.highlight .-Color[class*=-BGC19] { - background-color: #0000AF -} - -div.highlight .-Color[class*=-C20] { - color: #0000D7 -} - -div.highlight .-Color[class*=-BGC20] { - background-color: #0000D7 -} - -div.highlight .-Color[class*=-C21] { - color: #0000FF -} - -div.highlight .-Color[class*=-BGC21] { - background-color: #0000FF -} - -div.highlight .-Color[class*=-C22] { - color: #005F00 -} - -div.highlight .-Color[class*=-BGC22] { - background-color: #005F00 -} - -div.highlight .-Color[class*=-C23] { - color: #005F5F -} - -div.highlight .-Color[class*=-BGC23] { - background-color: #005F5F -} - -div.highlight .-Color[class*=-C24] { - color: #005F87 -} - -div.highlight .-Color[class*=-BGC24] { - background-color: #005F87 -} - -div.highlight .-Color[class*=-C25] { - color: #005FAF -} - -div.highlight .-Color[class*=-BGC25] { - background-color: #005FAF -} - -div.highlight .-Color[class*=-C26] { - color: #005FD7 -} - -div.highlight .-Color[class*=-BGC26] { - background-color: #005FD7 -} - -div.highlight .-Color[class*=-C27] { - color: #005FFF -} - -div.highlight .-Color[class*=-BGC27] { - background-color: #005FFF -} - -div.highlight .-Color[class*=-C28] { - color: #008700 -} - -div.highlight .-Color[class*=-BGC28] { - background-color: #008700 -} - -div.highlight .-Color[class*=-C29] { - color: #00875F -} - -div.highlight .-Color[class*=-BGC29] { - background-color: #00875F -} - -div.highlight .-Color[class*=-C30] { - color: #008787 -} - -div.highlight .-Color[class*=-BGC30] { - background-color: #008787 -} - -div.highlight .-Color[class*=-C31] { - color: #0087AF -} - -div.highlight .-Color[class*=-BGC31] { - background-color: #0087AF -} - -div.highlight .-Color[class*=-C32] { - color: #0087D7 -} - -div.highlight .-Color[class*=-BGC32] { - background-color: #0087D7 -} - -div.highlight .-Color[class*=-C33] { - color: #0087FF -} - -div.highlight .-Color[class*=-BGC33] { - background-color: #0087FF -} - -div.highlight .-Color[class*=-C34] { - color: #00AF00 -} - -div.highlight .-Color[class*=-BGC34] { - background-color: #00AF00 -} - -div.highlight .-Color[class*=-C35] { - color: #00AF5F -} - -div.highlight .-Color[class*=-BGC35] { - background-color: #00AF5F -} - -div.highlight .-Color[class*=-C36] { - color: #00AF87 -} - -div.highlight .-Color[class*=-BGC36] { - background-color: #00AF87 -} - -div.highlight .-Color[class*=-C37] { - color: #00AFAF -} - -div.highlight .-Color[class*=-BGC37] { - background-color: #00AFAF -} - -div.highlight .-Color[class*=-C38] { - color: #00AFD7 -} - -div.highlight .-Color[class*=-BGC38] { - background-color: #00AFD7 -} - -div.highlight .-Color[class*=-C39] { - color: #00AFFF -} - -div.highlight .-Color[class*=-BGC39] { - background-color: #00AFFF -} - -div.highlight .-Color[class*=-C40] { - color: #00D700 -} - -div.highlight .-Color[class*=-BGC40] { - background-color: #00D700 -} - -div.highlight .-Color[class*=-C41] { - color: #00D75F -} - -div.highlight .-Color[class*=-BGC41] { - background-color: #00D75F -} - -div.highlight .-Color[class*=-C42] { - color: #00D787 -} - -div.highlight .-Color[class*=-BGC42] { - background-color: #00D787 -} - -div.highlight .-Color[class*=-C43] { - color: #00D7AF -} - -div.highlight .-Color[class*=-BGC43] { - background-color: #00D7AF -} - -div.highlight .-Color[class*=-C44] { - color: #00D7D7 -} - -div.highlight .-Color[class*=-BGC44] { - background-color: #00D7D7 -} - -div.highlight .-Color[class*=-C45] { - color: #00D7FF -} - -div.highlight .-Color[class*=-BGC45] { - background-color: #00D7FF -} - -div.highlight .-Color[class*=-C46] { - color: #00FF00 -} - -div.highlight .-Color[class*=-BGC46] { - background-color: #00FF00 -} - -div.highlight .-Color[class*=-C47] { - color: #00FF5F -} - -div.highlight .-Color[class*=-BGC47] { - background-color: #00FF5F -} - -div.highlight .-Color[class*=-C48] { - color: #00FF87 -} - -div.highlight .-Color[class*=-BGC48] { - background-color: #00FF87 -} - -div.highlight .-Color[class*=-C49] { - color: #00FFAF -} - -div.highlight .-Color[class*=-BGC49] { - background-color: #00FFAF -} - -div.highlight .-Color[class*=-C50] { - color: #00FFD7 -} - -div.highlight .-Color[class*=-BGC50] { - background-color: #00FFD7 -} - -div.highlight .-Color[class*=-C51] { - color: #00FFFF -} - -div.highlight .-Color[class*=-BGC51] { - background-color: #00FFFF -} - -div.highlight .-Color[class*=-C52] { - color: #5F0000 -} - -div.highlight .-Color[class*=-BGC52] { - background-color: #5F0000 -} - -div.highlight .-Color[class*=-C53] { - color: #5F005F -} - -div.highlight .-Color[class*=-BGC53] { - background-color: #5F005F -} - -div.highlight .-Color[class*=-C54] { - color: #5F0087 -} - -div.highlight .-Color[class*=-BGC54] { - background-color: #5F0087 -} - -div.highlight .-Color[class*=-C55] { - color: #5F00AF -} - -div.highlight .-Color[class*=-BGC55] { - background-color: #5F00AF -} - -div.highlight .-Color[class*=-C56] { - color: #5F00D7 -} - -div.highlight .-Color[class*=-BGC56] { - background-color: #5F00D7 -} - -div.highlight .-Color[class*=-C57] { - color: #5F00FF -} - -div.highlight .-Color[class*=-BGC57] { - background-color: #5F00FF -} - -div.highlight .-Color[class*=-C58] { - color: #5F5F00 -} - -div.highlight .-Color[class*=-BGC58] { - background-color: #5F5F00 -} - -div.highlight .-Color[class*=-C59] { - color: #5F5F5F -} - -div.highlight .-Color[class*=-BGC59] { - background-color: #5F5F5F -} - -div.highlight .-Color[class*=-C60] { - color: #5F5F87 -} - -div.highlight .-Color[class*=-BGC60] { - background-color: #5F5F87 -} - -div.highlight .-Color[class*=-C61] { - color: #5F5FAF -} - -div.highlight .-Color[class*=-BGC61] { - background-color: #5F5FAF -} - -div.highlight .-Color[class*=-C62] { - color: #5F5FD7 -} - -div.highlight .-Color[class*=-BGC62] { - background-color: #5F5FD7 -} - -div.highlight .-Color[class*=-C63] { - color: #5F5FFF -} - -div.highlight .-Color[class*=-BGC63] { - background-color: #5F5FFF -} - -div.highlight .-Color[class*=-C64] { - color: #5F8700 -} - -div.highlight .-Color[class*=-BGC64] { - background-color: #5F8700 -} - -div.highlight .-Color[class*=-C65] { - color: #5F875F -} - -div.highlight .-Color[class*=-BGC65] { - background-color: #5F875F -} - -div.highlight .-Color[class*=-C66] { - color: #5F8787 -} - -div.highlight .-Color[class*=-BGC66] { - background-color: #5F8787 -} - -div.highlight .-Color[class*=-C67] { - color: #5F87AF -} - -div.highlight .-Color[class*=-BGC67] { - background-color: #5F87AF -} - -div.highlight .-Color[class*=-C68] { - color: #5F87D7 -} - -div.highlight .-Color[class*=-BGC68] { - background-color: #5F87D7 -} - -div.highlight .-Color[class*=-C69] { - color: #5F87FF -} - -div.highlight .-Color[class*=-BGC69] { - background-color: #5F87FF -} - -div.highlight .-Color[class*=-C70] { - color: #5FAF00 -} - -div.highlight .-Color[class*=-BGC70] { - background-color: #5FAF00 -} - -div.highlight .-Color[class*=-C71] { - color: #5FAF5F -} - -div.highlight .-Color[class*=-BGC71] { - background-color: #5FAF5F -} - -div.highlight .-Color[class*=-C72] { - color: #5FAF87 -} - -div.highlight .-Color[class*=-BGC72] { - background-color: #5FAF87 -} - -div.highlight .-Color[class*=-C73] { - color: #5FAFAF -} - -div.highlight .-Color[class*=-BGC73] { - background-color: #5FAFAF -} - -div.highlight .-Color[class*=-C74] { - color: #5FAFD7 -} - -div.highlight .-Color[class*=-BGC74] { - background-color: #5FAFD7 -} - -div.highlight .-Color[class*=-C75] { - color: #5FAFFF -} - -div.highlight .-Color[class*=-BGC75] { - background-color: #5FAFFF -} - -div.highlight .-Color[class*=-C76] { - color: #5FD700 -} - -div.highlight .-Color[class*=-BGC76] { - background-color: #5FD700 -} - -div.highlight .-Color[class*=-C77] { - color: #5FD75F -} - -div.highlight .-Color[class*=-BGC77] { - background-color: #5FD75F -} - -div.highlight .-Color[class*=-C78] { - color: #5FD787 -} - -div.highlight .-Color[class*=-BGC78] { - background-color: #5FD787 -} - -div.highlight .-Color[class*=-C79] { - color: #5FD7AF -} - -div.highlight .-Color[class*=-BGC79] { - background-color: #5FD7AF -} - -div.highlight .-Color[class*=-C80] { - color: #5FD7D7 -} - -div.highlight .-Color[class*=-BGC80] { - background-color: #5FD7D7 -} - -div.highlight .-Color[class*=-C81] { - color: #5FD7FF -} - -div.highlight .-Color[class*=-BGC81] { - background-color: #5FD7FF -} - -div.highlight .-Color[class*=-C82] { - color: #5FFF00 -} - -div.highlight .-Color[class*=-BGC82] { - background-color: #5FFF00 -} - -div.highlight .-Color[class*=-C83] { - color: #5FFF5F -} - -div.highlight .-Color[class*=-BGC83] { - background-color: #5FFF5F -} - -div.highlight .-Color[class*=-C84] { - color: #5FFF87 -} - -div.highlight .-Color[class*=-BGC84] { - background-color: #5FFF87 -} - -div.highlight .-Color[class*=-C85] { - color: #5FFFAF -} - -div.highlight .-Color[class*=-BGC85] { - background-color: #5FFFAF -} - -div.highlight .-Color[class*=-C86] { - color: #5FFFD7 -} - -div.highlight .-Color[class*=-BGC86] { - background-color: #5FFFD7 -} - -div.highlight .-Color[class*=-C87] { - color: #5FFFFF -} - -div.highlight .-Color[class*=-BGC87] { - background-color: #5FFFFF -} - -div.highlight .-Color[class*=-C88] { - color: #870000 -} - -div.highlight .-Color[class*=-BGC88] { - background-color: #870000 -} - -div.highlight .-Color[class*=-C89] { - color: #87005F -} - -div.highlight .-Color[class*=-BGC89] { - background-color: #87005F -} - -div.highlight .-Color[class*=-C90] { - color: #870087 -} - -div.highlight .-Color[class*=-BGC90] { - background-color: #870087 -} - -div.highlight .-Color[class*=-C91] { - color: #8700AF -} - -div.highlight .-Color[class*=-BGC91] { - background-color: #8700AF -} - -div.highlight .-Color[class*=-C92] { - color: #8700D7 -} - -div.highlight .-Color[class*=-BGC92] { - background-color: #8700D7 -} - -div.highlight .-Color[class*=-C93] { - color: #8700FF -} - -div.highlight .-Color[class*=-BGC93] { - background-color: #8700FF -} - -div.highlight .-Color[class*=-C94] { - color: #875F00 -} - -div.highlight .-Color[class*=-BGC94] { - background-color: #875F00 -} - -div.highlight .-Color[class*=-C95] { - color: #875F5F -} - -div.highlight .-Color[class*=-BGC95] { - background-color: #875F5F -} - -div.highlight .-Color[class*=-C96] { - color: #875F87 -} - -div.highlight .-Color[class*=-BGC96] { - background-color: #875F87 -} - -div.highlight .-Color[class*=-C97] { - color: #875FAF -} - -div.highlight .-Color[class*=-BGC97] { - background-color: #875FAF -} - -div.highlight .-Color[class*=-C98] { - color: #875FD7 -} - -div.highlight .-Color[class*=-BGC98] { - background-color: #875FD7 -} - -div.highlight .-Color[class*=-C99] { - color: #875FFF -} - -div.highlight .-Color[class*=-BGC99] { - background-color: #875FFF -} - -div.highlight .-Color[class*=-C100] { - color: #878700 -} - -div.highlight .-Color[class*=-BGC100] { - background-color: #878700 -} - -div.highlight .-Color[class*=-C101] { - color: #87875F -} - -div.highlight .-Color[class*=-BGC101] { - background-color: #87875F -} - -div.highlight .-Color[class*=-C102] { - color: #878787 -} - -div.highlight .-Color[class*=-BGC102] { - background-color: #878787 -} - -div.highlight .-Color[class*=-C103] { - color: #8787AF -} - -div.highlight .-Color[class*=-BGC103] { - background-color: #8787AF -} - -div.highlight .-Color[class*=-C104] { - color: #8787D7 -} - -div.highlight .-Color[class*=-BGC104] { - background-color: #8787D7 -} - -div.highlight .-Color[class*=-C105] { - color: #8787FF -} - -div.highlight .-Color[class*=-BGC105] { - background-color: #8787FF -} - -div.highlight .-Color[class*=-C106] { - color: #87AF00 -} - -div.highlight .-Color[class*=-BGC106] { - background-color: #87AF00 -} - -div.highlight .-Color[class*=-C107] { - color: #87AF5F -} - -div.highlight .-Color[class*=-BGC107] { - background-color: #87AF5F -} - -div.highlight .-Color[class*=-C108] { - color: #87AF87 -} - -div.highlight .-Color[class*=-BGC108] { - background-color: #87AF87 -} - -div.highlight .-Color[class*=-C109] { - color: #87AFAF -} - -div.highlight .-Color[class*=-BGC109] { - background-color: #87AFAF -} - -div.highlight .-Color[class*=-C110] { - color: #87AFD7 -} - -div.highlight .-Color[class*=-BGC110] { - background-color: #87AFD7 -} - -div.highlight .-Color[class*=-C111] { - color: #87AFFF -} - -div.highlight .-Color[class*=-BGC111] { - background-color: #87AFFF -} - -div.highlight .-Color[class*=-C112] { - color: #87D700 -} - -div.highlight .-Color[class*=-BGC112] { - background-color: #87D700 -} - -div.highlight .-Color[class*=-C113] { - color: #87D75F -} - -div.highlight .-Color[class*=-BGC113] { - background-color: #87D75F -} - -div.highlight .-Color[class*=-C114] { - color: #87D787 -} - -div.highlight .-Color[class*=-BGC114] { - background-color: #87D787 -} - -div.highlight .-Color[class*=-C115] { - color: #87D7AF -} - -div.highlight .-Color[class*=-BGC115] { - background-color: #87D7AF -} - -div.highlight .-Color[class*=-C116] { - color: #87D7D7 -} - -div.highlight .-Color[class*=-BGC116] { - background-color: #87D7D7 -} - -div.highlight .-Color[class*=-C117] { - color: #87D7FF -} - -div.highlight .-Color[class*=-BGC117] { - background-color: #87D7FF -} - -div.highlight .-Color[class*=-C118] { - color: #87FF00 -} - -div.highlight .-Color[class*=-BGC118] { - background-color: #87FF00 -} - -div.highlight .-Color[class*=-C119] { - color: #87FF5F -} - -div.highlight .-Color[class*=-BGC119] { - background-color: #87FF5F -} - -div.highlight .-Color[class*=-C120] { - color: #87FF87 -} - -div.highlight .-Color[class*=-BGC120] { - background-color: #87FF87 -} - -div.highlight .-Color[class*=-C121] { - color: #87FFAF -} - -div.highlight .-Color[class*=-BGC121] { - background-color: #87FFAF -} - -div.highlight .-Color[class*=-C122] { - color: #87FFD7 -} - -div.highlight .-Color[class*=-BGC122] { - background-color: #87FFD7 -} - -div.highlight .-Color[class*=-C123] { - color: #87FFFF -} - -div.highlight .-Color[class*=-BGC123] { - background-color: #87FFFF -} - -div.highlight .-Color[class*=-C124] { - color: #AF0000 -} - -div.highlight .-Color[class*=-BGC124] { - background-color: #AF0000 -} - -div.highlight .-Color[class*=-C125] { - color: #AF005F -} - -div.highlight .-Color[class*=-BGC125] { - background-color: #AF005F -} - -div.highlight .-Color[class*=-C126] { - color: #AF0087 -} - -div.highlight .-Color[class*=-BGC126] { - background-color: #AF0087 -} - -div.highlight .-Color[class*=-C127] { - color: #AF00AF -} - -div.highlight .-Color[class*=-BGC127] { - background-color: #AF00AF -} - -div.highlight .-Color[class*=-C128] { - color: #AF00D7 -} - -div.highlight .-Color[class*=-BGC128] { - background-color: #AF00D7 -} - -div.highlight .-Color[class*=-C129] { - color: #AF00FF -} - -div.highlight .-Color[class*=-BGC129] { - background-color: #AF00FF -} - -div.highlight .-Color[class*=-C130] { - color: #AF5F00 -} - -div.highlight .-Color[class*=-BGC130] { - background-color: #AF5F00 -} - -div.highlight .-Color[class*=-C131] { - color: #AF5F5F -} - -div.highlight .-Color[class*=-BGC131] { - background-color: #AF5F5F -} - -div.highlight .-Color[class*=-C132] { - color: #AF5F87 -} - -div.highlight .-Color[class*=-BGC132] { - background-color: #AF5F87 -} - -div.highlight .-Color[class*=-C133] { - color: #AF5FAF -} - -div.highlight .-Color[class*=-BGC133] { - background-color: #AF5FAF -} - -div.highlight .-Color[class*=-C134] { - color: #AF5FD7 -} - -div.highlight .-Color[class*=-BGC134] { - background-color: #AF5FD7 -} - -div.highlight .-Color[class*=-C135] { - color: #AF5FFF -} - -div.highlight .-Color[class*=-BGC135] { - background-color: #AF5FFF -} - -div.highlight .-Color[class*=-C136] { - color: #AF8700 -} - -div.highlight .-Color[class*=-BGC136] { - background-color: #AF8700 -} - -div.highlight .-Color[class*=-C137] { - color: #AF875F -} - -div.highlight .-Color[class*=-BGC137] { - background-color: #AF875F -} - -div.highlight .-Color[class*=-C138] { - color: #AF8787 -} - -div.highlight .-Color[class*=-BGC138] { - background-color: #AF8787 -} - -div.highlight .-Color[class*=-C139] { - color: #AF87AF -} - -div.highlight .-Color[class*=-BGC139] { - background-color: #AF87AF -} - -div.highlight .-Color[class*=-C140] { - color: #AF87D7 -} - -div.highlight .-Color[class*=-BGC140] { - background-color: #AF87D7 -} - -div.highlight .-Color[class*=-C141] { - color: #AF87FF -} - -div.highlight .-Color[class*=-BGC141] { - background-color: #AF87FF -} - -div.highlight .-Color[class*=-C142] { - color: #AFAF00 -} - -div.highlight .-Color[class*=-BGC142] { - background-color: #AFAF00 -} - -div.highlight .-Color[class*=-C143] { - color: #AFAF5F -} - -div.highlight .-Color[class*=-BGC143] { - background-color: #AFAF5F -} - -div.highlight .-Color[class*=-C144] { - color: #AFAF87 -} - -div.highlight .-Color[class*=-BGC144] { - background-color: #AFAF87 -} - -div.highlight .-Color[class*=-C145] { - color: #AFAFAF -} - -div.highlight .-Color[class*=-BGC145] { - background-color: #AFAFAF -} - -div.highlight .-Color[class*=-C146] { - color: #AFAFD7 -} - -div.highlight .-Color[class*=-BGC146] { - background-color: #AFAFD7 -} - -div.highlight .-Color[class*=-C147] { - color: #AFAFFF -} - -div.highlight .-Color[class*=-BGC147] { - background-color: #AFAFFF -} - -div.highlight .-Color[class*=-C148] { - color: #AFD700 -} - -div.highlight .-Color[class*=-BGC148] { - background-color: #AFD700 -} - -div.highlight .-Color[class*=-C149] { - color: #AFD75F -} - -div.highlight .-Color[class*=-BGC149] { - background-color: #AFD75F -} - -div.highlight .-Color[class*=-C150] { - color: #AFD787 -} - -div.highlight .-Color[class*=-BGC150] { - background-color: #AFD787 -} - -div.highlight .-Color[class*=-C151] { - color: #AFD7AF -} - -div.highlight .-Color[class*=-BGC151] { - background-color: #AFD7AF -} - -div.highlight .-Color[class*=-C152] { - color: #AFD7D7 -} - -div.highlight .-Color[class*=-BGC152] { - background-color: #AFD7D7 -} - -div.highlight .-Color[class*=-C153] { - color: #AFD7FF -} - -div.highlight .-Color[class*=-BGC153] { - background-color: #AFD7FF -} - -div.highlight .-Color[class*=-C154] { - color: #AFFF00 -} - -div.highlight .-Color[class*=-BGC154] { - background-color: #AFFF00 -} - -div.highlight .-Color[class*=-C155] { - color: #AFFF5F -} - -div.highlight .-Color[class*=-BGC155] { - background-color: #AFFF5F -} - -div.highlight .-Color[class*=-C156] { - color: #AFFF87 -} - -div.highlight .-Color[class*=-BGC156] { - background-color: #AFFF87 -} - -div.highlight .-Color[class*=-C157] { - color: #AFFFAF -} - -div.highlight .-Color[class*=-BGC157] { - background-color: #AFFFAF -} - -div.highlight .-Color[class*=-C158] { - color: #AFFFD7 -} - -div.highlight .-Color[class*=-BGC158] { - background-color: #AFFFD7 -} - -div.highlight .-Color[class*=-C159] { - color: #AFFFFF -} - -div.highlight .-Color[class*=-BGC159] { - background-color: #AFFFFF -} - -div.highlight .-Color[class*=-C160] { - color: #D70000 -} - -div.highlight .-Color[class*=-BGC160] { - background-color: #D70000 -} - -div.highlight .-Color[class*=-C161] { - color: #D7005F -} - -div.highlight .-Color[class*=-BGC161] { - background-color: #D7005F -} - -div.highlight .-Color[class*=-C162] { - color: #D70087 -} - -div.highlight .-Color[class*=-BGC162] { - background-color: #D70087 -} - -div.highlight .-Color[class*=-C163] { - color: #D700AF -} - -div.highlight .-Color[class*=-BGC163] { - background-color: #D700AF -} - -div.highlight .-Color[class*=-C164] { - color: #D700D7 -} - -div.highlight .-Color[class*=-BGC164] { - background-color: #D700D7 -} - -div.highlight .-Color[class*=-C165] { - color: #D700FF -} - -div.highlight .-Color[class*=-BGC165] { - background-color: #D700FF -} - -div.highlight .-Color[class*=-C166] { - color: #D75F00 -} - -div.highlight .-Color[class*=-BGC166] { - background-color: #D75F00 -} - -div.highlight .-Color[class*=-C167] { - color: #D75F5F -} - -div.highlight .-Color[class*=-BGC167] { - background-color: #D75F5F -} - -div.highlight .-Color[class*=-C168] { - color: #D75F87 -} - -div.highlight .-Color[class*=-BGC168] { - background-color: #D75F87 -} - -div.highlight .-Color[class*=-C169] { - color: #D75FAF -} - -div.highlight .-Color[class*=-BGC169] { - background-color: #D75FAF -} - -div.highlight .-Color[class*=-C170] { - color: #D75FD7 -} - -div.highlight .-Color[class*=-BGC170] { - background-color: #D75FD7 -} - -div.highlight .-Color[class*=-C171] { - color: #D75FFF -} - -div.highlight .-Color[class*=-BGC171] { - background-color: #D75FFF -} - -div.highlight .-Color[class*=-C172] { - color: #D78700 -} - -div.highlight .-Color[class*=-BGC172] { - background-color: #D78700 -} - -div.highlight .-Color[class*=-C173] { - color: #D7875F -} - -div.highlight .-Color[class*=-BGC173] { - background-color: #D7875F -} - -div.highlight .-Color[class*=-C174] { - color: #D78787 -} - -div.highlight .-Color[class*=-BGC174] { - background-color: #D78787 -} - -div.highlight .-Color[class*=-C175] { - color: #D787AF -} - -div.highlight .-Color[class*=-BGC175] { - background-color: #D787AF -} - -div.highlight .-Color[class*=-C176] { - color: #D787D7 -} - -div.highlight .-Color[class*=-BGC176] { - background-color: #D787D7 -} - -div.highlight .-Color[class*=-C177] { - color: #D787FF -} - -div.highlight .-Color[class*=-BGC177] { - background-color: #D787FF -} - -div.highlight .-Color[class*=-C178] { - color: #D7AF00 -} - -div.highlight .-Color[class*=-BGC178] { - background-color: #D7AF00 -} - -div.highlight .-Color[class*=-C179] { - color: #D7AF5F -} - -div.highlight .-Color[class*=-BGC179] { - background-color: #D7AF5F -} - -div.highlight .-Color[class*=-C180] { - color: #D7AF87 -} - -div.highlight .-Color[class*=-BGC180] { - background-color: #D7AF87 -} - -div.highlight .-Color[class*=-C181] { - color: #D7AFAF -} - -div.highlight .-Color[class*=-BGC181] { - background-color: #D7AFAF -} - -div.highlight .-Color[class*=-C182] { - color: #D7AFD7 -} - -div.highlight .-Color[class*=-BGC182] { - background-color: #D7AFD7 -} - -div.highlight .-Color[class*=-C183] { - color: #D7AFFF -} - -div.highlight .-Color[class*=-BGC183] { - background-color: #D7AFFF -} - -div.highlight .-Color[class*=-C184] { - color: #D7D700 -} - -div.highlight .-Color[class*=-BGC184] { - background-color: #D7D700 -} - -div.highlight .-Color[class*=-C185] { - color: #D7D75F -} - -div.highlight .-Color[class*=-BGC185] { - background-color: #D7D75F -} - -div.highlight .-Color[class*=-C186] { - color: #D7D787 -} - -div.highlight .-Color[class*=-BGC186] { - background-color: #D7D787 -} - -div.highlight .-Color[class*=-C187] { - color: #D7D7AF -} - -div.highlight .-Color[class*=-BGC187] { - background-color: #D7D7AF -} - -div.highlight .-Color[class*=-C188] { - color: #D7D7D7 -} - -div.highlight .-Color[class*=-BGC188] { - background-color: #D7D7D7 -} - -div.highlight .-Color[class*=-C189] { - color: #D7D7FF -} - -div.highlight .-Color[class*=-BGC189] { - background-color: #D7D7FF -} - -div.highlight .-Color[class*=-C190] { - color: #D7FF00 -} - -div.highlight .-Color[class*=-BGC190] { - background-color: #D7FF00 -} - -div.highlight .-Color[class*=-C191] { - color: #D7FF5F -} - -div.highlight .-Color[class*=-BGC191] { - background-color: #D7FF5F -} - -div.highlight .-Color[class*=-C192] { - color: #D7FF87 -} - -div.highlight .-Color[class*=-BGC192] { - background-color: #D7FF87 -} - -div.highlight .-Color[class*=-C193] { - color: #D7FFAF -} - -div.highlight .-Color[class*=-BGC193] { - background-color: #D7FFAF -} - -div.highlight .-Color[class*=-C194] { - color: #D7FFD7 -} - -div.highlight .-Color[class*=-BGC194] { - background-color: #D7FFD7 -} - -div.highlight .-Color[class*=-C195] { - color: #D7FFFF -} - -div.highlight .-Color[class*=-BGC195] { - background-color: #D7FFFF -} - -div.highlight .-Color[class*=-C196] { - color: #FF0000 -} - -div.highlight .-Color[class*=-BGC196] { - background-color: #FF0000 -} - -div.highlight .-Color[class*=-C197] { - color: #FF005F -} - -div.highlight .-Color[class*=-BGC197] { - background-color: #FF005F -} - -div.highlight .-Color[class*=-C198] { - color: #FF0087 -} - -div.highlight .-Color[class*=-BGC198] { - background-color: #FF0087 -} - -div.highlight .-Color[class*=-C199] { - color: #FF00AF -} - -div.highlight .-Color[class*=-BGC199] { - background-color: #FF00AF -} - -div.highlight .-Color[class*=-C200] { - color: #FF00D7 -} - -div.highlight .-Color[class*=-BGC200] { - background-color: #FF00D7 -} - -div.highlight .-Color[class*=-C201] { - color: #FF00FF -} - -div.highlight .-Color[class*=-BGC201] { - background-color: #FF00FF -} - -div.highlight .-Color[class*=-C202] { - color: #FF5F00 -} - -div.highlight .-Color[class*=-BGC202] { - background-color: #FF5F00 -} - -div.highlight .-Color[class*=-C203] { - color: #FF5F5F -} - -div.highlight .-Color[class*=-BGC203] { - background-color: #FF5F5F -} - -div.highlight .-Color[class*=-C204] { - color: #FF5F87 -} - -div.highlight .-Color[class*=-BGC204] { - background-color: #FF5F87 -} - -div.highlight .-Color[class*=-C205] { - color: #FF5FAF -} - -div.highlight .-Color[class*=-BGC205] { - background-color: #FF5FAF -} - -div.highlight .-Color[class*=-C206] { - color: #FF5FD7 -} - -div.highlight .-Color[class*=-BGC206] { - background-color: #FF5FD7 -} - -div.highlight .-Color[class*=-C207] { - color: #FF5FFF -} - -div.highlight .-Color[class*=-BGC207] { - background-color: #FF5FFF -} - -div.highlight .-Color[class*=-C208] { - color: #FF8700 -} - -div.highlight .-Color[class*=-BGC208] { - background-color: #FF8700 -} - -div.highlight .-Color[class*=-C209] { - color: #FF875F -} - -div.highlight .-Color[class*=-BGC209] { - background-color: #FF875F -} - -div.highlight .-Color[class*=-C210] { - color: #FF8787 -} - -div.highlight .-Color[class*=-BGC210] { - background-color: #FF8787 -} - -div.highlight .-Color[class*=-C211] { - color: #FF87AF -} - -div.highlight .-Color[class*=-BGC211] { - background-color: #FF87AF -} - -div.highlight .-Color[class*=-C212] { - color: #FF87D7 -} - -div.highlight .-Color[class*=-BGC212] { - background-color: #FF87D7 -} - -div.highlight .-Color[class*=-C213] { - color: #FF87FF -} - -div.highlight .-Color[class*=-BGC213] { - background-color: #FF87FF -} - -div.highlight .-Color[class*=-C214] { - color: #FFAF00 -} - -div.highlight .-Color[class*=-BGC214] { - background-color: #FFAF00 -} - -div.highlight .-Color[class*=-C215] { - color: #FFAF5F -} - -div.highlight .-Color[class*=-BGC215] { - background-color: #FFAF5F -} - -div.highlight .-Color[class*=-C216] { - color: #FFAF87 -} - -div.highlight .-Color[class*=-BGC216] { - background-color: #FFAF87 -} - -div.highlight .-Color[class*=-C217] { - color: #FFAFAF -} - -div.highlight .-Color[class*=-BGC217] { - background-color: #FFAFAF -} - -div.highlight .-Color[class*=-C218] { - color: #FFAFD7 -} - -div.highlight .-Color[class*=-BGC218] { - background-color: #FFAFD7 -} - -div.highlight .-Color[class*=-C219] { - color: #FFAFFF -} - -div.highlight .-Color[class*=-BGC219] { - background-color: #FFAFFF -} - -div.highlight .-Color[class*=-C220] { - color: #FFD700 -} - -div.highlight .-Color[class*=-BGC220] { - background-color: #FFD700 -} - -div.highlight .-Color[class*=-C221] { - color: #FFD75F -} - -div.highlight .-Color[class*=-BGC221] { - background-color: #FFD75F -} - -div.highlight .-Color[class*=-C222] { - color: #FFD787 -} - -div.highlight .-Color[class*=-BGC222] { - background-color: #FFD787 -} - -div.highlight .-Color[class*=-C223] { - color: #FFD7AF -} - -div.highlight .-Color[class*=-BGC223] { - background-color: #FFD7AF -} - -div.highlight .-Color[class*=-C224] { - color: #FFD7D7 -} - -div.highlight .-Color[class*=-BGC224] { - background-color: #FFD7D7 -} - -div.highlight .-Color[class*=-C225] { - color: #FFD7FF -} - -div.highlight .-Color[class*=-BGC225] { - background-color: #FFD7FF -} - -div.highlight .-Color[class*=-C226] { - color: #FFFF00 -} - -div.highlight .-Color[class*=-BGC226] { - background-color: #FFFF00 -} - -div.highlight .-Color[class*=-C227] { - color: #FFFF5F -} - -div.highlight .-Color[class*=-BGC227] { - background-color: #FFFF5F -} - -div.highlight .-Color[class*=-C228] { - color: #FFFF87 -} - -div.highlight .-Color[class*=-BGC228] { - background-color: #FFFF87 -} - -div.highlight .-Color[class*=-C229] { - color: #FFFFAF -} - -div.highlight .-Color[class*=-BGC229] { - background-color: #FFFFAF -} - -div.highlight .-Color[class*=-C230] { - color: #FFFFD7 -} - -div.highlight .-Color[class*=-BGC230] { - background-color: #FFFFD7 -} - -div.highlight .-Color[class*=-C231] { - color: #FFFFFF -} - -div.highlight .-Color[class*=-BGC231] { - background-color: #FFFFFF -} - -div.highlight .-Color[class*=-C232] { - color: #080808 -} - -div.highlight .-Color[class*=-BGC232] { - background-color: #080808 -} - -div.highlight .-Color[class*=-C233] { - color: #121212 -} - -div.highlight .-Color[class*=-BGC233] { - background-color: #121212 -} - -div.highlight .-Color[class*=-C234] { - color: #1C1C1C -} - -div.highlight .-Color[class*=-BGC234] { - background-color: #1C1C1C -} - -div.highlight .-Color[class*=-C235] { - color: #262626 -} - -div.highlight .-Color[class*=-BGC235] { - background-color: #262626 -} - -div.highlight .-Color[class*=-C236] { - color: #303030 -} - -div.highlight .-Color[class*=-BGC236] { - background-color: #303030 -} - -div.highlight .-Color[class*=-C237] { - color: #3A3A3A -} - -div.highlight .-Color[class*=-BGC237] { - background-color: #3A3A3A -} - -div.highlight .-Color[class*=-C238] { - color: #444444 -} - -div.highlight .-Color[class*=-BGC238] { - background-color: #444444 -} - -div.highlight .-Color[class*=-C239] { - color: #4E4E4E -} - -div.highlight .-Color[class*=-BGC239] { - background-color: #4E4E4E -} - -div.highlight .-Color[class*=-C240] { - color: #585858 -} - -div.highlight .-Color[class*=-BGC240] { - background-color: #585858 -} - -div.highlight .-Color[class*=-C241] { - color: #626262 -} - -div.highlight .-Color[class*=-BGC241] { - background-color: #626262 -} - -div.highlight .-Color[class*=-C242] { - color: #6C6C6C -} - -div.highlight .-Color[class*=-BGC242] { - background-color: #6C6C6C -} - -div.highlight .-Color[class*=-C243] { - color: #767676 -} - -div.highlight .-Color[class*=-BGC243] { - background-color: #767676 -} - -div.highlight .-Color[class*=-C244] { - color: #808080 -} - -div.highlight .-Color[class*=-BGC244] { - background-color: #808080 -} - -div.highlight .-Color[class*=-C245] { - color: #8A8A8A -} - -div.highlight .-Color[class*=-BGC245] { - background-color: #8A8A8A -} - -div.highlight .-Color[class*=-C246] { - color: #949494 -} - -div.highlight .-Color[class*=-BGC246] { - background-color: #949494 -} - -div.highlight .-Color[class*=-C247] { - color: #9E9E9E -} - -div.highlight .-Color[class*=-BGC247] { - background-color: #9E9E9E -} - -div.highlight .-Color[class*=-C248] { - color: #A8A8A8 -} - -div.highlight .-Color[class*=-BGC248] { - background-color: #A8A8A8 -} - -div.highlight .-Color[class*=-C249] { - color: #B2B2B2 -} - -div.highlight .-Color[class*=-BGC249] { - background-color: #B2B2B2 -} - -div.highlight .-Color[class*=-C250] { - color: #BCBCBC -} - -div.highlight .-Color[class*=-BGC250] { - background-color: #BCBCBC -} - -div.highlight .-Color[class*=-C251] { - color: #C6C6C6 -} - -div.highlight .-Color[class*=-BGC251] { - background-color: #C6C6C6 -} - -div.highlight .-Color[class*=-C252] { - color: #D0D0D0 -} - -div.highlight .-Color[class*=-BGC252] { - background-color: #D0D0D0 -} - -div.highlight .-Color[class*=-C253] { - color: #DADADA -} - -div.highlight .-Color[class*=-BGC253] { - background-color: #DADADA -} - -div.highlight .-Color[class*=-C254] { - color: #E4E4E4 -} - -div.highlight .-Color[class*=-BGC254] { - background-color: #E4E4E4 -} - -div.highlight .-Color[class*=-C255] { - color: #EEEEEE -} - -div.highlight .-Color[class*=-BGC255] { - background-color: #EEEEEE -} diff --git a/docs/_static/plus.png b/docs/_static/plus.png deleted file mode 100644 index 7107cec..0000000 Binary files a/docs/_static/plus.png and /dev/null differ diff --git a/docs/_static/pygments.css b/docs/_static/pygments.css deleted file mode 100644 index c2e07c7..0000000 --- a/docs/_static/pygments.css +++ /dev/null @@ -1,258 +0,0 @@ -.highlight pre { line-height: 125%; } -.highlight td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } -.highlight span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } -.highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } -.highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } -.highlight .hll { background-color: #ffffcc } -.highlight { background: #f8f8f8; } -.highlight .c { color: #8f5902; font-style: italic } /* Comment */ -.highlight .err { color: #a40000; border: 1px solid #ef2929 } /* Error */ -.highlight .g { color: #000000 } /* Generic */ -.highlight .k { color: #204a87; font-weight: bold } /* Keyword */ -.highlight .l { color: #000000 } /* Literal */ -.highlight .n { color: #000000 } /* Name */ -.highlight .o { color: #ce5c00; font-weight: bold } /* Operator */ -.highlight .x { color: #000000 } /* Other */ -.highlight .p { color: #000000; font-weight: bold } /* Punctuation */ -.highlight .ch { color: #8f5902; font-style: italic } /* Comment.Hashbang */ -.highlight .cm { color: #8f5902; font-style: italic } /* Comment.Multiline */ -.highlight .cp { color: #8f5902; font-style: italic } /* Comment.Preproc */ -.highlight .cpf { color: #8f5902; font-style: italic } /* Comment.PreprocFile */ -.highlight .c1 { color: #8f5902; font-style: italic } /* Comment.Single */ -.highlight .cs { color: #8f5902; font-style: italic } /* Comment.Special */ -.highlight .gd { color: #a40000 } /* Generic.Deleted */ -.highlight .ge { color: #000000; font-style: italic } /* Generic.Emph */ -.highlight .ges { color: #000000; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ -.highlight .gr { color: #ef2929 } /* Generic.Error */ -.highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ -.highlight .gi { color: #00A000 } /* Generic.Inserted */ -.highlight .go { color: #000000; font-style: italic } /* Generic.Output */ -.highlight .gp { color: #8f5902 } /* Generic.Prompt */ -.highlight .gs { color: #000000; font-weight: bold } /* Generic.Strong */ -.highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ -.highlight .gt { color: #a40000; font-weight: bold } /* Generic.Traceback */ -.highlight .kc { color: #204a87; font-weight: bold } /* Keyword.Constant */ -.highlight .kd { color: #204a87; font-weight: bold } /* Keyword.Declaration */ -.highlight .kn { color: #204a87; font-weight: bold } /* Keyword.Namespace */ -.highlight .kp { color: #204a87; font-weight: bold } /* Keyword.Pseudo */ -.highlight .kr { color: #204a87; font-weight: bold } /* Keyword.Reserved */ -.highlight .kt { color: #204a87; font-weight: bold } /* Keyword.Type */ -.highlight .ld { color: #000000 } /* Literal.Date */ -.highlight .m { color: #0000cf; font-weight: bold } /* Literal.Number */ -.highlight .s { color: #4e9a06 } /* Literal.String */ -.highlight .na { color: #c4a000 } /* Name.Attribute */ -.highlight .nb { color: #204a87 } /* Name.Builtin */ -.highlight .nc { color: #000000 } /* Name.Class */ -.highlight .no { color: #000000 } /* Name.Constant */ -.highlight .nd { color: #5c35cc; font-weight: bold } /* Name.Decorator */ -.highlight .ni { color: #ce5c00 } /* Name.Entity */ -.highlight .ne { color: #cc0000; font-weight: bold } /* Name.Exception */ -.highlight .nf { color: #000000 } /* Name.Function */ -.highlight .nl { color: #f57900 } /* Name.Label */ -.highlight .nn { color: #000000 } /* Name.Namespace */ -.highlight .nx { color: #000000 } /* Name.Other */ -.highlight .py { color: #000000 } /* Name.Property */ -.highlight .nt { color: #204a87; font-weight: bold } /* Name.Tag */ -.highlight .nv { color: #000000 } /* Name.Variable */ -.highlight .ow { color: #204a87; font-weight: bold } /* Operator.Word */ -.highlight .pm { color: #000000; font-weight: bold } /* Punctuation.Marker */ -.highlight .w { color: #f8f8f8 } /* Text.Whitespace */ -.highlight .mb { color: #0000cf; font-weight: bold } /* Literal.Number.Bin */ -.highlight .mf { color: #0000cf; font-weight: bold } /* Literal.Number.Float */ -.highlight .mh { color: #0000cf; font-weight: bold } /* Literal.Number.Hex */ -.highlight .mi { color: #0000cf; font-weight: bold } /* Literal.Number.Integer */ -.highlight .mo { color: #0000cf; font-weight: bold } /* Literal.Number.Oct */ -.highlight .sa { color: #4e9a06 } /* Literal.String.Affix */ -.highlight .sb { color: #4e9a06 } /* Literal.String.Backtick */ -.highlight .sc { color: #4e9a06 } /* Literal.String.Char */ -.highlight .dl { color: #4e9a06 } /* Literal.String.Delimiter */ -.highlight .sd { color: #8f5902; font-style: italic } /* Literal.String.Doc */ -.highlight .s2 { color: #4e9a06 } /* Literal.String.Double */ -.highlight .se { color: #4e9a06 } /* Literal.String.Escape */ -.highlight .sh { color: #4e9a06 } /* Literal.String.Heredoc */ -.highlight .si { color: #4e9a06 } /* Literal.String.Interpol */ -.highlight .sx { color: #4e9a06 } /* Literal.String.Other */ -.highlight .sr { color: #4e9a06 } /* Literal.String.Regex */ -.highlight .s1 { color: #4e9a06 } /* Literal.String.Single */ -.highlight .ss { color: #4e9a06 } /* Literal.String.Symbol */ -.highlight .bp { color: #3465a4 } /* Name.Builtin.Pseudo */ -.highlight .fm { color: #000000 } /* Name.Function.Magic */ -.highlight .vc { color: #000000 } /* Name.Variable.Class */ -.highlight .vg { color: #000000 } /* Name.Variable.Global */ -.highlight .vi { color: #000000 } /* Name.Variable.Instance */ -.highlight .vm { color: #000000 } /* Name.Variable.Magic */ -.highlight .il { color: #0000cf; font-weight: bold } /* Literal.Number.Integer.Long */ -@media not print { -body[data-theme="dark"] .highlight pre { line-height: 125%; } -body[data-theme="dark"] .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } -body[data-theme="dark"] .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } -body[data-theme="dark"] .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } -body[data-theme="dark"] .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } -body[data-theme="dark"] .highlight .hll { background-color: #404040 } -body[data-theme="dark"] .highlight { background: #202020; color: #d0d0d0 } -body[data-theme="dark"] .highlight .c { color: #ababab; font-style: italic } /* Comment */ -body[data-theme="dark"] .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ -body[data-theme="dark"] .highlight .esc { color: #d0d0d0 } /* Escape */ -body[data-theme="dark"] .highlight .g { color: #d0d0d0 } /* Generic */ -body[data-theme="dark"] .highlight .k { color: #6ebf26; font-weight: bold } /* Keyword */ -body[data-theme="dark"] .highlight .l { color: #d0d0d0 } /* Literal */ -body[data-theme="dark"] .highlight .n { color: #d0d0d0 } /* Name */ -body[data-theme="dark"] .highlight .o { color: #d0d0d0 } /* Operator */ -body[data-theme="dark"] .highlight .x { color: #d0d0d0 } /* Other */ -body[data-theme="dark"] .highlight .p { color: #d0d0d0 } /* Punctuation */ -body[data-theme="dark"] .highlight .ch { color: #ababab; font-style: italic } /* Comment.Hashbang */ -body[data-theme="dark"] .highlight .cm { color: #ababab; font-style: italic } /* Comment.Multiline */ -body[data-theme="dark"] .highlight .cp { color: #ff3a3a; font-weight: bold } /* Comment.Preproc */ -body[data-theme="dark"] .highlight .cpf { color: #ababab; font-style: italic } /* Comment.PreprocFile */ -body[data-theme="dark"] .highlight .c1 { color: #ababab; font-style: italic } /* Comment.Single */ -body[data-theme="dark"] .highlight .cs { color: #e50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ -body[data-theme="dark"] .highlight .gd { color: #d22323 } /* Generic.Deleted */ -body[data-theme="dark"] .highlight .ge { color: #d0d0d0; font-style: italic } /* Generic.Emph */ -body[data-theme="dark"] .highlight .ges { color: #d0d0d0; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ -body[data-theme="dark"] .highlight .gr { color: #d22323 } /* Generic.Error */ -body[data-theme="dark"] .highlight .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */ -body[data-theme="dark"] .highlight .gi { color: #589819 } /* Generic.Inserted */ -body[data-theme="dark"] .highlight .go { color: #cccccc } /* Generic.Output */ -body[data-theme="dark"] .highlight .gp { color: #aaaaaa } /* Generic.Prompt */ -body[data-theme="dark"] .highlight .gs { color: #d0d0d0; font-weight: bold } /* Generic.Strong */ -body[data-theme="dark"] .highlight .gu { color: #ffffff; text-decoration: underline } /* Generic.Subheading */ -body[data-theme="dark"] .highlight .gt { color: #d22323 } /* Generic.Traceback */ -body[data-theme="dark"] .highlight .kc { color: #6ebf26; font-weight: bold } /* Keyword.Constant */ -body[data-theme="dark"] .highlight .kd { color: #6ebf26; font-weight: bold } /* Keyword.Declaration */ -body[data-theme="dark"] .highlight .kn { color: #6ebf26; font-weight: bold } /* Keyword.Namespace */ -body[data-theme="dark"] .highlight .kp { color: #6ebf26 } /* Keyword.Pseudo */ -body[data-theme="dark"] .highlight .kr { color: #6ebf26; font-weight: bold } /* Keyword.Reserved */ -body[data-theme="dark"] .highlight .kt { color: #6ebf26; font-weight: bold } /* Keyword.Type */ -body[data-theme="dark"] .highlight .ld { color: #d0d0d0 } /* Literal.Date */ -body[data-theme="dark"] .highlight .m { color: #51b2fd } /* Literal.Number */ -body[data-theme="dark"] .highlight .s { color: #ed9d13 } /* Literal.String */ -body[data-theme="dark"] .highlight .na { color: #bbbbbb } /* Name.Attribute */ -body[data-theme="dark"] .highlight .nb { color: #2fbccd } /* Name.Builtin */ -body[data-theme="dark"] .highlight .nc { color: #71adff; text-decoration: underline } /* Name.Class */ -body[data-theme="dark"] .highlight .no { color: #40ffff } /* Name.Constant */ -body[data-theme="dark"] .highlight .nd { color: #ffa500 } /* Name.Decorator */ -body[data-theme="dark"] .highlight .ni { color: #d0d0d0 } /* Name.Entity */ -body[data-theme="dark"] .highlight .ne { color: #bbbbbb } /* Name.Exception */ -body[data-theme="dark"] .highlight .nf { color: #71adff } /* Name.Function */ -body[data-theme="dark"] .highlight .nl { color: #d0d0d0 } /* Name.Label */ -body[data-theme="dark"] .highlight .nn { color: #71adff; text-decoration: underline } /* Name.Namespace */ -body[data-theme="dark"] .highlight .nx { color: #d0d0d0 } /* Name.Other */ -body[data-theme="dark"] .highlight .py { color: #d0d0d0 } /* Name.Property */ -body[data-theme="dark"] .highlight .nt { color: #6ebf26; font-weight: bold } /* Name.Tag */ -body[data-theme="dark"] .highlight .nv { color: #40ffff } /* Name.Variable */ -body[data-theme="dark"] .highlight .ow { color: #6ebf26; font-weight: bold } /* Operator.Word */ -body[data-theme="dark"] .highlight .pm { color: #d0d0d0 } /* Punctuation.Marker */ -body[data-theme="dark"] .highlight .w { color: #666666 } /* Text.Whitespace */ -body[data-theme="dark"] .highlight .mb { color: #51b2fd } /* Literal.Number.Bin */ -body[data-theme="dark"] .highlight .mf { color: #51b2fd } /* Literal.Number.Float */ -body[data-theme="dark"] .highlight .mh { color: #51b2fd } /* Literal.Number.Hex */ -body[data-theme="dark"] .highlight .mi { color: #51b2fd } /* Literal.Number.Integer */ -body[data-theme="dark"] .highlight .mo { color: #51b2fd } /* Literal.Number.Oct */ -body[data-theme="dark"] .highlight .sa { color: #ed9d13 } /* Literal.String.Affix */ -body[data-theme="dark"] .highlight .sb { color: #ed9d13 } /* Literal.String.Backtick */ -body[data-theme="dark"] .highlight .sc { color: #ed9d13 } /* Literal.String.Char */ -body[data-theme="dark"] .highlight .dl { color: #ed9d13 } /* Literal.String.Delimiter */ -body[data-theme="dark"] .highlight .sd { color: #ed9d13 } /* Literal.String.Doc */ -body[data-theme="dark"] .highlight .s2 { color: #ed9d13 } /* Literal.String.Double */ -body[data-theme="dark"] .highlight .se { color: #ed9d13 } /* Literal.String.Escape */ -body[data-theme="dark"] .highlight .sh { color: #ed9d13 } /* Literal.String.Heredoc */ -body[data-theme="dark"] .highlight .si { color: #ed9d13 } /* Literal.String.Interpol */ -body[data-theme="dark"] .highlight .sx { color: #ffa500 } /* Literal.String.Other */ -body[data-theme="dark"] .highlight .sr { color: #ed9d13 } /* Literal.String.Regex */ -body[data-theme="dark"] .highlight .s1 { color: #ed9d13 } /* Literal.String.Single */ -body[data-theme="dark"] .highlight .ss { color: #ed9d13 } /* Literal.String.Symbol */ -body[data-theme="dark"] .highlight .bp { color: #2fbccd } /* Name.Builtin.Pseudo */ -body[data-theme="dark"] .highlight .fm { color: #71adff } /* Name.Function.Magic */ -body[data-theme="dark"] .highlight .vc { color: #40ffff } /* Name.Variable.Class */ -body[data-theme="dark"] .highlight .vg { color: #40ffff } /* Name.Variable.Global */ -body[data-theme="dark"] .highlight .vi { color: #40ffff } /* Name.Variable.Instance */ -body[data-theme="dark"] .highlight .vm { color: #40ffff } /* Name.Variable.Magic */ -body[data-theme="dark"] .highlight .il { color: #51b2fd } /* Literal.Number.Integer.Long */ -@media (prefers-color-scheme: dark) { -body:not([data-theme="light"]) .highlight pre { line-height: 125%; } -body:not([data-theme="light"]) .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } -body:not([data-theme="light"]) .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } -body:not([data-theme="light"]) .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } -body:not([data-theme="light"]) .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } -body:not([data-theme="light"]) .highlight .hll { background-color: #404040 } -body:not([data-theme="light"]) .highlight { background: #202020; color: #d0d0d0 } -body:not([data-theme="light"]) .highlight .c { color: #ababab; font-style: italic } /* Comment */ -body:not([data-theme="light"]) .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ -body:not([data-theme="light"]) .highlight .esc { color: #d0d0d0 } /* Escape */ -body:not([data-theme="light"]) .highlight .g { color: #d0d0d0 } /* Generic */ -body:not([data-theme="light"]) .highlight .k { color: #6ebf26; font-weight: bold } /* Keyword */ -body:not([data-theme="light"]) .highlight .l { color: #d0d0d0 } /* Literal */ -body:not([data-theme="light"]) .highlight .n { color: #d0d0d0 } /* Name */ -body:not([data-theme="light"]) .highlight .o { color: #d0d0d0 } /* Operator */ -body:not([data-theme="light"]) .highlight .x { color: #d0d0d0 } /* Other */ -body:not([data-theme="light"]) .highlight .p { color: #d0d0d0 } /* Punctuation */ -body:not([data-theme="light"]) .highlight .ch { color: #ababab; font-style: italic } /* Comment.Hashbang */ -body:not([data-theme="light"]) .highlight .cm { color: #ababab; font-style: italic } /* Comment.Multiline */ -body:not([data-theme="light"]) .highlight .cp { color: #ff3a3a; font-weight: bold } /* Comment.Preproc */ -body:not([data-theme="light"]) .highlight .cpf { color: #ababab; font-style: italic } /* Comment.PreprocFile */ -body:not([data-theme="light"]) .highlight .c1 { color: #ababab; font-style: italic } /* Comment.Single */ -body:not([data-theme="light"]) .highlight .cs { color: #e50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ -body:not([data-theme="light"]) .highlight .gd { color: #d22323 } /* Generic.Deleted */ -body:not([data-theme="light"]) .highlight .ge { color: #d0d0d0; font-style: italic } /* Generic.Emph */ -body:not([data-theme="light"]) .highlight .ges { color: #d0d0d0; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ -body:not([data-theme="light"]) .highlight .gr { color: #d22323 } /* Generic.Error */ -body:not([data-theme="light"]) .highlight .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */ -body:not([data-theme="light"]) .highlight .gi { color: #589819 } /* Generic.Inserted */ -body:not([data-theme="light"]) .highlight .go { color: #cccccc } /* Generic.Output */ -body:not([data-theme="light"]) .highlight .gp { color: #aaaaaa } /* Generic.Prompt */ -body:not([data-theme="light"]) .highlight .gs { color: #d0d0d0; font-weight: bold } /* Generic.Strong */ -body:not([data-theme="light"]) .highlight .gu { color: #ffffff; text-decoration: underline } /* Generic.Subheading */ -body:not([data-theme="light"]) .highlight .gt { color: #d22323 } /* Generic.Traceback */ -body:not([data-theme="light"]) .highlight .kc { color: #6ebf26; font-weight: bold } /* Keyword.Constant */ -body:not([data-theme="light"]) .highlight .kd { color: #6ebf26; font-weight: bold } /* Keyword.Declaration */ -body:not([data-theme="light"]) .highlight .kn { color: #6ebf26; font-weight: bold } /* Keyword.Namespace */ -body:not([data-theme="light"]) .highlight .kp { color: #6ebf26 } /* Keyword.Pseudo */ -body:not([data-theme="light"]) .highlight .kr { color: #6ebf26; font-weight: bold } /* Keyword.Reserved */ -body:not([data-theme="light"]) .highlight .kt { color: #6ebf26; font-weight: bold } /* Keyword.Type */ -body:not([data-theme="light"]) .highlight .ld { color: #d0d0d0 } /* Literal.Date */ -body:not([data-theme="light"]) .highlight .m { color: #51b2fd } /* Literal.Number */ -body:not([data-theme="light"]) .highlight .s { color: #ed9d13 } /* Literal.String */ -body:not([data-theme="light"]) .highlight .na { color: #bbbbbb } /* Name.Attribute */ -body:not([data-theme="light"]) .highlight .nb { color: #2fbccd } /* Name.Builtin */ -body:not([data-theme="light"]) .highlight .nc { color: #71adff; text-decoration: underline } /* Name.Class */ -body:not([data-theme="light"]) .highlight .no { color: #40ffff } /* Name.Constant */ -body:not([data-theme="light"]) .highlight .nd { color: #ffa500 } /* Name.Decorator */ -body:not([data-theme="light"]) .highlight .ni { color: #d0d0d0 } /* Name.Entity */ -body:not([data-theme="light"]) .highlight .ne { color: #bbbbbb } /* Name.Exception */ -body:not([data-theme="light"]) .highlight .nf { color: #71adff } /* Name.Function */ -body:not([data-theme="light"]) .highlight .nl { color: #d0d0d0 } /* Name.Label */ -body:not([data-theme="light"]) .highlight .nn { color: #71adff; text-decoration: underline } /* Name.Namespace */ -body:not([data-theme="light"]) .highlight .nx { color: #d0d0d0 } /* Name.Other */ -body:not([data-theme="light"]) .highlight .py { color: #d0d0d0 } /* Name.Property */ -body:not([data-theme="light"]) .highlight .nt { color: #6ebf26; font-weight: bold } /* Name.Tag */ -body:not([data-theme="light"]) .highlight .nv { color: #40ffff } /* Name.Variable */ -body:not([data-theme="light"]) .highlight .ow { color: #6ebf26; font-weight: bold } /* Operator.Word */ -body:not([data-theme="light"]) .highlight .pm { color: #d0d0d0 } /* Punctuation.Marker */ -body:not([data-theme="light"]) .highlight .w { color: #666666 } /* Text.Whitespace */ -body:not([data-theme="light"]) .highlight .mb { color: #51b2fd } /* Literal.Number.Bin */ -body:not([data-theme="light"]) .highlight .mf { color: #51b2fd } /* Literal.Number.Float */ -body:not([data-theme="light"]) .highlight .mh { color: #51b2fd } /* Literal.Number.Hex */ -body:not([data-theme="light"]) .highlight .mi { color: #51b2fd } /* Literal.Number.Integer */ -body:not([data-theme="light"]) .highlight .mo { color: #51b2fd } /* Literal.Number.Oct */ -body:not([data-theme="light"]) .highlight .sa { color: #ed9d13 } /* Literal.String.Affix */ -body:not([data-theme="light"]) .highlight .sb { color: #ed9d13 } /* Literal.String.Backtick */ -body:not([data-theme="light"]) .highlight .sc { color: #ed9d13 } /* Literal.String.Char */ -body:not([data-theme="light"]) .highlight .dl { color: #ed9d13 } /* Literal.String.Delimiter */ -body:not([data-theme="light"]) .highlight .sd { color: #ed9d13 } /* Literal.String.Doc */ -body:not([data-theme="light"]) .highlight .s2 { color: #ed9d13 } /* Literal.String.Double */ -body:not([data-theme="light"]) .highlight .se { color: #ed9d13 } /* Literal.String.Escape */ -body:not([data-theme="light"]) .highlight .sh { color: #ed9d13 } /* Literal.String.Heredoc */ -body:not([data-theme="light"]) .highlight .si { color: #ed9d13 } /* Literal.String.Interpol */ -body:not([data-theme="light"]) .highlight .sx { color: #ffa500 } /* Literal.String.Other */ -body:not([data-theme="light"]) .highlight .sr { color: #ed9d13 } /* Literal.String.Regex */ -body:not([data-theme="light"]) .highlight .s1 { color: #ed9d13 } /* Literal.String.Single */ -body:not([data-theme="light"]) .highlight .ss { color: #ed9d13 } /* Literal.String.Symbol */ -body:not([data-theme="light"]) .highlight .bp { color: #2fbccd } /* Name.Builtin.Pseudo */ -body:not([data-theme="light"]) .highlight .fm { color: #71adff } /* Name.Function.Magic */ -body:not([data-theme="light"]) .highlight .vc { color: #40ffff } /* Name.Variable.Class */ -body:not([data-theme="light"]) .highlight .vg { color: #40ffff } /* Name.Variable.Global */ -body:not([data-theme="light"]) .highlight .vi { color: #40ffff } /* Name.Variable.Instance */ -body:not([data-theme="light"]) .highlight .vm { color: #40ffff } /* Name.Variable.Magic */ -body:not([data-theme="light"]) .highlight .il { color: #51b2fd } /* Literal.Number.Integer.Long */ -} -} \ No newline at end of file diff --git a/docs/_static/scripts/furo-extensions.js b/docs/_static/scripts/furo-extensions.js deleted file mode 100644 index e69de29..0000000 diff --git a/docs/_static/scripts/furo.js b/docs/_static/scripts/furo.js deleted file mode 100644 index 32e7c05..0000000 --- a/docs/_static/scripts/furo.js +++ /dev/null @@ -1,3 +0,0 @@ -/*! For license information please see furo.js.LICENSE.txt */ -(()=>{var t={212:function(t,e,n){var o,r;r=void 0!==n.g?n.g:"undefined"!=typeof window?window:this,o=function(){return function(t){"use strict";var e={navClass:"active",contentClass:"active",nested:!1,nestedClass:"active",offset:0,reflow:!1,events:!0},n=function(t,e,n){if(n.settings.events){var o=new CustomEvent(t,{bubbles:!0,cancelable:!0,detail:n});e.dispatchEvent(o)}},o=function(t){var e=0;if(t.offsetParent)for(;t;)e+=t.offsetTop,t=t.offsetParent;return e>=0?e:0},r=function(t){t&&t.sort((function(t,e){return o(t.content)=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight,document.body.clientHeight,document.documentElement.clientHeight)},l=function(t,e){var n=t[t.length-1];if(function(t,e){return!(!s()||!c(t.content,e,!0))}(n,e))return n;for(var o=t.length-1;o>=0;o--)if(c(t[o].content,e))return t[o]},a=function(t,e){if(e.nested&&t.parentNode){var n=t.parentNode.closest("li");n&&(n.classList.remove(e.nestedClass),a(n,e))}},i=function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.remove(e.navClass),t.content.classList.remove(e.contentClass),a(o,e),n("gumshoeDeactivate",o,{link:t.nav,content:t.content,settings:e}))}},u=function(t,e){if(e.nested){var n=t.parentNode.closest("li");n&&(n.classList.add(e.nestedClass),u(n,e))}};return function(o,c){var s,a,d,f,m,v={setup:function(){s=document.querySelectorAll(o),a=[],Array.prototype.forEach.call(s,(function(t){var e=document.getElementById(decodeURIComponent(t.hash.substr(1)));e&&a.push({nav:t,content:e})})),r(a)},detect:function(){var t=l(a,m);t?d&&t.content===d.content||(i(d,m),function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.add(e.navClass),t.content.classList.add(e.contentClass),u(o,e),n("gumshoeActivate",o,{link:t.nav,content:t.content,settings:e}))}}(t,m),d=t):d&&(i(d,m),d=null)}},h=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame(v.detect)},g=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame((function(){r(a),v.detect()}))};return v.destroy=function(){d&&i(d,m),t.removeEventListener("scroll",h,!1),m.reflow&&t.removeEventListener("resize",g,!1),a=null,s=null,d=null,f=null,m=null},m=function(){var t={};return Array.prototype.forEach.call(arguments,(function(e){for(var n in e){if(!e.hasOwnProperty(n))return;t[n]=e[n]}})),t}(e,c||{}),v.setup(),v.detect(),t.addEventListener("scroll",h,!1),m.reflow&&t.addEventListener("resize",g,!1),v}}(r)}.apply(e,[]),void 0===o||(t.exports=o)}},e={};function n(o){var r=e[o];if(void 0!==r)return r.exports;var c=e[o]={exports:{}};return t[o].call(c.exports,c,c.exports,n),c.exports}n.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return n.d(e,{a:e}),e},n.d=(t,e)=>{for(var o in e)n.o(e,o)&&!n.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:e[o]})},n.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),n.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";var t=n(212),e=n.n(t),o=null,r=null,c=window.pageYOffset||document.documentElement.scrollTop;const s=64;function l(){const t=localStorage.getItem("theme")||"auto";var e;"light"!==(e=window.matchMedia("(prefers-color-scheme: dark)").matches?"auto"===t?"light":"light"==t?"dark":"auto":"auto"===t?"dark":"dark"==t?"light":"auto")&&"dark"!==e&&"auto"!==e&&(console.error(`Got invalid theme mode: ${e}. Resetting to auto.`),e="auto"),document.body.dataset.theme=e,localStorage.setItem("theme",e),console.log(`Changed to ${e} mode.`)}function a(){!function(){const t=document.getElementsByClassName("theme-toggle");Array.from(t).forEach((t=>{t.addEventListener("click",l)}))}(),function(){let t=0,e=!1;window.addEventListener("scroll",(function(n){t=window.scrollY,e||(window.requestAnimationFrame((function(){var n;n=t,0==Math.floor(r.getBoundingClientRect().top)?r.classList.add("scrolled"):r.classList.remove("scrolled"),function(t){tc&&document.documentElement.classList.remove("show-back-to-top"),c=t}(n),function(t){null!==o&&(0==t?o.scrollTo(0,0):Math.ceil(t)>=Math.floor(document.documentElement.scrollHeight-window.innerHeight)?o.scrollTo(0,o.scrollHeight):document.querySelector(".scroll-current"))}(n),e=!1})),e=!0)})),window.scroll()}(),null!==o&&new(e())(".toc-tree a",{reflow:!0,recursive:!0,navClass:"scroll-current",offset:()=>{let t=parseFloat(getComputedStyle(document.documentElement).fontSize);return r.getBoundingClientRect().height+.5*t+1}})}document.addEventListener("DOMContentLoaded",(function(){document.body.parentNode.classList.remove("no-js"),r=document.querySelector("header"),o=document.querySelector(".toc-scroll"),a()}))})()})(); -//# sourceMappingURL=furo.js.map \ No newline at end of file diff --git a/docs/_static/scripts/furo.js.LICENSE.txt b/docs/_static/scripts/furo.js.LICENSE.txt deleted file mode 100644 index 1632189..0000000 --- a/docs/_static/scripts/furo.js.LICENSE.txt +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * gumshoejs v5.1.2 (patched by @pradyunsg) - * A simple, framework-agnostic scrollspy script. - * (c) 2019 Chris Ferdinandi - * MIT License - * http://github.com/cferdinandi/gumshoe - */ diff --git a/docs/_static/scripts/furo.js.map b/docs/_static/scripts/furo.js.map deleted file mode 100644 index 4705302..0000000 --- a/docs/_static/scripts/furo.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"scripts/furo.js","mappings":";iCAAA,MAQWA,SAWS,IAAX,EAAAC,EACH,EAAAA,EACkB,oBAAXC,OACLA,OACAC,KAbO,EAAF,WACP,OAaJ,SAAUD,GACR,aAMA,IAAIE,EAAW,CAEbC,SAAU,SACVC,aAAc,SAGdC,QAAQ,EACRC,YAAa,SAGbC,OAAQ,EACRC,QAAQ,EAGRC,QAAQ,GA6BNC,EAAY,SAAUC,EAAMC,EAAMC,GAEpC,GAAKA,EAAOC,SAASL,OAArB,CAGA,IAAIM,EAAQ,IAAIC,YAAYL,EAAM,CAChCM,SAAS,EACTC,YAAY,EACZL,OAAQA,IAIVD,EAAKO,cAAcJ,EAVgB,CAWrC,EAOIK,EAAe,SAAUR,GAC3B,IAAIS,EAAW,EACf,GAAIT,EAAKU,aACP,KAAOV,GACLS,GAAYT,EAAKW,UACjBX,EAAOA,EAAKU,aAGhB,OAAOD,GAAY,EAAIA,EAAW,CACpC,EAMIG,EAAe,SAAUC,GACvBA,GACFA,EAASC,MAAK,SAAUC,EAAOC,GAG7B,OAFcR,EAAaO,EAAME,SACnBT,EAAaQ,EAAMC,UACF,EACxB,CACT,GAEJ,EAwCIC,EAAW,SAAUlB,EAAME,EAAUiB,GACvC,IAAIC,EAASpB,EAAKqB,wBACd1B,EAnCU,SAAUO,GAExB,MAA+B,mBAApBA,EAASP,OACX2B,WAAWpB,EAASP,UAItB2B,WAAWpB,EAASP,OAC7B,CA2Be4B,CAAUrB,GACvB,OAAIiB,EAEAK,SAASJ,EAAOD,OAAQ,KACvB/B,EAAOqC,aAAeC,SAASC,gBAAgBC,cAG7CJ,SAASJ,EAAOS,IAAK,KAAOlC,CACrC,EAMImC,EAAa,WACf,OACEC,KAAKC,KAAK5C,EAAOqC,YAAcrC,EAAO6C,cAnCjCF,KAAKG,IACVR,SAASS,KAAKC,aACdV,SAASC,gBAAgBS,aACzBV,SAASS,KAAKE,aACdX,SAASC,gBAAgBU,aACzBX,SAASS,KAAKP,aACdF,SAASC,gBAAgBC,aAkC7B,EAmBIU,EAAY,SAAUzB,EAAUX,GAClC,IAAIqC,EAAO1B,EAASA,EAAS2B,OAAS,GACtC,GAbgB,SAAUC,EAAMvC,GAChC,SAAI4B,MAAgBZ,EAASuB,EAAKxB,QAASf,GAAU,GAEvD,CAUMwC,CAAYH,EAAMrC,GAAW,OAAOqC,EACxC,IAAK,IAAII,EAAI9B,EAAS2B,OAAS,EAAGG,GAAK,EAAGA,IACxC,GAAIzB,EAASL,EAAS8B,GAAG1B,QAASf,GAAW,OAAOW,EAAS8B,EAEjE,EAOIC,EAAmB,SAAUC,EAAK3C,GAEpC,GAAKA,EAAST,QAAWoD,EAAIC,WAA7B,CAGA,IAAIC,EAAKF,EAAIC,WAAWE,QAAQ,MAC3BD,IAGLA,EAAGE,UAAUC,OAAOhD,EAASR,aAG7BkD,EAAiBG,EAAI7C,GAV0B,CAWjD,EAOIiD,EAAa,SAAUC,EAAOlD,GAEhC,GAAKkD,EAAL,CAGA,IAAIL,EAAKK,EAAMP,IAAIG,QAAQ,MACtBD,IAGLA,EAAGE,UAAUC,OAAOhD,EAASX,UAC7B6D,EAAMnC,QAAQgC,UAAUC,OAAOhD,EAASV,cAGxCoD,EAAiBG,EAAI7C,GAGrBJ,EAAU,oBAAqBiD,EAAI,CACjCM,KAAMD,EAAMP,IACZ5B,QAASmC,EAAMnC,QACff,SAAUA,IAjBM,CAmBpB,EAOIoD,EAAiB,SAAUT,EAAK3C,GAElC,GAAKA,EAAST,OAAd,CAGA,IAAIsD,EAAKF,EAAIC,WAAWE,QAAQ,MAC3BD,IAGLA,EAAGE,UAAUM,IAAIrD,EAASR,aAG1B4D,EAAeP,EAAI7C,GAVS,CAW9B,EA6LA,OA1JkB,SAAUsD,EAAUC,GAKpC,IACIC,EAAU7C,EAAU8C,EAASC,EAAS1D,EADtC2D,EAAa,CAUjBA,MAAmB,WAEjBH,EAAWhC,SAASoC,iBAAiBN,GAGrC3C,EAAW,GAGXkD,MAAMC,UAAUC,QAAQC,KAAKR,GAAU,SAAUjB,GAE/C,IAAIxB,EAAUS,SAASyC,eACrBC,mBAAmB3B,EAAK4B,KAAKC,OAAO,KAEjCrD,GAGLJ,EAAS0D,KAAK,CACZ1B,IAAKJ,EACLxB,QAASA,GAEb,IAGAL,EAAaC,EACf,EAKAgD,OAAoB,WAElB,IAAIW,EAASlC,EAAUzB,EAAUX,GAG5BsE,EASDb,GAAWa,EAAOvD,UAAY0C,EAAQ1C,UAG1CkC,EAAWQ,EAASzD,GAzFT,SAAUkD,EAAOlD,GAE9B,GAAKkD,EAAL,CAGA,IAAIL,EAAKK,EAAMP,IAAIG,QAAQ,MACtBD,IAGLA,EAAGE,UAAUM,IAAIrD,EAASX,UAC1B6D,EAAMnC,QAAQgC,UAAUM,IAAIrD,EAASV,cAGrC8D,EAAeP,EAAI7C,GAGnBJ,EAAU,kBAAmBiD,EAAI,CAC/BM,KAAMD,EAAMP,IACZ5B,QAASmC,EAAMnC,QACff,SAAUA,IAjBM,CAmBpB,CAqEIuE,CAASD,EAAQtE,GAGjByD,EAAUa,GAfJb,IACFR,EAAWQ,EAASzD,GACpByD,EAAU,KAchB,GAMIe,EAAgB,SAAUvE,GAExByD,GACFxE,EAAOuF,qBAAqBf,GAI9BA,EAAUxE,EAAOwF,sBAAsBf,EAAWgB,OACpD,EAMIC,EAAgB,SAAU3E,GAExByD,GACFxE,EAAOuF,qBAAqBf,GAI9BA,EAAUxE,EAAOwF,uBAAsB,WACrChE,EAAaC,GACbgD,EAAWgB,QACb,GACF,EAkDA,OA7CAhB,EAAWkB,QAAU,WAEfpB,GACFR,EAAWQ,EAASzD,GAItBd,EAAO4F,oBAAoB,SAAUN,GAAe,GAChDxE,EAASN,QACXR,EAAO4F,oBAAoB,SAAUF,GAAe,GAItDjE,EAAW,KACX6C,EAAW,KACXC,EAAU,KACVC,EAAU,KACV1D,EAAW,IACb,EAOEA,EA3XS,WACX,IAAI+E,EAAS,CAAC,EAOd,OANAlB,MAAMC,UAAUC,QAAQC,KAAKgB,WAAW,SAAUC,GAChD,IAAK,IAAIC,KAAOD,EAAK,CACnB,IAAKA,EAAIE,eAAeD,GAAM,OAC9BH,EAAOG,GAAOD,EAAIC,EACpB,CACF,IACOH,CACT,CAkXeK,CAAOhG,EAAUmE,GAAW,CAAC,GAGxCI,EAAW0B,QAGX1B,EAAWgB,SAGXzF,EAAOoG,iBAAiB,SAAUd,GAAe,GAC7CxE,EAASN,QACXR,EAAOoG,iBAAiB,SAAUV,GAAe,GAS9CjB,CACT,CAOF,CArcW4B,CAAQvG,EAChB,UAFM,SAEN,uBCXDwG,EAA2B,CAAC,EAGhC,SAASC,EAAoBC,GAE5B,IAAIC,EAAeH,EAAyBE,GAC5C,QAAqBE,IAAjBD,EACH,OAAOA,EAAaE,QAGrB,IAAIC,EAASN,EAAyBE,GAAY,CAGjDG,QAAS,CAAC,GAOX,OAHAE,EAAoBL,GAAU1B,KAAK8B,EAAOD,QAASC,EAAQA,EAAOD,QAASJ,GAGpEK,EAAOD,OACf,CCrBAJ,EAAoBO,EAAKF,IACxB,IAAIG,EAASH,GAAUA,EAAOI,WAC7B,IAAOJ,EAAiB,QACxB,IAAM,EAEP,OADAL,EAAoBU,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CAAM,ECLdR,EAAoBU,EAAI,CAACN,EAASQ,KACjC,IAAI,IAAInB,KAAOmB,EACXZ,EAAoBa,EAAED,EAAYnB,KAASO,EAAoBa,EAAET,EAASX,IAC5EqB,OAAOC,eAAeX,EAASX,EAAK,CAAEuB,YAAY,EAAMC,IAAKL,EAAWnB,IAE1E,ECNDO,EAAoBxG,EAAI,WACvB,GAA0B,iBAAf0H,WAAyB,OAAOA,WAC3C,IACC,OAAOxH,MAAQ,IAAIyH,SAAS,cAAb,EAChB,CAAE,MAAOC,GACR,GAAsB,iBAAX3H,OAAqB,OAAOA,MACxC,CACA,CAPuB,GCAxBuG,EAAoBa,EAAI,CAACrB,EAAK6B,IAAUP,OAAOzC,UAAUqB,eAAenB,KAAKiB,EAAK6B,4CCK9EC,EAAY,KACZC,EAAS,KACTC,EAAgB/H,OAAO6C,aAAeP,SAASC,gBAAgByF,UACnE,MAAMC,EAAmB,GA2EzB,SAASC,IACP,MAAMC,EAAeC,aAAaC,QAAQ,UAAY,OAZxD,IAAkBC,EACH,WADGA,EAaItI,OAAOuI,WAAW,gCAAgCC,QAI/C,SAAjBL,EACO,QACgB,SAAhBA,EACA,OAEA,OAIU,SAAjBA,EACO,OACgB,QAAhBA,EACA,QAEA,SA9BoB,SAATG,GAA4B,SAATA,IACzCG,QAAQC,MAAM,2BAA2BJ,yBACzCA,EAAO,QAGThG,SAASS,KAAK4F,QAAQC,MAAQN,EAC9BF,aAAaS,QAAQ,QAASP,GAC9BG,QAAQK,IAAI,cAAcR,UA0B5B,CAkDA,SAASnC,KART,WAEE,MAAM4C,EAAUzG,SAAS0G,uBAAuB,gBAChDrE,MAAMsE,KAAKF,GAASlE,SAASqE,IAC3BA,EAAI9C,iBAAiB,QAAS8B,EAAe,GAEjD,CAGEiB,GA9CF,WAEE,IAAIC,EAA6B,EAC7BC,GAAU,EAEdrJ,OAAOoG,iBAAiB,UAAU,SAAUuB,GAC1CyB,EAA6BpJ,OAAOsJ,QAE/BD,IACHrJ,OAAOwF,uBAAsB,WAzDnC,IAAuB+D,IA0DDH,EA9GkC,GAAlDzG,KAAK6G,MAAM1B,EAAO7F,wBAAwBQ,KAC5CqF,EAAOjE,UAAUM,IAAI,YAErB2D,EAAOjE,UAAUC,OAAO,YAI5B,SAAmCyF,GAC7BA,EAAYtB,EACd3F,SAASC,gBAAgBsB,UAAUC,OAAO,oBAEtCyF,EAAYxB,EACdzF,SAASC,gBAAgBsB,UAAUM,IAAI,oBAC9BoF,EAAYxB,GACrBzF,SAASC,gBAAgBsB,UAAUC,OAAO,oBAG9CiE,EAAgBwB,CAClB,CAoCEE,CAA0BF,GAlC5B,SAA6BA,GACT,OAAd1B,IAKa,GAAb0B,EACF1B,EAAU6B,SAAS,EAAG,GAGtB/G,KAAKC,KAAK2G,IACV5G,KAAK6G,MAAMlH,SAASC,gBAAgBS,aAAehD,OAAOqC,aAE1DwF,EAAU6B,SAAS,EAAG7B,EAAU7E,cAGhBV,SAASqH,cAAc,mBAc3C,CAKEC,CAAoBL,GAwDdF,GAAU,CACZ,IAEAA,GAAU,EAEd,IACArJ,OAAO6J,QACT,CA6BEC,GA1BkB,OAAdjC,GAKJ,IAAI,IAAJ,CAAY,cAAe,CACzBrH,QAAQ,EACRuJ,WAAW,EACX5J,SAAU,iBACVI,OAAQ,KACN,IAAIyJ,EAAM9H,WAAW+H,iBAAiB3H,SAASC,iBAAiB2H,UAChE,OAAOpC,EAAO7F,wBAAwBkI,OAAS,GAAMH,EAAM,CAAC,GAiBlE,CAcA1H,SAAS8D,iBAAiB,oBAT1B,WACE9D,SAASS,KAAKW,WAAWG,UAAUC,OAAO,SAE1CgE,EAASxF,SAASqH,cAAc,UAChC9B,EAAYvF,SAASqH,cAAc,eAEnCxD,GACF","sources":["webpack:///./src/furo/assets/scripts/gumshoe-patched.js","webpack:///webpack/bootstrap","webpack:///webpack/runtime/compat get default export","webpack:///webpack/runtime/define property getters","webpack:///webpack/runtime/global","webpack:///webpack/runtime/hasOwnProperty shorthand","webpack:///./src/furo/assets/scripts/furo.js"],"sourcesContent":["/*!\n * gumshoejs v5.1.2 (patched by @pradyunsg)\n * A simple, framework-agnostic scrollspy script.\n * (c) 2019 Chris Ferdinandi\n * MIT License\n * http://github.com/cferdinandi/gumshoe\n */\n\n(function (root, factory) {\n if (typeof define === \"function\" && define.amd) {\n define([], function () {\n return factory(root);\n });\n } else if (typeof exports === \"object\") {\n module.exports = factory(root);\n } else {\n root.Gumshoe = factory(root);\n }\n})(\n typeof global !== \"undefined\"\n ? global\n : typeof window !== \"undefined\"\n ? window\n : this,\n function (window) {\n \"use strict\";\n\n //\n // Defaults\n //\n\n var defaults = {\n // Active classes\n navClass: \"active\",\n contentClass: \"active\",\n\n // Nested navigation\n nested: false,\n nestedClass: \"active\",\n\n // Offset & reflow\n offset: 0,\n reflow: false,\n\n // Event support\n events: true,\n };\n\n //\n // Methods\n //\n\n /**\n * Merge two or more objects together.\n * @param {Object} objects The objects to merge together\n * @returns {Object} Merged values of defaults and options\n */\n var extend = function () {\n var merged = {};\n Array.prototype.forEach.call(arguments, function (obj) {\n for (var key in obj) {\n if (!obj.hasOwnProperty(key)) return;\n merged[key] = obj[key];\n }\n });\n return merged;\n };\n\n /**\n * Emit a custom event\n * @param {String} type The event type\n * @param {Node} elem The element to attach the event to\n * @param {Object} detail Any details to pass along with the event\n */\n var emitEvent = function (type, elem, detail) {\n // Make sure events are enabled\n if (!detail.settings.events) return;\n\n // Create a new event\n var event = new CustomEvent(type, {\n bubbles: true,\n cancelable: true,\n detail: detail,\n });\n\n // Dispatch the event\n elem.dispatchEvent(event);\n };\n\n /**\n * Get an element's distance from the top of the Document.\n * @param {Node} elem The element\n * @return {Number} Distance from the top in pixels\n */\n var getOffsetTop = function (elem) {\n var location = 0;\n if (elem.offsetParent) {\n while (elem) {\n location += elem.offsetTop;\n elem = elem.offsetParent;\n }\n }\n return location >= 0 ? location : 0;\n };\n\n /**\n * Sort content from first to last in the DOM\n * @param {Array} contents The content areas\n */\n var sortContents = function (contents) {\n if (contents) {\n contents.sort(function (item1, item2) {\n var offset1 = getOffsetTop(item1.content);\n var offset2 = getOffsetTop(item2.content);\n if (offset1 < offset2) return -1;\n return 1;\n });\n }\n };\n\n /**\n * Get the offset to use for calculating position\n * @param {Object} settings The settings for this instantiation\n * @return {Float} The number of pixels to offset the calculations\n */\n var getOffset = function (settings) {\n // if the offset is a function run it\n if (typeof settings.offset === \"function\") {\n return parseFloat(settings.offset());\n }\n\n // Otherwise, return it as-is\n return parseFloat(settings.offset);\n };\n\n /**\n * Get the document element's height\n * @private\n * @returns {Number}\n */\n var getDocumentHeight = function () {\n return Math.max(\n document.body.scrollHeight,\n document.documentElement.scrollHeight,\n document.body.offsetHeight,\n document.documentElement.offsetHeight,\n document.body.clientHeight,\n document.documentElement.clientHeight,\n );\n };\n\n /**\n * Determine if an element is in view\n * @param {Node} elem The element\n * @param {Object} settings The settings for this instantiation\n * @param {Boolean} bottom If true, check if element is above bottom of viewport instead\n * @return {Boolean} Returns true if element is in the viewport\n */\n var isInView = function (elem, settings, bottom) {\n var bounds = elem.getBoundingClientRect();\n var offset = getOffset(settings);\n if (bottom) {\n return (\n parseInt(bounds.bottom, 10) <\n (window.innerHeight || document.documentElement.clientHeight)\n );\n }\n return parseInt(bounds.top, 10) <= offset;\n };\n\n /**\n * Check if at the bottom of the viewport\n * @return {Boolean} If true, page is at the bottom of the viewport\n */\n var isAtBottom = function () {\n if (\n Math.ceil(window.innerHeight + window.pageYOffset) >=\n getDocumentHeight()\n )\n return true;\n return false;\n };\n\n /**\n * Check if the last item should be used (even if not at the top of the page)\n * @param {Object} item The last item\n * @param {Object} settings The settings for this instantiation\n * @return {Boolean} If true, use the last item\n */\n var useLastItem = function (item, settings) {\n if (isAtBottom() && isInView(item.content, settings, true)) return true;\n return false;\n };\n\n /**\n * Get the active content\n * @param {Array} contents The content areas\n * @param {Object} settings The settings for this instantiation\n * @return {Object} The content area and matching navigation link\n */\n var getActive = function (contents, settings) {\n var last = contents[contents.length - 1];\n if (useLastItem(last, settings)) return last;\n for (var i = contents.length - 1; i >= 0; i--) {\n if (isInView(contents[i].content, settings)) return contents[i];\n }\n };\n\n /**\n * Deactivate parent navs in a nested navigation\n * @param {Node} nav The starting navigation element\n * @param {Object} settings The settings for this instantiation\n */\n var deactivateNested = function (nav, settings) {\n // If nesting isn't activated, bail\n if (!settings.nested || !nav.parentNode) return;\n\n // Get the parent navigation\n var li = nav.parentNode.closest(\"li\");\n if (!li) return;\n\n // Remove the active class\n li.classList.remove(settings.nestedClass);\n\n // Apply recursively to any parent navigation elements\n deactivateNested(li, settings);\n };\n\n /**\n * Deactivate a nav and content area\n * @param {Object} items The nav item and content to deactivate\n * @param {Object} settings The settings for this instantiation\n */\n var deactivate = function (items, settings) {\n // Make sure there are items to deactivate\n if (!items) return;\n\n // Get the parent list item\n var li = items.nav.closest(\"li\");\n if (!li) return;\n\n // Remove the active class from the nav and content\n li.classList.remove(settings.navClass);\n items.content.classList.remove(settings.contentClass);\n\n // Deactivate any parent navs in a nested navigation\n deactivateNested(li, settings);\n\n // Emit a custom event\n emitEvent(\"gumshoeDeactivate\", li, {\n link: items.nav,\n content: items.content,\n settings: settings,\n });\n };\n\n /**\n * Activate parent navs in a nested navigation\n * @param {Node} nav The starting navigation element\n * @param {Object} settings The settings for this instantiation\n */\n var activateNested = function (nav, settings) {\n // If nesting isn't activated, bail\n if (!settings.nested) return;\n\n // Get the parent navigation\n var li = nav.parentNode.closest(\"li\");\n if (!li) return;\n\n // Add the active class\n li.classList.add(settings.nestedClass);\n\n // Apply recursively to any parent navigation elements\n activateNested(li, settings);\n };\n\n /**\n * Activate a nav and content area\n * @param {Object} items The nav item and content to activate\n * @param {Object} settings The settings for this instantiation\n */\n var activate = function (items, settings) {\n // Make sure there are items to activate\n if (!items) return;\n\n // Get the parent list item\n var li = items.nav.closest(\"li\");\n if (!li) return;\n\n // Add the active class to the nav and content\n li.classList.add(settings.navClass);\n items.content.classList.add(settings.contentClass);\n\n // Activate any parent navs in a nested navigation\n activateNested(li, settings);\n\n // Emit a custom event\n emitEvent(\"gumshoeActivate\", li, {\n link: items.nav,\n content: items.content,\n settings: settings,\n });\n };\n\n /**\n * Create the Constructor object\n * @param {String} selector The selector to use for navigation items\n * @param {Object} options User options and settings\n */\n var Constructor = function (selector, options) {\n //\n // Variables\n //\n\n var publicAPIs = {};\n var navItems, contents, current, timeout, settings;\n\n //\n // Methods\n //\n\n /**\n * Set variables from DOM elements\n */\n publicAPIs.setup = function () {\n // Get all nav items\n navItems = document.querySelectorAll(selector);\n\n // Create contents array\n contents = [];\n\n // Loop through each item, get it's matching content, and push to the array\n Array.prototype.forEach.call(navItems, function (item) {\n // Get the content for the nav item\n var content = document.getElementById(\n decodeURIComponent(item.hash.substr(1)),\n );\n if (!content) return;\n\n // Push to the contents array\n contents.push({\n nav: item,\n content: content,\n });\n });\n\n // Sort contents by the order they appear in the DOM\n sortContents(contents);\n };\n\n /**\n * Detect which content is currently active\n */\n publicAPIs.detect = function () {\n // Get the active content\n var active = getActive(contents, settings);\n\n // if there's no active content, deactivate and bail\n if (!active) {\n if (current) {\n deactivate(current, settings);\n current = null;\n }\n return;\n }\n\n // If the active content is the one currently active, do nothing\n if (current && active.content === current.content) return;\n\n // Deactivate the current content and activate the new content\n deactivate(current, settings);\n activate(active, settings);\n\n // Update the currently active content\n current = active;\n };\n\n /**\n * Detect the active content on scroll\n * Debounced for performance\n */\n var scrollHandler = function (event) {\n // If there's a timer, cancel it\n if (timeout) {\n window.cancelAnimationFrame(timeout);\n }\n\n // Setup debounce callback\n timeout = window.requestAnimationFrame(publicAPIs.detect);\n };\n\n /**\n * Update content sorting on resize\n * Debounced for performance\n */\n var resizeHandler = function (event) {\n // If there's a timer, cancel it\n if (timeout) {\n window.cancelAnimationFrame(timeout);\n }\n\n // Setup debounce callback\n timeout = window.requestAnimationFrame(function () {\n sortContents(contents);\n publicAPIs.detect();\n });\n };\n\n /**\n * Destroy the current instantiation\n */\n publicAPIs.destroy = function () {\n // Undo DOM changes\n if (current) {\n deactivate(current, settings);\n }\n\n // Remove event listeners\n window.removeEventListener(\"scroll\", scrollHandler, false);\n if (settings.reflow) {\n window.removeEventListener(\"resize\", resizeHandler, false);\n }\n\n // Reset variables\n contents = null;\n navItems = null;\n current = null;\n timeout = null;\n settings = null;\n };\n\n /**\n * Initialize the current instantiation\n */\n var init = function () {\n // Merge user options into defaults\n settings = extend(defaults, options || {});\n\n // Setup variables based on the current DOM\n publicAPIs.setup();\n\n // Find the currently active content\n publicAPIs.detect();\n\n // Setup event listeners\n window.addEventListener(\"scroll\", scrollHandler, false);\n if (settings.reflow) {\n window.addEventListener(\"resize\", resizeHandler, false);\n }\n };\n\n //\n // Initialize and return the public APIs\n //\n\n init();\n return publicAPIs;\n };\n\n //\n // Return the Constructor\n //\n\n return Constructor;\n },\n);\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.g = (function() {\n\tif (typeof globalThis === 'object') return globalThis;\n\ttry {\n\t\treturn this || new Function('return this')();\n\t} catch (e) {\n\t\tif (typeof window === 'object') return window;\n\t}\n})();","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","import Gumshoe from \"./gumshoe-patched.js\";\n\n////////////////////////////////////////////////////////////////////////////////\n// Scroll Handling\n////////////////////////////////////////////////////////////////////////////////\nvar tocScroll = null;\nvar header = null;\nvar lastScrollTop = window.pageYOffset || document.documentElement.scrollTop;\nconst GO_TO_TOP_OFFSET = 64;\n\nfunction scrollHandlerForHeader() {\n if (Math.floor(header.getBoundingClientRect().top) == 0) {\n header.classList.add(\"scrolled\");\n } else {\n header.classList.remove(\"scrolled\");\n }\n}\n\nfunction scrollHandlerForBackToTop(positionY) {\n if (positionY < GO_TO_TOP_OFFSET) {\n document.documentElement.classList.remove(\"show-back-to-top\");\n } else {\n if (positionY < lastScrollTop) {\n document.documentElement.classList.add(\"show-back-to-top\");\n } else if (positionY > lastScrollTop) {\n document.documentElement.classList.remove(\"show-back-to-top\");\n }\n }\n lastScrollTop = positionY;\n}\n\nfunction scrollHandlerForTOC(positionY) {\n if (tocScroll === null) {\n return;\n }\n\n // top of page.\n if (positionY == 0) {\n tocScroll.scrollTo(0, 0);\n } else if (\n // bottom of page.\n Math.ceil(positionY) >=\n Math.floor(document.documentElement.scrollHeight - window.innerHeight)\n ) {\n tocScroll.scrollTo(0, tocScroll.scrollHeight);\n } else {\n // somewhere in the middle.\n const current = document.querySelector(\".scroll-current\");\n if (current == null) {\n return;\n }\n\n // https://github.com/pypa/pip/issues/9159 This breaks scroll behaviours.\n // // scroll the currently \"active\" heading in toc, into view.\n // const rect = current.getBoundingClientRect();\n // if (0 > rect.top) {\n // current.scrollIntoView(true); // the argument is \"alignTop\"\n // } else if (rect.bottom > window.innerHeight) {\n // current.scrollIntoView(false);\n // }\n }\n}\n\nfunction scrollHandler(positionY) {\n scrollHandlerForHeader();\n scrollHandlerForBackToTop(positionY);\n scrollHandlerForTOC(positionY);\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Theme Toggle\n////////////////////////////////////////////////////////////////////////////////\nfunction setTheme(mode) {\n if (mode !== \"light\" && mode !== \"dark\" && mode !== \"auto\") {\n console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`);\n mode = \"auto\";\n }\n\n document.body.dataset.theme = mode;\n localStorage.setItem(\"theme\", mode);\n console.log(`Changed to ${mode} mode.`);\n}\n\nfunction cycleThemeOnce() {\n const currentTheme = localStorage.getItem(\"theme\") || \"auto\";\n const prefersDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n\n if (prefersDark) {\n // Auto (dark) -> Light -> Dark\n if (currentTheme === \"auto\") {\n setTheme(\"light\");\n } else if (currentTheme == \"light\") {\n setTheme(\"dark\");\n } else {\n setTheme(\"auto\");\n }\n } else {\n // Auto (light) -> Dark -> Light\n if (currentTheme === \"auto\") {\n setTheme(\"dark\");\n } else if (currentTheme == \"dark\") {\n setTheme(\"light\");\n } else {\n setTheme(\"auto\");\n }\n }\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Setup\n////////////////////////////////////////////////////////////////////////////////\nfunction setupScrollHandler() {\n // Taken from https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event\n let last_known_scroll_position = 0;\n let ticking = false;\n\n window.addEventListener(\"scroll\", function (e) {\n last_known_scroll_position = window.scrollY;\n\n if (!ticking) {\n window.requestAnimationFrame(function () {\n scrollHandler(last_known_scroll_position);\n ticking = false;\n });\n\n ticking = true;\n }\n });\n window.scroll();\n}\n\nfunction setupScrollSpy() {\n if (tocScroll === null) {\n return;\n }\n\n // Scrollspy -- highlight table on contents, based on scroll\n new Gumshoe(\".toc-tree a\", {\n reflow: true,\n recursive: true,\n navClass: \"scroll-current\",\n offset: () => {\n let rem = parseFloat(getComputedStyle(document.documentElement).fontSize);\n return header.getBoundingClientRect().height + 0.5 * rem + 1;\n },\n });\n}\n\nfunction setupTheme() {\n // Attach event handlers for toggling themes\n const buttons = document.getElementsByClassName(\"theme-toggle\");\n Array.from(buttons).forEach((btn) => {\n btn.addEventListener(\"click\", cycleThemeOnce);\n });\n}\n\nfunction setup() {\n setupTheme();\n setupScrollHandler();\n setupScrollSpy();\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Main entrypoint\n////////////////////////////////////////////////////////////////////////////////\nfunction main() {\n document.body.parentNode.classList.remove(\"no-js\");\n\n header = document.querySelector(\"header\");\n tocScroll = document.querySelector(\".toc-scroll\");\n\n setup();\n}\n\ndocument.addEventListener(\"DOMContentLoaded\", main);\n"],"names":["root","g","window","this","defaults","navClass","contentClass","nested","nestedClass","offset","reflow","events","emitEvent","type","elem","detail","settings","event","CustomEvent","bubbles","cancelable","dispatchEvent","getOffsetTop","location","offsetParent","offsetTop","sortContents","contents","sort","item1","item2","content","isInView","bottom","bounds","getBoundingClientRect","parseFloat","getOffset","parseInt","innerHeight","document","documentElement","clientHeight","top","isAtBottom","Math","ceil","pageYOffset","max","body","scrollHeight","offsetHeight","getActive","last","length","item","useLastItem","i","deactivateNested","nav","parentNode","li","closest","classList","remove","deactivate","items","link","activateNested","add","selector","options","navItems","current","timeout","publicAPIs","querySelectorAll","Array","prototype","forEach","call","getElementById","decodeURIComponent","hash","substr","push","active","activate","scrollHandler","cancelAnimationFrame","requestAnimationFrame","detect","resizeHandler","destroy","removeEventListener","merged","arguments","obj","key","hasOwnProperty","extend","setup","addEventListener","factory","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","exports","module","__webpack_modules__","n","getter","__esModule","d","a","definition","o","Object","defineProperty","enumerable","get","globalThis","Function","e","prop","tocScroll","header","lastScrollTop","scrollTop","GO_TO_TOP_OFFSET","cycleThemeOnce","currentTheme","localStorage","getItem","mode","matchMedia","matches","console","error","dataset","theme","setItem","log","buttons","getElementsByClassName","from","btn","setupTheme","last_known_scroll_position","ticking","scrollY","positionY","floor","scrollHandlerForBackToTop","scrollTo","querySelector","scrollHandlerForTOC","scroll","setupScrollHandler","recursive","rem","getComputedStyle","fontSize","height"],"sourceRoot":""} \ No newline at end of file diff --git a/docs/_static/searchtools.js b/docs/_static/searchtools.js deleted file mode 100644 index 7918c3f..0000000 --- a/docs/_static/searchtools.js +++ /dev/null @@ -1,574 +0,0 @@ -/* - * searchtools.js - * ~~~~~~~~~~~~~~~~ - * - * Sphinx JavaScript utilities for the full-text search. - * - * :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS. - * :license: BSD, see LICENSE for details. - * - */ -"use strict"; - -/** - * Simple result scoring code. - */ -if (typeof Scorer === "undefined") { - var Scorer = { - // Implement the following function to further tweak the score for each result - // The function takes a result array [docname, title, anchor, descr, score, filename] - // and returns the new score. - /* - score: result => { - const [docname, title, anchor, descr, score, filename] = result - return score - }, - */ - - // query matches the full name of an object - objNameMatch: 11, - // or matches in the last dotted part of the object name - objPartialMatch: 6, - // Additive scores depending on the priority of the object - objPrio: { - 0: 15, // used to be importantResults - 1: 5, // used to be objectResults - 2: -5, // used to be unimportantResults - }, - // Used when the priority is not in the mapping. - objPrioDefault: 0, - - // query found in title - title: 15, - partialTitle: 7, - // query found in terms - term: 5, - partialTerm: 2, - }; -} - -const _removeChildren = (element) => { - while (element && element.lastChild) element.removeChild(element.lastChild); -}; - -/** - * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping - */ -const _escapeRegExp = (string) => - string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string - -const _displayItem = (item, searchTerms, highlightTerms) => { - const docBuilder = DOCUMENTATION_OPTIONS.BUILDER; - const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX; - const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX; - const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY; - const contentRoot = document.documentElement.dataset.content_root; - - const [docName, title, anchor, descr, score, _filename] = item; - - let listItem = document.createElement("li"); - let requestUrl; - let linkUrl; - if (docBuilder === "dirhtml") { - // dirhtml builder - let dirname = docName + "/"; - if (dirname.match(/\/index\/$/)) - dirname = dirname.substring(0, dirname.length - 6); - else if (dirname === "index/") dirname = ""; - requestUrl = contentRoot + dirname; - linkUrl = requestUrl; - } else { - // normal html builders - requestUrl = contentRoot + docName + docFileSuffix; - linkUrl = docName + docLinkSuffix; - } - let linkEl = listItem.appendChild(document.createElement("a")); - linkEl.href = linkUrl + anchor; - linkEl.dataset.score = score; - linkEl.innerHTML = title; - if (descr) { - listItem.appendChild(document.createElement("span")).innerHTML = - " (" + descr + ")"; - // highlight search terms in the description - if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js - highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted")); - } - else if (showSearchSummary) - fetch(requestUrl) - .then((responseData) => responseData.text()) - .then((data) => { - if (data) - listItem.appendChild( - Search.makeSearchSummary(data, searchTerms) - ); - // highlight search terms in the summary - if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js - highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted")); - }); - Search.output.appendChild(listItem); -}; -const _finishSearch = (resultCount) => { - Search.stopPulse(); - Search.title.innerText = _("Search Results"); - if (!resultCount) - Search.status.innerText = Documentation.gettext( - "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories." - ); - else - Search.status.innerText = _( - `Search finished, found ${resultCount} page(s) matching the search query.` - ); -}; -const _displayNextItem = ( - results, - resultCount, - searchTerms, - highlightTerms, -) => { - // results left, load the summary and display it - // this is intended to be dynamic (don't sub resultsCount) - if (results.length) { - _displayItem(results.pop(), searchTerms, highlightTerms); - setTimeout( - () => _displayNextItem(results, resultCount, searchTerms, highlightTerms), - 5 - ); - } - // search finished, update title and status message - else _finishSearch(resultCount); -}; - -/** - * Default splitQuery function. Can be overridden in ``sphinx.search`` with a - * custom function per language. - * - * The regular expression works by splitting the string on consecutive characters - * that are not Unicode letters, numbers, underscores, or emoji characters. - * This is the same as ``\W+`` in Python, preserving the surrogate pair area. - */ -if (typeof splitQuery === "undefined") { - var splitQuery = (query) => query - .split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu) - .filter(term => term) // remove remaining empty strings -} - -/** - * Search Module - */ -const Search = { - _index: null, - _queued_query: null, - _pulse_status: -1, - - htmlToText: (htmlString) => { - const htmlElement = new DOMParser().parseFromString(htmlString, 'text/html'); - htmlElement.querySelectorAll(".headerlink").forEach((el) => { el.remove() }); - const docContent = htmlElement.querySelector('[role="main"]'); - if (docContent !== undefined) return docContent.textContent; - console.warn( - "Content block not found. Sphinx search tries to obtain it via '[role=main]'. Could you check your theme or template." - ); - return ""; - }, - - init: () => { - const query = new URLSearchParams(window.location.search).get("q"); - document - .querySelectorAll('input[name="q"]') - .forEach((el) => (el.value = query)); - if (query) Search.performSearch(query); - }, - - loadIndex: (url) => - (document.body.appendChild(document.createElement("script")).src = url), - - setIndex: (index) => { - Search._index = index; - if (Search._queued_query !== null) { - const query = Search._queued_query; - Search._queued_query = null; - Search.query(query); - } - }, - - hasIndex: () => Search._index !== null, - - deferQuery: (query) => (Search._queued_query = query), - - stopPulse: () => (Search._pulse_status = -1), - - startPulse: () => { - if (Search._pulse_status >= 0) return; - - const pulse = () => { - Search._pulse_status = (Search._pulse_status + 1) % 4; - Search.dots.innerText = ".".repeat(Search._pulse_status); - if (Search._pulse_status >= 0) window.setTimeout(pulse, 500); - }; - pulse(); - }, - - /** - * perform a search for something (or wait until index is loaded) - */ - performSearch: (query) => { - // create the required interface elements - const searchText = document.createElement("h2"); - searchText.textContent = _("Searching"); - const searchSummary = document.createElement("p"); - searchSummary.classList.add("search-summary"); - searchSummary.innerText = ""; - const searchList = document.createElement("ul"); - searchList.classList.add("search"); - - const out = document.getElementById("search-results"); - Search.title = out.appendChild(searchText); - Search.dots = Search.title.appendChild(document.createElement("span")); - Search.status = out.appendChild(searchSummary); - Search.output = out.appendChild(searchList); - - const searchProgress = document.getElementById("search-progress"); - // Some themes don't use the search progress node - if (searchProgress) { - searchProgress.innerText = _("Preparing search..."); - } - Search.startPulse(); - - // index already loaded, the browser was quick! - if (Search.hasIndex()) Search.query(query); - else Search.deferQuery(query); - }, - - /** - * execute search (requires search index to be loaded) - */ - query: (query) => { - const filenames = Search._index.filenames; - const docNames = Search._index.docnames; - const titles = Search._index.titles; - const allTitles = Search._index.alltitles; - const indexEntries = Search._index.indexentries; - - // stem the search terms and add them to the correct list - const stemmer = new Stemmer(); - const searchTerms = new Set(); - const excludedTerms = new Set(); - const highlightTerms = new Set(); - const objectTerms = new Set(splitQuery(query.toLowerCase().trim())); - splitQuery(query.trim()).forEach((queryTerm) => { - const queryTermLower = queryTerm.toLowerCase(); - - // maybe skip this "word" - // stopwords array is from language_data.js - if ( - stopwords.indexOf(queryTermLower) !== -1 || - queryTerm.match(/^\d+$/) - ) - return; - - // stem the word - let word = stemmer.stemWord(queryTermLower); - // select the correct list - if (word[0] === "-") excludedTerms.add(word.substr(1)); - else { - searchTerms.add(word); - highlightTerms.add(queryTermLower); - } - }); - - if (SPHINX_HIGHLIGHT_ENABLED) { // set in sphinx_highlight.js - localStorage.setItem("sphinx_highlight_terms", [...highlightTerms].join(" ")) - } - - // console.debug("SEARCH: searching for:"); - // console.info("required: ", [...searchTerms]); - // console.info("excluded: ", [...excludedTerms]); - - // array of [docname, title, anchor, descr, score, filename] - let results = []; - _removeChildren(document.getElementById("search-progress")); - - const queryLower = query.toLowerCase(); - for (const [title, foundTitles] of Object.entries(allTitles)) { - if (title.toLowerCase().includes(queryLower) && (queryLower.length >= title.length/2)) { - for (const [file, id] of foundTitles) { - let score = Math.round(100 * queryLower.length / title.length) - results.push([ - docNames[file], - titles[file] !== title ? `${titles[file]} > ${title}` : title, - id !== null ? "#" + id : "", - null, - score, - filenames[file], - ]); - } - } - } - - // search for explicit entries in index directives - for (const [entry, foundEntries] of Object.entries(indexEntries)) { - if (entry.includes(queryLower) && (queryLower.length >= entry.length/2)) { - for (const [file, id] of foundEntries) { - let score = Math.round(100 * queryLower.length / entry.length) - results.push([ - docNames[file], - titles[file], - id ? "#" + id : "", - null, - score, - filenames[file], - ]); - } - } - } - - // lookup as object - objectTerms.forEach((term) => - results.push(...Search.performObjectSearch(term, objectTerms)) - ); - - // lookup as search terms in fulltext - results.push(...Search.performTermsSearch(searchTerms, excludedTerms)); - - // let the scorer override scores with a custom scoring function - if (Scorer.score) results.forEach((item) => (item[4] = Scorer.score(item))); - - // now sort the results by score (in opposite order of appearance, since the - // display function below uses pop() to retrieve items) and then - // alphabetically - results.sort((a, b) => { - const leftScore = a[4]; - const rightScore = b[4]; - if (leftScore === rightScore) { - // same score: sort alphabetically - const leftTitle = a[1].toLowerCase(); - const rightTitle = b[1].toLowerCase(); - if (leftTitle === rightTitle) return 0; - return leftTitle > rightTitle ? -1 : 1; // inverted is intentional - } - return leftScore > rightScore ? 1 : -1; - }); - - // remove duplicate search results - // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept - let seen = new Set(); - results = results.reverse().reduce((acc, result) => { - let resultStr = result.slice(0, 4).concat([result[5]]).map(v => String(v)).join(','); - if (!seen.has(resultStr)) { - acc.push(result); - seen.add(resultStr); - } - return acc; - }, []); - - results = results.reverse(); - - // for debugging - //Search.lastresults = results.slice(); // a copy - // console.info("search results:", Search.lastresults); - - // print the results - _displayNextItem(results, results.length, searchTerms, highlightTerms); - }, - - /** - * search for object names - */ - performObjectSearch: (object, objectTerms) => { - const filenames = Search._index.filenames; - const docNames = Search._index.docnames; - const objects = Search._index.objects; - const objNames = Search._index.objnames; - const titles = Search._index.titles; - - const results = []; - - const objectSearchCallback = (prefix, match) => { - const name = match[4] - const fullname = (prefix ? prefix + "." : "") + name; - const fullnameLower = fullname.toLowerCase(); - if (fullnameLower.indexOf(object) < 0) return; - - let score = 0; - const parts = fullnameLower.split("."); - - // check for different match types: exact matches of full name or - // "last name" (i.e. last dotted part) - if (fullnameLower === object || parts.slice(-1)[0] === object) - score += Scorer.objNameMatch; - else if (parts.slice(-1)[0].indexOf(object) > -1) - score += Scorer.objPartialMatch; // matches in last name - - const objName = objNames[match[1]][2]; - const title = titles[match[0]]; - - // If more than one term searched for, we require other words to be - // found in the name/title/description - const otherTerms = new Set(objectTerms); - otherTerms.delete(object); - if (otherTerms.size > 0) { - const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase(); - if ( - [...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0) - ) - return; - } - - let anchor = match[3]; - if (anchor === "") anchor = fullname; - else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname; - - const descr = objName + _(", in ") + title; - - // add custom score for some objects according to scorer - if (Scorer.objPrio.hasOwnProperty(match[2])) - score += Scorer.objPrio[match[2]]; - else score += Scorer.objPrioDefault; - - results.push([ - docNames[match[0]], - fullname, - "#" + anchor, - descr, - score, - filenames[match[0]], - ]); - }; - Object.keys(objects).forEach((prefix) => - objects[prefix].forEach((array) => - objectSearchCallback(prefix, array) - ) - ); - return results; - }, - - /** - * search for full-text terms in the index - */ - performTermsSearch: (searchTerms, excludedTerms) => { - // prepare search - const terms = Search._index.terms; - const titleTerms = Search._index.titleterms; - const filenames = Search._index.filenames; - const docNames = Search._index.docnames; - const titles = Search._index.titles; - - const scoreMap = new Map(); - const fileMap = new Map(); - - // perform the search on the required terms - searchTerms.forEach((word) => { - const files = []; - const arr = [ - { files: terms[word], score: Scorer.term }, - { files: titleTerms[word], score: Scorer.title }, - ]; - // add support for partial matches - if (word.length > 2) { - const escapedWord = _escapeRegExp(word); - Object.keys(terms).forEach((term) => { - if (term.match(escapedWord) && !terms[word]) - arr.push({ files: terms[term], score: Scorer.partialTerm }); - }); - Object.keys(titleTerms).forEach((term) => { - if (term.match(escapedWord) && !titleTerms[word]) - arr.push({ files: titleTerms[word], score: Scorer.partialTitle }); - }); - } - - // no match but word was a required one - if (arr.every((record) => record.files === undefined)) return; - - // found search word in contents - arr.forEach((record) => { - if (record.files === undefined) return; - - let recordFiles = record.files; - if (recordFiles.length === undefined) recordFiles = [recordFiles]; - files.push(...recordFiles); - - // set score for the word in each file - recordFiles.forEach((file) => { - if (!scoreMap.has(file)) scoreMap.set(file, {}); - scoreMap.get(file)[word] = record.score; - }); - }); - - // create the mapping - files.forEach((file) => { - if (fileMap.has(file) && fileMap.get(file).indexOf(word) === -1) - fileMap.get(file).push(word); - else fileMap.set(file, [word]); - }); - }); - - // now check if the files don't contain excluded terms - const results = []; - for (const [file, wordList] of fileMap) { - // check if all requirements are matched - - // as search terms with length < 3 are discarded - const filteredTermCount = [...searchTerms].filter( - (term) => term.length > 2 - ).length; - if ( - wordList.length !== searchTerms.size && - wordList.length !== filteredTermCount - ) - continue; - - // ensure that none of the excluded terms is in the search result - if ( - [...excludedTerms].some( - (term) => - terms[term] === file || - titleTerms[term] === file || - (terms[term] || []).includes(file) || - (titleTerms[term] || []).includes(file) - ) - ) - break; - - // select one (max) score for the file. - const score = Math.max(...wordList.map((w) => scoreMap.get(file)[w])); - // add result to the result list - results.push([ - docNames[file], - titles[file], - "", - null, - score, - filenames[file], - ]); - } - return results; - }, - - /** - * helper function to return a node containing the - * search summary for a given text. keywords is a list - * of stemmed words. - */ - makeSearchSummary: (htmlText, keywords) => { - const text = Search.htmlToText(htmlText); - if (text === "") return null; - - const textLower = text.toLowerCase(); - const actualStartPosition = [...keywords] - .map((k) => textLower.indexOf(k.toLowerCase())) - .filter((i) => i > -1) - .slice(-1)[0]; - const startWithContext = Math.max(actualStartPosition - 120, 0); - - const top = startWithContext === 0 ? "" : "..."; - const tail = startWithContext + 240 < text.length ? "..." : ""; - - let summary = document.createElement("p"); - summary.classList.add("context"); - summary.textContent = top + text.substr(startWithContext, 240).trim() + tail; - - return summary; - }, -}; - -_ready(Search.init); diff --git a/docs/_static/skeleton.css b/docs/_static/skeleton.css deleted file mode 100644 index 467c878..0000000 --- a/docs/_static/skeleton.css +++ /dev/null @@ -1,296 +0,0 @@ -/* Some sane resets. */ -html { - height: 100%; -} - -body { - margin: 0; - min-height: 100%; -} - -/* All the flexbox magic! */ -body, -.sb-announcement, -.sb-content, -.sb-main, -.sb-container, -.sb-container__inner, -.sb-article-container, -.sb-footer-content, -.sb-header, -.sb-header-secondary, -.sb-footer { - display: flex; -} - -/* These order things vertically */ -body, -.sb-main, -.sb-article-container { - flex-direction: column; -} - -/* Put elements in the center */ -.sb-header, -.sb-header-secondary, -.sb-container, -.sb-content, -.sb-footer, -.sb-footer-content { - justify-content: center; -} -/* Put elements at the ends */ -.sb-article-container { - justify-content: space-between; -} - -/* These elements grow. */ -.sb-main, -.sb-content, -.sb-container, -article { - flex-grow: 1; -} - -/* Because padding making this wider is not fun */ -article { - box-sizing: border-box; -} - -/* The announcements element should never be wider than the page. */ -.sb-announcement { - max-width: 100%; -} - -.sb-sidebar-primary, -.sb-sidebar-secondary { - flex-shrink: 0; - width: 17rem; -} - -.sb-announcement__inner { - justify-content: center; - - box-sizing: border-box; - height: 3rem; - - overflow-x: auto; - white-space: nowrap; -} - -/* Sidebars, with checkbox-based toggle */ -.sb-sidebar-primary, -.sb-sidebar-secondary { - position: fixed; - height: 100%; - top: 0; -} - -.sb-sidebar-primary { - left: -17rem; - transition: left 250ms ease-in-out; -} -.sb-sidebar-secondary { - right: -17rem; - transition: right 250ms ease-in-out; -} - -.sb-sidebar-toggle { - display: none; -} -.sb-sidebar-overlay { - position: fixed; - top: 0; - width: 0; - height: 0; - - transition: width 0ms ease 250ms, height 0ms ease 250ms, opacity 250ms ease; - - opacity: 0; - background-color: rgba(0, 0, 0, 0.54); -} - -#sb-sidebar-toggle--primary:checked - ~ .sb-sidebar-overlay[for="sb-sidebar-toggle--primary"], -#sb-sidebar-toggle--secondary:checked - ~ .sb-sidebar-overlay[for="sb-sidebar-toggle--secondary"] { - width: 100%; - height: 100%; - opacity: 1; - transition: width 0ms ease, height 0ms ease, opacity 250ms ease; -} - -#sb-sidebar-toggle--primary:checked ~ .sb-container .sb-sidebar-primary { - left: 0; -} -#sb-sidebar-toggle--secondary:checked ~ .sb-container .sb-sidebar-secondary { - right: 0; -} - -/* Full-width mode */ -.drop-secondary-sidebar-for-full-width-content - .hide-when-secondary-sidebar-shown { - display: none !important; -} -.drop-secondary-sidebar-for-full-width-content .sb-sidebar-secondary { - display: none !important; -} - -/* Mobile views */ -.sb-page-width { - width: 100%; -} - -.sb-article-container, -.sb-footer-content__inner, -.drop-secondary-sidebar-for-full-width-content .sb-article, -.drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 100vw; -} - -.sb-article, -.match-content-width { - padding: 0 1rem; - box-sizing: border-box; -} - -@media (min-width: 32rem) { - .sb-article, - .match-content-width { - padding: 0 2rem; - } -} - -/* Tablet views */ -@media (min-width: 42rem) { - .sb-article-container { - width: auto; - } - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 42rem; - } - .sb-article, - .match-content-width { - width: 42rem; - } -} -@media (min-width: 46rem) { - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 46rem; - } - .sb-article, - .match-content-width { - width: 46rem; - } -} -@media (min-width: 50rem) { - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 50rem; - } - .sb-article, - .match-content-width { - width: 50rem; - } -} - -/* Tablet views */ -@media (min-width: 59rem) { - .sb-sidebar-secondary { - position: static; - } - .hide-when-secondary-sidebar-shown { - display: none !important; - } - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 59rem; - } - .sb-article, - .match-content-width { - width: 42rem; - } -} -@media (min-width: 63rem) { - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 63rem; - } - .sb-article, - .match-content-width { - width: 46rem; - } -} -@media (min-width: 67rem) { - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 67rem; - } - .sb-article, - .match-content-width { - width: 50rem; - } -} - -/* Desktop views */ -@media (min-width: 76rem) { - .sb-sidebar-primary { - position: static; - } - .hide-when-primary-sidebar-shown { - display: none !important; - } - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 59rem; - } - .sb-article, - .match-content-width { - width: 42rem; - } -} - -/* Full desktop views */ -@media (min-width: 80rem) { - .sb-article, - .match-content-width { - width: 46rem; - } - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 63rem; - } -} - -@media (min-width: 84rem) { - .sb-article, - .match-content-width { - width: 50rem; - } - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 67rem; - } -} - -@media (min-width: 88rem) { - .sb-footer-content__inner, - .drop-secondary-sidebar-for-full-width-content .sb-article, - .drop-secondary-sidebar-for-full-width-content .match-content-width { - width: 67rem; - } - .sb-page-width { - width: 88rem; - } -} diff --git a/docs/_static/sphinx_highlight.js b/docs/_static/sphinx_highlight.js deleted file mode 100644 index 8a96c69..0000000 --- a/docs/_static/sphinx_highlight.js +++ /dev/null @@ -1,154 +0,0 @@ -/* Highlighting utilities for Sphinx HTML documentation. */ -"use strict"; - -const SPHINX_HIGHLIGHT_ENABLED = true - -/** - * highlight a given string on a node by wrapping it in - * span elements with the given class name. - */ -const _highlight = (node, addItems, text, className) => { - if (node.nodeType === Node.TEXT_NODE) { - const val = node.nodeValue; - const parent = node.parentNode; - const pos = val.toLowerCase().indexOf(text); - if ( - pos >= 0 && - !parent.classList.contains(className) && - !parent.classList.contains("nohighlight") - ) { - let span; - - const closestNode = parent.closest("body, svg, foreignObject"); - const isInSVG = closestNode && closestNode.matches("svg"); - if (isInSVG) { - span = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); - } else { - span = document.createElement("span"); - span.classList.add(className); - } - - span.appendChild(document.createTextNode(val.substr(pos, text.length))); - const rest = document.createTextNode(val.substr(pos + text.length)); - parent.insertBefore( - span, - parent.insertBefore( - rest, - node.nextSibling - ) - ); - node.nodeValue = val.substr(0, pos); - /* There may be more occurrences of search term in this node. So call this - * function recursively on the remaining fragment. - */ - _highlight(rest, addItems, text, className); - - if (isInSVG) { - const rect = document.createElementNS( - "http://www.w3.org/2000/svg", - "rect" - ); - const bbox = parent.getBBox(); - rect.x.baseVal.value = bbox.x; - rect.y.baseVal.value = bbox.y; - rect.width.baseVal.value = bbox.width; - rect.height.baseVal.value = bbox.height; - rect.setAttribute("class", className); - addItems.push({ parent: parent, target: rect }); - } - } - } else if (node.matches && !node.matches("button, select, textarea")) { - node.childNodes.forEach((el) => _highlight(el, addItems, text, className)); - } -}; -const _highlightText = (thisNode, text, className) => { - let addItems = []; - _highlight(thisNode, addItems, text, className); - addItems.forEach((obj) => - obj.parent.insertAdjacentElement("beforebegin", obj.target) - ); -}; - -/** - * Small JavaScript module for the documentation. - */ -const SphinxHighlight = { - - /** - * highlight the search words provided in localstorage in the text - */ - highlightSearchWords: () => { - if (!SPHINX_HIGHLIGHT_ENABLED) return; // bail if no highlight - - // get and clear terms from localstorage - const url = new URL(window.location); - const highlight = - localStorage.getItem("sphinx_highlight_terms") - || url.searchParams.get("highlight") - || ""; - localStorage.removeItem("sphinx_highlight_terms") - url.searchParams.delete("highlight"); - window.history.replaceState({}, "", url); - - // get individual terms from highlight string - const terms = highlight.toLowerCase().split(/\s+/).filter(x => x); - if (terms.length === 0) return; // nothing to do - - // There should never be more than one element matching "div.body" - const divBody = document.querySelectorAll("div.body"); - const body = divBody.length ? divBody[0] : document.querySelector("body"); - window.setTimeout(() => { - terms.forEach((term) => _highlightText(body, term, "highlighted")); - }, 10); - - const searchBox = document.getElementById("searchbox"); - if (searchBox === null) return; - searchBox.appendChild( - document - .createRange() - .createContextualFragment( - '" - ) - ); - }, - - /** - * helper function to hide the search marks again - */ - hideSearchWords: () => { - document - .querySelectorAll("#searchbox .highlight-link") - .forEach((el) => el.remove()); - document - .querySelectorAll("span.highlighted") - .forEach((el) => el.classList.remove("highlighted")); - localStorage.removeItem("sphinx_highlight_terms") - }, - - initEscapeListener: () => { - // only install a listener if it is really needed - if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; - - document.addEventListener("keydown", (event) => { - // bail for input elements - if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; - // bail with special keys - if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return; - if (DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS && (event.key === "Escape")) { - SphinxHighlight.hideSearchWords(); - event.preventDefault(); - } - }); - }, -}; - -_ready(() => { - /* Do not call highlightSearchWords() when we are on the search page. - * It will highlight words from the *previous* search query. - */ - if (typeof Search === "undefined") SphinxHighlight.highlightSearchWords(); - SphinxHighlight.initEscapeListener(); -}); diff --git a/docs/_static/styles/furo-extensions.css b/docs/_static/styles/furo-extensions.css deleted file mode 100644 index bc447f2..0000000 --- a/docs/_static/styles/furo-extensions.css +++ /dev/null @@ -1,2 +0,0 @@ -#furo-sidebar-ad-placement{padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)}#furo-sidebar-ad-placement .ethical-sidebar{background:var(--color-background-secondary);border:none;box-shadow:none}#furo-sidebar-ad-placement .ethical-sidebar:hover{background:var(--color-background-hover)}#furo-sidebar-ad-placement .ethical-sidebar a{color:var(--color-foreground-primary)}#furo-sidebar-ad-placement .ethical-callout a{color:var(--color-foreground-secondary)!important}#furo-readthedocs-versions{background:transparent;display:block;position:static;width:100%}#furo-readthedocs-versions .rst-versions{background:#1a1c1e}#furo-readthedocs-versions .rst-current-version{background:var(--color-sidebar-item-background);cursor:unset}#furo-readthedocs-versions .rst-current-version:hover{background:var(--color-sidebar-item-background)}#furo-readthedocs-versions .rst-current-version .fa-book{color:var(--color-foreground-primary)}#furo-readthedocs-versions>.rst-other-versions{padding:0}#furo-readthedocs-versions>.rst-other-versions small{opacity:1}#furo-readthedocs-versions .injected .rst-versions{position:unset}#furo-readthedocs-versions:focus-within,#furo-readthedocs-versions:hover{box-shadow:0 0 0 1px var(--color-sidebar-background-border)}#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:hover .rst-current-version{background:#1a1c1e;font-size:inherit;height:auto;line-height:inherit;padding:12px;text-align:right}#furo-readthedocs-versions:focus-within .rst-current-version .fa-book,#furo-readthedocs-versions:hover .rst-current-version .fa-book{color:#fff;float:left}#furo-readthedocs-versions:focus-within .fa-caret-down,#furo-readthedocs-versions:hover .fa-caret-down{display:none}#furo-readthedocs-versions:focus-within .injected,#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:focus-within .rst-other-versions,#furo-readthedocs-versions:hover .injected,#furo-readthedocs-versions:hover .rst-current-version,#furo-readthedocs-versions:hover .rst-other-versions{display:block}#furo-readthedocs-versions:focus-within>.rst-current-version,#furo-readthedocs-versions:hover>.rst-current-version{display:none}.highlight:hover button.copybtn{color:var(--color-code-foreground)}.highlight button.copybtn{align-items:center;background-color:var(--color-code-background);border:none;color:var(--color-background-item);cursor:pointer;height:1.25em;opacity:1;right:.5rem;top:.625rem;transition:color .3s,opacity .3s;width:1.25em}.highlight button.copybtn:hover{background-color:var(--color-code-background);color:var(--color-brand-content)}.highlight button.copybtn:after{background-color:transparent;color:var(--color-code-foreground);display:none}.highlight button.copybtn.success{color:#22863a;transition:color 0ms}.highlight button.copybtn.success:after{display:block}.highlight button.copybtn svg{padding:0}body{--sd-color-primary:var(--color-brand-primary);--sd-color-primary-highlight:var(--color-brand-content);--sd-color-primary-text:var(--color-background-primary);--sd-color-shadow:rgba(0,0,0,.05);--sd-color-card-border:var(--color-card-border);--sd-color-card-border-hover:var(--color-brand-content);--sd-color-card-background:var(--color-card-background);--sd-color-card-text:var(--color-foreground-primary);--sd-color-card-header:var(--color-card-marginals-background);--sd-color-card-footer:var(--color-card-marginals-background);--sd-color-tabs-label-active:var(--color-brand-content);--sd-color-tabs-label-hover:var(--color-foreground-muted);--sd-color-tabs-label-inactive:var(--color-foreground-muted);--sd-color-tabs-underline-active:var(--color-brand-content);--sd-color-tabs-underline-hover:var(--color-foreground-border);--sd-color-tabs-underline-inactive:var(--color-background-border);--sd-color-tabs-overline:var(--color-background-border);--sd-color-tabs-underline:var(--color-background-border)}.sd-tab-content{box-shadow:0 -2px var(--sd-color-tabs-overline),0 1px var(--sd-color-tabs-underline)}.sd-card{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)}.sd-shadow-sm{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-md{box-shadow:0 .3rem .75rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-lg{box-shadow:0 .6rem 1.5rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-card-hover:hover{transform:none}.sd-cards-carousel{gap:.25rem;padding:.25rem}body{--tabs--label-text:var(--color-foreground-muted);--tabs--label-text--hover:var(--color-foreground-muted);--tabs--label-text--active:var(--color-brand-content);--tabs--label-text--active--hover:var(--color-brand-content);--tabs--label-background:transparent;--tabs--label-background--hover:transparent;--tabs--label-background--active:transparent;--tabs--label-background--active--hover:transparent;--tabs--padding-x:0.25em;--tabs--margin-x:1em;--tabs--border:var(--color-background-border);--tabs--label-border:transparent;--tabs--label-border--hover:var(--color-foreground-muted);--tabs--label-border--active:var(--color-brand-content);--tabs--label-border--active--hover:var(--color-brand-content)}[role=main] .container{max-width:none;padding-left:0;padding-right:0}.shadow.docutils{border:none;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)!important}.sphinx-bs .card{background-color:var(--color-background-secondary);color:var(--color-foreground)} -/*# sourceMappingURL=furo-extensions.css.map*/ \ No newline at end of file diff --git a/docs/_static/styles/furo-extensions.css.map b/docs/_static/styles/furo-extensions.css.map deleted file mode 100644 index 9ba5637..0000000 --- a/docs/_static/styles/furo-extensions.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"styles/furo-extensions.css","mappings":"AAGA,2BACE,oFACA,4CAKE,6CAHA,YACA,eAEA,CACA,kDACE,yCAEF,8CACE,sCAEJ,8CACE,kDAEJ,2BAGE,uBACA,cAHA,gBACA,UAEA,CAGA,yCACE,mBAEF,gDAEE,gDADA,YACA,CACA,sDACE,gDACF,yDACE,sCAEJ,+CACE,UACA,qDACE,UAGF,mDACE,eAEJ,yEAEE,4DAEA,mHASE,mBAPA,kBAEA,YADA,oBAGA,aADA,gBAIA,CAEA,qIAEE,WADA,UACA,CAEJ,uGACE,aAEF,iUAGE,cAEF,mHACE,aC1EJ,gCACE,mCAEF,0BAKE,mBAUA,8CACA,YAFA,mCAKA,eAZA,cALA,UASA,YADA,YAYA,iCAdA,YAcA,CAEA,gCAEE,8CADA,gCACA,CAEF,gCAGE,6BADA,mCADA,YAEA,CAEF,kCAEE,cADA,oBACA,CACA,wCACE,cAEJ,8BACE,UC5CN,KAEE,6CAA8C,CAC9C,uDAAwD,CACxD,uDAAwD,CAGxD,iCAAsC,CAGtC,+CAAgD,CAChD,uDAAwD,CACxD,uDAAwD,CACxD,oDAAqD,CACrD,6DAA8D,CAC9D,6DAA8D,CAG9D,uDAAwD,CACxD,yDAA0D,CAC1D,4DAA6D,CAC7D,2DAA4D,CAC5D,8DAA+D,CAC/D,iEAAkE,CAClE,uDAAwD,CACxD,wDAAyD,CAG3D,gBACE,qFAGF,SACE,6EAEF,cACE,uFAEF,cACE,uFAEF,cACE,uFAGF,qBACE,eAEF,mBACE,WACA,eChDF,KACE,gDAAiD,CACjD,uDAAwD,CACxD,qDAAsD,CACtD,4DAA6D,CAC7D,oCAAqC,CACrC,2CAA4C,CAC5C,4CAA6C,CAC7C,mDAAoD,CACpD,wBAAyB,CACzB,oBAAqB,CACrB,6CAA8C,CAC9C,gCAAiC,CACjC,yDAA0D,CAC1D,uDAAwD,CACxD,8DAA+D,CCbjE,uBACE,eACA,eACA,gBAGF,iBACE,YACA,+EAGF,iBACE,mDACA","sources":["webpack:///./src/furo/assets/styles/extensions/_readthedocs.sass","webpack:///./src/furo/assets/styles/extensions/_copybutton.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-design.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-inline-tabs.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-panels.sass"],"sourcesContent":["// This file contains the styles used for tweaking how ReadTheDoc's embedded\n// contents would show up inside the theme.\n\n#furo-sidebar-ad-placement\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n .ethical-sidebar\n // Remove the border and box-shadow.\n border: none\n box-shadow: none\n // Manage the background colors.\n background: var(--color-background-secondary)\n &:hover\n background: var(--color-background-hover)\n // Ensure the text is legible.\n a\n color: var(--color-foreground-primary)\n\n .ethical-callout a\n color: var(--color-foreground-secondary) !important\n\n#furo-readthedocs-versions\n position: static\n width: 100%\n background: transparent\n display: block\n\n // Make the background color fit with the theme's aesthetic.\n .rst-versions\n background: rgb(26, 28, 30)\n\n .rst-current-version\n cursor: unset\n background: var(--color-sidebar-item-background)\n &:hover\n background: var(--color-sidebar-item-background)\n .fa-book\n color: var(--color-foreground-primary)\n\n > .rst-other-versions\n padding: 0\n small\n opacity: 1\n\n .injected\n .rst-versions\n position: unset\n\n &:hover,\n &:focus-within\n box-shadow: 0 0 0 1px var(--color-sidebar-background-border)\n\n .rst-current-version\n // Undo the tweaks done in RTD's CSS\n font-size: inherit\n line-height: inherit\n height: auto\n text-align: right\n padding: 12px\n\n // Match the rest of the body\n background: #1a1c1e\n\n .fa-book\n float: left\n color: white\n\n .fa-caret-down\n display: none\n\n .rst-current-version,\n .rst-other-versions,\n .injected\n display: block\n\n > .rst-current-version\n display: none\n",".highlight\n &:hover button.copybtn\n color: var(--color-code-foreground)\n\n button.copybtn\n // Make it visible\n opacity: 1\n\n // Align things correctly\n align-items: center\n\n height: 1.25em\n width: 1.25em\n\n top: 0.625rem // $code-spacing-vertical\n right: 0.5rem\n\n // Make it look better\n color: var(--color-background-item)\n background-color: var(--color-code-background)\n border: none\n\n // Change to cursor to make it obvious that you can click on it\n cursor: pointer\n\n // Transition smoothly, for aesthetics\n transition: color 300ms, opacity 300ms\n\n &:hover\n color: var(--color-brand-content)\n background-color: var(--color-code-background)\n\n &::after\n display: none\n color: var(--color-code-foreground)\n background-color: transparent\n\n &.success\n transition: color 0ms\n color: #22863a\n &::after\n display: block\n\n svg\n padding: 0\n","body\n // Colors\n --sd-color-primary: var(--color-brand-primary)\n --sd-color-primary-highlight: var(--color-brand-content)\n --sd-color-primary-text: var(--color-background-primary)\n\n // Shadows\n --sd-color-shadow: rgba(0, 0, 0, 0.05)\n\n // Cards\n --sd-color-card-border: var(--color-card-border)\n --sd-color-card-border-hover: var(--color-brand-content)\n --sd-color-card-background: var(--color-card-background)\n --sd-color-card-text: var(--color-foreground-primary)\n --sd-color-card-header: var(--color-card-marginals-background)\n --sd-color-card-footer: var(--color-card-marginals-background)\n\n // Tabs\n --sd-color-tabs-label-active: var(--color-brand-content)\n --sd-color-tabs-label-hover: var(--color-foreground-muted)\n --sd-color-tabs-label-inactive: var(--color-foreground-muted)\n --sd-color-tabs-underline-active: var(--color-brand-content)\n --sd-color-tabs-underline-hover: var(--color-foreground-border)\n --sd-color-tabs-underline-inactive: var(--color-background-border)\n --sd-color-tabs-overline: var(--color-background-border)\n --sd-color-tabs-underline: var(--color-background-border)\n\n// Tabs\n.sd-tab-content\n box-shadow: 0 -2px var(--sd-color-tabs-overline), 0 1px var(--sd-color-tabs-underline)\n\n// Shadows\n.sd-card // Have a shadow by default\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n.sd-shadow-sm\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-md\n box-shadow: 0 0.3rem 0.75rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-lg\n box-shadow: 0 0.6rem 1.5rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Cards\n.sd-card-hover:hover // Don't change scale on hover\n transform: none\n\n.sd-cards-carousel // Have a bit of gap in the carousel by default\n gap: 0.25rem\n padding: 0.25rem\n","// This file contains styles to tweak sphinx-inline-tabs to work well with Furo.\n\nbody\n --tabs--label-text: var(--color-foreground-muted)\n --tabs--label-text--hover: var(--color-foreground-muted)\n --tabs--label-text--active: var(--color-brand-content)\n --tabs--label-text--active--hover: var(--color-brand-content)\n --tabs--label-background: transparent\n --tabs--label-background--hover: transparent\n --tabs--label-background--active: transparent\n --tabs--label-background--active--hover: transparent\n --tabs--padding-x: 0.25em\n --tabs--margin-x: 1em\n --tabs--border: var(--color-background-border)\n --tabs--label-border: transparent\n --tabs--label-border--hover: var(--color-foreground-muted)\n --tabs--label-border--active: var(--color-brand-content)\n --tabs--label-border--active--hover: var(--color-brand-content)\n","// This file contains styles to tweak sphinx-panels to work well with Furo.\n\n// sphinx-panels includes Bootstrap 4, which uses .container which can conflict\n// with docutils' `.. container::` directive.\n[role=\"main\"] .container\n max-width: initial\n padding-left: initial\n padding-right: initial\n\n// Make the panels look nicer!\n.shadow.docutils\n border: none\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Make panel colors respond to dark mode\n.sphinx-bs .card\n background-color: var(--color-background-secondary)\n color: var(--color-foreground)\n"],"names":[],"sourceRoot":""} \ No newline at end of file diff --git a/docs/_static/styles/furo.css b/docs/_static/styles/furo.css deleted file mode 100644 index 3d29a21..0000000 --- a/docs/_static/styles/furo.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{-webkit-text-size-adjust:100%;line-height:1.15}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}@media print{.content-icon-container,.headerlink,.mobile-header,.related-pages{display:none!important}.highlight{border:.1pt solid var(--color-foreground-border)}a,blockquote,dl,ol,pre,table,ul{page-break-inside:avoid}caption,figure,h1,h2,h3,h4,h5,h6,img{page-break-after:avoid;page-break-inside:avoid}dl,ol,ul{page-break-before:avoid}}.visually-hidden{clip:rect(0,0,0,0)!important;border:0!important;height:1px!important;margin:-1px!important;overflow:hidden!important;padding:0!important;position:absolute!important;white-space:nowrap!important;width:1px!important}:-moz-focusring{outline:auto}body{--font-stack:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;--font-stack--monospace:"SFMono-Regular",Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace;--font-size--normal:100%;--font-size--small:87.5%;--font-size--small--2:81.25%;--font-size--small--3:75%;--font-size--small--4:62.5%;--sidebar-caption-font-size:var(--font-size--small--2);--sidebar-item-font-size:var(--font-size--small);--sidebar-search-input-font-size:var(--font-size--small);--toc-font-size:var(--font-size--small--3);--toc-font-size--mobile:var(--font-size--normal);--toc-title-font-size:var(--font-size--small--4);--admonition-font-size:0.8125rem;--admonition-title-font-size:0.8125rem;--code-font-size:var(--font-size--small--2);--api-font-size:var(--font-size--small);--header-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*4);--header-padding:0.5rem;--sidebar-tree-space-above:1.5rem;--sidebar-caption-space-above:1rem;--sidebar-item-line-height:1rem;--sidebar-item-spacing-vertical:0.5rem;--sidebar-item-spacing-horizontal:1rem;--sidebar-item-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*2);--sidebar-expander-width:var(--sidebar-item-height);--sidebar-search-space-above:0.5rem;--sidebar-search-input-spacing-vertical:0.5rem;--sidebar-search-input-spacing-horizontal:0.5rem;--sidebar-search-input-height:1rem;--sidebar-search-icon-size:var(--sidebar-search-input-height);--toc-title-padding:0.25rem 0;--toc-spacing-vertical:1.5rem;--toc-spacing-horizontal:1.5rem;--toc-item-spacing-vertical:0.4rem;--toc-item-spacing-horizontal:1rem;--icon-search:url('data:image/svg+xml;charset=utf-8,');--icon-pencil:url('data:image/svg+xml;charset=utf-8,');--icon-abstract:url('data:image/svg+xml;charset=utf-8,');--icon-info:url('data:image/svg+xml;charset=utf-8,');--icon-flame:url('data:image/svg+xml;charset=utf-8,');--icon-question:url('data:image/svg+xml;charset=utf-8,');--icon-warning:url('data:image/svg+xml;charset=utf-8,');--icon-failure:url('data:image/svg+xml;charset=utf-8,');--icon-spark:url('data:image/svg+xml;charset=utf-8,');--color-admonition-title--caution:#ff9100;--color-admonition-title-background--caution:rgba(255,145,0,.2);--color-admonition-title--warning:#ff9100;--color-admonition-title-background--warning:rgba(255,145,0,.2);--color-admonition-title--danger:#ff5252;--color-admonition-title-background--danger:rgba(255,82,82,.2);--color-admonition-title--attention:#ff5252;--color-admonition-title-background--attention:rgba(255,82,82,.2);--color-admonition-title--error:#ff5252;--color-admonition-title-background--error:rgba(255,82,82,.2);--color-admonition-title--hint:#00c852;--color-admonition-title-background--hint:rgba(0,200,82,.2);--color-admonition-title--tip:#00c852;--color-admonition-title-background--tip:rgba(0,200,82,.2);--color-admonition-title--important:#00bfa5;--color-admonition-title-background--important:rgba(0,191,165,.2);--color-admonition-title--note:#00b0ff;--color-admonition-title-background--note:rgba(0,176,255,.2);--color-admonition-title--seealso:#448aff;--color-admonition-title-background--seealso:rgba(68,138,255,.2);--color-admonition-title--admonition-todo:grey;--color-admonition-title-background--admonition-todo:hsla(0,0%,50%,.2);--color-admonition-title:#651fff;--color-admonition-title-background:rgba(101,31,255,.2);--icon-admonition-default:var(--icon-abstract);--color-topic-title:#14b8a6;--color-topic-title-background:rgba(20,184,166,.2);--icon-topic-default:var(--icon-pencil);--color-problematic:#b30000;--color-foreground-primary:#000;--color-foreground-secondary:#5a5c63;--color-foreground-muted:#646776;--color-foreground-border:#878787;--color-background-primary:#fff;--color-background-secondary:#f8f9fb;--color-background-hover:#efeff4;--color-background-hover--transparent:#efeff400;--color-background-border:#eeebee;--color-background-item:#ccc;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#2962ff;--color-brand-content:#2a5adf;--color-api-background:var(--color-background-hover--transparent);--color-api-background-hover:var(--color-background-hover);--color-api-overall:var(--color-foreground-secondary);--color-api-name:var(--color-problematic);--color-api-pre-name:var(--color-problematic);--color-api-paren:var(--color-foreground-secondary);--color-api-keyword:var(--color-foreground-primary);--color-highlight-on-target:#ffc;--color-inline-code-background:var(--color-background-secondary);--color-highlighted-background:#def;--color-highlighted-text:var(--color-foreground-primary);--color-guilabel-background:#ddeeff80;--color-guilabel-border:#bedaf580;--color-guilabel-text:var(--color-foreground-primary);--color-admonition-background:transparent;--color-table-header-background:var(--color-background-secondary);--color-table-border:var(--color-background-border);--color-card-border:var(--color-background-secondary);--color-card-background:transparent;--color-card-marginals-background:var(--color-background-secondary);--color-header-background:var(--color-background-primary);--color-header-border:var(--color-background-border);--color-header-text:var(--color-foreground-primary);--color-sidebar-background:var(--color-background-secondary);--color-sidebar-background-border:var(--color-background-border);--color-sidebar-brand-text:var(--color-foreground-primary);--color-sidebar-caption-text:var(--color-foreground-muted);--color-sidebar-link-text:var(--color-foreground-secondary);--color-sidebar-link-text--top-level:var(--color-brand-primary);--color-sidebar-item-background:var(--color-sidebar-background);--color-sidebar-item-background--current:var( --color-sidebar-item-background );--color-sidebar-item-background--hover:linear-gradient(90deg,var(--color-background-hover--transparent) 0%,var(--color-background-hover) var(--sidebar-item-spacing-horizontal),var(--color-background-hover) 100%);--color-sidebar-item-expander-background:transparent;--color-sidebar-item-expander-background--hover:var( --color-background-hover );--color-sidebar-search-text:var(--color-foreground-primary);--color-sidebar-search-background:var(--color-background-secondary);--color-sidebar-search-background--focus:var(--color-background-primary);--color-sidebar-search-border:var(--color-background-border);--color-sidebar-search-icon:var(--color-foreground-muted);--color-toc-background:var(--color-background-primary);--color-toc-title-text:var(--color-foreground-muted);--color-toc-item-text:var(--color-foreground-secondary);--color-toc-item-text--hover:var(--color-foreground-primary);--color-toc-item-text--active:var(--color-brand-primary);--color-content-foreground:var(--color-foreground-primary);--color-content-background:transparent;--color-link:var(--color-brand-content);--color-link--hover:var(--color-brand-content);--color-link-underline:var(--color-background-border);--color-link-underline--hover:var(--color-foreground-border)}.only-light{display:block!important}html body .only-dark{display:none!important}@media not print{body[data-theme=dark]{--color-problematic:#ee5151;--color-foreground-primary:#ffffffcc;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#2b8cee;--color-brand-content:#368ce2;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body[data-theme=dark] .only-light{display:none!important}body[data-theme=dark] .only-dark{display:block!important}@media(prefers-color-scheme:dark){body:not([data-theme=light]){--color-problematic:#ee5151;--color-foreground-primary:#ffffffcc;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#2b8cee;--color-brand-content:#368ce2;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body:not([data-theme=light]) .only-light{display:none!important}body:not([data-theme=light]) .only-dark{display:block!important}}}body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto,body[data-theme=dark] .theme-toggle svg.theme-icon-when-dark,body[data-theme=light] .theme-toggle svg.theme-icon-when-light{display:block}body{font-family:var(--font-stack)}code,kbd,pre,samp{font-family:var(--font-stack--monospace)}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}article{line-height:1.5}h1,h2,h3,h4,h5,h6{border-radius:.5rem;font-weight:700;line-height:1.25;margin:.5rem -.5rem;padding-left:.5rem;padding-right:.5rem}h1+p,h2+p,h3+p,h4+p,h5+p,h6+p{margin-top:0}h1{font-size:2.5em;margin-bottom:1rem}h1,h2{margin-top:1.75rem}h2{font-size:2em}h3{font-size:1.5em}h4{font-size:1.25em}h5{font-size:1.125em}h6{font-size:1em}small{font-size:80%;opacity:75%}p{margin-bottom:.75rem;margin-top:.5rem}hr.docutils{background-color:var(--color-background-border);border:0;height:1px;margin:2rem 0;padding:0}.centered{text-align:center}a{color:var(--color-link);text-decoration:underline;text-decoration-color:var(--color-link-underline)}a:hover{color:var(--color-link--hover);text-decoration-color:var(--color-link-underline--hover)}a.muted-link{color:inherit}a.muted-link:hover{color:var(--color-link);text-decoration-color:var(--color-link-underline--hover)}html{overflow-x:hidden;overflow-y:scroll;scroll-behavior:smooth}.sidebar-scroll,.toc-scroll,article[role=main] *{scrollbar-color:var(--color-foreground-border) transparent;scrollbar-width:thin}.sidebar-scroll::-webkit-scrollbar,.toc-scroll::-webkit-scrollbar,article[role=main] ::-webkit-scrollbar{height:.25rem;width:.25rem}.sidebar-scroll::-webkit-scrollbar-thumb,.toc-scroll::-webkit-scrollbar-thumb,article[role=main] ::-webkit-scrollbar-thumb{background-color:var(--color-foreground-border);border-radius:.125rem}body,html{background:var(--color-background-primary);color:var(--color-foreground-primary);height:100%}article{background:var(--color-content-background);color:var(--color-content-foreground);overflow-wrap:break-word}.page{display:flex;min-height:100%}.mobile-header{background-color:var(--color-header-background);border-bottom:1px solid var(--color-header-border);color:var(--color-header-text);display:none;height:var(--header-height);width:100%;z-index:10}.mobile-header.scrolled{border-bottom:none;box-shadow:0 0 .2rem rgba(0,0,0,.1),0 .2rem .4rem rgba(0,0,0,.2)}.mobile-header .header-center a{color:var(--color-header-text);text-decoration:none}.main{display:flex;flex:1}.sidebar-drawer{background:var(--color-sidebar-background);border-right:1px solid var(--color-sidebar-background-border);box-sizing:border-box;display:flex;justify-content:flex-end;min-width:15em;width:calc(50% - 26em)}.sidebar-container,.toc-drawer{box-sizing:border-box;width:15em}.toc-drawer{background:var(--color-toc-background);padding-right:1rem}.sidebar-sticky,.toc-sticky{display:flex;flex-direction:column;height:min(100%,100vh);height:100vh;position:sticky;top:0}.sidebar-scroll,.toc-scroll{flex-grow:1;flex-shrink:1;overflow:auto;scroll-behavior:smooth}.content{display:flex;flex-direction:column;justify-content:space-between;padding:0 3em;width:46em}.icon{display:inline-block;height:1rem;width:1rem}.icon svg{height:100%;width:100%}.announcement{align-items:center;background-color:var(--color-announcement-background);color:var(--color-announcement-text);display:flex;height:var(--header-height);overflow-x:auto}.announcement+.page{min-height:calc(100% - var(--header-height))}.announcement-content{box-sizing:border-box;min-width:100%;padding:.5rem;text-align:center;white-space:nowrap}.announcement-content a{color:var(--color-announcement-text);text-decoration-color:var(--color-announcement-text)}.announcement-content a:hover{color:var(--color-announcement-text);text-decoration-color:var(--color-link--hover)}.no-js .theme-toggle-container{display:none}.theme-toggle-container{vertical-align:middle}.theme-toggle{background:transparent;border:none;cursor:pointer;padding:0}.theme-toggle svg{color:var(--color-foreground-primary);display:none;height:1rem;vertical-align:middle;width:1rem}.theme-toggle-header{float:left;padding:1rem .5rem}.nav-overlay-icon,.toc-overlay-icon{cursor:pointer;display:none}.nav-overlay-icon .icon,.toc-overlay-icon .icon{color:var(--color-foreground-secondary);height:1rem;width:1rem}.nav-overlay-icon,.toc-header-icon{align-items:center;justify-content:center}.toc-content-icon{height:1.5rem;width:1.5rem}.content-icon-container{display:flex;float:right;gap:.5rem;margin-bottom:1rem;margin-left:1rem;margin-top:1.5rem}.content-icon-container .edit-this-page svg{color:inherit;height:1rem;width:1rem}.sidebar-toggle{display:none;position:absolute}.sidebar-toggle[name=__toc]{left:20px}.sidebar-toggle:checked{left:40px}.overlay{background-color:rgba(0,0,0,.54);height:0;opacity:0;position:fixed;top:0;transition:width 0ms,height 0ms,opacity .25s ease-out;width:0}.sidebar-overlay{z-index:20}.toc-overlay{z-index:40}.sidebar-drawer{transition:left .25s ease-in-out;z-index:30}.toc-drawer{transition:right .25s ease-in-out;z-index:50}#__navigation:checked~.sidebar-overlay{height:100%;opacity:1;width:100%}#__navigation:checked~.page .sidebar-drawer{left:0;top:0}#__toc:checked~.toc-overlay{height:100%;opacity:1;width:100%}#__toc:checked~.page .toc-drawer{right:0;top:0}.back-to-top{background:var(--color-background-primary);border-radius:1rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 1px 0 hsla(220,9%,46%,.502);display:none;font-size:.8125rem;left:0;margin-left:50%;padding:.5rem .75rem .5rem .5rem;position:fixed;text-decoration:none;top:1rem;transform:translateX(-50%);z-index:10}.back-to-top svg{fill:currentColor;display:inline-block;height:1rem;width:1rem}.back-to-top span{margin-left:.25rem}.show-back-to-top .back-to-top{align-items:center;display:flex}@media(min-width:97em){html{font-size:110%}}@media(max-width:82em){.toc-content-icon{display:flex}.toc-drawer{border-left:1px solid var(--color-background-muted);height:100vh;position:fixed;right:-15em;top:0}.toc-tree{border-left:none;font-size:var(--toc-font-size--mobile)}.sidebar-drawer{width:calc(50% - 18.5em)}}@media(max-width:67em){.nav-overlay-icon{display:flex}.sidebar-drawer{height:100vh;left:-15em;position:fixed;top:0;width:15em}.toc-header-icon{display:flex}.theme-toggle-content,.toc-content-icon{display:none}.theme-toggle-header{display:block}.mobile-header{align-items:center;display:flex;justify-content:space-between;position:sticky;top:0}.mobile-header .header-left,.mobile-header .header-right{display:flex;height:var(--header-height);padding:0 var(--header-padding)}.mobile-header .header-left label,.mobile-header .header-right label{height:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:100%}.nav-overlay-icon .icon,.theme-toggle svg{height:1.25rem;width:1.25rem}:target{scroll-margin-top:var(--header-height)}.back-to-top{top:calc(var(--header-height) + .5rem)}.page{flex-direction:column;justify-content:center}.content{margin-left:auto;margin-right:auto}}@media(max-width:52em){.content{overflow-x:auto;width:100%}}@media(max-width:46em){.content{padding:0 1em}article aside.sidebar{float:none;margin:1rem 0;width:100%}}.admonition,.topic{background:var(--color-admonition-background);border-radius:.2rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1);font-size:var(--admonition-font-size);margin:1rem auto;overflow:hidden;padding:0 .5rem .5rem;page-break-inside:avoid}.admonition>:nth-child(2),.topic>:nth-child(2){margin-top:0}.admonition>:last-child,.topic>:last-child{margin-bottom:0}.admonition p.admonition-title,p.topic-title{font-size:var(--admonition-title-font-size);font-weight:500;line-height:1.3;margin:0 -.5rem .5rem;padding:.4rem .5rem .4rem 2rem;position:relative}.admonition p.admonition-title:before,p.topic-title:before{content:"";height:1rem;left:.5rem;position:absolute;width:1rem}p.admonition-title{background-color:var(--color-admonition-title-background)}p.admonition-title:before{background-color:var(--color-admonition-title);-webkit-mask-image:var(--icon-admonition-default);mask-image:var(--icon-admonition-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}p.topic-title{background-color:var(--color-topic-title-background)}p.topic-title:before{background-color:var(--color-topic-title);-webkit-mask-image:var(--icon-topic-default);mask-image:var(--icon-topic-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.admonition{border-left:.2rem solid var(--color-admonition-title)}.admonition.caution{border-left-color:var(--color-admonition-title--caution)}.admonition.caution>.admonition-title{background-color:var(--color-admonition-title-background--caution)}.admonition.caution>.admonition-title:before{background-color:var(--color-admonition-title--caution);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.warning{border-left-color:var(--color-admonition-title--warning)}.admonition.warning>.admonition-title{background-color:var(--color-admonition-title-background--warning)}.admonition.warning>.admonition-title:before{background-color:var(--color-admonition-title--warning);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.danger{border-left-color:var(--color-admonition-title--danger)}.admonition.danger>.admonition-title{background-color:var(--color-admonition-title-background--danger)}.admonition.danger>.admonition-title:before{background-color:var(--color-admonition-title--danger);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.attention{border-left-color:var(--color-admonition-title--attention)}.admonition.attention>.admonition-title{background-color:var(--color-admonition-title-background--attention)}.admonition.attention>.admonition-title:before{background-color:var(--color-admonition-title--attention);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.error{border-left-color:var(--color-admonition-title--error)}.admonition.error>.admonition-title{background-color:var(--color-admonition-title-background--error)}.admonition.error>.admonition-title:before{background-color:var(--color-admonition-title--error);-webkit-mask-image:var(--icon-failure);mask-image:var(--icon-failure)}.admonition.hint{border-left-color:var(--color-admonition-title--hint)}.admonition.hint>.admonition-title{background-color:var(--color-admonition-title-background--hint)}.admonition.hint>.admonition-title:before{background-color:var(--color-admonition-title--hint);-webkit-mask-image:var(--icon-question);mask-image:var(--icon-question)}.admonition.tip{border-left-color:var(--color-admonition-title--tip)}.admonition.tip>.admonition-title{background-color:var(--color-admonition-title-background--tip)}.admonition.tip>.admonition-title:before{background-color:var(--color-admonition-title--tip);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.important{border-left-color:var(--color-admonition-title--important)}.admonition.important>.admonition-title{background-color:var(--color-admonition-title-background--important)}.admonition.important>.admonition-title:before{background-color:var(--color-admonition-title--important);-webkit-mask-image:var(--icon-flame);mask-image:var(--icon-flame)}.admonition.note{border-left-color:var(--color-admonition-title--note)}.admonition.note>.admonition-title{background-color:var(--color-admonition-title-background--note)}.admonition.note>.admonition-title:before{background-color:var(--color-admonition-title--note);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition.seealso{border-left-color:var(--color-admonition-title--seealso)}.admonition.seealso>.admonition-title{background-color:var(--color-admonition-title-background--seealso)}.admonition.seealso>.admonition-title:before{background-color:var(--color-admonition-title--seealso);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.admonition-todo{border-left-color:var(--color-admonition-title--admonition-todo)}.admonition.admonition-todo>.admonition-title{background-color:var(--color-admonition-title-background--admonition-todo)}.admonition.admonition-todo>.admonition-title:before{background-color:var(--color-admonition-title--admonition-todo);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition-todo>.admonition-title{text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd{margin-left:2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:first-child{margin-top:.125rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list,dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:last-child{margin-bottom:.75rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list>dt{font-size:var(--font-size--small);text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd:empty{margin-bottom:.5rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul{margin-left:-1.2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p:nth-child(2){margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p+p:last-child:empty{margin-bottom:0;margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)>dt{color:var(--color-api-overall)}.sig:not(.sig-inline){background:var(--color-api-background);border-radius:.25rem;font-family:var(--font-stack--monospace);font-size:var(--api-font-size);font-weight:700;margin-left:-.25rem;margin-right:-.25rem;padding:.25rem .5rem .25rem 3em;text-indent:-2.5em;transition:background .1s ease-out}.sig:not(.sig-inline):hover{background:var(--color-api-background-hover)}.sig:not(.sig-inline) a.reference .viewcode-link{font-weight:400;width:3.5rem}em.property{font-style:normal}em.property:first-child{color:var(--color-api-keyword)}.sig-name{color:var(--color-api-name)}.sig-prename{color:var(--color-api-pre-name);font-weight:400}.sig-paren{color:var(--color-api-paren)}.sig-param{font-style:normal}.versionmodified{font-style:italic}div.deprecated p,div.versionadded p,div.versionchanged p{margin-bottom:.125rem;margin-top:.125rem}.viewcode-back,.viewcode-link{float:right;text-align:right}.line-block{margin-bottom:.75rem;margin-top:.5rem}.line-block .line-block{margin-bottom:0;margin-top:0;padding-left:1rem}.code-block-caption,article p.caption,table>caption{font-size:var(--font-size--small);text-align:center}.toctree-wrapper.compound .caption,.toctree-wrapper.compound :not(.caption)>.caption-text{font-size:var(--font-size--small);margin-bottom:0;text-align:initial;text-transform:uppercase}.toctree-wrapper.compound>ul{margin-bottom:0;margin-top:0}.sig-inline,code.literal{background:var(--color-inline-code-background);border-radius:.2em;font-size:var(--font-size--small--2);padding:.1em .2em}pre.literal-block .sig-inline,pre.literal-block code.literal{font-size:inherit;padding:0}p .sig-inline,p code.literal{border:1px solid var(--color-background-border)}.sig-inline{font-family:var(--font-stack--monospace)}div[class*=" highlight-"],div[class^=highlight-]{display:flex;margin:1em 0}div[class*=" highlight-"] .table-wrapper,div[class^=highlight-] .table-wrapper,pre{margin:0;padding:0}pre{overflow:auto}article[role=main] .highlight pre{line-height:1.5}.highlight pre,pre.literal-block{font-size:var(--code-font-size);padding:.625rem .875rem}pre.literal-block{background-color:var(--color-code-background);border-radius:.2rem;color:var(--color-code-foreground);margin-bottom:1rem;margin-top:1rem}.highlight{border-radius:.2rem;width:100%}.highlight .gp,.highlight span.linenos{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.highlight .hll{display:block;margin-left:-.875rem;margin-right:-.875rem;padding-left:.875rem;padding-right:.875rem}.code-block-caption{background-color:var(--color-code-background);border-bottom:1px solid;border-radius:.25rem;border-bottom-left-radius:0;border-bottom-right-radius:0;border-color:var(--color-background-border);color:var(--color-code-foreground);display:flex;font-weight:300;padding:.625rem .875rem}.code-block-caption+div[class]{margin-top:0}.code-block-caption+div[class] pre{border-top-left-radius:0;border-top-right-radius:0}.highlighttable{display:block;width:100%}.highlighttable tbody{display:block}.highlighttable tr{display:flex}.highlighttable td.linenos{background-color:var(--color-code-background);border-bottom-left-radius:.2rem;border-top-left-radius:.2rem;color:var(--color-code-foreground);padding:.625rem 0 .625rem .875rem}.highlighttable .linenodiv{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;font-size:var(--code-font-size);padding-right:.875rem}.highlighttable td.code{display:block;flex:1;overflow:hidden;padding:0}.highlighttable td.code .highlight{border-bottom-left-radius:0;border-top-left-radius:0}.highlight span.linenos{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;display:inline-block;margin-right:.875rem;padding-left:0;padding-right:.875rem}.footnote-reference{font-size:var(--font-size--small--4);vertical-align:super}dl.footnote.brackets{color:var(--color-foreground-secondary);display:grid;font-size:var(--font-size--small);grid-template-columns:max-content auto}dl.footnote.brackets dt{margin:0}dl.footnote.brackets dt>.fn-backref{margin-left:.25rem}dl.footnote.brackets dt:after{content:":"}dl.footnote.brackets dt .brackets:before{content:"["}dl.footnote.brackets dt .brackets:after{content:"]"}dl.footnote.brackets dd{margin:0;padding:0 1rem}aside.footnote{color:var(--color-foreground-secondary);font-size:var(--font-size--small)}aside.footnote>span,div.citation>span{float:left;font-weight:500;padding-right:.25rem}aside.footnote>p,div.citation>p{margin-left:2rem}img{box-sizing:border-box;height:auto;max-width:100%}article .figure,article figure{border-radius:.2rem;margin:0}article .figure :last-child,article figure :last-child{margin-bottom:0}article .align-left{clear:left;float:left;margin:0 1rem 1rem}article .align-right{clear:right;float:right;margin:0 1rem 1rem}article .align-center,article .align-default{display:block;margin-left:auto;margin-right:auto;text-align:center}article table.align-default{display:table;text-align:initial}.domainindex-jumpbox,.genindex-jumpbox{border-bottom:1px solid var(--color-background-border);border-top:1px solid var(--color-background-border);padding:.25rem}.domainindex-section h2,.genindex-section h2{margin-bottom:.5rem;margin-top:.75rem}.domainindex-section ul,.genindex-section ul{margin-bottom:0;margin-top:0}ol,ul{margin-bottom:1rem;margin-top:1rem;padding-left:1.2rem}ol li>p:first-child,ul li>p:first-child{margin-bottom:.25rem;margin-top:.25rem}ol li>p:last-child,ul li>p:last-child{margin-top:.25rem}ol li>ol,ol li>ul,ul li>ol,ul li>ul{margin-bottom:.5rem;margin-top:.5rem}ol.arabic{list-style:decimal}ol.loweralpha{list-style:lower-alpha}ol.upperalpha{list-style:upper-alpha}ol.lowerroman{list-style:lower-roman}ol.upperroman{list-style:upper-roman}.simple li>ol,.simple li>ul,.toctree-wrapper li>ol,.toctree-wrapper li>ul{margin-bottom:0;margin-top:0}.field-list dt,.option-list dt,dl.footnote dt,dl.glossary dt,dl.simple dt,dl:not([class]) dt{font-weight:500;margin-top:.25rem}.field-list dt+dt,.option-list dt+dt,dl.footnote dt+dt,dl.glossary dt+dt,dl.simple dt+dt,dl:not([class]) dt+dt{margin-top:0}.field-list dt .classifier:before,.option-list dt .classifier:before,dl.footnote dt .classifier:before,dl.glossary dt .classifier:before,dl.simple dt .classifier:before,dl:not([class]) dt .classifier:before{content:":";margin-left:.2rem;margin-right:.2rem}.field-list dd ul,.field-list dd>p:first-child,.option-list dd ul,.option-list dd>p:first-child,dl.footnote dd ul,dl.footnote dd>p:first-child,dl.glossary dd ul,dl.glossary dd>p:first-child,dl.simple dd ul,dl.simple dd>p:first-child,dl:not([class]) dd ul,dl:not([class]) dd>p:first-child{margin-top:.125rem}.field-list dd ul,.option-list dd ul,dl.footnote dd ul,dl.glossary dd ul,dl.simple dd ul,dl:not([class]) dd ul{margin-bottom:.125rem}.math-wrapper{overflow-x:auto;width:100%}div.math{position:relative;text-align:center}div.math .headerlink,div.math:focus .headerlink{display:none}div.math:hover .headerlink{display:inline-block}div.math span.eqno{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);z-index:1}abbr[title]{cursor:help}.problematic{color:var(--color-problematic)}kbd:not(.compound){background-color:var(--color-background-secondary);border:1px solid var(--color-foreground-border);border-radius:.2rem;box-shadow:0 .0625rem 0 rgba(0,0,0,.2),inset 0 0 0 .125rem var(--color-background-primary);color:var(--color-foreground-primary);display:inline-block;font-size:var(--font-size--small--3);margin:0 .2rem;padding:0 .2rem;vertical-align:text-bottom}blockquote{background:var(--color-background-secondary);border-left:4px solid var(--color-background-border);margin-left:0;margin-right:0;padding:.5rem 1rem}blockquote .attribution{font-weight:600;text-align:right}blockquote.highlights,blockquote.pull-quote{font-size:1.25em}blockquote.epigraph,blockquote.pull-quote{border-left-width:0;border-radius:.5rem}blockquote.highlights{background:transparent;border-left-width:0}p .reference img{vertical-align:middle}p.rubric{font-size:1.125em;font-weight:700;line-height:1.25}dd p.rubric{font-size:var(--font-size--small);font-weight:inherit;line-height:inherit;text-transform:uppercase}article .sidebar{background-color:var(--color-background-secondary);border:1px solid var(--color-background-border);border-radius:.2rem;clear:right;float:right;margin-left:1rem;margin-right:0;width:30%}article .sidebar>*{padding-left:1rem;padding-right:1rem}article .sidebar>ol,article .sidebar>ul{padding-left:2.2rem}article .sidebar .sidebar-title{border-bottom:1px solid var(--color-background-border);font-weight:500;margin:0;padding:.5rem 1rem}.table-wrapper{margin-bottom:.5rem;margin-top:1rem;overflow-x:auto;padding:.2rem .2rem .75rem;width:100%}table.docutils{border-collapse:collapse;border-radius:.2rem;border-spacing:0;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)}table.docutils th{background:var(--color-table-header-background)}table.docutils td,table.docutils th{border-bottom:1px solid var(--color-table-border);border-left:1px solid var(--color-table-border);border-right:1px solid var(--color-table-border);padding:0 .25rem}table.docutils td p,table.docutils th p{margin:.25rem}table.docutils td:first-child,table.docutils th:first-child{border-left:none}table.docutils td:last-child,table.docutils th:last-child{border-right:none}table.docutils td.text-left,table.docutils th.text-left{text-align:left}table.docutils td.text-right,table.docutils th.text-right{text-align:right}table.docutils td.text-center,table.docutils th.text-center{text-align:center}:target{scroll-margin-top:.5rem}@media(max-width:67em){:target{scroll-margin-top:calc(.5rem + var(--header-height))}section>span:target{scroll-margin-top:calc(.8rem + var(--header-height))}}.headerlink{font-weight:100;-webkit-user-select:none;-moz-user-select:none;user-select:none}.code-block-caption>.headerlink,dl dt>.headerlink,figcaption p>.headerlink,h1>.headerlink,h2>.headerlink,h3>.headerlink,h4>.headerlink,h5>.headerlink,h6>.headerlink,p.caption>.headerlink,table>caption>.headerlink{margin-left:.5rem;visibility:hidden}.code-block-caption:hover>.headerlink,dl dt:hover>.headerlink,figcaption p:hover>.headerlink,h1:hover>.headerlink,h2:hover>.headerlink,h3:hover>.headerlink,h4:hover>.headerlink,h5:hover>.headerlink,h6:hover>.headerlink,p.caption:hover>.headerlink,table>caption:hover>.headerlink{visibility:visible}.code-block-caption>.toc-backref,dl dt>.toc-backref,figcaption p>.toc-backref,h1>.toc-backref,h2>.toc-backref,h3>.toc-backref,h4>.toc-backref,h5>.toc-backref,h6>.toc-backref,p.caption>.toc-backref,table>caption>.toc-backref{color:inherit;text-decoration-line:none}figure:hover>figcaption>p>.headerlink,table:hover>caption>.headerlink{visibility:visible}:target>h1:first-of-type,:target>h2:first-of-type,:target>h3:first-of-type,:target>h4:first-of-type,:target>h5:first-of-type,:target>h6:first-of-type,span:target~h1:first-of-type,span:target~h2:first-of-type,span:target~h3:first-of-type,span:target~h4:first-of-type,span:target~h5:first-of-type,span:target~h6:first-of-type{background-color:var(--color-highlight-on-target)}:target>h1:first-of-type code.literal,:target>h2:first-of-type code.literal,:target>h3:first-of-type code.literal,:target>h4:first-of-type code.literal,:target>h5:first-of-type code.literal,:target>h6:first-of-type code.literal,span:target~h1:first-of-type code.literal,span:target~h2:first-of-type code.literal,span:target~h3:first-of-type code.literal,span:target~h4:first-of-type code.literal,span:target~h5:first-of-type code.literal,span:target~h6:first-of-type code.literal{background-color:transparent}.literal-block-wrapper:target .code-block-caption,.this-will-duplicate-information-and-it-is-still-useful-here li :target,figure:target,table:target>caption{background-color:var(--color-highlight-on-target)}dt:target{background-color:var(--color-highlight-on-target)!important}.footnote-reference:target,.footnote>dt:target+dd{background-color:var(--color-highlight-on-target)}.guilabel{background-color:var(--color-guilabel-background);border:1px solid var(--color-guilabel-border);border-radius:.5em;color:var(--color-guilabel-text);font-size:.9em;padding:0 .3em}footer{display:flex;flex-direction:column;font-size:var(--font-size--small);margin-top:2rem}.bottom-of-page{align-items:center;border-top:1px solid var(--color-background-border);color:var(--color-foreground-secondary);display:flex;justify-content:space-between;line-height:1.5;margin-top:1rem;padding-bottom:1rem;padding-top:1rem}@media(max-width:46em){.bottom-of-page{flex-direction:column-reverse;gap:.25rem;text-align:center}}.bottom-of-page .left-details{font-size:var(--font-size--small)}.bottom-of-page .right-details{display:flex;flex-direction:column;gap:.25rem;text-align:right}.bottom-of-page .icons{display:flex;font-size:1rem;gap:.25rem;justify-content:flex-end}.bottom-of-page .icons a{text-decoration:none}.bottom-of-page .icons img,.bottom-of-page .icons svg{font-size:1.125rem;height:1em;width:1em}.related-pages a{align-items:center;display:flex;text-decoration:none}.related-pages a:hover .page-info .title{color:var(--color-link);text-decoration:underline;text-decoration-color:var(--color-link-underline)}.related-pages a svg.furo-related-icon,.related-pages a svg.furo-related-icon>use{color:var(--color-foreground-border);flex-shrink:0;height:.75rem;margin:0 .5rem;width:.75rem}.related-pages a.next-page{clear:right;float:right;max-width:50%;text-align:right}.related-pages a.prev-page{clear:left;float:left;max-width:50%}.related-pages a.prev-page svg{transform:rotate(180deg)}.page-info{display:flex;flex-direction:column;overflow-wrap:anywhere}.next-page .page-info{align-items:flex-end}.page-info .context{align-items:center;color:var(--color-foreground-muted);display:flex;font-size:var(--font-size--small);padding-bottom:.1rem;text-decoration:none}ul.search{list-style:none;padding-left:0}ul.search li{border-bottom:1px solid var(--color-background-border);padding:1rem 0}[role=main] .highlighted{background-color:var(--color-highlighted-background);color:var(--color-highlighted-text)}.sidebar-brand{display:flex;flex-direction:column;flex-shrink:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none}.sidebar-brand-text{color:var(--color-sidebar-brand-text);font-size:1.5rem;overflow-wrap:break-word}.sidebar-brand-text,.sidebar-logo-container{margin:var(--sidebar-item-spacing-vertical) 0}.sidebar-logo{display:block;margin:0 auto;max-width:100%}.sidebar-search-container{align-items:center;background:var(--color-sidebar-search-background);display:flex;margin-top:var(--sidebar-search-space-above);position:relative}.sidebar-search-container:focus-within,.sidebar-search-container:hover{background:var(--color-sidebar-search-background--focus)}.sidebar-search-container:before{background-color:var(--color-sidebar-search-icon);content:"";height:var(--sidebar-search-icon-size);left:var(--sidebar-item-spacing-horizontal);-webkit-mask-image:var(--icon-search);mask-image:var(--icon-search);position:absolute;width:var(--sidebar-search-icon-size)}.sidebar-search{background:transparent;border:none;border-bottom:1px solid var(--color-sidebar-search-border);border-top:1px solid var(--color-sidebar-search-border);box-sizing:border-box;color:var(--color-sidebar-search-foreground);padding:var(--sidebar-search-input-spacing-vertical) var(--sidebar-search-input-spacing-horizontal) var(--sidebar-search-input-spacing-vertical) calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size));width:100%;z-index:10}.sidebar-search:focus{outline:none}.sidebar-search::-moz-placeholder{font-size:var(--sidebar-search-input-font-size)}.sidebar-search::placeholder{font-size:var(--sidebar-search-input-font-size)}#searchbox .highlight-link{margin:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0;text-align:center}#searchbox .highlight-link a{color:var(--color-sidebar-search-icon);font-size:var(--font-size--small--2)}.sidebar-tree{font-size:var(--sidebar-item-font-size);margin-bottom:var(--sidebar-item-spacing-vertical);margin-top:var(--sidebar-tree-space-above)}.sidebar-tree ul{display:flex;flex-direction:column;list-style:none;margin-bottom:0;margin-top:0;padding:0}.sidebar-tree li{margin:0;position:relative}.sidebar-tree li>ul{margin-left:var(--sidebar-item-spacing-horizontal)}.sidebar-tree .icon,.sidebar-tree .reference{color:var(--color-sidebar-link-text)}.sidebar-tree .reference{box-sizing:border-box;display:inline-block;height:100%;line-height:var(--sidebar-item-line-height);overflow-wrap:anywhere;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none;width:100%}.sidebar-tree .reference:hover{background:var(--color-sidebar-item-background--hover)}.sidebar-tree .reference.external:after{color:var(--color-sidebar-link-text);content:url("data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='12' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23607D8B' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M0 0h24v24H0z' stroke='none'/%3E%3Cpath d='M11 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-5M10 14 20 4M15 4h5v5'/%3E%3C/svg%3E");margin:0 .25rem;vertical-align:middle}.sidebar-tree .current-page>.reference{font-weight:700}.sidebar-tree label{align-items:center;cursor:pointer;display:flex;height:var(--sidebar-item-height);justify-content:center;position:absolute;right:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:var(--sidebar-expander-width)}.sidebar-tree .caption,.sidebar-tree :not(.caption)>.caption-text{color:var(--color-sidebar-caption-text);font-size:var(--sidebar-caption-font-size);font-weight:700;margin:var(--sidebar-caption-space-above) 0 0 0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-transform:uppercase}.sidebar-tree li.has-children>.reference{padding-right:var(--sidebar-expander-width)}.sidebar-tree .toctree-l1>.reference,.sidebar-tree .toctree-l1>label .icon{color:var(--color-sidebar-link-text--top-level)}.sidebar-tree label{background:var(--color-sidebar-item-expander-background)}.sidebar-tree label:hover{background:var(--color-sidebar-item-expander-background--hover)}.sidebar-tree .current>.reference{background:var(--color-sidebar-item-background--current)}.sidebar-tree .current>.reference:hover{background:var(--color-sidebar-item-background--hover)}.toctree-checkbox{display:none;position:absolute}.toctree-checkbox~ul{display:none}.toctree-checkbox~label .icon svg{transform:rotate(90deg)}.toctree-checkbox:checked~ul{display:block}.toctree-checkbox:checked~label .icon svg{transform:rotate(-90deg)}.toc-title-container{padding:var(--toc-title-padding);padding-top:var(--toc-spacing-vertical)}.toc-title{color:var(--color-toc-title-text);font-size:var(--toc-title-font-size);padding-left:var(--toc-spacing-horizontal);text-transform:uppercase}.no-toc{display:none}.toc-tree-container{padding-bottom:var(--toc-spacing-vertical)}.toc-tree{border-left:1px solid var(--color-background-border);font-size:var(--toc-font-size);line-height:1.3;padding-left:calc(var(--toc-spacing-horizontal) - var(--toc-item-spacing-horizontal))}.toc-tree>ul>li:first-child{padding-top:0}.toc-tree>ul>li:first-child>ul{padding-left:0}.toc-tree>ul>li:first-child>a{display:none}.toc-tree ul{list-style-type:none;margin-bottom:0;margin-top:0;padding-left:var(--toc-item-spacing-horizontal)}.toc-tree li{padding-top:var(--toc-item-spacing-vertical)}.toc-tree li.scroll-current>.reference{color:var(--color-toc-item-text--active);font-weight:700}.toc-tree .reference{color:var(--color-toc-item-text);overflow-wrap:anywhere;text-decoration:none}.toc-scroll{max-height:100vh;overflow-y:scroll}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here){background:rgba(255,0,0,.25);color:var(--color-problematic)}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here):before{content:"ERROR: Adding a table of contents in Furo-based documentation is unnecessary, and does not work well with existing styling.Add a 'this-will-duplicate-information-and-it-is-still-useful-here' class, if you want an escape hatch."}.text-align\:left>p{text-align:left}.text-align\:center>p{text-align:center}.text-align\:right>p{text-align:right} -/*# sourceMappingURL=furo.css.map*/ \ No newline at end of file diff --git a/docs/_static/styles/furo.css.map b/docs/_static/styles/furo.css.map deleted file mode 100644 index 1924b33..0000000 --- a/docs/_static/styles/furo.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"styles/furo.css","mappings":"AAAA,2EAA2E,CAU3E,KAEE,6BAA8B,CAD9B,gBAEF,CASA,KACE,QACF,CAMA,KACE,aACF,CAOA,GACE,aAAc,CACd,cACF,CAUA,GACE,sBAAuB,CACvB,QAAS,CACT,gBACF,CAOA,IACE,+BAAiC,CACjC,aACF,CASA,EACE,4BACF,CAOA,YACE,kBAAmB,CACnB,yBAA0B,CAC1B,gCACF,CAMA,SAEE,kBACF,CAOA,cAGE,+BAAiC,CACjC,aACF,CAeA,QAEE,aAAc,CACd,aAAc,CACd,iBAAkB,CAClB,uBACF,CAEA,IACE,aACF,CAEA,IACE,SACF,CASA,IACE,iBACF,CAUA,sCAKE,mBAAoB,CACpB,cAAe,CACf,gBAAiB,CACjB,QACF,CAOA,aAEE,gBACF,CAOA,cAEE,mBACF,CAMA,gDAIE,yBACF,CAMA,wHAIE,iBAAkB,CAClB,SACF,CAMA,4GAIE,6BACF,CAMA,SACE,0BACF,CASA,OACE,qBAAsB,CACtB,aAAc,CACd,aAAc,CACd,cAAe,CACf,SAAU,CACV,kBACF,CAMA,SACE,uBACF,CAMA,SACE,aACF,CAOA,6BAEE,qBAAsB,CACtB,SACF,CAMA,kFAEE,WACF,CAOA,cACE,4BAA6B,CAC7B,mBACF,CAMA,yCACE,uBACF,CAOA,6BACE,yBAA0B,CAC1B,YACF,CASA,QACE,aACF,CAMA,QACE,iBACF,CAiBA,kBACE,YACF,CCvVA,aAcE,kEACE,uBAOF,WACE,iDAMF,gCACE,wBAEF,qCAEE,uBADA,uBACA,CAEF,SACE,wBAtBA,CCpBJ,iBAOE,6BAEA,mBANA,qBAEA,sBACA,0BAFA,oBAHA,4BAOA,6BANA,mBAOA,CAEF,gBACE,aCPF,KCGE,mHAEA,wGAGA,wBAAyB,CACzB,wBAAyB,CACzB,4BAA6B,CAC7B,yBAA0B,CAC1B,2BAA4B,CAG5B,sDAAuD,CACvD,gDAAiD,CACjD,wDAAyD,CAGzD,0CAA2C,CAC3C,gDAAiD,CACjD,gDAAiD,CAKjD,gCAAiC,CACjC,sCAAuC,CAGvC,2CAA4C,CAG5C,uCAAwC,CChCxC,+FAGA,uBAAwB,CAGxB,iCAAkC,CAClC,kCAAmC,CAEnC,+BAAgC,CAChC,sCAAuC,CACvC,sCAAuC,CACvC,qGAIA,mDAAoD,CAEpD,mCAAoC,CACpC,8CAA+C,CAC/C,gDAAiD,CACjD,kCAAmC,CACnC,6DAA8D,CAG9D,6BAA8B,CAC9B,6BAA8B,CAC9B,+BAAgC,CAChC,kCAAmC,CACnC,kCAAmC,CCPjC,ukBCYA,srCAZF,kaCVA,mLAOA,oTAWA,2UAaA,0CACA,gEACA,0CAGA,gEAUA,yCACA,+DAGA,4CACA,CACA,iEAGA,sGACA,uCACA,4DAGA,sCACA,2DAEA,4CACA,kEACA,oGACA,CAEA,0GACA,+CAGA,+MAOA,+EACA,wCAIA,4DACA,sEACA,kEACA,sEACA,gDAGA,+DACA,0CACA,gEACA,gGACA,CAGA,2DACA,qDAGA,0CACA,8CACA,oDACA,oDL7GF,iCAEA,iEAME,oCKyGA,yDAIA,sCACA,kCACA,sDAGA,0CACA,kEACA,oDAEA,sDAGA,oCACA,oEAIA,CAGA,yDAGA,qDACA,oDAGA,6DAIA,iEAGA,2DAEA,2DL9IE,4DAEA,gEAIF,gEKgGA,gFAIA,oNAOA,qDAEA,gFAIA,4DAIA,oEAMA,yEAIA,6DACA,0DAGA,uDAGA,qDAEA,wDLpII,6DAEA,yDACE,2DAMN,uCAIA,yCACE,8CAGF,sDMjDA,6DAKA,oCAIA,4CACA,kBAGF,sBAMA,2BAME,qCAGA,qCAEA,iCAEA,+BAEA,mCAEA,qCAIA,CACA,gCACA,gDAKA,kCAIA,6BAEA,0CAQA,kCAIF,8BAGE,8BACA,uCAGF,sCAKE,kCAEA,sDAGA,iCACE,CACA,2FAGA,gCACE,CACA,+DCzEJ,wCAEA,sBAEF,yDAEE,mCACA,wDAGA,2GAGA,wIACE,gDAMJ,kCAGE,6BACA,0CAGA,gEACA,8BACA,uCAKA,sCAIA,kCACA,sDACA,iCACA,sCAOA,sDAKE,gGAIE,+CAGN,sBAEE,yCAMA,0BAOA,yLAKA,aACA,MAEF,6BACE,mBAEA,wCAEF,wCAIE,kCAGA,SACA,kCAKA,mBAGA,CAJA,eACA,CAHF,gBAEE,CAWA,mBACA,mBACA,mDAIA,YACA,mBACA,CAEE,kBAMF,OAPE,kBAOF,oCACA,yCAEA,wBAEA,cADA,WACA,GACA,oBACA,CAFA,gBAEA,aAGF,+CAEE,UAJE,wBAEJ,CAFI,SAIF,CACA,2BACA,GAGA,uBACE,CAJF,yBAGA,CACE,iDACA,uCAEA,yDACE,cACA,wDAKN,yDAIE,uBAEF,kBACE,uBAEA,kDAKA,0DAEA,CAHA,oBAIA,0GAWA,aAEA,CAHA,YAGA,4HAKF,+CAGE,sBAEF,WAKE,0CAGA,CANA,qCAGA,CAJA,WAOA,SAIA,0CACE,CALF,qCAIA,CACE,wBAEA,mBAEJ,gBACE,gBAIA,+CAKF,CAIE,kDAEA,CANF,8BAIE,CAEA,YAGA,CAfF,2BACE,CAHA,UAEF,CAYE,UAGA,2CACF,iEAOE,iCACA,8BAGA,wCAIA,wBAMI,0CAKF,CATA,6DAGA,CALF,qBAEE,CASA,YACA,yBAGA,CAEE,cAKN,CAPI,sBAOJ,gCAGE,qBAEA,WACA,aACA,sCAEA,mBACA,6BAGA,uEADA,qBACA,6BAIA,yBACA,qCAEE,UAEA,YACA,sBAEF,8BAGA,CAPE,aACA,WAMF,4BACE,sBACA,WAMJ,uBACE,cAYE,mBAXA,qDAKA,qCAGA,CAEA,YACA,CAHA,2BAEA,CACA,oCAEA,4CACA,uBAIA,sBAEJ,eAFI,cAIF,iBACE,CAHJ,kBAGI,yBAEA,oCAIA,qDAMF,mEAGE,+CAKA,gCAEA,qCAGA,oCAGE,sBACA,CAJF,WAEE,CAFF,eAEE,SAEA,mBACA,qCACE,aACA,CAFF,YADA,qBACA,WAEE,sBACA,kEAEN,cAEE,CAFF,YAEE,iDAKA,uCAIA,2DAKA,kBAEA,CAHA,sBAGA,mBACA,0BAEJ,yBAII,aADA,WACA,CAMF,UAFE,kBAEF,CAJF,gBAEI,CAFJ,iBAIE,6CC9ZF,yBACE,WACA,iBAEA,aAFA,iBAEA,6BAEA,kCACA,mBAKA,gCAGA,CARA,QAEA,CAGA,UALA,qBAEA,qDAGA,CALA,OAQA,4BACE,cAGF,2BACE,gCAEJ,CAHE,UAGF,8CAGE,CAHF,UAGE,wCAGA,qBACA,CAFA,UAEA,6CAGA,yCAIA,sBAHA,UAGA,kCACE,OACA,CADA,KACA,cAQF,0CACE,CAFF,kBACA,CACE,wEACA,CARA,YACA,CAKF,mBAFF,MACE,CAIE,gBAJF,iCAJE,cAGJ,CANI,oBAEA,CAKF,SAIE,2BADA,UACA,kBAGF,sCACA,CAFF,WACE,WACA,mBACE,kDACA,0EACA,uDAKJ,aACE,mDAII,CAJJ,6CAII,4BACA,sCACE,kEACA,+CACE,aACA,WADA,+BACA,uEANN,YACE,mDAEE,mBADF,0CACE,CADF,qBACE,0DACA,YACE,4DACA,sEANN,YACE,8CACA,kBADA,UACA,2CACE,2EACA,cACE,kEACA,mEANN,yBACE,4DACA,sBACE,+EAEE,iEACA,qEANN,sCACE,CAGE,iBAHF,gBAGE,qBACE,CAJJ,uBACA,gDACE,wDACA,6DAHF,2CACA,CADA,gBACA,eACE,CAGE,sBANN,8BACE,CAII,iBAFF,4DACA,WACE,YADF,uCACE,6EACA,2BANN,8CACE,kDACA,0CACE,8BACA,yFACE,sBACA,sFALJ,mEACA,sBACE,kEACA,6EACE,uCACA,kEALJ,qGAEE,kEACA,6EACE,uCACA,kEALJ,8CACA,uDACE,sEACA,2EACE,sCACA,iEALJ,mGACA,qCACE,oDACA,0DACE,6GACA,gDAGR,yDCrEA,sEACE,CACA,6GACE,gEACF,iGAIF,wFACE,qDAGA,mGAEE,2CAEF,4FACE,gCACF,wGACE,8DAEE,6FAIA,iJAKN,6GACE,gDAKF,yDACA,qCAGA,6BACA,kBACA,qDAKA,oCAEA,+DAGA,2CAGE,oDAIA,oEAEE,qBAGJ,wDAEE,uCAEF,kEAGA,8CAEA,uDAKA,oCAEA,yDAEE,gEAKF,+CC5FA,0EAGE,CACA,qDCLJ,+DAIE,sCAIA,kEACE,yBACA,2FAMA,gBACA,yGCbF,mBAOA,2MAIA,4HAYA,0DACE,8GAYF,8HAQE,mBAEA,6HAOF,YAGA,mIAME,eACA,CAFF,YAEE,4FAMJ,8BAEE,uBAYA,sCAEE,CAJF,oBAEA,CARA,wCAEA,CAHA,8BACA,CAFA,eACA,CAGA,wCAEA,CAEA,mDAIE,kCACE,6BACA,4CAKJ,kDAIA,eACE,aAGF,8BACE,uDACA,sCACA,cAEA,+BACA,CAFA,eAEA,wCAEF,YACE,iBACA,mCACA,0DAGF,qBAEE,CAFF,kBAEE,+BAIA,yCAEE,qBADA,gBACA,yBAKF,eACA,CAFF,YACE,CACA,iBACA,qDAEA,mDCvIJ,2FAOE,iCACA,CAEA,eACA,CAHA,kBAEA,CAFA,wBAGA,8BACA,eACE,CAFF,YAEE,0BACA,8CAGA,oBACE,oCAGA,kBACE,8DAEA,iBAEN,UACE,8BAIJ,+CAEE,qDAEF,kDAIE,YAEF,CAFE,YAEF,CCjCE,mFAJA,QACA,UAIE,CADF,iBACE,mCAGA,iDACE,+BAGF,wBAEA,mBAKA,6CAEF,CAHE,mBACA,CAEF,kCAIE,CARA,kBACA,CAFF,eASE,YACA,mBAGF,CAJE,UAIF,wCCjCA,oBDmCE,wBCpCJ,uCACE,8BACA,4CACA,oBAGA,2CCAA,6CAGE,CAPF,uBAIA,CDGA,gDACE,6BCVJ,CAWM,2CAEF,CAJA,kCAEE,CDJF,aCLF,gBDKE,uBCMA,gCAGA,gDAGE,wBAGJ,0BAEA,iBACE,aACF,CADE,UACF,uBACE,aACF,oBACE,YACF,4BACE,6CAMA,CAYF,6DAZE,mCAGE,iCASJ,4BAGE,4DADA,+BACA,CAFA,qBAEA,yBACE,aAEF,wBAHA,SAGA,iHACE,2DAKF,CANA,yCACE,CADF,oCAMA,uSAIA,sGACE,oDChEJ,WAEF,yBACE,QACA,eAEA,gBAEE,uCAGA,CALF,iCAKE,uCAGA,0BACA,CACA,oBACA,iCClBJ,gBACE,KAGF,qBACE,YAGF,CAHE,cAGF,gCAEE,mBACA,iEAEA,oCACA,wCAEA,sBACA,WAEA,CAFA,YAEA,8EAEA,mCAFA,iBAEA,6BAIA,wEAKA,sDAIE,CARF,mDAIA,CAIE,cAEF,8CAIA,oBAFE,iBAEF,8CAGE,eAEF,CAFE,YAEF,OAEE,kBAGJ,CAJI,eACA,CAFF,mBAKF,yCCjDE,oBACA,CAFA,iBAEA,uCAKE,iBACA,qCAGA,mBCZJ,CDWI,gBCXJ,6BAEE,eACA,sBAGA,eAEA,sBACA,oDACA,iGAMA,gBAFE,YAEF,8FAME,iJClBF,YACA,gNAUE,6BAEF,oTAcI,kBACF,gHAIA,qBACE,eACF,qDACE,kBACF,6DACE,4BCxCJ,oBAEF,qCAEI,+CAGF,uBACE,uDAGJ,oBAkBE,mDAhBA,+CAaA,CAbA,oBAaA,0FAEE,CAFF,gGAbA,+BAaA,0BAGA,mQAIA,oNAEE,iBAGJ,CAHI,gBADA,gBAIJ,8CAYI,CAZJ,wCAYI,sVACE,iCAGA,uEAHA,QAGA,qXAKJ,iDAGF,CARM,+CACE,iDAIN,CALI,gBAQN,mHACE,gBAGF,2DACE,0EAOA,0EAKA,6EC/EA,iDACA,gCACA,oDAGA,qBACA,oDCFA,cACA,eAEA,yBAGF,sBAEE,iBACA,sNAWA,iBACE,kBACA,wRAgBA,kBAEA,iOAgBA,uCACE,uEAEA,kBAEF,qUAuBE,iDAIJ,CACA,geCxFF,4BAEE,CAQA,6JACA,iDAIA,sEAGA,mDAOF,iDAGE,4DAIA,8CACA,qDAEE,eAFF,cAEE,oBAEF,uBAFE,kCAGA,eACA,iBACA,mBAIA,mDACA,CAHA,uCAEA,CAJA,0CACA,CAIA,gBAJA,gBACA,oBADA,gBAIA,wBAEJ,gBAGE,6BACA,YAHA,iBAGA,gCACA,iEAEA,6CACA,sDACA,0BADA,wBACA,0BACA,oIAIA,mBAFA,YAEA,qBACA,0CAIE,uBAEF,CAHA,yBACE,CAEF,iDACE,mFAKJ,oCACE,CANE,aAKJ,CACE,qEAIA,YAFA,WAEA,CAHA,aACA,CAEA,gBACE,4BACA,sBADA,aACA,gCAMF,oCACA,yDACA,2CAEA,qBAGE,kBAEA,CACA,mCAIF,CARE,YACA,CAOF,iCAEE,CAPA,oBACA,CAQA,oBACE,uDAEJ,sDAGA,CAHA,cAGA,0BACE,oDAIA,oCACA,4BACA,sBAGA,cAEA,oFAGA,sBAEA,yDACE,CAIA,iBAJA,wBAIA,6CAJA,6CAOA,4BAGJ,CAHI,cAGJ,yCAGA,kBACE,CAIA,iDAEA,CATA,YAEF,CACE,4CAGA,kBAIA,wEAEA,wDAIF,kCAOE,iDACA,CARF,WAIE,sCAGA,CANA,2CACA,CAMA,oEARF,iBACE,CACA,qCAMA,iBAuBE,uBAlBF,YAKA,2DALA,uDAKA,CALA,sBAiBA,4CACE,CALA,gRAIF,YACE,UAEN,uBACE,YACA,mCAOE,+CAGA,8BAGF,+CAGA,4BCjNA,SDiNA,qFCjNA,gDAGA,sCACA,qCACA,sDAIF,CAIE,kDAGA,CAPF,0CAOE,kBAEA,kDAEA,CAHA,eACA,CAFA,YACA,CADA,SAIA,mHAIE,CAGA,6CAFA,oCAeE,CAbF,yBACE,qBAEJ,CAGE,oBACA,CAEA,YAFA,2CACF,CACE,uBAEA,mFAEE,CALJ,oBACE,CAEA,UAEE,gCAGF,sDAEA,yCC7CJ,oCAGA,CD6CE,yXAQE,sCCrDJ,wCAGA,oCACE","sources":["webpack:///./node_modules/normalize.css/normalize.css","webpack:///./src/furo/assets/styles/base/_print.sass","webpack:///./src/furo/assets/styles/base/_screen-readers.sass","webpack:///./src/furo/assets/styles/base/_theme.sass","webpack:///./src/furo/assets/styles/variables/_fonts.scss","webpack:///./src/furo/assets/styles/variables/_spacing.scss","webpack:///./src/furo/assets/styles/variables/_icons.scss","webpack:///./src/furo/assets/styles/variables/_admonitions.scss","webpack:///./src/furo/assets/styles/variables/_colors.scss","webpack:///./src/furo/assets/styles/base/_typography.sass","webpack:///./src/furo/assets/styles/_scaffold.sass","webpack:///./src/furo/assets/styles/content/_admonitions.sass","webpack:///./src/furo/assets/styles/content/_api.sass","webpack:///./src/furo/assets/styles/content/_blocks.sass","webpack:///./src/furo/assets/styles/content/_captions.sass","webpack:///./src/furo/assets/styles/content/_code.sass","webpack:///./src/furo/assets/styles/content/_footnotes.sass","webpack:///./src/furo/assets/styles/content/_images.sass","webpack:///./src/furo/assets/styles/content/_indexes.sass","webpack:///./src/furo/assets/styles/content/_lists.sass","webpack:///./src/furo/assets/styles/content/_math.sass","webpack:///./src/furo/assets/styles/content/_misc.sass","webpack:///./src/furo/assets/styles/content/_rubrics.sass","webpack:///./src/furo/assets/styles/content/_sidebar.sass","webpack:///./src/furo/assets/styles/content/_tables.sass","webpack:///./src/furo/assets/styles/content/_target.sass","webpack:///./src/furo/assets/styles/content/_gui-labels.sass","webpack:///./src/furo/assets/styles/components/_footer.sass","webpack:///./src/furo/assets/styles/components/_sidebar.sass","webpack:///./src/furo/assets/styles/components/_table_of_contents.sass","webpack:///./src/furo/assets/styles/_shame.sass"],"sourcesContent":["/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */\n\n/* Document\n ========================================================================== */\n\n/**\n * 1. Correct the line height in all browsers.\n * 2. Prevent adjustments of font size after orientation changes in iOS.\n */\n\nhtml {\n line-height: 1.15; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n}\n\n/* Sections\n ========================================================================== */\n\n/**\n * Remove the margin in all browsers.\n */\n\nbody {\n margin: 0;\n}\n\n/**\n * Render the `main` element consistently in IE.\n */\n\nmain {\n display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n/* Grouping content\n ========================================================================== */\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n box-sizing: content-box; /* 1 */\n height: 0; /* 1 */\n overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\npre {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/* Text-level semantics\n ========================================================================== */\n\n/**\n * Remove the gray background on active links in IE 10.\n */\n\na {\n background-color: transparent;\n}\n\n/**\n * 1. Remove the bottom border in Chrome 57-\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n */\n\nabbr[title] {\n border-bottom: none; /* 1 */\n text-decoration: underline; /* 2 */\n text-decoration: underline dotted; /* 2 */\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\n/* Embedded content\n ========================================================================== */\n\n/**\n * Remove the border on images inside links in IE 10.\n */\n\nimg {\n border-style: none;\n}\n\n/* Forms\n ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers.\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n font-family: inherit; /* 1 */\n font-size: 100%; /* 1 */\n line-height: 1.15; /* 1 */\n margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput { /* 1 */\n overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect { /* 1 */\n text-transform: none;\n}\n\n/**\n * Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n border-style: none;\n padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule.\n */\n\nbutton:-moz-focusring,\n[type=\"button\"]:-moz-focusring,\n[type=\"reset\"]:-moz-focusring,\n[type=\"submit\"]:-moz-focusring {\n outline: 1px dotted ButtonText;\n}\n\n/**\n * Correct the padding in Firefox.\n */\n\nfieldset {\n padding: 0.35em 0.75em 0.625em;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n * `fieldset` elements in all browsers.\n */\n\nlegend {\n box-sizing: border-box; /* 1 */\n color: inherit; /* 2 */\n display: table; /* 1 */\n max-width: 100%; /* 1 */\n padding: 0; /* 3 */\n white-space: normal; /* 1 */\n}\n\n/**\n * Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n vertical-align: baseline;\n}\n\n/**\n * Remove the default vertical scrollbar in IE 10+.\n */\n\ntextarea {\n overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10.\n * 2. Remove the padding in IE 10.\n */\n\n[type=\"checkbox\"],\n[type=\"radio\"] {\n box-sizing: border-box; /* 1 */\n padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type=\"search\"] {\n -webkit-appearance: textfield; /* 1 */\n outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding in Chrome and Safari on macOS.\n */\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n -webkit-appearance: button; /* 1 */\n font: inherit; /* 2 */\n}\n\n/* Interactive\n ========================================================================== */\n\n/*\n * Add the correct display in Edge, IE 10+, and Firefox.\n */\n\ndetails {\n display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n display: list-item;\n}\n\n/* Misc\n ========================================================================== */\n\n/**\n * Add the correct display in IE 10+.\n */\n\ntemplate {\n display: none;\n}\n\n/**\n * Add the correct display in IE 10.\n */\n\n[hidden] {\n display: none;\n}\n","// This file contains styles for managing print media.\n\n////////////////////////////////////////////////////////////////////////////////\n// Hide elements not relevant to print media.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n // Hide icon container.\n .content-icon-container\n display: none !important\n\n // Hide showing header links if hovering over when printing.\n .headerlink\n display: none !important\n\n // Hide mobile header.\n .mobile-header\n display: none !important\n\n // Hide navigation links.\n .related-pages\n display: none !important\n\n////////////////////////////////////////////////////////////////////////////////\n// Tweaks related to decolorization.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n // Apply a border around code which no longer have a color background.\n .highlight\n border: 0.1pt solid var(--color-foreground-border)\n\n////////////////////////////////////////////////////////////////////////////////\n// Avoid page break in some relevant cases.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n ul, ol, dl, a, table, pre, blockquote\n page-break-inside: avoid\n\n h1, h2, h3, h4, h5, h6, img, figure, caption\n page-break-inside: avoid\n page-break-after: avoid\n\n ul, ol, dl\n page-break-before: avoid\n",".visually-hidden\n position: absolute !important\n width: 1px !important\n height: 1px !important\n padding: 0 !important\n margin: -1px !important\n overflow: hidden !important\n clip: rect(0,0,0,0) !important\n white-space: nowrap !important\n border: 0 !important\n\n:-moz-focusring\n outline: auto\n","// This file serves as the \"skeleton\" of the theming logic.\n//\n// This contains the bulk of the logic for handling dark mode, color scheme\n// toggling and the handling of color-scheme-specific hiding of elements.\n\nbody\n @include fonts\n @include spacing\n @include icons\n @include admonitions\n @include default-admonition(#651fff, \"abstract\")\n @include default-topic(#14B8A6, \"pencil\")\n\n @include colors\n\n.only-light\n display: block !important\nhtml body .only-dark\n display: none !important\n\n// Ignore dark-mode hints if print media.\n@media not print\n // Enable dark-mode, if requested.\n body[data-theme=\"dark\"]\n @include colors-dark\n\n html & .only-light\n display: none !important\n .only-dark\n display: block !important\n\n // Enable dark mode, unless explicitly told to avoid.\n @media (prefers-color-scheme: dark)\n body:not([data-theme=\"light\"])\n @include colors-dark\n\n html & .only-light\n display: none !important\n .only-dark\n display: block !important\n\n//\n// Theme toggle presentation\n//\nbody[data-theme=\"auto\"]\n .theme-toggle svg.theme-icon-when-auto\n display: block\n\nbody[data-theme=\"dark\"]\n .theme-toggle svg.theme-icon-when-dark\n display: block\n\nbody[data-theme=\"light\"]\n .theme-toggle svg.theme-icon-when-light\n display: block\n","// Fonts used by this theme.\n//\n// There are basically two things here -- using the system font stack and\n// defining sizes for various elements in %ages. We could have also used `em`\n// but %age is easier to reason about for me.\n\n@mixin fonts {\n // These are adapted from https://systemfontstack.com/\n --font-stack: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,\n sans-serif, Apple Color Emoji, Segoe UI Emoji;\n --font-stack--monospace: \"SFMono-Regular\", Menlo, Consolas, Monaco,\n Liberation Mono, Lucida Console, monospace;\n\n --font-size--normal: 100%;\n --font-size--small: 87.5%;\n --font-size--small--2: 81.25%;\n --font-size--small--3: 75%;\n --font-size--small--4: 62.5%;\n\n // Sidebar\n --sidebar-caption-font-size: var(--font-size--small--2);\n --sidebar-item-font-size: var(--font-size--small);\n --sidebar-search-input-font-size: var(--font-size--small);\n\n // Table of Contents\n --toc-font-size: var(--font-size--small--3);\n --toc-font-size--mobile: var(--font-size--normal);\n --toc-title-font-size: var(--font-size--small--4);\n\n // Admonitions\n //\n // These aren't defined in terms of %ages, since nesting these is permitted.\n --admonition-font-size: 0.8125rem;\n --admonition-title-font-size: 0.8125rem;\n\n // Code\n --code-font-size: var(--font-size--small--2);\n\n // API\n --api-font-size: var(--font-size--small);\n}\n","// Spacing for various elements on the page\n//\n// If the user wants to tweak things in a certain way, they are permitted to.\n// They also have to deal with the consequences though!\n\n@mixin spacing {\n // Header!\n --header-height: calc(\n var(--sidebar-item-line-height) + 4 * #{var(--sidebar-item-spacing-vertical)}\n );\n --header-padding: 0.5rem;\n\n // Sidebar\n --sidebar-tree-space-above: 1.5rem;\n --sidebar-caption-space-above: 1rem;\n\n --sidebar-item-line-height: 1rem;\n --sidebar-item-spacing-vertical: 0.5rem;\n --sidebar-item-spacing-horizontal: 1rem;\n --sidebar-item-height: calc(\n var(--sidebar-item-line-height) + 2 *#{var(--sidebar-item-spacing-vertical)}\n );\n\n --sidebar-expander-width: var(--sidebar-item-height); // be square\n\n --sidebar-search-space-above: 0.5rem;\n --sidebar-search-input-spacing-vertical: 0.5rem;\n --sidebar-search-input-spacing-horizontal: 0.5rem;\n --sidebar-search-input-height: 1rem;\n --sidebar-search-icon-size: var(--sidebar-search-input-height);\n\n // Table of Contents\n --toc-title-padding: 0.25rem 0;\n --toc-spacing-vertical: 1.5rem;\n --toc-spacing-horizontal: 1.5rem;\n --toc-item-spacing-vertical: 0.4rem;\n --toc-item-spacing-horizontal: 1rem;\n}\n","// Expose theme icons as CSS variables.\n\n$icons: (\n // Adapted from tabler-icons\n // url: https://tablericons.com/\n \"search\":\n url('data:image/svg+xml;charset=utf-8,'),\n // Factored out from mkdocs-material on 24-Aug-2020.\n // url: https://squidfunk.github.io/mkdocs-material/reference/admonitions/\n \"pencil\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"abstract\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"info\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"flame\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"question\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"warning\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"failure\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"spark\":\n url('data:image/svg+xml;charset=utf-8,')\n);\n\n@mixin icons {\n @each $name, $glyph in $icons {\n --icon-#{$name}: #{$glyph};\n }\n}\n","// Admonitions\n\n// Structure of these is:\n// admonition-class: color \"icon-name\";\n//\n// The colors are translated into CSS variables below. The icons are\n// used directly in the main declarations to set the `mask-image` in\n// the title.\n\n// prettier-ignore\n$admonitions: (\n // Each of these has an reST directives for it.\n \"caution\": #ff9100 \"spark\",\n \"warning\": #ff9100 \"warning\",\n \"danger\": #ff5252 \"spark\",\n \"attention\": #ff5252 \"warning\",\n \"error\": #ff5252 \"failure\",\n \"hint\": #00c852 \"question\",\n \"tip\": #00c852 \"info\",\n \"important\": #00bfa5 \"flame\",\n \"note\": #00b0ff \"pencil\",\n \"seealso\": #448aff \"info\",\n \"admonition-todo\": #808080 \"pencil\"\n);\n\n@mixin default-admonition($color, $icon-name) {\n --color-admonition-title: #{$color};\n --color-admonition-title-background: #{rgba($color, 0.2)};\n\n --icon-admonition-default: var(--icon-#{$icon-name});\n}\n\n@mixin default-topic($color, $icon-name) {\n --color-topic-title: #{$color};\n --color-topic-title-background: #{rgba($color, 0.2)};\n\n --icon-topic-default: var(--icon-#{$icon-name});\n}\n\n@mixin admonitions {\n @each $name, $values in $admonitions {\n --color-admonition-title--#{$name}: #{nth($values, 1)};\n --color-admonition-title-background--#{$name}: #{rgba(\n nth($values, 1),\n 0.2\n )};\n }\n}\n","// Colors used throughout this theme.\n//\n// The aim is to give the user more control. Thus, instead of hard-coding colors\n// in various parts of the stylesheet, the approach taken is to define all\n// colors as CSS variables and reusing them in all the places.\n//\n// `colors-dark` depends on `colors` being included at a lower specificity.\n\n@mixin colors {\n --color-problematic: #b30000;\n\n // Base Colors\n --color-foreground-primary: black; // for main text and headings\n --color-foreground-secondary: #5a5c63; // for secondary text\n --color-foreground-muted: #646776; // for muted text\n --color-foreground-border: #878787; // for content borders\n\n --color-background-primary: white; // for content\n --color-background-secondary: #f8f9fb; // for navigation + ToC\n --color-background-hover: #efeff4ff; // for navigation-item hover\n --color-background-hover--transparent: #efeff400;\n --color-background-border: #eeebee; // for UI borders\n --color-background-item: #ccc; // for \"background\" items (eg: copybutton)\n\n // Announcements\n --color-announcement-background: #000000dd;\n --color-announcement-text: #eeebee;\n\n // Brand colors\n --color-brand-primary: #2962ff;\n --color-brand-content: #2a5adf;\n\n // API documentation\n --color-api-background: var(--color-background-hover--transparent);\n --color-api-background-hover: var(--color-background-hover);\n --color-api-overall: var(--color-foreground-secondary);\n --color-api-name: var(--color-problematic);\n --color-api-pre-name: var(--color-problematic);\n --color-api-paren: var(--color-foreground-secondary);\n --color-api-keyword: var(--color-foreground-primary);\n --color-highlight-on-target: #ffffcc;\n\n // Inline code background\n --color-inline-code-background: var(--color-background-secondary);\n\n // Highlighted text (search)\n --color-highlighted-background: #ddeeff;\n --color-highlighted-text: var(--color-foreground-primary);\n\n // GUI Labels\n --color-guilabel-background: #ddeeff80;\n --color-guilabel-border: #bedaf580;\n --color-guilabel-text: var(--color-foreground-primary);\n\n // Admonitions!\n --color-admonition-background: transparent;\n\n //////////////////////////////////////////////////////////////////////////////\n // Everything below this should be one of:\n // - var(...)\n // - *-gradient(...)\n // - special literal values (eg: transparent, none)\n //////////////////////////////////////////////////////////////////////////////\n\n // Tables\n --color-table-header-background: var(--color-background-secondary);\n --color-table-border: var(--color-background-border);\n\n // Cards\n --color-card-border: var(--color-background-secondary);\n --color-card-background: transparent;\n --color-card-marginals-background: var(--color-background-secondary);\n\n // Header\n --color-header-background: var(--color-background-primary);\n --color-header-border: var(--color-background-border);\n --color-header-text: var(--color-foreground-primary);\n\n // Sidebar (left)\n --color-sidebar-background: var(--color-background-secondary);\n --color-sidebar-background-border: var(--color-background-border);\n\n --color-sidebar-brand-text: var(--color-foreground-primary);\n --color-sidebar-caption-text: var(--color-foreground-muted);\n --color-sidebar-link-text: var(--color-foreground-secondary);\n --color-sidebar-link-text--top-level: var(--color-brand-primary);\n\n --color-sidebar-item-background: var(--color-sidebar-background);\n --color-sidebar-item-background--current: var(\n --color-sidebar-item-background\n );\n --color-sidebar-item-background--hover: linear-gradient(\n 90deg,\n var(--color-background-hover--transparent) 0%,\n var(--color-background-hover) var(--sidebar-item-spacing-horizontal),\n var(--color-background-hover) 100%\n );\n\n --color-sidebar-item-expander-background: transparent;\n --color-sidebar-item-expander-background--hover: var(\n --color-background-hover\n );\n\n --color-sidebar-search-text: var(--color-foreground-primary);\n --color-sidebar-search-background: var(--color-background-secondary);\n --color-sidebar-search-background--focus: var(--color-background-primary);\n --color-sidebar-search-border: var(--color-background-border);\n --color-sidebar-search-icon: var(--color-foreground-muted);\n\n // Table of Contents (right)\n --color-toc-background: var(--color-background-primary);\n --color-toc-title-text: var(--color-foreground-muted);\n --color-toc-item-text: var(--color-foreground-secondary);\n --color-toc-item-text--hover: var(--color-foreground-primary);\n --color-toc-item-text--active: var(--color-brand-primary);\n\n // Actual page contents\n --color-content-foreground: var(--color-foreground-primary);\n --color-content-background: transparent;\n\n // Links\n --color-link: var(--color-brand-content);\n --color-link--hover: var(--color-brand-content);\n --color-link-underline: var(--color-background-border);\n --color-link-underline--hover: var(--color-foreground-border);\n}\n\n@mixin colors-dark {\n --color-problematic: #ee5151;\n\n // Base Colors\n --color-foreground-primary: #ffffffcc; // for main text and headings\n --color-foreground-secondary: #9ca0a5; // for secondary text\n --color-foreground-muted: #81868d; // for muted text\n --color-foreground-border: #666666; // for content borders\n\n --color-background-primary: #131416; // for content\n --color-background-secondary: #1a1c1e; // for navigation + ToC\n --color-background-hover: #1e2124ff; // for navigation-item hover\n --color-background-hover--transparent: #1e212400;\n --color-background-border: #303335; // for UI borders\n --color-background-item: #444; // for \"background\" items (eg: copybutton)\n\n // Announcements\n --color-announcement-background: #000000dd;\n --color-announcement-text: #eeebee;\n\n // Brand colors\n --color-brand-primary: #2b8cee;\n --color-brand-content: #368ce2;\n\n // Highlighted text (search)\n --color-highlighted-background: #083563;\n\n // GUI Labels\n --color-guilabel-background: #08356380;\n --color-guilabel-border: #13395f80;\n\n // API documentation\n --color-api-keyword: var(--color-foreground-secondary);\n --color-highlight-on-target: #333300;\n\n // Admonitions\n --color-admonition-background: #18181a;\n\n // Cards\n --color-card-border: var(--color-background-secondary);\n --color-card-background: #18181a;\n --color-card-marginals-background: var(--color-background-hover);\n}\n","// This file contains the styling for making the content throughout the page,\n// including fonts, paragraphs, headings and spacing among these elements.\n\nbody\n font-family: var(--font-stack)\npre,\ncode,\nkbd,\nsamp\n font-family: var(--font-stack--monospace)\n\n// Make fonts look slightly nicer.\nbody\n -webkit-font-smoothing: antialiased\n -moz-osx-font-smoothing: grayscale\n\n// Line height from Bootstrap 4.1\narticle\n line-height: 1.5\n\n//\n// Headings\n//\nh1,\nh2,\nh3,\nh4,\nh5,\nh6\n line-height: 1.25\n font-weight: bold\n\n border-radius: 0.5rem\n margin-top: 0.5rem\n margin-bottom: 0.5rem\n margin-left: -0.5rem\n margin-right: -0.5rem\n padding-left: 0.5rem\n padding-right: 0.5rem\n\n + p\n margin-top: 0\n\nh1\n font-size: 2.5em\n margin-top: 1.75rem\n margin-bottom: 1rem\nh2\n font-size: 2em\n margin-top: 1.75rem\nh3\n font-size: 1.5em\nh4\n font-size: 1.25em\nh5\n font-size: 1.125em\nh6\n font-size: 1em\n\nsmall\n opacity: 75%\n font-size: 80%\n\n// Paragraph\np\n margin-top: 0.5rem\n margin-bottom: 0.75rem\n\n// Horizontal rules\nhr.docutils\n height: 1px\n padding: 0\n margin: 2rem 0\n background-color: var(--color-background-border)\n border: 0\n\n.centered\n text-align: center\n\n// Links\na\n text-decoration: underline\n\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline)\n\n &:hover\n color: var(--color-link--hover)\n text-decoration-color: var(--color-link-underline--hover)\n &.muted-link\n color: inherit\n &:hover\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline--hover)\n","// This file contains the styles for the overall layouting of the documentation\n// skeleton, including the responsive changes as well as sidebar toggles.\n//\n// This is implemented as a mobile-last design, which isn't ideal, but it is\n// reasonably good-enough and I got pretty tired by the time I'd finished this\n// to move the rules around to fix this. Shouldn't take more than 3-4 hours,\n// if you know what you're doing tho.\n\n// HACK: Not all browsers account for the scrollbar width in media queries.\n// This results in horizontal scrollbars in the breakpoint where we go\n// from displaying everything to hiding the ToC. We accomodate for this by\n// adding a bit of padding to the TOC drawer, disabling the horizontal\n// scrollbar and allowing the scrollbars to cover the padding.\n// https://www.456bereastreet.com/archive/201301/media_query_width_and_vertical_scrollbars/\n\n// HACK: Always having the scrollbar visible, prevents certain browsers from\n// causing the content to stutter horizontally between taller-than-viewport and\n// not-taller-than-viewport pages.\n\nhtml\n overflow-x: hidden\n overflow-y: scroll\n scroll-behavior: smooth\n\n.sidebar-scroll, .toc-scroll, article[role=main] *\n // Override Firefox scrollbar style\n scrollbar-width: thin\n scrollbar-color: var(--color-foreground-border) transparent\n\n // Override Chrome scrollbar styles\n &::-webkit-scrollbar\n width: 0.25rem\n height: 0.25rem\n &::-webkit-scrollbar-thumb\n background-color: var(--color-foreground-border)\n border-radius: 0.125rem\n\n//\n// Overalls\n//\nhtml,\nbody\n height: 100%\n color: var(--color-foreground-primary)\n background: var(--color-background-primary)\n\narticle\n color: var(--color-content-foreground)\n background: var(--color-content-background)\n overflow-wrap: break-word\n\n.page\n display: flex\n // fill the viewport for pages with little content.\n min-height: 100%\n\n.mobile-header\n width: 100%\n height: var(--header-height)\n background-color: var(--color-header-background)\n color: var(--color-header-text)\n border-bottom: 1px solid var(--color-header-border)\n\n // Looks like sub-script/super-script have this, and we need this to\n // be \"on top\" of those.\n z-index: 10\n\n // We don't show the header on large screens.\n display: none\n\n // Add shadow when scrolled\n &.scrolled\n border-bottom: none\n box-shadow: 0 0 0.2rem rgba(0, 0, 0, 0.1), 0 0.2rem 0.4rem rgba(0, 0, 0, 0.2)\n\n .header-center\n a\n color: var(--color-header-text)\n text-decoration: none\n\n.main\n display: flex\n flex: 1\n\n// Sidebar (left) also covers the entire left portion of screen.\n.sidebar-drawer\n box-sizing: border-box\n\n border-right: 1px solid var(--color-sidebar-background-border)\n background: var(--color-sidebar-background)\n\n display: flex\n justify-content: flex-end\n // These next two lines took me two days to figure out.\n width: calc((100% - #{$full-width}) / 2 + #{$sidebar-width})\n min-width: $sidebar-width\n\n// Scroll-along sidebars\n.sidebar-container,\n.toc-drawer\n box-sizing: border-box\n width: $sidebar-width\n\n.toc-drawer\n background: var(--color-toc-background)\n // See HACK described on top of this document\n padding-right: 1rem\n\n.sidebar-sticky,\n.toc-sticky\n position: sticky\n top: 0\n height: min(100%, 100vh)\n height: 100vh\n\n display: flex\n flex-direction: column\n\n.sidebar-scroll,\n.toc-scroll\n flex-grow: 1\n flex-shrink: 1\n\n overflow: auto\n scroll-behavior: smooth\n\n// Central items.\n.content\n padding: 0 $content-padding\n width: $content-width\n\n display: flex\n flex-direction: column\n justify-content: space-between\n\n.icon\n display: inline-block\n height: 1rem\n width: 1rem\n svg\n width: 100%\n height: 100%\n\n//\n// Accommodate announcement banner\n//\n.announcement\n background-color: var(--color-announcement-background)\n color: var(--color-announcement-text)\n\n height: var(--header-height)\n display: flex\n align-items: center\n overflow-x: auto\n & + .page\n min-height: calc(100% - var(--header-height))\n\n.announcement-content\n box-sizing: border-box\n padding: 0.5rem\n min-width: 100%\n white-space: nowrap\n text-align: center\n\n a\n color: var(--color-announcement-text)\n text-decoration-color: var(--color-announcement-text)\n\n &:hover\n color: var(--color-announcement-text)\n text-decoration-color: var(--color-link--hover)\n\n////////////////////////////////////////////////////////////////////////////////\n// Toggles for theme\n////////////////////////////////////////////////////////////////////////////////\n.no-js .theme-toggle-container // don't show theme toggle if there's no JS\n display: none\n\n.theme-toggle-container\n vertical-align: middle\n\n.theme-toggle\n cursor: pointer\n border: none\n padding: 0\n background: transparent\n\n.theme-toggle svg\n vertical-align: middle\n height: 1rem\n width: 1rem\n color: var(--color-foreground-primary)\n display: none\n\n.theme-toggle-header\n float: left\n padding: 1rem 0.5rem\n\n////////////////////////////////////////////////////////////////////////////////\n// Toggles for elements\n////////////////////////////////////////////////////////////////////////////////\n.toc-overlay-icon, .nav-overlay-icon\n display: none\n cursor: pointer\n\n .icon\n color: var(--color-foreground-secondary)\n height: 1rem\n width: 1rem\n\n.toc-header-icon, .nav-overlay-icon\n // for when we set display: flex\n justify-content: center\n align-items: center\n\n.toc-content-icon\n height: 1.5rem\n width: 1.5rem\n\n.content-icon-container\n float: right\n display: flex\n margin-top: 1.5rem\n margin-left: 1rem\n margin-bottom: 1rem\n gap: 0.5rem\n\n .edit-this-page svg\n color: inherit\n height: 1rem\n width: 1rem\n\n.sidebar-toggle\n position: absolute\n display: none\n// \n.sidebar-toggle[name=\"__toc\"]\n left: 20px\n.sidebar-toggle:checked\n left: 40px\n// \n\n.overlay\n position: fixed\n top: 0\n width: 0\n height: 0\n\n transition: width 0ms, height 0ms, opacity 250ms ease-out\n\n opacity: 0\n background-color: rgba(0, 0, 0, 0.54)\n.sidebar-overlay\n z-index: 20\n.toc-overlay\n z-index: 40\n\n// Keep things on top and smooth.\n.sidebar-drawer\n z-index: 30\n transition: left 250ms ease-in-out\n.toc-drawer\n z-index: 50\n transition: right 250ms ease-in-out\n\n// Show the Sidebar\n#__navigation:checked\n & ~ .sidebar-overlay\n width: 100%\n height: 100%\n opacity: 1\n & ~ .page\n .sidebar-drawer\n top: 0\n left: 0\n // Show the toc sidebar\n#__toc:checked\n & ~ .toc-overlay\n width: 100%\n height: 100%\n opacity: 1\n & ~ .page\n .toc-drawer\n top: 0\n right: 0\n\n////////////////////////////////////////////////////////////////////////////////\n// Back to top\n////////////////////////////////////////////////////////////////////////////////\n.back-to-top\n text-decoration: none\n\n display: none\n position: fixed\n left: 0\n top: 1rem\n padding: 0.5rem\n padding-right: 0.75rem\n border-radius: 1rem\n font-size: 0.8125rem\n\n background: var(--color-background-primary)\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), #6b728080 0px 0px 1px 0px\n\n z-index: 10\n\n margin-left: 50%\n transform: translateX(-50%)\n svg\n height: 1rem\n width: 1rem\n fill: currentColor\n display: inline-block\n\n span\n margin-left: 0.25rem\n\n .show-back-to-top &\n display: flex\n align-items: center\n\n////////////////////////////////////////////////////////////////////////////////\n// Responsive layouting\n////////////////////////////////////////////////////////////////////////////////\n// Make things a bit bigger on bigger screens.\n@media (min-width: $full-width + $sidebar-width)\n html\n font-size: 110%\n\n@media (max-width: $full-width)\n // Collapse \"toc\" into the icon.\n .toc-content-icon\n display: flex\n .toc-drawer\n position: fixed\n height: 100vh\n top: 0\n right: -$sidebar-width\n border-left: 1px solid var(--color-background-muted)\n .toc-tree\n border-left: none\n font-size: var(--toc-font-size--mobile)\n\n // Accomodate for a changed content width.\n .sidebar-drawer\n width: calc((100% - #{$full-width - $sidebar-width}) / 2 + #{$sidebar-width})\n\n@media (max-width: $full-width - $sidebar-width)\n // Collapse \"navigation\".\n .nav-overlay-icon\n display: flex\n .sidebar-drawer\n position: fixed\n height: 100vh\n width: $sidebar-width\n\n top: 0\n left: -$sidebar-width\n\n // Swap which icon is visible.\n .toc-header-icon\n display: flex\n .toc-content-icon, .theme-toggle-content\n display: none\n .theme-toggle-header\n display: block\n\n // Show the header.\n .mobile-header\n position: sticky\n top: 0\n display: flex\n justify-content: space-between\n align-items: center\n\n .header-left,\n .header-right\n display: flex\n height: var(--header-height)\n padding: 0 var(--header-padding)\n label\n height: 100%\n width: 100%\n user-select: none\n\n .nav-overlay-icon .icon,\n .theme-toggle svg\n height: 1.25rem\n width: 1.25rem\n\n // Add a scroll margin for the content\n :target\n scroll-margin-top: var(--header-height)\n\n // Show back-to-top below the header\n .back-to-top\n top: calc(var(--header-height) + 0.5rem)\n\n // Center the page, and accommodate for the header.\n .page\n flex-direction: column\n justify-content: center\n .content\n margin-left: auto\n margin-right: auto\n\n@media (max-width: $content-width + 2* $content-padding)\n // Content should respect window limits.\n .content\n width: 100%\n overflow-x: auto\n\n@media (max-width: $content-width)\n .content\n padding: 0 $content-padding--small\n // Don't float sidebars to the right.\n article aside.sidebar\n float: none\n width: 100%\n margin: 1rem 0\n","//\n// The design here is strongly inspired by mkdocs-material.\n.admonition, .topic\n margin: 1rem auto\n padding: 0 0.5rem 0.5rem 0.5rem\n\n background: var(--color-admonition-background)\n\n border-radius: 0.2rem\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n font-size: var(--admonition-font-size)\n\n overflow: hidden\n page-break-inside: avoid\n\n // First element should have no margin, since the title has it.\n > :nth-child(2)\n margin-top: 0\n\n // Last item should have no margin, since we'll control that w/ padding\n > :last-child\n margin-bottom: 0\n\n.admonition p.admonition-title,\np.topic-title\n position: relative\n margin: 0 -0.5rem 0.5rem\n padding-left: 2rem\n padding-right: .5rem\n padding-top: .4rem\n padding-bottom: .4rem\n\n font-weight: 500\n font-size: var(--admonition-title-font-size)\n line-height: 1.3\n\n // Our fancy icon\n &::before\n content: \"\"\n position: absolute\n left: 0.5rem\n width: 1rem\n height: 1rem\n\n// Default styles\np.admonition-title\n background-color: var(--color-admonition-title-background)\n &::before\n background-color: var(--color-admonition-title)\n mask-image: var(--icon-admonition-default)\n mask-repeat: no-repeat\n\np.topic-title\n background-color: var(--color-topic-title-background)\n &::before\n background-color: var(--color-topic-title)\n mask-image: var(--icon-topic-default)\n mask-repeat: no-repeat\n\n//\n// Variants\n//\n.admonition\n border-left: 0.2rem solid var(--color-admonition-title)\n\n @each $type, $value in $admonitions\n &.#{$type}\n border-left-color: var(--color-admonition-title--#{$type})\n > .admonition-title\n background-color: var(--color-admonition-title-background--#{$type})\n &::before\n background-color: var(--color-admonition-title--#{$type})\n mask-image: var(--icon-#{nth($value, 2)})\n\n.admonition-todo > .admonition-title\n text-transform: uppercase\n","// This file stylizes the API documentation (stuff generated by autodoc). It's\n// deeply nested due to how autodoc structures the HTML without enough classes\n// to select the relevant items.\n\n// API docs!\ndl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)\n // Tweak the spacing of all the things!\n dd\n margin-left: 2rem\n > :first-child\n margin-top: 0.125rem\n > :last-child\n margin-bottom: 0.75rem\n\n // This is used for the arguments\n .field-list\n margin-bottom: 0.75rem\n\n // \"Headings\" (like \"Parameters\" and \"Return\")\n > dt\n text-transform: uppercase\n font-size: var(--font-size--small)\n\n dd:empty\n margin-bottom: 0.5rem\n dd > ul\n margin-left: -1.2rem\n > li\n > p:nth-child(2)\n margin-top: 0\n // When the last-empty-paragraph follows a paragraph, it doesn't need\n // to augument the existing spacing.\n > p + p:last-child:empty\n margin-top: 0\n margin-bottom: 0\n\n // Colorize the elements\n > dt\n color: var(--color-api-overall)\n\n.sig:not(.sig-inline)\n font-weight: bold\n\n font-size: var(--api-font-size)\n font-family: var(--font-stack--monospace)\n\n margin-left: -0.25rem\n margin-right: -0.25rem\n padding-top: 0.25rem\n padding-bottom: 0.25rem\n padding-right: 0.5rem\n\n // These are intentionally em, to properly match the font size.\n padding-left: 3em\n text-indent: -2.5em\n\n border-radius: 0.25rem\n\n background: var(--color-api-background)\n transition: background 100ms ease-out\n\n &:hover\n background: var(--color-api-background-hover)\n\n // adjust the size of the [source] link on the right.\n a.reference\n .viewcode-link\n font-weight: normal\n width: 3.5rem\n\nem.property\n font-style: normal\n &:first-child\n color: var(--color-api-keyword)\n.sig-name\n color: var(--color-api-name)\n.sig-prename\n font-weight: normal\n color: var(--color-api-pre-name)\n.sig-paren\n color: var(--color-api-paren)\n.sig-param\n font-style: normal\n\n.versionmodified\n font-style: italic\ndiv.versionadded, div.versionchanged, div.deprecated\n p\n margin-top: 0.125rem\n margin-bottom: 0.125rem\n\n// Align the [docs] and [source] to the right.\n.viewcode-link, .viewcode-back\n float: right\n text-align: right\n",".line-block\n margin-top: 0.5rem\n margin-bottom: 0.75rem\n .line-block\n margin-top: 0rem\n margin-bottom: 0rem\n padding-left: 1rem\n","// Captions\narticle p.caption,\ntable > caption,\n.code-block-caption\n font-size: var(--font-size--small)\n text-align: center\n\n// Caption above a TOCTree\n.toctree-wrapper.compound\n .caption, :not(.caption) > .caption-text\n font-size: var(--font-size--small)\n text-transform: uppercase\n\n text-align: initial\n margin-bottom: 0\n\n > ul\n margin-top: 0\n margin-bottom: 0\n","// Inline code\ncode.literal, .sig-inline\n background: var(--color-inline-code-background)\n border-radius: 0.2em\n // Make the font smaller, and use padding to recover.\n font-size: var(--font-size--small--2)\n padding: 0.1em 0.2em\n\n pre.literal-block &\n font-size: inherit\n padding: 0\n\n p &\n border: 1px solid var(--color-background-border)\n\n.sig-inline\n font-family: var(--font-stack--monospace)\n\n// Code and Literal Blocks\n$code-spacing-vertical: 0.625rem\n$code-spacing-horizontal: 0.875rem\n\n// Wraps every literal block + line numbers.\ndiv[class*=\" highlight-\"],\ndiv[class^=\"highlight-\"]\n margin: 1em 0\n display: flex\n\n .table-wrapper\n margin: 0\n padding: 0\n\npre\n margin: 0\n padding: 0\n overflow: auto\n\n // Needed to have more specificity than pygments' \"pre\" selector. :(\n article[role=\"main\"] .highlight &\n line-height: 1.5\n\n &.literal-block,\n .highlight &\n font-size: var(--code-font-size)\n padding: $code-spacing-vertical $code-spacing-horizontal\n\n // Make it look like all the other blocks.\n &.literal-block\n margin-top: 1rem\n margin-bottom: 1rem\n\n border-radius: 0.2rem\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n\n// All code is always contained in this.\n.highlight\n width: 100%\n border-radius: 0.2rem\n\n // Make line numbers and prompts un-selectable.\n .gp, span.linenos\n user-select: none\n pointer-events: none\n\n // Expand the line-highlighting.\n .hll\n display: block\n margin-left: -$code-spacing-horizontal\n margin-right: -$code-spacing-horizontal\n padding-left: $code-spacing-horizontal\n padding-right: $code-spacing-horizontal\n\n/* Make code block captions be nicely integrated */\n.code-block-caption\n display: flex\n padding: $code-spacing-vertical $code-spacing-horizontal\n\n border-radius: 0.25rem\n border-bottom-left-radius: 0\n border-bottom-right-radius: 0\n font-weight: 300\n border-bottom: 1px solid\n\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n border-color: var(--color-background-border)\n\n + div[class]\n margin-top: 0\n pre\n border-top-left-radius: 0\n border-top-right-radius: 0\n\n// When `html_codeblock_linenos_style` is table.\n.highlighttable\n width: 100%\n display: block\n tbody\n display: block\n\n tr\n display: flex\n\n // Line numbers\n td.linenos\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n padding: $code-spacing-vertical $code-spacing-horizontal\n padding-right: 0\n border-top-left-radius: 0.2rem\n border-bottom-left-radius: 0.2rem\n\n .linenodiv\n padding-right: $code-spacing-horizontal\n font-size: var(--code-font-size)\n box-shadow: -0.0625rem 0 var(--color-foreground-border) inset\n\n // Actual code\n td.code\n padding: 0\n display: block\n flex: 1\n overflow: hidden\n\n .highlight\n border-top-left-radius: 0\n border-bottom-left-radius: 0\n\n// When `html_codeblock_linenos_style` is inline.\n.highlight\n span.linenos\n display: inline-block\n padding-left: 0\n padding-right: $code-spacing-horizontal\n margin-right: $code-spacing-horizontal\n box-shadow: -0.0625rem 0 var(--color-foreground-border) inset\n","// Inline Footnote Reference\n.footnote-reference\n font-size: var(--font-size--small--4)\n vertical-align: super\n\n// Definition list, listing the content of each note.\n// docutils <= 0.17\ndl.footnote.brackets\n font-size: var(--font-size--small)\n color: var(--color-foreground-secondary)\n\n display: grid\n grid-template-columns: max-content auto\n dt\n margin: 0\n > .fn-backref\n margin-left: 0.25rem\n\n &:after\n content: \":\"\n\n .brackets\n &:before\n content: \"[\"\n &:after\n content: \"]\"\n\n dd\n margin: 0\n padding: 0 1rem\n\n// docutils >= 0.18\naside.footnote\n font-size: var(--font-size--small)\n color: var(--color-foreground-secondary)\n\naside.footnote > span,\ndiv.citation > span\n float: left\n font-weight: 500\n padding-right: 0.25rem\n\naside.footnote > p,\ndiv.citation > p\n margin-left: 2rem\n","//\n// Figures\n//\nimg\n box-sizing: border-box\n max-width: 100%\n height: auto\n\narticle\n figure, .figure\n border-radius: 0.2rem\n\n margin: 0\n :last-child\n margin-bottom: 0\n\n .align-left\n float: left\n clear: left\n margin: 0 1rem 1rem\n\n .align-right\n float: right\n clear: right\n margin: 0 1rem 1rem\n\n .align-default,\n .align-center\n display: block\n text-align: center\n margin-left: auto\n margin-right: auto\n\n // WELL, table needs to be stylised like a table.\n table.align-default\n display: table\n text-align: initial\n",".genindex-jumpbox, .domainindex-jumpbox\n border-top: 1px solid var(--color-background-border)\n border-bottom: 1px solid var(--color-background-border)\n padding: 0.25rem\n\n.genindex-section, .domainindex-section\n h2\n margin-top: 0.75rem\n margin-bottom: 0.5rem\n ul\n margin-top: 0\n margin-bottom: 0\n","ul,\nol\n padding-left: 1.2rem\n\n // Space lists out like paragraphs\n margin-top: 1rem\n margin-bottom: 1rem\n // reduce margins within li.\n li\n > p:first-child\n margin-top: 0.25rem\n margin-bottom: 0.25rem\n\n > p:last-child\n margin-top: 0.25rem\n\n > ul,\n > ol\n margin-top: 0.5rem\n margin-bottom: 0.5rem\n\nol\n &.arabic\n list-style: decimal\n &.loweralpha\n list-style: lower-alpha\n &.upperalpha\n list-style: upper-alpha\n &.lowerroman\n list-style: lower-roman\n &.upperroman\n list-style: upper-roman\n\n// Don't space lists out when they're \"simple\" or in a `.. toctree::`\n.simple,\n.toctree-wrapper\n li\n > ul,\n > ol\n margin-top: 0\n margin-bottom: 0\n\n// Definition Lists\n.field-list,\n.option-list,\ndl:not([class]),\ndl.simple,\ndl.footnote,\ndl.glossary\n dt\n font-weight: 500\n margin-top: 0.25rem\n + dt\n margin-top: 0\n\n .classifier::before\n content: \":\"\n margin-left: 0.2rem\n margin-right: 0.2rem\n\n dd\n > p:first-child,\n ul\n margin-top: 0.125rem\n\n ul\n margin-bottom: 0.125rem\n",".math-wrapper\n width: 100%\n overflow-x: auto\n\ndiv.math\n position: relative\n text-align: center\n\n .headerlink,\n &:focus .headerlink\n display: none\n\n &:hover .headerlink\n display: inline-block\n\n span.eqno\n position: absolute\n right: 0.5rem\n top: 50%\n transform: translate(0, -50%)\n z-index: 1\n","// Abbreviations\nabbr[title]\n cursor: help\n\n// \"Problematic\" content, as identified by Sphinx\n.problematic\n color: var(--color-problematic)\n\n// Keyboard / Mouse \"instructions\"\nkbd:not(.compound)\n margin: 0 0.2rem\n padding: 0 0.2rem\n border-radius: 0.2rem\n border: 1px solid var(--color-foreground-border)\n color: var(--color-foreground-primary)\n vertical-align: text-bottom\n\n font-size: var(--font-size--small--3)\n display: inline-block\n\n box-shadow: 0 0.0625rem 0 rgba(0, 0, 0, 0.2), inset 0 0 0 0.125rem var(--color-background-primary)\n\n background-color: var(--color-background-secondary)\n\n// Blockquote\nblockquote\n border-left: 4px solid var(--color-background-border)\n background: var(--color-background-secondary)\n\n margin-left: 0\n margin-right: 0\n padding: 0.5rem 1rem\n\n .attribution\n font-weight: 600\n text-align: right\n\n &.pull-quote,\n &.highlights\n font-size: 1.25em\n\n &.epigraph,\n &.pull-quote\n border-left-width: 0\n border-radius: 0.5rem\n\n &.highlights\n border-left-width: 0\n background: transparent\n\n// Center align embedded-in-text images\np .reference img\n vertical-align: middle\n","p.rubric\n line-height: 1.25\n font-weight: bold\n font-size: 1.125em\n\n // For Numpy-style documentation that's got rubrics within it.\n // https://github.com/pradyunsg/furo/discussions/505\n dd &\n line-height: inherit\n font-weight: inherit\n\n font-size: var(--font-size--small)\n text-transform: uppercase\n","article .sidebar\n float: right\n clear: right\n width: 30%\n\n margin-left: 1rem\n margin-right: 0\n\n border-radius: 0.2rem\n background-color: var(--color-background-secondary)\n border: var(--color-background-border) 1px solid\n\n > *\n padding-left: 1rem\n padding-right: 1rem\n\n > ul, > ol // lists need additional padding, because bullets.\n padding-left: 2.2rem\n\n .sidebar-title\n margin: 0\n padding: 0.5rem 1rem\n border-bottom: var(--color-background-border) 1px solid\n\n font-weight: 500\n\n// TODO: subtitle\n// TODO: dedicated variables?\n",".table-wrapper\n width: 100%\n overflow-x: auto\n margin-top: 1rem\n margin-bottom: 0.5rem\n padding: 0.2rem 0.2rem 0.75rem\n\ntable.docutils\n border-radius: 0.2rem\n border-spacing: 0\n border-collapse: collapse\n\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n th\n background: var(--color-table-header-background)\n\n td,\n th\n // Space things out properly\n padding: 0 0.25rem\n\n // Get the borders looking just-right.\n border-left: 1px solid var(--color-table-border)\n border-right: 1px solid var(--color-table-border)\n border-bottom: 1px solid var(--color-table-border)\n\n p\n margin: 0.25rem\n\n &:first-child\n border-left: none\n &:last-child\n border-right: none\n\n // MyST-parser tables set these classes for control of column alignment\n &.text-left\n text-align: left\n &.text-right\n text-align: right\n &.text-center\n text-align: center\n",":target\n scroll-margin-top: 0.5rem\n\n@media (max-width: $full-width - $sidebar-width)\n :target\n scroll-margin-top: calc(0.5rem + var(--header-height))\n\n // When a heading is selected\n section > span:target\n scroll-margin-top: calc(0.8rem + var(--header-height))\n\n// Permalinks\n.headerlink\n font-weight: 100\n user-select: none\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\ndl dt,\np.caption,\nfigcaption p,\ntable > caption,\n.code-block-caption\n > .headerlink\n margin-left: 0.5rem\n visibility: hidden\n &:hover > .headerlink\n visibility: visible\n\n // Don't change to link-like, if someone adds the contents directive.\n > .toc-backref\n color: inherit\n text-decoration-line: none\n\n// Figure and table captions are special.\nfigure:hover > figcaption > p > .headerlink,\ntable:hover > caption > .headerlink\n visibility: visible\n\n:target >, // Regular section[id] style anchors\nspan:target ~ // Non-regular span[id] style \"extra\" anchors\n h1,\n h2,\n h3,\n h4,\n h5,\n h6\n &:nth-of-type(1)\n background-color: var(--color-highlight-on-target)\n // .headerlink\n // visibility: visible\n code.literal\n background-color: transparent\n\ntable:target > caption,\nfigure:target\n background-color: var(--color-highlight-on-target)\n\n// Inline page contents\n.this-will-duplicate-information-and-it-is-still-useful-here li :target\n background-color: var(--color-highlight-on-target)\n\n// Code block permalinks\n.literal-block-wrapper:target .code-block-caption\n background-color: var(--color-highlight-on-target)\n\n// When a definition list item is selected\n//\n// There isn't really an alternative to !important here, due to the\n// high-specificity of API documentation's selector.\ndt:target\n background-color: var(--color-highlight-on-target) !important\n\n// When a footnote reference is selected\n.footnote > dt:target + dd,\n.footnote-reference:target\n background-color: var(--color-highlight-on-target)\n",".guilabel\n background-color: var(--color-guilabel-background)\n border: 1px solid var(--color-guilabel-border)\n color: var(--color-guilabel-text)\n\n padding: 0 0.3em\n border-radius: 0.5em\n font-size: 0.9em\n","// This file contains the styles used for stylizing the footer that's shown\n// below the content.\n\nfooter\n font-size: var(--font-size--small)\n display: flex\n flex-direction: column\n\n margin-top: 2rem\n\n// Bottom of page information\n.bottom-of-page\n display: flex\n align-items: center\n justify-content: space-between\n\n margin-top: 1rem\n padding-top: 1rem\n padding-bottom: 1rem\n\n color: var(--color-foreground-secondary)\n border-top: 1px solid var(--color-background-border)\n\n line-height: 1.5\n\n @media (max-width: $content-width)\n text-align: center\n flex-direction: column-reverse\n gap: 0.25rem\n\n .left-details\n font-size: var(--font-size--small)\n\n .right-details\n display: flex\n flex-direction: column\n gap: 0.25rem\n text-align: right\n\n .icons\n display: flex\n justify-content: flex-end\n gap: 0.25rem\n font-size: 1rem\n\n a\n text-decoration: none\n\n svg,\n img\n font-size: 1.125rem\n height: 1em\n width: 1em\n\n// Next/Prev page information\n.related-pages\n a\n display: flex\n align-items: center\n\n text-decoration: none\n &:hover .page-info .title\n text-decoration: underline\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline)\n\n svg.furo-related-icon,\n svg.furo-related-icon > use\n flex-shrink: 0\n\n color: var(--color-foreground-border)\n\n width: 0.75rem\n height: 0.75rem\n margin: 0 0.5rem\n\n &.next-page\n max-width: 50%\n\n float: right\n clear: right\n text-align: right\n\n &.prev-page\n max-width: 50%\n\n float: left\n clear: left\n\n svg\n transform: rotate(180deg)\n\n.page-info\n display: flex\n flex-direction: column\n overflow-wrap: anywhere\n\n .next-page &\n align-items: flex-end\n\n .context\n display: flex\n align-items: center\n\n padding-bottom: 0.1rem\n\n color: var(--color-foreground-muted)\n font-size: var(--font-size--small)\n text-decoration: none\n","// This file contains the styles for the contents of the left sidebar, which\n// contains the navigation tree, logo, search etc.\n\n////////////////////////////////////////////////////////////////////////////////\n// Brand on top of the scrollable tree.\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-brand\n display: flex\n flex-direction: column\n flex-shrink: 0\n\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n text-decoration: none\n\n.sidebar-brand-text\n color: var(--color-sidebar-brand-text)\n overflow-wrap: break-word\n margin: var(--sidebar-item-spacing-vertical) 0\n font-size: 1.5rem\n\n.sidebar-logo-container\n margin: var(--sidebar-item-spacing-vertical) 0\n\n.sidebar-logo\n margin: 0 auto\n display: block\n max-width: 100%\n\n////////////////////////////////////////////////////////////////////////////////\n// Search\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-search-container\n display: flex\n align-items: center\n margin-top: var(--sidebar-search-space-above)\n\n position: relative\n\n background: var(--color-sidebar-search-background)\n &:hover,\n &:focus-within\n background: var(--color-sidebar-search-background--focus)\n\n &::before\n content: \"\"\n position: absolute\n left: var(--sidebar-item-spacing-horizontal)\n width: var(--sidebar-search-icon-size)\n height: var(--sidebar-search-icon-size)\n\n background-color: var(--color-sidebar-search-icon)\n mask-image: var(--icon-search)\n\n.sidebar-search\n box-sizing: border-box\n\n border: none\n border-top: 1px solid var(--color-sidebar-search-border)\n border-bottom: 1px solid var(--color-sidebar-search-border)\n\n padding-top: var(--sidebar-search-input-spacing-vertical)\n padding-bottom: var(--sidebar-search-input-spacing-vertical)\n padding-right: var(--sidebar-search-input-spacing-horizontal)\n padding-left: calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size))\n\n width: 100%\n\n color: var(--color-sidebar-search-foreground)\n background: transparent\n z-index: 10\n\n &:focus\n outline: none\n\n &::placeholder\n font-size: var(--sidebar-search-input-font-size)\n\n//\n// Hide Search Matches link\n//\n#searchbox .highlight-link\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0\n margin: 0\n text-align: center\n\n a\n color: var(--color-sidebar-search-icon)\n font-size: var(--font-size--small--2)\n\n////////////////////////////////////////////////////////////////////////////////\n// Structure/Skeleton of the navigation tree (left)\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-tree\n font-size: var(--sidebar-item-font-size)\n margin-top: var(--sidebar-tree-space-above)\n margin-bottom: var(--sidebar-item-spacing-vertical)\n\n ul\n padding: 0\n margin-top: 0\n margin-bottom: 0\n\n display: flex\n flex-direction: column\n\n list-style: none\n\n li\n position: relative\n margin: 0\n\n > ul\n margin-left: var(--sidebar-item-spacing-horizontal)\n\n .icon\n color: var(--color-sidebar-link-text)\n\n .reference\n box-sizing: border-box\n color: var(--color-sidebar-link-text)\n\n // Fill the parent.\n display: inline-block\n line-height: var(--sidebar-item-line-height)\n text-decoration: none\n\n // Don't allow long words to cause wrapping.\n overflow-wrap: anywhere\n\n height: 100%\n width: 100%\n\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n\n &:hover\n background: var(--color-sidebar-item-background--hover)\n\n // Add a nice little \"external-link\" arrow here.\n &.external::after\n content: url('data:image/svg+xml,')\n margin: 0 0.25rem\n vertical-align: middle\n color: var(--color-sidebar-link-text)\n\n // Make the current page reference bold.\n .current-page > .reference\n font-weight: bold\n\n label\n position: absolute\n top: 0\n right: 0\n height: var(--sidebar-item-height)\n width: var(--sidebar-expander-width)\n\n cursor: pointer\n user-select: none\n\n display: flex\n justify-content: center\n align-items: center\n\n .caption, :not(.caption) > .caption-text\n font-size: var(--sidebar-caption-font-size)\n color: var(--color-sidebar-caption-text)\n\n font-weight: bold\n text-transform: uppercase\n\n margin: var(--sidebar-caption-space-above) 0 0 0\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n\n // If it has children, add a bit more padding to wrap the content to avoid\n // overlapping with the