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