first commit

This commit is contained in:
cool.gitter.not.me.again.duh
2025-05-31 15:51:18 -06:00
commit 089cb3dc5a
34 changed files with 9012 additions and 0 deletions

39
.github/workflows/python-publish.yml vendored Normal file
View File

@@ -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 }}

661
LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU 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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.

712
README.md Normal file
View File

@@ -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.

161
debug_flac.py Executable file
View File

@@ -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())

40
deezspot/__init__.py Normal file
View File

@@ -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)

315
deezspot/__taggers__.py Normal file
View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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)}

View File

@@ -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

View File

@@ -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

View File

@@ -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 ""

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}

252
deezspot/easy_spoty.py Normal file
View File

@@ -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

77
deezspot/exceptions.py Normal file
View File

@@ -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)

View File

View File

@@ -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

View File

@@ -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))

View File

@@ -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

371
deezspot/libutils/utils.py Normal file
View File

@@ -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

View File

@@ -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

20
deezspot/models/album.py Normal file
View File

@@ -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}"

View File

@@ -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}"

View File

@@ -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

View File

@@ -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

13
deezspot/models/smart.py Normal file
View File

@@ -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

37
deezspot/models/track.py Normal file
View File

@@ -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}"

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}

40
pyproject.toml Normal file
View File

@@ -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"
]

32
setup.py Normal file
View File

@@ -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"
],
)