first commit
This commit is contained in:
39
.github/workflows/python-publish.yml
vendored
Normal file
39
.github/workflows/python-publish.yml
vendored
Normal 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
661
LICENSE
Normal 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
712
README.md
Normal 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
161
debug_flac.py
Executable 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
40
deezspot/__init__.py
Normal 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
315
deezspot/__taggers__.py
Normal 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
|
||||
1346
deezspot/deezloader/__download__.py
Normal file
1346
deezspot/deezloader/__download__.py
Normal file
File diff suppressed because it is too large
Load Diff
448
deezspot/deezloader/__download_utils__.py
Normal file
448
deezspot/deezloader/__download_utils__.py
Normal 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)}
|
||||
849
deezspot/deezloader/__init__.py
Normal file
849
deezspot/deezloader/__init__.py
Normal 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
|
||||
149
deezspot/deezloader/__taggers__.py
Normal file
149
deezspot/deezloader/__taggers__.py
Normal 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
|
||||
186
deezspot/deezloader/__utils__.py
Normal file
186
deezspot/deezloader/__utils__.py
Normal 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 ""
|
||||
331
deezspot/deezloader/dee_api.py
Normal file
331
deezspot/deezloader/dee_api.py
Normal 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
|
||||
324
deezspot/deezloader/deegw_api.py
Normal file
324
deezspot/deezloader/deegw_api.py
Normal 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
|
||||
24
deezspot/deezloader/deezer_settings.py
Normal file
24
deezspot/deezloader/deezer_settings.py
Normal 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
252
deezspot/easy_spoty.py
Normal 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
77
deezspot/exceptions.py
Normal 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)
|
||||
0
deezspot/libutils/__init__.py
Normal file
0
deezspot/libutils/__init__.py
Normal file
253
deezspot/libutils/audio_converter.py
Normal file
253
deezspot/libutils/audio_converter.py
Normal 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
|
||||
69
deezspot/libutils/logging_utils.py
Normal file
69
deezspot/libutils/logging_utils.py
Normal 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))
|
||||
28
deezspot/libutils/others_settings.py
Normal file
28
deezspot/libutils/others_settings.py
Normal 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
371
deezspot/libutils/utils.py
Normal 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
|
||||
8
deezspot/models/__init__.py
Normal file
8
deezspot/models/__init__.py
Normal 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
20
deezspot/models/album.py
Normal 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}"
|
||||
34
deezspot/models/episode.py
Normal file
34
deezspot/models/episode.py
Normal 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}"
|
||||
8
deezspot/models/playlist.py
Normal file
8
deezspot/models/playlist.py
Normal 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
|
||||
22
deezspot/models/preferences.py
Normal file
22
deezspot/models/preferences.py
Normal 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
13
deezspot/models/smart.py
Normal 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
37
deezspot/models/track.py
Normal 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}"
|
||||
1441
deezspot/spotloader/__download__.py
Normal file
1441
deezspot/spotloader/__download__.py
Normal file
File diff suppressed because it is too large
Load Diff
526
deezspot/spotloader/__init__.py
Normal file
526
deezspot/spotloader/__init__.py
Normal 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
|
||||
170
deezspot/spotloader/__spo_api__.py
Normal file
170
deezspot/spotloader/__spo_api__.py
Normal 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
|
||||
26
deezspot/spotloader/spotify_settings.py
Normal file
26
deezspot/spotloader/spotify_settings.py
Normal 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
40
pyproject.toml
Normal 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
32
setup.py
Normal 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"
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user