commit 089cb3dc5aeb7dda6149a91661c13e6239491fbe Author: cool.gitter.not.me.again.duh Date: Sat May 31 15:51:18 2025 -0600 first commit diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..27e73e5 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: deezloadclone + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..09fe997 --- /dev/null +++ b/README.md @@ -0,0 +1,712 @@ +# DeezSpot + +DeezSpot is a Python library that enables downloading songs, albums, playlists, and podcasts from both Deezer and Spotify. This fork includes tweaks for use with the [spotizerr](https://github.com/Xoconoch/spotizerr) project. + +## Features + +- Download tracks, albums, playlists, and podcasts from both Deezer and Spotify +- Search for music by name or download directly using links +- Support for different audio quality options +- Download an artist's discography +- Smart link detection to identify and process different types of content +- Tag downloaded files with correct metadata +- Customizable file and directory naming formats + +## Installation + +### From PyPI (recommended) + +```bash +pip install git+https://github.com/Xoconoch/deezspot-fork-again.git +``` + +### From Source + +```bash +git clone https://github.com/Xoconoch/deezspot-fork-again.git +cd deezspot-fork-again +pip install -e . +``` + +## Configuration + +### Deezer Authentication + +DeezSpot supports two methods of authentication for Deezer: + +1. Using ARL token: +```python +from deezspot.deezloader import DeeLogin + +# Authenticate with ARL +downloader = DeeLogin(arl="your_arl_token") +``` + +2. Using email and password: +```python +from deezspot.deezloader import DeeLogin + +# Authenticate with email and password +downloader = DeeLogin(email="your_email", password="your_password") +``` + +### Spotify Authentication + +For Spotify, you'll need a credentials file: + +```python +from deezspot.spotloader import SpoLogin + +# Authenticate with credentials file +downloader = SpoLogin(credentials_path="/path/to/credentials.json") +``` + +To create a credentials file, use a tool like [librespot-java](https://github.com/librespot-org/librespot-java) to generate it. + +## Usage + +### Downloading from Deezer + +```python +from deezspot.deezloader import DeeLogin + +# Initialize with your credentials +downloader = DeeLogin(arl="your_arl_token") + +# Download a track +track = downloader.download_trackdee( + "https://www.deezer.com/track/123456789", + output_dir="./downloads", + quality_download="FLAC" # Options: MP3_320, FLAC, MP3_128 +) + +# Download an album +album = downloader.download_albumdee( + "https://www.deezer.com/album/123456789", + output_dir="./downloads", + quality_download="MP3_320", + make_zip=True # Create a zip archive of the album +) + +# Download a playlist +playlist = downloader.download_playlistdee( + "https://www.deezer.com/playlist/123456789", + output_dir="./downloads", + quality_download="MP3_320" +) + +# Download an artist's top tracks +tracks = downloader.download_artisttopdee( + "https://www.deezer.com/artist/123456789", + output_dir="./downloads" +) + +# Search and download by name +track = downloader.download_name( + artist="Artist Name", + song="Song Title", + output_dir="./downloads" +) +``` + +### Downloading from Spotify + +```python +from deezspot.spotloader import SpoLogin +import logging +from deezspot import set_log_level, enable_file_logging + +# Configure logging +set_log_level(logging.INFO) +enable_file_logging("spotify_downloads.log") + +# Custom progress callback +def spotify_progress_callback(progress_data): + status = progress_data.get("status") + if status == "real_time": + song = progress_data.get("song", "Unknown") + percentage = progress_data.get("percentage", 0) * 100 + print(f"Downloading '{song}': {percentage:.1f}%") + elif status == "downloading": + print(f"Starting download: {progress_data.get('song', 'Unknown')}") + elif status == "done": + print(f"Completed: {progress_data.get('song', 'Unknown')}") + +# Initialize Spotify client with progress callback +spotify = SpoLogin( + credentials_path="credentials.json", + spotify_client_id="your_client_id", + spotify_client_secret="your_client_secret", + progress_callback=spotify_progress_callback +) + +# Or use silent mode for background operations +spotify_silent = SpoLogin( + credentials_path="credentials.json", + spotify_client_id="your_client_id", + spotify_client_secret="your_client_secret", + silent=True +) + +# Download a track +spotify.download_track( + "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", + output_dir="downloads", + quality_download="HIGH", + real_time_dl=True +) + +# Download an album +album = spotify.download_album( + "https://open.spotify.com/album/123456789", + output_dir="./downloads", + quality_download="HIGH", + make_zip=True # Create a zip archive of the album +) + +# Download a playlist +playlist = spotify.download_playlist( + "https://open.spotify.com/playlist/123456789", + output_dir="./downloads" +) + +# Download a podcast episode +episode = spotify.download_episode( + "https://open.spotify.com/episode/123456789", + output_dir="./downloads" +) + +# Download an artist's discography +spotify.download_artist( + "https://open.spotify.com/artist/123456789", + album_type="album,single", # Options: album, single, compilation, appears_on + limit=50, # Number of albums to retrieve + output_dir="./downloads" +) +``` + +### Smart Download + +Both the Deezer and Spotify interfaces provide a "smart" download function that automatically detects the type of content from the link: + +```python +# For Deezer +result = downloader.download_smart("https://www.deezer.com/track/123456789") + +# For Spotify +result = downloader.download_smart("https://open.spotify.com/album/123456789") +``` + +### Converting Spotify links to Deezer + +DeezSpot can also convert Spotify links to Deezer for downloading with higher quality: + +```python +# Convert and download a Spotify track using Deezer +track = downloader.download_trackspo("https://open.spotify.com/track/123456789") + +# Convert and download a Spotify album using Deezer +album = downloader.download_albumspo("https://open.spotify.com/album/123456789") + +# Convert and download a Spotify playlist using Deezer +playlist = downloader.download_playlistspo("https://open.spotify.com/playlist/123456789") +``` + +## Available Quality Options + +### Deezer +- `MP3_320`: 320 kbps MP3 +- `FLAC`: Lossless audio +- `MP3_128`: 128 kbps MP3 + +### Spotify +- `VERY_HIGH`: 320 kbps OGG +- `HIGH`: 160 kbps OGG +- `NORMAL`: 96 kbps OGG + +## Common Parameters + +Most download methods accept these common parameters: + +- `output_dir`: Output directory for downloaded files (default: "Songs/") +- `quality_download`: Quality of audio files (see options above) +- `recursive_quality`: Try another quality if the selected one is not available (default: True) +- `recursive_download`: Try another API if the current one fails (default: True) +- `not_interface`: Hide download progress (default: False) +- `make_zip`: Create a zip archive for albums/playlists (default: False) +- `method_save`: How to save the downloads (default: varies by function) +- `custom_dir_format`: Custom directory naming format +- `custom_track_format`: Custom track naming format + +## Custom Naming Formats + +You can customize the output directory and file naming patterns: + +```python +# Example of custom directory format +result = downloader.download_albumdee( + "https://www.deezer.com/album/123456789", + custom_dir_format="{artist}/{album} [{year}]" +) + +# Example of custom track format +result = downloader.download_trackdee( + "https://www.deezer.com/track/123456789", + custom_track_format="{tracknumber} - {title}" +) +``` + +## License + +This project is licensed under the GNU Affero General Public License v3.0 - see the [LICENSE](LICENSE) file for details. + +## Credits + +This project is a fork of the [original deezspot library](https://github.com/jakiepari/deezspot). + +# Deezspot Logging System + +This document explains the enhanced logging system implemented in the Deezspot library, making it more suitable for production environments, Celery integrations, and other enterprise applications. + +## Overview + +The logging system in Deezspot provides: + +- Standardized, structured log messages +- Multiple logging levels for different verbosity needs +- File logging capabilities for persistent logs +- Console output for interactive use +- JSON-formatted progress updates +- Custom progress callbacks for integration with other systems +- Silent mode for background operation + +## Basic Configuration + +### Setting Log Level + +```python +from deezspot import set_log_level +import logging + +# Available log levels: +# - logging.DEBUG (most verbose) +# - logging.INFO (default) +# - logging.WARNING +# - logging.ERROR +# - logging.CRITICAL (least verbose) + +set_log_level(logging.INFO) # Default level shows important information +set_log_level(logging.DEBUG) # For detailed debugging information +set_log_level(logging.WARNING) # For warnings and errors only +``` + +### Enabling File Logging + +```python +from deezspot import enable_file_logging + +# Enable logging to a file (in addition to console) +enable_file_logging("/path/to/logs/deezspot.log") + +# With custom log level +enable_file_logging("/path/to/logs/deezspot.log", level=logging.DEBUG) +``` + +### Disabling Logging + +```python +from deezspot import disable_logging + +# Completely disable logging (except critical errors) +disable_logging() +``` + +## Progress Reporting + +The library uses a structured JSON format for progress reporting, making it easy to integrate with other systems. + +### Progress JSON Structure + +For tracks: +```json +{ + "status": "downloading|progress|done|skipped|retrying", + "type": "track", + "album": "Album Name", + "song": "Song Title", + "artist": "Artist Name" +} +``` + +For real-time downloads: +```json +{ + "status": "real_time", + "song": "Song Title", + "artist": "Artist Name", + "time_elapsed": 1500, + "percentage": 0.75 +} +``` + +For albums: +```json +{ + "status": "initializing|progress|done", + "type": "album", + "album": "Album Name", + "artist": "Artist Name", + "track": "Current Track Title", + "current_track": "3/12" +} +``` + +For playlists: +```json +{ + "status": "initializing|progress|done", + "type": "playlist", + "name": "Playlist Name", + "track": "Current Track Title", + "current_track": "5/25", + "total_tracks": 25 +} +``` + +## Custom Progress Callbacks + +For integration with other systems (like Celery), you can provide a custom progress callback function when initializing the library. + +### Example with Custom Callback + +```python +from deezspot.deezloader import DeeLogin + +def my_progress_callback(progress_data): + """ + Custom callback function to handle progress updates + + Args: + progress_data: Dictionary containing progress information + """ + status = progress_data.get("status") + track_title = progress_data.get("song", "") + + if status == "downloading": + print(f"Starting download: {track_title}") + elif status == "progress": + current = progress_data.get("current_track", "") + print(f"Progress: {current} - {track_title}") + elif status == "done": + print(f"Completed: {track_title}") + elif status == "real_time": + percentage = progress_data.get("percentage", 0) * 100 + print(f"Downloading: {track_title} - {percentage:.1f}%") + +# Initialize with custom callback +deezer = DeeLogin( + arl="your_arl_token", + progress_callback=my_progress_callback +) +``` + +### Silent Mode + +If you want to disable progress reporting completely (for background operations), use silent mode: + +```python +deezer = DeeLogin( + arl="your_arl_token", + silent=True +) +``` + +## Spotify Integration + +For Spotify downloads, the same logging principles apply. Here's an example using the Spotify client: + +```python +from deezspot.spotloader import SpoLogin +import logging +from deezspot import set_log_level, enable_file_logging + +# Configure logging +set_log_level(logging.INFO) +enable_file_logging("spotify_downloads.log") + +# Custom progress callback +def spotify_progress_callback(progress_data): + status = progress_data.get("status") + if status == "real_time": + song = progress_data.get("song", "Unknown") + percentage = progress_data.get("percentage", 0) * 100 + print(f"Downloading '{song}': {percentage:.1f}%") + elif status == "downloading": + print(f"Starting download: {progress_data.get('song', 'Unknown')}") + elif status == "done": + print(f"Completed: {progress_data.get('song', 'Unknown')}") + +# Initialize Spotify client with progress callback +spotify = SpoLogin( + credentials_path="credentials.json", + spotify_client_id="your_client_id", + spotify_client_secret="your_client_secret", + progress_callback=spotify_progress_callback +) + +# Or use silent mode for background operations +spotify_silent = SpoLogin( + credentials_path="credentials.json", + spotify_client_id="your_client_id", + spotify_client_secret="your_client_secret", + silent=True +) + +# Download a track +spotify.download_track( + "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", + output_dir="downloads", + quality_download="HIGH", + real_time_dl=True +) +``` + +## Celery Integration Example + +Here's how to integrate the logging system with Celery for task progress reporting: + +```python +from celery import Celery +from deezspot.deezloader import DeeLogin +import logging +from deezspot import enable_file_logging + +# Configure Celery +app = Celery('tasks', broker='pyamqp://guest@localhost//') + +# Configure logging +enable_file_logging("/path/to/logs/deezspot.log", level=logging.INFO) + +@app.task(bind=True) +def download_music(self, link, output_dir): + # Create a progress callback that updates the Celery task state + def update_progress(progress_data): + status = progress_data.get("status") + + if status == "downloading": + self.update_state( + state="DOWNLOADING", + meta={ + "track": progress_data.get("song", ""), + "progress": 0 + } + ) + elif status == "progress": + current, total = progress_data.get("current_track", "1/1").split("/") + progress = int(current) / int(total) + self.update_state( + state="PROGRESS", + meta={ + "track": progress_data.get("track", ""), + "progress": progress + } + ) + elif status == "real_time": + self.update_state( + state="PROGRESS", + meta={ + "track": progress_data.get("song", ""), + "progress": progress_data.get("percentage", 0) + } + ) + elif status == "done": + self.update_state( + state="COMPLETED", + meta={ + "track": progress_data.get("song", ""), + "progress": 1.0 + } + ) + + # Initialize the client with the progress callback + deezer = DeeLogin( + arl="your_arl_token", + progress_callback=update_progress + ) + + # Download the content + result = deezer.download_smart( + link=link, + output_dir=output_dir, + quality_download="MP3_320" + ) + + return {"status": "completed", "output": result.track.song_path} +``` + +## Direct Logger Access + +For advanced use cases, you can directly access and use the logger: + +```python +from deezspot.libutils.logging_utils import logger + +# Use the logger directly +logger.debug("Detailed debugging information") +logger.info("General information") +logger.warning("Warning message") +logger.error("Error message") +logger.critical("Critical error message") + +# Log structured data +import json +logger.info(json.dumps({ + "custom_event": "download_started", + "metadata": { + "source": "spotify", + "track_id": "1234567890" + } +})) +``` + +## Log Format + +The default log format is: +``` +%(asctime)s - %(name)s - %(levelname)s - %(message)s +``` + +Example: +``` +2023-10-15 12:34:56,789 - deezspot - INFO - {"status": "downloading", "type": "track", "album": "Album Name", "song": "Song Title", "artist": "Artist Name"} +``` + +## Console vs File Logging + +- By default, the library is configured to log at WARNING level to the console only +- You can enable file logging in addition to console logging +- File and console logging can have different log levels + +## Using the Logger in Your Code + +If you're extending the library or integrating it deeply into your application, you can use the logger directly: + +```python +from deezspot.libutils.logging_utils import logger, ProgressReporter + +# Create a custom progress reporter +my_reporter = ProgressReporter( + callback=my_callback_function, + silent=False, + log_level=logging.INFO +) + +# Report progress +my_reporter.report({ + "status": "custom_status", + "message": "Custom progress message", + "progress": 0.5 +}) + +# Log directly +logger.info("Processing started") +``` + +## Test Script Example + +Here's a complete example script that tests the Spotify functionality with logging enabled: + +```python +#!/usr/bin/env python3 + +import os +import logging +from deezspot import set_log_level, enable_file_logging +from deezspot.spotloader import SpoLogin + +def main(): + # Configure logging + set_log_level(logging.INFO) # Set to logging.DEBUG for more detailed output + enable_file_logging("deezspot.log") + + # Spotify API credentials + SPOTIFY_CLIENT_ID = "your_client_id" + SPOTIFY_CLIENT_SECRET = "your_client_secret" + + # Path to your Spotify credentials file (from librespot) + CREDENTIALS_PATH = "credentials.json" + + # Output directory for downloads + OUTPUT_DIR = "downloads" + os.makedirs(OUTPUT_DIR, exist_ok=True) + + try: + # Initialize the Spotify client + spotify = SpoLogin( + credentials_path=CREDENTIALS_PATH, + spotify_client_id=SPOTIFY_CLIENT_ID, + spotify_client_secret=SPOTIFY_CLIENT_SECRET + ) + + # Test track download + print("\nTesting track download...") + track_url = "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT" + spotify.download_track( + track_url, + output_dir=OUTPUT_DIR, + quality_download="HIGH", + real_time_dl=True, + custom_dir_format="{artist}/{album}", + custom_track_format="{tracknum} - {title}" + ) + + except Exception as e: + print(f"Error: {str(e)}") + logging.error(f"Test failed: {str(e)}", exc_info=True) + +if __name__ == "__main__": + main() +``` + +This logging system provides flexibility for both simple scripts and complex production applications, making it easier to monitor and integrate Deezspot in any environment. + +## Callback Functionality + +Both the Deezer and Spotify components of the deezspot library now support progress callbacks, allowing you to integrate download progress into your applications. This feature enables: + +1. **Real-time Progress Tracking**: Monitor download progress for tracks, albums, playlists, and episodes +2. **Custom UI Integration**: Update your application's UI with download status +3. **Background Processing**: Run downloads silently in background tasks +4. **Task Management**: Integrate with task systems like Celery for distributed processing + +### Common Callback Events + +The progress callback function receives a dictionary with the following common fields: + +- `status`: The current status of the operation (`initializing`, `downloading`, `progress`, `done`, `skipped`, `retrying`, `real_time`) +- `type`: The type of content (`track`, `album`, `playlist`, `episode`) +- Additional fields depending on the status and content type + +### Usage in Both Components + +Both the Deezer and Spotify components use the same callback system: + +```python +# For Deezer +deezer = DeeLogin( + arl="your_arl_token", + progress_callback=my_callback_function, + silent=False # Set to True to disable progress reporting +) + +# For Spotify +spotify = SpoLogin( + credentials_path="credentials.json", + spotify_client_id="your_client_id", + spotify_client_secret="your_client_secret", + progress_callback=my_callback_function, + silent=False # Set to True to disable progress reporting +) +``` + +The standardized callback system ensures that your application can handle progress updates consistently regardless of whether the content is being downloaded from Deezer or Spotify. diff --git a/debug_flac.py b/debug_flac.py new file mode 100755 index 0000000..b89f087 --- /dev/null +++ b/debug_flac.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Specialized debugging script for investigating FLAC decryption issues. +This script downloads a track and analyzes the decryption process in detail. +""" + +import os +import sys +import logging +import json +import argparse +from pathlib import Path + +# Configure logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler("flac_debug.log"), + logging.StreamHandler() + ] +) + +logger = logging.getLogger('flac-debug') + +# Import our modules +from deezspot.deezloader import DeeLogin +from deezspot.exceptions import BadCredentials, TrackNotFound +from deezspot.deezloader.__download_utils__ import analyze_flac_file + +def debug_flac_decryption(arl_token, track_url, output_dir="debug_output"): + """ + Debug the FLAC decryption process by downloading a track and analyzing each step. + + Args: + arl_token: Deezer ARL token + track_url: URL of the track to download + output_dir: Directory to save output files + + Returns: + Dict with debugging results + """ + os.makedirs(output_dir, exist_ok=True) + + results = { + "track_url": track_url, + "steps": [], + "success": False, + "output_file": None, + "analysis": None + } + + try: + # Step 1: Initialize DeeLogin + logger.info("Step 1: Initializing DeeLogin") + results["steps"].append({"step": "init", "status": "starting"}) + + deezer = DeeLogin(arl=arl_token) + results["steps"][-1]["status"] = "success" + + # Step 2: Download the track + logger.info(f"Step 2: Downloading track from {track_url}") + results["steps"].append({"step": "download", "status": "starting"}) + + download_result = deezer.download_trackdee( + track_url, + output_dir=output_dir, + quality_download="FLAC", + recursive_quality=True, + recursive_download=True + ) + + if not download_result.success: + results["steps"][-1]["status"] = "failed" + results["steps"][-1]["error"] = "Download failed" + return results + + results["steps"][-1]["status"] = "success" + results["output_file"] = download_result.song_path + logger.info(f"Downloaded file to: {download_result.song_path}") + + # Step 3: Analyze the downloaded file + logger.info("Step 3: Analyzing downloaded FLAC file") + results["steps"].append({"step": "analyze", "status": "starting"}) + + analysis = analyze_flac_file(download_result.song_path) + results["analysis"] = analysis + + if analysis.get("has_flac_signature", False) and not analysis.get("potential_issues"): + results["steps"][-1]["status"] = "success" + results["success"] = True + logger.info("FLAC analysis completed successfully - file appears valid") + else: + results["steps"][-1]["status"] = "warning" + issues = analysis.get("potential_issues", []) + results["steps"][-1]["issues"] = issues + logger.warning(f"FLAC analysis found potential issues: {issues}") + + # Save detailed analysis to a JSON file + analysis_file = os.path.join(output_dir, "flac_analysis.json") + with open(analysis_file, 'w') as f: + json.dump(analysis, f, indent=2) + logger.info(f"Saved detailed analysis to {analysis_file}") + + return results + + except BadCredentials: + logger.error("Invalid ARL token") + results["steps"].append({"step": "error", "status": "failed", "error": "Invalid ARL token"}) + return results + except TrackNotFound: + logger.error(f"Track not found at URL: {track_url}") + results["steps"].append({"step": "error", "status": "failed", "error": "Track not found"}) + return results + except Exception as e: + logger.error(f"Error during debugging: {str(e)}", exc_info=True) + results["steps"].append({"step": "error", "status": "failed", "error": str(e)}) + return results + +def main(): + parser = argparse.ArgumentParser(description="Debug FLAC decryption issues") + parser.add_argument("--arl", help="Deezer ARL token") + parser.add_argument("--track", help="Deezer track URL", default="https://www.deezer.com/us/track/2306672155") + parser.add_argument("--output-dir", help="Output directory", default="debug_output") + + args = parser.parse_args() + + # Check for ARL token + arl_token = args.arl or os.environ.get("DEEZER_ARL") + if not arl_token: + print("Error: Deezer ARL token not provided") + print("Please provide with --arl or set the DEEZER_ARL environment variable") + return 1 + + # Run the debugging + print(f"Starting FLAC decryption debugging for track: {args.track}") + results = debug_flac_decryption(arl_token, args.track, args.output_dir) + + # Print summary + print("\n===== Debugging Summary =====") + for step in results["steps"]: + status_icon = "✅" if step["status"] == "success" else "⚠️" if step["status"] == "warning" else "❌" + print(f"{status_icon} {step['step'].capitalize()}: {step['status'].upper()}") + + if step["status"] == "failed" and "error" in step: + print(f" Error: {step['error']}") + elif step["status"] == "warning" and "issues" in step: + for issue in step["issues"]: + print(f" Issue: {issue}") + + if results["success"]: + print("\n✅ FLAC file appears to be valid!") + if results["output_file"]: + print(f"Output file: {results['output_file']}") + return 0 + else: + print("\n❌ FLAC decryption had issues") + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/deezspot/__init__.py b/deezspot/__init__.py new file mode 100644 index 0000000..6492a17 --- /dev/null +++ b/deezspot/__init__.py @@ -0,0 +1,40 @@ +#!/usr/bin/python3 + +""" +Deezspot - A Deezer/Spotify downloading library with proper logging support. +""" + +import logging +from deezspot.libutils.logging_utils import configure_logger, logger + +# Export key functionality +from deezspot.deezloader import DeeLogin +from deezspot.models import Track, Album, Playlist, Smart, Episode + +__version__ = "1.0.1" + +# Configure default logging (silent by default) +configure_logger(level=logging.WARNING, to_console=False) + +def set_log_level(level): + """ + Set the logging level for the deezspot library. + + Args: + level: Logging level (e.g., logging.INFO, logging.DEBUG, logging.WARNING) + """ + configure_logger(level=level, to_console=True) + +def disable_logging(): + """Disable all logging output.""" + configure_logger(level=logging.CRITICAL, to_console=False) + +def enable_file_logging(filepath, level=logging.INFO): + """ + Enable logging to a file. + + Args: + filepath: Path to the log file + level: Logging level (defaults to INFO) + """ + configure_logger(level=level, to_file=filepath, to_console=True) diff --git a/deezspot/__taggers__.py b/deezspot/__taggers__.py new file mode 100644 index 0000000..e1a9ae0 --- /dev/null +++ b/deezspot/__taggers__.py @@ -0,0 +1,315 @@ +#!/usr/bin/python3 + +from base64 import b64encode +from mutagen.flac import FLAC, Picture +from mutagen.oggvorbis import OggVorbis +from deezspot.models import Track, Episode +import requests + +def request(url): + response = requests.get(url) + response.raise_for_status() + return response + +from mutagen.id3 import ( + ID3NoHeaderError, + ID3, APIC, USLT, SYLT, + COMM, TSRC, TRCK, TIT2, + TLEN, TEXT, TCON, TALB, TBPM, + TPE1, TYER, TDAT, TPOS, TPE2, + TPUB, TCOP, TXXX, TCOM, IPLS +) + +def __write_flac(song, data): + tag = FLAC(song) + tag.delete() + images = Picture() + images.type = 3 + images.mime = 'image/jpeg' + images.data = data['image'] + tag.clear_pictures() + tag.add_picture(images) + tag['lyrics'] = data['lyric'] + tag['artist'] = data['artist'] + tag['title'] = data['music'] + tag['date'] = f"{data['year'].year}/{data['year'].month}/{data['year'].day}" + tag['album'] = data['album'] + tag['tracknumber'] = f"{data['tracknum']}" + tag['discnumber'] = f"{data['discnum']}" + tag['genre'] = data['genre'] + tag['albumartist'] = data['ar_album'] + tag['author'] = data['author'] + tag['composer'] = data['composer'] + tag['copyright'] = data['copyright'] + tag['bpm'] = f"{data['bpm']}" + tag['length'] = f"{int(data['duration'] * 1000)}" + tag['organization'] = data['label'] + tag['isrc'] = data['isrc'] + tag['lyricist'] = data['lyricist'] + tag['version'] = data['version'] + tag.save() + + +def __write_mp3(song, data): + try: + audio = ID3(song) + audio.delete() + except ID3NoHeaderError: + audio = ID3() + + audio.add( + APIC( + mime = "image/jpeg", + type = 3, + desc = "album front cover", + data = data['image'] + ) + ) + + audio.add( + COMM( + lang = "eng", + desc = "my comment", + text = "DO NOT USE FOR YOUR OWN EARNING" + ) + ) + + audio.add( + USLT( + text = data['lyric'] + ) + ) + + audio.add( + SYLT( + type = 1, + format = 2, + desc = "sync lyric song", + text = data['lyric_sync'] + ) + ) + + audio.add( + TSRC( + text = data['isrc'] + ) + ) + + audio.add( + TRCK( + text = f"{data['tracknum']}/{data['nb_tracks']}" + ) + ) + + audio.add( + TIT2( + text = data['music'] + ) + ) + + audio.add( + TLEN( + text = f"{data['duration']}" + ) + ) + + audio.add( + TEXT( + text = data['lyricist'] + ) + ) + + audio.add( + TCON( + text = data['genre'] + ) + ) + + audio.add( + TALB( + text = data['album'] + ) + ) + + audio.add( + TBPM( + text = f"{data['bpm']}" + ) + ) + + audio.add( + TPE1( + text = data['artist'] + ) + ) + + audio.add( + TYER( + text = f"{data['year'].year}" + ) + ) + + audio.add( + TDAT( + text = f"{data['year'].day}{data['year'].month}" + ) + ) + + audio.add( + TPOS( + text = f"{data['discnum']}/{data['discnum']}" + ) + ) + + audio.add( + TPE2( + text = data['ar_album'] + ) + ) + + audio.add( + TPUB( + text = data['label'] + ) + ) + + audio.add( + TCOP( + text = data['copyright'] + ) + ) + + audio.add( + TXXX( + desc = "REPLAYGAIN_TRACK_GAIN", + text = f"{data['gain']}" + ) + ) + + audio.add( + TCOM( + text = data['composer'] + ) + ) + + audio.add( + IPLS( + people = [ + data['author'] + ] + ) + ) + + audio.save(song, v2_version = 3) + +def __write_ogg(song, song_metadata): + audio = OggVorbis(song) + audio.delete() + + # Standard Vorbis comment fields mapping + field_mapping = { + 'music': 'title', + 'artist': 'artist', + 'album': 'album', + 'tracknum': 'tracknumber', + 'discnum': 'discnumber', + 'year': 'date', + 'genre': 'genre', + 'isrc': 'isrc', + 'description': 'description', + 'ar_album': 'albumartist', + 'composer': 'composer', + 'copyright': 'copyright', + 'bpm': 'bpm', + 'lyricist': 'lyricist', + 'version': 'version' + } + + # Add standard text metadata + for source_key, vorbis_key in field_mapping.items(): + if source_key in song_metadata: + value = song_metadata[source_key] + + # Special handling for date field + if vorbis_key == 'date': + # Convert datetime object to YYYY-MM-DD string format + if hasattr(value, 'strftime'): + value = value.strftime('%Y-%m-%d') + # Handle string timestamps if necessary + elif isinstance(value, str) and ' ' in value: + value = value.split()[0] + + # Skip "Unknown" BPM values or other non-numeric BPM values + if vorbis_key == 'bpm' and (value == "Unknown" or not isinstance(value, (int, float)) and not str(value).isdigit()): + continue + + audio[vorbis_key] = [str(value)] + + # Add lyrics if present + if 'lyric' in song_metadata: + audio['lyrics'] = [str(song_metadata['lyric'])] + + # Handle cover art + if 'image' in song_metadata: + try: + image = Picture() + image.type = 3 # Front cover + image.mime = 'image/jpeg' + image.desc = 'Cover' + + if isinstance(song_metadata['image'], bytes): + image.data = song_metadata['image'] + else: + image.data = request(song_metadata['image']).content + + # Encode using base64 as required by Vorbis spec + audio['metadata_block_picture'] = [ + b64encode(image.write()).decode('utf-8') + ] + except Exception as e: + print(f"Error adding cover art: {e}") + + # Additional validation for numeric fields - exclude BPM since we already handled it + numeric_fields = ['tracknumber', 'discnumber'] + for field in numeric_fields: + if field in audio: + try: + int(audio[field][0]) + except ValueError: + print(f"Warning: Invalid numeric value for {field}") + del audio[field] + + audio.save() + +def write_tags(media): + if isinstance(media, Track): + song = media.song_path + elif isinstance(media, Episode): + song = media.episode_path + else: + raise ValueError("Unsupported media type") + + song_metadata = media.tags + f_format = media.file_format + + if f_format == ".flac": + __write_flac(song, song_metadata) + elif f_format == ".ogg": + __write_ogg(song, song_metadata) + else: + __write_mp3(song, song_metadata) + +def check_track(media): + if isinstance(media, Track): + song = media.song_path + elif isinstance(media, Episode): + song = media.episode_path + else: + raise ValueError("Unsupported media type") + + f_format = media.file_format + is_ok = False + + # Add your logic to check the track/episode here + + return is_ok \ No newline at end of file diff --git a/deezspot/deezloader/__download__.py b/deezspot/deezloader/__download__.py new file mode 100644 index 0000000..fb2b374 --- /dev/null +++ b/deezspot/deezloader/__download__.py @@ -0,0 +1,1346 @@ +#!/usr/bin/python3 +import os +import json +import re +from os.path import isfile +from copy import deepcopy +from deezspot.libutils.audio_converter import convert_audio, parse_format_string +from deezspot.deezloader.dee_api import API +from deezspot.deezloader.deegw_api import API_GW +from deezspot.deezloader.deezer_settings import qualities +from deezspot.libutils.others_settings import answers +from deezspot.__taggers__ import write_tags, check_track +from deezspot.deezloader.__download_utils__ import decryptfile, gen_song_hash +from deezspot.exceptions import ( + TrackNotFound, + NoRightOnMedia, + QualityNotFound, +) +from deezspot.models import ( + Track, + Album, + Playlist, + Preferences, + Episode, +) +from deezspot.deezloader.__utils__ import ( + check_track_ids, + check_track_token, + check_track_md5, +) +from deezspot.libutils.utils import ( + set_path, + trasform_sync_lyric, + create_zip, +) +from mutagen.flac import FLAC +from mutagen.mp3 import MP3 +from mutagen.id3 import ID3 +from mutagen.mp4 import MP4 +from mutagen import File +from deezspot.libutils.logging_utils import logger, ProgressReporter + +class Download_JOB: + progress_reporter = None + + @classmethod + def set_progress_reporter(cls, reporter): + cls.progress_reporter = reporter + + @classmethod + def report_progress(cls, progress_data): + """Report progress if a reporter is configured.""" + if cls.progress_reporter: + cls.progress_reporter.report(progress_data) + else: + # Fallback to logger if no reporter is configured + logger.info(json.dumps(progress_data)) + + @classmethod + def __get_url(cls, c_track: Track, quality_download: str) -> dict: + if c_track.get('__TYPE__') == 'episode': + return { + "media": [{ + "sources": [{ + "url": c_track.get('EPISODE_DIRECT_STREAM_URL') + }] + }] + } + else: + # Get track IDs and check which encryption method is available + track_info = check_track_ids(c_track) + encryption_type = track_info.get('encryption_type', 'blowfish') + + # If AES encryption is available (MEDIA_KEY and MEDIA_NONCE present) + if encryption_type == 'aes': + # Use track token to get media URL from API + track_token = check_track_token(c_track) + medias = API_GW.get_medias_url([track_token], quality_download) + return medias[0] + + # Use Blowfish encryption (legacy method) + else: + md5_origin = track_info.get('md5_origin') + media_version = track_info.get('media_version', '1') + track_id = track_info.get('track_id') + + if not md5_origin: + raise ValueError("MD5_ORIGIN is missing") + if not track_id: + raise ValueError("Track ID is missing") + + n_quality = qualities[quality_download]['n_quality'] + + # Create the song hash using the correct parameter order + # Note: For legacy Deezer API, the order is: MD5 + Media Version + Track ID + c_song_hash = gen_song_hash(track_id, md5_origin, media_version) + + # Log the hash generation parameters for debugging + logger.debug(f"Generating song hash with: track_id={track_id}, md5_origin={md5_origin}, media_version={media_version}") + + c_media_url = API_GW.get_song_url(md5_origin[0], c_song_hash) + + return { + "media": [ + { + "sources": [ + { + "url": c_media_url + } + ] + } + ] + } + + @classmethod + def check_sources( + cls, + infos_dw: list, + quality_download: str + ) -> list: + # Preprocess episodes separately + medias = [] + for track in infos_dw: + if track.get('__TYPE__') == 'episode': + media_json = cls.__get_url(track, quality_download) + medias.append(media_json) + + # For non-episodes, gather tokens + non_episode_tracks = [c_track for c_track in infos_dw if c_track.get('__TYPE__') != 'episode'] + tokens = [check_track_token(c_track) for c_track in non_episode_tracks] + + def chunk_list(lst, chunk_size): + """Yield successive chunk_size chunks from lst.""" + for i in range(0, len(lst), chunk_size): + yield lst[i:i + chunk_size] + + # Prepare list for media results for non-episodes + non_episode_medias = [] + + # Split tokens into chunks of 25 + for tokens_chunk in chunk_list(tokens, 25): + try: + chunk_medias = API_GW.get_medias_url(tokens_chunk, quality_download) + # Post-process each returned media in the chunk + for idx in range(len(chunk_medias)): + if "errors" in chunk_medias[idx]: + c_media_json = cls.__get_url(non_episode_tracks[len(non_episode_medias) + idx], quality_download) + chunk_medias[idx] = c_media_json + else: + if not chunk_medias[idx]['media']: + c_media_json = cls.__get_url(non_episode_tracks[len(non_episode_medias) + idx], quality_download) + chunk_medias[idx] = c_media_json + elif len(chunk_medias[idx]['media'][0]['sources']) == 1: + c_media_json = cls.__get_url(non_episode_tracks[len(non_episode_medias) + idx], quality_download) + chunk_medias[idx] = c_media_json + non_episode_medias.extend(chunk_medias) + except NoRightOnMedia: + for c_track in tokens_chunk: + # Find the corresponding full track info from non_episode_tracks + track_index = len(non_episode_medias) + c_media_json = cls.__get_url(non_episode_tracks[track_index], quality_download) + non_episode_medias.append(c_media_json) + + # Now, merge the medias. We need to preserve the original order. + # We'll create a final list that contains media for each track in infos_dw. + final_medias = [] + episode_idx = 0 + non_episode_idx = 0 + for track in infos_dw: + if track.get('__TYPE__') == 'episode': + final_medias.append(medias[episode_idx]) + episode_idx += 1 + else: + final_medias.append(non_episode_medias[non_episode_idx]) + non_episode_idx += 1 + + return final_medias + +class EASY_DW: + def __init__( + self, + infos_dw: dict, + preferences: Preferences, + parent: str = None # Can be 'album', 'playlist', or None for individual track + ) -> None: + + self.__preferences = preferences + self.__parent = parent # Store the parent type + + self.__infos_dw = infos_dw + self.__ids = preferences.ids + self.__link = preferences.link + self.__output_dir = preferences.output_dir + self.__method_save = preferences.method_save + self.__not_interface = preferences.not_interface + self.__quality_download = preferences.quality_download + self.__recursive_quality = preferences.recursive_quality + self.__recursive_download = preferences.recursive_download + self.__convert_to = getattr(preferences, 'convert_to', None) + + + if self.__infos_dw.get('__TYPE__') == 'episode': + self.__song_metadata = { + 'music': self.__infos_dw.get('EPISODE_TITLE', ''), + 'artist': self.__infos_dw.get('SHOW_NAME', ''), + 'album': self.__infos_dw.get('SHOW_NAME', ''), + 'date': self.__infos_dw.get('EPISODE_PUBLISHED_TIMESTAMP', '').split()[0], + 'genre': 'Podcast', + 'explicit': self.__infos_dw.get('SHOW_IS_EXPLICIT', '2'), + 'disc': 1, + 'track': 1, + 'duration': int(self.__infos_dw.get('DURATION', 0)), + 'isrc': None + } + self.__download_type = "episode" + else: + self.__song_metadata = preferences.song_metadata + self.__download_type = "track" + + self.__c_quality = qualities[self.__quality_download] + self.__fallback_ids = self.__ids + + self.__set_quality() + self.__write_track() + + def __track_already_exists(self, title, album): + # Ensure the song path is set; if not, compute it. + if not hasattr(self, '_EASY_DW__song_path') or not self.__song_path: + self.__set_song_path() + + # Get only the final directory where the track will be saved. + final_dir = os.path.dirname(self.__song_path) + if not os.path.exists(final_dir): + return False + + # List files only in the final directory. + for file in os.listdir(final_dir): + file_path = os.path.join(final_dir, file) + lower_file = file.lower() + try: + existing_title = None + existing_album = None + if lower_file.endswith('.flac'): + audio = FLAC(file_path) + existing_title = audio.get('title', [None])[0] + existing_album = audio.get('album', [None])[0] + elif lower_file.endswith('.mp3'): + audio = MP3(file_path, ID3=ID3) + existing_title = audio.get('TIT2', [None])[0] + existing_album = audio.get('TALB', [None])[0] + elif lower_file.endswith('.m4a'): + audio = MP4(file_path) + existing_title = audio.get('\xa9nam', [None])[0] + existing_album = audio.get('\xa9alb', [None])[0] + elif lower_file.endswith(('.ogg', '.wav')): + audio = File(file_path) + existing_title = audio.get('title', [None])[0] + existing_album = audio.get('album', [None])[0] + if existing_title == title and existing_album == album: + return True + except Exception: + continue + return False + + def __set_quality(self) -> None: + self.__file_format = self.__c_quality['f_format'] + self.__song_quality = self.__c_quality['s_quality'] + + def __set_song_path(self) -> None: + # If the Preferences object has custom formatting strings, pass them on. + custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None) + custom_track_format = getattr(self.__preferences, 'custom_track_format', None) + pad_tracks = getattr(self.__preferences, 'pad_tracks', True) + self.__song_path = set_path( + self.__song_metadata, + self.__output_dir, + self.__song_quality, + self.__file_format, + self.__method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks + ) + + def __set_episode_path(self) -> None: + custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None) + custom_track_format = getattr(self.__preferences, 'custom_track_format', None) + pad_tracks = getattr(self.__preferences, 'pad_tracks', True) + self.__song_path = set_path( + self.__song_metadata, + self.__output_dir, + self.__song_quality, + self.__file_format, + self.__method_save, + is_episode=True, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks + ) + + def __write_track(self) -> None: + self.__set_song_path() + + self.__c_track = Track( + self.__song_metadata, self.__song_path, + self.__file_format, self.__song_quality, + self.__link, self.__ids + ) + + self.__c_track.set_fallback_ids(self.__fallback_ids) + + def __write_episode(self) -> None: + self.__set_episode_path() + + self.__c_episode = Episode( + self.__song_metadata, self.__song_path, + self.__file_format, self.__song_quality, + self.__link, self.__ids + ) + + self.__c_episode.md5_image = self.__ids + self.__c_episode.set_fallback_ids(self.__fallback_ids) + + def easy_dw(self) -> Track: + if self.__infos_dw.get('__TYPE__') == 'episode': + pic = self.__infos_dw.get('EPISODE_IMAGE_MD5', '') + else: + pic = self.__infos_dw['ALB_PICTURE'] + image = API.choose_img(pic) + self.__song_metadata['image'] = image + song = f"{self.__song_metadata['music']} - {self.__song_metadata['artist']}" + + # Check if track already exists based on metadata + current_title = self.__song_metadata['music'] + current_album = self.__song_metadata['album'] + if self.__track_already_exists(current_title, current_album): + # Create skipped progress report using the new required format + progress_data = { + "type": "track", + "song": current_title, + "artist": self.__song_metadata['artist'], + "status": "skipped", + "url": self.__link, + "reason": "Track already exists", + "convert_to": self.__convert_to + } + + # Add parent info based on parent type + if self.__parent == "playlist" and hasattr(self.__preferences, "json_data"): + playlist_data = self.__preferences.json_data + playlist_name = playlist_data.get('title', 'unknown') + total_tracks = getattr(self.__preferences, 'total_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + # Format for playlist-parented tracks exactly as required + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "playlist", + "name": playlist_name, + "owner": playlist_data.get('creator', {}).get('name', 'unknown'), + "total_tracks": total_tracks, + "url": f"https://deezer.com/playlist/{self.__preferences.json_data.get('id', '')}" + } + }) + elif self.__parent == "album": + album_name = self.__song_metadata.get('album', '') + album_artist = self.__song_metadata.get('album_artist', self.__song_metadata.get('album_artist', '')) + total_tracks = getattr(self.__preferences, 'total_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + # Format for album-parented tracks exactly as required + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "album", + "title": album_name, + "artist": album_artist, + "total_tracks": total_tracks, + "url": f"https://deezer.com/album/{self.__preferences.song_metadata.get('album_id', '')}" + } + }) + + Download_JOB.report_progress(progress_data) + # self.__c_track might not be fully initialized here if __write_track() hasn't been called + # Create a minimal track object for skipped scenario + skipped_item = Track( + self.__song_metadata, + self.__song_path, # song_path would be set if __write_track was called + self.__file_format, self.__song_quality, + self.__link, self.__ids + ) + skipped_item.success = False + skipped_item.was_skipped = True + # It's important that this skipped_item is what's checked later, or self.__c_track is updated + self.__c_track = skipped_item # Ensure self.__c_track reflects this skipped state + return self.__c_track # Return the correctly flagged skipped track + + # Initialize success to False for the item being processed + if self.__infos_dw.get('__TYPE__') == 'episode': + if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: + self.__c_episode.success = False + else: + if hasattr(self, '_EASY_DW__c_track') and self.__c_track: + self.__c_track.success = False + + try: + if self.__infos_dw.get('__TYPE__') == 'episode': + # download_episode_try should set self.__c_episode.success = True if successful + self.download_episode_try() # This will modify self.__c_episode directly + else: + # download_try should set self.__c_track.success = True if successful + self.download_try() # This will modify self.__c_track directly + + # Create done status report using the new required format (only if download_try didn't fail) + # This part should only execute if download_try itself was successful (i.e., no exception) + if self.__c_track.success : # Check if download_try marked it as successful + progress_data = { + "type": "track", + "song": self.__song_metadata['music'], + "artist": self.__song_metadata['artist'], + "status": "done", + "convert_to": self.__convert_to + } + spotify_url = getattr(self.__preferences, 'spotify_url', None) + progress_data["url"] = spotify_url if spotify_url else self.__link + if self.__parent == "playlist" and hasattr(self.__preferences, "json_data"): + playlist_data = self.__preferences.json_data + # ... (rest of playlist parent data) ... + progress_data.update({ + "current_track": getattr(self.__preferences, 'track_number', 0), + "total_tracks": getattr(self.__preferences, 'total_tracks', 0), + "parent": { + "type": "playlist", + "name": playlist_data.get('title', 'unknown'), + "owner": playlist_data.get('creator', {}).get('name', 'unknown') + } + }) + elif self.__parent == "album": + album_name = self.__song_metadata.get('album', '') + album_artist = self.__song_metadata.get('album_artist', self.__song_metadata.get('album_artist', '')) + total_tracks = getattr(self.__preferences, 'total_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "album", + "title": album_name, + "artist": album_artist, + "total_tracks": total_tracks, + "url": f"https://deezer.com/album/{self.__preferences.song_metadata.get('album_id', '')}" + } + }) + Download_JOB.report_progress(progress_data) + + except Exception as e: # Covers failures within download_try or download_episode_try + item_type = "Episode" if self.__infos_dw.get('__TYPE__') == 'episode' else "Track" + item_name = self.__song_metadata.get('music', f'Unknown {item_type}') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + error_message = f"Download process failed for {item_type.lower()} '{item_name}' by '{artist_name}' (URL: {self.__link}). Error: {str(e)}" + logger.error(error_message) + + current_item_obj = self.__c_episode if self.__infos_dw.get('__TYPE__') == 'episode' else self.__c_track + if current_item_obj: + current_item_obj.success = False + current_item_obj.error_message = error_message + raise TrackNotFound(message=error_message, url=self.__link) from e + + # --- Handling after download attempt --- + + current_item = self.__c_episode if self.__infos_dw.get('__TYPE__') == 'episode' else self.__c_track + item_type_str = "episode" if self.__infos_dw.get('__TYPE__') == 'episode' else "track" + + # If the item was skipped (e.g. file already exists), return it immediately. + if getattr(current_item, 'was_skipped', False): + return current_item + + # Final check for non-skipped items that might have failed. + if not current_item.success: + item_name = self.__song_metadata.get('music', f'Unknown {item_type_str.capitalize()}') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + original_error_msg = getattr(current_item, 'error_message', f"Download failed for an unspecified reason after {item_type_str} processing attempt.") + error_msg_template = "Cannot download {type} '{title}' by '{artist}'. Reason: {reason}" + final_error_msg = error_msg_template.format(type=item_type_str, title=item_name, artist=artist_name, reason=original_error_msg) + current_link_attr = current_item.link if hasattr(current_item, 'link') and current_item.link else self.__link + logger.error(f"{final_error_msg} (URL: {current_link_attr})") + current_item.error_message = final_error_msg + raise TrackNotFound(message=final_error_msg, url=current_link_attr) + + # If we reach here, the item should be successful and not skipped. + if current_item.success: + if self.__infos_dw.get('__TYPE__') != 'episode': # Assuming pic is for tracks + current_item.md5_image = pic # Set md5_image for tracks + write_tags(current_item) + + return current_item + + def download_try(self) -> Track: + # Pre-check: if FLAC is requested but filesize is zero, fallback to MP3. + if self.__file_format == '.flac': + filesize_str = self.__infos_dw.get('FILESIZE_FLAC', '0') + try: + filesize = int(filesize_str) + except ValueError: + filesize = 0 + + if filesize == 0: + song = self.__song_metadata['music'] + artist = self.__song_metadata['artist'] + # Switch quality settings to MP3_320. + self.__quality_download = 'MP3_320' + self.__file_format = '.mp3' + self.__song_path = self.__song_path.rsplit('.', 1)[0] + '.mp3' + media = Download_JOB.check_sources([self.__infos_dw], 'MP3_320') + if media: + self.__infos_dw['media_url'] = media[0] + else: + raise TrackNotFound(f"Track {song} - {artist} not available in MP3 format after FLAC attempt failed (filesize was 0).") + + # Continue with the normal download process. + try: + media_list = self.__infos_dw['media_url']['media'] + song_link = media_list[0]['sources'][0]['url'] + + try: + crypted_audio = API_GW.song_exist(song_link) + except TrackNotFound: + song = self.__song_metadata['music'] + artist = self.__song_metadata['artist'] + + if self.__file_format == '.flac': + logger.warning(f"\n⚠ {song} - {artist} is not available in FLAC format. Trying MP3...") + self.__quality_download = 'MP3_320' + self.__file_format = '.mp3' + self.__song_path = self.__song_path.rsplit('.', 1)[0] + '.mp3' + + media = Download_JOB.check_sources( + [self.__infos_dw], 'MP3_320' + ) + if media: + self.__infos_dw['media_url'] = media[0] + song_link = media[0]['media'][0]['sources'][0]['url'] + crypted_audio = API_GW.song_exist(song_link) + else: + raise TrackNotFound(f"Track {song} - {artist} not available in MP3 after FLAC attempt failed (media not found for MP3).") + else: + if not self.__recursive_quality: + # msg was not defined, provide a more specific message + raise QualityNotFound(f"Quality {self.__quality_download} not found for {song} - {artist} and recursive quality search is disabled.") + for c_quality in qualities: + if self.__quality_download == c_quality: + continue + media = Download_JOB.check_sources( + [self.__infos_dw], c_quality + ) + if media: + self.__infos_dw['media_url'] = media[0] + song_link = media[0]['media'][0]['sources'][0]['url'] + try: + crypted_audio = API_GW.song_exist(song_link) + self.__c_quality = qualities[c_quality] + self.__set_quality() + break + except TrackNotFound: + if c_quality == "MP3_128": + raise TrackNotFound(f"Error with {song} - {artist}. All available qualities failed, last attempt was {c_quality}. Link: {self.__link}") + continue + + c_crypted_audio = crypted_audio.iter_content(2048) + + # Get track IDs and encryption information + # The enhanced check_track_ids function will determine the encryption type + self.__fallback_ids = check_track_ids(self.__infos_dw) + encryption_type = self.__fallback_ids.get('encryption_type', 'unknown') + logger.debug(f"Using encryption type: {encryption_type}") + + try: + self.__write_track() + + # Send immediate progress status for the track + progress_data = { + "type": "track", + "song": self.__song_metadata.get("music", ""), + "artist": self.__song_metadata.get("artist", ""), + "status": "progress" + } + + # Use Spotify URL if available, otherwise use Deezer link + spotify_url = getattr(self.__preferences, 'spotify_url', None) + progress_data["url"] = spotify_url if spotify_url else self.__link + + # Add parent info if present + if self.__parent == "playlist" and hasattr(self.__preferences, "json_data"): + playlist_data = self.__preferences.json_data + playlist_name = playlist_data.get('title', 'unknown') + total_tracks = getattr(self.__preferences, 'total_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "playlist", + "name": playlist_name, + "owner": playlist_data.get('creator', {}).get('name', 'unknown'), + "total_tracks": total_tracks, + "url": f"https://deezer.com/playlist/{self.__preferences.json_data.get('id', '')}" + } + }) + elif self.__parent == "album": + album_name = self.__song_metadata.get('album', '') + album_artist = self.__song_metadata.get('album_artist', self.__song_metadata.get('album_artist', '')) + total_tracks = getattr(self.__preferences, 'total_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "album", + "title": album_name, + "artist": album_artist, + "total_tracks": total_tracks, + "url": f"https://deezer.com/album/{self.__preferences.song_metadata.get('album_id', '')}" + } + }) + + Download_JOB.report_progress(progress_data) + + try: + # Decrypt the file using the utility function + decryptfile(c_crypted_audio, self.__fallback_ids, self.__song_path) + logger.debug(f"Successfully decrypted track using {encryption_type} encryption") + except Exception as decrypt_error: + # Detailed error logging for debugging + logger.error(f"Decryption error ({encryption_type}): {str(decrypt_error)}") + if "Data must be padded" in str(decrypt_error): + logger.error("This appears to be a padding issue with Blowfish decryption") + raise + + self.__add_more_tags() + + # Apply audio conversion if requested + if self.__convert_to: + format_name, bitrate = parse_format_string(self.__convert_to) + if format_name: + # Register and unregister functions for tracking downloads + from deezspot.deezloader.__download__ import register_active_download, unregister_active_download + try: + # Update the path with the converted file path + converted_path = convert_audio( + self.__song_path, + format_name, + bitrate, + register_active_download, + unregister_active_download + ) + if converted_path != self.__song_path: + # Update path in track object if conversion happened + self.__song_path = converted_path + self.__c_track.song_path = converted_path + except Exception as conv_error: + # Log conversion error but continue with original file + logger.error(f"Audio conversion error: {str(conv_error)}") + + # Write tags to the final file (original or converted) + write_tags(self.__c_track) + except Exception as e: + if isfile(self.__song_path): + os.remove(self.__song_path) + + # Improve error message formatting + error_msg = str(e) + if "Data must be padded" in error_msg: + error_msg = "Decryption error (padding issue) - Try a different quality setting or download format" + elif isinstance(e, ConnectionError) or "Connection" in error_msg: + error_msg = "Connection error - Check your internet connection" + elif "timeout" in error_msg.lower(): + error_msg = "Request timed out - Server may be busy" + elif "403" in error_msg or "Forbidden" in error_msg: + error_msg = "Access denied - Track might be region-restricted or premium-only" + elif "404" in error_msg or "Not Found" in error_msg: + error_msg = "Track not found - It might have been removed" + + # Create formatted error report + progress_data = { + "type": "track", + "status": "error", + "song": self.__song_metadata.get('music', ''), + "artist": self.__song_metadata.get('artist', ''), + "error": error_msg, + "url": getattr(self.__preferences, 'spotify_url', None) or self.__link, + "convert_to": self.__convert_to + } + + # Add parent info based on parent type + if self.__parent == "playlist" and hasattr(self.__preferences, "json_data"): + playlist_data = self.__preferences.json_data + playlist_name = playlist_data.get('title', 'unknown') + total_tracks = getattr(self.__preferences, 'total_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "playlist", + "name": playlist_name, + "owner": playlist_data.get('creator', {}).get('name', 'unknown'), + "total_tracks": total_tracks, + "url": f"https://deezer.com/playlist/{playlist_data.get('id', '')}" + } + }) + elif self.__parent == "album": + album_name = self.__song_metadata.get('album', '') + album_artist = self.__song_metadata.get('album_artist', self.__song_metadata.get('album_artist', '')) + total_tracks = getattr(self.__preferences, 'total_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "album", + "title": album_name, + "artist": album_artist, + "total_tracks": total_tracks, + "url": f"https://deezer.com/album/{self.__preferences.song_metadata.get('album_id', '')}" + } + }) + + # Report the error + Download_JOB.report_progress(progress_data) + logger.error(f"Failed to process track: {error_msg}") + + # Still raise the exception to maintain original flow + # Add the original exception e to the message for more context + self.__c_track.success = False # Mark as failed + self.__c_track.error_message = error_msg # Store the refined error message + raise TrackNotFound(f"Failed to process {self.__song_path}. Error: {error_msg}. Original Exception: {str(e)}") + + # If download and processing (like decryption, tagging) were successful before conversion + if not self.__convert_to: # Or if conversion was successful + self.__c_track.success = True + + return self.__c_track + + except Exception as e: + # Add more context to this exception + song_title = self.__song_metadata.get('music', 'Unknown Song') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + error_message = f"Download failed for '{song_title}' by '{artist_name}' (Link: {self.__link}). Error: {str(e)}" + logger.error(error_message) + # Store error on track object if possible + if hasattr(self, '_EASY_DW__c_track') and self.__c_track: + self.__c_track.success = False + self.__c_track.error_message = str(e) + raise TrackNotFound(message=error_message, url=self.__link) from e + + def download_episode_try(self) -> Episode: + try: + direct_url = self.__infos_dw.get('EPISODE_DIRECT_STREAM_URL') + if not direct_url: + raise TrackNotFound("No direct stream URL found") + + os.makedirs(os.path.dirname(self.__song_path), exist_ok=True) + + response = requests.get(direct_url, stream=True) + response.raise_for_status() + + content_length = response.headers.get('content-length') + total_size = int(content_length) if content_length else None + + downloaded = 0 + with open(self.__song_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + size = f.write(chunk) + downloaded += size + + # Download progress reporting could be added here + + # Build episode progress report + progress_data = { + "type": "episode", + "song": self.__song_metadata.get('music', 'Unknown Episode'), + "artist": self.__song_metadata.get('artist', 'Unknown Show'), + "status": "done" + } + + # Use Spotify URL if available (for downloadspo functions), otherwise use Deezer link + spotify_url = getattr(self.__preferences, 'spotify_url', None) + progress_data["url"] = spotify_url if spotify_url else self.__link + + Download_JOB.report_progress(progress_data) + + self.__c_track.success = True + self.__write_episode() + write_tags(self.__c_track) + + return self.__c_track + + except Exception as e: + if isfile(self.__song_path): + os.remove(self.__song_path) + self.__c_track.success = False + episode_title = self.__preferences.song_metadata.get('music', 'Unknown Episode') + err_msg = f"Episode download failed for '{episode_title}' (URL: {self.__link}). Error: {str(e)}" + logger.error(err_msg) + # Store error on track object + self.__c_track.error_message = str(e) + raise TrackNotFound(message=err_msg, url=self.__link) from e + + def __add_more_tags(self) -> None: + contributors = self.__infos_dw.get('SNG_CONTRIBUTORS', {}) + + if "author" in contributors: + self.__song_metadata['author'] = "; ".join( + contributors['author'] + ) + else: + self.__song_metadata['author'] = "" + + if "composer" in contributors: + self.__song_metadata['composer'] = "; ".join( + contributors['composer'] + ) + else: + self.__song_metadata['composer'] = "" + + if "lyricist" in contributors: + self.__song_metadata['lyricist'] = "; ".join( + contributors['lyricist'] + ) + else: + self.__song_metadata['lyricist'] = "" + + if "composerlyricist" in contributors: + self.__song_metadata['composer'] = "; ".join( + contributors['composerlyricist'] + ) + else: + self.__song_metadata['composerlyricist'] = "" + + if "version" in self.__infos_dw: + self.__song_metadata['version'] = self.__infos_dw['VERSION'] + else: + self.__song_metadata['version'] = "" + + self.__song_metadata['lyric'] = "" + self.__song_metadata['copyright'] = "" + self.__song_metadata['lyricist'] = "" + self.__song_metadata['lyric_sync'] = [] + + if self.__infos_dw.get('LYRICS_ID', 0) != 0: + need = API_GW.get_lyric(self.__ids) + + if "LYRICS_SYNC_JSON" in need: + self.__song_metadata['lyric_sync'] = trasform_sync_lyric( + need['LYRICS_SYNC_JSON'] + ) + + # This method should only add tags. Error handling for download success/failure + # is managed by easy_dw after calls to download_try/download_episode_try. + # No error re-raising or success flag modification here. + # write_tags is called after this in download_try if successful. + +class DW_TRACK: + def __init__( + self, + preferences: Preferences + ) -> None: + + self.__preferences = preferences + self.__ids = self.__preferences.ids + self.__song_metadata = self.__preferences.song_metadata + self.__quality_download = self.__preferences.quality_download + + def dw(self) -> Track: + infos_dw = API_GW.get_song_data(self.__ids) + + media = Download_JOB.check_sources( + [infos_dw], self.__quality_download + ) + + infos_dw['media_url'] = media[0] + + # For individual tracks, parent is None (not part of album or playlist) + track = EASY_DW(infos_dw, self.__preferences, parent=None).easy_dw() + + # Check if track failed but was NOT intentionally skipped + if not track.success and not getattr(track, 'was_skipped', False): + song = f"{self.__song_metadata['music']} - {self.__song_metadata['artist']}" + # Attempt to get the original error message if available from the track object + original_error = getattr(track, 'error_message', "it's not available in this format or an error occurred.") + error_msg = f"Cannot download '{song}'. Reason: {original_error}" + current_link = track.link if hasattr(track, 'link') and track.link else self.__preferences.link + logger.error(f"{error_msg} (Link: {current_link})") + raise TrackNotFound(message=error_msg, url=current_link) + + return track + +class DW_ALBUM: + def __init__( + self, + preferences: Preferences + ) -> None: + + self.__preferences = preferences + self.__ids = self.__preferences.ids + self.__make_zip = self.__preferences.make_zip + self.__output_dir = self.__preferences.output_dir + self.__method_save = self.__preferences.method_save + self.__song_metadata = self.__preferences.song_metadata + self.__not_interface = self.__preferences.not_interface + self.__quality_download = self.__preferences.quality_download + + self.__song_metadata_items = self.__song_metadata.items() + + def dw(self) -> Album: + # Helper function to find most frequent item in a list + def most_frequent(items): + if not items: + return None + return max(set(items), key=items.count) + + # Derive album_artist strictly from the album's API contributors + album_api_contributors = self.__preferences.json_data.get('contributors', []) + derived_album_artist_from_contributors = "Unknown Artist" # Default + + if album_api_contributors: # Check if contributors list is not empty + main_contributor_names = [ + c.get('name') for c in album_api_contributors + if c.get('name') and c.get('role', '').lower() == 'main' + ] + + if main_contributor_names: + derived_album_artist_from_contributors = "; ".join(main_contributor_names) + else: # No 'Main' contributors, try all contributors with a name + all_contributor_names = [ + c.get('name') for c in album_api_contributors if c.get('name') + ] + if all_contributor_names: + derived_album_artist_from_contributors = "; ".join(all_contributor_names) + # If album_api_contributors is empty or no names were found, it remains "Unknown Artist" + + + # Report album initializing status + album_name_for_report = self.__song_metadata.get('album', 'Unknown Album') + total_tracks_for_report = self.__song_metadata.get('nb_tracks', 0) + + Download_JOB.report_progress({ + "type": "album", + "artist": derived_album_artist_from_contributors, + "status": "initializing", + "total_tracks": total_tracks_for_report, + "title": album_name_for_report, + "url": f"https://deezer.com/album/{self.__ids}" + }) + + infos_dw = API_GW.get_album_data(self.__ids)['data'] + + md5_image = infos_dw[0]['ALB_PICTURE'] + image = API.choose_img(md5_image) + self.__song_metadata['image'] = image + + album = Album(self.__ids) + album.image = image + album.md5_image = md5_image + album.nb_tracks = self.__song_metadata['nb_tracks'] + album.album_name = self.__song_metadata['album'] + album.upc = self.__song_metadata['upc'] + tracks = album.tracks + album.tags = self.__song_metadata + + # Get media URLs using the splitting approach + medias = Download_JOB.check_sources( + infos_dw, self.__quality_download + ) + + # The album_artist for tagging individual tracks will be derived_album_artist_from_contributors + + total_tracks = len(infos_dw) + for a in range(total_tracks): + track_number = a + 1 + c_infos_dw = infos_dw[a] + + # Retrieve the contributors info from the API response. + # It might be an empty list. + contributors = c_infos_dw.get('SNG_CONTRIBUTORS', {}) + + # Check if contributors is an empty list. + if isinstance(contributors, list) and not contributors: + # Flag indicating we do NOT have contributors data to process. + has_contributors = False + else: + has_contributors = True + + # If we have contributor data, build the artist and composer strings. + if has_contributors: + main_artist = "; ".join(contributors.get('main_artist', [])) + featuring = "; ".join(contributors.get('featuring', [])) + + artist_parts = [main_artist] + if featuring: + artist_parts.append(f"(feat. {featuring})") + artist_str = " ".join(artist_parts) + composer_str = "; ".join(contributors.get('composer', [])) + + # Build the core track metadata. + # When there is no contributor info, we intentionally leave out the 'artist' + # and 'composer' keys so that the album-level metadata merge will supply them. + c_song_metadata = { + 'music': c_infos_dw.get('SNG_TITLE', 'Unknown'), + 'album': self.__song_metadata['album'], + 'date': c_infos_dw.get('DIGITAL_RELEASE_DATE', ''), + 'genre': self.__song_metadata.get('genre', 'Latin Music'), + 'tracknum': f"{track_number}", + 'discnum': f"{c_infos_dw.get('DISK_NUMBER', 1)}", + 'isrc': c_infos_dw.get('ISRC', ''), + 'album_artist': derived_album_artist_from_contributors, + 'publisher': 'CanZion R', + 'duration': int(c_infos_dw.get('DURATION', 0)), + 'explicit': '1' if c_infos_dw.get('EXPLICIT_LYRICS', '0') == '1' else '0' + } + + # Only add contributor-based metadata if available. + if has_contributors: + c_song_metadata['artist'] = artist_str + c_song_metadata['composer'] = composer_str + + # No progress reporting here - done at the track level + + # Merge album-level metadata (only add fields not already set in c_song_metadata) + for key, item in self.__song_metadata_items: + if key not in c_song_metadata: + if isinstance(item, list): + c_song_metadata[key] = self.__song_metadata[key][a] if len(self.__song_metadata[key]) > a else 'Unknown' + else: + c_song_metadata[key] = self.__song_metadata[key] + + # Continue with the rest of your processing (media handling, download, etc.) + c_infos_dw['media_url'] = medias[a] + c_preferences = deepcopy(self.__preferences) + c_preferences.song_metadata = c_song_metadata.copy() + c_preferences.ids = c_infos_dw['SNG_ID'] + c_preferences.track_number = track_number + + # Add additional information for consistent parent info + c_preferences.song_metadata['album_id'] = self.__ids + c_preferences.song_metadata['total_tracks'] = total_tracks + c_preferences.total_tracks = total_tracks + c_preferences.link = f"https://deezer.com/track/{c_preferences.ids}" + + try: + track = EASY_DW(c_infos_dw, c_preferences, parent='album').download_try() + except TrackNotFound: + try: + song = f"{c_song_metadata['music']} - {c_song_metadata.get('artist', self.__song_metadata['artist'])}" + ids = API.not_found(song, c_song_metadata['music']) + c_infos_dw = API_GW.get_song_data(ids) + c_media = Download_JOB.check_sources([c_infos_dw], self.__quality_download) + c_infos_dw['media_url'] = c_media[0] + track = EASY_DW(c_infos_dw, c_preferences, parent='album').download_try() + except TrackNotFound: + track = Track(c_song_metadata, None, None, None, None, None) + track.success = False + track.error_message = f"Track not found after fallback attempt for: {song}" + logger.warning(f"Track not found: {song} :( Details: {track.error_message}. URL: {c_preferences.link if c_preferences else 'N/A'}") + tracks.append(track) + + if self.__make_zip: + song_quality = tracks[0].quality if tracks else 'Unknown' + # Pass along custom directory format if set + custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None) + zip_name = create_zip( + tracks, + output_dir=self.__output_dir, + song_metadata=self.__song_metadata, + song_quality=song_quality, + method_save=self.__method_save, + custom_dir_format=custom_dir_format + ) + album.zip_path = zip_name + + # Report album done status + album_name = self.__song_metadata.get('album', 'Unknown Album') + total_tracks = self.__song_metadata.get('nb_tracks', 0) + + Download_JOB.report_progress({ + "type": "album", + "artist": derived_album_artist_from_contributors, + "status": "done", + "total_tracks": total_tracks, + "title": album_name, + "url": f"https://deezer.com/album/{self.__ids}" + }) + + return album + +class DW_PLAYLIST: + def __init__( + self, + preferences: Preferences + ) -> None: + + self.__preferences = preferences + self.__ids = self.__preferences.ids + self.__json_data = preferences.json_data + self.__make_zip = self.__preferences.make_zip + self.__output_dir = self.__preferences.output_dir + self.__song_metadata = self.__preferences.song_metadata + self.__quality_download = self.__preferences.quality_download + + def dw(self) -> Playlist: + # Extract playlist metadata for reporting + playlist_name = self.__json_data.get('title', 'Unknown Playlist') + playlist_owner = self.__json_data.get('creator', {}).get('name', 'Unknown Owner') + total_tracks = self.__json_data.get('nb_tracks', 0) + + # Report playlist initializing status + Download_JOB.report_progress({ + "type": "playlist", + "owner": playlist_owner, + "status": "initializing", + "total_tracks": total_tracks, + "name": playlist_name, + "url": f"https://deezer.com/playlist/{self.__ids}" + }) + + # Retrieve playlist data from API + infos_dw = API_GW.get_playlist_data(self.__ids)['data'] + + # Extract playlist metadata - we'll use this in the track-level reporting + playlist_name = self.__json_data['title'] + total_tracks = len(infos_dw) + + playlist = Playlist() + tracks = playlist.tracks + + # --- Prepare the m3u playlist file --- + # m3u file will be placed in output_dir/playlists + playlist_m3u_dir = os.path.join(self.__output_dir, "playlists") + os.makedirs(playlist_m3u_dir, exist_ok=True) + m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name}.m3u") + if not os.path.exists(m3u_path): + with open(m3u_path, "w", encoding="utf-8") as m3u_file: + m3u_file.write("#EXTM3U\n") + # ------------------------------------- + + # Get media URLs for each track in the playlist + medias = Download_JOB.check_sources( + infos_dw, self.__quality_download + ) + + # Process each track + for idx, (c_infos_dw, c_media, c_song_metadata) in enumerate(zip(infos_dw, medias, self.__song_metadata), 1): + + # Skip if song metadata is not valid + if type(c_song_metadata) is str: + continue + + c_infos_dw['media_url'] = c_media + c_preferences = deepcopy(self.__preferences) + c_preferences.ids = c_infos_dw['SNG_ID'] + c_preferences.song_metadata = c_song_metadata + c_preferences.track_number = idx + c_preferences.total_tracks = total_tracks + + # Download the track using the EASY_DW downloader + track = EASY_DW(c_infos_dw, c_preferences, parent='playlist').easy_dw() + + # Track-level progress reporting is handled in EASY_DW + + # Only log a warning if the track failed and was NOT intentionally skipped + if not track.success and not getattr(track, 'was_skipped', False): + song = f"{c_song_metadata['music']} - {c_song_metadata['artist']}" + error_detail = getattr(track, 'error_message', 'Download failed for unspecified reason.') + logger.warning(f"Cannot download '{song}'. Reason: {error_detail} (Link: {track.link or c_preferences.link})") + + tracks.append(track) + + # --- Append the final track path to the m3u file --- + # Build a relative path from the playlists directory + if track.success and hasattr(track, 'song_path') and track.song_path: + relative_song_path = os.path.relpath( + track.song_path, + start=os.path.join(self.__output_dir, "playlists") + ) + with open(m3u_path, "a", encoding="utf-8") as m3u_file: + m3u_file.write(f"{relative_song_path}\n") + # -------------------------------------------------- + + if self.__make_zip: + playlist_title = self.__json_data['title'] + zip_name = f"{self.__output_dir}/{playlist_title} [playlist {self.__ids}]" + create_zip(tracks, zip_name=zip_name) + playlist.zip_path = zip_name + + # Report playlist done status + playlist_name = self.__json_data.get('title', 'Unknown Playlist') + playlist_owner = self.__json_data.get('creator', {}).get('name', 'Unknown Owner') + total_tracks = self.__json_data.get('nb_tracks', 0) + + Download_JOB.report_progress({ + "type": "playlist", + "owner": playlist_owner, + "status": "done", + "total_tracks": total_tracks, + "name": playlist_name, + "url": f"https://deezer.com/playlist/{self.__ids}" + }) + + return playlist + +class DW_EPISODE: + def __init__( + self, + preferences: Preferences + ) -> None: + self.__preferences = preferences + self.__ids = preferences.ids + self.__output_dir = preferences.output_dir + self.__method_save = preferences.method_save + self.__not_interface = preferences.not_interface + self.__quality_download = preferences.quality_download + + def dw(self) -> Track: + infos_dw = API_GW.get_episode_data(self.__ids) + infos_dw['__TYPE__'] = 'episode' + + self.__preferences.song_metadata = { + 'music': infos_dw.get('EPISODE_TITLE', ''), + 'artist': infos_dw.get('SHOW_NAME', ''), + 'album': infos_dw.get('SHOW_NAME', ''), + 'date': infos_dw.get('EPISODE_PUBLISHED_TIMESTAMP', '').split()[0], + 'genre': 'Podcast', + 'explicit': infos_dw.get('SHOW_IS_EXPLICIT', '2'), + 'duration': int(infos_dw.get('DURATION', 0)), + } + + try: + direct_url = infos_dw.get('EPISODE_DIRECT_STREAM_URL') + if not direct_url: + raise TrackNotFound("No direct URL found") + + from deezspot.libutils.utils import sanitize_name + from pathlib import Path + safe_filename = sanitize_name(self.__preferences.song_metadata['music']) + Path(self.__output_dir).mkdir(parents=True, exist_ok=True) + output_path = os.path.join(self.__output_dir, f"{safe_filename}.mp3") + + response = requests.get(direct_url, stream=True) + response.raise_for_status() + + content_length = response.headers.get('content-length') + total_size = int(content_length) if content_length else None + + downloaded = 0 + total_size = int(response.headers.get('content-length', 0)) + + # Send initial progress status + progress_data = { + "type": "episode", + "song": self.__preferences.song_metadata.get('name', ''), + "artist": self.__preferences.song_metadata.get('publisher', ''), + "status": "progress", + "url": f"https://www.deezer.com/episode/{self.__ids}", + "parent": { + "type": "show", + "title": self.__preferences.song_metadata.get('show', ''), + "artist": self.__preferences.song_metadata.get('publisher', '') + } + } + Download_JOB.report_progress(progress_data) + + with open(output_path, 'wb') as f: + start_time = time.time() + last_report_time = 0 + + for chunk in response.iter_content(chunk_size=8192): + if chunk: + size = f.write(chunk) + downloaded += size + + # Real-time progress reporting every 0.5 seconds + current_time = time.time() + if self.__real_time_dl and total_size > 0 and current_time - last_report_time >= 0.5: + last_report_time = current_time + percentage = round((downloaded / total_size) * 100, 2) + + progress_data = { + "type": "episode", + "song": self.__preferences.song_metadata.get('name', ''), + "artist": self.__preferences.song_metadata.get('publisher', ''), + "status": "real-time", + "url": f"https://www.deezer.com/episode/{self.__ids}", + "time_elapsed": int((current_time - start_time) * 1000), + "progress": percentage, + "parent": { + "type": "show", + "title": self.__preferences.song_metadata.get('show', ''), + "artist": self.__preferences.song_metadata.get('publisher', '') + } + } + Download_JOB.report_progress(progress_data) + + episode = Track( + self.__preferences.song_metadata, + output_path, + '.mp3', + self.__quality_download, + f"https://www.deezer.com/episode/{self.__ids}", + self.__ids + ) + episode.success = True + + # Send completion status + progress_data = { + "type": "episode", + "song": self.__preferences.song_metadata.get('name', ''), + "artist": self.__preferences.song_metadata.get('publisher', ''), + "status": "done", + "url": f"https://www.deezer.com/episode/{self.__ids}", + "parent": { + "type": "show", + "title": self.__preferences.song_metadata.get('show', ''), + "artist": self.__preferences.song_metadata.get('publisher', '') + } + } + Download_JOB.report_progress(progress_data) + + return episode + + except Exception as e: + if 'output_path' in locals() and os.path.exists(output_path): + os.remove(output_path) + episode_title = self.__preferences.song_metadata.get('music', 'Unknown Episode') + err_msg = f"Episode download failed for '{episode_title}' (URL: {self.__preferences.link}). Error: {str(e)}" + logger.error(err_msg) + # Add original error to exception + raise TrackNotFound(message=err_msg, url=self.__preferences.link) from e diff --git a/deezspot/deezloader/__download_utils__.py b/deezspot/deezloader/__download_utils__.py new file mode 100644 index 0000000..a41fc2f --- /dev/null +++ b/deezspot/deezloader/__download_utils__.py @@ -0,0 +1,448 @@ +#!/usr/bin/python3 + +from hashlib import md5 as __md5 + +from binascii import ( + a2b_hex as __a2b_hex, + b2a_hex as __b2a_hex +) + +from Crypto.Cipher.Blowfish import ( + new as __newBlowfish, + MODE_CBC as __MODE_CBC +) + +from Crypto.Cipher import AES +from Crypto.Util import Counter +import os +from deezspot.libutils.logging_utils import logger + +__secret_key = "g4el58wc0zvf9na1" +__secret_key2 = b"jo6aey6haid2Teih" +__idk = __a2b_hex("0001020304050607") + +def md5hex(data: str): + hashed = __md5( + data.encode() + ).hexdigest() + + return hashed + +def gen_song_hash(song_id, song_md5, media_version): + """ + Generate a hash for the song using its ID, MD5 and media version. + + Args: + song_id: The song's ID + song_md5: The song's MD5 hash + media_version: The media version + + Returns: + str: The generated hash + """ + try: + # Combine the song data + data = f"{song_md5}{media_version}{song_id}" + + # Generate hash using SHA1 + import hashlib + hash_obj = hashlib.sha1() + hash_obj.update(data.encode('utf-8')) + return hash_obj.hexdigest() + + except Exception as e: + logger.error(f"Failed to generate song hash: {str(e)}") + raise + +def __calcbfkey(songid): + """ + Calculate the Blowfish decrypt key for a given song ID. + + Args: + songid: String song ID + + Returns: + The Blowfish decryption key + """ + try: + h = md5hex(songid) + logger.debug(f"MD5 hash of song ID '{songid}': {h}") + + # Build the key through XOR operations as per Deezer's algorithm + bfkey = "".join( + chr( + ord(h[i]) ^ ord(h[i + 16]) ^ ord(__secret_key[i]) + ) + for i in range(16) + ) + + # Log the generated key in hex format for debugging + logger.debug(f"Generated Blowfish key: {bfkey.encode().hex()}") + return bfkey + except Exception as e: + logger.error(f"Error calculating Blowfish key: {str(e)}") + raise + +def __blowfishDecrypt(data, key): + """ + Decrypt a single block of data using Blowfish in CBC mode. + + Args: + data: The encrypted data block (must be a multiple of 8 bytes) + key: The Blowfish key as a string + + Returns: + The decrypted data + """ + try: + # Ensure data is a multiple of Blowfish block size (8 bytes) + if len(data) % 8 != 0: + logger.warning(f"Data length {len(data)} is not a multiple of 8 bytes - Blowfish requires 8-byte blocks") + # Pad data to a multiple of 8 if needed (though this should be avoided) + padding = 8 - (len(data) % 8) + data += b'\x00' * padding + logger.warning(f"Padded data with {padding} null bytes") + + # Create Blowfish cipher in CBC mode with initialization vector + c = __newBlowfish( + key.encode(), __MODE_CBC, __idk + ) + + # Decrypt the data + decrypted = c.decrypt(data) + logger.debug(f"Decrypted {len(data)} bytes of data") + + return decrypted + except Exception as e: + logger.error(f"Error in Blowfish decryption: {str(e)}") + raise + +def decrypt_blowfish_track(crypted_audio, song_id, md5_origin, song_path): + """ + Decrypt the audio file using Blowfish encryption. + + Args: + crypted_audio: The encrypted audio data + song_id: The song ID for generating the key + md5_origin: The MD5 hash of the track + song_path: Path where to save the decrypted file + """ + try: + # Calculate the Blowfish key + bf_key = __calcbfkey(song_id) + + # For debugging - log the key being used + logger.debug(f"Using Blowfish key for decryption: {bf_key.encode().hex()}") + + # Prepare to process the file + block_size = 2048 # Size of each block to process + + # We need to reconstruct the data from potentially variable-sized chunks into + # fixed-size blocks for proper decryption + buffer = bytearray() + block_count = 0 # Count of completed blocks + + # Open the output file + with open(song_path, 'wb') as output_file: + # Process each incoming chunk of data + for chunk in crypted_audio: + if not chunk: + continue + + # Add current chunk to our buffer + buffer.extend(chunk) + + # Process as many complete blocks as we can + while len(buffer) >= block_size: + # Extract a block from buffer + block = buffer[:block_size] + buffer = buffer[block_size:] + + # Only decrypt every third block + is_encrypted = (block_count % 3 == 0) + + if is_encrypted: + # Ensure the block is a multiple of 8 bytes (Blowfish block size) + if len(block) == block_size and len(block) % 8 == 0: + try: + # Create a fresh cipher with the initialization vector for each block + # This is crucial - we need to reset the IV for each encrypted block + cipher = __newBlowfish(bf_key.encode(), __MODE_CBC, __idk) + + # Decrypt the block + block = cipher.decrypt(block) + logger.debug(f"Decrypted block {block_count} (size: {len(block)})") + except Exception as e: + logger.error(f"Failed to decrypt block {block_count}: {str(e)}") + # Continue with the encrypted block rather than failing completely + + # Write the block (decrypted or not) to the output file + output_file.write(block) + block_count += 1 + + # Write any remaining data in the buffer (this won't be decrypted as it's a partial block) + if buffer: + logger.debug(f"Writing final partial block of size {len(buffer)}") + output_file.write(buffer) + + logger.debug(f"Successfully decrypted and saved Blowfish-encrypted file to {song_path}") + + except Exception as e: + logger.error(f"Failed to decrypt Blowfish file: {str(e)}") + raise + +def decryptfile(crypted_audio, ids, song_path): + """ + Decrypt the audio file using either AES or Blowfish encryption. + + Args: + crypted_audio: The encrypted audio data + ids: The track IDs containing encryption info + song_path: Path where to save the decrypted file + """ + try: + # Check encryption type + encryption_type = ids.get('encryption_type', 'aes') + # Check if this is a FLAC file based on file extension + is_flac = song_path.lower().endswith('.flac') + + if encryption_type == 'aes': + # Get the AES encryption key and nonce + key = bytes.fromhex(ids['key']) + nonce = bytes.fromhex(ids['nonce']) + + # For AES-CTR, we can decrypt chunk by chunk + counter = Counter.new(128, initial_value=int.from_bytes(nonce, byteorder='big')) + cipher = AES.new(key, AES.MODE_CTR, counter=counter) + + # Open the output file + with open(song_path, 'wb') as f: + # Process the data in chunks + for chunk in crypted_audio: + if chunk: + # Decrypt the chunk and write to file + decrypted_chunk = cipher.decrypt(chunk) + f.write(decrypted_chunk) + + logger.debug(f"Successfully decrypted and saved AES-encrypted file to {song_path}") + + elif encryption_type == 'blowfish': + # Customize Blowfish decryption based on file type + if is_flac: + logger.debug("Detected FLAC file - using special FLAC decryption handling") + decrypt_blowfish_flac( + crypted_audio, + str(ids['track_id']), + ids['md5_origin'], + song_path + ) + else: + # Use standard Blowfish decryption for MP3 + decrypt_blowfish_track( + crypted_audio, + str(ids['track_id']), + ids['md5_origin'], + song_path + ) + else: + raise ValueError(f"Unknown encryption type: {encryption_type}") + + except Exception as e: + logger.error(f"Failed to decrypt file: {str(e)}") + raise + +def decrypt_blowfish_flac(crypted_audio, song_id, md5_origin, song_path): + """ + Special decryption function for FLAC files using Blowfish encryption. + This implementation follows Deezer's encryption scheme exactly. + + In Deezer's encryption scheme: + - Data is processed in 2048-byte blocks + - Only every third block is encrypted (blocks 0, 3, 6, etc.) + - Partial blocks at the end of the file are not encrypted + - FLAC file structure must be preserved exactly + - The initialization vector is reset for each encrypted block + + Args: + crypted_audio: Iterator of the encrypted audio data chunks + song_id: The song ID for generating the key + md5_origin: The MD5 hash of the track + song_path: Path where to save the decrypted file + """ + try: + # Calculate the Blowfish key + bf_key = __calcbfkey(song_id) + + # For debugging - log the key being used + logger.debug(f"Using Blowfish key for decryption: {bf_key.encode().hex()}") + + # Prepare to process the file + block_size = 2048 # Size of each block to process + + # We need to reconstruct the data from potentially variable-sized chunks into + # fixed-size blocks for proper decryption + buffer = bytearray() + block_count = 0 # Count of completed blocks + + # Open the output file + with open(song_path, 'wb') as output_file: + # Process each incoming chunk of data + for chunk in crypted_audio: + if not chunk: + continue + + # Add current chunk to our buffer + buffer.extend(chunk) + + # Process as many complete blocks as we can + while len(buffer) >= block_size: + # Extract a block from buffer + block = buffer[:block_size] + buffer = buffer[block_size:] + + # Determine if this block should be decrypted (every third block) + if block_count % 3 == 0: + # Ensure we have a complete block for decryption and it's a multiple of 8 bytes + if len(block) == block_size and len(block) % 8 == 0: + try: + # Create a fresh cipher with the initialization vector for each block + # This is crucial - we need to reset the IV for each encrypted block + cipher = __newBlowfish(bf_key.encode(), __MODE_CBC, __idk) + + # Decrypt the block + block = cipher.decrypt(block) + logger.debug(f"Decrypted block {block_count} (size: {len(block)})") + except Exception as e: + logger.error(f"Failed to decrypt block {block_count}: {str(e)}") + # Continue with the encrypted block rather than failing completely + + # Write the block (decrypted or not) to the output file + output_file.write(block) + block_count += 1 + + # Write any remaining data in the buffer (this won't be decrypted as it's a partial block) + if buffer: + logger.debug(f"Writing final partial block of size {len(buffer)}") + output_file.write(buffer) + + # Final validation + if os.path.getsize(song_path) > 0: + with open(song_path, 'rb') as f: + if f.read(4) == b'fLaC': + logger.info(f"FLAC file header verification passed") + else: + logger.warning("FLAC file doesn't begin with proper 'fLaC' signature") + + logger.info(f"Successfully decrypted FLAC file to {song_path} ({os.path.getsize(song_path)} bytes)") + + # Run the detailed analysis + analysis = analyze_flac_file(song_path) + if analysis.get("potential_issues"): + logger.warning(f"Decryption completed but analysis found issues: {analysis['potential_issues']}") + else: + logger.info("FLAC analysis indicates the file structure is valid") + + else: + logger.error("Decrypted file is empty - decryption likely failed") + + except Exception as e: + logger.error(f"Failed to decrypt Blowfish FLAC file: {str(e)}") + raise + +def analyze_flac_file(file_path, limit=100): + """ + Analyze a FLAC file at the binary level for debugging purposes. + This function helps identify issues with file structure that might cause + playback problems. + + Args: + file_path: Path to the FLAC file + limit: Maximum number of blocks to analyze + + Returns: + A dictionary with analysis results + """ + try: + results = { + "file_size": 0, + "has_flac_signature": False, + "block_structure": [], + "metadata_blocks": 0, + "potential_issues": [] + } + + if not os.path.exists(file_path): + results["potential_issues"].append("File does not exist") + return results + + # Get file size + file_size = os.path.getsize(file_path) + results["file_size"] = file_size + + if file_size < 8: + results["potential_issues"].append("File too small to be a valid FLAC") + return results + + with open(file_path, 'rb') as f: + # Check FLAC signature (first 4 bytes should be 'fLaC') + header = f.read(4) + results["has_flac_signature"] = (header == b'fLaC') + + if not results["has_flac_signature"]: + results["potential_issues"].append(f"Missing FLAC signature. Found: {header}") + + # Read and analyze metadata blocks + # FLAC format: https://xiph.org/flac/format.html + try: + # Go back to position after signature + f.seek(4) + + # Read metadata blocks + last_block = False + block_count = 0 + + while not last_block and block_count < limit: + block_header = f.read(4) + if len(block_header) < 4: + break + + # First bit of first byte indicates if this is the last metadata block + last_block = (block_header[0] & 0x80) != 0 + # Last 7 bits of first byte indicate block type + block_type = block_header[0] & 0x7F + # Next 3 bytes indicate length of block data + block_length = (block_header[1] << 16) | (block_header[2] << 8) | block_header[3] + + # Record block info + block_info = { + "position": f.tell() - 4, + "type": block_type, + "length": block_length, + "is_last": last_block + } + + results["block_structure"].append(block_info) + + # Skip to next block + f.seek(block_length, os.SEEK_CUR) + block_count += 1 + + results["metadata_blocks"] = block_count + + # Check for common issues + if block_count == 0: + results["potential_issues"].append("No metadata blocks found") + + # Check for STREAMINFO block (type 0) which should be present + has_streaminfo = any(block["type"] == 0 for block in results["block_structure"]) + if not has_streaminfo: + results["potential_issues"].append("Missing STREAMINFO block") + + except Exception as e: + results["potential_issues"].append(f"Error analyzing metadata: {str(e)}") + + return results + + except Exception as e: + logger.error(f"Error analyzing FLAC file: {str(e)}") + return {"error": str(e)} \ No newline at end of file diff --git a/deezspot/deezloader/__init__.py b/deezspot/deezloader/__init__.py new file mode 100644 index 0000000..9c7ba7b --- /dev/null +++ b/deezspot/deezloader/__init__.py @@ -0,0 +1,849 @@ +#!/usr/bin/python3 +import os +import json +import logging +from deezspot.deezloader.dee_api import API +from deezspot.easy_spoty import Spo +from deezspot.deezloader.deegw_api import API_GW +from deezspot.deezloader.deezer_settings import stock_quality +from deezspot.models import ( + Track, + Album, + Playlist, + Preferences, + Smart, + Episode, +) +from deezspot.deezloader.__download__ import ( + DW_TRACK, + DW_ALBUM, + DW_PLAYLIST, + DW_EPISODE, + Download_JOB, +) +from deezspot.exceptions import ( + InvalidLink, + TrackNotFound, + NoDataApi, + AlbumNotFound, +) +from deezspot.libutils.utils import ( + create_zip, + get_ids, + link_is_valid, + what_kind, +) +from deezspot.libutils.others_settings import ( + stock_output, + stock_recursive_quality, + stock_recursive_download, + stock_not_interface, + stock_zip, + method_save, +) +from deezspot.libutils.logging_utils import ProgressReporter, logger + +API() + +# Create a logger for the deezspot library +logger = logging.getLogger('deezspot') + +class DeeLogin: + def __init__( + self, + arl=None, + email=None, + password=None, + spotify_client_id=None, + spotify_client_secret=None, + progress_callback=None, + silent=False + ) -> None: + + # Store Spotify credentials + self.spotify_client_id = spotify_client_id + self.spotify_client_secret = spotify_client_secret + + # Initialize Spotify API if credentials are provided + if spotify_client_id and spotify_client_secret: + Spo.__init__(client_id=spotify_client_id, client_secret=spotify_client_secret) + + # Initialize Deezer API + if arl: + self.__gw_api = API_GW(arl=arl) + else: + self.__gw_api = API_GW( + email=email, + password=password + ) + + # Reference to the Spotify search functionality + self.__spo = Spo + + # Configure progress reporting + self.progress_reporter = ProgressReporter(callback=progress_callback, silent=silent) + + # Set the progress reporter for Download_JOB + Download_JOB.set_progress_reporter(self.progress_reporter) + + def report_progress(self, progress_data): + """Report progress using the configured reporter.""" + self.progress_reporter.report(progress_data) + + def download_trackdee( + self, link_track, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Track: + + link_is_valid(link_track) + ids = get_ids(link_track) + + try: + song_metadata = API.tracking(ids) + except NoDataApi: + infos = self.__gw_api.get_song_data(ids) + + if not "FALLBACK" in infos: + raise TrackNotFound(link_track) + + ids = infos['FALLBACK']['SNG_ID'] + song_metadata = API.tracking(ids) + + preferences = Preferences() + preferences.link = link_track + preferences.song_metadata = song_metadata + preferences.quality_download = quality_download + preferences.output_dir = output_dir + preferences.ids = ids + preferences.recursive_quality = recursive_quality + preferences.recursive_download = recursive_download + preferences.not_interface = not_interface + preferences.method_save = method_save + # New custom formatting preferences: + preferences.custom_dir_format = custom_dir_format + preferences.custom_track_format = custom_track_format + # Track number padding option + preferences.pad_tracks = pad_tracks + # Retry parameters + preferences.initial_retry_delay = initial_retry_delay + preferences.retry_delay_increase = retry_delay_increase + preferences.max_retries = max_retries + # Audio conversion parameter + preferences.convert_to = convert_to + + track = DW_TRACK(preferences).dw() + + return track + + def download_albumdee( + self, link_album, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + make_zip=stock_zip, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Album: + + link_is_valid(link_album) + ids = get_ids(link_album) + + try: + album_json = API.get_album(ids) + except NoDataApi: + raise AlbumNotFound(link_album) + + song_metadata = API.tracking_album(album_json) + + preferences = Preferences() + preferences.link = link_album + preferences.song_metadata = song_metadata + preferences.quality_download = quality_download + preferences.output_dir = output_dir + preferences.ids = ids + preferences.json_data = album_json + preferences.recursive_quality = recursive_quality + preferences.recursive_download = recursive_download + preferences.not_interface = not_interface + preferences.method_save = method_save + preferences.make_zip = make_zip + # New custom formatting preferences: + preferences.custom_dir_format = custom_dir_format + preferences.custom_track_format = custom_track_format + # Track number padding option + preferences.pad_tracks = pad_tracks + # Retry parameters + preferences.initial_retry_delay = initial_retry_delay + preferences.retry_delay_increase = retry_delay_increase + preferences.max_retries = max_retries + # Audio conversion parameter + preferences.convert_to = convert_to + + album = DW_ALBUM(preferences).dw() + + return album + + def download_playlistdee( + self, link_playlist, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + make_zip=stock_zip, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Playlist: + + link_is_valid(link_playlist) + ids = get_ids(link_playlist) + + song_metadata = [] + playlist_json = API.get_playlist(ids) + + for track in playlist_json['tracks']['data']: + c_ids = track['id'] + + try: + c_song_metadata = API.tracking(c_ids) + except NoDataApi: + infos = self.__gw_api.get_song_data(c_ids) + if not "FALLBACK" in infos: + c_song_metadata = f"{track['title']} - {track['artist']['name']}" + else: + c_song_metadata = API.tracking(c_ids) + + song_metadata.append(c_song_metadata) + + preferences = Preferences() + preferences.link = link_playlist + preferences.song_metadata = song_metadata + preferences.quality_download = quality_download + preferences.output_dir = output_dir + preferences.ids = ids + preferences.json_data = playlist_json + preferences.recursive_quality = recursive_quality + preferences.recursive_download = recursive_download + preferences.not_interface = not_interface + preferences.method_save = method_save + preferences.make_zip = make_zip + # New custom formatting preferences: + preferences.custom_dir_format = custom_dir_format + preferences.custom_track_format = custom_track_format + # Track number padding option + preferences.pad_tracks = pad_tracks + # Retry parameters + preferences.initial_retry_delay = initial_retry_delay + preferences.retry_delay_increase = retry_delay_increase + preferences.max_retries = max_retries + # Audio conversion parameter + preferences.convert_to = convert_to + + playlist = DW_PLAYLIST(preferences).dw() + + return playlist + + def download_artisttopdee( + self, link_artist, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + convert_to=None + ) -> list[Track]: + + link_is_valid(link_artist) + ids = get_ids(link_artist) + + playlist_json = API.get_artist_top_tracks(ids)['data'] + + names = [ + self.download_trackdee( + track['link'], output_dir, + quality_download, recursive_quality, + recursive_download, not_interface, + method_save=method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + convert_to=convert_to + ) + for track in playlist_json + ] + + return names + + def convert_spoty_to_dee_link_track(self, link_track): + link_is_valid(link_track) + ids = get_ids(link_track) + + # Use stored credentials for API calls + track_json = Spo.get_track(ids) + external_ids = track_json['external_ids'] + + if not external_ids: + msg = f"⚠ The track \"{track_json['name']}\" can't be converted to Deezer link :( ⚠" + raise TrackNotFound( + url=link_track, + message=msg + ) + + isrc = f"isrc:{external_ids['isrc']}" + track_json_dee = API.get_track(isrc) + track_link_dee = track_json_dee['link'] + + return track_link_dee + + def download_trackspo( + self, link_track, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Track: + + track_link_dee = self.convert_spoty_to_dee_link_track(link_track) + + track = self.download_trackdee( + track_link_dee, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + method_save=method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries, + convert_to=convert_to + ) + + return track + + def convert_spoty_to_dee_link_album(self, link_album): + link_is_valid(link_album) + ids = get_ids(link_album) + link_dee = None + + spotify_album_data = Spo.get_album(ids) + + # Method 1: Try UPC + try: + external_ids = spotify_album_data.get('external_ids') + if external_ids and 'upc' in external_ids: + upc_base = str(external_ids['upc']).lstrip('0') + if upc_base: + logger.debug(f"Attempting Deezer album search with UPC: {upc_base}") + try: + deezer_album_info = API.get_album(f"upc:{upc_base}") + if isinstance(deezer_album_info, dict) and 'link' in deezer_album_info: + link_dee = deezer_album_info['link'] + logger.info(f"Found Deezer album via UPC: {link_dee}") + except NoDataApi: + logger.debug(f"No Deezer album found for UPC: {upc_base}") + except Exception as e_upc_search: + logger.warning(f"Error during Deezer API call for UPC {upc_base}: {e_upc_search}") + else: + logger.debug("No UPC found in Spotify data for album link conversion.") + except Exception as e_upc_block: + logger.error(f"Error processing UPC for album {link_album}: {e_upc_block}") + + # Method 2: Try ISRC if UPC failed + if not link_dee: + logger.debug(f"UPC method failed or skipped for {link_album}. Attempting ISRC method.") + try: + spotify_total_tracks = spotify_album_data.get('total_tracks') + spotify_tracks_items = spotify_album_data.get('tracks', {}).get('items', []) + + if not spotify_tracks_items: + logger.warning(f"No track items in Spotify data for {link_album} to attempt ISRC lookup.") + else: + for track_item in spotify_tracks_items: + try: + track_spotify_link = track_item.get('external_urls', {}).get('spotify') + if not track_spotify_link: continue + + spotify_track_info = Spo.get_track(track_spotify_link) + isrc_value = spotify_track_info.get('external_ids', {}).get('isrc') + if not isrc_value: continue + + logger.debug(f"Attempting Deezer track search with ISRC: {isrc_value}") + deezer_track_info = API.get_track(f"isrc:{isrc_value}") + + if isinstance(deezer_track_info, dict) and 'album' in deezer_track_info: + deezer_album_preview = deezer_track_info['album'] + if isinstance(deezer_album_preview, dict) and 'id' in deezer_album_preview: + deezer_album_id = deezer_album_preview['id'] + full_deezer_album_info = API.get_album(deezer_album_id) + if ( + isinstance(full_deezer_album_info, dict) and + full_deezer_album_info.get('nb_tracks') == spotify_total_tracks and + 'link' in full_deezer_album_info + ): + link_dee = full_deezer_album_info['link'] + logger.info(f"Found Deezer album via ISRC ({isrc_value}): {link_dee}") + break # Found a matching album, exit track loop + except NoDataApi: + logger.debug(f"No Deezer track/album found for ISRC: {isrc_value}") + # Continue to the next track's ISRC + except Exception as e_isrc_track_search: + logger.warning(f"Error during Deezer search for ISRC {isrc_value}: {e_isrc_track_search}") + # Continue to the next track's ISRC + if not link_dee: # If loop finished and no link found via ISRC + logger.warning(f"ISRC method completed for {link_album}, but no matching Deezer album found.") + except Exception as e_isrc_block: + logger.error(f"Error during ISRC processing block for {link_album}: {e_isrc_block}") + + if not link_dee: + raise AlbumNotFound(f"Failed to convert Spotify album link {link_album} to a Deezer link after all attempts.") + + return link_dee + + def download_albumspo( + self, link_album, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + make_zip=stock_zip, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Album: + + link_dee = self.convert_spoty_to_dee_link_album(link_album) + + album = self.download_albumdee( + link_dee, output_dir, + quality_download, recursive_quality, + recursive_download, not_interface, + make_zip, method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries, + convert_to=convert_to + ) + + return album + + def download_playlistspo( + self, link_playlist, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + make_zip=stock_zip, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Playlist: + + link_is_valid(link_playlist) + ids = get_ids(link_playlist) + + # Use stored credentials for API calls + playlist_json = Spo.get_playlist(ids) + playlist_name = playlist_json['name'] + total_tracks = playlist_json['tracks']['total'] + playlist_tracks = playlist_json['tracks']['items'] + playlist = Playlist() + tracks = playlist.tracks + + # Initializing status - replaced print with report_progress + self.report_progress({ + "status": "initializing", + "type": "playlist", + "name": playlist_name, + "total_tracks": total_tracks + }) + + for index, item in enumerate(playlist_tracks, 1): + is_track = item.get('track') + if not is_track: + # Progress status for an invalid track item + self.report_progress({ + "status": "progress", + "type": "playlist", + "track": "Unknown Track", + "current_track": f"{index}/{total_tracks}" + }) + continue + + track_info = is_track + track_name = track_info.get('name', 'Unknown Track') + artists = track_info.get('artists', []) + artist_name = artists[0]['name'] if artists else 'Unknown Artist' + + external_urls = track_info.get('external_urls', {}) + if not external_urls: + # Progress status for unavailable track + self.report_progress({ + "status": "progress", + "type": "playlist", + "track": track_name, + "current_track": f"{index}/{total_tracks}" + }) + logger.warning(f"The track \"{track_name}\" is not available on Spotify :(") + continue + + # Progress status before download attempt + self.report_progress({ + "status": "progress", + "type": "playlist", + "track": track_name, + "current_track": f"{index}/{total_tracks}" + }) + + link_track = external_urls['spotify'] + + try: + # Download each track individually via the Spotify-to-Deezer conversion method. + downloaded_track = self.download_trackspo( + link_track, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + method_save=method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries, + convert_to=convert_to + ) + tracks.append(downloaded_track) + except (TrackNotFound, NoDataApi) as e: + logger.error(f"Failed to download track: {track_name} - {artist_name}") + tracks.append(f"{track_name} - {artist_name}") + + # Done status + self.report_progress({ + "status": "done", + "type": "playlist", + "name": playlist_name, + "total_tracks": total_tracks + }) + + # === New m3u File Creation Section === + # Create a subfolder "playlists" inside the output directory + playlist_m3u_dir = os.path.join(output_dir, "playlists") + os.makedirs(playlist_m3u_dir, exist_ok=True) + # The m3u file will be named after the playlist (e.g. "MyPlaylist.m3u") + m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name}.m3u") + with open(m3u_path, "w", encoding="utf-8") as m3u_file: + # Write the m3u header + m3u_file.write("#EXTM3U\n") + # Append each successfully downloaded track's relative path + for track in tracks: + if isinstance(track, Track) and track.success and hasattr(track, 'song_path') and track.song_path: + # Calculate the relative path from the m3u folder to the track file + relative_song_path = os.path.relpath(track.song_path, start=playlist_m3u_dir) + m3u_file.write(f"{relative_song_path}\n") + logger.info(f"Created m3u playlist file at: {m3u_path}") + # === End m3u File Creation Section === + + if make_zip: + playlist_name = playlist_json['name'] + zip_name = f"{output_dir}playlist {playlist_name}.zip" + create_zip(tracks, zip_name=zip_name) + playlist.zip_path = zip_name + + return playlist + + def download_name( + self, artist, song, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + convert_to=None + ) -> Track: + + query = f"track:{song} artist:{artist}" + # Use the stored credentials when searching + search = self.__spo.search( + query, + client_id=self.spotify_client_id, + client_secret=self.spotify_client_secret + ) if not self.__spo._Spo__initialized else self.__spo.search(query) + + items = search['tracks']['items'] + + if len(items) == 0: + msg = f"No result for {query} :(" + raise TrackNotFound(message=msg) + + link_track = items[0]['external_urls']['spotify'] + + track = self.download_trackspo( + link_track, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + method_save=method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + convert_to=convert_to + ) + + return track + + def download_episode( + self, + link_episode, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Episode: + + link_is_valid(link_episode) + ids = get_ids(link_episode) + + try: + episode_metadata = API.tracking(ids) + except NoDataApi: + infos = self.__gw_api.get_episode_data(ids) + if not infos: + raise TrackNotFound("Episode not found") + episode_metadata = { + 'music': infos.get('EPISODE_TITLE', ''), + 'artist': infos.get('SHOW_NAME', ''), + 'album': infos.get('SHOW_NAME', ''), + 'date': infos.get('EPISODE_PUBLISHED_TIMESTAMP', '').split()[0], + 'genre': 'Podcast', + 'explicit': infos.get('SHOW_IS_EXPLICIT', '2'), + 'disc': 1, + 'track': 1, + 'duration': int(infos.get('DURATION', 0)), + 'isrc': None, + 'image': infos.get('EPISODE_IMAGE_MD5', '') + } + + preferences = Preferences() + preferences.link = link_episode + preferences.song_metadata = episode_metadata + preferences.quality_download = quality_download + preferences.output_dir = output_dir + preferences.ids = ids + preferences.recursive_quality = recursive_quality + preferences.recursive_download = recursive_download + preferences.not_interface = not_interface + preferences.method_save = method_save + # New custom formatting preferences: + preferences.custom_dir_format = custom_dir_format + preferences.custom_track_format = custom_track_format + # Track number padding option + preferences.pad_tracks = pad_tracks + # Retry parameters + preferences.initial_retry_delay = initial_retry_delay + preferences.retry_delay_increase = retry_delay_increase + preferences.max_retries = max_retries + + episode = DW_EPISODE(preferences).dw() + + return episode + + def download_smart( + self, link, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + make_zip=stock_zip, + method_save=method_save, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5 + ) -> Smart: + + link_is_valid(link) + link = what_kind(link) + smart = Smart() + + if "spotify.com" in link: + source = "https://spotify.com" + elif "deezer.com" in link: + source = "https://deezer.com" + + smart.source = source + + # Add progress reporting for the smart downloader + self.report_progress({ + "status": "initializing", + "type": "smart_download", + "link": link, + "source": source + }) + + if "track/" in link: + if "spotify.com" in link: + func = self.download_trackspo + elif "deezer.com" in link: + func = self.download_trackdee + else: + raise InvalidLink(link) + + track = func( + link, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + method_save=method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) + smart.type = "track" + smart.track = track + + elif "album/" in link: + if "spotify.com" in link: + func = self.download_albumspo + elif "deezer.com" in link: + func = self.download_albumdee + else: + raise InvalidLink(link) + + album = func( + link, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + make_zip=make_zip, + method_save=method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) + smart.type = "album" + smart.album = album + + elif "playlist/" in link: + if "spotify.com" in link: + func = self.download_playlistspo + elif "deezer.com" in link: + func = self.download_playlistdee + else: + raise InvalidLink(link) + + playlist = func( + link, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + make_zip=make_zip, + method_save=method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) + smart.type = "playlist" + smart.playlist = playlist + + # Report completion + self.report_progress({ + "status": "done", + "type": "smart_download", + "source": source, + "content_type": smart.type + }) + + return smart diff --git a/deezspot/deezloader/__taggers__.py b/deezspot/deezloader/__taggers__.py new file mode 100644 index 0000000..643fdee --- /dev/null +++ b/deezspot/deezloader/__taggers__.py @@ -0,0 +1,149 @@ +from mutagen.flac import FLAC +from mutagen.mp3 import MP3 +from mutagen.id3 import ID3 +from mutagen.mp4 import MP4 +from mutagen import File +from deezspot.libutils.logging_utils import logger + +def write_tags(track): + """ + Write metadata tags to the audio file. + + Args: + track: Track object containing metadata + """ + try: + if not track.song_path: + logger.warning("No song path provided for tagging") + return + + # Get the audio file + audio = File(track.song_path) + + # Common metadata fields + metadata = { + 'title': track.song_metadata['music'], + 'artist': track.song_metadata['artist'], + 'album': track.song_metadata['album'], + 'date': track.song_metadata.get('date', ''), + 'genre': track.song_metadata.get('genre', ''), + 'tracknumber': track.song_metadata.get('tracknum', ''), + 'discnumber': track.song_metadata.get('discnum', ''), + 'isrc': track.song_metadata.get('isrc', ''), + 'albumartist': track.song_metadata.get('album_artist', ''), + 'publisher': track.song_metadata.get('publisher', ''), + 'comment': track.song_metadata.get('comment', ''), + 'composer': track.song_metadata.get('composer', ''), + 'copyright': track.song_metadata.get('copyright', ''), + 'encodedby': track.song_metadata.get('encodedby', ''), + 'language': track.song_metadata.get('language', ''), + 'lyrics': track.song_metadata.get('lyrics', ''), + 'mood': track.song_metadata.get('mood', ''), + 'rating': track.song_metadata.get('rating', ''), + 'replaygain_album_gain': track.song_metadata.get('replaygain_album_gain', ''), + 'replaygain_album_peak': track.song_metadata.get('replaygain_album_peak', ''), + 'replaygain_track_gain': track.song_metadata.get('replaygain_track_gain', ''), + 'replaygain_track_peak': track.song_metadata.get('replaygain_track_peak', ''), + 'website': track.song_metadata.get('website', ''), + 'year': track.song_metadata.get('year', ''), + 'explicit': track.song_metadata.get('explicit', '0') + } + + # Handle different file formats + if isinstance(audio, FLAC): + # FLAC specific handling + for key, value in metadata.items(): + if value: + audio[key] = str(value) + + elif isinstance(audio, MP3): + # MP3 specific handling + id3 = ID3() + for key, value in metadata.items(): + if value: + if key == 'title': + id3.add(TIT2(encoding=3, text=value)) + elif key == 'artist': + id3.add(TPE1(encoding=3, text=value)) + elif key == 'album': + id3.add(TALB(encoding=3, text=value)) + elif key == 'date': + id3.add(TDRC(encoding=3, text=value)) + elif key == 'genre': + id3.add(TCON(encoding=3, text=value)) + elif key == 'tracknumber': + id3.add(TRCK(encoding=3, text=value)) + elif key == 'discnumber': + id3.add(TPOS(encoding=3, text=value)) + elif key == 'isrc': + id3.add(TSRC(encoding=3, text=value)) + elif key == 'albumartist': + id3.add(TPE2(encoding=3, text=value)) + elif key == 'composer': + id3.add(TCOM(encoding=3, text=value)) + elif key == 'lyrics': + id3.add(USLT(encoding=3, lang='eng', desc='', text=value)) + + audio.tags = id3 + + elif isinstance(audio, MP4): + # MP4 specific handling + for key, value in metadata.items(): + if value: + if key == 'title': + audio['\xa9nam'] = value + elif key == 'artist': + audio['\xa9ART'] = value + elif key == 'album': + audio['\xa9alb'] = value + elif key == 'date': + audio['\xa9day'] = value + elif key == 'genre': + audio['\xa9gen'] = value + elif key == 'tracknumber': + audio['trkn'] = [(int(value.split('/')[0]), int(value.split('/')[1]))] + elif key == 'discnumber': + audio['disk'] = [(int(value.split('/')[0]), int(value.split('/')[1]))] + elif key == 'isrc': + audio['isrc'] = value + elif key == 'albumartist': + audio['aART'] = value + elif key == 'composer': + audio['\xa9wrt'] = value + elif key == 'lyrics': + audio['\xa9lyr'] = value + + # Save the changes + audio.save() + logger.debug(f"Successfully wrote tags to {track.song_path}") + + except Exception as e: + logger.error(f"Failed to write tags to {track.song_path}: {str(e)}") + raise + +def check_track(track): + """ + Check if a track's metadata is valid. + + Args: + track: Track object to check + + Returns: + bool: True if track is valid, False otherwise + """ + try: + required_fields = ['music', 'artist', 'album'] + for field in required_fields: + if field not in track.song_metadata or not track.song_metadata[field]: + logger.warning(f"Missing required field: {field}") + return False + + if not track.song_path or not os.path.exists(track.song_path): + logger.warning("Track file does not exist") + return False + + return True + + except Exception as e: + logger.error(f"Failed to check track: {str(e)}") + return False \ No newline at end of file diff --git a/deezspot/deezloader/__utils__.py b/deezspot/deezloader/__utils__.py new file mode 100644 index 0000000..892aab5 --- /dev/null +++ b/deezspot/deezloader/__utils__.py @@ -0,0 +1,186 @@ +#!/usr/bin/python3 + +import json +import os +from deezspot.libutils.logging_utils import logger + +def artist_sort(array: list): + if len(array) > 1: + for a in array: + for b in array: + if a in b and a != b: + array.remove(b) + + array = list( + dict.fromkeys(array) + ) + + artists = "; ".join(array) + + return artists + +def check_track_token(infos_dw): + """ + Check and extract track token from the Deezer API response. + + Args: + infos_dw: Deezer API response data + + Returns: + str: Track token + """ + try: + token = infos_dw.get('TRACK_TOKEN') + if not token: + logger.error("Missing TRACK_TOKEN in API response") + raise ValueError("Missing TRACK_TOKEN") + + return token + + except Exception as e: + logger.error(f"Failed to check track token: {str(e)}") + raise + +def check_track_ids(infos_dw): + """ + Check and extract track IDs from the Deezer API response. + + Args: + infos_dw: Deezer API response data + + Returns: + dict: Track IDs and encryption info + """ + try: + # Extract required IDs + track_id = infos_dw.get('SNG_ID') + if not track_id: + logger.error("Missing SNG_ID in API response") + raise ValueError("Missing SNG_ID") + + # Initialize result dictionary + result = {'track_id': track_id} + + # Check for AES encryption info (MEDIA_KEY and MEDIA_NONCE) + key = infos_dw.get('MEDIA_KEY') + nonce = infos_dw.get('MEDIA_NONCE') + + if key and nonce: + # AES encryption is available + result['encryption_type'] = 'aes' + result['key'] = key + result['nonce'] = nonce + else: + # Fallback to Blowfish encryption + md5_origin = infos_dw.get('MD5_ORIGIN') + track_token = infos_dw.get('TRACK_TOKEN') + media_version = infos_dw.get('MEDIA_VERSION', '1') + + if not md5_origin or not track_token: + logger.error("Missing Blowfish encryption info (MD5_ORIGIN or TRACK_TOKEN) in API response") + raise ValueError("Missing encryption info") + + result['encryption_type'] = 'blowfish' + result['md5_origin'] = md5_origin + result['track_token'] = track_token + result['media_version'] = media_version + + return result + + except Exception as e: + logger.error(f"Failed to check track IDs: {str(e)}") + raise + +def check_track_md5(infos_dw): + """ + Check and extract track MD5 and media version from the Deezer API response. + + Args: + infos_dw: Deezer API response data + + Returns: + tuple: (Track MD5 hash, Media version) + """ + try: + md5 = infos_dw.get('MD5_ORIGIN') + if not md5: + logger.error("Missing MD5_ORIGIN in API response") + raise ValueError("Missing MD5_ORIGIN") + + media_version = infos_dw.get('MEDIA_VERSION', '1') + + return md5, media_version + + except Exception as e: + logger.error(f"Failed to check track MD5: {str(e)}") + raise + +def set_path(song_metadata, output_dir, method_save): + """ + Set the output path for a track based on metadata and save method. + + Args: + song_metadata: Track metadata + output_dir: Base output directory + method_save: Save method (e.g., 'artist/album/track') + + Returns: + str: Full output path + """ + try: + # Create base directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + # Build path based on method + if method_save == 'artist/album/track': + path = os.path.join( + output_dir, + song_metadata['artist'], + song_metadata['album'], + f"{song_metadata['music']}.mp3" + ) + else: + path = os.path.join( + output_dir, + f"{song_metadata['artist']} - {song_metadata['music']}.mp3" + ) + + # Create parent directories + os.makedirs(os.path.dirname(path), exist_ok=True) + + return path + + except Exception as e: + logger.error(f"Failed to set path: {str(e)}") + raise + +def trasform_sync_lyric(lyrics): + """ + Transform synchronized lyrics into a standard format. + + Args: + lyrics: Raw lyrics data + + Returns: + str: Formatted lyrics + """ + try: + if not lyrics: + return "" + + # Parse lyrics data + data = json.loads(lyrics) + + # Format each line with timestamp + formatted = [] + for line in data: + timestamp = line.get('timestamp', 0) + text = line.get('text', '') + if text: + formatted.append(f"[{timestamp}]{text}") + + return "\n".join(formatted) + + except Exception as e: + logger.error(f"Failed to transform lyrics: {str(e)}") + return "" \ No newline at end of file diff --git a/deezspot/deezloader/dee_api.py b/deezspot/deezloader/dee_api.py new file mode 100644 index 0000000..1e32f52 --- /dev/null +++ b/deezspot/deezloader/dee_api.py @@ -0,0 +1,331 @@ +#!/usr/bin/python3 + +from time import sleep +from datetime import datetime +from deezspot.deezloader.__utils__ import artist_sort +from requests import get as req_get +from deezspot.libutils.utils import convert_to_date +from deezspot.libutils.others_settings import header +from deezspot.exceptions import ( + NoDataApi, + QuotaExceeded, + TrackNotFound, +) +from deezspot.libutils.logging_utils import logger + +class API: + + @classmethod + def __init__(cls): + cls.__api_link = "https://api.deezer.com/" + cls.__cover = "https://e-cdns-images.dzcdn.net/images/cover/%s/{}-000000-80-0-0.jpg" + cls.headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + + @classmethod + def __get_api(cls, url, quota_exceeded = False): + try: + response = req_get(url, headers=cls.headers) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"Failed to get API data from {url}: {str(e)}") + raise + + @classmethod + def get_chart(cls, index = 0): + url = f"{cls.__api_link}chart/{index}" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_track(cls, track_id): + url = f"{cls.__api_link}track/{track_id}" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_album(cls, album_id): + url = f"{cls.__api_link}album/{album_id}" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_playlist(cls, playlist_id): + url = f"{cls.__api_link}playlist/{playlist_id}" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_episode(cls, episode_id): + url = f"{cls.__api_link}episode/{episode_id}" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_artist(cls, ids): + url = f"{cls.__api_link}artist/{ids}" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_artist_top_tracks(cls, ids, limit = 25): + url = f"{cls.__api_link}artist/{ids}/top?limit={limit}" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_artist_top_albums(cls, ids, limit = 25): + url = f"{cls.__api_link}artist/{ids}/albums?limit={limit}" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_artist_related(cls, ids): + url = f"{cls.__api_link}artist/{ids}/related" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_artist_radio(cls, ids): + url = f"{cls.__api_link}artist/{ids}/radio" + infos = cls.__get_api(url) + + return infos + + @classmethod + def get_artist_top_playlists(cls, ids, limit = 25): + url = f"{cls.__api_link}artist/{ids}/playlists?limit={limit}" + infos = cls.__get_api(url) + + return infos + + @classmethod + def search(cls, query, limit=25): + url = f"{cls.__api_link}search" + params = { + "q": query, + "limit": limit + } + infos = cls.__get_api(url, params=params) + + if infos['total'] == 0: + raise NoDataApi(query) + + return infos + + @classmethod + def search_track(cls, query, limit=None): + url = f"{cls.__api_link}search/track/?q={query}" + + # Add the limit parameter to the URL if it is provided + if limit is not None: + url += f"&limit={limit}" + + infos = cls.__get_api(url) + + if infos['total'] == 0: + raise NoDataApi(query) + + return infos + + @classmethod + def search_album(cls, query, limit=None): + url = f"{cls.__api_link}search/album/?q={query}" + + # Add the limit parameter to the URL if it is provided + if limit is not None: + url += f"&limit={limit}" + + infos = cls.__get_api(url) + + if infos['total'] == 0: + raise NoDataApi(query) + + return infos + + @classmethod + def search_playlist(cls, query, limit=None): + url = f"{cls.__api_link}search/playlist/?q={query}" + + # Add the limit parameter to the URL if it is provided + if limit is not None: + url += f"&limit={limit}" + + infos = cls.__get_api(url) + + if infos['total'] == 0: + raise NoDataApi(query) + + return infos + + @classmethod + def search_artist(cls, query, limit=None): + url = f"{cls.__api_link}search/artist/?q={query}" + + # Add the limit parameter to the URL if it is provided + if limit is not None: + url += f"&limit={limit}" + + infos = cls.__get_api(url) + + if infos['total'] == 0: + raise NoDataApi(query) + + return infos + + @classmethod + def not_found(cls, song, title): + try: + data = cls.search_track(song)['data'] + except NoDataApi: + raise TrackNotFound(song) + + ids = None + + for track in data: + if ( + track['title'] == title + ) or ( + title in track['title_short'] + ): + ids = track['id'] + break + + if not ids: + raise TrackNotFound(song) + + return str(ids) + + @classmethod + def get_img_url(cls, md5_image, size = "1200x1200"): + cover = cls.__cover.format(size) + image_url = cover % md5_image + + return image_url + + @classmethod + def choose_img(cls, md5_image, size = "1200x1200"): + image_url = cls.get_img_url(md5_image, size) + image = req_get(image_url).content + + if len(image) == 13: + image_url = cls.get_img_url("", size) + image = req_get(image_url).content + + return image + + @classmethod + def tracking(cls, ids, album = False) -> dict: + song_metadata = {} + json_track = cls.get_track(ids) + + # Ensure ISRC is always fetched + song_metadata['isrc'] = json_track.get('isrc', '') + + if not album: + album_ids = json_track['album']['id'] + album_json = cls.get_album(album_ids) + genres = [] + + if "genres" in album_json: + for genre in album_json['genres']['data']: + genres.append(genre['name']) + + song_metadata['genre'] = "; ".join(genres) + ar_album = [] + + for contributor in album_json['contributors']: + if contributor['role'] == "Main": + ar_album.append(contributor['name']) + + song_metadata['ar_album'] = "; ".join(ar_album) + song_metadata['album'] = album_json['title'] + song_metadata['label'] = album_json['label'] + # Ensure UPC is fetched from album data + song_metadata['upc'] = album_json.get('upc', '') + song_metadata['nb_tracks'] = album_json['nb_tracks'] + + song_metadata['music'] = json_track['title'] + array = [] + + for contributor in json_track['contributors']: + if contributor['name'] != "": + array.append(contributor['name']) + + array.append( + json_track['artist']['name'] + ) + + song_metadata['artist'] = artist_sort(array) + song_metadata['tracknum'] = json_track['track_position'] + song_metadata['discnum'] = json_track['disk_number'] + song_metadata['year'] = convert_to_date(json_track['release_date']) + song_metadata['bpm'] = json_track['bpm'] + song_metadata['duration'] = json_track['duration'] + # song_metadata['isrc'] = json_track['isrc'] # Already handled above + song_metadata['gain'] = json_track['gain'] + + return song_metadata + + @classmethod + def tracking_album(cls, album_json): + song_metadata: dict[ + str, + list or str or int or datetime + ] = { + "music": [], + "artist": [], + "tracknum": [], + "discnum": [], + "bpm": [], + "duration": [], + "isrc": [], # Ensure isrc list is present for tracks + "gain": [], + "album": album_json['title'], + "label": album_json['label'], + "year": convert_to_date(album_json['release_date']), + # Ensure UPC is fetched at album level + "upc": album_json.get('upc', ''), + "nb_tracks": album_json['nb_tracks'] + } + + genres = [] + + if "genres" in album_json: + for a in album_json['genres']['data']: + genres.append(a['name']) + + song_metadata['genre'] = "; ".join(genres) + ar_album = [] + + for a in album_json['contributors']: + if a['role'] == "Main": + ar_album.append(a['name']) + + song_metadata['ar_album'] = "; ".join(ar_album) + sm_items = song_metadata.items() + + for track in album_json['tracks']['data']: + c_ids = track['id'] + detas = cls.tracking(c_ids, album = True) + + for key, item in sm_items: + if type(item) is list: + # Ensure ISRC is appended for each track + if key == 'isrc': + song_metadata[key].append(detas.get('isrc', '')) + else: + song_metadata[key].append(detas[key]) + + return song_metadata diff --git a/deezspot/deezloader/deegw_api.py b/deezspot/deezloader/deegw_api.py new file mode 100644 index 0000000..c0f9cb9 --- /dev/null +++ b/deezspot/deezloader/deegw_api.py @@ -0,0 +1,324 @@ +#!/usr/bin/python3 + +from requests import Session +from deezspot.deezloader.deezer_settings import qualities +from deezspot.deezloader.__download_utils__ import md5hex +from deezspot.exceptions import ( + BadCredentials, + TrackNotFound, + NoRightOnMedia, +) +from requests import ( + get as req_get, + post as req_post, +) +from deezspot.libutils.logging_utils import logger + +class API_GW: + + @classmethod + def __init__( + cls, + arl = None, + email = None, + password = None + ): + cls.__req = Session() + cls.__arl = arl + cls.__email = email + cls.__password = password + cls.__token = "null" + + cls.__client_id = 172365 + cls.__client_secret = "fb0bec7ccc063dab0417eb7b0d847f34" + cls.__try_link = "https://api.deezer.com/platform/generic/track/3135556" + + cls.__get_lyric = "song.getLyrics" + cls.__get_song_data = "song.getData" + cls.__get_user_getArl = "user.getArl" + cls.__get_page_track = "deezer.pageTrack" + cls.__get_user_data = "deezer.getUserData" + cls.__get_album_data = "song.getListByAlbum" + cls.__get_playlist_data = "playlist.getSongs" + cls.__get_episode_data = "episode.getData" + + cls.__get_media_url = "https://media.deezer.com/v1/get_url" + cls.__get_auth_token_url = "https://api.deezer.com/auth/token" + cls.__private_api_link = "https://www.deezer.com/ajax/gw-light.php" + cls.__song_server = "https://e-cdns-proxy-{}.dzcdn.net/mobile/1/{}" + + cls.__refresh_token() + + @classmethod + def __login(cls): + if ( + (not cls.__arl) and + (not cls.__email) and + (not cls.__password) + ): + msg = f"NO LOGIN STUFF INSERTED :)))" + + raise BadCredentials(msg = msg) + + if cls.__arl: + cls.__req.cookies['arl'] = cls.__arl + else: + cls.__set_arl() + + @classmethod + def __set_arl(cls): + access_token = cls.__get_access_token() + + c_headers = { + "Authorization": f"Bearer {access_token}" + } + + cls.__req.get(cls.__try_link, headers = c_headers).json() + cls.__arl = cls.__get_api(cls.__get_user_getArl) + + @classmethod + def __get_access_token(cls): + password = md5hex(cls.__password) + + to_hash = ( + f"{cls.__client_id}{cls.__email}{password}{cls.__client_secret}" + ) + + request_hash = md5hex(to_hash) + + params = { + "app_id": cls.__client_id, + "login": cls.__email, + "password": password, + "hash": request_hash + } + + results = req_get(cls.__get_auth_token_url, params = params).json() + + if "error" in results: + raise BadCredentials( + email = cls.__email, + password = cls.__password + ) + + access_token = results['access_token'] + + return access_token + + def __cool_api(cls): + guest_sid = cls.__req.cookies.get("sid") + url = "https://api.deezer.com/1.0/gateway.php" + + params = { + 'api_key': "4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE", + 'sid': guest_sid, + 'input': '3', + 'output': '3', + 'method': 'song_getData' + } + + json = {'sng_id': 302127} + + json = req_post(url, params = params, json = json).json() + print(json) + + @classmethod + def __get_api( + cls, method, + json_data = None, + repeats = 4 + ): + params = { + "api_version": "1.0", + "api_token": cls.__token, + "input": "3", + "method": method + } + + results = cls.__req.post( + cls.__private_api_link, + params = params, + json = json_data + ).json()['results'] + + if not results and repeats != 0: + cls.__refresh_token() + + cls.__get_api( + method, json_data, + repeats = repeats - 1 + ) + + return results + + @classmethod + def get_user(cls): + data = cls.__get_api(cls.__get_user_data) + + return data + + @classmethod + def __refresh_token(cls): + cls.__req.cookies.clear_session_cookies() + + if not cls.amIlog(): + cls.__login() + cls.am_I_log() + + data = cls.get_user() + cls.__token = data['checkForm'] + cls.__license_token = cls.__get_license_token() + + @classmethod + def __get_license_token(cls): + data = cls.get_user() + license_token = data['USER']['OPTIONS']['license_token'] + + return license_token + + @classmethod + def amIlog(cls): + data = cls.get_user() + user_id = data['USER']['USER_ID'] + is_logged = False + + if user_id != 0: + is_logged = True + + return is_logged + + @classmethod + def am_I_log(cls): + if not cls.amIlog(): + raise BadCredentials(arl = cls.__arl) + + @classmethod + def get_song_data(cls, ids): + json_data = { + "sng_id" : ids + } + + infos = cls.__get_api(cls.__get_song_data, json_data) + + return infos + + @classmethod + def get_album_data(cls, ids): + json_data = { + "alb_id": ids, + "nb": -1 + } + + infos = cls.__get_api(cls.__get_album_data, json_data) + + return infos + + @classmethod + def get_lyric(cls, ids): + json_data = { + "sng_id": ids + } + + infos = cls.__get_api(cls.__get_lyric, json_data) + + return infos + + @classmethod + def get_playlist_data(cls, ids): + json_data = { + "playlist_id": ids, + "nb": -1 + } + + infos = cls.__get_api(cls.__get_playlist_data, json_data) + + return infos + + @classmethod + def get_page_track(cls, ids): + json_data = { + "sng_id" : ids + } + + infos = cls.__get_api(cls.__get_page_track, json_data) + + return infos + + @classmethod + def get_episode_data(cls, ids): + json_data = { + "episode_id": ids + } + + infos = cls.__get_api(cls.__get_episode_data, json_data) + + if infos: + infos['MEDIA_VERSION'] = '1' + infos['SNG_ID'] = infos.get('EPISODE_ID') + if 'EPISODE_DIRECT_STREAM_URL' in infos: + infos['MD5_ORIGIN'] = 'episode' + + return infos + + @classmethod + def get_song_url(cls, n, song_hash): + song_url = cls.__song_server.format(n, song_hash) + + return song_url + + @classmethod + def song_exist(cls, song_link): + if song_link and 'spreaker.com' in song_link: + return req_get(song_link, stream=True) + + crypted_audio = req_get(song_link) + + if len(crypted_audio.content) == 0: + raise TrackNotFound + + return crypted_audio + + @classmethod + def get_medias_url(cls, tracks_token, quality): + others_qualities = [] + + for c_quality in qualities: + if c_quality == quality: + continue + + c_quality_set = { + "cipher": "BF_CBC_STRIPE", + "format": c_quality + } + + others_qualities.append(c_quality_set) + + json_data = { + "license_token": cls.__license_token, + "media": [ + { + "type": "FULL", + "formats": [ + { + "cipher": "BF_CBC_STRIPE", + "format": quality + } + ] + others_qualities + } + ], + "track_tokens": tracks_token + } + + infos = req_post( + cls.__get_media_url, + json = json_data + ).json() + + if "errors" in infos: + msg = infos['errors'][0]['message'] + + raise NoRightOnMedia(msg) + + medias = infos['data'] + + return medias diff --git a/deezspot/deezloader/deezer_settings.py b/deezspot/deezloader/deezer_settings.py new file mode 100644 index 0000000..0e833c9 --- /dev/null +++ b/deezspot/deezloader/deezer_settings.py @@ -0,0 +1,24 @@ +#!/usr/bin/python3 + +stock_quality = "MP3_320" +method_saves = ["0", "1", "2"] + +qualities = { + "MP3_320": { + "n_quality": "3", + "f_format": ".mp3", + "s_quality": "320" + }, + + "FLAC": { + "n_quality": "9", + "f_format": ".flac", + "s_quality": "FLAC" + }, + + "MP3_128": { + "n_quality": "1", + "f_format": ".mp3", + "s_quality": "128" + } +} \ No newline at end of file diff --git a/deezspot/easy_spoty.py b/deezspot/easy_spoty.py new file mode 100644 index 0000000..64f15d3 --- /dev/null +++ b/deezspot/easy_spoty.py @@ -0,0 +1,252 @@ +#!/usr/bin/python3 + +from spotipy import Spotify +from deezspot.exceptions import InvalidLink +from spotipy.exceptions import SpotifyException +from spotipy.oauth2 import SpotifyClientCredentials +import os + +class Spo: + __error_codes = [404, 400] + + # Class-level API instance and credentials + __api = None + __client_id = None + __client_secret = None + __initialized = False + + @classmethod + def __init__(cls, client_id, client_secret): + """ + Initialize the Spotify API client. + + Args: + client_id (str): Spotify API client ID. + client_secret (str): Spotify API client secret. + """ + if not client_id or not client_secret: + raise ValueError("Spotify API credentials required. Provide client_id and client_secret.") + + client_credentials_manager = SpotifyClientCredentials( + client_id=client_id, + client_secret=client_secret + ) + + # Store the credentials and API instance + cls.__client_id = client_id + cls.__client_secret = client_secret + cls.__api = Spotify( + auth_manager=client_credentials_manager + ) + cls.__initialized = True + + @classmethod + def __check_initialized(cls): + """Check if the class has been initialized with credentials""" + if not cls.__initialized: + raise ValueError("Spotify API not initialized. Call Spo.__init__(client_id, client_secret) first.") + + @classmethod + def __get_api(cls, client_id=None, client_secret=None): + """ + Get a Spotify API instance with the provided credentials or use stored credentials. + + Args: + client_id (str, optional): Spotify API client ID + client_secret (str, optional): Spotify API client secret + + Returns: + A Spotify API instance + """ + # If new credentials are provided, create a new API instance + if client_id and client_secret: + client_credentials_manager = SpotifyClientCredentials( + client_id=client_id, + client_secret=client_secret + ) + return Spotify(auth_manager=client_credentials_manager) + + # Otherwise, use the existing class-level API + cls.__check_initialized() + return cls.__api + + @classmethod + def __lazy(cls, results, api=None): + """Process paginated results""" + api = api or cls.__api + albums = results['items'] + + while results['next']: + results = api.next(results) + albums.extend(results['items']) + + return results + + @classmethod + def get_track(cls, ids, client_id=None, client_secret=None): + """ + Get track information by ID. + + Args: + ids (str): Spotify track ID + client_id (str, optional): Optional custom Spotify client ID + client_secret (str, optional): Optional custom Spotify client secret + + Returns: + dict: Track information + """ + api = cls.__get_api(client_id, client_secret) + try: + track_json = api.track(ids) + except SpotifyException as error: + if error.http_status in cls.__error_codes: + raise InvalidLink(ids) + + return track_json + + @classmethod + def get_album(cls, ids, client_id=None, client_secret=None): + """ + Get album information by ID. + + Args: + ids (str): Spotify album ID + client_id (str, optional): Optional custom Spotify client ID + client_secret (str, optional): Optional custom Spotify client secret + + Returns: + dict: Album information + """ + api = cls.__get_api(client_id, client_secret) + try: + album_json = api.album(ids) + except SpotifyException as error: + if error.http_status in cls.__error_codes: + raise InvalidLink(ids) + + tracks = album_json['tracks'] + cls.__lazy(tracks, api) + + return album_json + + @classmethod + def get_playlist(cls, ids, client_id=None, client_secret=None): + """ + Get playlist information by ID. + + Args: + ids (str): Spotify playlist ID + client_id (str, optional): Optional custom Spotify client ID + client_secret (str, optional): Optional custom Spotify client secret + + Returns: + dict: Playlist information + """ + api = cls.__get_api(client_id, client_secret) + try: + playlist_json = api.playlist(ids) + except SpotifyException as error: + if error.http_status in cls.__error_codes: + raise InvalidLink(ids) + + tracks = playlist_json['tracks'] + cls.__lazy(tracks, api) + + return playlist_json + + @classmethod + def get_episode(cls, ids, client_id=None, client_secret=None): + """ + Get episode information by ID. + + Args: + ids (str): Spotify episode ID + client_id (str, optional): Optional custom Spotify client ID + client_secret (str, optional): Optional custom Spotify client secret + + Returns: + dict: Episode information + """ + api = cls.__get_api(client_id, client_secret) + try: + episode_json = api.episode(ids) + except SpotifyException as error: + if error.http_status in cls.__error_codes: + raise InvalidLink(ids) + + return episode_json + + @classmethod + def search(cls, query, search_type='track', limit=10, client_id=None, client_secret=None): + """ + Search for tracks, albums, artists, or playlists. + + Args: + query (str): Search query + search_type (str, optional): Type of search ('track', 'album', 'artist', 'playlist') + limit (int, optional): Maximum number of results to return + client_id (str, optional): Optional custom Spotify client ID + client_secret (str, optional): Optional custom Spotify client secret + + Returns: + dict: Search results + """ + api = cls.__get_api(client_id, client_secret) + search = api.search(q=query, type=search_type, limit=limit) + return search + + @classmethod + def get_artist(cls, ids, client_id=None, client_secret=None): + """ + Get artist information by ID. + + Args: + ids (str): Spotify artist ID + client_id (str, optional): Optional custom Spotify client ID + client_secret (str, optional): Optional custom Spotify client secret + + Returns: + dict: Artist information + """ + api = cls.__get_api(client_id, client_secret) + try: + artist_json = api.artist(ids) + except SpotifyException as error: + if error.http_status in cls.__error_codes: + raise InvalidLink(ids) + + return artist_json + + @classmethod + def get_artist_discography(cls, ids, album_type='album,single,compilation,appears_on', limit=50, offset=0, client_id=None, client_secret=None): + """ + Get artist information and discography by ID. + + Args: + ids (str): Spotify artist ID + album_type (str, optional): Types of albums to include + limit (int, optional): Maximum number of results + client_id (str, optional): Optional custom Spotify client ID + client_secret (str, optional): Optional custom Spotify client secret + + Returns: + dict: Artist discography + """ + api = cls.__get_api(client_id, client_secret) + try: + # Request all types of releases by the artist. + discography = api.artist_albums( + ids, + album_type=album_type, + limit=limit, + offset=offset + ) + except SpotifyException as error: + if error.http_status in cls.__error_codes: + raise InvalidLink(ids) + else: + raise + + # Ensure that all pages of results are fetched. + cls.__lazy(discography, api) + return discography diff --git a/deezspot/exceptions.py b/deezspot/exceptions.py new file mode 100644 index 0000000..1f1acaa --- /dev/null +++ b/deezspot/exceptions.py @@ -0,0 +1,77 @@ +#!/usr/bin/python3 + +class TrackNotFound(Exception): + def __init__(self, url = None, message = None): + self.url = url + + if not message: + self.message = f"Track {self.url} not found (are you premium?)" + else: + self.message = message + + super().__init__(self.message) + +class AlbumNotFound(Exception): + def __init__(self, url = None): + self.url = url + self.msg = f"Album {self.url} not found (are you premium?)" + super().__init__(self.msg) + +class InvalidLink(Exception): + def __init__(self, url): + self.url = url + self.msg = f"Invalid Link {self.url} :(" + super().__init__(self.msg) + +class QuotaExceeded(Exception): + def __init__(self, message = None): + + if not message: + self.message = "TOO MUCH REQUESTS LIMIT YOURSELF !!! :)" + + super().__init__(self.message) + +class QualityNotFound(Exception): + def __init__(self, quality = None, msg = None): + self.quality = quality + + if not msg: + self.msg = ( + f"The {quality} quality doesn't exist :)\ + \nThe qualities have to be FLAC or MP3_320 or MP3_256 or MP3_128" + ) + else: + self.msg = msg + + super().__init__(self.msg) + +class NoRightOnMedia(Exception): + def __init__(self, msg): + self.msg = msg + super().__init__(msg) + +class NoDataApi(Exception): + def __init__(self, message): + super().__init__(message) + +class BadCredentials(Exception): + def __init__( + self, + arl = None, + email = None, + password = None, + msg = None + ): + if msg: + self.msg = msg + else: + self.arl = arl + self.email = email + self.password = password + + if arl: + self.msg = f"Wrong token: {arl} :(" + else: + self.msg = f"Wrong credentials email: {self.email}, password: {self.password}" + + super().__init__(self.msg) \ No newline at end of file diff --git a/deezspot/libutils/__init__.py b/deezspot/libutils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deezspot/libutils/audio_converter.py b/deezspot/libutils/audio_converter.py new file mode 100644 index 0000000..70593b1 --- /dev/null +++ b/deezspot/libutils/audio_converter.py @@ -0,0 +1,253 @@ +#!/usr/bin/python3 + +import os +import re +import subprocess +import logging +from os.path import exists, basename, dirname +from shutil import which + +logger = logging.getLogger("deezspot") + +# Define available audio formats and their properties +AUDIO_FORMATS = { + "MP3": { + "extension": ".mp3", + "mime": "audio/mpeg", + "ffmpeg_codec": "libmp3lame", + "default_bitrate": "320k", + "bitrates": ["32k", "64k", "96k", "128k", "192k", "256k", "320k"], + }, + "AAC": { + "extension": ".m4a", + "mime": "audio/mp4", + "ffmpeg_codec": "aac", + "default_bitrate": "256k", + "bitrates": ["32k", "64k", "96k", "128k", "192k", "256k"], + }, + "OGG": { + "extension": ".ogg", + "mime": "audio/ogg", + "ffmpeg_codec": "libvorbis", + "default_bitrate": "256k", + "bitrates": ["64k", "96k", "128k", "192k", "256k", "320k"], + }, + "OPUS": { + "extension": ".opus", + "mime": "audio/opus", + "ffmpeg_codec": "libopus", + "default_bitrate": "128k", + "bitrates": ["32k", "64k", "96k", "128k", "192k", "256k"], + }, + "FLAC": { + "extension": ".flac", + "mime": "audio/flac", + "ffmpeg_codec": "flac", + "default_bitrate": None, # Lossless, no bitrate needed + "bitrates": [], + }, + "WAV": { + "extension": ".wav", + "mime": "audio/wav", + "ffmpeg_codec": "pcm_s16le", + "default_bitrate": None, # Lossless, no bitrate needed + "bitrates": [], + }, + "ALAC": { + "extension": ".m4a", + "mime": "audio/mp4", + "ffmpeg_codec": "alac", + "default_bitrate": None, # Lossless, no bitrate needed + "bitrates": [], + } +} + +def check_ffmpeg_available(): + """Check if FFmpeg is installed and available.""" + if which("ffmpeg") is None: + logger.error("FFmpeg is not installed or not in PATH. Audio conversion is unavailable.") + return False + return True + +def parse_format_string(format_string): + """ + Parse a format string like "MP3_320" into (format, bitrate). + Returns (format_name, bitrate) or (None, None) if invalid. + """ + if not format_string or format_string.lower() == "false": + return None, None + + # Check for format with bitrate specification + format_match = re.match(r"^([A-Za-z]+)(?:_(\d+[kK]))?$", format_string) + if format_match: + format_name = format_match.group(1).upper() + bitrate = format_match.group(2) + + # Validate format name + if format_name not in AUDIO_FORMATS: + logger.warning(f"Unknown audio format: {format_name}. Using original format.") + return None, None + + # If format is lossless but bitrate was specified, log a warning + if bitrate and AUDIO_FORMATS[format_name]["default_bitrate"] is None: + logger.warning(f"Bitrate specified for lossless format {format_name}. Ignoring bitrate.") + bitrate = None + + # If bitrate wasn't specified, use default + if not bitrate and AUDIO_FORMATS[format_name]["default_bitrate"]: + bitrate = AUDIO_FORMATS[format_name]["default_bitrate"] + + # Validate bitrate if specified + if bitrate and AUDIO_FORMATS[format_name]["bitrates"] and bitrate.lower() not in [b.lower() for b in AUDIO_FORMATS[format_name]["bitrates"]]: + logger.warning(f"Invalid bitrate {bitrate} for {format_name}. Using default {AUDIO_FORMATS[format_name]['default_bitrate']}.") + bitrate = AUDIO_FORMATS[format_name]["default_bitrate"] + + return format_name, bitrate + + # Simple format name without bitrate + if format_string.upper() in AUDIO_FORMATS: + format_name = format_string.upper() + bitrate = AUDIO_FORMATS[format_name]["default_bitrate"] + return format_name, bitrate + + logger.warning(f"Invalid format specification: {format_string}. Using original format.") + return None, None + +def get_output_path(input_path, format_name): + """Get the output path with the new extension based on the format.""" + if not format_name or format_name not in AUDIO_FORMATS: + return input_path + + dir_name = dirname(input_path) + file_name = basename(input_path) + + # Find the position of the last period to replace extension + dot_pos = file_name.rfind('.') + if dot_pos > 0: + new_file_name = file_name[:dot_pos] + AUDIO_FORMATS[format_name]["extension"] + else: + new_file_name = file_name + AUDIO_FORMATS[format_name]["extension"] + + return os.path.join(dir_name, new_file_name) + +def register_active_download(path): + """ + Register a file as being actively downloaded. + This is a placeholder that both modules implement, so we declare it here + to maintain the interface. + """ + # This function is expected to be overridden by the module + pass + +def unregister_active_download(path): + """ + Unregister a file from the active downloads list. + This is a placeholder that both modules implement, so we declare it here + to maintain the interface. + """ + # This function is expected to be overridden by the module + pass + +def convert_audio(input_path, format_name=None, bitrate=None, register_func=None, unregister_func=None): + """ + Convert audio file to the specified format and bitrate. + + Args: + input_path: Path to the input audio file + format_name: Target format name (e.g., 'MP3', 'OGG', 'FLAC') + bitrate: Target bitrate (e.g., '320k', '128k') + register_func: Function to register a file as being actively downloaded + unregister_func: Function to unregister a file from the active downloads list + + Returns: + Path to the converted file, or the original path if no conversion was done + """ + # Initialize the register and unregister functions + if register_func: + global register_active_download + register_active_download = register_func + + if unregister_func: + global unregister_active_download + unregister_active_download = unregister_func + + # If no format specified or FFmpeg not available, return the original path + if not format_name or not check_ffmpeg_available(): + return input_path + + # Validate format and get format details + if format_name not in AUDIO_FORMATS: + logger.warning(f"Unknown format: {format_name}. Using original format.") + return input_path + + format_details = AUDIO_FORMATS[format_name] + + # Skip conversion if the file is already in the target format + if input_path.lower().endswith(format_details["extension"].lower()): + # Only do conversion if a specific bitrate is requested + if not bitrate or format_details["default_bitrate"] is None: + logger.info(f"File {input_path} is already in {format_name} format. Skipping conversion.") + return input_path + + # Get the output path + output_path = get_output_path(input_path, format_name) + + # Use a temporary file for the conversion to avoid conflicts + temp_output = output_path + ".tmp" + + # Register the temporary file + register_active_download(temp_output) + + try: + cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", input_path] + + # Add bitrate parameter for lossy formats + if bitrate and format_details["bitrates"]: + cmd.extend(["-b:a", bitrate]) + + # Add codec parameter + cmd.extend(["-c:a", format_details["ffmpeg_codec"]]) + + # For some formats, add additional parameters + if format_name == "MP3": + # Use high quality settings for MP3 + if not bitrate or int(bitrate.replace('k', '')) >= 256: + cmd.extend(["-q:a", "0"]) + + # Add output file + cmd.append(temp_output) + + # Run the conversion + logger.info(f"Converting {input_path} to {format_name}" + (f" at {bitrate}" if bitrate else "")) + process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + if process.returncode != 0: + logger.error(f"Audio conversion failed: {process.stderr}") + if exists(temp_output): + os.remove(temp_output) + unregister_active_download(temp_output) + return input_path + + # Register the output file and unregister the temp file + register_active_download(output_path) + + # Rename the temporary file to the final file + os.rename(temp_output, output_path) + unregister_active_download(temp_output) + + # Remove the original file if the conversion was successful and the files are different + if exists(output_path) and input_path != output_path and exists(input_path): + os.remove(input_path) + unregister_active_download(input_path) + + logger.info(f"Successfully converted to {format_name}" + (f" at {bitrate}" if bitrate else "")) + return output_path + + except Exception as e: + logger.error(f"Error during audio conversion: {str(e)}") + # Clean up temp files + if exists(temp_output): + os.remove(temp_output) + unregister_active_download(temp_output) + # Return the original file path + return input_path diff --git a/deezspot/libutils/logging_utils.py b/deezspot/libutils/logging_utils.py new file mode 100644 index 0000000..25aadca --- /dev/null +++ b/deezspot/libutils/logging_utils.py @@ -0,0 +1,69 @@ +#!/usr/bin/python3 + +import logging +import sys +from typing import Optional, Callable, Dict, Any, Union +import json + +# Create the main library logger +logger = logging.getLogger('deezspot') + +def configure_logger( + level: int = logging.INFO, + to_file: Optional[str] = None, + to_console: bool = True, + format_string: str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) -> None: + """ + Configure the deezspot logger with the specified settings. + + Args: + level: Logging level (e.g., logging.INFO, logging.DEBUG) + to_file: Optional file path to write logs + to_console: Whether to output logs to console + format_string: Log message format + """ + # Clear existing handlers to avoid duplicates + logger.handlers = [] + logger.setLevel(level) + + formatter = logging.Formatter(format_string) + + if to_console: + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + if to_file: + file_handler = logging.FileHandler(to_file) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + +class ProgressReporter: + """ + Handles progress reporting for the deezspot library. + Supports both logging and custom callback functions. + """ + def __init__( + self, + callback: Optional[Callable[[Dict[str, Any]], None]] = None, + silent: bool = False, + log_level: int = logging.INFO + ): + self.callback = callback + self.silent = silent + self.log_level = log_level + + def report(self, progress_data: Dict[str, Any]) -> None: + """ + Report progress using the configured method. + + Args: + progress_data: Dictionary containing progress information + """ + if self.callback: + # Call the custom callback function if provided + self.callback(progress_data) + elif not self.silent: + # Log using JSON format + logger.log(self.log_level, json.dumps(progress_data)) \ No newline at end of file diff --git a/deezspot/libutils/others_settings.py b/deezspot/libutils/others_settings.py new file mode 100644 index 0000000..49dc49e --- /dev/null +++ b/deezspot/libutils/others_settings.py @@ -0,0 +1,28 @@ +#!/usr/bin/python3 + +method_saves = ["0", "1", "2", "3"] + +sources = [ + "dee", "spo" +] + +header = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0", + "Accept-Language": "en-US;q=0.5,en;q=0.3" +} + +supported_link = [ + "www.deezer.com", "open.spotify.com", + "deezer.com", "spotify.com", + "deezer.page.link", "www.spotify.com" +] + +answers = ["Y", "y", "Yes", "YES"] +stock_output = "Songs" +stock_recursive_quality = False +stock_recursive_download = False +stock_not_interface = False +stock_zip = False +method_save = 3 +is_thread = False # WARNING FOR TRUE, LOOP ON DEFAULT +stock_real_time_dl = True diff --git a/deezspot/libutils/utils.py b/deezspot/libutils/utils.py new file mode 100644 index 0000000..f609e98 --- /dev/null +++ b/deezspot/libutils/utils.py @@ -0,0 +1,371 @@ +#!/usr/bin/python3 + +import re +from os import makedirs +from datetime import datetime +from urllib.parse import urlparse +from requests import get as req_get +from zipfile import ZipFile, ZIP_DEFLATED +from deezspot.models.track import Track +from deezspot.exceptions import InvalidLink +from deezspot.libutils.others_settings import supported_link, header + +from os.path import ( + isdir, basename, + join, isfile +) + +def link_is_valid(link): + netloc = urlparse(link).netloc + + if not any( + c_link == netloc + for c_link in supported_link + ): + raise InvalidLink(link) + +def get_ids(link): + parsed = urlparse(link) + path = parsed.path + ids = path.split("/")[-1] + + return ids + +def request(url): + thing = req_get(url, headers=header) + return thing + +def __check_dir(directory): + if not isdir(directory): + makedirs(directory) + +def sanitize_name(string, max_length=200): + """Sanitize a string for use as a filename or directory name. + + Args: + string: The string to sanitize + max_length: Maximum length for the resulting string + + Returns: + A sanitized string safe for use in file paths + """ + if string is None: + return "Unknown" + + # Convert to string if not already + string = str(string) + + # Enhance character replacement for filenames + replacements = { + "\\": "-", # Backslash to hyphen + "/": "-", # Forward slash to hyphen + ":": "-", # Colon to hyphen + "*": "+", # Asterisk to plus + "?": "", # Question mark removed + "\"": "'", # Double quote to single quote + "<": "[", # Less than to open bracket + ">": "]", # Greater than to close bracket + "|": "-", # Pipe to hyphen + "&": "and", # Ampersand to 'and' + "$": "s", # Dollar to 's' + ";": ",", # Semicolon to comma + "\t": " ", # Tab to space + "\n": " ", # Newline to space + "\r": " ", # Carriage return to space + "\0": "", # Null byte removed + } + + for old, new in replacements.items(): + string = string.replace(old, new) + + # Remove any other non-printable characters + string = ''.join(char for char in string if char.isprintable()) + + # Remove leading/trailing whitespace + string = string.strip() + + # Replace multiple spaces with a single space + string = re.sub(r'\s+', ' ', string) + + # Truncate if too long + if len(string) > max_length: + string = string[:max_length] + + # Ensure we don't end with a dot or space (can cause issues in some filesystems) + string = string.rstrip('. ') + + # Provide a fallback for empty strings + if not string: + string = "Unknown" + + return string + +# Keep the original function name for backward compatibility +def var_excape(string): + """Legacy function name for backward compatibility.""" + return sanitize_name(string) + +def convert_to_date(date: str): + if date == "0000-00-00": + date = "0001-01-01" + elif date.isdigit(): + date = f"{date}-01-01" + date = datetime.strptime(date, "%Y-%m-%d") + return date + +def what_kind(link): + url = request(link).url + if url.endswith("/"): + url = url[:-1] + return url + +def __get_tronc(string): + l_encoded = len(string.encode()) + if l_encoded > 242: + n_tronc = len(string) - l_encoded - 242 + else: + n_tronc = 242 + return n_tronc + +def apply_custom_format(format_str, metadata: dict, pad_tracks=True) -> str: + """ + Replaces placeholders in the format string with values from metadata. + Placeholders are denoted by %key%, for example: "%ar_album%/%album%". + The pad_tracks parameter controls whether track numbers are padded with leading zeros. + """ + def replacer(match): + key = match.group(1) + # Alias and special keys + if key == 'album_artist': + raw_value = metadata.get('ar_album', metadata.get('album_artist')) + elif key == 'year': + raw_value = metadata.get('release_date', metadata.get('year')) + elif key == 'date': + raw_value = metadata.get('release_date', metadata.get('date')) + elif key == 'discnum': + raw_value = metadata.get('disc_number', metadata.get('discnum')) + else: + # All other placeholders map directly + raw_value = metadata.get(key) + + # Friendly names for missing metadata + key_mappings = { + 'ar_album': 'album artist', + 'album_artist': 'album artist', + 'artist': 'artist', + 'album': 'album', + 'tracknum': 'track number', + 'discnum': 'disc number', + 'date': 'release date', + 'year': 'year', + 'genre': 'genre', + 'isrc': 'ISRC', + 'explicit': 'explicit flag', + 'duration': 'duration', + 'publisher': 'publisher', + 'composer': 'composer', + 'copyright': 'copyright', + 'author': 'author', + 'lyricist': 'lyricist', + 'version': 'version', + 'comment': 'comment', + 'encodedby': 'encoded by', + 'language': 'language', + 'lyrics': 'lyrics', + 'mood': 'mood', + 'rating': 'rating', + 'website': 'website', + 'replaygain_album_gain': 'replaygain album gain', + 'replaygain_album_peak': 'replaygain album peak', + 'replaygain_track_gain': 'replaygain track gain', + 'replaygain_track_peak': 'replaygain track peak', + } + + # Custom formatting for specific keys + if key == 'tracknum' and pad_tracks and raw_value not in (None, ''): + try: + return sanitize_name(f"{int(raw_value):02d}") + except (ValueError, TypeError): + pass + if key == 'discnum' and raw_value not in (None, ''): + try: + return sanitize_name(f"{int(raw_value):02d}") + except (ValueError, TypeError): + pass + if key == 'year' and raw_value not in (None, ''): + m = re.match(r"^(\d{4})", str(raw_value)) + if m: + return sanitize_name(m.group(1)) + + # Handle missing metadata with descriptive default + if raw_value in (None, ''): + friendly = key_mappings.get(key, key.replace('_', ' ')) + return sanitize_name(f"Unknown {friendly}") + + # Default handling + return sanitize_name(str(raw_value)) + return re.sub(r'%(\w+)%', replacer, format_str) + +def __get_dir(song_metadata, output_dir, method_save, custom_dir_format=None, pad_tracks=True): + """ + Returns the final directory based either on a custom directory format string + or the legacy method_save logic. + """ + if song_metadata is None: + raise ValueError("song_metadata cannot be None") + + if custom_dir_format is not None: + # Use the custom format string + dir_name = apply_custom_format(custom_dir_format, song_metadata, pad_tracks) + else: + # Legacy logic based on method_save (for episodes or albums) + if 'show' in song_metadata and 'name' in song_metadata: + show = var_excape(song_metadata.get('show', '')) + episode = var_excape(song_metadata.get('name', '')) + if show and episode: + dir_name = f"{show} - {episode}" + elif show: + dir_name = show + elif episode: + dir_name = episode + else: + dir_name = "Unknown Episode" + else: + album = var_excape(song_metadata.get('album', '')) + ar_album = var_excape(song_metadata.get('ar_album', '')) + if method_save == 0: + dir_name = f"{album} - {ar_album}" + elif method_save == 1: + dir_name = f"{ar_album}/{album}" + elif method_save == 2: + dir_name = f"{album} - {ar_album}" + elif method_save == 3: + dir_name = f"{album} - {ar_album}" + else: + dir_name = "Unknown" + + # Prevent absolute paths and sanitize each directory segment + dir_name = dir_name.strip('/') + dir_name = '/'.join(sanitize_name(seg) for seg in dir_name.split('/') if seg) + final_dir = join(output_dir, dir_name) + if not isdir(final_dir): + makedirs(final_dir) + return final_dir + +def set_path( + song_metadata, output_dir, + song_quality, file_format, method_save, + is_episode=False, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True +): + if song_metadata is None: + raise ValueError("song_metadata cannot be None") + + if is_episode: + if custom_track_format is not None: + song_name = apply_custom_format(custom_track_format, song_metadata, pad_tracks) + else: + show = var_excape(song_metadata.get('show', '')) + episode = var_excape(song_metadata.get('name', '')) + if show and episode: + song_name = f"{show} - {episode}" + elif show: + song_name = show + elif episode: + song_name = episode + else: + song_name = "Unknown Episode" + else: + if custom_track_format is not None: + song_name = apply_custom_format(custom_track_format, song_metadata, pad_tracks) + else: + album = var_excape(song_metadata.get('album', '')) + artist = var_excape(song_metadata.get('artist', '')) + music = var_excape(song_metadata.get('music', '')) # Track title + discnum = song_metadata.get('discnum', '') + tracknum = song_metadata.get('tracknum', '') + + if method_save == 0: + song_name = f"{album} CD {discnum} TRACK {tracknum}" + elif method_save == 1: + try: + if pad_tracks: + tracknum = f"{int(tracknum):02d}" # Format as two digits with padding + else: + tracknum = f"{int(tracknum)}" # Format without padding + except (ValueError, TypeError): + pass # Fallback to raw value + tracknum_clean = var_excape(str(tracknum)) + tracktitle_clean = var_excape(music) + song_name = f"{tracknum_clean}. {tracktitle_clean}" + elif method_save == 2: + isrc = song_metadata.get('isrc', '') + song_name = f"{music} - {artist} [{isrc}]" + elif method_save == 3: + song_name = f"{discnum}|{tracknum} - {music} - {artist}" + + # Sanitize song_name to remove invalid chars and prevent '/' + song_name = sanitize_name(song_name) + # Truncate to avoid filesystem limits + max_length = 255 - len(output_dir) - len(file_format) + song_name = song_name[:max_length] + + # Build final path + song_dir = __get_dir(song_metadata, output_dir, method_save, custom_dir_format, pad_tracks) + __check_dir(song_dir) + n_tronc = __get_tronc(song_name) + song_path = f"{song_dir}/{song_name[:n_tronc]}{file_format}" + return song_path + +def create_zip( + tracks: list[Track], + output_dir=None, + song_metadata=None, + song_quality=None, + method_save=0, + zip_name=None +): + if not zip_name: + album = var_excape(song_metadata.get('album', '')) + song_dir = __get_dir(song_metadata, output_dir, method_save) + if method_save == 0: + zip_name = f"{album}" + elif method_save == 1: + artist = var_excape(song_metadata.get('ar_album', '')) + zip_name = f"{album} - {artist}" + elif method_save == 2: + artist = var_excape(song_metadata.get('ar_album', '')) + upc = song_metadata.get('upc', '') + zip_name = f"{album} - {artist} {upc}" + elif method_save == 3: + artist = var_excape(song_metadata.get('ar_album', '')) + upc = song_metadata.get('upc', '') + zip_name = f"{album} - {artist} {upc}" + n_tronc = __get_tronc(zip_name) + zip_name = zip_name[:n_tronc] + zip_name += ".zip" + zip_path = f"{song_dir}/{zip_name}" + else: + zip_path = zip_name + + z = ZipFile(zip_path, "w", ZIP_DEFLATED) + for track in tracks: + if not track.success: + continue + c_song_path = track.song_path + song_path = basename(c_song_path) + if not isfile(c_song_path): + continue + z.write(c_song_path, song_path) + z.close() + return zip_path + +def trasform_sync_lyric(lyric): + sync_array = [] + for a in lyric: + if "milliseconds" in a: + arr = (a['line'], int(a['milliseconds'])) + sync_array.append(arr) + return sync_array diff --git a/deezspot/models/__init__.py b/deezspot/models/__init__.py new file mode 100644 index 0000000..2847ff6 --- /dev/null +++ b/deezspot/models/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/python3 + +from deezspot.models.smart import Smart +from deezspot.models.track import Track +from deezspot.models.album import Album +from deezspot.models.playlist import Playlist +from deezspot.models.preferences import Preferences +from deezspot.models.episode import Episode diff --git a/deezspot/models/album.py b/deezspot/models/album.py new file mode 100644 index 0000000..6cfb958 --- /dev/null +++ b/deezspot/models/album.py @@ -0,0 +1,20 @@ +#!/usr/bin/python3 + +from deezspot.models.track import Track + +class Album: + def __init__(self, ids: int) -> None: + self.tracks: list[Track] = [] + self.zip_path = None + self.image = None + self.album_quality = None + self.md5_image = None + self.ids = ids + self.nb_tracks = None + self.album_name = None + self.upc = None + self.tags = None + self.__set_album_md5() + + def __set_album_md5(self): + self.album_md5 = f"album/{self.ids}" \ No newline at end of file diff --git a/deezspot/models/episode.py b/deezspot/models/episode.py new file mode 100644 index 0000000..421252a --- /dev/null +++ b/deezspot/models/episode.py @@ -0,0 +1,34 @@ +#!/usr/bin/python3 + +class Episode: + def __init__( + self, + tags: dict, + episode_path: str, + file_format: str, + quality: str, + link: str, + ids: int + ) -> None: + self.tags = tags + self.__set_tags() + self.episode_name = f"{self.name} - {self.show}" + self.episode_path = episode_path + self.file_format = file_format + self.quality = quality + self.link = link + self.ids = ids + self.md5_image = None + self.success = True + self.__set_episode_md5() + + def __set_tags(self): + for tag, value in self.tags.items(): + setattr(self, tag, value) + + def __set_episode_md5(self): + self.episode_md5 = f"episode/{self.ids}" + + def set_fallback_ids(self, fallback_ids): + self.fallback_ids = fallback_ids + self.fallback_episode_md5 = f"episode/{self.fallback_ids}" \ No newline at end of file diff --git a/deezspot/models/playlist.py b/deezspot/models/playlist.py new file mode 100644 index 0000000..53c9aa7 --- /dev/null +++ b/deezspot/models/playlist.py @@ -0,0 +1,8 @@ +#!/usr/bin/python3 + +from deezspot.models.track import Track + +class Playlist: + def __init__(self) -> None: + self.tracks: list[Track] = [] + self.zip_path = None \ No newline at end of file diff --git a/deezspot/models/preferences.py b/deezspot/models/preferences.py new file mode 100644 index 0000000..557bf6d --- /dev/null +++ b/deezspot/models/preferences.py @@ -0,0 +1,22 @@ +#!/usr/bin/python3 + +class Preferences: + def __init__(self) -> None: + self.link = None + self.song_metadata: dict = None + self.quality_download = None + self.output_dir = None + self.ids = None + self.json_data = None + self.recursive_quality = None + self.recursive_download = None + self.not_interface = None + self.method_save = None + self.make_zip = None + self.real_time_dl = None , + self.custom_dir_format = None, + self.custom_track_format = None, + self.pad_tracks = True # Default to padded track numbers (01, 02, etc.) + self.initial_retry_delay = 30 # Default initial retry delay in seconds + self.retry_delay_increase = 30 # Default increase in delay between retries in seconds + self.max_retries = 5 # Default maximum number of retries per track \ No newline at end of file diff --git a/deezspot/models/smart.py b/deezspot/models/smart.py new file mode 100644 index 0000000..38a980e --- /dev/null +++ b/deezspot/models/smart.py @@ -0,0 +1,13 @@ +#!/usr/bin/python3 + +from deezspot.models.track import Track +from deezspot.models.album import Album +from deezspot.models.playlist import Playlist + +class Smart: + def __init__(self) -> None: + self.track: Track = None + self.album: Album = None + self.playlist: Playlist = None + self.type = None + self.source = None \ No newline at end of file diff --git a/deezspot/models/track.py b/deezspot/models/track.py new file mode 100644 index 0000000..234279c --- /dev/null +++ b/deezspot/models/track.py @@ -0,0 +1,37 @@ +#!/usr/bin/python3 + +class Track: + def __init__( + self, + tags: dict, + song_path: str, + file_format: str, + quality: str, + link: str, + ids: int +) -> None: + + self.tags = tags + self.__set_tags() + self.song_name = f"{self.music} - {self.artist}" + self.song_path = song_path + self.file_format = file_format + self.quality = quality + self.link = link + self.ids = ids + self.md5_image = None + self.success = True + self.__set_track_md5() + + def __set_tags(self): + for tag, value in self.tags.items(): + setattr( + self, tag, value + ) + + def __set_track_md5(self): + self.track_md5 = f"track/{self.ids}" + + def set_fallback_ids(self, fallback_ids): + self.fallback_ids = fallback_ids + self.fallback_track_md5 = f"track/{self.fallback_ids}" \ No newline at end of file diff --git a/deezspot/spotloader/__download__.py b/deezspot/spotloader/__download__.py new file mode 100644 index 0000000..112eb0c --- /dev/null +++ b/deezspot/spotloader/__download__.py @@ -0,0 +1,1441 @@ +import traceback +import json +import os +import time +import signal +import atexit +import sys +from copy import deepcopy +from os.path import isfile, dirname +from librespot.core import Session +from deezspot.exceptions import TrackNotFound +from librespot.metadata import TrackId, EpisodeId +from deezspot.spotloader.spotify_settings import qualities +from deezspot.libutils.others_settings import answers +from deezspot.__taggers__ import write_tags, check_track +from librespot.audio.decoders import AudioQuality, VorbisOnlyAudioQuality +from deezspot.libutils.audio_converter import convert_audio, parse_format_string +from os import ( + remove, + system, + replace as os_replace, +) +from deezspot.models import ( + Track, + Album, + Playlist, + Preferences, + Episode, +) +from deezspot.libutils.utils import ( + set_path, + create_zip, + request, +) +from mutagen import File +from mutagen.easyid3 import EasyID3 +from mutagen.oggvorbis import OggVorbis +from mutagen.flac import FLAC +from mutagen.mp4 import MP4 +from deezspot.libutils.logging_utils import logger + +# --- Global retry counter variables --- +GLOBAL_RETRY_COUNT = 0 +GLOBAL_MAX_RETRIES = 100 # Adjust this value as needed + +# --- Global tracking of active downloads --- +ACTIVE_DOWNLOADS = set() +CLEANUP_LOCK = False +CURRENT_DOWNLOAD = None + +def register_active_download(file_path): + """Register a file as being actively downloaded""" + global CURRENT_DOWNLOAD + ACTIVE_DOWNLOADS.add(file_path) + CURRENT_DOWNLOAD = file_path + +def unregister_active_download(file_path): + """Remove a file from the active downloads list""" + global CURRENT_DOWNLOAD + if file_path in ACTIVE_DOWNLOADS: + ACTIVE_DOWNLOADS.remove(file_path) + if CURRENT_DOWNLOAD == file_path: + CURRENT_DOWNLOAD = None + +def cleanup_active_downloads(): + """Clean up any incomplete downloads during process termination""" + global CLEANUP_LOCK, CURRENT_DOWNLOAD + if CLEANUP_LOCK: + return + + CLEANUP_LOCK = True + # Only remove the file that was in progress when stopped + if CURRENT_DOWNLOAD: + try: + if os.path.exists(CURRENT_DOWNLOAD): + logger.info(f"Removing incomplete download: {CURRENT_DOWNLOAD}") + os.remove(CURRENT_DOWNLOAD) + unregister_active_download(CURRENT_DOWNLOAD) + except Exception as e: + logger.error(f"Error cleaning up file {CURRENT_DOWNLOAD}: {str(e)}") + CLEANUP_LOCK = False + +# Register the cleanup function to run on exit +atexit.register(cleanup_active_downloads) + +# Set up signal handlers +def signal_handler(sig, frame): + logger.info(f"Received termination signal {sig}. Cleaning up...") + cleanup_active_downloads() + if sig == signal.SIGINT: + logger.info("CTRL+C received. Exiting...") + sys.exit(0) + +# Register signal handlers for common termination signals +signal.signal(signal.SIGINT, signal_handler) # CTRL+C +signal.signal(signal.SIGTERM, signal_handler) # Normal termination +try: + # These may not be available on all platforms + signal.signal(signal.SIGHUP, signal_handler) # Terminal closed + signal.signal(signal.SIGQUIT, signal_handler) # CTRL+\ +except AttributeError: + pass + +class Download_JOB: + session = None + progress_reporter = None + + @classmethod + def __init__(cls, session: Session) -> None: + cls.session = session + + @classmethod + def set_progress_reporter(cls, reporter): + cls.progress_reporter = reporter + + @classmethod + def report_progress(cls, progress_data): + """Report progress if a reporter is configured.""" + if cls.progress_reporter: + cls.progress_reporter.report(progress_data) + else: + # Fallback to logger if no reporter is configured + logger.info(json.dumps(progress_data)) + +class EASY_DW: + def __init__( + self, + preferences: Preferences, + parent: str = None # Can be 'album', 'playlist', or None for individual track + ) -> None: + + self.__preferences = preferences + self.__parent = parent # Store the parent type + + self.__ids = preferences.ids + self.__link = preferences.link + self.__output_dir = preferences.output_dir + self.__method_save = preferences.method_save + self.__song_metadata = preferences.song_metadata + self.__not_interface = preferences.not_interface + self.__quality_download = preferences.quality_download or "NORMAL" + self.__recursive_download = preferences.recursive_download + self.__type = "episode" if preferences.is_episode else "track" # New type parameter + self.__real_time_dl = preferences.real_time_dl + self.__convert_to = getattr(preferences, 'convert_to', None) + + self.__c_quality = qualities[self.__quality_download] + self.__fallback_ids = self.__ids + + self.__set_quality() + if preferences.is_episode: + self.__write_episode() + else: + self.__write_track() + + def __set_quality(self) -> None: + self.__dw_quality = self.__c_quality['n_quality'] + self.__file_format = self.__c_quality['f_format'] + self.__song_quality = self.__c_quality['s_quality'] + + def __set_song_path(self) -> None: + # Retrieve custom formatting strings from preferences, if any. + custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None) + custom_track_format = getattr(self.__preferences, 'custom_track_format', None) + pad_tracks = getattr(self.__preferences, 'pad_tracks', True) + self.__song_path = set_path( + self.__song_metadata, + self.__output_dir, + self.__song_quality, + self.__file_format, + self.__method_save, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks + ) + + def __set_episode_path(self) -> None: + custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None) + custom_track_format = getattr(self.__preferences, 'custom_track_format', None) + pad_tracks = getattr(self.__preferences, 'pad_tracks', True) + self.__song_path = set_path( + self.__song_metadata, + self.__output_dir, + self.__song_quality, + self.__file_format, + self.__method_save, + is_episode=True, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks + ) + + def __write_track(self) -> None: + self.__set_song_path() + self.__c_track = Track( + self.__song_metadata, self.__song_path, + self.__file_format, self.__song_quality, + self.__link, self.__ids + ) + self.__c_track.md5_image = self.__ids + self.__c_track.set_fallback_ids(self.__fallback_ids) + + def __write_episode(self) -> None: + self.__set_episode_path() + self.__c_episode = Episode( + self.__song_metadata, self.__song_path, + self.__file_format, self.__song_quality, + self.__link, self.__ids + ) + self.__c_episode.md5_image = self.__ids + self.__c_episode.set_fallback_ids(self.__fallback_ids) + + def __convert_audio(self) -> None: + # First, handle Spotify's OGG to standard format conversion (always needed) + temp_filename = self.__song_path.replace(".ogg", ".tmp") + os_replace(self.__song_path, temp_filename) + + # Register the temporary file + register_active_download(temp_filename) + + try: + # Step 1: First convert the OGG file to standard format + ffmpeg_cmd = f"ffmpeg -y -hide_banner -loglevel error -i \"{temp_filename}\" -c:a copy \"{self.__song_path}\"" + system(ffmpeg_cmd) + + # Register the new output file and unregister the temp file + register_active_download(self.__song_path) + + # Remove the temporary file + if os.path.exists(temp_filename): + remove(temp_filename) + unregister_active_download(temp_filename) + + # Step 2: Convert to requested format if specified + if self.__convert_to: + format_name, bitrate = parse_format_string(self.__convert_to) + if format_name: + try: + # Convert to the requested format using our standardized converter + converted_path = convert_audio( + self.__song_path, + format_name, + bitrate, + register_active_download, + unregister_active_download + ) + if converted_path != self.__song_path: + # Update the path to the converted file + self.__song_path = converted_path + self.__c_track.song_path = converted_path + except Exception as conv_error: + # Log conversion error but continue with original file + logger.error(f"Audio conversion error: {str(conv_error)}") + + except Exception as e: + # In case of failure, try to restore the original file + if os.path.exists(temp_filename) and not os.path.exists(self.__song_path): + os_replace(temp_filename, self.__song_path) + + # Clean up temp files + if os.path.exists(temp_filename): + remove(temp_filename) + unregister_active_download(temp_filename) + + # Re-throw the exception + raise e + + def get_no_dw_track(self) -> Track: + return self.__c_track + + def easy_dw(self) -> Track: + # Request the image data + pic = self.__song_metadata['image'] + image = request(pic).content + self.__song_metadata['image'] = image + + try: + # Initialize success to False, it will be set to True if download_try is successful + if hasattr(self, '_EASY_DW__c_track') and self.__c_track: + self.__c_track.success = False + elif hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: # For episodes + self.__c_episode.success = False + + self.download_try() # This should set self.__c_track.success = True if successful + + except Exception as e: + song_title = self.__song_metadata.get('music', 'Unknown Song') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + error_message = f"Download failed for '{song_title}' by '{artist_name}' (URL: {self.__link}). Original error: {str(e)}" + logger.error(error_message) + traceback.print_exc() + # Store the error message on the track object if it exists + if hasattr(self, '_EASY_DW__c_track') and self.__c_track: + self.__c_track.success = False + self.__c_track.error_message = error_message # Store the more detailed error message + # Removed problematic elif for __c_episode here as easy_dw in spotloader is focused on tracks. + # Episode-specific error handling should be within download_eps or its callers. + raise TrackNotFound(message=error_message, url=self.__link) from e + + # If the track was skipped (e.g. file already exists), return it immediately. + # download_try sets success=False and was_skipped=True in this case. + if hasattr(self, '_EASY_DW__c_track') and self.__c_track and getattr(self.__c_track, 'was_skipped', False): + return self.__c_track + + # Final check for non-skipped tracks that might have failed after download_try returned. + # This handles cases where download_try didn't raise an exception but self.__c_track.success is still False. + if hasattr(self, '_EASY_DW__c_track') and self.__c_track and not self.__c_track.success: + song_title = self.__song_metadata.get('music', 'Unknown Song') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + original_error_msg = getattr(self.__c_track, 'error_message', "Download failed for an unspecified reason after attempt.") + error_msg_template = "Cannot download '{title}' by '{artist}'. Reason: {reason}" + final_error_msg = error_msg_template.format(title=song_title, artist=artist_name, reason=original_error_msg) + current_link = self.__c_track.link if hasattr(self.__c_track, 'link') and self.__c_track.link else self.__link + logger.error(f"{final_error_msg} (URL: {current_link})") + self.__c_track.error_message = final_error_msg # Ensure the most specific error is on the object + raise TrackNotFound(message=final_error_msg, url=current_link) + + # If we reach here, the track should be successful and not skipped. + if hasattr(self, '_EASY_DW__c_track') and self.__c_track and self.__c_track.success: + write_tags(self.__c_track) + + return self.__c_track # Return the track object + + def track_exists(self, title, album): + try: + # Ensure the final song path is set + if not hasattr(self, '_EASY_DW__song_path') or not self.__song_path: + self.__set_song_path() + + # Use only the final directory for scanning + final_dir = os.path.dirname(self.__song_path) + + # If the final directory doesn't exist, there are no files to check + if not os.path.exists(final_dir): + return False + + # Iterate over files only in the final directory + for file in os.listdir(final_dir): + if file.lower().endswith(('.mp3', '.ogg', '.flac', '.wav', '.m4a', '.opus')): + file_path = os.path.join(final_dir, file) + existing_title, existing_album = self.read_metadata(file_path) + if existing_title == title and existing_album == album: + logger.info(f"Found existing track: {title} - {album}") + return True + return False + except Exception as e: + logger.error(f"Error checking if track exists: {str(e)}") + return False + + def read_metadata(self, file_path): + try: + if not os.path.isfile(file_path): + return None, None + audio = File(file_path) + if audio is None: + return None, None + title = None + album = None + if file_path.endswith('.mp3'): + try: + audio = EasyID3(file_path) + title = audio.get('title', [None])[0] + album = audio.get('album', [None])[0] + except Exception as e: + logger.error(f"Error reading MP3 metadata: {str(e)}") + elif file_path.endswith('.ogg'): + audio = OggVorbis(file_path) + title = audio.get('title', [None])[0] + album = audio.get('album', [None])[0] + elif file_path.endswith('.flac'): + audio = FLAC(file_path) + title = audio.get('title', [None])[0] + album = audio.get('album', [None])[0] + elif file_path.endswith('.m4a'): + audio = MP4(file_path) + title = audio.get('\xa9nam', [None])[0] + album = audio.get('\xa9alb', [None])[0] + else: + return None, None + return title, album + except Exception as e: + logger.error(f"Error reading metadata from {file_path}: {str(e)}") + return None, None + + def download_try(self) -> Track: + current_title = self.__song_metadata.get('music') + current_album = self.__song_metadata.get('album') + current_artist = self.__song_metadata.get('artist') + + if self.track_exists(current_title, current_album): + # Create skipped progress report using new format + progress_data = { + "type": "track", + "song": current_title, + "artist": current_artist, + "status": "skipped", + "url": self.__link, + "reason": "Track already exists", + "convert_to": self.__convert_to + } + + # Add parent info based on parent type + if self.__parent == "playlist" and hasattr(self.__preferences, "json_data"): + playlist_data = self.__preferences.json_data + playlist_name = playlist_data.get('name', 'unknown') + total_tracks = playlist_data.get('tracks', {}).get('total', 'unknown') + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "playlist", + "name": playlist_name, + "owner": playlist_data.get('owner', {}).get('display_name', 'unknown') + } + }) + elif self.__parent == "album": + album_name = self.__song_metadata.get('album', '') + album_artist = self.__song_metadata.get('album_artist', self.__song_metadata.get('ar_album', '')) + total_tracks = self.__song_metadata.get('nb_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "album", + "title": album_name, + "artist": album_artist + } + }) + + Download_JOB.report_progress(progress_data) + + # Mark track as intentionally skipped + self.__c_track.success = False + self.__c_track.was_skipped = True + return self.__c_track + + retries = 0 + # Use the customizable retry parameters + retry_delay = getattr(self.__preferences, 'initial_retry_delay', 30) # Default to 30 seconds + retry_delay_increase = getattr(self.__preferences, 'retry_delay_increase', 30) # Default to 30 seconds + max_retries = getattr(self.__preferences, 'max_retries', 5) # Default to 5 retries + + while True: + try: + track_id_obj = TrackId.from_base62(self.__ids) + stream = Download_JOB.session.content_feeder().load_track( + track_id_obj, + VorbisOnlyAudioQuality(self.__dw_quality), + False, + None + ) + c_stream = stream.input_stream.stream() + total_size = stream.input_stream.size + + os.makedirs(dirname(self.__song_path), exist_ok=True) + + # Register this file as being actively downloaded + register_active_download(self.__song_path) + + try: + with open(self.__song_path, "wb") as f: + if self.__real_time_dl and self.__song_metadata.get("duration"): + # Real-time download path + duration = self.__song_metadata["duration"] + if duration > 0: + rate_limit = total_size / duration + chunk_size = 4096 + bytes_written = 0 + start_time = time.time() + + # Initialize tracking variable for percentage reporting + self._last_reported_percentage = -1 + + while True: + chunk = c_stream.read(chunk_size) + if not chunk: + break + f.write(chunk) + bytes_written += len(chunk) + + # Calculate current percentage (as integer) + current_time = time.time() + current_percentage = int((bytes_written / total_size) * 100) + + # Only report when percentage increases by at least 1 point + if current_percentage > self._last_reported_percentage: + self._last_reported_percentage = current_percentage + + # Create real-time progress data + progress_data = { + "type": "track", + "song": self.__song_metadata.get("music", ""), + "artist": self.__song_metadata.get("artist", ""), + "status": "real-time", + "url": self.__link, + "time_elapsed": int((current_time - start_time) * 1000), + "progress": current_percentage, + "convert_to": self.__convert_to + } + + # Add parent info based on parent type + if self.__parent == "playlist" and hasattr(self.__preferences, "json_data"): + playlist_data = self.__preferences.json_data + playlist_name = playlist_data.get('name', 'unknown') + total_tracks = playlist_data.get('tracks', {}).get('total', 'unknown') + current_track = getattr(self.__preferences, 'track_number', 0) + playlist_owner = playlist_data.get('owner', {}).get('display_name', 'unknown') + playlist_id = playlist_data.get('id', '') + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "playlist", + "name": playlist_name, + "owner": playlist_owner, + "total_tracks": total_tracks, + "url": f"https://open.spotify.com/playlist/{playlist_id}" + } + }) + elif self.__parent == "album": + album_name = self.__song_metadata.get('album', '') + album_artist = self.__song_metadata.get('album_artist', self.__song_metadata.get('ar_album', '')) + total_tracks = self.__song_metadata.get('nb_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "album", + "title": album_name, + "artist": album_artist, + "total_tracks": total_tracks, + "url": f"https://open.spotify.com/album/{self.__song_metadata.get('album_id', '')}" + } + }) + + # Report the progress + Download_JOB.report_progress(progress_data) + + # Rate limiting (if needed) + expected_time = bytes_written / rate_limit + if expected_time > (time.time() - start_time): + time.sleep(expected_time - (time.time() - start_time)) + else: + # Non real-time download path + data = c_stream.read(total_size) + f.write(data) + + # Close the stream after successful write + c_stream.close() + + # After successful download, unregister the file + unregister_active_download(self.__song_path) + break + + except Exception as e: + # Handle any exceptions that might occur during download + error_msg = f"Error during download process: {str(e)}" + logger.error(error_msg) + + # Clean up resources + if 'c_stream' in locals(): + try: + c_stream.close() + except Exception: + pass + + # Remove partial download if it exists + if os.path.exists(self.__song_path): + try: + os.remove(self.__song_path) + except Exception: + pass + + # Unregister the download + unregister_active_download(self.__song_path) + + # After successful download, unregister the file (moved here from below) + unregister_active_download(self.__song_path) + break + + except Exception as e: + # Handle retry logic + global GLOBAL_RETRY_COUNT + GLOBAL_RETRY_COUNT += 1 + retries += 1 + + # Clean up any incomplete file + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + unregister_active_download(self.__song_path) + progress_data = { + "type": "track", + "status": "retrying", + "retry_count": retries, + "seconds_left": retry_delay, + "song": self.__song_metadata.get('music', ''), + "artist": self.__song_metadata.get('artist', ''), + "album": self.__song_metadata.get('album', ''), + "error": str(e), + "url": self.__link, + "convert_to": self.__convert_to + } + + # Add parent info based on parent type + if self.__parent == "playlist" and hasattr(self.__preferences, "json_data"): + playlist_data = self.__preferences.json_data + playlist_name = playlist_data.get('name', 'unknown') + total_tracks = playlist_data.get('tracks', {}).get('total', 'unknown') + current_track = getattr(self.__preferences, 'track_number', 0) + playlist_owner = playlist_data.get('owner', {}).get('display_name', 'unknown') + playlist_id = playlist_data.get('id', '') + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "playlist", + "name": playlist_name, + "owner": playlist_owner, + "total_tracks": total_tracks, + "url": f"https://open.spotify.com/playlist/{playlist_id}" + } + }) + elif self.__parent == "album": + album_name = self.__song_metadata.get('album', '') + album_artist = self.__song_metadata.get('album_artist', self.__song_metadata.get('ar_album', '')) + total_tracks = self.__song_metadata.get('nb_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + album_id = self.__song_metadata.get('album_id', '') + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "album", + "title": album_name, + "artist": album_artist, + "total_tracks": total_tracks, + "url": f"https://open.spotify.com/album/{album_id}" + } + }) + + Download_JOB.report_progress(progress_data) + if retries >= max_retries or GLOBAL_RETRY_COUNT >= GLOBAL_MAX_RETRIES: + # Final cleanup before giving up + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + # Add track info to exception + track_name = self.__song_metadata.get('music', 'Unknown Track') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + final_error_msg = f"Maximum retry limit reached for '{track_name}' by '{artist_name}' (local: {max_retries}, global: {GLOBAL_MAX_RETRIES}). Last error: {str(e)}" + # Store error on track object + if hasattr(self, '_EASY_DW__c_track') and self.__c_track: + self.__c_track.success = False + self.__c_track.error_message = final_error_msg + raise Exception(final_error_msg) from e + time.sleep(retry_delay) + retry_delay += retry_delay_increase # Use the custom retry delay increase + + try: + self.__convert_audio() + except Exception as e: + # Improve error message formatting + original_error_str = str(e) + if "codec" in original_error_str.lower(): + error_msg = "Audio conversion error - Missing codec or unsupported format" + elif "ffmpeg" in original_error_str.lower(): + error_msg = "FFmpeg error - Audio conversion failed" + else: + error_msg = f"Audio conversion failed: {original_error_str}" + + # Create standardized error format + progress_data = { + "type": "track", + "status": "error", + "song": self.__song_metadata.get('music', ''), + "artist": self.__song_metadata.get('artist', ''), + "error": error_msg, + "url": self.__link, + "convert_to": self.__convert_to + } + + # Add parent info based on parent type + if self.__parent == "playlist" and hasattr(self.__preferences, "json_data"): + playlist_data = self.__preferences.json_data + playlist_name = playlist_data.get('name', 'unknown') + total_tracks = playlist_data.get('tracks', {}).get('total', 'unknown') + current_track = getattr(self.__preferences, 'track_number', 0) + playlist_owner = playlist_data.get('owner', {}).get('display_name', 'unknown') + playlist_id = playlist_data.get('id', '') + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "playlist", + "name": playlist_name, + "owner": playlist_owner, + "total_tracks": total_tracks, + "url": f"https://open.spotify.com/playlist/{playlist_id}" + } + }) + elif self.__parent == "album": + album_name = self.__song_metadata.get('album', '') + album_artist = self.__song_metadata.get('album_artist', self.__song_metadata.get('ar_album', '')) + total_tracks = self.__song_metadata.get('nb_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + album_id = self.__song_metadata.get('album_id', '') + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "album", + "title": album_name, + "artist": album_artist, + "total_tracks": total_tracks, + "url": f"https://open.spotify.com/album/{album_id}" + } + }) + + # Report the error + Download_JOB.report_progress(progress_data) + logger.error(f"Audio conversion error: {error_msg}") + + # If conversion fails, clean up the .ogg file + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + + # Try one more time + time.sleep(retry_delay) + retry_delay += retry_delay_increase + try: + self.__convert_audio() + except Exception as conv_e: + # If conversion fails twice, create a final error report + error_msg = f"Audio conversion failed after retry for '{self.__song_metadata.get('music', 'Unknown Track')}'. Original error: {str(conv_e)}" + progress_data["error"] = error_msg + progress_data["status"] = "error" + Download_JOB.report_progress(progress_data) + logger.error(error_msg) + + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + # Store error on track object + if hasattr(self, '_EASY_DW__c_track') and self.__c_track: + self.__c_track.success = False + self.__c_track.error_message = error_msg + raise TrackNotFound(message=error_msg, url=self.__link) from conv_e + + if hasattr(self, '_EASY_DW__c_track') and self.__c_track: + self.__c_track.success = True + self.__write_track() + write_tags(self.__c_track) + + # Create done status report using the same format as progress status + progress_data = { + "type": "track", + "song": self.__song_metadata.get("music", ""), + "artist": self.__song_metadata.get("artist", ""), + "status": "done", + "url": self.__link, + "convert_to": self.__convert_to + } + + # Add parent info based on parent type + if self.__parent == "playlist" and hasattr(self.__preferences, "json_data"): + playlist_data = self.__preferences.json_data + playlist_name = playlist_data.get('name', 'unknown') + total_tracks = playlist_data.get('tracks', {}).get('total', 'unknown') + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "playlist", + "name": playlist_name, + "owner": playlist_data.get('owner', {}).get('display_name', 'unknown'), + "total_tracks": total_tracks, + "url": f"https://open.spotify.com/playlist/{playlist_data.get('id', '')}" + } + }) + elif self.__parent == "album": + album_name = self.__song_metadata.get('album', '') + album_artist = self.__song_metadata.get('album_artist', self.__song_metadata.get('ar_album', '')) + total_tracks = self.__song_metadata.get('nb_tracks', 0) + current_track = getattr(self.__preferences, 'track_number', 0) + + progress_data.update({ + "current_track": current_track, + "total_tracks": total_tracks, + "parent": { + "type": "album", + "title": album_name, + "artist": album_artist, + "total_tracks": total_tracks, + "url": f"https://open.spotify.com/album/{self.__song_metadata.get('album_id', '')}" + } + }) + + Download_JOB.report_progress(progress_data) + return self.__c_track + + def download_eps(self) -> Episode: + # Use the customizable retry parameters + retry_delay = getattr(self.__preferences, 'initial_retry_delay', 30) # Default to 30 seconds + retry_delay_increase = getattr(self.__preferences, 'retry_delay_increase', 30) # Default to 30 seconds + max_retries = getattr(self.__preferences, 'max_retries', 5) # Default to 5 retries + + retries = 0 + if isfile(self.__song_path) and check_track(self.__c_episode): + ans = input( + f"Episode \"{self.__song_path}\" already exists, do you want to redownload it?(y or n):" + ) + if not ans in answers: + return self.__c_episode + episode_id = EpisodeId.from_base62(self.__ids) + while True: + try: + stream = Download_JOB.session.content_feeder().load_episode( + episode_id, + AudioQuality(self.__dw_quality), + False, + None + ) + break + except Exception as e: + global GLOBAL_RETRY_COUNT + GLOBAL_RETRY_COUNT += 1 + retries += 1 + print(json.dumps({ + "status": "retrying", + "retry_count": retries, + "seconds_left": retry_delay, + "song": self.__song_metadata['music'], + "artist": self.__song_metadata['artist'], + "album": self.__song_metadata['album'], + "error": str(e), + "convert_to": self.__convert_to + })) + if retries >= max_retries or GLOBAL_RETRY_COUNT >= GLOBAL_MAX_RETRIES: + # Clean up any partial files before giving up + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + # Add track info to exception + track_name = self.__song_metadata.get('music', 'Unknown Track') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + final_error_msg = f"Maximum retry limit reached for '{track_name}' by '{artist_name}' (local: {max_retries}, global: {GLOBAL_MAX_RETRIES}). Last error: {str(e)}" + # Store error on track object + if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: + self.__c_episode.success = False + self.__c_episode.error_message = final_error_msg + raise Exception(final_error_msg) from e + time.sleep(retry_delay) + retry_delay += retry_delay_increase # Use the custom retry delay increase + total_size = stream.input_stream.size + os.makedirs(dirname(self.__song_path), exist_ok=True) + + # Register this file as being actively downloaded + register_active_download(self.__song_path) + + try: + with open(self.__song_path, "wb") as f: + c_stream = stream.input_stream.stream() + if self.__real_time_dl and self.__song_metadata.get("duration"): + duration = self.__song_metadata["duration"] + if duration > 0: + rate_limit = total_size / duration + chunk_size = 4096 + bytes_written = 0 + start_time = time.time() + try: + while True: + chunk = c_stream.read(chunk_size) + if not chunk: + break + f.write(chunk) + bytes_written += len(chunk) + # Could add progress reporting here + expected_time = bytes_written / rate_limit + elapsed_time = time.time() - start_time + if expected_time > elapsed_time: + time.sleep(expected_time - elapsed_time) + except Exception as e: + # If any error occurs during real-time download, delete the incomplete file + logger.error(f"Error during real-time download: {str(e)}") + try: + c_stream.close() + except: + pass + try: + f.close() + except: + pass + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + # Add track info to exception + track_name = self.__song_metadata.get('music', 'Unknown Track') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + final_error_msg = f"Error during real-time download for '{track_name}' by '{artist_name}' (URL: {self.__link}). Error: {str(e)}" + # Store error on track object + if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: + self.__c_episode.success = False + self.__c_episode.error_message = final_error_msg + raise TrackNotFound(message=final_error_msg, url=self.__link) from e + else: + try: + data = c_stream.read(total_size) + f.write(data) + except Exception as e: + logger.error(f"Error during episode download: {str(e)}") + try: + c_stream.close() + except: + pass + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + # Add track info to exception + track_name = self.__song_metadata.get('music', 'Unknown Track') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + final_error_msg = f"Error during episode download for '{track_name}' by '{artist_name}' (URL: {self.__link}). Error: {str(e)}" + # Store error on track object + if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: + self.__c_episode.success = False + self.__c_episode.error_message = final_error_msg + raise TrackNotFound(message=final_error_msg, url=self.__link) from e + else: + try: + data = c_stream.read(total_size) + f.write(data) + except Exception as e: + logger.error(f"Error during episode download: {str(e)}") + try: + c_stream.close() + except: + pass + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + # Add track info to exception + track_name = self.__song_metadata.get('music', 'Unknown Track') + artist_name = self.__song_metadata.get('artist', 'Unknown Artist') + final_error_msg = f"Error during episode download for '{track_name}' by '{artist_name}' (URL: {self.__link}). Error: {str(e)}" + # Store error on track object + if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: + self.__c_episode.success = False + self.__c_episode.error_message = final_error_msg + raise TrackNotFound(message=final_error_msg, url=self.__link) from e + c_stream.close() + except Exception as e: + # Clean up the file on any error + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + unregister_active_download(self.__song_path) + episode_title = self.__song_metadata.get('music', 'Unknown Episode') + error_message = f"Failed to download episode '{episode_title}' (URL: {self.__link}). Error: {str(e)}" + logger.error(error_message) + # Store error on episode object + if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: + self.__c_episode.success = False + self.__c_episode.error_message = error_message + raise TrackNotFound(message=error_message, url=self.__link) from e + + try: + self.__convert_audio() + except Exception as e: + logger.error(json.dumps({ + "status": "retrying", + "action": "convert_audio", + "song": self.__song_metadata['music'], + "artist": self.__song_metadata['artist'], + "album": self.__song_metadata['album'], + "error": str(e), + "convert_to": self.__convert_to + })) + # Clean up if conversion fails + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + + time.sleep(retry_delay) + retry_delay += retry_delay_increase # Use the custom retry delay increase + try: + self.__convert_audio() + except Exception as conv_e: + # If conversion fails twice, clean up and raise + if os.path.exists(self.__song_path): + os.remove(self.__song_path) + episode_title = self.__song_metadata.get('music', 'Unknown Episode') + error_message = f"Audio conversion for episode '{episode_title}' failed after retry. Original error: {str(conv_e)}" + logger.error(error_message) + # Store error on episode object + self.__c_episode.success = False + self.__c_episode.error_message = error_message + raise TrackNotFound(message=error_message, url=self.__link) from conv_e + + self.__write_episode() + # Write metadata tags so subsequent skips work + write_tags(self.__c_episode) + return self.__c_episode + +def download_cli(preferences: Preferences) -> None: + __link = preferences.link + __output_dir = preferences.output_dir + __method_save = preferences.method_save + __not_interface = preferences.not_interface + __quality_download = preferences.quality_download + __recursive_download = preferences.recursive_download + __recursive_quality = preferences.recursive_quality + cmd = f"deez-dw.py -so spo -l \"{__link}\" " + if __output_dir: + cmd += f"-o {__output_dir} " + if __method_save: + cmd += f"-sa {__method_save} " + if __not_interface: + cmd += f"-g " + if __quality_download: + cmd += f"-q {__quality_download} " + if __recursive_download: + cmd += f"-rd " + if __recursive_quality: + cmd += f"-rq" + system(cmd) + +class DW_TRACK: + def __init__( + self, + preferences: Preferences + ) -> None: + self.__preferences = preferences + + def dw(self) -> Track: + track = EASY_DW(self.__preferences).easy_dw() + # No error handling needed here - if track.success is False but was_skipped is True, + # it's an intentional skip, not an error + return track + + def dw2(self) -> Track: + track = EASY_DW(self.__preferences).get_no_dw_track() + download_cli(self.__preferences) + return track + +class DW_ALBUM: + def __init__( + self, + preferences: Preferences + ) -> None: + self.__preferences = preferences + self.__ids = self.__preferences.ids + self.__make_zip = self.__preferences.make_zip + self.__output_dir = self.__preferences.output_dir + self.__method_save = self.__preferences.method_save + self.__song_metadata = self.__preferences.song_metadata + self.__not_interface = self.__preferences.not_interface + self.__song_metadata_items = self.__song_metadata.items() + + def dw(self) -> Album: + # Helper function to find most frequent item in a list + def most_frequent(items): + if not items: + return None + # If items is a string with semicolons, split it + if isinstance(items, str) and ";" in items: + items = [item.strip() for item in items.split(";")] + # If it's still a string, return it directly + if isinstance(items, str): + return items + # Otherwise, find the most frequent item + return max(set(items), key=items.count) + + # Report album initializing status + album_name = self.__song_metadata.get('album', 'Unknown Album') + + # Process album artist to get the most representative one + album_artist = self.__song_metadata.get('artist', 'Unknown Artist') + if isinstance(album_artist, list): + album_artist = most_frequent(album_artist) + elif isinstance(album_artist, str) and ";" in album_artist: + artists_list = [artist.strip() for artist in album_artist.split(";")] + album_artist = most_frequent(artists_list) if artists_list else album_artist + + total_tracks = self.__song_metadata.get('nb_tracks', 0) + album_id = self.__ids + + Download_JOB.report_progress({ + "type": "album", + "artist": album_artist, + "status": "initializing", + "total_tracks": total_tracks, + "title": album_name, + "url": f"https://open.spotify.com/album/{album_id}" + }) + + pic = self.__song_metadata['image'] + image = request(pic).content + self.__song_metadata['image'] = image + album = Album(self.__ids) + album.image = image + album.nb_tracks = self.__song_metadata['nb_tracks'] + album.album_name = self.__song_metadata['album'] + album.upc = self.__song_metadata['upc'] + tracks = album.tracks + album.md5_image = self.__ids + album.tags = self.__song_metadata + + c_song_metadata = {} + for key, item in self.__song_metadata_items: + if type(item) is not list: + c_song_metadata[key] = self.__song_metadata[key] + total_tracks = album.nb_tracks + for a in range(total_tracks): + for key, item in self.__song_metadata_items: + if type(item) is list: + c_song_metadata[key] = self.__song_metadata[key][a] + song_name = c_song_metadata['music'] + artist_name = c_song_metadata['artist'] + album_name = c_song_metadata['album'] + current_track = a + 1 + + c_preferences = deepcopy(self.__preferences) + c_preferences.song_metadata = c_song_metadata.copy() + c_preferences.ids = c_song_metadata['ids'] + c_preferences.track_number = current_track # Track number in the album + c_preferences.link = f"https://open.spotify.com/track/{c_preferences.ids}" + + # Add album_id to song metadata for consistent parent info + c_preferences.song_metadata['album_id'] = self.__ids + + try: + # Use track-level reporting through EASY_DW + track = EASY_DW(c_preferences, parent='album').download_try() + except TrackNotFound as e_track: + track = Track(c_song_metadata, None, None, None, None, None) + track.success = False + track.error_message = str(e_track) # Store the error message from TrackNotFound + logger.warning(f"Track '{song_name}' by '{artist_name}' from album '{album.album_name}' not found or failed to download. Reason: {track.error_message}") + except Exception as e_generic: + track = Track(c_song_metadata, None, None, None, None, None) + track.success = False + track.error_message = f"An unexpected error occurred: {str(e_generic)}" + logger.error(f"Unexpected error downloading track '{song_name}' by '{artist_name}' from album '{album.album_name}'. Reason: {track.error_message}") + tracks.append(track) + if self.__make_zip: + song_quality = tracks[0].quality + custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None) + zip_name = create_zip( + tracks, + output_dir=self.__output_dir, + song_metadata=self.__song_metadata, + song_quality=song_quality, + method_save=self.__method_save, + custom_dir_format=custom_dir_format + ) + album.zip_path = zip_name + + # Report album done status + album_name = self.__song_metadata.get('album', 'Unknown Album') + + # Process album artist for the done status (use the same logic as initializing) + album_artist = self.__song_metadata.get('artist', 'Unknown Artist') + if isinstance(album_artist, list): + album_artist = most_frequent(album_artist) + elif isinstance(album_artist, str) and ";" in album_artist: + artists_list = [artist.strip() for artist in album_artist.split(";")] + album_artist = most_frequent(artists_list) if artists_list else album_artist + + total_tracks = self.__song_metadata.get('nb_tracks', 0) + album_id = self.__ids + + Download_JOB.report_progress({ + "type": "album", + "artist": album_artist, + "status": "done", + "total_tracks": total_tracks, + "title": album_name, + "url": f"https://open.spotify.com/album/{album_id}" + }) + + return album + + def dw2(self) -> Album: + track = EASY_DW(self.__preferences).get_no_dw_track() + download_cli(self.__preferences) + return track + +class DW_PLAYLIST: + def __init__( + self, + preferences: Preferences + ) -> None: + self.__preferences = preferences + self.__ids = self.__preferences.ids + self.__json_data = preferences.json_data + self.__make_zip = self.__preferences.make_zip + self.__output_dir = self.__preferences.output_dir + self.__song_metadata = self.__preferences.song_metadata + + def dw(self) -> Playlist: + playlist_name = self.__json_data.get('name', 'unknown') + total_tracks = self.__json_data.get('tracks', {}).get('total', 'unknown') + playlist_owner = self.__json_data.get('owner', {}).get('display_name', 'Unknown Owner') + playlist_id = self.__ids + + # Report playlist initializing status + Download_JOB.report_progress({ + "type": "playlist", + "owner": playlist_owner, + "status": "initializing", + "total_tracks": total_tracks, + "name": playlist_name, + "url": f"https://open.spotify.com/playlist/{playlist_id}" + }) + + # --- Prepare the m3u playlist file --- + playlist_m3u_dir = os.path.join(self.__output_dir, "playlists") + os.makedirs(playlist_m3u_dir, exist_ok=True) + m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name}.m3u") + if not os.path.exists(m3u_path): + with open(m3u_path, "w", encoding="utf-8") as m3u_file: + m3u_file.write("#EXTM3U\n") + # ------------------------------------- + + playlist = Playlist() + tracks = playlist.tracks + for idx, c_song_metadata in enumerate(self.__song_metadata): + if type(c_song_metadata) is str: + print(f"Track not found {c_song_metadata} :(") + continue + c_preferences = deepcopy(self.__preferences) + c_preferences.ids = c_song_metadata['ids'] + c_preferences.song_metadata = c_song_metadata + c_preferences.json_data = self.__json_data # Pass playlist data for reporting + c_preferences.track_number = idx + 1 # Track number in the playlist + + # Use track-level reporting through EASY_DW + track = EASY_DW(c_preferences, parent='playlist').easy_dw() + + # Only log a warning if the track failed and was NOT intentionally skipped + if not track.success and not getattr(track, 'was_skipped', False): + song = f"{c_song_metadata['music']} - {c_song_metadata['artist']}" + error_detail = getattr(track, 'error_message', 'Download failed for unspecified reason.') + logger.warning(f"Cannot download '{song}' from playlist '{playlist_name}'. Reason: {error_detail} (URL: {track.link or c_preferences.link})") + + tracks.append(track) + # --- Append the final track path to the m3u file using a relative path --- + if track.success and hasattr(track, 'song_path') and track.song_path: + # Build the relative path from the playlists directory + relative_path = os.path.relpath( + track.song_path, + start=os.path.join(self.__output_dir, "playlists") + ) + with open(m3u_path, "a", encoding="utf-8") as m3u_file: + m3u_file.write(f"{relative_path}\n") + # --------------------------------------------------------------------- + + if self.__make_zip: + playlist_title = self.__json_data['name'] + zip_name = f"{self.__output_dir}/{playlist_title} [playlist {self.__ids}]" + create_zip(tracks, zip_name=zip_name) + playlist.zip_path = zip_name + + # Report playlist done status + playlist_name = self.__json_data.get('name', 'Unknown Playlist') + playlist_owner = self.__json_data.get('owner', {}).get('display_name', 'Unknown Owner') + total_tracks = self.__json_data.get('tracks', {}).get('total', 0) + playlist_id = self.__ids + + Download_JOB.report_progress({ + "type": "playlist", + "owner": playlist_owner, + "status": "done", + "total_tracks": total_tracks, + "name": playlist_name, + "url": f"https://open.spotify.com/playlist/{playlist_id}" + }) + + return playlist + + def dw2(self) -> Playlist: + # Extract playlist metadata for reporting + playlist_name = self.__json_data.get('name', 'Unknown Playlist') + playlist_owner = self.__json_data.get('owner', {}).get('display_name', 'Unknown Owner') + total_tracks = self.__json_data.get('tracks', {}).get('total', 'unknown') + playlist_id = self.__ids + + # Report playlist initializing status + Download_JOB.report_progress({ + "type": "playlist", + "owner": playlist_owner, + "status": "initializing", + "total_tracks": total_tracks, + "name": playlist_name, + "url": f"https://open.spotify.com/playlist/{playlist_id}" + }) + + playlist = Playlist() + tracks = playlist.tracks + for i, c_song_metadata in enumerate(self.__song_metadata): + if type(c_song_metadata) is str: + logger.warning(f"Track not found {c_song_metadata}") + continue + c_preferences = deepcopy(self.__preferences) + c_preferences.ids = c_song_metadata['ids'] + c_preferences.song_metadata = c_song_metadata + c_preferences.json_data = self.__json_data # Pass playlist data for reporting + c_preferences.track_number = i + 1 # Track number in the playlist + + # Even though we're not downloading directly, we still need to set up the track object + track = EASY_DW(c_preferences, parent='playlist').get_no_dw_track() + if not track.success: + song = f"{c_song_metadata['music']} - {c_song_metadata['artist']}" + error_detail = getattr(track, 'error_message', 'Download failed for unspecified reason.') + logger.warning(f"Cannot download '{song}' (CLI mode). Reason: {error_detail} (Link: {track.link or c_preferences.link})") + tracks.append(track) + + # Track-level progress reporting using the standardized format + progress_data = { + "type": "track", + "song": c_song_metadata.get("music", ""), + "artist": c_song_metadata.get("artist", ""), + "status": "progress", + "current_track": i + 1, + "total_tracks": total_tracks, + "parent": { + "type": "playlist", + "name": playlist_name, + "owner": self.__json_data.get('owner', {}).get('display_name', 'unknown'), + "total_tracks": total_tracks, + "url": f"https://open.spotify.com/playlist/{self.__json_data.get('id', '')}" + }, + "url": f"https://open.spotify.com/track/{c_song_metadata['ids']}" + } + Download_JOB.report_progress(progress_data) + download_cli(self.__preferences) + + if self.__make_zip: + playlist_title = self.__json_data['name'] + zip_name = f"{self.__output_dir}/{playlist_title} [playlist {self.__ids}]" + create_zip(tracks, zip_name=zip_name) + playlist.zip_path = zip_name + + # Report playlist done status + playlist_name = self.__json_data.get('name', 'Unknown Playlist') + playlist_owner = self.__json_data.get('owner', {}).get('display_name', 'Unknown Owner') + total_tracks = self.__json_data.get('tracks', {}).get('total', 0) + playlist_id = self.__ids + + Download_JOB.report_progress({ + "type": "playlist", + "owner": playlist_owner, + "status": "done", + "total_tracks": total_tracks, + "name": playlist_name, + "url": f"https://open.spotify.com/playlist/{playlist_id}" + }) + + return playlist + +class DW_EPISODE: + def __init__( + self, + preferences: Preferences + ) -> None: + self.__preferences = preferences + + def dw(self) -> Episode: + # Using standardized episode progress format + progress_data = { + "type": "episode", + "song": self.__preferences.song_metadata.get('name', 'Unknown Episode'), + "artist": self.__preferences.song_metadata.get('show', 'Unknown Show'), + "status": "initializing" + } + + # Set URL if available + episode_id = self.__preferences.ids + if episode_id: + progress_data["url"] = f"https://open.spotify.com/episode/{episode_id}" + + Download_JOB.report_progress(progress_data) + + episode = EASY_DW(self.__preferences).download_eps() + + # Using standardized episode progress format + progress_data = { + "type": "episode", + "song": self.__preferences.song_metadata.get('name', 'Unknown Episode'), + "artist": self.__preferences.song_metadata.get('show', 'Unknown Show'), + "status": "done" + } + + # Set URL if available + episode_id = self.__preferences.ids + if episode_id: + progress_data["url"] = f"https://open.spotify.com/episode/{episode_id}" + + Download_JOB.report_progress(progress_data) + + return episode + + def dw2(self) -> Episode: + # Using standardized episode progress format + progress_data = { + "type": "episode", + "song": self.__preferences.song_metadata.get('name', 'Unknown Episode'), + "artist": self.__preferences.song_metadata.get('show', 'Unknown Show'), + "status": "initializing" + } + + # Set URL if available + episode_id = self.__preferences.ids + if episode_id: + progress_data["url"] = f"https://open.spotify.com/episode/{episode_id}" + + Download_JOB.report_progress(progress_data) + + episode = EASY_DW(self.__preferences).get_no_dw_track() + download_cli(self.__preferences) + + # Using standardized episode progress format + progress_data = { + "type": "episode", + "song": self.__preferences.song_metadata.get('name', 'Unknown Episode'), + "artist": self.__preferences.song_metadata.get('show', 'Unknown Show'), + "status": "done" + } + + # Set URL if available + episode_id = self.__preferences.ids + if episode_id: + progress_data["url"] = f"https://open.spotify.com/episode/{episode_id}" + + Download_JOB.report_progress(progress_data) + + return episode diff --git a/deezspot/spotloader/__init__.py b/deezspot/spotloader/__init__.py new file mode 100644 index 0000000..8c3cb53 --- /dev/null +++ b/deezspot/spotloader/__init__.py @@ -0,0 +1,526 @@ +#!/usr/bin/python3 +import traceback +from os.path import isfile +from deezspot.easy_spoty import Spo +from librespot.core import Session +from deezspot.exceptions import InvalidLink +from deezspot.spotloader.__spo_api__ import tracking, tracking_album, tracking_episode +from deezspot.spotloader.spotify_settings import stock_quality +from deezspot.libutils.utils import ( + get_ids, + link_is_valid, + what_kind, +) +from deezspot.models import ( + Track, + Album, + Playlist, + Preferences, + Smart, + Episode +) +from deezspot.spotloader.__download__ import ( + DW_TRACK, + DW_ALBUM, + DW_PLAYLIST, + DW_EPISODE, + Download_JOB, +) +from deezspot.libutils.others_settings import ( + stock_output, + stock_recursive_quality, + stock_recursive_download, + stock_not_interface, + stock_zip, + method_save, + is_thread, + stock_real_time_dl +) +from deezspot.libutils.logging_utils import logger, ProgressReporter + +class SpoLogin: + def __init__( + self, + credentials_path: str, + spotify_client_id: str = None, + spotify_client_secret: str = None, + progress_callback = None, + silent: bool = False + ) -> None: + self.credentials_path = credentials_path + self.spotify_client_id = spotify_client_id + self.spotify_client_secret = spotify_client_secret + + # Initialize Spotify API with credentials if provided + if spotify_client_id and spotify_client_secret: + Spo.__init__(client_id=spotify_client_id, client_secret=spotify_client_secret) + logger.info("Initialized Spotify API with provided credentials") + + # Configure progress reporting + self.progress_reporter = ProgressReporter(callback=progress_callback, silent=silent) + + self.__initialize_session() + + def report_progress(self, progress_data): + """Report progress using the configured reporter.""" + self.progress_reporter.report(progress_data) + + def __initialize_session(self) -> None: + try: + session_builder = Session.Builder() + session_builder.conf.stored_credentials_file = self.credentials_path + + if isfile(self.credentials_path): + session = session_builder.stored_file().create() + logger.info("Successfully initialized Spotify session") + else: + logger.error("Credentials file not found") + raise FileNotFoundError("Please fill your credentials.json location!") + + Download_JOB(session) + Download_JOB.set_progress_reporter(self.progress_reporter) + except Exception as e: + logger.error(f"Failed to initialize Spotify session: {str(e)}") + raise + + def download_track( + self, link_track, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + method_save=method_save, + is_thread=is_thread, + real_time_dl=stock_real_time_dl, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Track: + try: + link_is_valid(link_track) + ids = get_ids(link_track) + song_metadata = tracking(ids) + + logger.info(f"Starting download for track: {song_metadata.get('music', 'Unknown')} - {song_metadata.get('artist', 'Unknown')}") + + preferences = Preferences() + preferences.real_time_dl = real_time_dl + preferences.link = link_track + preferences.song_metadata = song_metadata + preferences.quality_download = quality_download + preferences.output_dir = output_dir + preferences.ids = ids + preferences.recursive_quality = recursive_quality + preferences.recursive_download = recursive_download + preferences.not_interface = not_interface + preferences.method_save = method_save + preferences.is_episode = False + preferences.custom_dir_format = custom_dir_format + preferences.custom_track_format = custom_track_format + preferences.pad_tracks = pad_tracks + preferences.initial_retry_delay = initial_retry_delay + preferences.retry_delay_increase = retry_delay_increase + preferences.max_retries = max_retries + preferences.convert_to = convert_to + + if not is_thread: + track = DW_TRACK(preferences).dw() + else: + track = DW_TRACK(preferences).dw2() + + return track + except Exception as e: + logger.error(f"Failed to download track: {str(e)}") + traceback.print_exc() + raise e + + def download_album( + self, link_album, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + make_zip=stock_zip, + method_save=method_save, + is_thread=is_thread, + real_time_dl=stock_real_time_dl, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Album: + try: + link_is_valid(link_album) + ids = get_ids(link_album) + # Use stored credentials for API calls + album_json = Spo.get_album(ids) + song_metadata = tracking_album(album_json) + + logger.info(f"Starting download for album: {song_metadata.get('album', 'Unknown')} - {song_metadata.get('ar_album', 'Unknown')}") + + preferences = Preferences() + preferences.real_time_dl = real_time_dl + preferences.link = link_album + preferences.song_metadata = song_metadata + preferences.quality_download = quality_download + preferences.output_dir = output_dir + preferences.ids = ids + preferences.json_data = album_json + preferences.recursive_quality = recursive_quality + preferences.recursive_download = recursive_download + preferences.not_interface = not_interface + preferences.method_save = method_save + preferences.make_zip = make_zip + preferences.is_episode = False + preferences.custom_dir_format = custom_dir_format + preferences.custom_track_format = custom_track_format + preferences.pad_tracks = pad_tracks + preferences.initial_retry_delay = initial_retry_delay + preferences.retry_delay_increase = retry_delay_increase + preferences.max_retries = max_retries + preferences.convert_to = convert_to + + if not is_thread: + album = DW_ALBUM(preferences).dw() + else: + album = DW_ALBUM(preferences).dw2() + + return album + except Exception as e: + logger.error(f"Failed to download album: {str(e)}") + traceback.print_exc() + raise e + + def download_playlist( + self, link_playlist, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + make_zip=stock_zip, + method_save=method_save, + is_thread=is_thread, + real_time_dl=stock_real_time_dl, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Playlist: + try: + link_is_valid(link_playlist) + ids = get_ids(link_playlist) + + song_metadata = [] + # Use stored credentials for API calls + playlist_json = Spo.get_playlist(ids) + + logger.info(f"Starting download for playlist: {playlist_json.get('name', 'Unknown')}") + + for track in playlist_json['tracks']['items']: + is_track = track['track'] + if not is_track: + continue + external_urls = is_track['external_urls'] + if not external_urls: + c_song_metadata = f"The track \"{is_track['name']}\" is not available on Spotify :(" + logger.warning(f"Track not available: {is_track['name']}") + else: + ids = get_ids(external_urls['spotify']) + c_song_metadata = tracking(ids) + song_metadata.append(c_song_metadata) + + preferences = Preferences() + preferences.real_time_dl = real_time_dl + preferences.link = link_playlist + preferences.song_metadata = song_metadata + preferences.quality_download = quality_download + preferences.output_dir = output_dir + preferences.ids = ids + preferences.json_data = playlist_json + preferences.recursive_quality = recursive_quality + preferences.recursive_download = recursive_download + preferences.not_interface = not_interface + preferences.method_save = method_save + preferences.make_zip = make_zip + preferences.is_episode = False + preferences.custom_dir_format = custom_dir_format + preferences.custom_track_format = custom_track_format + preferences.pad_tracks = pad_tracks + preferences.initial_retry_delay = initial_retry_delay + preferences.retry_delay_increase = retry_delay_increase + preferences.max_retries = max_retries + preferences.convert_to = convert_to + + if not is_thread: + playlist = DW_PLAYLIST(preferences).dw() + else: + playlist = DW_PLAYLIST(preferences).dw2() + + return playlist + except Exception as e: + logger.error(f"Failed to download playlist: {str(e)}") + traceback.print_exc() + raise e + + def download_episode( + self, link_episode, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + method_save=method_save, + is_thread=is_thread, + real_time_dl=stock_real_time_dl, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Episode: + try: + link_is_valid(link_episode) + ids = get_ids(link_episode) + # Use stored credentials for API calls + episode_json = Spo.get_episode(ids) + episode_metadata = tracking_episode(ids) + + logger.info(f"Starting download for episode: {episode_metadata.get('name', 'Unknown')} - {episode_metadata.get('show', 'Unknown')}") + + preferences = Preferences() + preferences.real_time_dl = real_time_dl + preferences.link = link_episode + preferences.song_metadata = episode_metadata + preferences.output_dir = output_dir + preferences.ids = ids + preferences.json_data = episode_json + preferences.recursive_quality = recursive_quality + preferences.recursive_download = recursive_download + preferences.not_interface = not_interface + preferences.method_save = method_save + preferences.is_episode = True + preferences.custom_dir_format = custom_dir_format + preferences.custom_track_format = custom_track_format + preferences.pad_tracks = pad_tracks + preferences.initial_retry_delay = initial_retry_delay + preferences.retry_delay_increase = retry_delay_increase + preferences.max_retries = max_retries + preferences.convert_to = convert_to + + if not is_thread: + episode = DW_EPISODE(preferences).dw() + else: + episode = DW_EPISODE(preferences).dw2() + + return episode + except Exception as e: + logger.error(f"Failed to download episode: {str(e)}") + traceback.print_exc() + raise e + + def download_artist( + self, link_artist, + album_type: str = 'album,single,compilation,appears_on', + limit: int = 50, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + make_zip=stock_zip, + method_save=method_save, + is_thread=is_thread, + real_time_dl=stock_real_time_dl, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ): + """ + Download all albums (or a subset based on album_type and limit) from an artist. + """ + try: + link_is_valid(link_artist) + ids = get_ids(link_artist) + discography = Spo.get_artist(ids, album_type=album_type, limit=limit) + albums = discography.get('items', []) + if not albums: + logger.warning("No albums found for the provided artist") + raise Exception("No albums found for the provided artist.") + + logger.info(f"Starting download for artist discography: {discography.get('name', 'Unknown')}") + + downloaded_albums = [] + for album in albums: + album_url = album.get('external_urls', {}).get('spotify') + if not album_url: + logger.warning(f"No URL found for album: {album.get('name', 'Unknown')}") + continue + downloaded_album = self.download_album( + album_url, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + make_zip=make_zip, + method_save=method_save, + is_thread=is_thread, + real_time_dl=real_time_dl, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries, + convert_to=convert_to + ) + downloaded_albums.append(downloaded_album) + return downloaded_albums + except Exception as e: + logger.error(f"Failed to download artist discography: {str(e)}") + traceback.print_exc() + raise e + + def download_smart( + self, link, + output_dir=stock_output, + quality_download=stock_quality, + recursive_quality=stock_recursive_quality, + recursive_download=stock_recursive_download, + not_interface=stock_not_interface, + make_zip=stock_zip, + method_save=method_save, + real_time_dl=stock_real_time_dl, + custom_dir_format=None, + custom_track_format=None, + pad_tracks=True, + initial_retry_delay=30, + retry_delay_increase=30, + max_retries=5, + convert_to=None + ) -> Smart: + try: + link_is_valid(link) + link = what_kind(link) + smart = Smart() + + if "spotify.com" in link: + source = "https://spotify.com" + smart.source = source + + logger.info(f"Starting smart download for: {link}") + + if "track/" in link: + if not "spotify.com" in link: + raise InvalidLink(link) + track = self.download_track( + link, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + method_save=method_save, + real_time_dl=real_time_dl, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) + smart.type = "track" + smart.track = track + + elif "album/" in link: + if not "spotify.com" in link: + raise InvalidLink(link) + album = self.download_album( + link, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + make_zip=make_zip, + method_save=method_save, + real_time_dl=real_time_dl, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) + smart.type = "album" + smart.album = album + + elif "playlist/" in link: + if not "spotify.com" in link: + raise InvalidLink(link) + playlist = self.download_playlist( + link, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + make_zip=make_zip, + method_save=method_save, + real_time_dl=real_time_dl, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) + smart.type = "playlist" + smart.playlist = playlist + + elif "episode/" in link: + if not "spotify.com" in link: + raise InvalidLink(link) + episode = self.download_episode( + link, + output_dir=output_dir, + quality_download=quality_download, + recursive_quality=recursive_quality, + recursive_download=recursive_download, + not_interface=not_interface, + method_save=method_save, + real_time_dl=real_time_dl, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) + smart.type = "episode" + smart.episode = episode + + return smart + except Exception as e: + logger.error(f"Failed to perform smart download: {str(e)}") + traceback.print_exc() + raise e diff --git a/deezspot/spotloader/__spo_api__.py b/deezspot/spotloader/__spo_api__.py new file mode 100644 index 0000000..d9cb373 --- /dev/null +++ b/deezspot/spotloader/__spo_api__.py @@ -0,0 +1,170 @@ +#!/usr/bin/python3 + +from deezspot.easy_spoty import Spo +from datetime import datetime +from deezspot.libutils.utils import convert_to_date +import traceback +from deezspot.libutils.logging_utils import logger + +def tracking(ids, album=None): + datas = {} + try: + json_track = Spo.get_track(ids) + + if not album: + album_ids = json_track['album']['id'] + json_album = Spo.get_album(album_ids) + datas['image'] = json_album['images'][0]['url'] + datas['image2'] = json_album['images'][1]['url'] + datas['image3'] = json_album['images'][2]['url'] + datas['genre'] = "; ".join(json_album['genres']) + + ar_album = [ + artist['name'] + for artist in json_album['artists'] + ] + + datas['ar_album'] = "; ".join(ar_album) + datas['album'] = json_album['name'] + datas['label'] = json_album['label'] + + external_ids = json_album.get('external_ids', {}) + datas['upc'] = external_ids.get('upc', "Unknown") + + datas['nb_tracks'] = json_album['total_tracks'] + + datas['music'] = json_track['name'] + + artists = [ + artist['name'] + for artist in json_track['artists'] + ] + + datas['artist'] = "; ".join(artists) + datas['tracknum'] = json_track['track_number'] + datas['discnum'] = json_track['disc_number'] + + datas['year'] = convert_to_date( + json_track['album']['release_date'] + ) + + datas['bpm'] = "Unknown" + datas['duration'] = json_track['duration_ms'] // 1000 + + external_ids = json_track.get('external_ids', {}) + datas['isrc'] = external_ids.get('isrc', 'Unknown') + + datas['gain'] = "Unknown" + datas['ids'] = ids + + logger.debug(f"Successfully tracked metadata for track {ids}") + + except Exception as e: + logger.error(f"Failed to track metadata for track {ids}: {str(e)}") + traceback.print_exc() + return None + + return datas + +def tracking_album(album_json): + song_metadata = {} + try: + song_metadata = { + "music": [], + "artist": [], + "tracknum": [], + "discnum": [], + "bpm": [], + "duration": [], + "isrc": [], + "gain": [], + "ids": [], + "image": album_json['images'][0]['url'], + "image2": album_json['images'][1]['url'], + "image3": album_json['images'][2]['url'], + "album": album_json['name'], + "label": album_json['label'], + "year": convert_to_date(album_json['release_date']), + "nb_tracks": album_json['total_tracks'], + "genre": "; ".join(album_json['genres']) + } + + ar_album = [ + artist['name'] + for artist in album_json['artists'] + ] + + song_metadata['ar_album'] = "; ".join(ar_album) + + external_ids = album_json.get('external_ids', {}) + song_metadata['upc'] = external_ids.get('upc', "Unknown") + + sm_items = song_metadata.items() + + for track in album_json['tracks']['items']: + c_ids = track['id'] + detas = tracking(c_ids, album=True) + if detas is None: + logger.warning(f"Could not retrieve metadata for track {c_ids} in album {album_json['id']}. Skipping.") + for key, item in sm_items: + if type(item) is list: + if key == 'isrc': + song_metadata[key].append('Unknown') + elif key in detas: + song_metadata[key].append(detas[key]) + else: + song_metadata[key].append('Unknown') + continue + + for key, item in sm_items: + if type(item) is list: + if key == 'isrc': + song_metadata[key].append(detas.get('isrc', 'Unknown')) + elif key in detas: + song_metadata[key].append(detas[key]) + else: + song_metadata[key].append('Unknown') + + logger.debug(f"Successfully tracked metadata for album {album_json['id']}") + + except Exception as e: + logger.error(f"Failed to track album metadata: {str(e)}") + traceback.print_exc() + return None + + return song_metadata + +def tracking_episode(ids): + datas = {} + try: + json_episode = Spo.get_episode(ids) + + datas['audio_preview_url'] = json_episode.get('audio_preview_url', '') + datas['description'] = json_episode.get('description', '') + datas['duration'] = json_episode.get('duration_ms', 0) // 1000 + datas['explicit'] = json_episode.get('explicit', False) + datas['external_urls'] = json_episode.get('external_urls', {}).get('spotify', '') + datas['href'] = json_episode.get('href', '') + datas['html_description'] = json_episode.get('html_description', '') + datas['id'] = json_episode.get('id', '') + datas['image'] = json_episode['images'][0]['url'] if json_episode.get('images') else '' + datas['image2'] = json_episode['images'][1]['url'] if len(json_episode.get('images', [])) > 1 else '' + datas['image3'] = json_episode['images'][2]['url'] if len(json_episode.get('images', [])) > 2 else '' + datas['is_externally_hosted'] = json_episode.get('is_externally_hosted', False) + datas['is_playable'] = json_episode.get('is_playable', False) + datas['language'] = json_episode.get('language', '') + datas['languages'] = "; ".join(json_episode.get('languages', [])) + datas['name'] = json_episode.get('name', '') + datas['release_date'] = convert_to_date(json_episode.get('release_date', '')) + datas['show'] = json_episode.get('show', {}).get('name', '') + datas['publisher'] = json_episode.get('show', {}).get('publisher', '') + datas['ids'] = ids + + logger.debug(f"Successfully tracked metadata for episode {ids}") + + except Exception as e: + logger.error(f"Failed to track episode metadata: {str(e)}") + traceback.print_exc() + return None + + return datas \ No newline at end of file diff --git a/deezspot/spotloader/spotify_settings.py b/deezspot/spotloader/spotify_settings.py new file mode 100644 index 0000000..02f1274 --- /dev/null +++ b/deezspot/spotloader/spotify_settings.py @@ -0,0 +1,26 @@ +#!/usr/bin/python3 + +from librespot.audio.decoders import AudioQuality + +stock_quality = "HIGH" +librespot_credentials = "credentials.json" + +qualities = { + "HIGH": { + "n_quality": AudioQuality.HIGH, + "f_format": ".ogg", + "s_quality": "HIGH" + }, + + "VERY_HIGH": { + "n_quality": AudioQuality.VERY_HIGH, + "f_format": ".ogg", + "s_quality": "VERY_HIGH" + }, + + "NORMAL": { + "n_quality": AudioQuality.NORMAL, + "f_format": ".ogg", + "s_quality": "NORMAL" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6b72e60 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "deezspot" +version = "1.1" +description = "Downloads songs, albums or playlists from deezer and spotify (clone from deezloader)" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "GNU Affero General Public License v3"} +authors = [ + {name = "jakiepari", email = "farihmuhammad75@gmail.com"} +] + +dependencies = [ + "mutagen", + "pycryptodome", + "requests", + "spotipy", + "tqdm", + "fastapi", + "uvicorn[standard]", + "spotipy-anon", + "librespot" +] + +[project.urls] +Homepage = "https://github.com/jakiepari/deezspot" +Repository = "https://github.com/jakiepari/deezspot.git" +Documentation = "https://github.com/jakiepari/deezspot/blob/main/README.md" + +[tool.setuptools] +packages = [ + "deezspot", + "deezspot.models", + "deezspot.spotloader", + "deezspot.deezloader", + "deezspot.libutils" +] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..829f1f6 --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +from setuptools import setup + +README = open("README.md", "r") +readmed = README.read() +README.close() + +setup( + name = "deezspot", + version = "1.1", + description = "Downloads songs, albums or playlists from deezer and spotify (clone from https://pypi.org/project/deezloader/)", + long_description = readmed, + long_description_content_type = "text/markdown", + license = "GNU Affero General Public License v3", + python_requires = ">=3.10", + author = "jakiepari", + author_email = "farihmuhammad75@gmail.com", + url = "https://github.com/jakiepari/deezspot", + + packages = [ + "deezspot", + "deezspot/models", "deezspot/spotloader", + "deezspot/deezloader", "deezspot/libutils" + ], + + install_requires = [ + "mutagen", "pycryptodome", "requests", + "spotipy", "tqdm", "fastapi", + "uvicorn[standard]", + "spotipy-anon", + "librespot" + ], +)