├── .github
└── workflows
│ ├── build_android.yml
│ ├── build_windows.yml
│ └── run_tests.yml
├── .gitignore
├── LICENSE
├── README.md
├── img
├── bug_outline.png
├── bug_outline_light.png
├── neodeemer_screenshot_1.jpg
├── neodeemer_screenshot_1_light.jpg
├── neodeemer_screenshot_2.jpg
├── neodeemer_screenshot_2_light.jpg
├── neodeemer_screenshot_3.jpg
├── neodeemer_screenshot_3_light.jpg
├── neodeemer_screenshot_4.jpg
└── neodeemer_screenshot_4_light.jpg
├── neodeemer
├── __init__.py
├── buildozer.spec
├── data
│ ├── icon.ico
│ ├── icon.png
│ ├── intentfilters.xml
│ ├── presplash.png
│ └── ytsfilter.json
├── download.py
├── errorscreen.kv
├── fonts
│ ├── MPLUS1p-ExtraBold.ttf
│ └── MPLUS1p-Medium.ttf
├── localization.py
├── lyrics.py
├── main.py
├── neodeemer.kv
├── neodeemer.spec
├── p4a
│ └── hook.py
├── requirements.txt
├── settingsscreen.kv
├── songinfoloader.py
├── splaylistscreen.kv
├── tools.py
├── utils
│ └── userscript.user.js
├── webapi.py
├── youtubescreen.kv
└── yplaylistscreen.kv
└── tests
├── __init__.py
├── test_lyrics.py
├── test_playlistdownload.py
└── test_searchdownload.py
/.github/workflows/build_android.yml:
--------------------------------------------------------------------------------
1 | name: Build Android
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | workflow_dispatch:
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 |
19 | - name: Create env.env file
20 | shell: bash
21 | env:
22 | SPOTIPY_CLIENT_ID: ${{ secrets.SPOTIPY_CLIENT_ID }}
23 | SPOTIPY_CLIENT_SECRET: ${{ secrets.SPOTIPY_CLIENT_SECRET }}
24 | YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
25 | run: |
26 | touch neodeemer/env.env
27 | echo SPOTIPY_CLIENT_ID="$SPOTIPY_CLIENT_ID" >> neodeemer/env.env
28 | echo SPOTIPY_CLIENT_SECRET="$SPOTIPY_CLIENT_SECRET" >> neodeemer/env.env
29 | echo YOUTUBE_API_KEY="$YOUTUBE_API_KEY" >> neodeemer/env.env
30 |
31 | - name: Build with Buildozer
32 | uses: ArtemSBulgakov/buildozer-action@v1.1.3
33 | id: buildozer
34 | with:
35 | command: pip3 install sh==1.14.2; buildozer android debug
36 | workdir: neodeemer
37 |
38 | - name: Upload a Build Artifact
39 | uses: actions/upload-artifact@v4
40 | with:
41 | path: ${{ steps.buildozer.outputs.filename }}
--------------------------------------------------------------------------------
/.github/workflows/build_windows.yml:
--------------------------------------------------------------------------------
1 | name: Build Windows
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | workflow_dispatch:
10 |
11 | jobs:
12 | build:
13 | runs-on: windows-latest
14 |
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 |
19 | - name: Create .env file
20 | shell: cmd
21 | env:
22 | SPOTIPY_CLIENT_ID: ${{ secrets.SPOTIPY_CLIENT_ID }}
23 | SPOTIPY_CLIENT_SECRET: ${{ secrets.SPOTIPY_CLIENT_SECRET }}
24 | YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
25 | run: |
26 | echo SPOTIPY_CLIENT_ID="%SPOTIPY_CLIENT_ID%" >> neodeemer/.env
27 | echo SPOTIPY_CLIENT_SECRET="%SPOTIPY_CLIENT_SECRET%" >> neodeemer/.env
28 | echo YOUTUBE_API_KEY="%YOUTUBE_API_KEY%" >> neodeemer/.env
29 |
30 | - name: Download dependencies
31 | shell: cmd
32 | run: |
33 | python -m pip install pyinstaller==5.3
34 | python -m pip install -r neodeemer/requirements.txt
35 | curl -L https://downloads.fdossena.com/geth.php?r=mesa64-latest --output mesa.7z
36 | 7z e mesa.7z opengl32.dll -o"neodeemer"
37 |
38 | - name: Build with PyInstaller
39 | shell: cmd
40 | run: |
41 | cd neodeemer
42 | set KIVY_GL_BACKEND=angle_sdl2
43 | python -m PyInstaller neodeemer.spec
44 |
45 | - name: Upload a Build Artifact
46 | uses: actions/upload-artifact@v4
47 | with:
48 | path: neodeemer/dist
--------------------------------------------------------------------------------
/.github/workflows/run_tests.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | workflow_dispatch:
10 | schedule:
11 | - cron: "33 3 15 * *"
12 |
13 | jobs:
14 | test:
15 | runs-on: ubuntu-latest
16 | strategy:
17 | matrix:
18 | python-version: ["3.8", "3.9", "3.10"]
19 |
20 | steps:
21 | - name: Checkout
22 | uses: actions/checkout@v3.3.0
23 |
24 | - name: Create .env file
25 | shell: bash
26 | env:
27 | SPOTIPY_CLIENT_ID: ${{ secrets.SPOTIPY_CLIENT_ID }}
28 | SPOTIPY_CLIENT_SECRET: ${{ secrets.SPOTIPY_CLIENT_SECRET }}
29 | YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
30 | run: |
31 | touch neodeemer/.env
32 | echo SPOTIPY_CLIENT_ID="$SPOTIPY_CLIENT_ID" >> neodeemer/.env
33 | echo SPOTIPY_CLIENT_SECRET="$SPOTIPY_CLIENT_SECRET" >> neodeemer/.env
34 | echo YOUTUBE_API_KEY="$YOUTUBE_API_KEY" >> neodeemer/.env
35 |
36 | - name: Set up Python ${{ matrix.python-version }}
37 | uses: actions/setup-python@v4.2.0
38 | with:
39 | python-version: ${{ matrix.python-version }}
40 |
41 | - name: Download dependencies
42 | shell: bash
43 | run: |
44 | python3 -m pip install -r neodeemer/requirements.txt
45 |
46 | - name: Test with unittest
47 | shell: bash
48 | run: |
49 | python3 -m unittest discover -v -s tests
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | #*.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | #.idea/
161 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [
](https://github.com/Tutislav/neodeemer/releases/latest)
2 |
3 | # Neodeemer
4 | [](https://github.com/Tutislav/neodeemer/releases/latest)
5 | [](https://github.com/Tutislav/neodeemer/releases/latest)
6 | [](https://github.com/Tutislav/neodeemer/blob/main/LICENSE)
7 | [](https://www.softpedia.com/get/Internet/Download-Managers/Neodeemer.shtml#status)\
8 | [](#installation)
9 | [](#installation)
10 | [](#running-from-source)
11 |
12 | Spotify/YouTube song downloader with option to download whole albums, playlists and also lyrics.\
13 | Available on [**Android**](#installation), [**Windows**](#installation) and [**Linux***](#running-from-source "You must run it from source").
14 |
15 | > [!IMPORTANT]
16 | > YouTube recently started blocking IP adresses, when you download too much videos as described in https://github.com/yt-dlp/yt-dlp/issues/3766. \
17 | > It looks like bans are temporary and doesn't apply to logged users in browser.\
18 | > So if you are downloading large playlist and download suddenly stops working , just try it later.
19 |
20 | ## **[▶Download latest release◀](https://github.com/Tutislav/neodeemer/releases/latest)**
21 |
22 | ## Features
23 | - Spotify/YouTube search - you can search by artist, album or track name
24 | - Play songs before you download it
25 | - Download single songs or whole albums
26 | - Download whole Spotify/YouTube playlists - saved to `.m3u` file
27 | - Download songs from share screen - tap share in Spotify/YouTube app on Android
28 | - Automatically save track name, artist name, album image and other tags to songs
29 | - Lyrics - embedded directly to audio files
30 | - Synchronized lyrics - saved to `.lrc` files
31 | - ~~Change audio format - `m4a` or `mp3`~~ - only m4a audio format is available right now
32 | - ~~Download age restricted videos~~ - not available right now
33 | - [Browser Extension](#browser-extension) - download music directly from YouTube video page
34 |
35 | ## Screenshots
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | ## Installation
54 | ### Android
55 | 1. [Download neodeemer_android.apk](https://github.com/Tutislav/neodeemer/releases/latest)
56 | 2. Open downloaded apk and install it
57 |
58 | If it says you can't install unknown apps, just go to Settings and search Install unknown apps, then select your browser and tap Allow from this source.
59 | ### Windows
60 | 1. [Download neodeemer_windows.exe](https://github.com/Tutislav/neodeemer/releases/latest)
61 | 2. Just open the downloaded exe (It doesn't require installation)
62 |
63 | ## Running from source
64 | 1. Install Python 3.8.10 or later if you don't have it already
65 | 2. Clone this repo
66 | 3. Get your own [Spotify](https://developer.spotify.com/dashboard/) and [YouTube](https://developers.google.com/youtube/v3/getting-started) API keys
67 | 4. Create `.env` file in `neodeemer\neodeemer` (folder where is main.py) like this:
68 | ```dotenv
69 | SPOTIPY_CLIENT_ID=
70 | SPOTIPY_CLIENT_SECRET=
71 | YOUTUBE_API_KEY=
72 | ```
73 | 5. Continue depending on your platform
74 | ### Windows
75 | ```cmd
76 | cd neodeemer\neodeemer
77 | python -m venv venv
78 | venv\Scripts\activate
79 | pip install -r requirements.txt
80 | python main.py
81 | ```
82 | ### Linux
83 | ```bash
84 | cd neodeemer/neodeemer
85 | python3 -m venv venv
86 | source venv/bin/activate
87 | pip install -r requirements.txt
88 | python3 main.py
89 | ```
90 |
91 | ## Browser Extension
92 | You can install Neodeemer UserScript to download music directly from YouTube video page.
93 | 1. Install [TamperMonkey](https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo) to your browser
94 | 2. Install [Neodeemer UserScript](https://raw.githubusercontent.com/Tutislav/neodeemer/main/neodeemer/utils/userscript.user.js)
95 | 3. Start Neodeemer
96 | 4. Enable WebApi in Neodeemeer settings
97 | 5. Go to any YouTube video and click Neodeemer icon under the video, it will add video to download queue
98 | ### Download to different device
99 | 1. Click on TamperMonkey extension
100 | 2. Go to *Control Panel > Installed Scripts > Neodeemer UserScript*
101 | 3. Change `localhost` to `yourdeviceip` on these lines:
102 | ```js
103 | // @connect yourdeviceip
104 | ```
105 | ```js
106 | const host = "yourdeviceip";
107 | ```
108 | where `yourdeviceip` is IP adress of device you want to control (e.g. 192.168.0.123).\
109 | You can get your device IP depending on your OS:\
110 | Android - *System Settings > Wi-Fi > YourNetwork > IP Address*\
111 | Windows - *cmd > ipconfig > IPv4 Address*\
112 | Linux - *terminal > ip addr > inet*
113 |
114 | ## Issues
115 | If encounter some tracks, that has bad quality or even doesn't match the name, you can submit it directly in the app using
116 |
117 |
118 |
119 |
120 | icon, when you select track.\
121 | If you have other issue or some idea to make the app better, just open a new issue on GitHub.
122 |
123 | ## Acknowledgments
124 | This app wouldn't be possible to make without these libraries:
125 | - [Kivy](https://kivy.org/)
126 | - [KivyMD](https://github.com/kivymd/KivyMD)
127 | - [Spotipy](https://github.com/plamere/spotipy)
128 | - [youtube_search](https://github.com/joetats/youtube_search)
129 | - [ytmusicapi](https://github.com/sigma67/ytmusicapi)
130 | - [pytube](https://github.com/pytube/pytube)
131 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp)
132 | - [Poke](https://github.com/ashley0143/poke)
133 | - [LRCLIB](https://github.com/tranxuanthang/lrclib)
134 | - [music-tag](https://github.com/KristoforMaynard/music-tag)
135 | - [FFPyPlayer](https://github.com/matham/ffpyplayer)
136 | - [Plyer](https://github.com/kivy/plyer)
137 | - [python-dotenv](https://github.com/theskumar/python-dotenv)
138 | - [Requests](https://github.com/psf/requests)
139 | - [Unidecode](https://github.com/avian2/unidecode)
140 | - [Certifi](https://github.com/certifi/python-certifi)
141 | - [TamperMonkey](https://www.tampermonkey.net/)
--------------------------------------------------------------------------------
/img/bug_outline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/bug_outline.png
--------------------------------------------------------------------------------
/img/bug_outline_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/bug_outline_light.png
--------------------------------------------------------------------------------
/img/neodeemer_screenshot_1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/neodeemer_screenshot_1.jpg
--------------------------------------------------------------------------------
/img/neodeemer_screenshot_1_light.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/neodeemer_screenshot_1_light.jpg
--------------------------------------------------------------------------------
/img/neodeemer_screenshot_2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/neodeemer_screenshot_2.jpg
--------------------------------------------------------------------------------
/img/neodeemer_screenshot_2_light.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/neodeemer_screenshot_2_light.jpg
--------------------------------------------------------------------------------
/img/neodeemer_screenshot_3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/neodeemer_screenshot_3.jpg
--------------------------------------------------------------------------------
/img/neodeemer_screenshot_3_light.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/neodeemer_screenshot_3_light.jpg
--------------------------------------------------------------------------------
/img/neodeemer_screenshot_4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/neodeemer_screenshot_4.jpg
--------------------------------------------------------------------------------
/img/neodeemer_screenshot_4_light.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/img/neodeemer_screenshot_4_light.jpg
--------------------------------------------------------------------------------
/neodeemer/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/neodeemer/__init__.py
--------------------------------------------------------------------------------
/neodeemer/buildozer.spec:
--------------------------------------------------------------------------------
1 | [app]
2 |
3 | # (str) Title of your application
4 | title = Neodeemer
5 |
6 | # (str) Package name
7 | package.name = neodeemer
8 |
9 | # (str) Package domain (needed for android/ios packaging)
10 | package.domain = cz.tutislav
11 |
12 | # (str) Source code where the main.py live
13 | source.dir = .
14 |
15 | # (list) Source files to include (let empty to include all the files)
16 | source.include_exts = py,png,jpg,kv,env,json,ttf
17 |
18 | # (str) Application versioning (method 2)
19 | version.regex = __version__ = ['"](.*)['"]
20 | version.filename = %(source.dir)s/main.py
21 |
22 | # (list) Application requirements
23 | # comma separated e.g. requirements = sqlite3,kivy
24 | requirements = python3,async-timeout==4.0.2,Brotli==1.0.9,certifi>=2024.8.30,charset-normalizer==2.1.1,docutils==0.19,ffpyplayer,ffpyplayer_codecs,idna==3.4,Kivy==2.1.0,kivymd==1.0.2,music-tag==0.4.3,mutagen==1.47.0,Pillow==8.4.0,plyer==2.1.0,Pygments==2.14.0,python-dotenv==0.21.1,pytube,redis==5.0.8,requests>=2.32.3,six==1.16.0,spotipy==2.22.1,Unidecode==1.3.6,urllib3==1.26.20,websockets==13.0.1,youtube-search==2.1.2,yt-dlp>=2024.8.6,ytmusicapi==1.7.5
25 |
26 | # (str) Presplash of the application
27 | presplash.filename = %(source.dir)s/data/presplash.png
28 |
29 | # (str) Icon of the application
30 | icon.filename = %(source.dir)s/data/icon.png
31 |
32 | # (str) Supported orientation (one of landscape, sensorLandscape, portrait or all)
33 | orientation = portrait
34 |
35 | # (bool) Indicate if the application should be fullscreen or not
36 | fullscreen = 0
37 |
38 | # (list) Permissions
39 | android.permissions = INTERNET, WRITE_EXTERNAL_STORAGE, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
40 |
41 | # (int) Target Android API, should be as high as possible.
42 | android.api = 29
43 |
44 | # (int) Minimum API your APK / AAB will support.
45 | android.minapi = 21
46 |
47 | # (str) Android NDK version to use
48 | android.ndk = 19b
49 |
50 | # (int) Android NDK API to use. This is the minimum API your app will support, it should usually match android.minapi.
51 | android.ndk_api = 21
52 |
53 | # (list) The Android archs to build for, choices: armeabi-v7a, arm64-v8a, x86, x86_64
54 | # In past, was `android.arch` as we weren't supporting builds for multiple archs at the same time.
55 | android.archs = arm64-v8a, armeabi-v7a
56 |
57 | # (str) XML file to include as an intent filters in tag
58 | android.manifest.intent_filters = %(source.dir)s/data/intentfilters.xml
59 |
60 | # (bool) enables Android auto backup feature (Android API >=23)
61 | android.allow_backup = True
62 |
63 | # (str) python-for-android specific commit to use, defaults to HEAD, must be within p4a.branch
64 | p4a.commit = 227a765
65 |
66 | # (str) Filename to the hook for p4a
67 | p4a.hook = %(source.dir)s/p4a/hook.py
68 |
69 | [buildozer]
70 |
71 | # (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
72 | log_level = 2
--------------------------------------------------------------------------------
/neodeemer/data/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/neodeemer/data/icon.ico
--------------------------------------------------------------------------------
/neodeemer/data/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/neodeemer/data/icon.png
--------------------------------------------------------------------------------
/neodeemer/data/intentfilters.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/neodeemer/data/presplash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/neodeemer/data/presplash.png
--------------------------------------------------------------------------------
/neodeemer/data/ytsfilter.json:
--------------------------------------------------------------------------------
1 | {
2 | "options": {
3 | "min_video_views": 300,
4 | "video_duration_tolerance_s": 150,
5 | "not_same_name_penalization": -10,
6 | "contains_date_penalization": -10,
7 | "contains_word_title_penalization": -10,
8 | "contains_word_description_penalization": -5,
9 | "youtube_music_priority": 15,
10 | "prefered_channel_priority": 10,
11 | "artist_in_title_priority": 5
12 | },
13 | "preferred_channels": ["rhino", "vevo", "warner", "watertower", "bandzonecz", "afm records"],
14 | "excluded_channels": ["songs_cz", "cumizgum", "forever runandplay", "travelguy"],
15 | "excluded_words": ["fest", "tour", "filmed", "live", "cover", "remix", "drum version", "full album", "trailer", "news", "arena", "koncert", "koncertu", "konzertu", "zaznam koncertu", "zive", "zivak", "festival", "cele album"]
16 | }
--------------------------------------------------------------------------------
/neodeemer/download.py:
--------------------------------------------------------------------------------
1 | import os
2 | from urllib import request
3 |
4 | import music_tag
5 | import requests
6 | from pytube import YouTube
7 | from yt_dlp import YoutubeDL
8 |
9 | from lyrics import Lyrics
10 | from songinfoloader import SpotifyLoader
11 | from tools import HEADERS, TrackStates
12 |
13 |
14 | class Download():
15 | def __init__(self, track_dict: dict, spotifyloader: SpotifyLoader, download_queue_info: dict = None, save_lyrics: bool = True, synchronized_lyrics: bool = False, app_version: float = 0.0):
16 | self.track_dict = track_dict
17 | self.spotifyloader = spotifyloader
18 | if download_queue_info != None:
19 | self.download_queue_info = download_queue_info
20 | else:
21 | self.download_queue_info = {
22 | "position": 0,
23 | "downloaded_b": 0,
24 | "total_b": 0
25 | }
26 | self.downloaded_bytes_prev = 0
27 | self.save_lyrics = save_lyrics
28 | self.synchronized_lyrics = synchronized_lyrics
29 | if self.save_lyrics:
30 | self.lyrics = Lyrics(app_version)
31 | self.download_attempt = 0
32 |
33 | def download_on_progress(self, stream=None, chunk=None, bytes_remaining=None):
34 | if type(stream) is dict:
35 | chunk_size_b = stream["downloaded_bytes"] - self.downloaded_bytes_prev
36 | self.downloaded_bytes_prev = stream["downloaded_bytes"]
37 | self.download_queue_info["downloaded_b"] += chunk_size_b
38 | else:
39 | self.download_queue_info["downloaded_b"] += len(chunk)
40 |
41 | def total_b_add(self, size_b):
42 | if self.track_dict["track_size_b"] == None:
43 | self.track_dict["track_size_b"] = size_b
44 | if not self.track_dict["track_size_added"]:
45 | self.download_queue_info["total_b"] += self.track_dict["track_size_b"]
46 | self.track_dict["track_size_added"] = True
47 |
48 | def create_subfolders(self):
49 | if not os.path.exists(self.track_dict["folder_path"]):
50 | try:
51 | os.makedirs(self.track_dict["folder_path"])
52 | except OSError:
53 | pass
54 |
55 | def delete_broken_files(self):
56 | try:
57 | if os.path.exists(self.track_dict["file_path"]):
58 | os.remove(self.track_dict["file_path"])
59 | elif os.path.exists(self.track_dict["file_path2"]):
60 | os.remove(self.track_dict["file_path2"])
61 | except:
62 | pass
63 |
64 | def playlist_file_save(self):
65 | if "playlist_name" in self.track_dict:
66 | if self.track_dict["forcedmp3"]:
67 | file_path = self.track_dict["file_path2"]
68 | else:
69 | file_path = self.track_dict["file_path"]
70 | file_path = os.path.relpath(file_path, self.spotifyloader.music_folder_path)
71 | with open(self.track_dict["playlist_file_path"], "a+", encoding="utf-8") as playlist_file:
72 | playlist_file.seek(0)
73 | if not file_path + "\n" in playlist_file.readlines():
74 | playlist_file.write(file_path + "\n")
75 |
76 | def download_synchronized_lyrics(self):
77 | if self.track_dict["forcedmp3"]:
78 | file_path = self.track_dict["file_path2"]
79 | else:
80 | file_path = self.track_dict["file_path"]
81 | lyrics = self.lyrics.find_lyrics(self.track_dict, self.synchronized_lyrics)
82 | if lyrics != "":
83 | lrc_file_path = os.path.splitext(file_path)[0] + ".lrc"
84 | with open(lrc_file_path, "w", encoding="utf-8") as lrc_file:
85 | lrc_file.write(lyrics)
86 |
87 | def save_tags(self):
88 | self.track_dict["state"] = TrackStates.TAGSAVING
89 | try:
90 | if self.track_dict["forcedmp3"]:
91 | file_path = self.track_dict["file_path2"]
92 | else:
93 | file_path = self.track_dict["file_path"]
94 | mtag = music_tag.load_file(file_path)
95 | mtag["artist"] = self.track_dict["artist_name2"]
96 | mtag["albumartist"] = self.track_dict["album_artist"]
97 | if len(self.track_dict["artist_genres"]) > 0:
98 | mtag["genre"] = self.track_dict["artist_genres"][0]
99 | mtag["album"] = self.track_dict["album_name"]
100 | mtag["totaltracks"] = self.track_dict["album_trackscount"]
101 | mtag["year"] = self.track_dict["album_year"]
102 | if len(self.track_dict["album_image"]) > 0:
103 | with request.urlopen(self.track_dict["album_image"]) as urldata:
104 | mtag["artwork"] = urldata.read()
105 | mtag["tracktitle"] = self.track_dict["track_name"]
106 | mtag["tracknumber"] = self.track_dict["track_number"]
107 | mtag["comment"] = self.track_dict["video_id"]
108 | if self.save_lyrics and self.track_dict["artist_name"] != "":
109 | try:
110 | mtag["lyrics"] = self.lyrics.find_lyrics(self.track_dict)
111 | if self.synchronized_lyrics:
112 | self.download_synchronized_lyrics()
113 | except Exception as e:
114 | print("Error while getting lyrics for " + self.track_dict["artist_name"] + " - " + self.track_dict["track_name"] + ": " + e)
115 | mtag.save()
116 | except:
117 | self.track_dict["state"] = TrackStates.FOUND
118 | else:
119 | self.track_dict["state"] = TrackStates.COMPLETED
120 | self.download_queue_info["position"] += 1
121 | self.playlist_file_save()
122 |
123 | def download_file(self, url, file_path, use_headers=True, response=None):
124 | with open(file_path, "wb") as file:
125 | if response == None:
126 | if use_headers:
127 | response = requests.get(url, headers=HEADERS, stream=True)
128 | else:
129 | response = requests.get(url, stream=True)
130 | if response.status_code != 200 or len(response.content) == 0:
131 | raise
132 | self.total_b_add(len(response.content))
133 | for data in response.iter_content(4096):
134 | file.write(data)
135 | self.download_on_progress(chunk=data)
136 |
137 | def download_m4a_youtube_dl(self):
138 | video_url = "https://youtu.be/" + self.track_dict["video_id"]
139 | file_path_without_ext = os.path.splitext(self.track_dict["file_path"])[0]
140 | params = {
141 | "format": "m4a/bestaudio",
142 | "outtmpl": file_path_without_ext + ".%(ext)s",
143 | "progress_hooks": [self.download_on_progress],
144 | "quiet": True,
145 | "postprocessor_args": ["-loglevel", "quiet"]
146 | }
147 | with YoutubeDL(params) as ydl:
148 | video_info = ydl.extract_info(video_url, False)
149 | self.total_b_add(video_info["filesize"])
150 | if video_info["ext"] != "m4a":
151 | self.track_dict["forcedmp3"] = True
152 | self.track_dict["state"] = TrackStates.DOWNLOADING
153 | ydl.download([video_url])
154 | self.track_dict["state"] = TrackStates.SAVED
155 |
156 | def download_m4a_pytube(self):
157 | file_name = os.path.split(self.track_dict["file_path"])[1]
158 | youtube_video = YouTube("https://youtu.be/" + self.track_dict["video_id"], self.download_on_progress).streams.get_audio_only()
159 | self.total_b_add(int(youtube_video.filesize))
160 | self.track_dict["state"] = TrackStates.DOWNLOADING
161 | try:
162 | youtube_video.download(self.track_dict["folder_path"], file_name)
163 | except:
164 | self.download_file(youtube_video.url, self.track_dict["file_path"])
165 | self.track_dict["state"] = TrackStates.SAVED
166 |
167 | def download_m4a_poke(self):
168 | url = "https://poketube.fun/api/video/download?v=" + self.track_dict["video_id"] + "&q=140&f=webm"
169 | self.track_dict["state"] = TrackStates.DOWNLOADING
170 | self.download_file(url, self.track_dict["file_path"], False)
171 | self.track_dict["state"] = TrackStates.SAVED
172 |
173 | def download_mp3_neodeemer(self):
174 | raise
175 | track_dict_temp = {}
176 | track_dict_temp.update(self.track_dict)
177 | track_dict_temp["forcedmp3"] = False
178 | d = Download(track_dict_temp, self.spotifyloader, None, False)
179 | d.download_track()
180 | with open(track_dict_temp["file_path"], "rb") as input_file:
181 | response = requests.post("https://neodeemer.vorpal.tk/converttomp3.php", files={"input_file": input_file}, stream=True)
182 | d.delete_broken_files()
183 | del d
184 | self.track_dict["state"] = TrackStates.DOWNLOADING
185 | self.download_file("", self.track_dict["file_path2"], False, response)
186 | self.track_dict["state"] = TrackStates.SAVED
187 |
188 | def download_track(self):
189 | self.create_subfolders()
190 | while not any(state == self.track_dict["state"] for state in [TrackStates.UNAVAILABLE, TrackStates.COMPLETED]):
191 | self.download_attempt += 1
192 | if self.track_dict["state"] == TrackStates.UNKNOWN and self.track_dict["video_id"] == None:
193 | self.spotifyloader.track_find_video_id(self.track_dict)
194 | elif self.track_dict["state"] == TrackStates.FOUND and self.track_dict["artist_name"] == "":
195 | self.spotifyloader.track_find_spotify_metadata(self.track_dict)
196 | if self.track_dict["state"] == TrackStates.FOUND:
197 | if not self.track_dict["forcedmp3"]:
198 | try:
199 | self.delete_broken_files()
200 | self.download_m4a_youtube_dl()
201 | except:
202 | try:
203 | self.delete_broken_files()
204 | self.download_m4a_pytube()
205 | except:
206 | try:
207 | self.delete_broken_files()
208 | self.download_m4a_poke()
209 | except:
210 | self.delete_broken_files()
211 | if not self.spotifyloader.format_mp3:
212 | self.track_dict["state"] = TrackStates.FOUND
213 | self.track_dict["forcedmp3"] = True
214 | else:
215 | self.track_dict["state"] = TrackStates.UNAVAILABLE
216 | self.track_dict["reason"] = "Error while downloading"
217 | else:
218 | try:
219 | self.delete_broken_files()
220 | self.download_mp3_neodeemer()
221 | except:
222 | self.delete_broken_files()
223 | if self.spotifyloader.format_mp3:
224 | self.track_dict["state"] = TrackStates.FOUND
225 | self.track_dict["forcedmp3"] = False
226 | else:
227 | self.track_dict["state"] = TrackStates.UNAVAILABLE
228 | self.track_dict["reason"] = "Error while downloading"
229 | if self.track_dict["state"] == TrackStates.SAVED:
230 | self.save_tags()
231 | if self.download_attempt >= 5:
232 | self.track_dict["state"] = TrackStates.UNAVAILABLE
233 | self.track_dict["reason"] = "Error while downloading"
234 | if self.track_dict["state"] == TrackStates.UNAVAILABLE:
235 | self.download_queue_info["position"] += 1
236 |
--------------------------------------------------------------------------------
/neodeemer/errorscreen.kv:
--------------------------------------------------------------------------------
1 | :
2 | MDBoxLayout:
3 | orientation: "vertical"
4 |
5 | MDBoxLayout:
6 | orientation: "vertical"
7 | adaptive_height: True
8 | spacing: 4
9 |
10 | MDTopAppBar:
11 | id: toolbar
12 | title: app.loc.TITLE
13 | right_action_items: [["menu", lambda x: app.navigation_menu.set_state("open")]]
14 |
15 | MDProgressBar:
16 | id: progressbar
17 | color: app.theme_cls.accent_dark
18 |
19 | ScrollView:
20 | MDSelectionListFix:
21 | id: mdlist_tracks
22 | on_selected: app.mdlist_selected(*args)
23 | on_unselected: app.mdlist_unselected(*args)
24 | on_selected_mode: app.mdlist_set_mode(*args)
--------------------------------------------------------------------------------
/neodeemer/fonts/MPLUS1p-ExtraBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/neodeemer/fonts/MPLUS1p-ExtraBold.ttf
--------------------------------------------------------------------------------
/neodeemer/fonts/MPLUS1p-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/neodeemer/fonts/MPLUS1p-Medium.ttf
--------------------------------------------------------------------------------
/neodeemer/localization.py:
--------------------------------------------------------------------------------
1 | from locale import getdefaultlocale
2 |
3 | from kivy.utils import platform
4 |
5 | from tools import font
6 |
7 |
8 | CZ = {
9 | #common
10 | "Added to download queue": "Přidáno do fronty stahování",
11 | "Download completed": "Stahování dokončeno",
12 | "Downloaded ": "Staženo ",
13 | " songs": " skladeb",
14 | " songs can't be downloaded": " skladeb se nepodařilo stáhnout",
15 | "Error while loading artists": "Chyba při načítání interpretů",
16 | "Error while loading albums": "Chyba při načítání alb",
17 | "Error while loading tracks": "Chyba při načítání skladeb",
18 | "Error while loading playlist": "Chyba při načítání playlistu",
19 | "Error while playing track": "Chyba při přehrávání skladby",
20 | "Not available on YouTube": "Není na YouTube",
21 | "Video is age restricted on YouTube": "Video je omezeno věkem na YouTube",
22 | "Error while downloading": "Chyba při stahování",
23 | #navigation_menu
24 | "Spotify search": "Vyhledávání na Spotify",
25 | "Tracks, Albums and Artists": "Skladby, Alba a Interpreti",
26 | "YouTube search": "Vyhledávání na YouTube",
27 | "Videos": "Videa",
28 | "Spotify playlist": "Spotify playlist",
29 | "YouTube playlist": "YouTube playlist",
30 | "Settings": "Nastavení",
31 | "Update": "Aktualizace",
32 | "New version is available": "Je dostupná nová verze",
33 | #SpotifyScreen
34 | "[b]Artists[/b]": "[b]Interpreti[/b]",
35 | "[b]Albums[/b]": "[b]Alba[/b]",
36 | "[b]Tracks[/b]": "[b]Skladby[/b]",
37 | "Search singers/bands": "Vyhledávání zpěváků/kapel",
38 | "Search albums": "Vyhledávání alb",
39 | "Search tracks": "Vyhledávání skladeb",
40 | #YouTubeScreen
41 | "Search video name": "Vyhledávání videí",
42 | #SPlaylistScreen
43 | "Link or ID of Spotify playlist": "Odkaz na Spotify playlist",
44 | #YPlaylistScreen
45 | "Link of YouTube playlist": "Odkaz na YouTube playlist",
46 | #tracks_actions
47 | "Cancel": "Zrušit",
48 | "All": "Všechny",
49 | "Only selected": "Jen vybrané",
50 | #playlist_actions
51 | "Show": "Zobrazit",
52 | "Lyrics only": "Pouze texty",
53 | #SettingsScreen
54 | "Format": "Formát",
55 | "(Better quality)": "(Lepší kvalita)",
56 | "(Slower download)": "(Pomalejší stahování)",
57 | "Download lyrics": "Stahovat texty",
58 | "Unsynchronized lyrics": "Nesynchronizované texty",
59 | "Synchronized lyrics": "Synchronizované texty",
60 | "Enable WebApi": "Povolit WebApi",
61 | "Create subfolders": "Vytvářet podsložky",
62 | "Music folder": "Složka, do které se ukládá hudba",
63 | "Choose folder": "Vybrat složku",
64 | "Toggle theme": "Přepnout vzhled",
65 | "Language": "Jazyk",
66 | "Settings saved": "Nastavení uloženo",
67 | #submit_bug_dialog
68 | "Submit bug": "Nahlásit chybu",
69 | "If some tracks has bad quality or even doesn't match the name you can submit it": "Pokud je nějaká skladba ve špatné kvalitě nebo dokonce nesedí ke jménu skladby, tak ji můžete nahlásit"
70 | }
71 |
72 | class Localization():
73 | TITLE_R = "Neodeemer"
74 | LANGUAGES = {
75 | "en_US": "default",
76 | "cs_CZ": CZ
77 | }
78 |
79 | def __init__(self):
80 | self.lang = "en_US"
81 | if platform == "android":
82 | from jnius import autoclass
83 | javalocale = autoclass("java.util.Locale")
84 | self.system_lang = javalocale.getDefault().toString()
85 | else:
86 | self.system_lang = getdefaultlocale()[0]
87 | for lang in self.LANGUAGES.keys():
88 | if lang == self.system_lang:
89 | self.lang = self.system_lang
90 | self.TITLE = self.get(self.TITLE_R)
91 |
92 | def set_lang(self, lang):
93 | self.lang = lang
94 |
95 | def get_lang(self):
96 | return self.lang
97 |
98 | def get_market(self):
99 | return self.system_lang[-2:]
100 |
101 | def get(self, text):
102 | return font(self.get_r(text))
103 |
104 | def get_r(self, text):
105 | if self.lang == "en_US":
106 | return text
107 | elif text in self.LANGUAGES[self.lang]:
108 | return self.LANGUAGES[self.lang][text]
109 | else:
110 | return text
--------------------------------------------------------------------------------
/neodeemer/lyrics.py:
--------------------------------------------------------------------------------
1 | import requests
2 |
3 |
4 | class Lyrics():
5 | def __init__(self, app_version: float):
6 | self.lrclib = LRCLIB(app_version)
7 |
8 | def find_lyrics(self, track_dict: dict, synchronized: bool = False):
9 | lyrics = self.lrclib.find_lyrics(track_dict, synchronized)
10 | return lyrics
11 |
12 | class LRCLIB():
13 | def __init__(self, app_version: float):
14 | self.HEADERS = {"user-agent": "Neodeemer " + str(app_version) + " (https://github.com/Tutislav/neodeemer)"}
15 | self.get_url = "https://lrclib.net/api/get?artist_name={artist_name}&album_name={album_name}&track_name={track_name}&duration={duration}"
16 |
17 | def find_lyrics(self, track_dict: dict, synchronized: bool = False):
18 | lyrics = ""
19 | track_duration_s = int(track_dict["track_duration_ms"] / 1000)
20 | url = self.get_url.format(artist_name=track_dict["artist_name"], album_name=track_dict["album_name"], track_name=track_dict["track_name"], duration=track_duration_s)
21 | urldata = requests.get(url, headers=self.HEADERS)
22 | data = urldata.json()
23 | if "statusCode" in data and data["statusCode"] == 404:
24 | lyrics = ""
25 | elif "syncedLyrics" in data:
26 | if synchronized:
27 | if data["syncedLyrics"] != None and len(data["syncedLyrics"]) > 0:
28 | lyrics = data["syncedLyrics"]
29 | else:
30 | if data["plainLyrics"] != None and len(data["plainLyrics"]) > 0:
31 | lyrics = data["plainLyrics"]
32 | return lyrics
33 |
--------------------------------------------------------------------------------
/neodeemer/main.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import sys
4 | from functools import partial
5 | from random import randint
6 | from threading import Thread
7 | from time import sleep
8 |
9 | import certifi
10 | from kivy.animation import Animation
11 | from kivy.clock import Clock
12 | from kivy.core.audio import SoundLoader
13 | from kivy.core.text import LabelBase
14 | from kivy.core.window import Window
15 | from kivy.lang import Builder
16 | from kivy.metrics import dp
17 | from kivy.properties import DictProperty
18 | from kivy.resources import resource_add_path, resource_find
19 | from kivy.uix.image import AsyncImage
20 | from kivy.uix.screenmanager import Screen, ScreenManager
21 | from kivy.utils import platform
22 | from kivymd.app import MDApp
23 | from kivymd.uix.boxlayout import MDBoxLayout
24 | from kivymd.uix.button import MDFlatButton
25 | from kivymd.uix.dialog import MDDialog
26 | from kivymd.uix.filemanager import MDFileManager
27 | from kivymd.uix.floatlayout import MDFloatLayout
28 | from kivymd.uix.list import (IconLeftWidget, IconRightWidget, ILeftBody,
29 | OneLineAvatarIconListItem,
30 | TwoLineAvatarIconListItem, TwoLineIconListItem)
31 | from kivymd.uix.menu import MDDropdownMenu
32 | from kivymd.uix.selection import MDSelectionList
33 | from kivymd.uix.snackbar import Snackbar
34 | from kivymd.uix.tab import MDTabsBase
35 | from plyer import notification
36 |
37 | from download import Download
38 | from localization import Localization
39 | from songinfoloader import SpotifyLoader, YoutubeLoader
40 | from tools import (TrackStates, check_update_available, font, open_url, submit_bugs,
41 | check_mp3_available)
42 | from webapi import WebApiServer
43 |
44 | __version__ = "0.75"
45 |
46 | class Loading(MDFloatLayout):
47 | pass
48 |
49 | class MDSelectionListFix(MDSelectionList):
50 | def add_widget(self, widget, index=0, canvas=None):
51 | super().add_widget(widget, index, canvas)
52 | selection_icon = widget.parent.children[0]
53 | widget.parent.remove_widget(selection_icon)
54 | widget.parent.add_widget(selection_icon, 1)
55 |
56 | class AsyncImageLeftWidget(ILeftBody, AsyncImage):
57 | pass
58 |
59 | class ListLineArtist(TwoLineIconListItem):
60 | artist_dict = DictProperty()
61 |
62 | def __init__(self, *args, **kwargs):
63 | kwargs["text"] = font(kwargs["text"])
64 | kwargs["secondary_text"] = font(kwargs["secondary_text"])
65 | super().__init__(*args, **kwargs)
66 |
67 | class ListLineAlbum(TwoLineIconListItem):
68 | album_dict = DictProperty()
69 |
70 | def __init__(self, *args, **kwargs):
71 | kwargs["text"] = font(kwargs["text"])
72 | kwargs["secondary_text"] = font(kwargs["secondary_text"])
73 | super().__init__(*args, **kwargs)
74 |
75 | class ListLineTrack(TwoLineAvatarIconListItem):
76 | track_dict = DictProperty()
77 |
78 | def __init__(self, *args, **kwargs):
79 | kwargs["text"] = font(kwargs["text"])
80 | kwargs["secondary_text"] = font(kwargs["secondary_text"])
81 | super().__init__(*args, **kwargs)
82 |
83 | class WindowManager(ScreenManager):
84 | pass
85 |
86 | class SpotifyScreen(Screen):
87 | pass
88 |
89 | class ArtistsTab(MDBoxLayout, MDTabsBase):
90 | tab_name = "ArtistsTab"
91 |
92 | class AlbumsTab(MDBoxLayout, MDTabsBase):
93 | tab_name = "AlbumsTab"
94 | page = 1
95 |
96 | class TracksTab(MDBoxLayout, MDTabsBase):
97 | tab_name = "TracksTab"
98 | page = 1
99 |
100 | class YouTubeScreen(Screen):
101 | pass
102 |
103 | class SPlaylistScreen(Screen):
104 | page = 1
105 |
106 | class YPlaylistScreen(Screen):
107 | page = 1
108 |
109 | class SettingsScreen(Screen):
110 | pass
111 |
112 | class ErrorScreen(Screen):
113 | pass
114 |
115 | class Neodeemer(MDApp):
116 | icon = "data/icon.png"
117 | loc = Localization()
118 | format_mp3 = False
119 | create_subfolders = True
120 | save_lyrics = True
121 | synchronized_lyrics = False
122 | webapi_enabled = False
123 | selected_tracks = []
124 | download_queue = []
125 | download_queue_info = {
126 | "position": 0,
127 | "downloaded_b": 0,
128 | "total_b": 0
129 | }
130 | playlist_queue = []
131 | lyrics_queue = []
132 | unavailable_tracks = []
133 | intent_url = ""
134 | sound = None
135 | playlist_last = {
136 | "spotify": {},
137 | "youtube": {}
138 | }
139 |
140 | def build(self):
141 | self.theme_cls.primary_palette = "DeepPurple"
142 | self.theme_cls.accent_palette = "Amber"
143 | self.theme_cls.accent_hue = "700"
144 | self.theme_cls.accent_dark_hue = "900"
145 | LabelBase.register(name="Regular", fn_regular="fonts/MPLUS1p-Medium.ttf", fn_bold="fonts/MPLUS1p-ExtraBold.ttf")
146 | self.navigation_menu = self.root.ids.navigation_menu
147 | self.screen_manager = self.root.ids.screen_manager
148 | self.screens = [SpotifyScreen(name="SpotifyScreen")]
149 | self.screen_manager.add_widget(self.screens[0])
150 | Window.bind(on_keyboard=self.on_keyboard)
151 | self.screen_cur = self.screen_manager.current_screen
152 | self.toolbar = self.screen_cur.ids.toolbar
153 | self.progressbar = self.screen_cur.ids.progressbar
154 | self.artists_tab = self.screen_cur.ids.artists_tab
155 | self.albums_tab = self.screen_cur.ids.albums_tab
156 | self.tracks_tab = self.screen_cur.ids.tracks_tab
157 | self.file_manager = MDFileManager(exit_manager=self.file_manager_close, select_path=self.file_manager_select)
158 | if platform == "android":
159 | from android import activity, autoclass, mActivity
160 | from android.storage import primary_external_storage_path
161 | try:
162 | self.music_folder_path
163 | except:
164 | self.music_folder_path = os.path.join(primary_external_storage_path(), "Music")
165 | self.file_manager_default_path = primary_external_storage_path()
166 | self.download_threads_count = 2
167 | self.IntentClass = autoclass("android.content.Intent")
168 | self.intent = mActivity.getIntent()
169 | self.on_new_intent(self.intent)
170 | activity.bind(on_new_intent=self.on_new_intent)
171 | else:
172 | from kivy.config import Config
173 | Config.set("input", "mouse", "mouse,multitouch_on_demand")
174 | try:
175 | self.music_folder_path
176 | except:
177 | path = os.path.join(os.path.expanduser("~"), "Music")
178 | if not os.path.exists(path):
179 | path = self.user_data_dir
180 | self.music_folder_path = path
181 | self.file_manager_default_path = os.path.expanduser("~")
182 | self.download_threads_count = 5
183 | Clock.schedule_once(self.after_start, 2)
184 | self.tab_switch(self.albums_tab)
185 | return
186 |
187 | def after_start(self, *args):
188 | self.tab_switch(self.tracks_tab)
189 | self.loading = MDDialog(type="custom", content_cls=Loading(), md_bg_color=(0, 0, 0, 0))
190 | self.label_loading_info = self.loading.children[0].children[2].children[0].ids.label_loading_info
191 | self.s = SpotifyLoader(self.loc.get_market(), self.music_folder_path, self.format_mp3, self.create_subfolders, self.label_loading_info, resource_find(".env"), resource_find("data/ytsfilter.json"), os.path.join(self.user_data_dir, ".cache"))
192 | self.y = YoutubeLoader(self.music_folder_path, self.format_mp3, self.create_subfolders, self.label_loading_info)
193 | self.watchdog = Thread()
194 | self.play_track = Thread()
195 | for i in range(1, self.download_threads_count + 1):
196 | globals()[f"download_tracks_{i}"] = Thread()
197 | self.webapi_watchdog = Thread()
198 | if self.webapi_enabled and not self.webapi_watchdog.is_alive():
199 | self.webapi_server = WebApiServer()
200 | self.webapi_watchdog = Thread(target=self.watchdog_webapi, name="webapi_watchdog")
201 | self.webapi_watchdog.start()
202 | self.navigation_menu_list = self.root.ids.navigation_menu_list
203 | if check_update_available(__version__):
204 | line = TwoLineIconListItem(text=self.loc.get("Update"), secondary_text=self.loc.get("New version is available"), on_press=lambda x:open_url("https://github.com/Tutislav/neodeemer/releases/latest", platform))
205 | self.navigation_menu_list.add_widget(line)
206 | self.submit_bug_dialog = MDDialog(
207 | title=self.loc.get("Submit bug"),
208 | text=self.loc.get("If some tracks has bad quality or even doesn't match the name you can submit it"),
209 | buttons=[
210 | MDFlatButton(text=self.loc.get("Submit bug"), on_press=lambda x:[submit_bugs(self.selected_tracks), self.submit_bug_dialog.dismiss()]),
211 | MDFlatButton(text=self.loc.get("Cancel"), on_press=lambda x:self.submit_bug_dialog.dismiss())
212 | ]
213 | )
214 | self.handle_intent(self.intent_url)
215 | self.intent_url = ""
216 |
217 | def on_stop(self):
218 | os.kill(os.getpid(), 9)
219 |
220 | def screen_switch(self, screen_name, direction="left"):
221 | if not self.screen_manager.has_screen(screen_name):
222 | Builder.load_file(screen_name.lower() + ".kv")
223 | screen = eval(screen_name + "()")
224 | screen.name = screen_name
225 | self.screens.append(screen)
226 | self.screen_manager.add_widget(screen)
227 | if "Playlist" in screen_name:
228 | if not hasattr(self, "playlist_last_menu"):
229 | if screen_name == "SPlaylistScreen":
230 | self.text_playlist_last = screen.ids.text_splaylist_id
231 | else:
232 | self.text_playlist_last = screen.ids.text_yplaylist_id
233 | self.playlist_last_menu_list = []
234 | self.playlist_last_menu = MDDropdownMenu(caller=self.text_playlist_last, items=self.playlist_last_menu_list, position="bottom", width_mult=20)
235 | elif screen_name == "SettingsScreen":
236 | if not hasattr(self, "localization_menu"):
237 | self.switch_format = screen.ids.switch_format
238 | self.switch_create_subfolders = screen.ids.switch_create_subfolders
239 | self.text_music_folder_path = screen.ids.text_music_folder_path
240 | self.switch_save_lyrics = screen.ids.switch_save_lyrics
241 | self.options_lyrics = screen.ids.options_lyrics
242 | self.switch_lyrics_type = screen.ids.switch_lyrics_type
243 | self.switch_webapi_enabled = screen.ids.switch_webapi_enabled
244 | self.text_localization = screen.ids.text_localization
245 | self.localization_menu_list = [
246 | {
247 | "viewclass": "OneLineListItem",
248 | "height": dp(50),
249 | "text": f"{lang}",
250 | "on_release": lambda x=lang:self.localization_menu_set(x)
251 | } for lang in self.loc.LANGUAGES.keys()
252 | ]
253 | self.localization_menu = MDDropdownMenu(caller=self.text_localization, items=self.localization_menu_list, position="auto", width_mult=2)
254 | elif screen_name == "ErrorScreen":
255 | mdlist_tracks = screen.ids.mdlist_tracks
256 | mdlist_tracks.clear_widgets()
257 | for track in self.unavailable_tracks:
258 | track_name = track["track_name"] + " - [b]" + track["artist_name"] + "[/b]"
259 | secondary_text = self.loc.get(track["reason"])
260 | line = ListLineTrack(text=track_name, secondary_text=secondary_text, track_dict=track)
261 | line.add_widget(IconLeftWidget(icon="alert", on_press=lambda widget:self.mdlist_on_press(widget)))
262 | mdlist_tracks.add_widget(line)
263 | self.screen_manager.direction = direction
264 | self.screen_manager.current = screen_name
265 | self.screen_cur = self.screen_manager.current_screen
266 | self.toolbar = self.screen_cur.ids.toolbar
267 | self.progressbar = self.screen_cur.ids.progressbar
268 | self.progressbar_update()
269 | if screen_name == "SettingsScreen":
270 | self.text_music_folder_path.text = self.music_folder_path
271 | self.text_localization.text = self.loc.get_lang()
272 |
273 | def tab_switch(self, tab_instance):
274 | tabs = self.screen_manager.current_screen.ids.tabs
275 | tabs.switch_tab(tab_instance.tab_label)
276 | self.tab_cur = tab_instance
277 |
278 | def on_tab_switch(self, instance_tabs, instance_tab, instance_tab_label, tab_text):
279 | self.tab_cur = instance_tab
280 |
281 | def on_new_intent(self, intent):
282 | action = intent.getAction()
283 | if action == self.IntentClass.ACTION_SEND:
284 | mime_type = intent.getType()
285 | if mime_type == "text/plain":
286 | text = intent.getStringExtra(self.IntentClass.EXTRA_TEXT)
287 | self.intent_url = text
288 |
289 | def handle_intent(self, intent_url="", *args):
290 | if intent_url != "":
291 | if "youtube.com" in intent_url or "youtu.be" in intent_url:
292 | if "playlist" in intent_url:
293 | self.screen_switch("YPlaylistScreen")
294 | self.text_playlist_last.text = intent_url
295 | self.load_in_thread(self.playlist_load, self.tracks_actions_show, load_arg=True, show_arg=True, show_arg2=True)
296 | else:
297 | tracks = self.y.tracks_search(intent_url)
298 | if len(tracks) > 0:
299 | self.download([tracks[0]])
300 | elif "spotify.com" in intent_url:
301 | intent_parts = intent_url.split("/")
302 | spotify_id = intent_parts[len(intent_parts) - 1]
303 | if "?" in spotify_id:
304 | spotify_id = spotify_id.split("?")[0]
305 | if "/artist/" in intent_url:
306 | artist = self.s.artist(spotify_id)
307 | if artist != None:
308 | self.tab_switch(self.albums_tab)
309 | self.load_in_thread(self.albums_load, self.albums_show, artist)
310 | elif "/album/" in intent_url:
311 | album = self.s.album(spotify_id)
312 | if album != None:
313 | self.tab_switch(self.tracks_tab)
314 | self.load_in_thread(self.tracks_load, self.tracks_show, album)
315 | elif "/track/" in intent_url:
316 | track = self.s.track(spotify_id)
317 | if track != None:
318 | self.download([track])
319 | elif "/playlist/" in intent_url:
320 | self.screen_switch("SPlaylistScreen")
321 | self.text_playlist_last.text = spotify_id
322 | self.load_in_thread(self.playlist_load, self.tracks_actions_show, show_arg=True, show_arg2=True)
323 |
324 | def artists_load(self):
325 | text = self.artists_tab.ids.text_artists_search.text
326 | artists = self.s.artists_search(text)
327 | self.artists_tab.artists = artists
328 | if len(artists) > 0:
329 | return True
330 | else:
331 | Clock.schedule_once(partial(self.snackbar_show, self.loc.get("Error while loading artists")))
332 | return False
333 |
334 | def artists_show(self, *args):
335 | artists = self.artists_tab.artists
336 | mdlist_artists = self.artists_tab.ids.mdlist_artists
337 | mdlist_artists.clear_widgets()
338 | for artist in artists:
339 | if len(artist["artist_genres"]) > 0:
340 | genres = ""
341 | for i, genre in enumerate(artist["artist_genres"]):
342 | genres += genre
343 | if i < (len(artist["artist_genres"]) - 1):
344 | genres += ", "
345 | secondary_text = genres
346 | else:
347 | secondary_text = " "
348 | line = ListLineArtist(text=artist["artist_name"], secondary_text=secondary_text, artist_dict=artist, on_press=lambda widget:self.load_in_thread(self.albums_load, self.albums_show, widget.artist_dict))
349 | line.add_widget(AsyncImageLeftWidget(source=artist["artist_image"]))
350 | mdlist_artists.add_widget(line)
351 |
352 | def albums_load(self, artist_dict=None, reset_page=True):
353 | if reset_page:
354 | self.albums_tab.page = 1
355 | if artist_dict != None:
356 | Clock.schedule_once(partial(self.text_widget_clear, self.albums_tab.ids.text_albums_search))
357 | albums = self.s.artist_albums(artist_dict, self.albums_tab.page)
358 | else:
359 | text = self.albums_tab.ids.text_albums_search.text
360 | albums = self.s.albums_search(text, self.albums_tab.page)
361 | self.albums_tab.albums = albums
362 | self.albums_tab.artist_dict = artist_dict
363 | if len(albums) > 0:
364 | return True
365 | else:
366 | Clock.schedule_once(partial(self.snackbar_show, self.loc.get("Error while loading albums")))
367 | return False
368 |
369 | def albums_show(self, *args):
370 | albums = self.albums_tab.albums
371 | artist_dict = self.albums_tab.artist_dict
372 | if artist_dict != None:
373 | self.tab_switch(self.albums_tab)
374 | self.albums_tab.title = "[b]" + artist_dict["artist_name"] + "[/b]"
375 | else:
376 | self.albums_tab.title = self.loc.get("[b]Albums[/b]")
377 | mdlist_albums = self.albums_tab.ids.mdlist_albums
378 | mdlist_albums.clear_widgets()
379 | for album in albums:
380 | if artist_dict != None:
381 | secondary_text = album["album_year"]
382 | else:
383 | secondary_text = "[b]" + album["artist_name"] + "[/b] | " + album["album_year"]
384 | line = ListLineAlbum(text=album["album_name"], secondary_text=secondary_text, album_dict=album, on_press=lambda widget:self.load_in_thread(self.tracks_load, self.tracks_show, widget.album_dict))
385 | line.add_widget(AsyncImageLeftWidget(source=album["album_image"]))
386 | mdlist_albums.add_widget(line)
387 | self.mdlist_add_page_controls(mdlist_albums)
388 |
389 | def tracks_load(self, album_dict=None, reset_page=True):
390 | if reset_page:
391 | self.tracks_tab.page = 1
392 | if album_dict != None:
393 | Clock.schedule_once(partial(self.text_widget_clear, self.tracks_tab.ids.text_tracks_search))
394 | tracks = self.s.album_tracks(album_dict)
395 | else:
396 | text = self.tracks_tab.ids.text_tracks_search.text
397 | tracks = self.s.tracks_search(text, self.tracks_tab.page)
398 | self.tracks_tab.tracks = tracks
399 | self.tracks_tab.album_dict = album_dict
400 | if len(tracks) > 0:
401 | return True
402 | else:
403 | Clock.schedule_once(partial(self.snackbar_show, self.loc.get("Error while loading tracks")))
404 | return False
405 |
406 | def tracks_show(self, *args):
407 | tracks = self.tracks_tab.tracks
408 | album_dict = self.tracks_tab.album_dict
409 | if album_dict != None:
410 | self.tab_switch(self.tracks_tab)
411 | self.tracks_tab.title = "[b]" + album_dict["album_name"] + "[/b]"
412 | else:
413 | self.tracks_tab.title = self.loc.get("[b]Tracks[/b]")
414 | mdlist_tracks = self.tracks_tab.ids.mdlist_tracks
415 | mdlist_tracks.clear_widgets()
416 | for track in tracks:
417 | if album_dict != None:
418 | track_name = str(track["track_number"]) + ". " + track["track_name"]
419 | secondary_text = " " + track["track_duration_str"]
420 | else:
421 | track_name = track["track_name"]
422 | secondary_text = track["track_duration_str"] + " | [b]" + track["artist_name"] + "[/b] | " + track["album_name"]
423 | line = ListLineTrack(text=track_name, secondary_text=secondary_text, track_dict=track)
424 | line.add_widget(IconLeftWidget(icon="play-circle-outline", on_press=lambda widget:self.play(widget)))
425 | if track["state"] == TrackStates.COMPLETED:
426 | line.add_widget(IconRightWidget(icon="check-circle"))
427 | else:
428 | line.add_widget(IconRightWidget(icon="download-outline", on_press=lambda widget:self.mdlist_on_press(widget)))
429 | mdlist_tracks.add_widget(line)
430 | if album_dict == None:
431 | self.mdlist_add_page_controls(mdlist_tracks)
432 |
433 | def youtube_load(self):
434 | text = self.screen_cur.ids.text_youtube_search.text
435 | tracks = self.y.tracks_search(text)
436 | self.screen_cur.tracks = tracks
437 | if len(tracks) > 0:
438 | return True
439 | else:
440 | Clock.schedule_once(partial(self.snackbar_show, self.loc.get("Error while loading tracks")))
441 | return False
442 |
443 | def playlist_load(self, youtube=False):
444 | if youtube:
445 | text = self.screen_cur.ids.text_yplaylist_id.text
446 | tracks = self.y.playlist_tracks(text)
447 | if len(self.playlist_last["youtube"]) > 10:
448 | del self.playlist_last["youtube"][list(self.playlist_last["youtube"].keys())[0]]
449 | if len(tracks) > 0:
450 | self.playlist_last["youtube"].update({tracks[0]["playlist_name"]: text})
451 | else:
452 | text = self.screen_cur.ids.text_splaylist_id.text
453 | tracks = self.s.playlist_tracks(text)
454 | if len(self.playlist_last["spotify"]) > 10:
455 | del self.playlist_last["spotify"][list(self.playlist_last["spotify"].keys())[0]]
456 | if len(tracks) > 0:
457 | self.playlist_last["spotify"].update({tracks[0]["playlist_name"]: text})
458 | self.screen_cur.tracks = tracks
459 | self.screen_cur.page = 1
460 | self.settings_save(False)
461 | if len(tracks) > 0:
462 | label_playlist_info = self.screen_cur.ids.label_playlist_info
463 | playlist_downloaded_count = tracks[len(tracks) - 1]["playlist_downloaded_count"]
464 | label_playlist_info.text = "[b]" + tracks[0]["playlist_name"] + "[/b] - " + str(playlist_downloaded_count) + "/[b]" + str(len(tracks)) + "[/b]" + self.loc.get_r(" songs")
465 | label_playlist_info.text = font(label_playlist_info.text)
466 | return True
467 | else:
468 | Clock.schedule_once(partial(self.snackbar_show, self.loc.get("Error while loading playlist")))
469 | return False
470 |
471 | def playlist_show(self, page=0, youtube=False, *args):
472 | tracks = self.screen_cur.tracks
473 | if page:
474 | limit, offset = self.s.limit_offset(page)
475 | tracks = tracks[offset:offset + limit]
476 | mdlist_tracks = self.screen_cur.ids.mdlist_tracks
477 | mdlist_tracks.clear_widgets()
478 | for track in tracks:
479 | if youtube:
480 | secondary_text = track["track_duration_str"] + " | [b]" + track["video_channel"] + "[/b]"
481 | else:
482 | secondary_text = track["track_duration_str"] + " | [b]" + track["artist_name"] + "[/b] | " + track["album_name"]
483 | line = ListLineTrack(text=track["track_name"], secondary_text=secondary_text, track_dict=track)
484 | line.add_widget(IconLeftWidget(icon="play-circle-outline", on_press=lambda widget:self.play(widget)))
485 | if track["state"] == TrackStates.COMPLETED:
486 | line.add_widget(IconRightWidget(icon="check-circle"))
487 | else:
488 | line.add_widget(IconRightWidget(icon="download-outline", on_press=lambda widget:self.mdlist_on_press(widget)))
489 | mdlist_tracks.add_widget(line)
490 | if page:
491 | self.mdlist_add_page_controls(mdlist_tracks)
492 |
493 | def load_in_thread(self, load_function, show_function=None, load_arg=None, load_arg2=None, show_arg=None, show_arg2=None):
494 | def load():
495 | if load_arg != None or load_arg2 != None:
496 | if load_arg2 != None:
497 | show = load_function(load_arg, load_arg2)
498 | else:
499 | show = load_function(load_arg)
500 | else:
501 | show = load_function()
502 | if show_function != None and show:
503 | if show_arg != None or show_arg2 != None:
504 | if show_arg2 != None:
505 | Clock.schedule_once(partial(show_function, show_arg, show_arg2))
506 | else:
507 | Clock.schedule_once(partial(show_function, show_arg))
508 | else:
509 | Clock.schedule_once(show_function)
510 | Clock.schedule_once(self.loading.dismiss)
511 | self.loading.open()
512 | Thread(target=load, name="data_load").start()
513 |
514 | def download(self, selected_tracks=None, lyrics_only=False):
515 | if selected_tracks != None:
516 | self.selected_tracks = selected_tracks
517 | for track in self.selected_tracks:
518 | if track["state"] != TrackStates.COMPLETED and not lyrics_only:
519 | self.download_queue.append(track)
520 | elif track["state"] == TrackStates.COMPLETED and lyrics_only:
521 | self.lyrics_queue.append(track)
522 | else:
523 | self.playlist_queue.append(track)
524 | self.selected_tracks = []
525 | if self.screen_cur.name != "SettingsScreen":
526 | if self.screen_cur.name == "SpotifyScreen":
527 | mdlist_tracks = self.tracks_tab.ids.mdlist_tracks
528 | else:
529 | mdlist_tracks = self.screen_cur.ids.mdlist_tracks
530 | self.mdlist_set_mode(mdlist_tracks, 0)
531 | for i in range(1, self.download_threads_count + 1):
532 | if not globals()[f"download_tracks_{i}"].is_alive():
533 | globals()[f"download_tracks_{i}"] = Thread(target=self.download_tracks_from_queue, name=f"download_tracks_{i}")
534 | globals()[f"download_tracks_{i}"].start()
535 | if not self.watchdog.is_alive():
536 | self.watchdog = Thread(target=self.watchdog_progress, name="watchdog")
537 | self.watchdog.start()
538 | self.snackbar_show(self.loc.get("Added to download queue"))
539 |
540 | def download_tracks_from_queue(self):
541 | while self.download_queue_info["position"] != len(self.download_queue):
542 | for track in self.download_queue:
543 | sleep(randint(0, self.download_threads_count * 4) / 100)
544 | if not track["locked"]:
545 | track["locked"] = True
546 | if any(state == track["state"] for state in [TrackStates.UNKNOWN, TrackStates.FOUND, TrackStates.SAVED]):
547 | Download(track, self.s, self.download_queue_info, self.save_lyrics, self.synchronized_lyrics, __version__).download_track()
548 | track["locked"] = False
549 | else:
550 | continue
551 | sleep(1)
552 |
553 | def play(self, widget):
554 | if not self.play_track.is_alive():
555 | self.play_track = Thread(target=self.track_play, args=[widget], name="play_track")
556 | self.play_track.start()
557 |
558 | def track_play(self, widget, stream=True):
559 | Clock.schedule_once(self.loading.open)
560 | try:
561 | if widget.children[0].icon == "play-circle-outline":
562 | track_dict = widget.parent.parent.parent.children[0].track_dict
563 | track_dict_temp = {}
564 | track_dict_temp.update(track_dict)
565 | if self.sound != None:
566 | self.sound.stop()
567 | self.sound_prev_widget.children[0].icon = "play-circle-outline"
568 | if stream and track_dict_temp["state"] != TrackStates.COMPLETED:
569 | try:
570 | if track_dict_temp["state"] == TrackStates.UNKNOWN and track_dict_temp["video_id"] == None:
571 | self.s.track_find_video_id(track_dict_temp)
572 | track_dict["video_id"] = track_dict_temp["video_id"]
573 | track_dict["state"] = track_dict_temp["state"]
574 | if not check_mp3_available(track_dict):
575 | raise
576 | file_path = "https://neodeemer.vorpal.tk/mp3.php?video_id=" + track_dict_temp["video_id"] + ".mp3"
577 | self.sound = SoundLoader.load(file_path)
578 | self.sound.play()
579 | widget.children[0].icon = "stop-circle"
580 | self.sound_prev_widget = widget
581 | except:
582 | self.track_play(widget, False)
583 | return
584 | elif track_dict_temp["state"] != TrackStates.COMPLETED:
585 | track_dict_temp["forcedmp3"] = False
586 | track_dict_temp["folder_path"] = self.user_data_dir
587 | track_dict_temp["file_path"] = os.path.join(self.user_data_dir, "temp.m4a")
588 | track_dict_temp["file_path2"] = os.path.join(self.user_data_dir, "temp.mp3")
589 | if "playlist_name" in track_dict_temp:
590 | del track_dict_temp["playlist_name"]
591 | if track_dict_temp["state"] != TrackStates.COMPLETED:
592 | if platform == "android":
593 | if track_dict_temp["state"] == TrackStates.UNKNOWN and track_dict_temp["video_id"] == None:
594 | self.s.track_find_video_id(track_dict_temp)
595 | open_url("https://youtu.be/" + track_dict_temp["video_id"], platform)
596 | else:
597 | Download(track_dict_temp, self.s, None, False, __version__).download_track()
598 | track_dict["video_id"] = track_dict_temp["video_id"]
599 | track_dict["state"] = track_dict_temp["state"]
600 | if track_dict_temp["forcedmp3"]:
601 | file_path = track_dict_temp["file_path2"]
602 | else:
603 | file_path = track_dict_temp["file_path"]
604 | if track_dict_temp["state"] == TrackStates.COMPLETED:
605 | self.sound = SoundLoader.load(file_path)
606 | self.sound.play()
607 | widget.children[0].icon = "stop-circle"
608 | self.sound_prev_widget = widget
609 | elif track_dict_temp["state"] == TrackStates.UNAVAILABLE:
610 | widget.children[0].icon = "alert"
611 | Clock.schedule_once(partial(self.snackbar_show, self.loc.get("Error while playing track")))
612 | elif self.sound != None:
613 | self.sound.stop()
614 | widget.children[0].icon = "play-circle-outline"
615 | except:
616 | Clock.schedule_once(partial(self.snackbar_show, self.loc.get("Error while playing track")))
617 | Clock.schedule_once(self.loading.dismiss)
618 |
619 | def watchdog_progress(self):
620 | if len(self.unavailable_tracks) > 0:
621 | self.unavailable_tracks = []
622 | self.toolbar.left_action_items = []
623 | for track in self.playlist_queue:
624 | Download(track, self.s, self.download_queue_info, False, __version__).playlist_file_save()
625 | for track in self.lyrics_queue:
626 | Download(track, self.s, self.download_queue_info, True, True, __version__).download_synchronized_lyrics()
627 | while self.download_queue_info["position"] != len(self.download_queue):
628 | Clock.schedule_once(self.progressbar_update)
629 | sleep(0.5)
630 | self.progressbar_update()
631 | for track in self.download_queue:
632 | if track["state"] == TrackStates.UNAVAILABLE:
633 | self.unavailable_tracks.append(track)
634 | tracks_count = len(self.download_queue) - len(self.unavailable_tracks)
635 | self.download_queue = []
636 | self.download_queue_info["position"] = 0
637 | self.download_queue_info["downloaded_b"] = 0
638 | self.download_queue_info["total_b"] = 0
639 | self.playlist_queue = []
640 | self.lyrics_queue = []
641 | message = self.loc.get_r("Downloaded ") + str(tracks_count) + self.loc.get_r(" songs")
642 | if len(self.unavailable_tracks) > 0:
643 | message += "\n" + str(len(self.unavailable_tracks)) + self.loc.get_r(" songs can't be downloaded")
644 | Clock.schedule_once(partial(self.snackbar_show, str(len(self.unavailable_tracks)) + self.loc.get(" songs can't be downloaded")))
645 | left_action_items = [["alert", lambda x:self.screen_switch("ErrorScreen")]]
646 | self.toolbar.left_action_items = left_action_items
647 | if platform == "win":
648 | icon_path = resource_find("data/icon.ico")
649 | else:
650 | icon_path = resource_find("data/icon.png")
651 | notification.notify(title=self.loc.get_r("Download completed"), message=message, app_name=self.loc.TITLE_R, app_icon=icon_path)
652 |
653 | def watchdog_webapi(self):
654 | intent_url = ""
655 | while self.webapi_enabled or self.webapi_server.server_thread.is_alive():
656 | if self.webapi_server.intent_url != "":
657 | intent_url = self.webapi_server.intent_url
658 | self.webapi_server.intent_url = ""
659 | if intent_url != "":
660 | Clock.schedule_once(partial(self.handle_intent, intent_url))
661 | intent_url = ""
662 | sleep(0.5)
663 |
664 | def progressbar_update(self, *args):
665 | if self.download_queue_info["total_b"] > 0:
666 | if len(self.download_queue) <= 10:
667 | self.progressbar.value = int((self.download_queue_info["downloaded_b"] / self.download_queue_info["total_b"]) * 100)
668 | else:
669 | self.progressbar.value = int(self.download_queue_info["position"] / len(self.download_queue) * 100)
670 | self.toolbar.title = self.loc.TITLE + " - " + str(self.download_queue_info["position"]) + "/" + str(len(self.download_queue))
671 | elif len(self.download_queue) > 0:
672 | if self.toolbar.title == self.loc.TITLE or len(self.toolbar.title) >= (len(self.loc.TITLE) + 6):
673 | self.toolbar.title = self.loc.TITLE + " - "
674 | else:
675 | self.toolbar.title += "."
676 | else:
677 | self.progressbar.value = 0
678 | self.toolbar.title = self.loc.TITLE
679 |
680 | def snackbar_show(self, text, *args):
681 | Snackbar(text=text).open()
682 |
683 | def text_widget_clear(self, text_widget, *args):
684 | text_widget.text = ""
685 |
686 | def mdlist_on_press(self, widget):
687 | widget.parent.parent.parent.do_selected_item()
688 |
689 | def mdlist_selected(self, instance_selection_list, instance_selection_item):
690 | self.toolbar.title = str(len(instance_selection_list.get_selected_list_items()))
691 | line = instance_selection_item.children[0]
692 | if hasattr(line, "track_dict"):
693 | if not line.track_dict in self.selected_tracks:
694 | self.selected_tracks.append(line.track_dict)
695 | else:
696 | instance_selection_item.do_unselected_item()
697 | if not instance_selection_list.get_selected_list_items():
698 | Clock.schedule_once(partial(self.mdlist_set_mode, instance_selection_list, 0))
699 |
700 | def mdlist_unselected(self, instance_selection_list, instance_selection_item):
701 | if instance_selection_list.get_selected_list_items():
702 | self.toolbar.title = str(len(instance_selection_list.get_selected_list_items()))
703 | else:
704 | self.toolbar.title = self.loc.TITLE
705 | line = instance_selection_item.children[0]
706 | if hasattr(line, "track_dict"):
707 | if line.track_dict in self.selected_tracks:
708 | self.selected_tracks.remove(line.track_dict)
709 |
710 | def mdlist_set_mode(self, instance_selection_list, mode, *args):
711 | if mode:
712 | bg_color = self.theme_cls.accent_color
713 | left_action_items = [
714 | ["close", lambda x:self.mdlist_set_mode(instance_selection_list, 0)],
715 | ["bug-outline", lambda x:self.submit_bug_dialog.open()]
716 | ]
717 | if self.screen_cur.name != "ErrorScreen":
718 | self.tracks_actions_show()
719 | else:
720 | bg_color = self.theme_cls.primary_color
721 | left_action_items = []
722 | instance_selection_list.unselected_all()
723 | if self.screen_cur.name != "ErrorScreen":
724 | self.tracks_actions_show(False)
725 | Animation(md_bg_color=bg_color, d=0.2).start(self.toolbar)
726 | self.toolbar.left_action_items = left_action_items
727 |
728 | def mdlist_add_page_controls(self, mdlist):
729 | if self.screen_cur.name == "SpotifyScreen":
730 | view_cur = self.tab_cur
731 | else:
732 | view_cur = self.screen_cur
733 | line = OneLineAvatarIconListItem()
734 | if view_cur.page > 1:
735 | line.add_widget(IconLeftWidget(icon="arrow-left-bold", on_press=lambda x:self.tracks_change_page(False)))
736 | if view_cur.page < 10:
737 | line.add_widget(IconRightWidget(icon="arrow-right-bold", on_press=lambda x:self.tracks_change_page()))
738 | mdlist.add_widget(line)
739 |
740 | def tracks_change_page(self, next=True):
741 | if self.screen_cur.name == "SpotifyScreen":
742 | view_cur = self.tab_cur
743 | else:
744 | view_cur = self.screen_cur
745 | page_prev = view_cur.page
746 | if next and view_cur.page < 10:
747 | view_cur.page += 1
748 | elif not next and view_cur.page > 1:
749 | view_cur.page -= 1
750 | if view_cur.page != page_prev:
751 | view_cur.ids.scrollview.scroll_y = 1
752 | if self.screen_cur.name == "SpotifyScreen":
753 | if view_cur.tab_name == "TracksTab":
754 | self.load_in_thread(self.tracks_load, self.tracks_show, load_arg2=False)
755 | elif view_cur.tab_name == "AlbumsTab":
756 | text = self.albums_tab.ids.text_albums_search.text
757 | mdlist_albums = self.albums_tab.ids.mdlist_albums
758 | if len(mdlist_albums.children) > 1 and len(text) == 0:
759 | album_dict = mdlist_albums.children[1].album_dict
760 | else:
761 | album_dict = None
762 | self.load_in_thread(self.albums_load, self.albums_show, album_dict, False)
763 | elif self.screen_cur.name == "SPlaylistScreen":
764 | self.playlist_show(self.screen_cur.page, False)
765 | elif self.screen_cur.name == "YPlaylistScreen":
766 | self.playlist_show(self.screen_cur.page, True)
767 |
768 | def tracks_actions(self, action, youtube=False):
769 | if action == "download_all":
770 | if self.screen_cur.name == "SpotifyScreen":
771 | self.download(self.tracks_tab.tracks)
772 | else:
773 | self.download(self.screen_cur.tracks)
774 | elif action == "download_selected":
775 | self.download()
776 | elif action == "download_lyrics":
777 | self.download(self.screen_cur.tracks, True)
778 | elif action == "show":
779 | self.playlist_show(self.screen_cur.page, youtube)
780 |
781 | def tracks_actions_show(self, show=True, playlist=False, *args):
782 | if playlist:
783 | tracks_actions = self.screen_cur.ids.playlist_actions
784 | else:
785 | if self.screen_cur.name == "SpotifyScreen":
786 | tracks_actions = self.tracks_tab.ids.tracks_actions
787 | else:
788 | tracks_actions = self.screen_cur.ids.tracks_actions
789 | if show:
790 | tracks_actions.opacity = 1
791 | tracks_actions.height = 40
792 | tracks_actions.pos_hint = {"center_x": .5}
793 | else:
794 | tracks_actions.opacity = 0
795 | tracks_actions.height = 0
796 | tracks_actions.pos_hint = {"center_x": -1}
797 |
798 | def playlist_last_menu_show(self, youtube=False):
799 | if youtube:
800 | playlist_last_dict = self.playlist_last["youtube"]
801 | self.text_playlist_last = self.screen_cur.ids.text_yplaylist_id
802 | else:
803 | playlist_last_dict = self.playlist_last["spotify"]
804 | self.text_playlist_last = self.screen_cur.ids.text_splaylist_id
805 | self.playlist_last_menu_list = [
806 | {
807 | "viewclass": "OneLineListItem",
808 | "height": dp(50),
809 | "text": f"{playlist_id}",
810 | "on_release": lambda x=playlist_id:self.playlist_last_menu_set(playlist_last_dict[x], youtube)
811 | } for playlist_id in playlist_last_dict.keys()
812 | ]
813 | self.playlist_last_menu.caller = self.text_playlist_last
814 | self.playlist_last_menu.items = self.playlist_last_menu_list
815 | self.playlist_last_menu.open()
816 |
817 | def playlist_last_menu_set(self, playlist_id, youtube=False):
818 | self.playlist_last_menu.dismiss()
819 | self.text_playlist_last.text = playlist_id
820 | if youtube:
821 | self.load_in_thread(self.playlist_load, self.tracks_actions_show, load_arg=True, show_arg=True, show_arg2=True)
822 | else:
823 | self.load_in_thread(self.playlist_load, self.tracks_actions_show, show_arg=True, show_arg2=True)
824 |
825 | def format_change(self):
826 | self.format_mp3 = self.switch_format.active
827 | self.settings_save()
828 |
829 | def create_subfolders_change(self):
830 | self.create_subfolders = self.switch_create_subfolders.active
831 | self.settings_save()
832 |
833 | def music_folder_path_change(self):
834 | self.music_folder_path = self.text_music_folder_path.text
835 | self.settings_save()
836 |
837 | def file_manager_select(self, path):
838 | self.file_manager.close()
839 | self.text_music_folder_path.text = path
840 | self.music_folder_path_change()
841 |
842 | def file_manager_close(self, *args):
843 | self.file_manager.close()
844 |
845 | def save_lyrics_change(self):
846 | self.save_lyrics = self.switch_save_lyrics.active
847 | self.options_lyrics.height = int(self.save_lyrics) * 40
848 | self.options_lyrics.opacity = int(self.save_lyrics)
849 | self.settings_save()
850 |
851 | def lyrics_type_change(self):
852 | self.synchronized_lyrics = self.switch_lyrics_type.active
853 | self.settings_save()
854 |
855 | def webapi_enabled_change(self):
856 | self.webapi_enabled = self.switch_webapi_enabled.active
857 | if self.webapi_enabled and not self.webapi_watchdog.is_alive():
858 | self.webapi_server = WebApiServer()
859 | self.webapi_watchdog = Thread(target=self.watchdog_webapi, name="webapi_watchdog")
860 | self.webapi_watchdog.start()
861 | self.settings_save()
862 |
863 | def theme_toggle(self):
864 | if self.theme_cls.theme_style == "Light":
865 | self.theme_cls.theme_style = "Dark"
866 | else:
867 | self.theme_cls.theme_style = "Light"
868 | self.settings_save()
869 |
870 | def localization_menu_set(self, lang):
871 | self.localization_menu.dismiss()
872 | self.loc.set_lang(lang)
873 | self.text_localization.text = self.loc.get_lang()
874 | self.settings_save()
875 |
876 | def on_keyboard(self, window, key, scancode, codepoin, modifier):
877 | if key == 27:
878 | if self.screen_cur.name == "SpotifyScreen":
879 | if self.tab_cur.tab_name == "AlbumsTab":
880 | self.tab_switch(self.artists_tab)
881 | elif self.tab_cur.tab_name == "TracksTab":
882 | self.tab_switch(self.albums_tab)
883 | else:
884 | self.screen_switch("SpotifyScreen", "right")
885 | return True
886 | else:
887 | return False
888 |
889 | def settings_load(self):
890 | if os.path.exists(self.settings_file_path) and os.path.getsize(self.settings_file_path) > 0:
891 | with open(self.settings_file_path, "r") as settings_file:
892 | data = json.load(settings_file)
893 | self.music_folder_path = data["music_folder_path"]
894 | #if "format_mp3" in data:
895 | # self.format_mp3 = data["format_mp3"]
896 | self.create_subfolders = data["create_subfolders"]
897 | if "save_lyrics" in data:
898 | self.save_lyrics = data["save_lyrics"]
899 | if "synchronized_lyrics" in data:
900 | self.synchronized_lyrics = data["synchronized_lyrics"]
901 | if "webapi_enabled" in data:
902 | self.webapi_enabled = data["webapi_enabled"]
903 | self.theme_cls.theme_style = data["theme"]
904 | self.loc.set_lang(data["lang"])
905 | if "playlist_last" in data:
906 | self.playlist_last = data["playlist_last"]
907 |
908 | def settings_save(self, notify=True):
909 | if notify:
910 | self.snackbar_show(self.loc.get("Settings saved"))
911 | with open(self.settings_file_path, "w") as settings_file:
912 | data = {
913 | "music_folder_path": self.music_folder_path,
914 | #"format_mp3": self.format_mp3,
915 | "create_subfolders": self.create_subfolders,
916 | "save_lyrics": self.save_lyrics,
917 | "synchronized_lyrics": self.synchronized_lyrics,
918 | "webapi_enabled": self.webapi_enabled,
919 | "theme": self.theme_cls.theme_style,
920 | "lang": self.loc.get_lang(),
921 | "playlist_last": self.playlist_last
922 | }
923 | json.dump(data, settings_file)
924 | del self.s
925 | del self.y
926 | self.s = SpotifyLoader(self.loc.get_market(), self.music_folder_path, self.format_mp3, self.create_subfolders, self.label_loading_info, resource_find(".env"), resource_find("data/ytsfilter.json"), os.path.join(self.user_data_dir, ".cache"))
927 | self.y = YoutubeLoader(self.music_folder_path, self.format_mp3, self.create_subfolders, self.label_loading_info)
928 |
929 | if __name__ == "__main__":
930 | os.environ["SSL_CERT_FILE"] = certifi.where()
931 | os.environ["KIVY_AUDIO"] = "ffpyplayer"
932 | if hasattr(sys, "_MEIPASS"):
933 | resource_add_path(os.path.join(sys._MEIPASS))
934 | app = Neodeemer()
935 | if platform == "android":
936 | from android.storage import primary_external_storage_path
937 | from android.permissions import Permission, request_permissions
938 | settings_folder_path = os.path.join(primary_external_storage_path(), app.loc.TITLE_R)
939 | request_permissions([Permission.WRITE_EXTERNAL_STORAGE, Permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS])
940 | else:
941 | settings_folder_path = app.user_data_dir
942 | if not os.path.exists(settings_folder_path):
943 | try:
944 | os.makedirs(settings_folder_path)
945 | except OSError:
946 | pass
947 | app.settings_file_path = os.path.join(settings_folder_path, "settings.json")
948 | try:
949 | app.settings_load()
950 | except OSError:
951 | pass
952 | app.run()
953 |
--------------------------------------------------------------------------------
/neodeemer/neodeemer.kv:
--------------------------------------------------------------------------------
1 | #:import NoTransition kivy.uix.screenmanager.NoTransition
2 |
3 | MDScreen:
4 | MDNavigationLayout:
5 | ScreenManager:
6 | id: screen_manager
7 | transition: NoTransition()
8 |
9 | MDNavigationDrawer:
10 | id: navigation_menu
11 | anchor: "right"
12 |
13 | ScrollView:
14 | MDList:
15 | id: navigation_menu_list
16 |
17 | TwoLineAvatarListItem:
18 | text: app.loc.get("Spotify search")
19 | secondary_text: app.loc.get("Tracks, Albums and Artists")
20 | on_press:
21 | app.navigation_menu.set_state("close")
22 | app.screen_switch("SpotifyScreen")
23 |
24 | IconLeftWidget:
25 | icon: "spotify"
26 | on_press:
27 | app.navigation_menu.set_state("close")
28 | app.screen_switch("SpotifyScreen")
29 |
30 | TwoLineAvatarListItem:
31 | text: app.loc.get("YouTube search")
32 | secondary_text: app.loc.get("Videos")
33 | on_press:
34 | app.navigation_menu.set_state("close")
35 | app.screen_switch("YouTubeScreen")
36 |
37 | IconLeftWidget:
38 | icon: "youtube"
39 | on_press:
40 | app.navigation_menu.set_state("close")
41 | app.screen_switch("YouTubeScreen")
42 |
43 | OneLineAvatarListItem:
44 | text: app.loc.get("Spotify playlist")
45 | on_press:
46 | app.navigation_menu.set_state("close")
47 | app.screen_switch("SPlaylistScreen")
48 |
49 | IconLeftWidget:
50 | icon: "playlist-music"
51 | on_press:
52 | app.navigation_menu.set_state("close")
53 | app.screen_switch("SPlaylistScreen")
54 |
55 | OneLineAvatarListItem:
56 | text: app.loc.get("YouTube playlist")
57 | on_press:
58 | app.navigation_menu.set_state("close")
59 | app.screen_switch("YPlaylistScreen")
60 |
61 | IconLeftWidget:
62 | icon: "playlist-music"
63 | on_press:
64 | app.navigation_menu.set_state("close")
65 | app.screen_switch("YPlaylistScreen")
66 |
67 | OneLineAvatarListItem:
68 | text: app.loc.get("Settings")
69 | on_press:
70 | app.navigation_menu.set_state("close")
71 | app.screen_switch("SettingsScreen")
72 |
73 | IconLeftWidget:
74 | icon: "cog"
75 | on_press:
76 | app.navigation_menu.set_state("close")
77 | app.screen_switch("SettingsScreen")
78 |
79 | :
80 | MDBoxLayout:
81 | orientation: "vertical"
82 |
83 | MDBoxLayout:
84 | orientation: "vertical"
85 | adaptive_height: True
86 | spacing: 4
87 |
88 | MDTopAppBar:
89 | id: toolbar
90 | title: app.loc.TITLE
91 | right_action_items: [["menu", lambda x: app.navigation_menu.set_state("open")]]
92 |
93 | MDProgressBar:
94 | id: progressbar
95 | color: app.theme_cls.accent_dark
96 |
97 | MDTabs:
98 | id: tabs
99 | tab_indicator_type: "round"
100 | tab_hint_x: True
101 | on_tab_switch: app.on_tab_switch(*args)
102 |
103 | ArtistsTab:
104 | id: artists_tab
105 | title: app.loc.get("[b]Artists[/b]")
106 | AlbumsTab:
107 | id: albums_tab
108 | title: app.loc.get("[b]Albums[/b]")
109 | TracksTab:
110 | id: tracks_tab
111 | title: app.loc.get("[b]Tracks[/b]")
112 |
113 | :
114 | MDBoxLayout:
115 | orientation: "vertical"
116 | spacing: 25
117 |
118 | MDGridLayout:
119 | cols: 1
120 | size_hint: 1, 0.1
121 | spacing: 15
122 | padding: 15
123 |
124 | MDTextField:
125 | id: text_artists_search
126 | hint_text: app.loc.get_r("Search singers/bands")
127 | font_size: "26sp"
128 | on_text_validate: app.load_in_thread(app.artists_load, app.artists_show)
129 | font_name: "Regular"
130 |
131 | ScrollView:
132 | MDList:
133 | id: mdlist_artists
134 |
135 | :
136 | MDBoxLayout:
137 | orientation: "vertical"
138 | spacing: 25
139 |
140 | MDGridLayout:
141 | cols: 1
142 | size_hint: 1, 0.1
143 | spacing: 15
144 | padding: 15
145 |
146 | MDTextField:
147 | id: text_albums_search
148 | hint_text: app.loc.get_r("Search albums")
149 | font_size: "26sp"
150 | on_text_validate: app.load_in_thread(app.albums_load, app.albums_show)
151 | font_name: "Regular"
152 |
153 | ScrollView:
154 | id: scrollview
155 |
156 | MDList:
157 | id: mdlist_albums
158 |
159 | :
160 | MDBoxLayout:
161 | orientation: "vertical"
162 | spacing: 25
163 |
164 | MDGridLayout:
165 | cols: 1
166 | size_hint: 1, 0.1
167 | spacing: 15
168 | padding: 15
169 |
170 | MDTextField:
171 | id: text_tracks_search
172 | hint_text: app.loc.get_r("Search tracks")
173 | font_size: "26sp"
174 | on_text_validate: app.load_in_thread(app.tracks_load, app.tracks_show)
175 | font_name: "Regular"
176 |
177 | ScrollView:
178 | id: scrollview
179 |
180 | MDSelectionListFix:
181 | id: mdlist_tracks
182 | on_selected: app.mdlist_selected(*args)
183 | on_unselected: app.mdlist_unselected(*args)
184 | on_selected_mode: app.mdlist_set_mode(*args)
185 |
186 | MDBoxLayout:
187 | id: tracks_actions
188 | orientation: "horizontal"
189 | adaptive_height: True
190 | adaptive_width: True
191 | pos_hint: {"center_x": .5}
192 | spacing: 5
193 | padding: [15, 5]
194 | opacity: 0
195 |
196 | MDFillRoundFlatIconButton:
197 | text: app.loc.get("Cancel")
198 | icon: "close"
199 | on_press: app.mdlist_set_mode(root.ids.mdlist_tracks, 0)
200 |
201 | MDFillRoundFlatIconButton:
202 | text: app.loc.get("All")
203 | icon: "download-multiple"
204 | on_press: app.tracks_actions("download_all")
205 |
206 | MDFillRoundFlatIconButton:
207 | text: app.loc.get("Only selected")
208 | icon: "download"
209 | on_press: app.tracks_actions("download_selected")
210 |
211 | :
212 | MDSpinner:
213 | pos_hint: {"center_x": .5, "center_y": .5}
214 | size_hint: 1.0, None
215 | line_width: dp(6)
216 | active: True
217 |
218 | MDLabel:
219 | id: label_loading_info
220 | pos_hint: {"center_x": .5, "center_y": .5}
221 | size_hint: 0.25, None
222 | haling: "center"
223 | text: ""
224 | font_style: "H4"
225 | theme_text_color: "Custom"
226 | text_color: app.theme_cls.primary_color
227 | markup: True
--------------------------------------------------------------------------------
/neodeemer/neodeemer.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python ; coding: utf-8 -*-
2 | from PyInstaller.utils.hooks import collect_data_files
3 | from kivy_deps import sdl2, glew
4 |
5 |
6 | block_cipher = None
7 |
8 |
9 | a = Analysis(
10 | ['main.py'],
11 | pathex=[],
12 | binaries=[],
13 | datas=collect_data_files('ytmusicapi'),
14 | hiddenimports=['plyer.platforms.win.notification'],
15 | hookspath=[],
16 | hooksconfig={},
17 | runtime_hooks=[],
18 | excludes=[],
19 | win_no_prefer_redirects=False,
20 | win_private_assemblies=False,
21 | cipher=block_cipher,
22 | noarchive=False,
23 | )
24 | a.binaries -= TOC([
25 | ('opengl32.dll', None, None),
26 | ('OPENGL32.dll', None, None)
27 | ])
28 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
29 |
30 | exe = EXE(
31 | pyz,
32 | Tree('.', excludes=['build', 'dist', 'p4a', 'venv', 'opengl32.dll', 'OPENGL32.dll']),
33 | a.scripts,
34 | a.binaries,
35 | a.zipfiles,
36 | a.datas,
37 | *[Tree(p) for p in (sdl2.dep_bins + glew.dep_bins)],
38 | [],
39 | name='neodeemer',
40 | debug=False,
41 | bootloader_ignore_signals=False,
42 | strip=False,
43 | upx=True,
44 | upx_exclude=[],
45 | runtime_tmpdir=None,
46 | console=False,
47 | disable_windowed_traceback=False,
48 | argv_emulation=False,
49 | target_arch=None,
50 | codesign_identity=None,
51 | entitlements_file=None,
52 | icon='data\\icon.ico',
53 | )
54 |
--------------------------------------------------------------------------------
/neodeemer/p4a/hook.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from pythonforandroid.toolchain import ToolchainCL
3 |
4 | def after_apk_build(toolchain: ToolchainCL):
5 | manifest_file = Path(toolchain._dist.dist_dir) / "src" / "main" / "AndroidManifest.xml"
6 | old_manifest = manifest_file.read_text(encoding="utf-8")
7 | new_manifest = old_manifest.replace(
8 | 'android:hardwareAccelerated="true"',
9 | 'android:hardwareAccelerated="true" android:requestLegacyExternalStorage="true"',
10 | )
11 | manifest_file.write_text(new_manifest, encoding="utf-8")
--------------------------------------------------------------------------------
/neodeemer/requirements.txt:
--------------------------------------------------------------------------------
1 | certifi>=2024.8.30
2 | ffpyplayer
3 | Kivy==2.1.0
4 | kivymd==1.0.2
5 | music_tag==0.4.3
6 | Pillow==8.4.0
7 | plyer==2.1.0
8 | python-dotenv==0.21.1
9 | pytube
10 | requests>=2.32.3
11 | spotipy==2.22.1
12 | Unidecode==1.3.6
13 | youtube-search==2.1.2
14 | yt-dlp>=2024.8.6
15 | ytmusicapi==1.7.5
--------------------------------------------------------------------------------
/neodeemer/settingsscreen.kv:
--------------------------------------------------------------------------------
1 | :
2 | MDBoxLayout:
3 | orientation: "vertical"
4 |
5 | MDBoxLayout:
6 | orientation: "vertical"
7 | adaptive_height: True
8 | spacing: 4
9 |
10 | MDTopAppBar:
11 | id: toolbar
12 | title: app.loc.TITLE
13 | right_action_items: [["menu", lambda x: app.navigation_menu.set_state("open")]]
14 |
15 | MDProgressBar:
16 | id: progressbar
17 | color: app.theme_cls.accent_dark
18 |
19 | ScrollView:
20 | MDBoxLayout:
21 | orientation: "vertical"
22 | adaptive_height: True
23 | spacing: 30
24 | padding: 15
25 |
26 | MDGridLayout:
27 | cols: 4
28 | adaptive_height: True
29 | spacing: 15, 0
30 |
31 | MDLabel:
32 | text: app.loc.get("Format")
33 | markup: True
34 |
35 | MDLabel:
36 | text: app.loc.get("M4A")
37 | halign: "right"
38 | markup: True
39 |
40 | MDSwitch:
41 | id: switch_format
42 | active: app.format_mp3
43 | width: dp(64)
44 | on_active: app.format_change()
45 | disabled: True
46 |
47 | MDLabel:
48 | text: app.loc.get("MP3")
49 | markup: True
50 | theme_text_color: "Hint"
51 |
52 | MDBoxLayout:
53 |
54 | MDLabel:
55 | text: app.loc.get("(Better quality)")
56 | halign: "right"
57 | font_style: "Caption"
58 | theme_text_color: "Hint"
59 | markup: True
60 |
61 | MDSwitch:
62 | width: dp(64)
63 | opacity: 0
64 |
65 | MDLabel:
66 | text: app.loc.get("(Slower download)")
67 | font_style: "Caption"
68 | theme_text_color: "Hint"
69 | markup: True
70 |
71 | MDGridLayout:
72 | cols: 4
73 | adaptive_height: True
74 | spacing: 15
75 |
76 | MDLabel:
77 | text: app.loc.get("Create subfolders")
78 | markup: True
79 |
80 | MDBoxLayout:
81 |
82 | MDSwitch:
83 | id: switch_create_subfolders
84 | active: app.create_subfolders
85 | width: dp(64)
86 | on_active: app.create_subfolders_change()
87 |
88 | MDBoxLayout:
89 |
90 | MDTextField:
91 | id: text_music_folder_path
92 | hint_text: app.loc.get_r("Music folder")
93 | on_text_validate: app.music_folder_path_change()
94 | font_name: "Regular"
95 |
96 | MDFillRoundFlatIconButton:
97 | text: app.loc.get("Choose folder")
98 | icon: "folder"
99 | on_press: app.file_manager.show(app.file_manager_default_path)
100 |
101 | MDGridLayout:
102 | cols: 4
103 | adaptive_height: True
104 | spacing: 15
105 |
106 | MDLabel:
107 | text: app.loc.get("Download lyrics")
108 | markup: True
109 |
110 | MDBoxLayout:
111 |
112 | MDSwitch:
113 | id: switch_save_lyrics
114 | active: app.save_lyrics
115 | width: dp(64)
116 | on_active: app.save_lyrics_change()
117 |
118 | MDBoxLayout:
119 |
120 | MDGridLayout:
121 | id: options_lyrics
122 | cols: 3
123 | adaptive_height: True
124 | height: int(app.save_lyrics) * 40
125 | spacing: 15
126 | opacity: int(app.save_lyrics)
127 |
128 | MDLabel:
129 | text: app.loc.get("Unsynchronized lyrics")
130 | halign: "right"
131 | markup: True
132 |
133 | MDSwitch:
134 | id: switch_lyrics_type
135 | active: app.synchronized_lyrics
136 | width: dp(64)
137 | on_active: app.lyrics_type_change()
138 |
139 | MDLabel:
140 | text: app.loc.get("Synchronized lyrics")
141 | markup: True
142 |
143 | MDGridLayout:
144 | cols: 4
145 | adaptive_height: True
146 | spacing: 15
147 |
148 | MDLabel:
149 | text: app.loc.get("Enable WebApi")
150 | markup: True
151 |
152 | MDBoxLayout:
153 |
154 | MDSwitch:
155 | id: switch_webapi_enabled
156 | active: app.webapi_enabled
157 | width: dp(64)
158 | on_active: app.webapi_enabled_change()
159 |
160 | MDBoxLayout:
161 |
162 | MDFillRoundFlatIconButton:
163 | text: app.loc.get("Toggle theme")
164 | icon: "theme-light-dark"
165 | on_press: app.theme_toggle()
166 |
167 | MDTextField:
168 | id: text_localization
169 | hint_text: app.loc.get_r("Language")
170 | on_focus:
171 | if self.focus: app.localization_menu.open()
172 | if self.focus: Window.release_all_keyboards()
173 | font_name: "Regular"
--------------------------------------------------------------------------------
/neodeemer/songinfoloader.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from datetime import datetime
4 | from time import sleep
5 | from urllib import request
6 |
7 | import spotipy
8 | from dotenv import load_dotenv
9 | from pytube import Playlist as YoutubePlaylist
10 | from spotipy.oauth2 import SpotifyClientCredentials
11 | from yt_dlp import YoutubeDL
12 | from youtube_search import YoutubeSearch
13 | from ytmusicapi import YTMusic
14 |
15 | from tools import (TrackStates, clean_track_name, contains_artist_track, contains_date, contains_separate_word, contains_part, font, mstostr, norm, strtoms,
16 | track_file_state)
17 |
18 |
19 | class MDLabel():
20 | text = ""
21 |
22 | class SpotifyFix(spotipy.Spotify):
23 | artists_cache = {}
24 |
25 | def artist(self, artist: dict):
26 | if not artist["id"] in self.artists_cache:
27 | try:
28 | artist_dict = super().artist(artist["id"])
29 | except:
30 | artist_dict = {
31 | "id": artist["id"],
32 | "name": artist["name"],
33 | "images": [],
34 | "genres": []
35 | }
36 | self.artists_cache.update({artist["id"]: artist_dict})
37 | else:
38 | artist_dict = self.artists_cache[artist["id"]]
39 | return artist_dict
40 |
41 | class Base():
42 | def __init__(self, music_folder_path: str, format_mp3: bool, create_subfolders: bool, label_loading_info: MDLabel = None):
43 | self.music_folder_path = music_folder_path
44 | self.format_mp3 = format_mp3
45 | self.create_subfolders = create_subfolders
46 | if label_loading_info != None:
47 | self.label_loading_info = label_loading_info
48 | else:
49 | self.label_loading_info = MDLabel()
50 |
51 | class SpotifyLoader(Base):
52 | def __init__(self, market: str, music_folder_path: str, format_mp3: bool, create_subfolders: bool, label_loading_info: MDLabel = None, env_file_path: str = ".env", filter_file_path: str = "data/ytsfilter.json", cache_file_path: str = ".cache"):
53 | super().__init__(music_folder_path, format_mp3, create_subfolders, label_loading_info)
54 | if os.path.exists("env.env"):
55 | load_dotenv("env.env")
56 | else:
57 | load_dotenv(env_file_path)
58 | self.spotify = SpotifyFix(client_credentials_manager=SpotifyClientCredentials(cache_handler=spotipy.CacheFileHandler(cache_path=cache_file_path)))
59 | self.limit_small = 10
60 | self.limit = 50
61 | self.market = market
62 | self.filter_file_path = filter_file_path
63 | self.youtube_api_key = os.environ.get("YOUTUBE_API_KEY")
64 | self.load_filter()
65 |
66 | def load_filter(self):
67 | try:
68 | request.urlretrieve("https://raw.githubusercontent.com/Tutislav/neodeemer/main/neodeemer/data/ytsfilter.json", self.filter_file_path)
69 | except:
70 | pass
71 | if os.path.exists(self.filter_file_path):
72 | with open(self.filter_file_path, "r") as filter_file:
73 | self.ytsfilter = json.load(filter_file)
74 |
75 | def select_image(self, images):
76 | if len(images) > 0:
77 | return images[0]["url"]
78 | else:
79 | return ""
80 |
81 | def artists_to_str(self, artists):
82 | str = ""
83 | if len(artists) > 1:
84 | for i, artist in enumerate(artists):
85 | str += artist["name"]
86 | if i < (len(artists) - 1):
87 | str += "; "
88 | else:
89 | str = artists[0]["name"]
90 | return str
91 |
92 | def limit_offset(self, page):
93 | if page > 0:
94 | limit = self.limit_small
95 | offset = (page - 1) * 10
96 | else:
97 | limit = self.limit
98 | offset = 0
99 | return limit, offset
100 |
101 | def artist_to_dict(self, artist):
102 | return {
103 | "artist_id": artist["id"],
104 | "artist_name": artist["name"],
105 | "artist_genres": artist["genres"],
106 | "artist_image": self.select_image(artist["images"])
107 | }
108 |
109 | def album_to_dict(self, album, artist_dict=None):
110 | if artist_dict == None:
111 | artist = self.spotify.artist(album["artists"][0])
112 | artist_dict = self.artist_to_dict(artist)
113 | if album["release_date_precision"] == "day":
114 | album_year = datetime.strptime(album["release_date"], "%Y-%m-%d").strftime("%Y")
115 | elif album["release_date_precision"] == "month":
116 | album_year = datetime.strptime(album["release_date"], "%Y-%m").strftime("%Y")
117 | else:
118 | album_year = album["release_date"]
119 | album_dict = {}
120 | album_dict.update(artist_dict)
121 | album_dict.update({
122 | "album_id": album["id"],
123 | "album_name": album["name"],
124 | "album_artist": self.artists_to_str(album["artists"]),
125 | "album_trackscount": album["total_tracks"],
126 | "album_year": album_year,
127 | "album_image": self.select_image(album["images"])
128 | })
129 | return album_dict
130 |
131 | def track_to_dict(self, track, album_dict=None):
132 | if album_dict == None:
133 | album_dict = self.album_to_dict(track["album"])
134 | if self.create_subfolders:
135 | folder_path = os.path.join(self.music_folder_path, norm(album_dict["artist_name"], True, True), norm(album_dict["album_name"], True, True))
136 | file_name = norm(track["name"], True, True)
137 | else:
138 | folder_path = self.music_folder_path
139 | file_name = norm(album_dict["artist_name"], True, True) + " - " + norm(track["name"], True, True)
140 | file_path = os.path.join(folder_path, file_name + ".m4a")
141 | file_path2 = os.path.join(folder_path, file_name + ".mp3")
142 | track_dict = {}
143 | track_dict.update(album_dict)
144 | track_dict.update({
145 | "artist_name": track["artists"][0]["name"],
146 | "artist_name2": self.artists_to_str(track["artists"]),
147 | "track_id": track["id"],
148 | "track_name": track["name"],
149 | "track_duration_ms": track["duration_ms"],
150 | "track_duration_str": mstostr(track["duration_ms"]),
151 | "track_number": track["track_number"],
152 | "track_size_b": None,
153 | "track_size_added": False,
154 | "video_id": None,
155 | "forcedmp3": self.format_mp3,
156 | "reason": "",
157 | "folder_path": folder_path,
158 | "file_path": file_path,
159 | "file_path2": file_path2,
160 | "locked": False
161 | })
162 | track_dict.update({"state": track_file_state(track_dict)})
163 | return track_dict
164 |
165 | def artists_search(self, artist_name):
166 | list = []
167 | if len(artist_name) > 0:
168 | artists = self.spotify.search(artist_name, type="artist", limit=self.limit_small, market=self.market)
169 | artists = artists["artists"]["items"]
170 | for artist in artists:
171 | list.append(self.artist_to_dict(artist))
172 | return list
173 |
174 | def albums_search(self, album_name, page=0):
175 | list = []
176 | if len(album_name) > 0:
177 | limit, offset = self.limit_offset(page)
178 | albums = self.spotify.search(album_name, type="album", limit=limit, offset=offset, market=self.market)
179 | albums = albums["albums"]["items"]
180 | for album in albums:
181 | list.append(self.album_to_dict(album))
182 | return list
183 |
184 | def tracks_search(self, track_name, page=0):
185 | list = []
186 | if len(track_name) > 0:
187 | try:
188 | limit, offset = self.limit_offset(page)
189 | tracks = self.spotify.search(track_name, type="track", limit=limit, offset=offset, market=self.market)
190 | tracks = tracks["tracks"]["items"]
191 | for track in tracks:
192 | list.append(self.track_to_dict(track))
193 | except:
194 | pass
195 | return list
196 |
197 | def artist(self, artist_id):
198 | try:
199 | artist_dict = self.artist_to_dict(self.spotify.artist({"id": artist_id}))
200 | except:
201 | artist_dict = None
202 | return artist_dict
203 |
204 | def album(self, album_id):
205 | try:
206 | album_dict = self.album_to_dict(self.spotify.album(album_id, self.market))
207 | except:
208 | album_dict = None
209 | return album_dict
210 |
211 | def track(self, track_id):
212 | try:
213 | track_dict = self.track_to_dict(self.spotify.track(track_id, self.market))
214 | except:
215 | track_dict = None
216 | return track_dict
217 |
218 | def artist_albums(self, artist_dict, page=0):
219 | list = []
220 | limit, offset = self.limit_offset(page)
221 | albums = self.spotify.artist_albums(artist_dict["artist_id"], limit=limit, offset=offset)
222 | albums = albums["items"]
223 | for album in albums:
224 | list.append(self.album_to_dict(album, artist_dict))
225 | return list
226 |
227 | def album_tracks(self, album_dict):
228 | list = []
229 | tracks = self.spotify.album_tracks(album_dict["album_id"], limit=self.limit)
230 | tracks = tracks["items"]
231 | for track in tracks:
232 | list.append(self.track_to_dict(track, album_dict))
233 | return list
234 |
235 | def playlist_tracks(self, playlist_id):
236 | list = []
237 | if len(playlist_id) > 0:
238 | try:
239 | tracks2 = self.spotify.playlist(playlist_id, additional_types=("track",), market=self.market)
240 | playlist_name = tracks2["name"]
241 | playlist_file_path = os.path.join(self.music_folder_path, norm(playlist_name, True, True) + ".m3u")
242 | tracks = tracks2["tracks"]["items"]
243 | next = tracks2["tracks"]["next"]
244 | if next:
245 | tracks2 = self.spotify.next(tracks2["tracks"])
246 | tracks.extend(tracks2["items"])
247 | next = tracks2["next"]
248 | while next:
249 | tracks2 = self.spotify.next(tracks2)
250 | tracks.extend(tracks2["items"])
251 | next = tracks2["next"]
252 | position = 0
253 | playlist_downloaded_count = 0
254 | for track in tracks:
255 | track2 = track["track"]
256 | track_dict = self.track_to_dict(track2)
257 | if track_dict["state"] == TrackStates.COMPLETED:
258 | playlist_downloaded_count += 1
259 | track_dict.update({
260 | "playlist_name": playlist_name,
261 | "playlist_file_path": playlist_file_path,
262 | "playlist_downloaded_count": playlist_downloaded_count
263 | })
264 | list.append(track_dict)
265 | position += 1
266 | self.label_loading_info.text = font(str(position).rjust(3))
267 | self.label_loading_info.text = ""
268 | except:
269 | pass
270 | return list
271 |
272 | def video_get_details(self, video_dict):
273 | try:
274 | details_url = "https://youtube.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=" + video_dict["id"] + "&key=" + self.youtube_api_key
275 | with request.urlopen(details_url) as urldata:
276 | details_data = json.loads(urldata.read().decode())
277 | video_description = details_data["items"][0]["snippet"]["description"]
278 | video_details = details_data["items"][0]["contentDetails"]
279 | video_dict["video_details"] = video_details
280 | except:
281 | video_url = "https://youtu.be/" + video_dict["id"]
282 | with YoutubeDL() as ydl:
283 | video_info = ydl.extract_info(video_url, False)
284 | video_description = video_info["description"]
285 | video_dict["video_description"] = norm(video_description)
286 |
287 | def track_find_video_id(self, track_dict):
288 | track_dict["state"] = TrackStates.SEARCHING
289 | options = self.ytsfilter["options"]
290 | preferred_channels = self.ytsfilter["preferred_channels"]
291 | excluded_channels = self.ytsfilter["excluded_channels"]
292 | excluded_words = self.ytsfilter["excluded_words"]
293 | artist_name2 = norm(track_dict["artist_name"])
294 | album_name2 = norm(track_dict["album_name"])
295 | track_name2 = norm(track_dict["track_name"])
296 | track_name3 = clean_track_name(track_name2)
297 | track_duration_s = track_dict["track_duration_ms"] / 1000
298 | max_results = 5
299 | text = track_dict["artist_name"] + " " + track_dict["track_name"]
300 | video_id = None
301 | age_restricted = False
302 | try:
303 | with YTMusic() as ytmusic:
304 | tracks = ytmusic.search(text, "songs")
305 | if len(tracks) > 0:
306 | tracks = tracks[0:3]
307 | for track in tracks:
308 | track_artist = norm(track["artists"][0]["name"])
309 | track_name = norm(track["title"])
310 | if contains_artist_track(track_artist, artist_name2):
311 | if contains_artist_track(track_name, track_name=track_name3):
312 | contains_excluded = False
313 | for word in excluded_words:
314 | if word in track_name and not word in track_name2:
315 | if contains_separate_word(track_name, word):
316 | contains_excluded = True
317 | break
318 | if not contains_excluded:
319 | video_id = track["videoId"]
320 | break
321 | if video_id == None:
322 | album_text = track_dict["artist_name"] + " " + track_dict["album_name"]
323 | albums = ytmusic.search(album_text, "albums")
324 | if len(albums) > 0:
325 | album = albums[0]
326 | album_artist = norm(album["artists"][0]["name"])
327 | album_name = norm(album["title"])
328 | if contains_artist_track(album_artist, artist_name2):
329 | if album_name2 in album_name or contains_part(album_name, album_name2):
330 | album2 = ytmusic.get_album(album["browseId"])
331 | tracks = album2["tracks"]
332 | for track in tracks:
333 | track_name = norm(track["title"])
334 | if contains_artist_track(track_name, track_name=track_name3):
335 | contains_excluded = False
336 | for word in excluded_words:
337 | if word in track_name and not word in track_name2:
338 | if contains_separate_word(track_name, word):
339 | contains_excluded = True
340 | break
341 | if not contains_excluded:
342 | video_id = track["videoId"]
343 | break
344 | except:
345 | pass
346 | while video_id == None and track_dict["state"] != TrackStates.UNAVAILABLE:
347 | try:
348 | videos = YoutubeSearch(text, max_results=max_results).to_dict()
349 | except:
350 | sleep(2)
351 | continue
352 | else:
353 | if len(videos) == 0:
354 | sleep(2)
355 | continue
356 | suitable_videos = []
357 | for video in videos:
358 | video_channel = norm(video["channel"])
359 | video_title = norm(video["title"])
360 | if type(video["duration"]) is int:
361 | video_duration_s = video["duration"]
362 | else:
363 | video_duration_s = strtoms(video["duration"]) / 1000
364 | if type(video["views"]) is int:
365 | video_views = video["views"]
366 | else:
367 | video_views = video["views"].encode().decode("utf-8")
368 | video_views = int("".join([c for c in video_views if c.isdigit()]).rstrip())
369 | video["video_channel"] = video_channel
370 | video["video_title"] = video_title
371 | video["video_description"] = ""
372 | video["video_details"] = ""
373 | if video_views > options["min_video_views"] and video_duration_s >= (track_duration_s - options["video_duration_tolerance_s"]) and video_duration_s <= (track_duration_s + options["video_duration_tolerance_s"]):
374 | self.video_get_details(video)
375 | video_description = video["video_description"]
376 | video_details = video["video_details"]
377 | if "contentRating" in video_details:
378 | content_rating = video_details["contentRating"]
379 | if "ytRating" in content_rating:
380 | if content_rating["ytRating"] == "ytAgeRestricted":
381 | age_restricted = True
382 | continue
383 | if contains_artist_track(video_title, artist_name2, track_name3) or contains_artist_track(video_channel, artist_name2) or contains_artist_track(video_description, artist_name2):
384 | if contains_artist_track(video_title, track_name=track_name3):
385 | priority = 5
386 | if track_name3 == artist_name2:
387 | if not video_title.count(artist_name2) == 2:
388 | priority += options["not_same_name_penalization"]
389 | if contains_date(video["title"], track_name2)[0]:
390 | priority += options["contains_date_penalization"]
391 | if any(word in video_channel for word in excluded_channels):
392 | continue
393 | for word in excluded_words:
394 | if word in video_title and not word in track_name2:
395 | if contains_separate_word(video_title, word):
396 | priority += options["contains_word_title_penalization"]
397 | break
398 | if word in video_description:
399 | if contains_separate_word(video_description, word, 100):
400 | priority += options["contains_word_description_penalization"]
401 | break
402 | if "provided to youtube" in video["video_description"] or "taken from the album" in video["video_description"]:
403 | priority += options["youtube_music_priority"]
404 | elif artist_name2 in video["video_channel"] or any(word in video["video_channel"] for word in preferred_channels):
405 | priority += options["prefered_channel_priority"]
406 | elif artist_name2 in video["video_title"]:
407 | priority += options["artist_in_title_priority"]
408 | if priority > 0:
409 | suitable_videos.append([video, priority])
410 | if len(suitable_videos) > 0:
411 | suitable_videos = sorted(suitable_videos, key=lambda x:x[1], reverse=True)
412 | video_id = suitable_videos[0][0]["id"]
413 | if video_id == None:
414 | if max_results < 10:
415 | max_results = 10
416 | continue
417 | if len(suitable_videos) > 0:
418 | video_id = suitable_videos[0][0]["id"]
419 | else:
420 | if age_restricted:
421 | track_dict["reason"] = "Video is age restricted on YouTube"
422 | else:
423 | track_dict["reason"] = "Not available on YouTube"
424 | track_dict["state"] = TrackStates.UNAVAILABLE
425 | if video_id != None:
426 | track_dict["video_id"] = video_id
427 | track_dict["state"] = TrackStates.FOUND
428 |
429 | def track_find_spotify_metadata(self, track_dict):
430 | if track_dict["state"] == TrackStates.FOUND and track_dict["artist_name"] == "":
431 | options = self.ytsfilter["options"]
432 | excluded_words = self.ytsfilter["excluded_words"]
433 | video_title = norm(track_dict["track_name"])
434 | video_channel = norm(track_dict["video_channel"])
435 | video_duration_s = track_dict["track_duration_ms"] / 1000
436 | tracks = self.tracks_search(track_dict["track_name"], 1)
437 | for track in tracks:
438 | artist_name2 = norm(track["artist_name"])
439 | track_name2 = norm(track["track_name"])
440 | track_duration_s = track["track_duration_ms"] / 1000
441 | if video_duration_s >= (track_duration_s - options["video_duration_tolerance_s"]) and video_duration_s <= (track_duration_s + options["video_duration_tolerance_s"]):
442 | if contains_artist_track(video_title, artist_name2, track_name2) or contains_artist_track(video_channel, artist_name2):
443 | if contains_artist_track(video_title, track_name=track_name2):
444 | contains = False
445 | for word in excluded_words:
446 | if word in video_title and not word in track_name2:
447 | if contains_separate_word(video_title, word):
448 | contains = True
449 | if contains:
450 | continue
451 | track["video_id"] = track_dict["video_id"]
452 | track["state"] = track_dict["state"]
453 | track["locked"] = track_dict["locked"]
454 | contains, date = contains_date(track_dict["track_name"], track_name2)
455 | if contains:
456 | track["track_name"] = track["track_name"] + " (" + date + ")"
457 | track["folder_path"] = track_dict["folder_path"]
458 | track["file_path"] = track_dict["file_path"]
459 | track["file_path2"] = track_dict["file_path2"]
460 | track_dict.update(track)
461 | break
462 |
463 | class YoutubeLoader(Base):
464 | def track_to_dict(self, track, playlist=False):
465 | if not playlist:
466 | track_name = track["title"]
467 | if type(track["duration"]) is str:
468 | track_duration_ms = strtoms(track["duration"])
469 | track_duration_str = track["duration"]
470 | else:
471 | track_duration_ms = track["duration"] * 1000
472 | track_duration_str = mstostr(track["duration"] * 1000)
473 | video_id = track["id"]
474 | video_channel = track["channel"]
475 | else:
476 | try:
477 | track_name = track.title
478 | track_duration_ms = track.length * 1000
479 | track_duration_str = mstostr(track.length * 1000)
480 | video_id = track.video_id
481 | video_channel = track.author
482 | except:
483 | video_url = "https://youtu.be/" + track.video_id
484 | with YoutubeDL() as ydl:
485 | video_info = ydl.extract_info(video_url, False)
486 | track_name = video_info["title"]
487 | track_duration_ms = video_info["duration"] * 1000
488 | track_duration_str = mstostr(video_info["duration"] * 1000)
489 | video_id = track.video_id
490 | video_channel = video_info["uploader"]
491 | file_path = os.path.join(self.music_folder_path, norm(track_name, True, True) + ".m4a")
492 | file_path2 = os.path.join(self.music_folder_path, norm(track_name, True, True) + ".mp3")
493 | track_dict = {
494 | "artist_name": "",
495 | "artist_name2": "",
496 | "artist_genres": "",
497 | "album_name": "",
498 | "album_artist": "",
499 | "album_trackscount": 0,
500 | "album_year": 0,
501 | "album_image": "",
502 | "track_id": "",
503 | "track_name": track_name,
504 | "track_duration_ms": track_duration_ms,
505 | "track_duration_str": track_duration_str,
506 | "track_number": 0,
507 | "track_size_b": None,
508 | "track_size_added": False,
509 | "video_id": video_id,
510 | "forcedmp3": self.format_mp3,
511 | "reason": "",
512 | "folder_path": self.music_folder_path,
513 | "file_path": file_path,
514 | "file_path2": file_path2,
515 | "locked": False,
516 | "video_channel": video_channel
517 | }
518 | track_dict.update({"state": track_file_state(track_dict)})
519 | if track_dict["state"] != TrackStates.COMPLETED:
520 | track_dict["state"] = TrackStates.FOUND
521 | return track_dict
522 |
523 | def tracks_search(self, track_name):
524 | list = []
525 | if len(track_name) > 0:
526 | try:
527 | if "youtube.com" in track_name or "youtu.be" in track_name:
528 | with YoutubeDL() as ydl:
529 | track = ydl.extract_info(track_name, False)
530 | track["channel"] = track["uploader"]
531 | list.append(self.track_to_dict(track))
532 | else:
533 | tracks = YoutubeSearch(track_name).to_dict()
534 | for track in tracks:
535 | list.append(self.track_to_dict(track))
536 | except:
537 | pass
538 | return list
539 |
540 | def playlist_tracks(self, playlist_url):
541 | list = []
542 | if len(playlist_url) > 0:
543 | try:
544 | tracks2 = YoutubePlaylist(playlist_url)
545 | playlist_name = tracks2.title
546 | playlist_file_path = os.path.join(self.music_folder_path, norm(playlist_name, True, True) + ".m3u")
547 | tracks = tracks2.videos
548 | position = 0
549 | playlist_downloaded_count = 0
550 | for track in tracks:
551 | track_dict = self.track_to_dict(track, True)
552 | if track_dict["state"] == TrackStates.COMPLETED:
553 | playlist_downloaded_count += 1
554 | track_dict.update({
555 | "playlist_name": playlist_name,
556 | "playlist_file_path": playlist_file_path,
557 | "playlist_downloaded_count": playlist_downloaded_count
558 | })
559 | list.append(track_dict)
560 | position += 1
561 | self.label_loading_info.text = font(str(position).rjust(3))
562 | self.label_loading_info.text = ""
563 | except:
564 | pass
565 | return list
566 |
--------------------------------------------------------------------------------
/neodeemer/splaylistscreen.kv:
--------------------------------------------------------------------------------
1 | :
2 | MDBoxLayout:
3 | orientation: "vertical"
4 |
5 | MDBoxLayout:
6 | orientation: "vertical"
7 | adaptive_height: True
8 | spacing: 4
9 |
10 | MDTopAppBar:
11 | id: toolbar
12 | title: app.loc.TITLE
13 | right_action_items: [["menu", lambda x: app.navigation_menu.set_state("open")]]
14 |
15 | MDProgressBar:
16 | id: progressbar
17 | color: app.theme_cls.accent_dark
18 |
19 | MDGridLayout:
20 | rows: 3
21 | adaptive_height: True
22 | spacing: 15
23 | padding: 15
24 |
25 | MDTextField:
26 | id: text_splaylist_id
27 | hint_text: app.loc.get_r("Link or ID of Spotify playlist")
28 | font_size: "26sp"
29 | on_text_validate: app.load_in_thread(app.playlist_load, app.tracks_actions_show, show_arg=True, show_arg2=True)
30 | on_focus: if self.focus: app.playlist_last_menu_show()
31 | font_name: "Regular"
32 |
33 | MDLabel:
34 | id: label_playlist_info
35 | markup: True
36 |
37 | ScrollView:
38 | id: scrollview
39 |
40 | MDSelectionListFix:
41 | id: mdlist_tracks
42 | on_selected: app.mdlist_selected(*args)
43 | on_unselected: app.mdlist_unselected(*args)
44 | on_selected_mode: app.mdlist_set_mode(*args)
45 |
46 | MDBoxLayout:
47 | id: playlist_actions
48 | orientation: "horizontal"
49 | adaptive_height: True
50 | adaptive_width: True
51 | pos_hint: {"center_x": .5}
52 | spacing: 5
53 | padding: [15, 5]
54 | opacity: 0
55 |
56 | MDFillRoundFlatIconButton:
57 | text: app.loc.get("Show")
58 | icon: "view-list"
59 | on_press: app.tracks_actions("show")
60 |
61 | MDFillRoundFlatIconButton:
62 | text: app.loc.get("Lyrics only")
63 | icon: "text-long"
64 | on_press: app.tracks_actions("download_lyrics")
65 | visible: int(app.synchronized_lyrics)
66 | size_hint_x: 1 if self.visible else 0
67 | opacity: 1 if self.visible else 0
68 | disabled: not self.visible
69 |
70 | MDFillRoundFlatIconButton:
71 | text: app.loc.get("All")
72 | icon: "download-multiple"
73 | on_press: app.tracks_actions("download_all")
74 |
75 | MDBoxLayout:
76 | id: tracks_actions
77 | orientation: "horizontal"
78 | adaptive_height: True
79 | adaptive_width: True
80 | pos_hint: {"center_x": .5}
81 | spacing: 5
82 | padding: [15, 5]
83 | opacity: 0
84 |
85 | MDFillRoundFlatIconButton:
86 | text: app.loc.get("Cancel")
87 | icon: "close"
88 | on_press: app.mdlist_set_mode(root.ids.mdlist_tracks, 0)
89 |
90 | MDFillRoundFlatIconButton:
91 | text: app.loc.get("Only selected")
92 | icon: "download"
93 | on_press: app.tracks_actions("download_selected")
--------------------------------------------------------------------------------
/neodeemer/tools.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from datetime import datetime
4 | from enum import Enum
5 |
6 | import music_tag
7 | import requests
8 | import unidecode
9 |
10 |
11 | class TrackStates(Enum):
12 | UNAVAILABLE = -1
13 | UNKNOWN = 0
14 | SEARCHING = 1
15 | FOUND = 2
16 | DOWNLOADING = 3
17 | SAVED = 4
18 | TAGSAVING = 5
19 | COMPLETED = 6
20 |
21 | HEADERS = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36"}
22 |
23 | def norm(text, keepdiacritic=False, keepcase=False):
24 | text = "".join([c for c in text if c.isalpha() or c.isdigit() or c == " "]).rstrip()
25 | text = " ".join(text.split())
26 | if not keepdiacritic:
27 | text = unidecode.unidecode(text)
28 | if not keepcase:
29 | text = text.lower()
30 | return text
31 |
32 | def clean_track_name(track_name):
33 | endings = ["original", "from", "remastered", "remake", "remaster", "music", "radio", "mix", "featuring", "new version"]
34 | track_name = track_name.lower()
35 | for ending in endings:
36 | if " - " in track_name and ending in track_name:
37 | if track_name.count(" - ") > 1:
38 | track_name = track_name[0:track_name.find(" - ", track_name.find(" - ") + 1)]
39 | else:
40 | track_name = track_name[0:track_name.find(" - ")]
41 | if " (" in track_name:
42 | track_name = track_name[0:track_name.find(" (")]
43 | return track_name
44 |
45 | def mstostr(ms):
46 | min = round(ms / 1000 / 60)
47 | sec = round(ms / 1000 % 60)
48 | return str(min).zfill(2) + ":" + str(sec).zfill(2)
49 |
50 | def strtoms(str):
51 | if str.count(":") == 1:
52 | colon_first = str.find(":")
53 | min = int(str[:colon_first])
54 | sec = int(str[colon_first + 1:])
55 | return (min * 60 + sec) * 1000
56 | else:
57 | colon_first = str.find(":")
58 | colon_second = str.find(":", colon_first + 1)
59 | hour = int(str[:colon_first])
60 | min = int(str[colon_first + 1:colon_second])
61 | sec = int(str[colon_second + 1:])
62 | return (hour * 60 * 60 + min * 60 + sec) * 1000
63 |
64 | def track_file_state(track_dict):
65 | state = TrackStates.UNKNOWN
66 | file_path = None
67 | if os.path.exists(track_dict["file_path"]) and os.path.getsize(track_dict["file_path"]) > 0:
68 | file_path = track_dict["file_path"]
69 | track_dict["forcedmp3"] = False
70 | elif os.path.exists(track_dict["file_path2"]) and os.path.getsize(track_dict["file_path2"]) > 0:
71 | file_path = track_dict["file_path2"]
72 | track_dict["forcedmp3"] = True
73 | if file_path != None:
74 | try:
75 | mtag = music_tag.load_file(file_path)
76 | if (norm(mtag["artist"].value) == norm(track_dict["artist_name2"]) and norm(mtag["tracktitle"].value) == norm(track_dict["track_name"])):
77 | if (track_dict["video_id"] != None):
78 | if mtag["comment"].value == track_dict["video_id"]:
79 | state = TrackStates.COMPLETED
80 | else:
81 | state = TrackStates.COMPLETED
82 | else:
83 | state = TrackStates.SAVED
84 | except:
85 | state = TrackStates.UNKNOWN
86 | return state
87 |
88 | def submit_bug(track_dict):
89 | track_dict_temp = {}
90 | track_dict_temp.update(track_dict)
91 | del track_dict_temp["folder_path"]
92 | del track_dict_temp["file_path"]
93 | del track_dict_temp["file_path2"]
94 | if "playlist_name" in track_dict_temp:
95 | del track_dict_temp["playlist_name"]
96 | del track_dict_temp["playlist_file_path"]
97 | try:
98 | form_url = "https://docs.google.com/forms/d/e/1FAIpQLSfedpb4aVpMSyzjKMgmkQ1RZ9myBlMPpwo0OvVdpKrxd9nkvQ/formResponse"
99 | form_data = {
100 | "entry.634354352": track_dict_temp["artist_name"],
101 | "entry.1409540080": track_dict_temp["track_name"],
102 | "entry.1756305412": json.dumps(track_dict_temp, default=str)
103 | }
104 | requests.post(form_url, form_data, headers=HEADERS)
105 | except:
106 | pass
107 |
108 | def submit_bugs(selected_tracks):
109 | for track in selected_tracks:
110 | submit_bug(track)
111 |
112 | def contains_separate_word(text, word, max_position=None):
113 | contains = False
114 | if word in text:
115 | word_position = text.find(word)
116 | word_char_start = word_position - 1
117 | word_char_end = word_position + len(word)
118 | if word_position == 0:
119 | if word_char_end < len(text) and text[word_char_end] == " ":
120 | contains = True
121 | elif word_position == len(text) - len(word):
122 | if word_char_start >= 0 and text[word_char_start] == " ":
123 | contains = True
124 | else:
125 | if text[word_char_start] == " " and text[word_char_end] == " ":
126 | contains = True
127 | if max_position != None:
128 | if word_position > max_position:
129 | contains = False
130 | return contains
131 |
132 | def contains_part(text, compare_text, compare_chars=False):
133 | contains = False
134 | text2 = text
135 | compare_text2 = compare_text.split()
136 | if compare_chars and len(compare_text2) <= 2:
137 | compare_text2 = []
138 | i = 0
139 | while i < len(compare_text) - 1:
140 | compare_text2.append(compare_text[i:i + 2])
141 | i += 2
142 | if len(compare_text) % 2 != 0:
143 | compare_text2.append(compare_text[len(compare_text) - 1])
144 | parts_half = len(compare_text2) * (2 / 3)
145 | else:
146 | parts_half = len(compare_text2) / 2
147 | parts_count = 0
148 | for word in compare_text2:
149 | if word in text2:
150 | text2 = text2[text2.find(word) + len(word):len(text2)]
151 | parts_count += 1
152 | contains = parts_count > parts_half
153 | if contains:
154 | break
155 | if not compare_chars and not contains and len(compare_text2) <= 2:
156 | contains = contains_part(text, compare_text, True)
157 | return contains
158 |
159 | def contains_date(text, compare_text=None):
160 | contains = False
161 | date_start_position = -1
162 | date_end_position = -1
163 | date_formats = [
164 | "%d/%m/%Y",
165 | "%d/%m/%y",
166 | "%d.%m.%Y",
167 | "%d.%m.%y",
168 | "%d %b %Y",
169 | "%d %B %Y",
170 | "%m.%d.%Y",
171 | "%m.%d.%y",
172 | "%Y-%m-%d",
173 | "%y-%m-%d",
174 | "%Y/%m/%d",
175 | "%y/%m/%d"
176 | ]
177 | dates = []
178 | for i, char in enumerate(text):
179 | if char.isdigit():
180 | if date_start_position == -1:
181 | date_start_position = i
182 | else:
183 | date_end_position = i
184 | if date_end_position - date_start_position > 10:
185 | date_start_position = date_end_position
186 | elif all(char != c for c in ["/", ".", "-", " "]) and date_end_position - date_start_position > 2:
187 | dates.append(text[date_start_position:date_end_position + 1])
188 | date_start_position = date_end_position
189 | if date_end_position == (len(text) - 1) and date_end_position - date_start_position > 2:
190 | dates.append(text[date_start_position:date_end_position + 1])
191 | if len(dates) > 0:
192 | for date in dates:
193 | for date_format in date_formats:
194 | try:
195 | parsed_date = datetime.strptime(date, date_format)
196 | if parsed_date <= datetime.now():
197 | contains = True
198 | break
199 | except:
200 | continue
201 | if compare_text != None:
202 | try:
203 | parsed_date = datetime.strptime(date, "%Y")
204 | if not date in compare_text:
205 | if parsed_date <= datetime.now():
206 | contains = True
207 | except:
208 | pass
209 | return contains, dates[0]
210 | else:
211 | return contains, None
212 |
213 | def contains_artist_track(text, artist_name=None, track_name=None):
214 | contains = False
215 | if " - " in text:
216 | text2 = text.split(" - ")
217 | text_artist = norm(text2[0])
218 | text_track = norm(clean_track_name(text2[1]))
219 | else:
220 | text = norm(clean_track_name(text))
221 | text_artist = text
222 | text_track = text
223 | if artist_name != None:
224 | if "; " in artist_name:
225 | artists = artist_name.split("; ")
226 | else:
227 | artists = [norm(artist_name)]
228 | else:
229 | artists = [""]
230 | if track_name != None:
231 | track_name = norm(clean_track_name(track_name))
232 | else:
233 | track_name = ""
234 | for artist in artists:
235 | artist = norm(artist)
236 | if artist in text_artist or contains_part(text_artist, artist):
237 | if track_name in text_track or contains_part(text_track, track_name):
238 | contains = True
239 | break
240 | return contains
241 |
242 | def open_url(url, platform):
243 | if platform == "android":
244 | from jnius import cast, autoclass
245 | PythonActivity = autoclass("org.kivy.android.PythonActivity")
246 | Intent = autoclass("android.content.Intent")
247 | Uri = autoclass("android.net.Uri")
248 | intent = Intent()
249 | intent.setAction(Intent.ACTION_VIEW)
250 | intent.setData(Uri.parse(url))
251 | currentActivity = cast("android.app.Activity", PythonActivity.mActivity)
252 | currentActivity.startActivity(intent)
253 | else:
254 | import webbrowser
255 | webbrowser.open(url)
256 |
257 | def check_update_available(current_version):
258 | url = "https://api.github.com/repos/Tutislav/neodeemer/releases"
259 | try:
260 | urldata = requests.get(url)
261 | data = urldata.json()
262 | new_version = data[0]["tag_name"]
263 | except:
264 | new_version = current_version
265 | return new_version != current_version
266 |
267 | def check_mp3_available(track_dict):
268 | url = "https://neodeemer.vorpal.tk/mp3.php?video_id=" + track_dict["video_id"] + "&info=1"
269 | urldata = requests.get(url)
270 | return bool(int(urldata.text))
271 |
272 | def font(text: str):
273 | text = "[font=Regular]" + text + "[/font]"
274 | return text
275 |
--------------------------------------------------------------------------------
/neodeemer/utils/userscript.user.js:
--------------------------------------------------------------------------------
1 | // ==UserScript==
2 | // @name Neodeemer UserScript
3 | // @namespace https://github.com/Tutislav/neodeemer
4 | // @version 0.1
5 | // @description Script to add music to download queue from browser
6 | // @icon https://github.com/Tutislav/neodeemer/raw/main/neodeemer/data/icon.png
7 | // @grant GM_xmlhttpRequest
8 | // @author Tutislav
9 | // @match https://www.youtube.com/watch?v=*
10 | // @match https://www.youtube.com/playlist?list=*
11 | // @connect localhost
12 | // @updateURL https://raw.githubusercontent.com/Tutislav/neodeemer/main/neodeemer/utils/userscript.user.js
13 | // @downloadURL https://raw.githubusercontent.com/Tutislav/neodeemer/main/neodeemer/utils/userscript.user.js
14 | // @supportURL https://github.com/Tutislav/neodeemer/issues
15 | // ==/UserScript==
16 |
17 | (function () {
18 | 'use strict';
19 |
20 | const host = "localhost";
21 |
22 | const downloadButtonCode = ``;
26 |
27 | var resetBorder = function () {
28 | var downloadButton = document.querySelector("#neodeemer-download");
29 | downloadButton.style.border = "2px solid transparent";
30 | };
31 |
32 | var addButton = function () {
33 | var downloadElement = document.querySelector("ytd-download-button-renderer");
34 | if (downloadElement) {
35 | downloadElement.setAttribute("is-hidden", "true");
36 | }
37 | var itemsDiv = document.querySelector("#middle-row");
38 | if (!itemsDiv) {
39 | itemsDiv = document.querySelector(".metadata-wrapper .description");
40 | }
41 | if (itemsDiv) {
42 | itemsDiv.removeAttribute("is-hidden");
43 | itemsDiv.innerHTML = itemsDiv.innerHTML + downloadButtonCode;
44 | var downloadButton = document.querySelector("#neodeemer-download");
45 | }
46 | if (itemsDiv && downloadButton) {
47 | downloadButton.onclick = function () {
48 | var url = "http://" + host + ":8686/download/" + window.location.href;
49 | GM_xmlhttpRequest({
50 | method: "GET",
51 | url: url,
52 | onerror: function () {
53 | downloadButton.style.border = "2px solid red";
54 | setTimeout(resetBorder, 5000);
55 | },
56 | onload: function (response) {
57 | if (response.status == 200) {
58 | downloadButton.style.border = "2px solid lime";
59 | }
60 | else {
61 | downloadButton.style.border = "2px solid red";
62 | }
63 | setTimeout(resetBorder, 5000);
64 | }
65 | });
66 | };
67 | } else {
68 | setTimeout(addButton, 250);
69 | }
70 | };
71 |
72 | addButton();
73 | })();
--------------------------------------------------------------------------------
/neodeemer/webapi.py:
--------------------------------------------------------------------------------
1 | from http.server import BaseHTTPRequestHandler, HTTPServer
2 | from threading import Thread
3 |
4 |
5 | class RequestHandler(BaseHTTPRequestHandler):
6 | def do_GET(self):
7 | if "/download/" in self.path:
8 | self.send_response(200)
9 | self.send_header("Content-Type", "application/json")
10 | self.end_headers()
11 | self.wfile.write(bytes("OK", "utf-8"))
12 | url_parts = self.path.split("/download/")
13 | url = url_parts[1]
14 | self.app.intent_url = url
15 | else:
16 | self.send_response(404)
17 | self.end_headers()
18 |
19 | class WebApiServer():
20 | def __init__(self):
21 | self.server = HTTPServer(("0.0.0.0", 8686), RequestHandler)
22 | self.server.RequestHandlerClass.app = self
23 | self.intent_url = ""
24 | self.server_thread = Thread(target=self.server.serve_forever, name="webapi_server")
25 | self.server_thread.start()
--------------------------------------------------------------------------------
/neodeemer/youtubescreen.kv:
--------------------------------------------------------------------------------
1 | :
2 | MDBoxLayout:
3 | orientation: "vertical"
4 |
5 | MDBoxLayout:
6 | orientation: "vertical"
7 | adaptive_height: True
8 | spacing: 4
9 |
10 | MDTopAppBar:
11 | id: toolbar
12 | title: app.loc.TITLE
13 | right_action_items: [["menu", lambda x: app.navigation_menu.set_state("open")]]
14 |
15 | MDProgressBar:
16 | id: progressbar
17 | color: app.theme_cls.accent_dark
18 |
19 | MDGridLayout:
20 | rows: 1
21 | adaptive_height: True
22 | spacing: 15
23 | padding: 15
24 |
25 | MDTextField:
26 | id: text_youtube_search
27 | hint_text: app.loc.get_r("Search video name")
28 | font_size: "26sp"
29 | on_text_validate: app.load_in_thread(app.youtube_load, app.playlist_show, show_arg2=True)
30 | font_name: "Regular"
31 |
32 | ScrollView:
33 | MDSelectionListFix:
34 | id: mdlist_tracks
35 | on_selected: app.mdlist_selected(*args)
36 | on_unselected: app.mdlist_unselected(*args)
37 | on_selected_mode: app.mdlist_set_mode(*args)
38 |
39 | MDBoxLayout:
40 | id: tracks_actions
41 | orientation: "horizontal"
42 | adaptive_height: True
43 | adaptive_width: True
44 | pos_hint: {"center_x": .5}
45 | spacing: 5
46 | padding: [15, 5]
47 | opacity: 0
48 |
49 | MDFillRoundFlatIconButton:
50 | text: app.loc.get("Cancel")
51 | icon: "close"
52 | on_press: app.mdlist_set_mode(root.ids.mdlist_tracks, 0)
53 |
54 | MDFillRoundFlatIconButton:
55 | text: app.loc.get("All")
56 | icon: "download-multiple"
57 | on_press: app.tracks_actions("download_all")
58 |
59 | MDFillRoundFlatIconButton:
60 | text: app.loc.get("Only selected")
61 | icon: "download"
62 | on_press: app.tracks_actions("download_selected")
--------------------------------------------------------------------------------
/neodeemer/yplaylistscreen.kv:
--------------------------------------------------------------------------------
1 | :
2 | MDBoxLayout:
3 | orientation: "vertical"
4 |
5 | MDBoxLayout:
6 | orientation: "vertical"
7 | adaptive_height: True
8 | spacing: 4
9 |
10 | MDTopAppBar:
11 | id: toolbar
12 | title: app.loc.TITLE
13 | right_action_items: [["menu", lambda x: app.navigation_menu.set_state("open")]]
14 |
15 | MDProgressBar:
16 | id: progressbar
17 | color: app.theme_cls.accent_dark
18 |
19 | MDGridLayout:
20 | rows: 3
21 | adaptive_height: True
22 | spacing: 15
23 | padding: 15
24 |
25 | MDTextField:
26 | id: text_yplaylist_id
27 | hint_text: app.loc.get_r("Link of YouTube playlist")
28 | font_size: "26sp"
29 | on_text_validate: app.load_in_thread(app.playlist_load, app.tracks_actions_show, load_arg=True, show_arg=True, show_arg2=True)
30 | on_focus: if self.focus: app.playlist_last_menu_show(True)
31 | font_name: "Regular"
32 |
33 | MDLabel:
34 | id: label_playlist_info
35 | markup: True
36 |
37 | ScrollView:
38 | id: scrollview
39 |
40 | MDSelectionListFix:
41 | id: mdlist_tracks
42 | on_selected: app.mdlist_selected(*args)
43 | on_unselected: app.mdlist_unselected(*args)
44 | on_selected_mode: app.mdlist_set_mode(*args)
45 |
46 | MDBoxLayout:
47 | id: playlist_actions
48 | orientation: "horizontal"
49 | adaptive_height: True
50 | adaptive_width: True
51 | pos_hint: {"center_x": .5}
52 | spacing: 5
53 | padding: [15, 5]
54 | opacity: 0
55 |
56 | MDFillRoundFlatIconButton:
57 | text: app.loc.get("Show")
58 | icon: "view-list"
59 | on_press: app.tracks_actions("show", True)
60 |
61 | MDFillRoundFlatIconButton:
62 | text: app.loc.get("All")
63 | icon: "download-multiple"
64 | on_press: app.tracks_actions("download_all")
65 |
66 | MDBoxLayout:
67 | id: tracks_actions
68 | orientation: "horizontal"
69 | adaptive_height: True
70 | adaptive_width: True
71 | pos_hint: {"center_x": .5}
72 | spacing: 5
73 | padding: [15, 5]
74 | opacity: 0
75 |
76 | MDFillRoundFlatIconButton:
77 | text: app.loc.get("Cancel")
78 | icon: "close"
79 | on_press: app.mdlist_set_mode(root.ids.mdlist_tracks, 0)
80 |
81 | MDFillRoundFlatIconButton:
82 | text: app.loc.get("Only selected")
83 | icon: "download"
84 | on_press: app.tracks_actions("download_selected")
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tutislav/neodeemer/9567744489d44190779ec6460f4cba1b256b1bb4/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_lyrics.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import tempfile
4 | import unittest
5 |
6 | sys.path.append(os.getcwd())
7 | sys.path.append(os.path.abspath("neodeemer"))
8 | if not os.path.exists("main.py"):
9 | os.chdir("neodeemer")
10 | from neodeemer.songinfoloader import SpotifyLoader
11 | from neodeemer.lyrics import LRCLIB
12 |
13 | class TestLyrics(unittest.TestCase):
14 | music_folder_path = tempfile.mkdtemp()
15 | s = SpotifyLoader("CZ", music_folder_path, False, True)
16 | lrclib = LRCLIB(0.0)
17 | tolerance = 10
18 | tracks = [
19 | { "name": "HIM Wicked Game", "track_dict": None, "lrclib": 1298 },
20 | { "name": "Depeche Mode Enjoy the Silence", "track_dict": None, "lrclib": 735 },
21 | { "name": "My Chemical Romance Teenagers", "track_dict": None, "lrclib": 1601 },
22 | { "name": "Smash Mouth All Star", "track_dict": None, "lrclib": 2250 },
23 | { "name": "Journey Dont Stop Believin", "track_dict": None, "lrclib": 1046 }
24 | ]
25 |
26 | def test_a_spotifysearch(self):
27 | for track in self.tracks:
28 | results = self.s.tracks_search(track["name"])
29 | self.assertGreater(len(results), 0)
30 | track["track_dict"] = results[0]
31 |
32 | def test_b_lyrics(self):
33 | for track in self.tracks:
34 | try:
35 | lyrics = self.lrclib.find_lyrics(track["track_dict"])
36 | except:
37 | lyrics = ""
38 | self.assertAlmostEqual(len(lyrics), track["lrclib"], delta=self.tolerance)
39 |
40 | if __name__ == "__main__":
41 | unittest.main(verbosity=2)
--------------------------------------------------------------------------------
/tests/test_playlistdownload.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | import sys
4 | import tempfile
5 | import unittest
6 |
7 | sys.path.append(os.getcwd())
8 | sys.path.append(os.path.abspath("neodeemer"))
9 | if not os.path.exists("main.py"):
10 | os.chdir("neodeemer")
11 | from neodeemer.download import Download
12 | from neodeemer.songinfoloader import SpotifyLoader, YoutubeLoader
13 | from neodeemer.tools import TrackStates, track_file_state
14 |
15 |
16 | class TestPlaylistDownload(unittest.TestCase):
17 | music_folder_path = tempfile.mkdtemp()
18 | s = SpotifyLoader("CZ", music_folder_path, False, True)
19 | y = YoutubeLoader(music_folder_path, False, True)
20 | s_playlist_id = "https://open.spotify.com/playlist/37i9dQZF1DWXRqgorJj26U?si=5061e09bcd6a41cc"
21 | y_playlist_url = "https://www.youtube.com/playlist?list=PLvyEB5k0wSw6cy8ARt5c-VoyfNIe5udfd"
22 | tracks = []
23 |
24 | def test_a_spotifyplaylist(self):
25 | results = self.s.playlist_tracks(self.s_playlist_id)
26 | self.assertGreater(len(results), 0)
27 | self.tracks.extend(results[0:5])
28 |
29 | def test_b_youtubeplaylist(self):
30 | results = self.y.playlist_tracks(self.y_playlist_url)
31 | self.assertGreater(len(results), 0)
32 | self.tracks.extend(results[0:5])
33 |
34 | def test_c_find_video_id(self):
35 | for track in self.tracks:
36 | if track["state"].value == TrackStates.UNKNOWN.value and track["video_id"] == None:
37 | self.s.track_find_video_id(track)
38 | self.assertIsNot(track["video_id"], None)
39 |
40 | def test_d_download(self):
41 | for track in self.tracks:
42 | Download(track, self.s, None).download_track()
43 | self.assertEqual(track_file_state(track).value, TrackStates.COMPLETED.value, "Download error: " + str(track))
44 |
45 | def test_e_splaylist_file(self):
46 | with open(self.tracks[0]["playlist_file_path"], "r", encoding="utf-8") as playlist_file:
47 | paths = playlist_file.readlines()
48 | for path in paths:
49 | file_path = os.path.join(self.music_folder_path, path[:-1])
50 | self.assertTrue(os.path.exists(file_path))
51 |
52 | def test_f_yplaylist_file(self):
53 | with open(self.tracks[5]["playlist_file_path"], "r", encoding="utf-8") as playlist_file:
54 | paths = playlist_file.readlines()
55 | for path in paths:
56 | file_path = os.path.join(self.music_folder_path, path[:-1])
57 | self.assertTrue(os.path.exists(file_path))
58 |
59 | def test_z_cleanup(self):
60 | shutil.rmtree(self.music_folder_path)
61 |
62 | if __name__ == "__main__":
63 | unittest.main(verbosity=2)
--------------------------------------------------------------------------------
/tests/test_searchdownload.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | import sys
4 | import tempfile
5 | import unittest
6 |
7 | sys.path.append(os.getcwd())
8 | sys.path.append(os.path.abspath("neodeemer"))
9 | if not os.path.exists("main.py"):
10 | os.chdir("neodeemer")
11 | from neodeemer.download import Download
12 | from neodeemer.songinfoloader import SpotifyLoader, YoutubeLoader
13 | from neodeemer.tools import TrackStates, track_file_state
14 |
15 |
16 | class TestSearchDownload(unittest.TestCase):
17 | music_folder_path = tempfile.mkdtemp()
18 | s = SpotifyLoader("CZ", music_folder_path, False, True)
19 | y = YoutubeLoader(music_folder_path, False, True)
20 | tracks_names = ["Jason Charles Miller Rules of Nature", "Mandrage Františkovy Lázně", "Laura Branigan Self Control", "Dymytry Černí Andělé", "Imagine Dragons Enemy"]
21 | tracks = []
22 | tracks2 = []
23 |
24 | def test_a_spotifysearch(self):
25 | for track_name in self.tracks_names:
26 | results = self.s.tracks_search(track_name)
27 | self.assertGreater(len(results), 0)
28 | self.tracks.append(results[0])
29 |
30 | def test_b_youtubesearch(self):
31 | for track_name in self.tracks_names:
32 | results = self.y.tracks_search(track_name)
33 | self.assertGreater(len(results), 0)
34 | self.tracks.append(results[0])
35 |
36 | def test_c_find_video_id(self):
37 | for track in self.tracks:
38 | if track["state"].value == TrackStates.UNKNOWN.value and track["video_id"] == None:
39 | self.s.track_find_video_id(track)
40 | self.assertIsNot(track["video_id"], None)
41 | track2 = {}
42 | track2.update(track)
43 | track2["forcedmp3"] = True
44 | self.tracks2.append(track2)
45 |
46 | def test_d_download_m4a(self):
47 | for track in self.tracks:
48 | Download(track, self.s, None).download_track()
49 | self.assertEqual(track_file_state(track).value, TrackStates.COMPLETED.value, "Download m4a error: " + str(track))
50 |
51 | def test_e_cleanup(self):
52 | shutil.rmtree(self.music_folder_path)
53 |
54 | #def test_f_download_mp3(self):
55 | # for track in self.tracks2:
56 | # Download(track, self.s, None).download_track()
57 | # self.assertEqual(track_file_state(track).value, TrackStates.COMPLETED.value, "Download mp3 error: " + str(track))
58 |
59 | def test_z_cleanup(self):
60 | shutil.rmtree(self.music_folder_path)
61 |
62 | if __name__ == "__main__":
63 | unittest.main(verbosity=2)
--------------------------------------------------------------------------------