From 3b6c1dc0bd080eb7a0024aebd15c49b897fd7e44 Mon Sep 17 00:00:00 2001 From: nathom Date: Mon, 22 Mar 2021 09:21:27 -0700 Subject: [PATCH] initial commit --- .flake8 | 7 + .gitignore | 155 ++++ .isort.cfg | 3 + LICENSE | 674 +++++++++++++++++ README.md | 177 +++++ qobuz_dl/__init__.py | 2 + qobuz_dl/cli.py | 187 +++++ qobuz_dl/color.py | 14 + qobuz_dl/commands.py | 167 +++++ qobuz_dl/core.py | 575 +++++++++++++++ qobuz_dl/db.py | 39 + qobuz_dl/downloader.py | 400 ++++++++++ qobuz_dl/exceptions.py | 22 + qobuz_dl/metadata.py | 224 ++++++ qobuz_dl/qopy.py | 204 +++++ qobuz_dl/spoofbuz.py | 51 ++ qobuz_dl_rewrite/__init__.py | 0 qobuz_dl_rewrite/cli.py | 107 +++ qobuz_dl_rewrite/clients.py | 503 +++++++++++++ qobuz_dl_rewrite/config.py | 159 ++++ qobuz_dl_rewrite/constants.py | 146 ++++ qobuz_dl_rewrite/converter.py | 213 ++++++ qobuz_dl_rewrite/core.py | 184 +++++ qobuz_dl_rewrite/db.py | 62 ++ qobuz_dl_rewrite/downloader.py | 1270 ++++++++++++++++++++++++++++++++ qobuz_dl_rewrite/exceptions.py | 46 ++ qobuz_dl_rewrite/metadata.py | 391 ++++++++++ qobuz_dl_rewrite/spoofbuz.py | 56 ++ qobuz_dl_rewrite/utils.py | 154 ++++ setup.py | 41 ++ 30 files changed, 6233 insertions(+) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 .isort.cfg create mode 100644 LICENSE create mode 100644 README.md create mode 100644 qobuz_dl/__init__.py create mode 100644 qobuz_dl/cli.py create mode 100644 qobuz_dl/color.py create mode 100644 qobuz_dl/commands.py create mode 100644 qobuz_dl/core.py create mode 100644 qobuz_dl/db.py create mode 100644 qobuz_dl/downloader.py create mode 100644 qobuz_dl/exceptions.py create mode 100644 qobuz_dl/metadata.py create mode 100644 qobuz_dl/qopy.py create mode 100644 qobuz_dl/spoofbuz.py create mode 100644 qobuz_dl_rewrite/__init__.py create mode 100644 qobuz_dl_rewrite/cli.py create mode 100644 qobuz_dl_rewrite/clients.py create mode 100644 qobuz_dl_rewrite/config.py create mode 100644 qobuz_dl_rewrite/constants.py create mode 100644 qobuz_dl_rewrite/converter.py create mode 100644 qobuz_dl_rewrite/core.py create mode 100644 qobuz_dl_rewrite/db.py create mode 100644 qobuz_dl_rewrite/downloader.py create mode 100644 qobuz_dl_rewrite/exceptions.py create mode 100644 qobuz_dl_rewrite/metadata.py create mode 100644 qobuz_dl_rewrite/spoofbuz.py create mode 100644 qobuz_dl_rewrite/utils.py create mode 100644 setup.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..449e476 --- /dev/null +++ b/.flake8 @@ -0,0 +1,7 @@ +[flake8] +extend-ignore = E203, E266, E501 +# line length is intentionally set to 80 here because black uses Bugbear +# See https://github.com/psf/black/blob/master/docs/the_black_code_style.md#line-length for more details +max-line-length = 80 +max-complexity = 18 +select = B,C,E,F,W,T4,B9 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23e6070 --- /dev/null +++ b/.gitignore @@ -0,0 +1,155 @@ +Qobuz Downloads +*__pycache* +.env +__pycache__/ +*.py[cod] +*$py.class +.bumpversion.cfg +/*.py +!/setup.py + +# C extensions +*.so + +*.json +*.txt +*.db +*.sh + +*.txt + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ +*.yaml +*.flac +*.m4a +*.ogg +*.mp3 diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..cd3bfb3 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,3 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8566a0 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# qobuz-dl +Search, explore and download Lossless and Hi-Res music from [Qobuz](https://www.qobuz.com/). +[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=VZWSWVGZGJRMU&source=url) + +## Features + +* Download FLAC and MP3 files from Qobuz +* Explore and download music directly from your terminal with **interactive** or **lucky** mode +* Download albums, tracks, artists, playlists and labels with **download** mode +* Download music from last.fm playlists (Spotify, Apple Music and Youtube playlists are also supported through this method) +* Queue support on **interactive** mode +* Effective duplicate handling with own portable database +* Support for albums with multiple discs +* Support for M3U playlists +* Downloads URLs from text file +* Extended tags +* And more + +## Getting started + +> You'll need an **active subscription** + +#### Install qobuz-dl with pip +##### Linux / MAC OS +``` +pip3 install --upgrade qobuz-dl +``` +##### Windows +``` +pip3 install windows-curses +pip3 install --upgrade qobuz-dl +``` +#### Run qobuz-dl and enter your credentials +##### Linux / MAC OS +``` +qobuz-dl +``` +##### Windows +``` +qobuz-dl.exe +``` + +> If something fails, run `qobuz-dl -r` to reset your config file. + +## Examples + +### Download mode +Download URL in 24B<96khz quality +``` +qobuz-dl dl https://play.qobuz.com/album/qxjbxh1dc3xyb -q 7 +``` +Download multiple URLs to custom directory +``` +qobuz-dl dl https://play.qobuz.com/artist/2038380 https://play.qobuz.com/album/ip8qjy1m6dakc -d "Some pop from 2020" +``` +Download multiple URLs from text file +``` +qobuz-dl dl this_txt_file_has_urls.txt +``` +Download albums from a label and also embed cover art images into the downloaded files +``` +qobuz-dl dl https://play.qobuz.com/label/7526 --embed-art +``` +Download a Qobuz playlist in maximum quality +``` +qobuz-dl dl https://play.qobuz.com/playlist/5388296 -q 27 +``` +Download all the music from an artist except singles, EPs and VA releases +``` +qobuz-dl dl https://play.qobuz.com/artist/2528676 --albums-only +``` + +#### Last.fm playlists +> Last.fm has a new feature for creating playlists: you can create your own based on the music you listen to or you can import one from popular streaming services like Spotify, Apple Music and Youtube. Visit: `https://www.last.fm/user//playlists` (e.g. https://www.last.fm/user/vitiko98/playlists) to get started. + +Download a last.fm playlist in the maximum quality +``` +qobuz-dl dl https://www.last.fm/user/vitiko98/playlists/11887574 -q 27 +``` + +Run `qobuz-dl dl --help` for more info. + +### Interactive mode +Run interactive mode with a limit of 10 results +``` +qobuz-dl fun -l 10 +``` +Type your search query +``` +Logging... +Logged: OK +Membership: Studio + + +Enter your search: [Ctrl + c to quit] +- fka twigs magdalene +``` +`qobuz-dl` will bring up a nice list of releases. Now choose whatever releases you want to download (everything else is interactive). + +Run `qobuz-dl fun --help` for more info. + +### Lucky mode +Download the first album result +``` +qobuz-dl lucky playboi carti die lit +``` +Download the first 5 artist results +``` +qobuz-dl lucky joy division -n 5 --type artist +``` +Download the first 3 track results in 320 quality +``` +qobuz-dl lucky eric dolphy remastered --type track -n 3 -q 5 +``` +Download the first track result without cover art +``` +qobuz-dl lucky jay z story of oj --type track --no-cover +``` + +Run `qobuz-dl lucky --help` for more info. + +### Other +Reset your config file +``` +qobuz-dl -r +``` + +By default, `qobuz-dl` will skip already downloaded items by ID with the message `This release ID ({item_id}) was already downloaded`. To avoid this check, add the flag `--no-db` at the end of a command. In extreme cases (e.g. lost collection), you can run `qobuz-dl -p` to completely reset the database. + +## Usage +``` +usage: qobuz-dl [-h] [-r] {fun,dl,lucky} ... + +The ultimate Qobuz music downloader. +See usage examples on https://github.com/vitiko98/qobuz-dl + +optional arguments: + -h, --help show this help message and exit + -r, --reset create/reset config file + -p, --purge purge/delete downloaded-IDs database + +commands: + run qobuz-dl --help for more info + (e.g. qobuz-dl fun --help) + + {fun,dl,lucky} + fun interactive mode + dl input mode + lucky lucky mode +``` + +## Module usage +Using `qobuz-dl` as a module is really easy. Basically, the only thing you need is `QobuzDL` from `core`. + +```python +import logging +from qobuz_dl.core import QobuzDL + +logging.basicConfig(level=logging.INFO) + +email = "your@email.com" +password = "your_password" + +qobuz = QobuzDL() +qobuz.get_tokens() # get 'app_id' and 'secrets' attrs +qobuz.initialize_client(email, password, qobuz.app_id, qobuz.secrets) + +qobuz.handle_url("https://play.qobuz.com/album/va4j3hdlwaubc") +``` + +Attributes, methods and parameters have been named as self-explanatory as possible. + +## A note about Qo-DL +`qobuz-dl` is inspired in the discontinued Qo-DL-Reborn. This tool uses two modules from Qo-DL: `qopy` and `spoofer`, both written by Sorrow446 and DashLt. +## Disclaimer +* This tool was written for educational purposes. I will not be responsible if you use this program in bad faith. By using it, you are accepting the [Qobuz API Terms of Use](https://static.qobuz.com/apps/api/QobuzAPI-TermsofUse.pdf). +* `qobuz-dl` is not affiliated with Qobuz diff --git a/qobuz_dl/__init__.py b/qobuz_dl/__init__.py new file mode 100644 index 0000000..c88afc3 --- /dev/null +++ b/qobuz_dl/__init__.py @@ -0,0 +1,2 @@ +from .cli import main +from .qopy import Client diff --git a/qobuz_dl/cli.py b/qobuz_dl/cli.py new file mode 100644 index 0000000..fdc3592 --- /dev/null +++ b/qobuz_dl/cli.py @@ -0,0 +1,187 @@ +import configparser +import glob +import hashlib +import logging +import os +import sys + +import qobuz_dl.spoofbuz as spoofbuz +from qobuz_dl.color import GREEN, RED, YELLOW +from qobuz_dl.commands import qobuz_dl_args +from qobuz_dl.core import QobuzDL + +logging.basicConfig( + level=logging.INFO, + format="%(message)s", +) + +if os.name == "nt": + OS_CONFIG = os.environ.get("APPDATA") +else: + OS_CONFIG = os.path.join(os.environ["HOME"], ".config") + +CONFIG_PATH = os.path.join(OS_CONFIG, "qobuz-dl") +CONFIG_FILE = os.path.join(CONFIG_PATH, "config.ini") +QOBUZ_DB = os.path.join(CONFIG_PATH, "qobuz_dl.db") + + +def reset_config(config_file): + logging.info(f"{YELLOW}Creating config file: {config_file}") + config = configparser.ConfigParser() + config["DEFAULT"]["email"] = input("Enter your email:\n- ") + password = input("Enter your password\n- ") + config["DEFAULT"]["password"] = hashlib.md5(password.encode("utf-8")).hexdigest() + config["DEFAULT"]["default_folder"] = ( + input("Folder for downloads (leave empty for default 'Qobuz Downloads')\n- ") + or "Qobuz Downloads" + ) + config["DEFAULT"]["default_quality"] = ( + input( + "Download quality (5, 6, 7, 27) " + "[320, LOSSLESS, 24B <96KHZ, 24B >96KHZ]" + "\n(leave empty for default '6')\n- " + ) + or "6" + ) + config["DEFAULT"]["default_limit"] = "20" + config["DEFAULT"]["no_m3u"] = "false" + config["DEFAULT"]["albums_only"] = "false" + config["DEFAULT"]["no_fallback"] = "false" + config["DEFAULT"]["og_cover"] = "false" + config["DEFAULT"]["embed_art"] = "false" + config["DEFAULT"]["no_cover"] = "false" + config["DEFAULT"]["no_database"] = "false" + logging.info(f"{YELLOW}Getting tokens. Please wait...") + spoofer = spoofbuz.Spoofer() + config["DEFAULT"]["app_id"] = str(spoofer.getAppId()) + config["DEFAULT"]["secrets"] = ",".join(spoofer.getSecrets().values()) + config["DEFAULT"]["folder_format"] = "{artist} - {album} ({year}) " + "[{bit_depth}B-{sampling_rate}kHz]" + config["DEFAULT"]["track_format"] = "{tracknumber}. {tracktitle}" + config["DEFAULT"]["smart_discography"] = "false" + with open(config_file, "w") as configfile: + config.write(configfile) + logging.info( + f"{GREEN}Config file updated. Edit more options in {config_file}" + "\nso you don't have to call custom flags every time you run " + "a qobuz-dl command." + ) + + +def remove_leftovers(directory): + directory = os.path.join(directory, "**", ".*.tmp") + for i in glob.glob(directory, recursive=True): + try: + os.remove(i) + except: # noqa + pass + + +def main(): + if not os.path.isdir(CONFIG_PATH) or not os.path.isfile(CONFIG_FILE): + os.makedirs(CONFIG_PATH, exist_ok=True) + reset_config(CONFIG_FILE) + + if len(sys.argv) < 2: + sys.exit(qobuz_dl_args().print_help()) + + config = configparser.ConfigParser() + config.read(CONFIG_FILE) + + try: + email = config["DEFAULT"]["email"] + password = config["DEFAULT"]["password"] + default_folder = config["DEFAULT"]["default_folder"] + default_limit = config["DEFAULT"]["default_limit"] + default_quality = config["DEFAULT"]["default_quality"] + no_m3u = config.getboolean("DEFAULT", "no_m3u") + albums_only = config.getboolean("DEFAULT", "albums_only") + no_fallback = config.getboolean("DEFAULT", "no_fallback") + og_cover = config.getboolean("DEFAULT", "og_cover") + embed_art = config.getboolean("DEFAULT", "embed_art") + no_cover = config.getboolean("DEFAULT", "no_cover") + no_database = config.getboolean("DEFAULT", "no_database") + app_id = config["DEFAULT"]["app_id"] + + if ( + "folder_format" not in config["DEFAULT"] + or "track_format" not in config["DEFAULT"] + or "smart_discography" not in config["DEFAULT"] + ): + logging.info( + f"{YELLOW}Config file does not include some settings, updating..." + ) + config["DEFAULT"]["folder_format"] = "{artist} - {album} ({year}) " + "[{bit_depth}B-{sampling_rate}kHz]" + config["DEFAULT"]["track_format"] = "{tracknumber}. {tracktitle}" + config["DEFAULT"]["smart_discography"] = "false" + with open(CONFIG_FILE, "w") as cf: + config.write(cf) + + smart_discography = config.getboolean("DEFAULT", "smart_discography") + folder_format = config["DEFAULT"]["folder_format"] + track_format = config["DEFAULT"]["track_format"] + + secrets = [ + secret for secret in config["DEFAULT"]["secrets"].split(",") if secret + ] + arguments = qobuz_dl_args( + default_quality, default_limit, default_folder + ).parse_args() + except (KeyError, UnicodeDecodeError, configparser.Error): + arguments = qobuz_dl_args().parse_args() + if not arguments.reset: + sys.exit( + f"{RED}Your config file is corrupted! Run 'qobuz-dl -r' to fix this." + ) + + if arguments.reset: + sys.exit(reset_config(CONFIG_FILE)) + + if arguments.purge: + try: + os.remove(QOBUZ_DB) + except FileNotFoundError: + pass + sys.exit(f"{GREEN}The database was deleted.") + + qobuz = QobuzDL( + arguments.directory, + arguments.quality, + arguments.embed_art or embed_art, + ignore_singles_eps=arguments.albums_only or albums_only, + no_m3u_for_playlists=arguments.no_m3u or no_m3u, + quality_fallback=not arguments.no_fallback or not no_fallback, + cover_og_quality=arguments.og_cover or og_cover, + no_cover=arguments.no_cover or no_cover, + downloads_db=None if no_database or arguments.no_db else QOBUZ_DB, + folder_format=arguments.folder_format or folder_format, + track_format=arguments.track_format or track_format, + smart_discography=arguments.smart_discography or smart_discography, + ) + qobuz.initialize_client(email, password, app_id, secrets) + + try: + if arguments.command == "dl": + qobuz.download_list_of_urls(arguments.SOURCE) + elif arguments.command == "lucky": + query = " ".join(arguments.QUERY) + qobuz.lucky_type = arguments.type + qobuz.lucky_limit = arguments.number + qobuz.lucky_mode(query) + else: + qobuz.interactive_limit = arguments.limit + qobuz.interactive() + + except KeyboardInterrupt: + logging.info( + f"{RED}Interrupted by user\n{YELLOW}Already downloaded items will " + "be skipped if you try to download the same releases again." + ) + + finally: + remove_leftovers(qobuz.directory) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/qobuz_dl/color.py b/qobuz_dl/color.py new file mode 100644 index 0000000..e170c2d --- /dev/null +++ b/qobuz_dl/color.py @@ -0,0 +1,14 @@ +from colorama import Fore, Style, init + +init(autoreset=True) + +DF = Style.NORMAL +BG = Style.BRIGHT +RESET = Style.RESET_ALL +OFF = Style.DIM +RED = Fore.RED +BLUE = Fore.BLUE +GREEN = Fore.GREEN +YELLOW = Fore.YELLOW +CYAN = Fore.CYAN +MAGENTA = Fore.MAGENTA diff --git a/qobuz_dl/commands.py b/qobuz_dl/commands.py new file mode 100644 index 0000000..08b02dd --- /dev/null +++ b/qobuz_dl/commands.py @@ -0,0 +1,167 @@ +import argparse + + +def fun_args(subparsers, default_limit): + interactive = subparsers.add_parser( + "fun", + description="Interactively search for tracks and albums.", + help="interactive mode", + ) + interactive.add_argument( + "-l", + "--limit", + metavar="int", + default=default_limit, + help="limit of search results (default: 20)", + ) + return interactive + + +def lucky_args(subparsers): + lucky = subparsers.add_parser( + "lucky", + description="Download the first albums returned from a Qobuz search.", + help="lucky mode", + ) + lucky.add_argument( + "-t", + "--type", + default="album", + help="type of items to search (artist, album, track, playlist) (default: album)", + ) + lucky.add_argument( + "-n", + "--number", + metavar="int", + default=1, + help="number of results to download (default: 1)", + ) + lucky.add_argument("QUERY", nargs="+", help="search query") + return lucky + + +def dl_args(subparsers): + download = subparsers.add_parser( + "dl", + description="Download by album/track/artist/label/playlist/last.fm-playlist URL.", + help="input mode", + ) + download.add_argument( + "SOURCE", + metavar="SOURCE", + nargs="+", + help=("one or more URLs (space separated) or a text file"), + ) + return download + + +def add_common_arg(custom_parser, default_folder, default_quality): + custom_parser.add_argument( + "-d", + "--directory", + metavar="PATH", + default=default_folder, + help=f'directory for downloads (default: "{default_folder}")', + ) + custom_parser.add_argument( + "-q", + "--quality", + metavar="int", + default=default_quality, + help=( + 'audio "quality" (5, 6, 7, 27)\n' + f"[320, LOSSLESS, 24B<=96KHZ, 24B>96KHZ] (default: {default_quality})" + ), + ) + custom_parser.add_argument( + "--albums-only", + action="store_true", + help=("don't download singles, EPs and VA releases"), + ) + custom_parser.add_argument( + "--no-m3u", + action="store_true", + help="don't create .m3u files when downloading playlists", + ) + custom_parser.add_argument( + "--no-fallback", + action="store_true", + help="disable quality fallback (skip releases not available in set quality)", + ) + custom_parser.add_argument( + "-e", "--embed-art", action="store_true", help="embed cover art into files" + ) + custom_parser.add_argument( + "--og-cover", + action="store_true", + help="download cover art in its original quality (bigger file)", + ) + custom_parser.add_argument( + "--no-cover", action="store_true", help="don't download cover art" + ) + custom_parser.add_argument( + "--no-db", action="store_true", help="don't call the database" + ) + custom_parser.add_argument( + "-ff", + "--folder-format", + metavar="PATTERN", + help="""pattern for formatting folder names, e.g + "{artist} - {album} ({year})". available keys: artist, + albumartist, album, year, sampling_rate, bit_rate, tracktitle, version. + cannot contain characters used by the system, which includes /:<>""", + ) + custom_parser.add_argument( + "-tf", + "--track-format", + metavar="PATTERN", + help="pattern for formatting track names. see `folder-format`.", + ) + # TODO: add customization options + custom_parser.add_argument( + "-s", + "--smart-discography", + action="store_true", + help="""Try to filter out spam-like albums when requesting an artist's + discography, and other optimizations. Filters albums not made by requested + artist, and deluxe/live/collection albums. Gives preference to remastered + albums, high bit depth/dynamic range, and low sampling rates (to save space).""", + ) + + +def qobuz_dl_args( + default_quality=6, default_limit=20, default_folder="Qobuz Downloads" +): + parser = argparse.ArgumentParser( + prog="qobuz-dl", + description=( + "The ultimate Qobuz music downloader.\nSee usage" + " examples on https://github.com/vitiko98/qobuz-dl" + ), + formatter_class=argparse.RawTextHelpFormatter, + ) + parser.add_argument( + "-r", "--reset", action="store_true", help="create/reset config file" + ) + parser.add_argument( + "-p", + "--purge", + action="store_true", + help="purge/delete downloaded-IDs database", + ) + + subparsers = parser.add_subparsers( + title="commands", + description="run qobuz-dl --help for more info\n(e.g. qobuz-dl fun --help)", + dest="command", + ) + + interactive = fun_args(subparsers, default_limit) + download = dl_args(subparsers) + lucky = lucky_args(subparsers) + [ + add_common_arg(i, default_folder, default_quality) + for i in (interactive, download, lucky) + ] + + return parser diff --git a/qobuz_dl/core.py b/qobuz_dl/core.py new file mode 100644 index 0000000..95b634f --- /dev/null +++ b/qobuz_dl/core.py @@ -0,0 +1,575 @@ +import logging +import os +import re +import string +import sys +import time +from typing import Tuple + +import requests +from bs4 import BeautifulSoup as bso +from mutagen.flac import FLAC +from mutagen.mp3 import EasyMP3 +from pathvalidate import sanitize_filename + +import qobuz_dl.spoofbuz as spoofbuz +from qobuz_dl import downloader, qopy +from qobuz_dl.color import CYAN, DF, OFF, RED, RESET, YELLOW +from qobuz_dl.db import create_db, handle_download_id +from qobuz_dl.exceptions import NonStreamable + +WEB_URL = "https://play.qobuz.com/" +ARTISTS_SELECTOR = "td.chartlist-artist > a" +TITLE_SELECTOR = "td.chartlist-name > a" +EXTENSIONS = (".mp3", ".flac") +QUALITIES = { + 5: "5 - MP3", + 6: "6 - 16 bit, 44.1kHz", + 7: "7 - 24 bit, <96kHz", + 27: "27 - 24 bit, >96kHz", +} + +logger = logging.getLogger(__name__) + + +class PartialFormatter(string.Formatter): + def __init__(self, missing="n/a", bad_fmt="n/a"): + self.missing, self.bad_fmt = missing, bad_fmt + + def get_field(self, field_name, args, kwargs): + try: + val = super(PartialFormatter, self).get_field(field_name, args, kwargs) + except (KeyError, AttributeError): + val = None, field_name + return val + + def format_field(self, value, spec): + if not value: + return self.missing + try: + return super(PartialFormatter, self).format_field(value, spec) + except ValueError: + if self.bad_fmt: + return self.bad_fmt + raise + + +class QobuzDL: + def __init__( + self, + directory="Qobuz Downloads", + quality=6, + embed_art=False, + lucky_limit=1, + lucky_type="album", + interactive_limit=20, + ignore_singles_eps=False, + no_m3u_for_playlists=False, + quality_fallback=True, + cover_og_quality=False, + no_cover=False, + downloads_db=None, + folder_format="{artist} - {album} ({year}) [{bit_depth}B-" + "{sampling_rate}kHz]", + track_format="{tracknumber}. {tracktitle}", + smart_discography=False, + ): + self.directory = self.create_dir(directory) + self.quality = quality + self.embed_art = embed_art + self.lucky_limit = lucky_limit + self.lucky_type = lucky_type + self.interactive_limit = interactive_limit + self.ignore_singles_eps = ignore_singles_eps + self.no_m3u_for_playlists = no_m3u_for_playlists + self.quality_fallback = quality_fallback + self.cover_og_quality = cover_og_quality + self.no_cover = no_cover + self.downloads_db = create_db(downloads_db) if downloads_db else None + self.folder_format = folder_format + self.track_format = track_format + self.smart_discography = smart_discography + + def initialize_client(self, email, pwd, app_id, secrets): + self.client = qopy.Client(email, pwd, app_id, secrets) + logger.info(f"{YELLOW}Set max quality: {QUALITIES[int(self.quality)]}\n") + + def get_tokens(self): + spoofer = spoofbuz.Spoofer() + self.app_id = spoofer.getAppId() + self.secrets = [ + secret for secret in spoofer.getSecrets().values() if secret + ] # avoid empty fields + + def create_dir(self, directory=None): + fix = os.path.normpath(directory) + os.makedirs(fix, exist_ok=True) + return fix + + def get_url_info(self, url: str) -> Tuple[str, str]: + """Returns the type of the url and the id. + + Compatible with urls of the form: + https://www.qobuz.com/us-en/{type}/{name}/{id} + https://open.qobuz.com/{type}/{id} + https://play.qobuz.com/{type}/{id} + /us-en/{type}/-/{id} + """ + + r = re.search( + r"(?:https:\/\/(?:w{3}|open|play)\.qobuz\.com)?(?:\/[a-z]{2}-[a-z]{2})" + r"?\/(album|artist|track|playlist|label)(?:\/[-\w\d]+)?\/([\w\d]+)", + url, + ) + return r.groups() + + def download_from_id(self, item_id, album=True, alt_path=None): + if handle_download_id(self.downloads_db, item_id, add_id=False): + logger.info( + f"{OFF}This release ID ({item_id}) was already downloaded " + "according to the local database.\nUse the '--no-db' flag " + "to bypass this." + ) + return + try: + downloader.download_id_by_type( + self.client, + item_id, + alt_path or self.directory, + str(self.quality), + album, + self.embed_art, + self.ignore_singles_eps, + self.quality_fallback, + self.cover_og_quality, + self.no_cover, + folder_format=self.folder_format, + track_format=self.track_format, + ) + handle_download_id(self.downloads_db, item_id, add_id=True) + except (requests.exceptions.RequestException, NonStreamable) as e: + logger.error(f"{RED}Error getting release: {e}. Skipping...") + + def handle_url(self, url): + possibles = { + "playlist": { + "func": self.client.get_plist_meta, + "iterable_key": "tracks", + }, + "artist": { + "func": self.client.get_artist_meta, + "iterable_key": "albums", + }, + "label": { + "func": self.client.get_label_meta, + "iterable_key": "albums", + }, + "album": {"album": True, "func": None, "iterable_key": None}, + "track": {"album": False, "func": None, "iterable_key": None}, + } + try: + url_type, item_id = self.get_url_info(url) + type_dict = possibles[url_type] + except (KeyError, IndexError): + logger.info( + f'{RED}Invalid url: "{url}". Use urls from ' "https://play.qobuz.com!" + ) + return + if type_dict["func"]: + content = [item for item in type_dict["func"](item_id)] + content_name = content[0]["name"] + logger.info( + f"{YELLOW}Downloading all the music from {content_name} " + f"({url_type})!" + ) + new_path = self.create_dir( + os.path.join(self.directory, sanitize_filename(content_name)) + ) + + if self.smart_discography and url_type == "artist": + # change `save_space` and `skip_extras` for customization + items = self._smart_discography_filter( + content, + save_space=True, + skip_extras=True, + ) + else: + items = [item[type_dict["iterable_key"]]["items"] for item in content][ + 0 + ] + + logger.info(f"{YELLOW}{len(items)} downloads in queue") + for item in items: + self.download_from_id( + item["id"], + True if type_dict["iterable_key"] == "albums" else False, + new_path, + ) + if url_type == "playlist": + self.make_m3u(new_path) + else: + self.download_from_id(item_id, type_dict["album"]) + + def download_list_of_urls(self, urls): + if not urls or not isinstance(urls, list): + logger.info(f"{OFF}Nothing to download") + return + for url in urls: + if "last.fm" in url: + self.download_lastfm_pl(url) + elif os.path.isfile(url): + self.download_from_txt_file(url) + else: + self.handle_url(url) + + def download_from_txt_file(self, txt_file): + with open(txt_file, "r") as txt: + try: + urls = [ + line.replace("\n", "") + for line in txt.readlines() + if not line.strip().startswith("#") + ] + except Exception as e: + logger.error(f"{RED}Invalid text file: {e}") + return + logger.info( + f"{YELLOW}qobuz-dl will download {len(urls)}" + f" urls from file: {txt_file}" + ) + self.download_list_of_urls(urls) + + def lucky_mode(self, query, download=True): + if len(query) < 3: + logger.info(f"{RED}Your search query is too short or invalid") + return + + logger.info( + f'{YELLOW}Searching {self.lucky_type}s for "{query}".\n' + f"{YELLOW}qobuz-dl will attempt to download the first " + f"{self.lucky_limit} results." + ) + results = self.search_by_type(query, self.lucky_type, self.lucky_limit, True) + + if download: + self.download_list_of_urls(results) + + return results + + def format_duration(self, duration): + return time.strftime("%H:%M:%S", time.gmtime(duration)) + + def search_by_type(self, query, item_type, limit=10, lucky=False): + if len(query) < 3: + logger.info("{RED}Your search query is too short or invalid") + return + + possibles = { + "album": { + "func": self.client.search_albums, + "album": True, + "key": "albums", + "format": "{artist[name]} - {title}", + "requires_extra": True, + }, + "artist": { + "func": self.client.search_artists, + "album": True, + "key": "artists", + "format": "{name} - ({albums_count} releases)", + "requires_extra": False, + }, + "track": { + "func": self.client.search_tracks, + "album": False, + "key": "tracks", + "format": "{performer[name]} - {title}", + "requires_extra": True, + }, + "playlist": { + "func": self.client.search_playlists, + "album": False, + "key": "playlists", + "format": "{name} - ({tracks_count} releases)", + "requires_extra": False, + }, + } + + try: + mode_dict = possibles[item_type] + results = mode_dict["func"](query, limit) + iterable = results[mode_dict["key"]]["items"] + item_list = [] + for i in iterable: + fmt = PartialFormatter() + text = fmt.format(mode_dict["format"], **i) + if mode_dict["requires_extra"]: + + text = "{} - {} [{}]".format( + text, + self.format_duration(i["duration"]), + "HI-RES" if i["hires_streamable"] else "LOSSLESS", + ) + + url = "{}{}/{}".format(WEB_URL, item_type, i.get("id", "")) + item_list.append({"text": text, "url": url} if not lucky else url) + return item_list + except (KeyError, IndexError): + logger.info(f"{RED}Invalid type: {item_type}") + return + + def interactive(self, download=True): + try: + from pick import pick + except (ImportError, ModuleNotFoundError): + if os.name == "nt": + sys.exit( + "Please install curses with " + '"pip3 install windows-curses" to continue' + ) + raise + + qualities = [ + {"q_string": "320", "q": 5}, + {"q_string": "Lossless", "q": 6}, + {"q_string": "Hi-res =< 96kHz", "q": 7}, + {"q_string": "Hi-Res > 96 kHz", "q": 27}, + ] + + def get_title_text(option): + return option.get("text") + + def get_quality_text(option): + return option.get("q_string") + + try: + item_types = ["Albums", "Tracks", "Artists", "Playlists"] + selected_type = pick(item_types, "I'll search for:\n[press Intro]")[0][ + :-1 + ].lower() + logger.info(f"{YELLOW}Ok, we'll search for " f"{selected_type}s{RESET}") + final_url_list = [] + while True: + query = input( + f"{CYAN}Enter your search: [Ctrl + c to quit]\n" f"-{DF} " + ) + logger.info(f"{YELLOW}Searching...{RESET}") + options = self.search_by_type( + query, selected_type, self.interactive_limit + ) + if not options: + logger.info(f"{OFF}Nothing found{RESET}") + continue + title = ( + f'*** RESULTS FOR "{query.title()}" ***\n\n' + "Select [space] the item(s) you want to download " + "(one or more)\nPress Ctrl + c to quit\n" + "Don't select anything to try another search" + ) + selected_items = pick( + options, + title, + multiselect=True, + min_selection_count=0, + options_map_func=get_title_text, + ) + if len(selected_items) > 0: + [final_url_list.append(i[0]["url"]) for i in selected_items] + y_n = pick( + ["Yes", "No"], + "Items were added to queue to be downloaded. " + "Keep searching?", + ) + if y_n[0][0] == "N": + break + else: + logger.info(f"{YELLOW}Ok, try again...{RESET}") + continue + if final_url_list: + desc = ( + "Select [intro] the quality (the quality will " + "be automatically\ndowngraded if the selected " + "is not found)" + ) + self.quality = pick( + qualities, + desc, + default_index=1, + options_map_func=get_quality_text, + )[0]["q"] + + if download: + self.download_list_of_urls(final_url_list) + + return final_url_list + except KeyboardInterrupt: + logger.info(f"{YELLOW}Bye") + return + + def download_lastfm_pl(self, playlist_url): + # Apparently, last fm API doesn't have a playlist endpoint. If you + # find out that it has, please fix this! + try: + r = requests.get(playlist_url, timeout=10) + except requests.exceptions.RequestException as e: + logger.error(f"{RED}Playlist download failed: {e}") + return + soup = bso(r.content, "html.parser") + artists = [artist.text for artist in soup.select(ARTISTS_SELECTOR)] + titles = [title.text for title in soup.select(TITLE_SELECTOR)] + + track_list = [] + if len(artists) == len(titles) and artists: + track_list = [ + artist + " " + title for artist, title in zip(artists, titles) + ] + + if not track_list: + logger.info(f"{OFF}Nothing found") + return + + pl_title = sanitize_filename(soup.select_one("h1").text) + pl_directory = os.path.join(self.directory, pl_title) + logger.info( + f"{YELLOW}Downloading playlist: {pl_title} " f"({len(track_list)} tracks)" + ) + + for i in track_list: + track_id = self.get_url_info( + self.search_by_type(i, "track", 1, lucky=True)[0] + )[1] + if track_id: + self.download_from_id(track_id, False, pl_directory) + + self.make_m3u(pl_directory) + + def make_m3u(self, pl_directory): + if self.no_m3u_for_playlists: + return + + track_list = ["#EXTM3U"] + rel_folder = os.path.basename(os.path.normpath(pl_directory)) + pl_name = rel_folder + ".m3u" + for local, dirs, files in os.walk(pl_directory): + dirs.sort() + audio_rel_files = [ + # os.path.abspath(os.path.join(local, file_)) + # os.path.join(rel_folder, + # os.path.basename(os.path.normpath(local)), + # file_) + os.path.join(os.path.basename(os.path.normpath(local)), file_) + for file_ in files + if os.path.splitext(file_)[-1] in EXTENSIONS + ] + audio_files = [ + os.path.abspath(os.path.join(local, file_)) + for file_ in files + if os.path.splitext(file_)[-1] in EXTENSIONS + ] + if not audio_files or len(audio_files) != len(audio_rel_files): + continue + + for audio_rel_file, audio_file in zip(audio_rel_files, audio_files): + try: + pl_item = ( + EasyMP3(audio_file) + if ".mp3" in audio_file + else FLAC(audio_file) + ) + title = pl_item["TITLE"][0] + artist = pl_item["ARTIST"][0] + length = int(pl_item.info.length) + index = "#EXTINF:{}, {} - {}\n{}".format( + length, artist, title, audio_rel_file + ) + except: # noqa + continue + track_list.append(index) + + if len(track_list) > 1: + with open(os.path.join(pl_directory, pl_name), "w") as pl: + pl.write("\n\n".join(track_list)) + + def _smart_discography_filter( + self, contents: list, save_space: bool = False, skip_extras: bool = False + ) -> list: + """When downloading some artists' discography, many random and spam-like + albums can get downloaded. This helps filter those out to just get the good stuff. + + This function removes: + * albums by other artists, which may contain a feature from the requested artist + * duplicate albums in different qualities + * (optionally) removes collector's, deluxe, live albums + + :param list contents: contents returned by qobuz API + :param bool save_space: choose highest bit depth, lowest sampling rate + :param bool remove_extras: remove albums with extra material (i.e. live, deluxe,...) + :returns: filtered items list + """ + + # for debugging + def print_album(album: dict) -> None: + logger.debug( + f"{album['title']} - {album.get('version', '~~')} ({album['maximum_bit_depth']}/{album['maximum_sampling_rate']} by {album['artist']['name']}) {album['id']}" + ) + + TYPE_REGEXES = { + "remaster": r"(?i)(re)?master(ed)?", + "extra": r"(?i)(anniversary|deluxe|live|collector|demo|expanded)", + } + + def is_type(album_t: str, album: dict) -> bool: + """Check if album is of type `album_t`""" + version = album.get("version", "") + title = album.get("title", "") + regex = TYPE_REGEXES[album_t] + return re.search(regex, f"{title} {version}") is not None + + def essence(album: dict) -> str: + """Ignore text in parens/brackets, return all lowercase. + Used to group two albums that may be named similarly, but not exactly + the same. + """ + r = re.match(r"([^\(]+)(?:\s*[\(\[][^\)][\)\]])*", album) + return r.group(1).strip().lower() + + requested_artist = contents[0]["name"] + items = [item["albums"]["items"] for item in contents][0] + + # use dicts to group duplicate albums together by title + title_grouped = dict() + for item in items: + if (t := essence(item["title"])) not in title_grouped: + title_grouped[t] = [] + title_grouped[t].append(item) + + items = [] + for albums in title_grouped.values(): + best_bit_depth = max(a["maximum_bit_depth"] for a in albums) + get_best = min if save_space else max + best_sampling_rate = get_best( + a["maximum_sampling_rate"] + for a in albums + if a["maximum_bit_depth"] == best_bit_depth + ) + remaster_exists = any(is_type("remaster", a) for a in albums) + + def is_valid(album: dict) -> bool: + return ( + album["maximum_bit_depth"] == best_bit_depth + and album["maximum_sampling_rate"] == best_sampling_rate + and album["artist"]["name"] == requested_artist + and not ( # states that are not allowed + (remaster_exists and not is_type("remaster", album)) + or (skip_extras and is_type("extra", album)) + ) + ) + + filtered = tuple(filter(is_valid, albums)) + # most of the time, len is 0 or 1. + # if greater, it is a complete duplicate, + # so it doesn't matter which is chosen + if len(filtered) >= 1: + items.append(filtered[0]) + + return items diff --git a/qobuz_dl/db.py b/qobuz_dl/db.py new file mode 100644 index 0000000..ccc117b --- /dev/null +++ b/qobuz_dl/db.py @@ -0,0 +1,39 @@ +import logging +import sqlite3 + +from qobuz_dl.color import RED, YELLOW + +logger = logging.getLogger(__name__) + + +def create_db(db_path): + with sqlite3.connect(db_path) as conn: + try: + conn.execute("CREATE TABLE downloads (id TEXT UNIQUE NOT NULL);") + logger.info(f"{YELLOW}Download-IDs database created") + except sqlite3.OperationalError: + pass + return db_path + + +def handle_download_id(db_path, item_id, add_id=False): + if not db_path: + return + + with sqlite3.connect(db_path) as conn: + # If add_if is False return a string to know if the ID is in the DB + # Otherwise just add the ID to the DB + if add_id: + try: + conn.execute( + "INSERT INTO downloads (id) VALUES (?)", + (item_id,), + ) + conn.commit() + except sqlite3.Error as e: + logger.error(f"{RED}Unexpected DB error: {e}") + else: + return conn.execute( + "SELECT id FROM downloads where id=?", + (item_id,), + ).fetchone() diff --git a/qobuz_dl/downloader.py b/qobuz_dl/downloader.py new file mode 100644 index 0000000..7859def --- /dev/null +++ b/qobuz_dl/downloader.py @@ -0,0 +1,400 @@ +import logging +import os +from typing import Tuple + +import requests +from pathvalidate import sanitize_filename +from tqdm import tqdm + +import qobuz_dl.metadata as metadata +from qobuz_dl.color import CYAN, GREEN, OFF, RED, YELLOW +from qobuz_dl.exceptions import NonStreamable + +QL_DOWNGRADE = "FormatRestrictedByFormatAvailability" +# used in case of error +DEFAULT_FORMATS = { + "MP3": [ + "{artist} - {album} ({year}) [MP3]", + "{tracknumber}. {tracktitle}", + ], + "Unknown": [ + "{artist} - {album}", + "{tracknumber}. {tracktitle}", + ], +} + +logger = logging.getLogger(__name__) + + +def tqdm_download(url, fname, track_name): + r = requests.get(url, allow_redirects=True, stream=True) + total = int(r.headers.get("content-length", 0)) + with open(fname, "wb") as file, tqdm( + total=total, + unit="iB", + unit_scale=True, + unit_divisor=1024, + desc=track_name, + bar_format=CYAN + "{n_fmt}/{total_fmt} /// {desc}", + ) as bar: + for data in r.iter_content(chunk_size=1024): + size = file.write(data) + bar.update(size) + + +def get_description(u: dict, track_title, multiple=None): + downloading_title = f"{track_title} " + f'[{u["bit_depth"]}/{u["sampling_rate"]}]' + if multiple: + downloading_title = f"[Disc {multiple}] {downloading_title}" + return downloading_title + + +def get_format( + client, item_dict, quality, is_track_id=False, track_url_dict=None +) -> Tuple[str, bool, int, int]: + quality_met = True + if int(quality) == 5: + return ("MP3", quality_met, None, None) + track_dict = item_dict + if not is_track_id: + track_dict = item_dict["tracks"]["items"][0] + + try: + new_track_dict = ( + client.get_track_url(track_dict["id"], quality) + if not track_url_dict + else track_url_dict + ) + restrictions = new_track_dict.get("restrictions") + if isinstance(restrictions, list): + if any( + restriction.get("code") == QL_DOWNGRADE for restriction in restrictions + ): + quality_met = False + + return ( + "FLAC", + quality_met, + new_track_dict["bit_depth"], + new_track_dict["sampling_rate"], + ) + except (KeyError, requests.exceptions.HTTPError): + return ("Unknown", quality_met, None, None) + + +def get_title(item_dict): + album_title = item_dict["title"] + version = item_dict.get("version") + if version: + album_title = ( + f"{album_title} ({version})" + if version.lower() not in album_title.lower() + else album_title + ) + return album_title + + +def get_extra(i, dirn, extra="cover.jpg", og_quality=False): + extra_file = os.path.join(dirn, extra) + if os.path.isfile(extra_file): + logger.info(f"{OFF}{extra} was already downloaded") + return + tqdm_download( + i.replace("_600.", "_org.") if og_quality else i, + extra_file, + extra, + ) + + +# Download and tag a file +def download_and_tag( + root_dir, + tmp_count, + track_url_dict, + track_metadata, + album_or_track_metadata, + is_track, + is_mp3, + embed_art=False, + multiple=None, + track_format="{tracknumber}. {tracktitle}", +): + """ + Download and tag a file + + :param str root_dir: Root directory where the track will be stored + :param int tmp_count: Temporal download file number + :param dict track_url_dict: get_track_url dictionary from Qobuz client + :param dict track_metadata: Track item dictionary from Qobuz client + :param dict album_or_track_metadata: Album/track dict from Qobuz client + :param bool is_track + :param bool is_mp3 + :param bool embed_art: Embed cover art into file (FLAC-only) + :param str track_format format-string that determines file naming + :param multiple: Multiple disc integer + :type multiple: integer or None + """ + + extension = ".mp3" if is_mp3 else ".flac" + + try: + url = track_url_dict["url"] + except KeyError: + logger.info(f"{OFF}Track not available for download") + return + + if multiple: + root_dir = os.path.join(root_dir, f"Disc {multiple}") + os.makedirs(root_dir, exist_ok=True) + + filename = os.path.join(root_dir, f".{tmp_count:02}.tmp") + + # Determine the filename + track_title = track_metadata.get("title") + artist = _safe_get(track_metadata, "performer", "name") + filename_attr = { + "artist": artist, + "albumartist": _safe_get( + track_metadata, "album", "artist", "name", default=artist + ), + "bit_depth": track_metadata["maximum_bit_depth"], + "sampling_rate": track_metadata["maximum_sampling_rate"], + "tracktitle": track_title, + "version": track_metadata.get("version"), + "tracknumber": f"{track_metadata['track_number']:02}", + } + # track_format is a format string + # e.g. '{tracknumber}. {artist} - {tracktitle}' + formatted_path = sanitize_filename(track_format.format(**filename_attr)) + final_file = os.path.join(root_dir, formatted_path)[:250] + extension + + if os.path.isfile(final_file): + logger.info(f"{OFF}{track_title} was already downloaded") + return + + desc = get_description(track_url_dict, track_title, multiple) + tqdm_download(url, filename, desc) + tag_function = metadata.tag_mp3 if is_mp3 else metadata.tag_flac + try: + tag_function( + filename, + root_dir, + final_file, + track_metadata, + album_or_track_metadata, + is_track, + embed_art, + ) + except Exception as e: + logger.error(f"{RED}Error tagging the file: {e}", exc_info=True) + + +def download_id_by_type( + client, + item_id, + path, + quality, + album=False, + embed_art=False, + albums_only=False, + downgrade_quality=True, + cover_og_quality=False, + no_cover=False, + folder_format="{artist} - {album} ({year}) " "[{bit_depth}B-{sampling_rate}kHz]", + track_format="{tracknumber}. {tracktitle}", +): + """ + Download and get metadata by ID and type (album or track) + + :param Qopy client: qopy Client + :param int item_id: Qobuz item id + :param str path: The root directory where the item will be downloaded + :param int quality: Audio quality (5, 6, 7, 27) + :param bool album: album type or not + :param embed_art album: Embed cover art into files + :param bool albums_only: Ignore Singles, EPs and VA releases + :param bool downgrade: Skip releases not available in set quality + :param bool cover_og_quality: Download cover in its original quality + :param bool no_cover: Don't download cover art + :param str folder_format: format string that determines folder naming + :param str track_format: format string that determines track naming + """ + count = 0 + + if album: + meta = client.get_album_meta(item_id) + + if not meta.get("streamable"): + raise NonStreamable("This release is not streamable") + + if albums_only and ( + meta.get("release_type") != "album" + or meta.get("artist").get("name") == "Various Artists" + ): + logger.info(f'{OFF}Ignoring Single/EP/VA: {meta.get("title", "")}') + return + + album_title = get_title(meta) + + format_info = get_format(client, meta, quality) + file_format, quality_met, bit_depth, sampling_rate = format_info + + if not downgrade_quality and not quality_met: + logger.info( + f"{OFF}Skipping {album_title} as it doesn't " "meet quality requirement" + ) + return + + logger.info( + f"\n{YELLOW}Downloading: {album_title}\nQuality: {file_format} ({bit_depth}/{sampling_rate})\n" + ) + album_attr = { + "artist": meta["artist"]["name"], + "album": album_title, + "year": meta["release_date_original"].split("-")[0], + "format": file_format, + "bit_depth": bit_depth, + "sampling_rate": sampling_rate, + } + folder_format, track_format = _clean_format_str( + folder_format, track_format, file_format + ) + sanitized_title = sanitize_filename(folder_format.format(**album_attr)) + dirn = os.path.join(path, sanitized_title) + os.makedirs(dirn, exist_ok=True) + + if no_cover: + logger.info(f"{OFF}Skipping cover") + else: + get_extra(meta["image"]["large"], dirn, og_quality=cover_og_quality) + + if "goodies" in meta: + try: + get_extra(meta["goodies"][0]["url"], dirn, "booklet.pdf") + except: # noqa + pass + media_numbers = [track["media_number"] for track in meta["tracks"]["items"]] + is_multiple = True if len([*{*media_numbers}]) > 1 else False + for i in meta["tracks"]["items"]: + parse = client.get_track_url(i["id"], quality) + if "sample" not in parse and parse["sampling_rate"]: + is_mp3 = True if int(quality) == 5 else False + download_and_tag( + dirn, + count, + parse, + i, + meta, + False, + is_mp3, + embed_art, + i["media_number"] if is_multiple else None, + track_format=track_format, + ) + else: + logger.info(f"{OFF}Demo. Skipping") + count = count + 1 + else: + parse = client.get_track_url(item_id, quality) + + if "sample" not in parse and parse["sampling_rate"]: + meta = client.get_track_meta(item_id) + track_title = get_title(meta) + logger.info(f"\n{YELLOW}Downloading: {track_title}") + format_info = get_format( + client, meta, quality, is_track_id=True, track_url_dict=parse + ) + file_format, quality_met, bit_depth, sampling_rate = format_info + + folder_format, track_format = _clean_format_str( + folder_format, track_format, bit_depth + ) + + if not downgrade_quality and not quality_met: + logger.info( + f"{OFF}Skipping {track_title} as it doesn't " + "meet quality requirement" + ) + return + track_attr = { + "artist": meta["album"]["artist"]["name"], + "tracktitle": track_title, + "year": meta["album"]["release_date_original"].split("-")[0], + "bit_depth": bit_depth, + "sampling_rate": sampling_rate, + } + sanitized_title = sanitize_filename(folder_format.format(**track_attr)) + + dirn = os.path.join(path, sanitized_title) + os.makedirs(dirn, exist_ok=True) + if no_cover: + logger.info(f"{OFF}Skipping cover") + else: + get_extra( + meta["album"]["image"]["large"], dirn, og_quality=cover_og_quality + ) + is_mp3 = True if int(quality) == 5 else False + download_and_tag( + dirn, + count, + parse, + meta, + meta, + True, + is_mp3, + embed_art, + track_format=track_format, + ) + else: + logger.info(f"{OFF}Demo. Skipping") + logger.info(f"{GREEN}Completed") + + +# ----------- Utilities ----------- + + +def _clean_format_str(folder: str, track: str, file_format: str) -> Tuple[str, str]: + """Cleans up the format strings, avoids errors + with MP3 files. + """ + final = [] + for i, fs in enumerate((folder, track)): + if fs.endswith(".mp3"): + fs = fs[:-4] + elif fs.endswith(".flac"): + fs = fs[:-5] + fs = fs.strip() + + # default to pre-chosen string if format is invalid + if file_format in ("MP3", "Unknown") and ( + "bit_depth" in fs or "sampling_rate" in fs + ): + default = DEFAULT_FORMATS[file_format][i] + logger.error( + f"{RED}invalid format string for format {file_format}" + f". defaulting to {default}" + ) + fs = default + final.append(fs) + + return tuple(final) + + +def _safe_get(d: dict, *keys, default=None): + """A replacement for chained `get()` statements on dicts: + >>> d = {'foo': {'bar': 'baz'}} + >>> _safe_get(d, 'baz') + None + >>> _safe_get(d, 'foo', 'bar') + 'baz' + """ + curr = d + res = default + for key in keys: + res = curr.get(key, default) + if res == default or not hasattr(res, "__getitem__"): + return res + else: + curr = res + return res diff --git a/qobuz_dl/exceptions.py b/qobuz_dl/exceptions.py new file mode 100644 index 0000000..9461cda --- /dev/null +++ b/qobuz_dl/exceptions.py @@ -0,0 +1,22 @@ +class AuthenticationError(Exception): + pass + + +class IneligibleError(Exception): + pass + + +class InvalidAppIdError(Exception): + pass + + +class InvalidAppSecretError(Exception): + pass + + +class InvalidQuality(Exception): + pass + + +class NonStreamable(Exception): + pass diff --git a/qobuz_dl/metadata.py b/qobuz_dl/metadata.py new file mode 100644 index 0000000..c1b87ef --- /dev/null +++ b/qobuz_dl/metadata.py @@ -0,0 +1,224 @@ +import logging +import os +import re + +import mutagen.id3 as id3 +from mutagen.flac import FLAC, Picture +from mutagen.id3 import ID3NoHeaderError + +logger = logging.getLogger(__name__) + + +# unicode symbols +COPYRIGHT, PHON_COPYRIGHT = "\u2117", "\u00a9" +# if a metadata block exceeds this, mutagen will raise error +# and the file won't be tagged +FLAC_MAX_BLOCKSIZE = 16777215 + + +def get_title(track_dict): + title = track_dict["title"] + version = track_dict.get("version") + if version: + title = f"{title} ({version})" + # for classical works + if track_dict.get("work"): + title = "{}: {}".format(track_dict["work"], title) + + return title + + +def _format_copyright(s: str) -> str: + s = s.replace("(P)", PHON_COPYRIGHT) + s = s.replace("(C)", COPYRIGHT) + return s + + +def _format_genres(genres: list) -> str: + """Fixes the weirdly formatted genre lists returned by the API. + >>> g = ['Pop/Rock', 'Pop/Rock→Rock', 'Pop/Rock→Rock→Alternatif et Indé'] + >>> _format_genres(g) + 'Pop, Rock, Alternatif et Indé' + """ + genres = re.findall(r"([^\u2192\/]+)", "/".join(genres)) + no_repeats = [] + [no_repeats.append(g) for g in genres if g not in no_repeats] + return ", ".join(no_repeats) + + +# Use KeyError catching instead of dict.get to avoid empty tags +def tag_flac(filename, root_dir, final_name, d, album, istrack=True, em_image=False): + """ + Tag a FLAC file + + :param str filename: FLAC file path + :param str root_dir: Root dir used to get the cover art + :param str final_name: Final name of the FLAC file (complete path) + :param dict d: Track dictionary from Qobuz_client + :param dict album: Album dictionary from Qobuz_client + :param bool istrack + :param bool em_image: Embed cover art into file + """ + audio = FLAC(filename) + + audio["TITLE"] = get_title(d) + + audio["TRACKNUMBER"] = str(d["track_number"]) # TRACK NUMBER + + if "Disc " in final_name: + audio["DISCNUMBER"] = str(d["media_number"]) + + try: + audio["COMPOSER"] = d["composer"]["name"] # COMPOSER + except KeyError: + pass + + try: + audio["ARTIST"] = d["performer"]["name"] # TRACK ARTIST + except KeyError: + if istrack: + audio["ARTIST"] = d["album"]["artist"]["name"] # TRACK ARTIST + else: + audio["ARTIST"] = album["artist"]["name"] + + try: + audio["LABEL"] = album["label"]["name"] + except KeyError: + pass + + if istrack: + audio["GENRE"] = _format_genres(d["album"]["genres_list"]) + audio["ALBUMARTIST"] = d["album"]["artist"]["name"] + audio["TRACKTOTAL"] = str(d["album"]["tracks_count"]) + audio["ALBUM"] = d["album"]["title"] + audio["DATE"] = d["album"]["release_date_original"] + audio["COPYRIGHT"] = _format_copyright(d["copyright"]) + else: + audio["GENRE"] = _format_genres(album["genres_list"]) + audio["ALBUMARTIST"] = album["artist"]["name"] + audio["TRACKTOTAL"] = str(album["tracks_count"]) + audio["ALBUM"] = album["title"] + audio["DATE"] = album["release_date_original"] + audio["COPYRIGHT"] = _format_copyright(album["copyright"]) + + if em_image: + emb_image = os.path.join(root_dir, "cover.jpg") + multi_emb_image = os.path.join( + os.path.abspath(os.path.join(root_dir, os.pardir)), "cover.jpg" + ) + if os.path.isfile(emb_image): + cover_image = emb_image + else: + cover_image = multi_emb_image + + try: + # rest of the metadata still gets embedded + # when the image size is too big + if os.path.getsize(cover_image) > FLAC_MAX_BLOCKSIZE: + raise Exception( + "downloaded cover size too large to embed. " + "turn off `og_cover` to avoid error" + ) + + image = Picture() + image.type = 3 + image.mime = "image/jpeg" + image.desc = "cover" + with open(cover_image, "rb") as img: + image.data = img.read() + audio.add_picture(image) + except Exception as e: + logger.error(f"Error embedding image: {e}", exc_info=True) + + audio.save() + os.rename(filename, final_name) + + +def tag_mp3(filename, root_dir, final_name, d, album, istrack=True, em_image=False): + """ + Tag an mp3 file + + :param str filename: mp3 temporary file path + :param str root_dir: Root dir used to get the cover art + :param str final_name: Final name of the mp3 file (complete path) + :param dict d: Track dictionary from Qobuz_client + :param bool istrack + :param bool em_image: Embed cover art into file + """ + + id3_legend = { + "album": id3.TALB, + "albumartist": id3.TPE2, + "artist": id3.TPE1, + "comment": id3.COMM, + "composer": id3.TCOM, + "copyright": id3.TCOP, + "date": id3.TDAT, + "genre": id3.TCON, + "isrc": id3.TSRC, + "label": id3.TPUB, + "performer": id3.TOPE, + "title": id3.TIT2, + "year": id3.TYER, + } + try: + audio = id3.ID3(filename) + except ID3NoHeaderError: + audio = id3.ID3() + + # temporarily holds metadata + tags = dict() + tags["title"] = get_title(d) + try: + tags["label"] = album["label"]["name"] + except KeyError: + pass + + try: + tags["artist"] = d["performer"]["name"] + except KeyError: + if istrack: + tags["artist"] = d["album"]["artist"]["name"] + else: + tags["artist"] = album["artist"]["name"] + + if istrack: + tags["genre"] = _format_genres(d["album"]["genres_list"]) + tags["albumartist"] = d["album"]["artist"]["name"] + tags["album"] = d["album"]["title"] + tags["date"] = d["album"]["release_date_original"] + tags["copyright"] = _format_copyright(d["copyright"]) + tracktotal = str(d["album"]["tracks_count"]) + else: + tags["genre"] = _format_genres(album["genres_list"]) + tags["albumartist"] = album["artist"]["name"] + tags["album"] = album["title"] + tags["date"] = album["release_date_original"] + tags["copyright"] = _format_copyright(album["copyright"]) + tracktotal = str(album["tracks_count"]) + + tags["year"] = tags["date"][:4] + + audio["TRCK"] = id3.TRCK(encoding=3, text=f'{d["track_number"]}/{tracktotal}') + audio["TPOS"] = id3.TPOS(encoding=3, text=str(d["media_number"])) + + # write metadata in `tags` to file + for k, v in tags.items(): + id3tag = id3_legend[k] + audio[id3tag.__name__] = id3tag(encoding=3, text=v) + + if em_image: + emb_image = os.path.join(root_dir, "cover.jpg") + multi_emb_image = os.path.join( + os.path.abspath(os.path.join(root_dir, os.pardir)), "cover.jpg" + ) + if os.path.isfile(emb_image): + cover_image = emb_image + else: + cover_image = multi_emb_image + + with open(cover_image, "rb") as cover: + audio.add(id3.APIC(3, "image/jpeg", 3, "", cover.read())) + + audio.save(filename, "v2_version=3") + os.rename(filename, final_name) diff --git a/qobuz_dl/qopy.py b/qobuz_dl/qopy.py new file mode 100644 index 0000000..ff2cb87 --- /dev/null +++ b/qobuz_dl/qopy.py @@ -0,0 +1,204 @@ +# Wrapper for Qo-DL Reborn. This is a sligthly modified version +# of qopy, originally written by Sorrow446. All credits to the +# original author. + +import hashlib +import logging +import time + +import requests + +from qobuz_dl.color import GREEN, YELLOW +from qobuz_dl.exceptions import ( + AuthenticationError, + IneligibleError, + InvalidAppIdError, + InvalidAppSecretError, + InvalidQuality, +) + +RESET = "Reset your credentials with 'qobuz-dl -r'" + +logger = logging.getLogger(__name__) + + +class Client: + def __init__(self, email, pwd, app_id, secrets): + logger.info(f"{YELLOW}Logging...") + self.secrets = secrets + self.id = app_id + self.session = requests.Session() + self.session.headers.update( + { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0", + "X-App-Id": self.id, + } + ) + self.base = "https://www.qobuz.com/api.json/0.2/" + self.auth(email, pwd) + self.cfg_setup() + + def api_call(self, epoint, **kwargs): + if epoint == "user/login": + params = { + "email": kwargs["email"], + "password": kwargs["pwd"], + "app_id": self.id, + } + elif epoint == "track/get": + params = {"track_id": kwargs["id"]} + elif epoint == "album/get": + params = {"album_id": kwargs["id"]} + elif epoint == "playlist/get": + params = { + "extra": "tracks", + "playlist_id": kwargs["id"], + "limit": 500, + "offset": kwargs["offset"], + } + elif epoint == "artist/get": + params = { + "app_id": self.id, + "artist_id": kwargs["id"], + "limit": 500, + "offset": kwargs["offset"], + "extra": "albums", + } + elif epoint == "label/get": + params = { + "label_id": kwargs["id"], + "limit": 500, + "offset": kwargs["offset"], + "extra": "albums", + } + elif epoint == "userLibrary/getAlbumsList": + unix = time.time() + r_sig = "userLibrarygetAlbumsList" + str(unix) + kwargs["sec"] + r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest() + params = { + "app_id": self.id, + "user_auth_token": self.uat, + "request_ts": unix, + "request_sig": r_sig_hashed, + } + elif epoint == "track/getFileUrl": + unix = time.time() + track_id = kwargs["id"] + fmt_id = kwargs["fmt_id"] + if int(fmt_id) not in (5, 6, 7, 27): + raise InvalidQuality("Invalid quality id: choose between 5, 6, 7 or 27") + r_sig = "trackgetFileUrlformat_id{}intentstreamtrack_id{}{}{}".format( + fmt_id, track_id, unix, self.sec + ) + r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest() + params = { + "request_ts": unix, + "request_sig": r_sig_hashed, + "track_id": track_id, + "format_id": fmt_id, + "intent": "stream", + } + else: + params = kwargs + r = self.session.get(self.base + epoint, params=params) + if epoint == "user/login": + if r.status_code == 401: + raise AuthenticationError("Invalid credentials.\n" + RESET) + elif r.status_code == 400: + raise InvalidAppIdError("Invalid app id.\n" + RESET) + else: + logger.info(f"{GREEN}Logged: OK") + elif epoint in ["track/getFileUrl", "userLibrary/getAlbumsList"]: + if r.status_code == 400: + raise InvalidAppSecretError("Invalid app secret.\n" + RESET) + r.raise_for_status() + return r.json() + + def auth(self, email, pwd): + usr_info = self.api_call("user/login", email=email, pwd=pwd) + if not usr_info["user"]["credential"]["parameters"]: + raise IneligibleError("Free accounts are not eligible to download tracks.") + self.uat = usr_info["user_auth_token"] + self.session.headers.update({"X-User-Auth-Token": self.uat}) + self.label = usr_info["user"]["credential"]["parameters"]["short_label"] + logger.info(f"{GREEN}Membership: {self.label}") + + def multi_meta(self, epoint, key, id, type): + total = 1 + offset = 0 + while total > 0: + if type in ["tracks", "albums"]: + j = self.api_call(epoint, id=id, offset=offset, type=type)[type] + else: + j = self.api_call(epoint, id=id, offset=offset, type=type) + if offset == 0: + yield j + total = j[key] - 500 + else: + yield j + total -= 500 + offset += 500 + + def get_album_meta(self, id): + return self.api_call("album/get", id=id) + + def get_track_meta(self, id): + return self.api_call("track/get", id=id) + + def get_track_url(self, id, fmt_id): + return self.api_call("track/getFileUrl", id=id, fmt_id=fmt_id) + + def get_artist_meta(self, id): + return self.multi_meta("artist/get", "albums_count", id, None) + + def get_plist_meta(self, id): + return self.multi_meta("playlist/get", "tracks_count", id, None) + + def get_label_meta(self, id): + return self.multi_meta("label/get", "albums_count", id, None) + + def search_albums(self, query, limit): + return self.api_call("album/search", query=query, limit=limit) + + def search_artists(self, query, limit): + return self.api_call("artist/search", query=query, limit=limit) + + def search_playlists(self, query, limit): + return self.api_call("playlist/search", query=query, limit=limit) + + def search_tracks(self, query, limit): + return self.api_call("track/search", query=query, limit=limit) + + def get_favorite_albums(self, offset, limit): + return self.api_call( + "favorite/getUserFavorites", type="albums", offset=offset, limit=limit + ) + + def get_favorite_tracks(self, offset, limit): + return self.api_call( + "favorite/getUserFavorites", type="tracks", offset=offset, limit=limit + ) + + def get_favorite_artists(self, offset, limit): + return self.api_call( + "favorite/getUserFavorites", type="artists", offset=offset, limit=limit + ) + + def get_user_playlists(self, limit): + return self.api_call("playlist/getUserPlaylists", limit=limit) + + def test_secret(self, sec): + try: + r = self.api_call("userLibrary/getAlbumsList", sec=sec) + return True + except InvalidAppSecretError: + return False + + def cfg_setup(self): + logging.debug(self.secrets) + for secret in self.secrets: + if self.test_secret(secret): + self.sec = secret + break + if not hasattr(self, "sec"): + raise InvalidAppSecretError("Invalid app secret.\n" + RESET) diff --git a/qobuz_dl/spoofbuz.py b/qobuz_dl/spoofbuz.py new file mode 100644 index 0000000..624d9cf --- /dev/null +++ b/qobuz_dl/spoofbuz.py @@ -0,0 +1,51 @@ +import base64 +import re +from collections import OrderedDict + +import requests + + +class Spoofer: + def __init__(self): + self.seed_timezone_regex = r'[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)' + # note: {timezones} should be replaced with every capitalized timezone joined by a | + self.info_extras_regex = r'name:"\w+/(?P{timezones})",info:"(?P[\w=]+)",extras:"(?P[\w=]+)"' + self.appId_regex = r'{app_id:"(?P\d{9})",app_secret:"\w{32}",base_port:"80",base_url:"https://www\.qobuz\.com",base_method:"/api\.json/0\.2/"},n\.base_url="https://play\.qobuz\.com"' + login_page_request = requests.get("https://play.qobuz.com/login") + login_page = login_page_request.text + bundle_url_match = re.search( + r'', + login_page, + ) + bundle_url = bundle_url_match.group(1) + bundle_req = requests.get("https://play.qobuz.com" + bundle_url) + self.bundle = bundle_req.text + + def getAppId(self): + return re.search(self.appId_regex, self.bundle).group("app_id") + + def getSecrets(self): + seed_matches = re.finditer(self.seed_timezone_regex, self.bundle) + secrets = OrderedDict() + for match in seed_matches: + seed, timezone = match.group("seed", "timezone") + secrets[timezone] = [seed] + """The code that follows switches around the first and second timezone. Why? Read on: + Qobuz uses two ternary (a shortened if statement) conditions that should always return false. + The way Javascript's ternary syntax works, the second option listed is what runs if the condition returns false. + Because of this, we must prioritize the *second* seed/timezone pair captured, not the first. + """ + keypairs = list(secrets.items()) + secrets.move_to_end(keypairs[1][0], last=False) + info_extras_regex = self.info_extras_regex.format( + timezones="|".join([timezone.capitalize() for timezone in secrets]) + ) + info_extras_matches = re.finditer(info_extras_regex, self.bundle) + for match in info_extras_matches: + timezone, info, extras = match.group("timezone", "info", "extras") + secrets[timezone.lower()] += [info, extras] + for secret_pair in secrets: + secrets[secret_pair] = base64.standard_b64decode( + "".join(secrets[secret_pair])[:-44] + ).decode("utf-8") + return secrets diff --git a/qobuz_dl_rewrite/__init__.py b/qobuz_dl_rewrite/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qobuz_dl_rewrite/cli.py b/qobuz_dl_rewrite/cli.py new file mode 100644 index 0000000..7a69500 --- /dev/null +++ b/qobuz_dl_rewrite/cli.py @@ -0,0 +1,107 @@ +# For tests + +import logging +import os + +import click + +from qobuz_dl_rewrite.config import Config +from qobuz_dl_rewrite.constants import CACHE_DIR, CONFIG_DIR +from qobuz_dl_rewrite.core import QobuzDL +from qobuz_dl_rewrite.utils import init_log + +logger = logging.getLogger(__name__) + + +def _get_config(ctx): + if not os.path.isdir(CONFIG_DIR): + os.makedirs(CONFIG_DIR) + if not os.path.isdir(CACHE_DIR): + os.makedirs(CONFIG_DIR) + + config = Config(ctx.obj.get("config")) + config.update_from_cli(**ctx.obj) + return config + + +# fmt: off +@click.group() +@click.option("--disable", metavar="PROVIDER,...", help="Disable following providers (comma separated)") +@click.option("-q", "--quality", metavar="INT", help="Quality integer ID (5, 6, 7, 27)") +@click.option("--embed-cover", is_flag=True, help="Embed cover art into files") +@click.option("--no-extras", is_flag=True, help="Ignore extras") +@click.option("--no-features", is_flag=True, help="Ignore features") +@click.option("--studio-albums", is_flag=True, help="Ignore non-studio albums") +@click.option("--remaster-only", is_flag=True, help="Ignore non-remastered albums") +@click.option("--albums-only", is_flag=True, help="Ignore non-album downloads") +@click.option("--large-cover", is_flag=True, help="Download large covers (it might fail with embed)") +@click.option("--remove-extra-tags", default=False, is_flag=True, help="Remove extra metadata from tags and files") +@click.option("--debug", default=False, is_flag=True, help="Enable debug logging") +@click.option("-f", "--folder", metavar="PATH", help="Custom download folder") +@click.option("--default-comment", metavar="COMMENT", help="Custom comment tag for audio files") +@click.option("-c", "--config", metavar="PATH", help="Custom config file") +@click.option("--db-file", metavar="PATH", help="Custom database file") +@click.option("--log-file", metavar="PATH", help="Custom logfile") +@click.option("--flush-cache", metavar="PATH", help="Flush the cache before running (only for extreme cases)") +# TODO: add options for conversion +@click.pass_context +# fmt: on +def cli(ctx, **kwargs): + ctx.ensure_object(dict) + + for key in kwargs.keys(): + ctx.obj[key] = kwargs.get(key) + + if ctx.obj["debug"]: + init_log(path=ctx.obj.get("log_file")) + else: + click.secho("Debug is not enabled", fg="yellow") + + +@click.command(name="dl") +@click.argument("items", nargs=-1) +@click.pass_context +def download(ctx, items): + """ + Download an URL, space separated URLs or a text file with URLs. + Mixed arguments are also supported. + + Examples: + + * `qobuz-dl dl https://some.url/some_type/some_id` + + * `qobuz-dl dl file_with_urls.txt` + + * `qobuz-dl dl URL URL URL` + + Supported sources and their types: + + * Deezer (album, artist, track, playlist) + + * Qobuz (album, artist, label, track, playlist) + + * Tidal (album, artist, track, playlist) + """ + config = _get_config(ctx) + core = QobuzDL(config) + for item in items: + try: + if os.path.isfile(item): + core.from_txt(item) + click.secho(f"File input found: {item}", fg="yellow") + else: + core.handle_url(item) + except Exception as error: + logger.error(error, exc_info=True) + click.secho( + f"{type(error).__name__} raised processing {item}: {error}", fg="red" + ) + + +def main(): + cli.add_command(download) + cli(obj={}) + + +if __name__ == "__main__": + main() diff --git a/qobuz_dl_rewrite/clients.py b/qobuz_dl_rewrite/clients.py new file mode 100644 index 0000000..91790b0 --- /dev/null +++ b/qobuz_dl_rewrite/clients.py @@ -0,0 +1,503 @@ +import datetime +import hashlib +import logging +import os +import time +from abc import ABC, abstractmethod +from typing import Generator, Sequence, Tuple, Union + +import requests +import tidalapi +from dogpile.cache import make_region + +from .constants import ( + AGENT, + CACHE_DIR, + DEEZER_MAX_Q, + DEEZER_Q_IDS, + QOBUZ_FEATURED_KEYS, + TIDAL_MAX_Q, + TIDAL_Q_IDS, +) +from .exceptions import ( + AuthenticationError, + IneligibleError, + InvalidAppIdError, + InvalidAppSecretError, + InvalidQuality, +) +from .spoofbuz import Spoofer + +os.makedirs(CACHE_DIR, exist_ok=True) +region = make_region().configure( + "dogpile.cache.dbm", + arguments={"filename": os.path.join(CACHE_DIR, "clients.db")}, +) + +logger = logging.getLogger(__name__) + +TRACK_CACHE_TIME = datetime.timedelta(weeks=2).total_seconds() +RELEASE_CACHE_TIME = datetime.timedelta(days=1).total_seconds() + +# Qobuz +QOBUZ_BASE = "https://www.qobuz.com/api.json/0.2" + + +# Deezer +DEEZER_BASE = "https://api.deezer.com" +DEEZER_DL = "http://dz.loaderapp.info/deezer" + + +# ----------- Abstract Classes ----------------- + + +class ClientInterface(ABC): + """Common API for clients of all platforms. + + This is an Abstract Base Class. It cannot be instantiated; + it is merely a template. + """ + + @abstractmethod + def login(self, **kwargs): + """Authenticate the client. + + :param kwargs: + """ + pass + + @abstractmethod + def search(self, query: str, media_type="album"): + """Search API for query. + + :param query: + :type query: str + :param type_: + """ + pass + + @abstractmethod + def get(self, item_id, media_type="album"): + """Get metadata. + + :param meta_id: + :param type_: + """ + pass + + @abstractmethod + def get_file_url(self, track_id, quality=6) -> Union[dict]: + """Get the direct download url dict for a file. + + :param track_id: id of the track + """ + pass + + @property + @abstractmethod + def source(self): + pass + + +# ------------- Clients ----------------- + + +class QobuzClient(ClientInterface): + # ------- Public Methods ------------- + def __init__(self): + self.logged_in = False + + def login(self, email: str, pwd: str, **kwargs): + """Authenticate the QobuzClient. Must have a paid membership. + + If `app_id` and `secrets` are not provided, this will run the + Spoofer script, which retrieves them. This will take some time, + so it is recommended to cache them somewhere for reuse. + + :param email: email for the qobuz account + :type email: str + :param pwd: password for the qobuz account + :type pwd: str + :param kwargs: app_id: str, secrets: list, return_secrets: bool + """ + if self.logged_in: + logger.debug("Already logged in") + return + + if (kwargs.get("app_id") or kwargs.get("secrets")) in (None, [], ""): + logger.info("Fetching tokens from Qobuz") + spoofer = Spoofer() + kwargs["app_id"] = spoofer.get_app_id() + kwargs["secrets"] = spoofer.get_secrets() + + self.app_id = str(kwargs["app_id"]) # Ensure it is a string + self.secrets = kwargs["secrets"] + + self.session = requests.Session() + self.session.headers.update( + { + "User-Agent": AGENT, + "X-App-Id": self.app_id, + } + ) + + self._api_login(email, pwd) + logger.debug("Logged into Qobuz") + self._validate_secrets() + logger.debug("Qobuz client is ready to use") + + self.logged_in = True + + def get_tokens(self) -> Tuple[str, Sequence[str]]: + return self.app_id, self.secrets + + def search( + self, query: str, media_type: str = "album", limit: int = 500 + ) -> Generator: + """Search the qobuz API. + + If 'featured' is given as media type, this will retrieve results + from the featured albums in qobuz. The queries available with this type + are: + + * most-streamed + * recent-releases + * best-sellers + * press-awards + * ideal-discography + * editor-picks + * most-featured + * qobuzissims + * new-releases + * new-releases-full + * harmonia-mundi + * universal-classic + * universal-jazz + * universal-jeunesse + * universal-chanson + + :param query: + :type query: str + :param media_type: + :type media_type: str + :param limit: + :type limit: int + :rtype: Generator + """ + return self._api_search(query, media_type, limit) + + @region.cache_on_arguments(expiration_time=RELEASE_CACHE_TIME) + def get(self, item_id: Union[str, int], media_type: str = "album") -> dict: + return self._api_get(media_type, item_id=item_id) + + def get_file_url(self, item_id, quality=6) -> dict: + return self._api_get_file_url(item_id, quality=quality) + + @property + def source(self): + return "qobuz" + + # ---------- Private Methods --------------- + + # Credit to Sorrow446 for the original methods + + def _gen_pages(self, epoint: str, params: dict) -> dict: + page, status_code = self._api_request(epoint, params) + logger.debug("Keys returned from _gen_pages: %s", ", ".join(page.keys())) + key = epoint.split("/")[0] + "s" + total = page.get(key, {}) + total = total.get("total") or total.get("items") + + if not total: + logger.debug("Nothing found from %s epoint", epoint) + return + + limit = page.get(key, {}).get("limit", 500) + offset = page.get(key, {}).get("offset", 0) + params.update({"limit": limit}) + yield page + while (offset + limit) < total: + offset += limit + params.update({"offset": offset}) + page, status_code = self._api_request(epoint, params) + yield page + + def _validate_secrets(self): + for secret in self.secrets: + if self._test_secret(secret): + self.sec = secret + logger.debug("Working secret and app_id: %s - %s", secret, self.app_id) + break + if not hasattr(self, "sec"): + raise InvalidAppSecretError(f"Invalid secrets: {self.secrets}") + + def _api_get(self, media_type: str, **kwargs) -> dict: + item_id = kwargs.get("item_id") + + params = { + "app_id": self.app_id, + f"{media_type}_id": item_id, + "limit": kwargs.get("limit", 500), + "offset": kwargs.get("offset", 0), + } + extras = { + "artist": "albums", + "playlist": "tracks", + "label": "albums", # not tested + } + + if media_type in extras: + params.update({"extra": extras[media_type]}) + + epoint = f"{media_type}/get" + + response, status_code = self._api_request(epoint, params) + return response + + def _api_search(self, query, media_type, limit=500) -> Generator: + params = { + "query": query, + "limit": limit, + } + # TODO: move featured, favorites, and playlists into _api_get later + if media_type == "featured": + assert query in QOBUZ_FEATURED_KEYS, f'query "{query}" is invalid.' + params.update({"type": query}) + del params["query"] + epoint = "album/getFeatured" + + elif query == "user-favorites": + assert query in ("track", "artist", "album") + params.update({"type": f"{media_type}s"}) + epoint = "favorite/getUserFavorites" + + elif query == "user-playlists": + epoint = "playlist/getUserPlaylists" + + else: + epoint = f"{media_type}/search" + + return self._gen_pages(epoint, params) + + def _api_login(self, email: str, pwd: str): + # usr_info = self._api_call("user/login", email=email, pwd=pwd) + params = { + "email": email, + "password": pwd, + "app_id": self.app_id, + } + epoint = "user/login" + resp, status_code = self._api_request(epoint, params) + + if status_code == 401: + raise AuthenticationError(f"Invalid credentials from params {params}") + elif status_code == 400: + raise InvalidAppIdError(f"Invalid app id from params {params}") + else: + logger.info("Logged in to Qobuz") + + if not resp["user"]["credential"]["parameters"]: + raise IneligibleError("Free accounts are not eligible to download tracks.") + + self.uat = resp["user_auth_token"] + self.session.headers.update({"X-User-Auth-Token": self.uat}) + self.label = resp["user"]["credential"]["parameters"]["short_label"] + + def _api_get_file_url( + self, track_id: Union[str, int], quality: int = 6, sec: str = None + ) -> dict: + unix_ts = time.time() + + if int(quality) not in (5, 6, 7, 27): # Needed? + raise InvalidQuality(f"Invalid quality id {quality}. Choose 5, 6, 7 or 27") + + if sec is not None: + secret = sec + elif hasattr(self, "sec"): + secret = self.sec + else: + raise InvalidAppSecretError("Cannot find app secret") + + r_sig = f"trackgetFileUrlformat_id{quality}intentstreamtrack_id{track_id}{unix_ts}{secret}" + logger.debug("Raw request signature: %s", r_sig) + r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest() + logger.debug("Hashed request signature: %s", r_sig_hashed) + + params = { + "request_ts": unix_ts, + "request_sig": r_sig_hashed, + "track_id": track_id, + "format_id": quality, + "intent": "stream", + } + response, status_code = self._api_request("track/getFileUrl", params) + if status_code == 400: + raise InvalidAppSecretError("Invalid app secret from params %s" % params) + + return response + + def _api_request(self, epoint: str, params: dict) -> Tuple[dict, int]: + logging.debug(f"Calling API with endpoint {epoint} params {params}") + r = self.session.get(f"{QOBUZ_BASE}/{epoint}", params=params) + try: + return r.json(), r.status_code + except Exception: + logger.error("Problem getting JSON. Status code: %s", r.status_code) + raise + + def _test_secret(self, secret: str) -> bool: + try: + self._api_get_file_url("19512574", sec=secret) + return True + except InvalidAppSecretError as error: + logger.debug("Test for %s secret didn't work: %s", secret, error) + return False + + +class DeezerClient(ClientInterface): + def __init__(self): + self.session = requests.Session() + self.logged_in = True + + def search(self, query: str, media_type: str = "album", limit: int = 200) -> dict: + """Search API for query. + + :param query: + :type query: str + :param media_type: + :type media_type: str + :param limit: + :type limit: int + """ + # TODO: more robust url sanitize + query = query.replace(" ", "+") + + if media_type.endswith("s"): + media_type = media_type[:-1] + + # TODO: use limit parameter + response = self.session.get(f"{DEEZER_BASE}/search/{media_type}?q={query}") + response.raise_for_status() + + return response.json() + + def login(self, **kwargs): + logger.debug("Deezer does not require login call, returning") + + @region.cache_on_arguments(expiration_time=RELEASE_CACHE_TIME) + def get(self, meta_id: Union[str, int], media_type: str = "album"): + """Get metadata. + + :param meta_id: + :type meta_id: Union[str, int] + :param type_: + :type type_: str + """ + url = f"{DEEZER_BASE}/{media_type}/{meta_id}" + item = self.session.get(url).json() + if media_type in ("album", "playlist"): + tracks = self.session.get(f"{url}/tracks").json() + item["tracks"] = tracks["data"] + item["track_total"] = len(tracks["data"]) + elif media_type == "artist": + albums = self.session.get(f"{url}/albums").json() + item["albums"] = albums["data"] + + return item + + @staticmethod + def get_file_url(meta_id: Union[str, int], quality: int = 6): + quality = min(DEEZER_MAX_Q, quality) + url = f"{DEEZER_DL}/{DEEZER_Q_IDS[quality]}/{DEEZER_BASE}/track/{meta_id}" + logger.debug(f"Download url {url}") + return url + + @property + def source(self): + return "deezer" + + +class TidalClient(ClientInterface): + def __init__(self): + self.logged_in = False + + def login(self, email: str, pwd: str): + if self.logged_in: + return + + config = tidalapi.Config() + + self.session = tidalapi.Session(config=config) + self.session.login(email, pwd) + logger.info("Logged into Tidal") + + self.logged_in = True + + @region.cache_on_arguments(expiration_time=RELEASE_CACHE_TIME) + def search(self, query: str, media_type: str = "album", limit: int = 50): + """ + :param query: + :type query: str + :param media_type: artist, album, playlist, or track + :type media_type: str + :param limit: + :type limit: int + :raises ValueError: if field value is invalid + """ + + return self._search(query, media_type, limit=limit) + + @region.cache_on_arguments(expiration_time=RELEASE_CACHE_TIME) + def get(self, meta_id: Union[str, int], media_type: str = "album"): + """Get metadata. + + :param meta_id: + :type meta_id: Union[str, int] + :param media_type: + :type media_type: str + """ + return self._get(meta_id, media_type) + + def get_file_url(self, meta_id: Union[str, int], quality: int = 6): + """ + :param meta_id: + :type meta_id: Union[str, int] + :param quality: + :type quality: int + """ + logger.debug(f"Fetching file url with quality {quality}") + return self._get_file_url(meta_id, quality=min(TIDAL_MAX_Q, quality)) + + @property + def source(self): + return "tidal" + + def _search(self, query, media_type="album", **kwargs): + params = { + "query": query, + "limit": kwargs.get("limit", 50), + } + return self.session.request("GET", f"search/{media_type}s", params).json() + + def _get(self, media_id, media_type="album"): + if media_type == "album": + info = self.session.request("GET", f"albums/{media_id}") + tracklist = self.session.request("GET", f"albums/{media_id}/tracks") + album = info.json() + album["tracks"] = tracklist.json() + return album + + elif media_type == "track": + return self.session.request("GET", f"tracks/{media_id}").json() + elif media_type == "playlist": + return self.session.request("GET", f"playlists/{media_id}/tracks").json() + elif media_type == "artist": + return self.session.request("GET", f"artists/{media_id}/albums").json() + else: + raise ValueError + + def _get_file_url(self, track_id, quality=6): + params = {"soundQuality": TIDAL_Q_IDS[quality]} + resp = self.session.request("GET", f"tracks/{track_id}/streamUrl", params) + resp.raise_for_status() + return resp.json() diff --git a/qobuz_dl_rewrite/config.py b/qobuz_dl_rewrite/config.py new file mode 100644 index 0000000..e5ae086 --- /dev/null +++ b/qobuz_dl_rewrite/config.py @@ -0,0 +1,159 @@ +import logging +import os +from pprint import pformat + +from ruamel.yaml import YAML + +from .constants import CONFIG_PATH, FOLDER_FORMAT, TRACK_FORMAT +from .exceptions import InvalidSourceError + +yaml = YAML() + + +logger = logging.getLogger(__name__) + + +class Config: + """Config class that handles command line args and config files. + + Usage: + >>> config = Config('test_config.yaml') + + If test_config was already initialized with values, this will load them + into `config`. Otherwise, a new config file is created with the default + values. + + >>> config.update_from_cli(**args) + + This will update the config values based on command line args. + """ + + def __init__(self, path: str): + + # DEFAULTS + folder = "Downloads" + quality = 6 + folder_format = FOLDER_FORMAT + track_format = TRACK_FORMAT + + self.qobuz = { + "enabled": True, + "email": None, + "password": None, + "app_id": "", # Avoid NoneType error + "secrets": [], + } + self.tidal = {"enabled": True, "email": None, "password": None} + self.deezer = {"enabled": True} + self.downloads_database = None + self.filters = { + "no_extras": False, + "albums_only": False, + "no_features": False, + "studio_albums": False, + "remaster_only": False, + } + self.downloads = {"folder": folder, "quality": quality} + self.metadata = { + "embed_cover": False, + "large_cover": False, + "default_comment": None, + "remove_extra_tags": False, + } + self.path_format = {"folder": folder_format, "track": track_format} + + if path is None: + self._path = CONFIG_PATH + else: + self._path = path + + if not os.path.exists(self._path): + logger.debug(f"Creating yaml config file at {self._path}") + self.dump(self.info) + else: + # sometimes the file gets erased, this will reset it + with open(self._path) as f: + if f.read().strip() == "": + logger.debug(f"Config file {self._path} corrupted, resetting.") + self.dump(self.info) + else: + self.load() + + def save(self): + self.dump(self.info) + + def reset(self): + os.remove(self._path) + # re initialize with default info + self.__init__(self._path) + + def load(self): + with open(self._path) as cfg: + for k, v in yaml.load(cfg).items(): + setattr(self, k, v) + + logger.debug("Config loaded") + self.__loaded = True + + def update_from_cli(self, **kwargs): + for category in (self.downloads, self.metadata, self.filters): + for key in category.keys(): + if kwargs[key] is None: + continue + + # For debugging's sake + og_value = category[key] + new_value = kwargs[key] or og_value + category[key] = new_value + + if og_value != new_value: + logger.debug("Updated %s config key from args: %s", key, new_value) + + def dump(self, info): + with open(self._path, "w") as cfg: + logger.debug("Config saved: %s", self._path) + yaml.dump(info, cfg) + + @property + def tidal_creds(self): + return { + "email": self.tidal["email"], + "pwd": self.tidal["password"], + } + + @property + def qobuz_creds(self): + return { + "email": self.qobuz["email"], + "pwd": self.qobuz["password"], + "app_id": self.qobuz["app_id"], + "secrets": self.qobuz["secrets"], + } + + def creds(self, source: str): + if source == "qobuz": + return self.qobuz_creds + elif source == "tidal": + return self.tidal_creds + elif source == "deezer": + return dict() + else: + raise InvalidSourceError(source) + + @property + def info(self): + return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} + + @info.setter + def info(self, val): + for k, v in val.items(): + setattr(self, k, v) + + def __getitem__(self, key): + return getattr(self, key) + + def __setitem__(self, key, val): + setattr(self, key, val) + + def __repr__(self): + return f"Config({pformat(self.info)})" diff --git a/qobuz_dl_rewrite/constants.py b/qobuz_dl_rewrite/constants.py new file mode 100644 index 0000000..fdcd996 --- /dev/null +++ b/qobuz_dl_rewrite/constants.py @@ -0,0 +1,146 @@ +import os + +import appdirs +import mutagen.id3 as id3 + +APPNAME = "qobuz-dl" + +CACHE_DIR = appdirs.user_cache_dir(APPNAME) +CONFIG_DIR = appdirs.user_config_dir(APPNAME) +CONFIG_PATH = os.path.join(CONFIG_DIR, "config.yaml") +LOG_DIR = appdirs.user_config_dir(APPNAME) +DB_PATH = os.path.join(LOG_DIR, "qobuz-dl.db") + +AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0" + +TIDAL_COVER_URL = "https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg" + +EXT = { + 5: ".mp3", + 6: ".flac", + 7: ".flac", + 27: ".flac", +} + +QUALITY_DESC = { + 4: "128kbps", + 5: "320kbps", + 6: "16bit/44.1kHz", + 7: "24bit/96kHz", + 27: "24bit/192kHz", +} + + +QOBUZ_FEATURED_KEYS = ( + "most-streamed", + "recent-releases", + "best-sellers", + "press-awards", + "ideal-discography", + "editor-picks", + "most-featured", + "qobuzissims", + "new-releases", + "new-releases-full", + "harmonia-mundi", + "universal-classic", + "universal-jazz", + "universal-jeunesse", + "universal-chanson", +) + +__MP4_KEYS = ( + "\xa9nam", + "\xa9ART", + "\xa9alb", + r"aART", + "\xa9day", + "\xa9day", + "\xa9cmt", + "desc", + "purd", + "\xa9grp", + "\xa9gen", + "\xa9lyr", + "\xa9too", + "cprt", + "cpil", + "covr", + "trkn", + "disk", +) + +__MP3_KEYS = ( + id3.TIT2, + id3.TPE1, + id3.TALB, + id3.TPE2, + id3.TCOM, + id3.TYER, + id3.COMM, + id3.TT1, + id3.TT1, + id3.GP1, + id3.TCON, + id3.USLT, + id3.TEN, + id3.TCOP, + id3.TCMP, + None, + id3.TRCK, + id3.TPOS, +) + +__METADATA_TYPES = ( + "title", + "artist", + "album", + "albumartist", + "composer", + "year", + "comment", + "description", + "purchase_date", + "grouping", + "genre", + "lyrics", + "encoder", + "copyright", + "compilation", + "cover", + "tracknumber", + "discnumber", +) + + +FLAC_KEY = {v: v.upper() for v in __METADATA_TYPES} +MP4_KEY = dict(zip(__METADATA_TYPES, __MP4_KEYS)) +MP3_KEY = dict(zip(__METADATA_TYPES, __MP3_KEYS)) + +COPYRIGHT = "\u2117" +PHON_COPYRIGHT = "\u00a9" +FLAC_MAX_BLOCKSIZE = 16777215 # 16.7 MB + +TRACK_KEYS = ("tracknumber", "artist", "albumartist", "composer", "title") +ALBUM_KEYS = ("albumartist", "title", "year", "bit_depth", "sampling_rate", "container") +FOLDER_FORMAT = ( + "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{sampling_rate}kHz]" +) +TRACK_FORMAT = "{tracknumber}. {artist} - {title}" + +URL_REGEX = ( + r"https:\/\/(?:www|open|play)?\.?(\w+)\.com(?:(?:\/(track|playlist|album|" + r"artist|label))|(?:\/[-\w]+?))+\/(\w+)" +) + + +TIDAL_Q_IDS = { + 4: "LOW", # AAC + 5: "HIGH", # AAC + 6: "LOSSLESS", # Lossless, but it also could be MQA + 7: "HI_RES", # not available for download +} +TIDAL_MAX_Q = 7 + +DEEZER_Q_IDS = {4: 128, 5: 320, 6: 1411} +DEEZER_MAX_Q = 6 diff --git a/qobuz_dl_rewrite/converter.py b/qobuz_dl_rewrite/converter.py new file mode 100644 index 0000000..a1b216a --- /dev/null +++ b/qobuz_dl_rewrite/converter.py @@ -0,0 +1,213 @@ +import logging +import os +import shutil +import subprocess +from tempfile import gettempdir +from typing import Optional + +from .exceptions import ConversionError + +logger = logging.getLogger(__name__) + + +class Converter: + """Base class for audio codecs.""" + + codec_name = None + codec_lib = None + container = None + lossless = False + default_ffmpeg_arg = "" + + def __init__( + self, + filename: str, + ffmpeg_arg: Optional[str] = None, + sampling_rate: Optional[int] = None, + bit_depth: Optional[int] = None, + copy_art: bool = True, + remove_source: bool = False, + show_progress: bool = False, + ): + """ + :param filename: + :type filename: str + :param ffmpeg_arg: The codec ffmpeg argument (defaults to an "optimal value") + :type ffmpeg_arg: Optional[str] + :param sampling_rate: This value is ignored if a lossy codec is detected + :type sampling_rate: Optional[int] + :param bit_depth: This value is ignored if a lossy codec is detected + :type bit_depth: Optional[int] + :param copy_art: Embed the cover art (if found) into the encoded file + :type copy_art: bool + :param remove_source: + :type remove_source: bool + """ + logger.debug(locals()) + + self.filename = filename + self.final_fn = f"{os.path.splitext(filename)[0]}.{self.container}" + self.tempfile = os.path.join(gettempdir(), os.path.basename(self.final_fn)) + self.remove_source = remove_source + self.sampling_rate = sampling_rate + self.bit_depth = bit_depth + self.copy_art = copy_art + self.show_progress = show_progress + + if ffmpeg_arg is None: + logger.debug("No arguments provided. Codec defaults will be used") + self.ffmpeg_arg = self.default_ffmpeg_arg + else: + self.ffmpeg_arg = ffmpeg_arg + self._is_command_valid() + + logger.debug("FFmpeg codec extra argument: %s", self.ffmpeg_arg) + + def convert(self, custom_fn: Optional[str] = None): + """Convert the file. + + :param custom_fn: Custom output filename (defaults to the original + name with a replaced container) + :type custom_fn: Optional[str] + """ + if custom_fn: + self.final_fn = custom_fn + + self.command = self._gen_command() + logger.debug("Generated conversion command: %s", self.command) + + process = subprocess.Popen(self.command) + process.wait() + if os.path.isfile(self.tempfile): + if self.remove_source: + os.remove(self.filename) + logger.debug("Source removed: %s", self.filename) + + shutil.move(self.tempfile, self.final_fn) + logger.debug("Moved: %s -> %s", self.tempfile, self.final_fn) + logger.debug("Converted: %s -> %s", self.filename, self.final_fn) + else: + raise ConversionError("No file was returned from conversion") + + def _gen_command(self): + command = [ + "ffmpeg", + "-i", + self.filename, + "-loglevel", + "warning", + "-c:a", + self.codec_lib, + ] + if self.show_progress: + command.append("-stats") + + if self.copy_art: + command.extend(["-c:v", "copy"]) + + if self.ffmpeg_arg: + command.extend(self.ffmpeg_arg.split()) + + if self.lossless: + if isinstance(self.sampling_rate, int): + command.extend(["-ar", str(self.sampling_rate)]) + elif self.sampling_rate is not None: + raise TypeError( + f"Sampling rate must be int, not {type(self.sampling_rate)}" + ) + + if isinstance(self.bit_depth, int): + if int(self.bit_depth) == 16: + command.extend(["-sample_fmt", "s16"]) + elif int(self.bit_depth) in (24, 32): + command.extend(["-sample_fmt", "s32"]) + else: + raise ValueError("Bit depth must be 16, 24, or 32") + elif self.bit_depth is not None: + raise TypeError(f"Bit depth must be int, not {type(self.bit_depth)}") + + command.extend(["-y", self.tempfile]) + + return command + + def _is_command_valid(self): + # TODO: add error handling for lossy codecs + if self.ffmpeg_arg is not None and self.lossless: + logger.debug( + "Lossless codecs don't support extra arguments; " + "the extra argument will be ignored" + ) + self.ffmpeg_arg = self.default_ffmpeg_arg + return + + +class FLAC(Converter): + " Class for FLAC converter. " + codec_name = "flac" + codec_lib = "flac" + container = "flac" + lossless = True + + +class LAME(Converter): + """ + Class for libmp3lame converter. Defaul ffmpeg_arg: `-q:a 0`. + + See available options: + https://trac.ffmpeg.org/wiki/Encode/MP3 + """ + + codec_name = "lame" + codec_lib = "libmp3lame" + container = "mp3" + default_ffmpeg_arg = "-q:a 0" # V0 + + +class ALAC(Converter): + " Class for ALAC converter. " + codec_name = "alac" + codec_lib = "alac" + container = "m4a" + lossless = True + + +class Vorbis(Converter): + """ + Class for libvorbis converter. Default ffmpeg_arg: `-q:a 6`. + + See available options: + https://trac.ffmpeg.org/wiki/TheoraVorbisEncodingGuide + """ + + codec_name = "vorbis" + codec_lib = "libvorbis" + container = "ogg" + default_ffmpeg_arg = "-q:a 6" # 160, aka the "high" quality profile from Spotify + + +class OPUS(Converter): + """ + Class for libopus. Default ffmpeg_arg: `-b:a 128 -vbr on`. + + See more: + http://ffmpeg.org/ffmpeg-codecs.html#libopus-1 + """ + + codec_name = "opus" + codec_lib = "libopus" + container = "opus" + default_ffmpeg_arg = "-b:a 128k" # Transparent + + +class AAC(Converter): + """ + Class for libfdk_aac converter. Default ffmpeg_arg: `-b:a 256k`. + + See available options: + https://trac.ffmpeg.org/wiki/Encode/AAC + """ + + codec_name = "aac" + codec_lib = "libfdk_aac" + container = "m4a" + default_ffmpeg_arg = "-b:a 256k" diff --git a/qobuz_dl_rewrite/core.py b/qobuz_dl_rewrite/core.py new file mode 100644 index 0000000..552b108 --- /dev/null +++ b/qobuz_dl_rewrite/core.py @@ -0,0 +1,184 @@ +import logging +import os +import re +from getpass import getpass +from typing import Generator, Optional, Tuple, Union + +import click + +from .clients import DeezerClient, QobuzClient, TidalClient +from .config import Config +from .constants import CONFIG_PATH, DB_PATH, URL_REGEX +from .db import QobuzDB +from .downloader import Album, Artist, Playlist, Track, Label +from .exceptions import AuthenticationError, ParsingError +from .utils import capitalize + +logger = logging.getLogger(__name__) + + +MEDIA_CLASS = {"album": Album, "playlist": Playlist, "artist": Artist, "track": Track, "label": Label} +CLIENTS = {"qobuz": QobuzClient, "tidal": TidalClient, "deezer": DeezerClient} +Media = Union[Album, Playlist, Artist, Track] # type hint + +# TODO: add support for database + + +class QobuzDL: + def __init__( + self, + config: Optional[Config] = None, + database: Optional[str] = None, + ): + logger.debug(locals()) + + self.url_parse = re.compile(URL_REGEX) + self.config = config + if self.config is None: + self.config = Config(CONFIG_PATH) + + self.clients = { + "qobuz": QobuzClient(), + "tidal": TidalClient(), + "deezer": DeezerClient(), + } + + if database is None: + self.db = QobuzDB(DB_PATH) + else: + assert isinstance(database, QobuzDB) + self.db = database + + def prompt_creds(self, source: str): + """Prompt the user for credentials. + + :param source: + :type source: str + """ + click.secho(f"Enter {capitalize(source)} email:", fg="green") + self.config[source]["email"] = input() + click.secho( + f"Enter {capitalize(source)} password (will not show on screen):", + fg="green", + ) + self.config[source]["password"] = getpass( + prompt="" + ) # does hashing work for tidal? + + self.config.save() + click.secho(f'Credentials saved to config file at "{self.config._path}"') + + def assert_creds(self, source: str): + assert source in ("qobuz", "tidal", "deezer"), f"Invalid source {source}" + if source == "deezer": + # no login for deezer + return + + if ( + self.config[source]["email"] is None + or self.config[source]["password"] is None + ): + self.prompt_creds(source) + + def handle_url(self, url: str): + """Download an url + + :param url: + :type url: str + :raises InvalidSourceError + :raises ParsingError + """ + source, url_type, item_id = self.parse_url(url) + if item_id in self.db: + logger.info(f"{url} already downloaded, use --no-db to override.") + return + self.handle_item(source, url_type, item_id) + + def handle_item(self, source: str, media_type: str, item_id: str): + self.assert_creds(source) + + arguments = { + "database": self.db, + "parent_folder": self.config.downloads["folder"], + "quality": self.config.downloads["quality"], + "embed_cover": self.config.metadata["embed_cover"], + } + + client = self.clients[source] + if not client.logged_in: + while True: + try: + client.login(**self.config.creds(source)) + break + except AuthenticationError: + click.secho("Invalid credentials, try again.") + self.prompt_creds(source) + + item = MEDIA_CLASS[media_type](client=client, id=item_id) + if isinstance(item, Artist): + keys = self.config.filters.keys() + # TODO: move this to config.py + filters_ = tuple(key for key in keys if self.config.filters[key]) + arguments["filters"] = filters_ + logger.debug("Added filter argument for artist/label: %s", filters_) + + logger.debug("Arguments from config: %s", arguments) + + item.load_meta() + item.download(**arguments) + + def parse_url(self, url: str) -> Tuple[str, str]: + """Returns the type of the url and the id. + + Compatible with urls of the form: + https://www.qobuz.com/us-en/{type}/{name}/{id} + https://open.qobuz.com/{type}/{id} + https://play.qobuz.com/{type}/{id} + /us-en/{type}/-/{id} + + https://www.deezer.com/us/{type}/{id} + https://tidal.com/browse/{type}/{id} + + :raises exceptions.ParsingError + """ + parsed = self.url_parse.search(url) + + if parsed is not None: + parsed = parsed.groups() + + if len(parsed) == 3: + return tuple(parsed) # Convert from Seq for the sake of typing + + raise ParsingError(f"Error parsing URL: `{url}`") + + def from_txt(self, filepath: Union[str, os.PathLike]): + """ + Handle a text file containing URLs. Lines starting with `#` are ignored. + + :param filepath: + :type filepath: Union[str, os.PathLike] + :raises OSError + :raises exceptions.ParsingError + """ + with open(filepath) as txt: + lines = ( + line for line in txt.readlines() if not line.strip().startswith("#") + ) + + click.secho(f"URLs found in text file: {len(lines)}") + + for line in lines: + self.handle_url(line) + + def search( + self, query: str, media_type: str = "album", limit: int = 200 + ) -> Generator: + results = self.client.search(query, media_type, limit) + + if isinstance(results, Generator): # QobuzClient + for page in results: + for item in page[f"{media_type}s"]["items"]: + yield MEDIA_CLASS[media_type].from_api(item, self.client) + else: + for item in results.get("data") or results.get("items"): + yield MEDIA_CLASS[media_type].from_api(item, self.client) diff --git a/qobuz_dl_rewrite/db.py b/qobuz_dl_rewrite/db.py new file mode 100644 index 0000000..f7392c4 --- /dev/null +++ b/qobuz_dl_rewrite/db.py @@ -0,0 +1,62 @@ +import logging +import os +import sqlite3 +from typing import Union + +logger = logging.getLogger(__name__) + + +class QobuzDB: + """Simple interface for the downloaded track database.""" + + def __init__(self, db_path: Union[str, os.PathLike]): + """Create a QobuzDB object + + :param db_path: filepath of the database + :type db_path: Union[str, os.PathLike] + """ + self.path = db_path + if not os.path.exists(self.path): + self.create() + + def create(self): + """Create a database at `self.path`""" + with sqlite3.connect(self.path) as conn: + try: + conn.execute("CREATE TABLE downloads (id TEXT UNIQUE NOT NULL);") + logger.debug("Download-IDs database created: %s", self.path) + except sqlite3.OperationalError: + pass + + return self.path + + def __contains__(self, item_id: Union[str, int]) -> bool: + """Checks whether the database contains an id. + + :param item_id: the id to check + :type item_id: str + :rtype: bool + """ + with sqlite3.connect(self.path) as conn: + return ( + conn.execute( + "SELECT id FROM downloads where id=?", (item_id,) + ).fetchone() + is not None + ) + + def add(self, item_id: str): + """Adds an id to the database. + + :param item_id: + :type item_id: str + """ + with sqlite3.connect(self.path) as conn: + try: + conn.execute( + "INSERT INTO downloads (id) VALUES (?)", + (item_id,), + ) + conn.commit() + except sqlite3.Error as error: + logger.error("Unexpected DB error: %s", error) diff --git a/qobuz_dl_rewrite/downloader.py b/qobuz_dl_rewrite/downloader.py new file mode 100644 index 0000000..ab870dc --- /dev/null +++ b/qobuz_dl_rewrite/downloader.py @@ -0,0 +1,1270 @@ +import logging +import os +import re +import shutil +import sys +from abc import ABC, abstractmethod +from pprint import pprint +from tempfile import gettempdir +from typing import Any, Callable, Optional, Tuple, Union + +import click +import requests +from mutagen.flac import FLAC, Picture +from mutagen.id3 import APIC, ID3, ID3NoHeaderError +from pathvalidate import sanitize_filename, sanitize_filepath + +from . import converter +from .clients import ClientInterface +from .constants import ( + ALBUM_KEYS, + EXT, + FLAC_MAX_BLOCKSIZE, + FOLDER_FORMAT, + TRACK_FORMAT, +) +from .db import QobuzDB +from .exceptions import ( + InvalidQuality, + InvalidSourceError, + NonStreamable, + TooLargeCoverArt, +) +from .metadata import TrackMetadata +from .utils import ( + clean_format, + quality_id, + safe_get, + tidal_cover_url, + tqdm_download, +) + +logger = logging.getLogger(__name__) + +# TODO: add the other quality options +TIDAL_Q_MAP = { + "LOW": 4, + "HIGH": 5, + "LOSSLESS": 6, + "HI_RES": 7, +} + +# used to homogenize cover size keys +COVER_SIZES = ("thumbnail", "small", "large") + +TYPE_REGEXES = { + "remaster": re.compile(r"(?i)(re)?master(ed)?"), + "extra": re.compile(r"(?i)(anniversary|deluxe|live|collector|demo|expanded)"), +} + + +class Track: + """Represents a downloadable track. + + Loading metadata as a single track: + >>> t = Track(client, id='20252078') + >>> t.load_meta() # load metadata from api + + Loading metadata as part of an Album: + >>> t = Track.from_album_meta(api_track_dict, client) + + where `api_track_dict` is a track entry in an album tracklist. + + Downloading and tagging: + >>> t.download() + >>> t.tag() + """ + + def __init__(self, client: ClientInterface, **kwargs): + """Create a track object. + + The only required parameter is client, but passing at an id is + highly recommended. Every value in kwargs will be set as an attribute + of the object. (TODO: make this safer) + + :param track_id: track id returned by Qobuz API + :type track_id: Optional[Union[str, int]] + :param client: qopy client + :type client: ClientInterface + :param meta: TrackMetadata object + :type meta: Optional[TrackMetadata] + :param kwargs: id, filepath_format, meta, quality, folder + """ + self.client = client + self.__dict__.update(kwargs) + + # adjustments after blind attribute sets + self.file_format = kwargs.get("track_format", TRACK_FORMAT) + self.container = "FLAC" + self.sampling_rate = 44100 + self.bit_depth = 16 + + self.__is_downloaded = False + self.__is_tagged = False + for attr in ("quality", "folder", "meta"): + setattr(self, attr, None) + + if isinstance(kwargs.get("meta"), TrackMetadata): + self.meta = kwargs["meta"] + else: + self.meta = None + # `load_meta` must be called at some point + logger.debug("Track: meta not provided") + + if (u := kwargs.get("cover_url")) is not None: + logger.debug(f"Cover url: {u}") + self.cover_url = u + + def load_meta(self): + """Send a request to the client to get metadata for this Track.""" + + assert hasattr(self, "id"), "id must be set before loading metadata" + + track_meta = self.client.get(self.id, media_type="track") + self.meta = TrackMetadata( + track=track_meta, source=self.client.source + ) # meta dict -> TrackMetadata object + + @staticmethod + def _get_tracklist(resp, source): + if source in ("qobuz", "tidal"): + return resp["tracks"]["items"] + elif source == "deezer": + return resp["tracks"] + + raise NotImplementedError(source) + + def download( + self, + quality: int = 7, + parent_folder: str = "Downloads", + progress_bar: bool = True, + database: QobuzDB = None, + ): + """ + Download the track. + + :param quality: (5, 6, 7, 27) + :type quality: int + :param folder: folder to download the files to + :type folder: Optional[Union[str, os.PathLike]] + :param progress_bar: turn on/off progress bar + :type progress_bar: bool + """ + self.quality, self.folder = ( + quality or self.quality, + parent_folder or self.folder, + ) + self.folder = sanitize_filepath(parent_folder) + + os.makedirs(self.folder, exist_ok=True) + + assert database is not None # remove this later + if os.path.isfile(self.format_final_path()) or self.id in database: + self.__is_downloaded = True + self.__is_tagged = True + click.secho(f"Track already downloaded: {self.final_path}", fg="green") + return False + + if hasattr(self, "cover_url"): + self.download_cover() + + dl_info = self.client.get_file_url(self.id, quality) # dict + + temp_file = os.path.join(gettempdir(), f"~{self.id}_{quality}.tmp") + logger.debug("Temporary file path: %s", temp_file) + + if self.client.source == "qobuz": + if not (dl_info.get("sampling_rate") and dl_info.get("url")) or dl_info.get( + "sample" + ): + logger.debug("Track is not downloadable: %s", dl_info) + return False + + self.sampling_rate = dl_info.get("sampling_rate") + self.bit_depth = dl_info.get("bit_depth") + + if os.path.isfile(temp_file): + logger.debug("Temporary file found: %s", temp_file) + self.__is_downloaded = True + self.__is_tagged = False + + if self.client.source in ("qobuz", "tidal"): + logger.debug("Downloadable URL found: %s", dl_info.get("url")) + tqdm_download(dl_info["url"], temp_file) # downloads file + elif isinstance(dl_info, str): # Deezer + logger.debug("Downloadable URL found: %s", dl_info) + tqdm_download(dl_info, temp_file) # downloads file + else: + raise InvalidSourceError(self.client.source) + + shutil.move(temp_file, self.final_path) + database.add(self.id) + logger.debug("Downloaded: %s -> %s", temp_file, self.final_path) + + self.__is_downloaded = True + return True + + def download_cover(self): + """Downloads the cover art, if cover_url is given.""" + + assert hasattr(self, "cover_url"), "must pass cover_url parameter" + + self.cover_path = os.path.join(self.folder, f"cover{hash(self.cover_url)}.jpg") + logger.debug(f"Downloading cover from {self.cover_url}") + if not os.path.exists(self.cover_path): + tqdm_download(self.cover_url, self.cover_path) + else: + logger.debug("Cover already exists, skipping download") + + self.cover = Tracklist.get_cover_obj(self.cover_path, self.quality) + logger.debug(f"Cover obj: {self.cover}") + + def format_final_path(self) -> str: + """Return the final filepath of the downloaded file. + + This uses the `get_formatter` method of TrackMetadata, which returns + a dict with the keys allowed in formatter strings, and their values in + the TrackMetadata object. + """ + formatter = self.meta.get_formatter() + # filename = sanitize_filepath(self.file_format.format(**formatter)) + filename = clean_format(self.file_format, formatter) + self.final_path = ( + os.path.join(self.folder, filename)[:250].strip() + + EXT[self.quality] # file extension dict + ) + + logger.debug("Formatted path: %s", self.final_path) + + return self.final_path + + @classmethod + def from_album_meta(cls, album: dict, pos: int, client: ClientInterface): + """Return a new Track object initialized with info from the album dicts + returned by client.get calls. + + :param album: album metadata returned by API + :param pos: index of the track + :param client: qopy client object + :type client: ClientInterface + :raises IndexError + """ + + track = cls._get_tracklist(album, client.source)[pos] + meta = TrackMetadata(album=album, track=track, source=client.source) + return cls(client=client, meta=meta, id=track["id"]) + + @classmethod + def from_api(cls, item: dict, client: ClientInterface): + meta = TrackMetadata(track=item, source=client.source) + try: + if client.source == "qobuz": + cover_url = item["album"]["image"]["small"] + elif client.source == "tidal": + cover_url = tidal_cover_url(item["album"]["cover"], 320) + elif client.source == "deezer": + cover_url = item["album"]["cover_medium"] + else: + raise InvalidSourceError(client.source) + except KeyError: + cover_url = None + + return cls( + client=client, + meta=meta, + id=item["id"], + cover_url=cover_url, + ) + + def tag( + self, + album_meta: dict = None, + cover: Union[Picture, APIC] = None, + embed_cover: bool = False, + ): + """Tag the track using the stored metadata. + + The info stored in the TrackMetadata object (self.meta) can be updated + with album metadata if necessary. The cover must be a mutagen cover-type + object that already has the bytes loaded. + + :param album_meta: album metadata to update Track with + :type album_meta: dict + :param cover: initialized mutagen cover object + :type cover: Union[Picture, APIC] + """ + assert isinstance(self.meta, TrackMetadata), "meta must be TrackMetadata" + if not self.__is_downloaded: + logger.info( + "Track %s not tagged because it was not downloaded", self["title"] + ) + return + + if self.__is_tagged: + logger.info( + "Track %s not tagged because it is already tagged", self["title"] + ) + return + + if album_meta is not None: + self.meta.add_album_meta(album_meta) # extend meta with album info + + if self.quality in (6, 7, 27): + self.container = "FLAC" + logger.debug("Tagging file with %s container", self.container) + audio = FLAC(self.final_path) + elif self.quality == 5: + self.container = "MP3" + logger.debug("Tagging file with %s container", self.container) + try: + audio = ID3(self.final_path) + except ID3NoHeaderError: + audio = ID3() + elif self.quality == 4: # tidal and deezer + # TODO: add compatibility with MP4 container + raise NotImplementedError("Qualities < 320kbps not implemented") + else: + raise InvalidQuality(f'Invalid quality: "{self.quality}"') + + # automatically generate key, value pairs based on container + for k, v in self.meta.tags(self.container): + audio[k] = v + + if cover is None and embed_cover: + assert hasattr(self, "cover") + cover = self.cover + + if isinstance(audio, FLAC): + if embed_cover: + audio.add_picture(cover) + audio.save() + elif isinstance(audio, ID3): + if embed_cover: + audio.add(cover) + audio.save(self.final_path, "v2_version=3") + else: + raise ValueError(f"Unknown container type: {audio}") + + self.__is_tagged = True + + def convert(self, codec: str = "ALAC", **kwargs): + """Converts the track to another codec. + + Valid values for codec: + * FLAC + * ALAC + * MP3 + * OPUS + * OGG + * VORBIS + * AAC + * M4A + + :param codec: the codec to convert the track to + :type codec: str + :param kwargs: + """ + assert self.__is_downloaded, "Track must be downloaded before conversion" + + CONV_CLASS = { + "FLAC": converter.FLAC, + "ALAC": converter.ALAC, + "MP3": converter.LAME, + "OPUS": converter.OPUS, + "OGG": converter.Vorbis, + "VORBIS": converter.Vorbis, + "AAC": converter.AAC, + "M4A": converter.AAC, + } + + self.container = codec.upper() + + engine = CONV_CLASS[codec.upper()]( + filename=self.final_path, + sampling_rate=kwargs.get("sampling_rate"), + remove_source=kwargs.get("remove_source", True), + ) + engine.convert() + + def get(self, *keys, default=None): + """Safe get method that allows for layered access. + + :param keys: + :param default: + """ + return safe_get(self.meta, *keys, default=default) + + def set(self, key, val): + """Equivalent to __setitem__. Implemented only for + consistency. + + :param key: + :param val: + """ + self.__setitem__(key, val) + + def __getitem__(self, key): + """Dict-like interface for Track metadata. + + :param key: + """ + return getattr(self.meta, key) + + def __setitem__(self, key, val): + """Dict-like interface for Track metadata. + + :param key: + :param val: + """ + setattr(self.meta, key, val) + + def __repr__(self) -> str: + """Return a string representation of the track. + + :rtype: str + """ + return f"" + + +class Tracklist(list, ABC): + """A base class for tracklist-like objects. + + Implements methods to give it dict-like behavior. If a Tracklist + subclass is subscripted with [s: str], it will return an attribute s. + If it is subscripted with [i: int] it will return the i'th track in + the tracklist. + + >>> tlist = Tracklist() + >>> tlist.tracklistname = 'my tracklist' + >>> tlist.append('first track') + >>> tlist[0] + 'first track' + >>> tlist['tracklistname'] + 'my tracklist' + >>> tlist[2] + IndexError + """ + + def __getitem__(self, key: Union[str, int]): + if isinstance(key, str): + return getattr(self, key) + + if isinstance(key, int): + return super().__getitem__(key) + + def __setitem__(self, key: Union[str, int], val: Any): + if isinstance(key, str): + setattr(self, key, val) + + if isinstance(key, int): + super().__setitem__(key, val) + + def get(self, key: Union[str, int], default: Optional[Any]): + if isinstance(key, str): + if hasattr(self, key): + return getattr(self, key) + + return default + + if isinstance(key, int): + if 0 <= key < len(self): + return super().__getitem__(key) + + return default + + def set(self, key, val): + self.__setitem__(key, val) + + def convert(self, codec="ALAC", **kwargs): + if (sr := kwargs.get("sampling_rate")) : + if sr < 44100: + logger.warning( + "Sampling rate %d is lower than 44.1kHz." + "This may cause distortion and ruin the track.", + kwargs["sampling_rate"], + ) + else: + logger.debug(f"Downsampling to {sr/1000}kHz") + + for track in self: + track.convert(codec, **kwargs) + + @classmethod + def from_api(cls, item: dict, client: ClientInterface): + """Create an Album object from the api response of Qobuz, Tidal, + or Deezer. + + :param resp: response dict + :type resp: dict + :param source: in ('qobuz', 'deezer', 'tidal') + :type source: str + """ + info = cls._parse_get_resp(item, client=client) + + # equivalent to Album(client=client, **info) + return cls(client=client, **info) + + @staticmethod + def get_cover_obj(cover_path: str, quality: int) -> Union[Picture, APIC]: + """Given the path to an image and a quality id, return an initialized + cover object that can be used for every track in the album. + + :param cover_path: + :type cover_path: str + :param quality: + :type quality: int + :rtype: Union[Picture, APIC] + """ + cover_type = {5: APIC, 6: Picture, 7: Picture, 27: Picture} + + cover = cover_type.get(quality) + if cover is Picture: + size_ = os.path.getsize(cover_path) + if size_ > FLAC_MAX_BLOCKSIZE: + raise TooLargeCoverArt( + "Not suitable for Picture embed: {size_ * 10 ** 6}MB" + ) + elif cover is None: + raise InvalidQuality(f"Quality {quality} not allowed") + + cover_obj = cover() + cover_obj.type = 3 + cover_obj.mime = "image/jpeg" + with open(cover_path, "rb") as img: + cover_obj.data = img.read() + + return cover_obj + + @staticmethod + @abstractmethod + def _parse_get_resp(item, client): + pass + + @abstractmethod + def download(self, **kwargs): + pass + + @staticmethod + def essence(album: str) -> str: + """Ignore text in parens/brackets, return all lowercase. + Used to group two albums that may be named similarly, but not exactly + the same. + """ + # fixme: compile this first + match = re.match(r"([^\(]+)(?:\s*[\(\[][^\)][\)\]])*", album) + if match: + return match.group(1).strip().lower() + + return album + + +class Album(Tracklist): + """Represents a downloadable Qobuz album. + + Usage: + + >>> resp = client.get('fleetwood mac rumours', 'album') + >>> album = Album.from_api(resp['items'][0], client) + >>> album.load_meta() + >>> album.download() + """ + + def __init__(self, client: ClientInterface, **kwargs): + """Create a new Album object. + + :param client: a qopy client instance + :param album_id: album id returned by qobuz api + :type album_id: Union[str, int] + :param kwargs: + """ + self.client = client + + self.sampling_rate = None + self.bit_depth = None + self.container = None + + self.folder_format = kwargs.get("album_format", FOLDER_FORMAT) + for k, v in kwargs.items(): + setattr(self, k, v) + + # to improve from_api method speed + if kwargs.get("load_on_init"): + self.load_meta() + + self.downloaded = False + + def load_meta(self): + assert hasattr(self, "id"), "id must be set to load metadata" + self.meta = self.client.get(self.id, media_type="album") + + # update attributes based on response + for k, v in self._parse_get_resp(self.meta, self.client).items(): + setattr(self, k, v) # prefer to __dict__.update for properties + + if not self.get("streamable", False): # Typing's sake + raise NonStreamable(f"This album is not streamable ({self.id} ID)") + + self._load_tracks() + + @classmethod + def from_api(cls, resp, client): + info = cls._parse_get_resp(resp, client) + return cls(client, **info) + + @staticmethod + def _parse_get_resp(resp: dict, client: ClientInterface) -> dict: + """Parse information from a client.get(query, 'album') call. + + :param resp: + :type resp: dict + :rtype: dict + """ + if client.source == "qobuz": + return { + "id": resp.get("id"), + "title": resp.get("title"), + "_artist": resp.get("artist") or resp.get("performer"), + "albumartist": resp.get("artist", {}).get("name"), + "year": str(resp.get("release_date_original"))[:4], + "version": resp.get("version"), + "release_type": resp.get("release_type", "album"), + "cover_urls": resp.get("image"), + "streamable": resp.get("streamable"), + "quality": quality_id( + resp.get("maximum_bit_depth"), resp.get("maximum_sampling_rate") + ), + "bit_depth": resp.get("maximum_bit_depth"), + "sampling_rate": resp.get("maximum_sampling_rate") * 1000, + "tracktotal": resp.get("tracks_count"), + } + elif client.source == "tidal": + return { + "id": resp.get("id"), + "title": resp.get("title"), + "_artist": safe_get(resp, "artist", "name"), + "albumartist": safe_get(resp, "artist", "name"), + "year": str(resp.get("year"))[:4], + "version": resp.get("version"), + "cover_urls": { + size: tidal_cover_url(resp.get("cover"), x) + for size, x in zip(COVER_SIZES, (160, 320, 1280)) + }, + "streamable": resp.get("allowStreaming"), + "quality": TIDAL_Q_MAP[resp.get("audioQuality")], + "bit_depth": 16, + "sampling_rate": 44100, + "tracktotal": resp.get("numberOfTracks"), + } + elif client.source == "deezer": + return { + "id": resp.get("id"), + "title": resp.get("title"), + "_artist": safe_get(resp, "artist", "name"), + "albumartist": safe_get(resp, "artist", "name"), + "year": str(resp.get("year"))[:4], + # version not given by API + "cover_urls": { + sk: resp.get(rk) # size key, resp key + for sk, rk in zip( + COVER_SIZES, ("cover", "cover_medium", "cover_xl") + ) + }, + "url": resp.get("link"), + "streamable": True, # api only returns streamables + "quality": 6, # all tracks are 16/44.1 streamable + "bit_depth": 16, + "sampling_rate": 44100, + "tracktotal": resp.get("track_total"), + } + + raise InvalidSourceError(client.source) + + def _load_tracks(self): + """Given an album metadata dict returned by the API, append all of its + tracks to `self`. + + This uses a classmethod to convert an item into a Track object, which + stores the metadata inside a TrackMetadata object. + """ + logging.debug("Loading tracks to album") + for i in range(self.tracktotal): + # append method inherited from superclass list + self.append( + Track.from_album_meta(album=self.meta, pos=i, client=self.client) + ) + + @property + def title(self) -> str: + """Return the title of the album. + + It is formatted so that "version" keys are included. + + :rtype: str + """ + album_title = self._title + if isinstance(self.version, str): + if self.version.lower() not in album_title.lower(): + album_title = f"{album_title} ({self.version})" + + return album_title + + @title.setter + def title(self, val): + """Sets the internal _title attribute to the given value. + + :param val: title to set + """ + self._title = val + + def download( + self, + quality: int = 7, + parent_folder: Union[str, os.PathLike] = "Downloads", + progress_bar: bool = True, + tag_tracks: bool = True, + cover_key: str = "large", + embed_cover: bool = False, + database: QobuzDB = None, + ): + """Download all of the tracks in the album. + + :param quality: (5, 6, 7, 27) + :type quality: int + :param parent_folder: the folder to download the album to + :type parent_folder: Union[str, os.PathLike] + :param progress_bar: turn on/off a tqdm progress bar + :type progress_bar: bool + """ + folder = self._get_formatted_folder(parent_folder) + + os.makedirs(folder, exist_ok=True) + logger.debug("Directory created: %s", folder) + + # choose optimal cover size and download it + cover = None + cover_path = os.path.join(folder, "cover.jpg") + + if os.path.isfile(cover_path): + logger.debug("Cover already downloaded: %s. Skipping", cover_path) + + else: + if self.cover_urls: + cover_url = self.cover_urls.get(cover_key) + + img = requests.head(cover_url) + + if int(img.headers["Content-Length"]) > FLAC_MAX_BLOCKSIZE: # 16.7 MB + logger.info( + f"{cover_key} cover size is too large to " + "embed. Using small cover instead" + ) + cover_url = self.cover_urls.get("small") + + tqdm_download(cover_url, cover_path) + + if self.client.source != "deezer" and embed_cover: + cover = self.get_cover_obj(cover_path, quality) + + for track in self: + logger.debug("Downloading track to %s", folder) + + track.download(quality, folder, progress_bar, database=database) + if tag_tracks and self.client.source != "deezer": + track.tag(cover=cover, embed_cover=embed_cover) + + logger.debug("Final album folder: %s", folder) + + self.downloaded = True + + def _get_formatter(self) -> dict: + dict_ = dict() + for key in ALBUM_KEYS: + if hasattr(self, key): + dict_[key] = getattr(self, key) + else: + dict_[key] = None + + dict_["sampling_rate"] /= 1000 + # 48.0kHz -> 48kHz, 44.1kHz -> 44.1kHz + if dict_["sampling_rate"] % 1 == 0.0: + dict_["sampling_rate"] = int(dict_["sampling_rate"]) + + return dict_ + + def _get_formatted_folder(self, parent_folder: str) -> str: + if self.bit_depth is not None and self.sampling_rate is not None: + self.container = "FLAC" + elif self.client.source == "qobuz": + self.container = "MP3" + elif self.client.source == "tidal": + self.container = "AAC" + else: + raise Exception(f"{self.bit_depth=}, {self.sampling_rate=}") + + formatted_folder = clean_format(self.folder_format, self._get_formatter()) + + return os.path.join(parent_folder, formatted_folder) + + def __repr__(self) -> str: + """Return a string representation of this Album object. + Useful for pprint and json.dumps. + + :rtype: str + """ + # Avoid AttributeError if load_on_init key is not set + if hasattr(self, "albumartist"): + return f"" + + return f"" + + +class Playlist(Tracklist): + """Represents a downloadable Qobuz playlist. + + Usage: + >>> resp = client.get('hip hop', 'playlist') + >>> pl = Playlist.from_api(resp['items'][0], client) + >>> pl.load_meta() + >>> pl.download() + """ + + def __init__(self, client: ClientInterface, **kwargs): + """Create a new Playlist object. + + :param client: a qopy client instance + :param album_id: playlist id returned by qobuz api + :type album_id: Union[str, int] + :param kwargs: + """ + self.client = client + + for k, v in kwargs.items(): + setattr(self, k, v) + + # to improve from_api method speed + if kwargs.get("load_on_init"): + self.load_meta() + + @classmethod + def from_api(cls, resp: dict, client: ClientInterface): + """Return a Playlist object initialized with information from + a search result returned by the API. + + :param resp: a single search result entry of a playlist + :type resp: dict + :param client: + :type client: ClientInterface + """ + info = cls._parse_get_resp(resp, client) + return cls(client, **info) + + def load_meta(self, **kwargs): + """Send a request to fetch the tracklist from the api. + + :param new_tracknumbers: replace the tracknumber with playlist position + :type new_tracknumbers: bool + :param kwargs: + """ + self.meta = self.client.get(self.id, "playlist") + self.name = self.meta.get("name") + self._load_tracks(**kwargs) + + def _load_tracks(self, new_tracknumbers: bool = True): + """Parses the tracklist returned by the API. + + :param new_tracknumbers: replace tracknumber tag with playlist position + :type new_tracknumbers: bool + """ + if self.client.source == "qobuz": + tracklist = self.meta["tracks"]["items"] + + def gen_cover(track): # ? + return track["album"]["image"]["small"] + + def meta_args(track): + return {"track": track, "album": track["album"]} + + elif self.client.source == "tidal": + tracklist = self.meta["items"] + + def gen_cover(track): + cover_url = tidal_cover_url(track["album"]["cover"], 320) + return cover_url + + def meta_args(track): + return { + "track": track, + "source": self.client.source, + } + + elif self.client.source == "deezer": + tracklist = self.meta["tracks"] + + def gen_cover(track): + return track["album"]["cover_medium"] + + def meta_args(track): + return {"track": track, "source": self.client.source} + + else: + raise NotImplementedError + + for i, track in enumerate(tracklist): + # TODO: This should be managed with .m3u files and alike. Arbitrary + # tracknumber tags might cause conflicts if the playlist files are + # inside of a library folder + meta = TrackMetadata(**meta_args(track)) + if new_tracknumbers: + meta["tracknumber"] = f"{i:02}" + + self.append( + Track( + self.client, + id=track.get("id"), + meta=meta, + cover_url=gen_cover(track), + ) + ) + + logger.debug(f"Loaded {len(self)} tracks from playlist {self.name}") + + def download( + self, + parent_folder: str = "Downloads", + quality: int = 6, + filters: Callable = None, + embed_cover: bool = False, + database: QobuzDB = None, + ): + """Download and tag all of the tracks. + + :param parent_folder: + :type parent_folder: str + :param quality: + :type quality: int + :param filters: + :type filters: Callable + """ + folder = sanitize_filename(self.name) + folder = os.path.join(parent_folder, folder) + + for track in self: + track.download(parent_folder=folder, quality=quality, database=database) + if self.client.source != "deezer": + track.tag(embed_cover=embed_cover) + + @staticmethod + def _parse_get_resp(item: dict, client: ClientInterface): + """Parses information from a search result returned + by a client.search call. + + :param item: + :type item: dict + :param client: + :type client: ClientInterface + """ + if client.source == "qobuz": + return { + "name": item.get("name"), + "id": item.get("id"), + } + elif client.source == "tidal": + return { + "name": item["title"], + "id": item["uuid"], + } + elif client.source == "deezer": + return { + "name": item["title"], + "id": item["id"], + } + + raise InvalidSourceError(client.source) + + def __repr__(self) -> str: + """Return a string representation of this Playlist object. + Useful for pprint and json.dumps. + + :rtype: str + """ + return f"" + + +class Artist(Tracklist): + """Represents a downloadable artist. + + Usage: + >>> resp = client.get('fleetwood mac', 'artist') + >>> artist = Artist.from_api(resp['items'][0], client) + >>> artist.load_meta() + >>> artist.download() + """ + + def __init__(self, client: ClientInterface, **kwargs): + """Create a new Artist object. + + :param client: a qopy client instance + :param album_id: artist id returned by qobuz api + :type album_id: Union[str, int] + :param kwargs: + """ + self.client = client + + for k, v in kwargs.items(): + setattr(self, k, v) + + # to improve from_api method speed + if kwargs.get("load_on_init"): + self.load_meta() + + def load_meta(self): + """Send an API call to get album info based on id.""" + self.meta = self.client.get(self.id, media_type="artist") + self._load_albums() + + self.name = self.meta.get("name") + + def _load_albums(self): + """From the discography returned by client.get(query, 'artist'), + generate album objects and append them to self. + """ + if self.client.source == "qobuz": + albums = self.meta["albums"]["items"] + + elif self.client.source == "tidal": + albums = self.meta["items"] + + elif self.client.source == "deezer": + albums = self.meta["albums"] + + else: + raise InvalidSourceError(self.client.source) + + for album in albums: + logger.debug("Appending album: %s", album.get("title")) + self.append(Album.from_api(album, self.client)) + + def download( + self, + parent_folder: str = "Downloads", + filters: Optional[Tuple] = None, + no_repeats: bool = False, + quality: int = 6, + embed_cover: bool = False, + database: QobuzDB = None, + ): + """Download all albums in the discography. + + :param filters: Filters to apply to discography, see options below. + These only work for Qobuz. + :type filters: Optional[Tuple] + :param no_repeats: Remove repeats + :type no_repeats: bool + :param quality: in (4, 5, 6, 7, 27) + :type quality: int + """ + folder = sanitize_filename(self.name) + folder = os.path.join(parent_folder, folder) + + logger.debug("Artist folder: %s", folder) + + logger.debug(f"Length of tracklist {len(self)}") + if no_repeats: + final = self._remove_repeats(bit_depth=max, sampling_rate=min) + else: + final = self + + if isinstance(filters, tuple) and self.client.source == "qobuz": + filters = [getattr(self, filter_) for filter_ in filters] + logger.debug("Filters: %s", filters) + for filter_ in filters: + + def inter(album): + """Intermediate function to pass self into f""" + return filter_(self, album) + + final = filter(inter, final) + + i = 0 + for album in final: + i += 1 + click.secho(f"Downloading album: {album}", fg="blue") + try: + album.load_meta() + except NonStreamable: + logger.info("Skipping album, not available to stream.") + album.download( + parent_folder=folder, + quality=quality, + embed_cover=embed_cover, + database=database, + ) + + logger.debug(f"{i} albums downloaded") + + @classmethod + def from_api(cls, item: dict, client: ClientInterface, source: str = "qobuz"): + """Create an Artist object from the api response of Qobuz, Tidal, + or Deezer. + + :param resp: response dict + :type resp: dict + :param source: in ('qobuz', 'deezer', 'tidal') + :type source: str + """ + logging.debug("Loading item from API") + info = cls._parse_get_resp(item, client) + + # equivalent to Artist(client=client, **info) + return cls(client=client, **info) + + def _remove_repeats(self, bit_depth=max, sampling_rate=max): + """Remove the repeated albums from self. May remove different + versions of the same album. + + :param bit_depth: either max or min functions + :param sampling_rate: either max or min functions + """ + groups = dict() + for album in self: + if (t := self.essence(album.title)) not in groups: + groups[t] = [] + groups[t].append(album) + + for group in groups.values(): + assert bit_depth in (min, max) and sampling_rate in (min, max) + best_bd = bit_depth(a["bit_depth"] for a in group) + best_sr = sampling_rate(a["sampling_rate"] for a in group) + for album in group: + if album["bit_depth"] == best_bd and album["sampling_rate"] == best_sr: + yield album + break + + @staticmethod + def _parse_get_resp(item: dict, client: ClientInterface): + """Parse a result from a client.search call. + + :param item: the item to parse + :type item: dict + :param client: + :type client: ClientInterface + """ + if client.source in ("qobuz", "deezer"): + info = { + "name": item.get("name"), + "id": item.get("id"), + } + elif client.source == "tidal": + info = { + "name": item["name"], + "id": item["id"], + } + else: + raise InvalidSourceError(client.source) + + return info + + # ----------- Filters -------------- + + @staticmethod + def studio_albums(artist, album: Album) -> bool: + """Passed as a parameter by the user. + + >>> artist.download(filters=Artist.studio_albums) + + This will download only studio albums. + + :param artist: usually self + :param album: the album to check + :type album: Album + :rtype: bool + """ + return ( + album["albumartist"] != "Various Artists" + and TYPE_REGEXES["extra"].search(album.title) is None + ) + + @staticmethod + def no_features(artist, album): + """Passed as a parameter by the user. + + >>> artist.download(filters=Artist.no_features) + + This will download only albums where the requested + artist is the album artist. + + :param artist: usually self + :param album: the album to check + :type album: Album + :rtype: bool + """ + return artist["name"] == album["albumartist"] + + @staticmethod + def no_extras(artist, album): + """Passed as a parameter by the user. + + >>> artist.download(filters=Artist.no_extras) + + This will skip any extras. + + :param artist: usually self + :param album: the album to check + :type album: Album + :rtype: bool + """ + return TYPE_REGEXES["extra"].search(album.title) is None + + @staticmethod + def remaster_only(artist, album): + """Passed as a parameter by the user. + + >>> artist.download(filters=Artist.remaster_only) + + This will download only remasterd albums. + + :param artist: usually self + :param album: the album to check + :type album: Album + :rtype: bool + """ + return TYPE_REGEXES["remaster"].search(album.title) is not None + + @staticmethod + def albums_only(artist, album): + """This will ignore non-album releases. + + >>> artist.download(filters=(albums_only)) + + :param artist: usually self + :param album: the album to check + :type album: Album + :rtype: bool + """ + # Doesn't work yet + return album["release_type"] == "album" + + # --------- Magic Methods -------- + + def __repr__(self) -> str: + """Return a string representation of this Artist object. + Useful for pprint and json.dumps. + + :rtype: str + """ + return f"" + + +class Label(Artist): + def load_meta(self): + assert self.client.source == "qobuz", "Label source must be qobuz" + + resp = self.client.get(self.id, "label") + self.name = resp["name"] + for album in resp["albums"]["items"]: + pprint(album) + self.append(Album.from_api(album, client=self.client)) + + def __repr__(self): + return f"