├── .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 | [Icon](https://github.com/Tutislav/neodeemer/releases/latest) 2 | 3 | # Neodeemer 4 | [![Downloads](https://img.shields.io/github/downloads/Tutislav/neodeemer/total?label=Downloads)](https://github.com/Tutislav/neodeemer/releases/latest) 5 | [![Release](https://img.shields.io/github/v/release/Tutislav/neodeemer?label=Release)](https://github.com/Tutislav/neodeemer/releases/latest) 6 | [![License](https://img.shields.io/github/license/Tutislav/neodeemer?label=License)](https://github.com/Tutislav/neodeemer/blob/main/LICENSE) 7 | [![Softpedia](https://img.shields.io/badge/Softpedia-Certified-limegreen)](https://www.softpedia.com/get/Internet/Download-Managers/Neodeemer.shtml#status)\ 8 | [![Android](https://img.shields.io/badge/Android-%3E%3D%205.0-mediumseagreen?logo=android&logoColor=mediumseagreen)](#installation) 9 | [![Windows](https://img.shields.io/badge/Windows-%3E%3D%208-deepskyblue?logo=windows&logoColor=deepskyblue)](#installation) 10 | [![Python](https://img.shields.io/badge/Python-%3E%3D%203.8-steelblue?logo=python&logoColor=steelblue)](#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 | Screenshot 1 39 | 40 | 41 | 42 | Screenshot 2 43 | 44 | 45 | 46 | Screenshot 3 47 | 48 | 49 | 50 | Screenshot 4 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 | Bug icon 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) --------------------------------------------------------------------------------