From ba65f77ac0244c79e3e42c4778261392d11f5787 Mon Sep 17 00:00:00 2001 From: randompersona1 <74961116+randompersona1@users.noreply.github.com> Date: Thu, 1 Aug 2024 07:07:19 +0200 Subject: [PATCH] Initial Commit Featuring versioning, IO, and a song class for library usage. --- .github/workflows/python-publish.yml | 38 ++ .gitignore | 162 ++++++ .python-version | 1 + LICENCE | 19 + README.md | 40 ++ poetry.lock | 179 ++++++ pyproject.toml | 22 + src/ultrastarparser/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 341 bytes .../__pycache__/io.cpython-312.pyc | Bin 0 -> 3768 bytes .../__pycache__/library.cpython-312.pyc | Bin 0 -> 359 bytes .../__pycache__/song.cpython-312.pyc | Bin 0 -> 5792 bytes .../__pycache__/versions.cpython-312.pyc | Bin 0 -> 15926 bytes src/ultrastarparser/audio.py | 0 src/ultrastarparser/io.py | 59 ++ src/ultrastarparser/library.py | 114 ++++ src/ultrastarparser/song.py | 126 ++++ src/ultrastarparser/transform_funcs.py | 3 + src/ultrastarparser/versions.py | 542 ++++++++++++++++++ tests/__init__.py | 16 + 20 files changed, 1328 insertions(+) create mode 100644 .github/workflows/python-publish.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 LICENCE create mode 100644 README.md create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 src/ultrastarparser/__init__.py create mode 100644 src/ultrastarparser/__pycache__/__init__.cpython-312.pyc create mode 100644 src/ultrastarparser/__pycache__/io.cpython-312.pyc create mode 100644 src/ultrastarparser/__pycache__/library.cpython-312.pyc create mode 100644 src/ultrastarparser/__pycache__/song.cpython-312.pyc create mode 100644 src/ultrastarparser/__pycache__/versions.cpython-312.pyc create mode 100644 src/ultrastarparser/audio.py create mode 100644 src/ultrastarparser/io.py create mode 100644 src/ultrastarparser/library.py create mode 100644 src/ultrastarparser/song.py create mode 100644 src/ultrastarparser/transform_funcs.py create mode 100644 src/ultrastarparser/versions.py create mode 100644 tests/__init__.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..82eb1f1 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,38 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install poetry + poetry install + - name: Build package + run: poetry build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82f9275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..455808f --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.4 diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..dcdd6d4 --- /dev/null +++ b/LICENCE @@ -0,0 +1,19 @@ +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8bcd20e --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# UltrastarParser + +Contains methods to read or edit ultrastar files or directories. + +## Installation + +Use the package manager of your choice: [pypi](https://pypi.org/project/ultrastarParser/). + +## Usage tips + +- Make a backup of the text files. The library currently does not touch any other files. Though I try very hard to fix bugs and use the library myself, I cannot guarantee your files won't go up in flames, particularly if you are using a non-standard file structure or encoding. If something does go wrong, please file an issue. +- Certain formattings will be lost. For example, attributes (like #ARTIST) will always be converted to uppercase. Same goes for empty lines in the attributes section. The current goal is to be able to parse files that adhere to the [Official Ultrastar Format Specification](https://usdx.eu/format/). For everything else, a best effort is made. + +## Features + +```python +from ultrastarparser import Song, Library +song = Song('path_to_txt_file') +song.get_attribute('ARTIST') # Returns song artist +song.set_attribute('ARTIST', 'Bon Jovi') # Set song artist +song.set_version('1.1.0') # Set song file version. See https://usdx.eu/format. +song.flush() # Flush changes made to the file system. + +lib = Library('path_to_library') +for s in lib: + # check for somthing in every song +songs_by_bon_jovi = lib.search('ARTIST', 'Bon Jovi') # Returns all songs with Bon Jovi as artist +lib.export('export_path', 'json') # exports library to path as certain format. +``` + +## Planned features + +- Ability to backup files, for example before overwriting +- Logging +- Better error handling (and documentation of error handling) +- Metadata extraction not from txt file (e.g. song duration from audio file or video resolution from video file) + +## Unplanned features + +- Editing the songtext and notes diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..d796be2 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,179 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "tinytag" +version = "1.10.1" +description = "Read music meta data and length of MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave files" +optional = false +python-versions = ">=2.7" +files = [ + {file = "tinytag-1.10.1-py3-none-any.whl", hash = "sha256:e437654d04c966fbbbdbf807af61eb9759f1d80e4173a7d26202506b37cfdaf0"}, + {file = "tinytag-1.10.1.tar.gz", hash = "sha256:122a63b836f85094aacca43fc807aaee3290be3de17d134f5f4a08b509ae268f"}, +] + +[package.extras] +tests = ["flake8", "pytest", "pytest-cov"] + +[[package]] +name = "urllib3" +version = "2.2.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.10" +content-hash = "75af16939fd6dbb9fa056d9c7649943417e29b6df17b71ddcebc45b97e5bb6c0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..54c2f3d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[tool.poetry] +name = "ultrastarParser" +version = "0.6.0" +description = "Parser for Ultrastar text files." +authors = [ + "randompersona1 <74961116+randompersona1@users.noreply.github.com>" +] +readme = "README.md" +license = "MIT" +keywords = ["ultrastar", "parser", "karaoke"] +homepage = "https://github.com/randompersona1/ultrastarParser" +packages = [{include = "ultrastarParser", from="src"}] + +[tool.poetry.dependencies] +python = ">=3.10" +requests = "^2.32.3" +tinytag = "^1.10.1" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/src/ultrastarparser/__init__.py b/src/ultrastarparser/__init__.py new file mode 100644 index 0000000..601ad9b --- /dev/null +++ b/src/ultrastarparser/__init__.py @@ -0,0 +1,7 @@ +from ultrastarparser.song import Song +from ultrastarparser.library import Library + +__all__ = [ + "Song", + "Library", +] diff --git a/src/ultrastarparser/__pycache__/__init__.cpython-312.pyc b/src/ultrastarparser/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc452c657d20dfa11fbe1734434f64b958e1d5fb GIT binary patch literal 341 zcmX@j%ge<81Un>Fr!@oV#~=<2FhLogRe+4?3@HpLj5!Rsj8Tk?AU0DDQ!aB9Gmy=k z!jjGu#Zt+t$@UVYMU(LsOK^T(I*?}f$xJFrEUGMG28tK400}=$)>|T_IVDAj#U+VF z1&Kw)sYQClK-FL+x5UwbFJaZ5~md}dx|NqoFsLFF$F zo80`A(wtPgB5t5{j6hs01tdN&Gcq#XWl+7#p#6c3ftC9P3-1S3W^SfN_99N8Gyr8F BVj2Jd literal 0 HcmV?d00001 diff --git a/src/ultrastarparser/__pycache__/io.cpython-312.pyc b/src/ultrastarparser/__pycache__/io.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c8326f7192701b684956d4233c6b74d0fd91f5dd GIT binary patch literal 3768 zcmcImT}&HS7QT0W`~zeDY7+25a2uLr(!~);+fCTN1e^qv1XT&uHi(sFJVUT!YZ-{=DoPDUl-awCBut#*XXV zKJB%9?%z4*o_ppy=iI;gd^H5x?_OIQ^LPmPCr<1p6bE}}VUQyVQD~A(&|ylgF*nSa zYy2<|J(mci=7IhUTd+)&@ zM^rLQ6#^&JC{*PYPT|J{MYt;t3oosRup%n8itDa0>{8sSTk)tK)!hm@6n@4H6eV4X z7xul=yet{b?Ra zzn27(BUzdy5g1ubFJ70BgzY+e3;F~YQ`xd~jaqA<$B|J`t7!9P2_@4+<_2fPj6T+N zT6P&e{gxgXizn5{WKfA8Z}xvbz>}@g!>sD`${rz2%4H4V_wkB z(BB%~B^V6uzt`6@@~);bZRA>fBApuPGkq}f&a^(BP6e;USbS2~Ml!bNN~3q848%q> z7AvhyT5IuiXmZ-{L?ZE2T#rO%4pnu0$leF^5$za|J7l~4_?)om3G4*gR*o(oT{*FQ z;z3s-aC%O-SHIoT0iEw39%&DFNzCy%Oz=wP*U5rcb3`xtmXIt-l=Gn=G=Xv}-ri3%*rQUPb?g9)Id+17no2zODA8<>N_|NlQF)kj6OB%Blw1dt zQgZJ%xA^O1ipu<;!LiYYl+}T#Tx$sFNi}8S&JY-c9mCILxU87N9)eCanGMee0K<4X zr5UXb-Xg`d$XFEf#x&p<@NI}D(qtFIE1Q5e=$IiU<0(}$JeKk_Gu3K{4L~|HhHha8 zfy@M|LlP}SmD)!@?vNc{<6`GlQ)j-Zb5$w$-q?Agdp%R|{}cf3-S$We-g)mr{e1m>@rmcqi)J{&Jb7P3^yC_QPx6mXioHK} z#~hr)IwI1HfrJ?Y3Y~>)l7Pj^mO!qGW}`Sve)&4Vn4k*8#qS}N{udoi&MGO&a+rKU zM)*XW@qre4@2G$gSYmNT)pBB)4N#h9dC0N6sf{aYlLVrdBiAfoj!JW&h;UxDIB~5N zZw(QTk?O=sNxqbm%`*2=%zqd0)1-)a_n<-c>EV6avrlUbI>j(}WDjeD&ZxVg>XE3f zv-oI62W-}sPdvCenpURS0pPWgGlq>JPGM=1MGM}hBCn&w5D@0<5HM<(x5}V>9Huir zs19vYC92Z?1O!rTedC<)w6%Sub-8t`wL9P1U1&Y^EdYFg&JAo!4U5tH-o;<7zPcLz zY~a&@tuX0ogzidY=AmnyeY8(xD-0JQ;>Sgl%Rs8$I| zxX=!iDoXbt!E8y&t^x|Z`6B?t@iMt8m@diDvWoY8c4fI=5B%>+ z@jV`TrQ*^sg_!eKT}511G`o(1^~K5z^#pPaWS*a6)c+rBYpE~+rnaZct+;ni083gixKXn`(hJ+eX$?^Sgo{eh|l)mw2*hj(PqgkmtBGQ}%| z9H}0tWYLMD&JvJ&fLst*VK8EmPSVat% zX(v?6iReugUXPl=C)JeUPJ;C?HAFUnol6_f=<%c>#M1Dl6B6loia~C-vXvp=xw2s8 zsV-a&Avq01!wP{`npM|uSr*N-RnJb=?opKc3y?eHc>`%^T9}%jTKI7O!;M3s&H58N zQr*I(`AZuu$DT;?j<4ajO}W5QV59Aag~qOeFSP#Z-+ZT#r+>bGqxtns=}1XnQ<9(7 zHf+?k?MO|iW2d1h=U#H>q$TP8&`M-E@<7=Noy~{N78=fN^j&+-bH2J=7xBFUB^;Ak zADs&qMgPF9xR>24(z3KV^x5@KuNPWQY;>P{BK1Clbk#>0KBr`QLFO11|+#Jh5>EwVE^^d#07_ zEV7_)O!N0WKd~I~Gu8)d_>9rofj~K-^c(WV*TnlZ@qI(uU%1|+ee_#`)D-^@Tx=GH literal 0 HcmV?d00001 diff --git a/src/ultrastarparser/__pycache__/library.cpython-312.pyc b/src/ultrastarparser/__pycache__/library.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3eef076b111289ef4d1c03b150750b65869c7b44 GIT binary patch literal 359 zcmX@j%ge<81bqKjrAYzl#~=<2us|7~C4h|S3``8}3@HpP3@MDOnIJMz43$ip%*inI zAes@%`OE`kOlL@Ch+<4(h+?W_)MUEF?vt5Rlvq^hr^$SaBR)PaF*h|n{uWn!d~SY9 zX%2|Z6CYn#nwSHTS;_DjWZ17*XRDad;?$zznBdIZ{Ja?F{FKbR^q7Fkl8pR3h2Z3( z%z~2Qn9`h*qQv5o#3BSMAh8IjEvC3A86j2x5zB$NM6aN-h#6=Hm;gDwm=#F0Fx+6_ zDB^&L`)M*l6tROuz`~qB)-Mhlh`a5IKtTxB%@1UJU}j`w{LaM4$oYu@NPsl}00&-H A`v3p{ literal 0 HcmV?d00001 diff --git a/src/ultrastarparser/__pycache__/song.cpython-312.pyc b/src/ultrastarparser/__pycache__/song.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5bd81367af391587daa6dc0fa40fdbb38a7c0f4e GIT binary patch literal 5792 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 P?)TY>R3*i_iI)5~1F None: + self.txt_file_path = txt_file_path + self.songfolder = os.path.dirname(txt_file_path) + self.encoding = "utf-8" + + self.song: versions.BaseUltrastarVersion + + def read(self, detect_encoding: bool = False) -> None: + with open(self.txt_file_path, "rb") as f: + lines = f.read() + if detect_encoding: + detector = Detector() + encoding = detector.detect(lines) + lines = lines.decode(encoding=encoding) + else: + lines = lines.decode(encoding=self.encoding) + + self.song = versions.ultrastar_version_factory(lines) + self.song.parse(lines) + + def write(self) -> None: + song = "" + + for attribute in self.song.get_attributes(): + song += f"#{attribute}:{self.song._attributes[attribute]}\n" + for line in self.song.get_body(): + song += f"{line}\n" + + with open(self.txt_file_path, "w", encoding=self.encoding) as f: + f.write(song) + + def backup(self, backup_file_path: str, files: list[str] | None) -> None: + """ + Backup the song to a backup folder. + Removes existing files in the backup folder. + + :param backup_file_path: The path to the backup folder. + :param files: Files to backup + """ + if not os.path.exists(backup_file_path): + os.makedirs(backup_file_path) + elif len(os.listdir(backup_file_path)) != 0: + os.rmdir(backup_file_path) + os.makedirs(backup_file_path) + + if files is not None: + shutil.copy( + os.path.join(self.songfolder, self.txt_file_path), backup_file_path + ) + return + for file in files: + shutil.copy(os.path.join(self.file), backup_file_path) diff --git a/src/ultrastarparser/library.py b/src/ultrastarparser/library.py new file mode 100644 index 0000000..37c91ea --- /dev/null +++ b/src/ultrastarparser/library.py @@ -0,0 +1,114 @@ +from csv import DictWriter +import os +import ultrastarparser.song as song +import json + + +class Library: + def __init__(self, library_folder: str) -> None: + self.library_folder = library_folder + self.songs: list[song.Song] = [] + self.load_songs() + + def load_songs(self) -> None: + self.songs.clear() + for root, _, files in os.walk(self.library_folder): + for file in files: + if file.endswith(".txt"): + self.songs.append(song.Song(os.path.join(root, file))) + + def search(self, attribute: str, value: str) -> list[song.Song]: + """ + Search for songs with the given attribute and value like 'ARTIST', + 'Bon Jovi' -> [UltraStarFile, ...] + + :param attribute: The attribute to search for. + :param value: The value of the attribute to search for. + :return: A list of UltraStarFile objects that match the search. + """ + return [song for song in self.songs if song.get_attribute(attribute) == value] + + def least_common_divisor_attributes(self) -> list[str]: + """ + Returns all attributes in use in the entire library. + + :return: A list of all attributes used in the library sorted to match + the USDX format. + """ + attributes = set() + for ussong in self: + attributes.update(ussong.get_attributes().keys()) + attributes = list(attributes) + + # TODO find a way to sort the attributes in a sensible way + + return attributes + + def export( + self, + path: str, + export_format: str, + attributes: list[str] | None = None, + ) -> None: + """ + Exports the library to a file. Exports only necessary attributes + by default. + + :param path: The path to the json file. + :param export_format: The format to export the library to. Can be + ExportFormat.JSON or ExportFormat.CSV. + :param attributes: A list of attributes to export like ['#ARTIST', + '#TITLE']. If not provided, all necessary attributes are exported. + :raises ValueError: If the export format is not supported. + :raises FileNotFoundError: If the path to the file is invalid. + :raises PermissionError: If the file cannot be written to. + """ + if attributes is None: + attributes = self.least_common_divisor_attributes() + + export_data = {} + for ussong in self: + song_data = {} + for attribute in attributes: + song_data[attribute] = ussong.get_attribute(attribute) + export_data[ussong.commonname] = song_data + + match export_format.upper(): + case "json": + with open(file=path, mode="w") as output_file: + json.dump(export_data, output_file, indent=4) + + case "csv": + with open(file=path, mode="w") as output_file: + writer = DictWriter( + f=output_file, fieldnames=attributes, dialect="excel" + ) + writer.writeheader() + for ussong in export_data.values(): + writer.writerow(ussong) + case _: + raise ValueError(f"Export format '{export_format}" f"not supported.") + + def get_songs(self) -> list[song.Song]: + return self.songs + + def get_song(self, index: int) -> song.Song: + return self.songs[index] + + def __iter__(self) -> iter: + return iter(self.songs) + + def __next__(self) -> song.Song: + return next(self.songs) + + def __str__(self) -> str: + return f"Library: {self.library_folder}" + + def __repr__(self) -> str: + return f"Library({self.library_folder})" + + def __len__(self) -> int: + return len(self.songs) + + def __getitem__(self, index: int) -> song.Song: + return self.get_song(index) diff --git a/src/ultrastarparser/song.py b/src/ultrastarparser/song.py new file mode 100644 index 0000000..f99f7f3 --- /dev/null +++ b/src/ultrastarparser/song.py @@ -0,0 +1,126 @@ +import ultrastarparser.io as io +import ultrastarparser.versions as versions +import os + + +class Song: + """ + Represents an Ultrastar song. + """ + + def __init__(self, txt_file_path: str) -> None: + """ + :param txt_file_path: Path to the Ultrastar song file. The folder + containing the song file is considered the song folder and should not + contain any more ultrastar text files. + """ + self.reader_writer = io.UltrastarReaderWriter(txt_file_path) + self.parse() + + self.songfolder = os.path.dirname(txt_file_path) + + def parse(self) -> None: + """ + Parse the song file. This is done automatically when the song is created. + Reparsing the song before flushing changes resets the changes made to the + song. + """ + self.reader_writer.read() + + def get_attribute(self, attribute: str) -> str: + """ + Get an attribute from the song. + + :param attribute: The attribute to get. + """ + return self.reader_writer.song.get_attribute(attribute.upper()) + + def get_attributes(self) -> dict[str, str]: + """ + Get all attributes from the song. + + :return: A dictionary of all attributes in the song. + """ + return self.reader_writer.song.get_attributes() + + def set_attribute(self, attribute: str, value: str) -> None: + """ + Set an attribute in the song. + + :param attribute: The attribute to set. + :param value: The value to set the attribute to. + """ + self.reader_writer.song._attributes[attribute.upper()] = value + + def get_songtext(self) -> str: + """ + Get the song text of the song. + + :return: The song text. + """ + return self.reader_writer.song.get_body() + + def get_version(self) -> str: + """ + Get the version of the ultrastar song file. + + :return: The version of the ultrastar song file. + """ + return str(self.reader_writer.song.get_version()) + + def set_version(self, dest_version: str) -> None: + """ + Set the version of the ultrastar song file. Upgrades or downgrades the + song to the desired version. This may result in data loss if the version + to be changed to or an intermediate version removes attributes or changes + the format of the song. + + This operation is not reversible. Upgrading a song to a higher version and + then downgrading it to the original version might not result in the same + song as the original. + + Some attributes' values will be lost. For example, version 1.0.0 depreciates + the "DUETSINGERPX" attributes. However, we cannot naively copy these values + to the new "PX" attributes, as those also existed before version 1.0.0. In this + case, the values are lost. + + :param dest_version: The version to change the song to. + """ + dest_version = versions.FormatVersion(dest_version) + current_version = versions.FormatVersion(self.get_version()) + if dest_version == current_version: + return + available_versions = versions.versions + if dest_version not in available_versions.keys(): + raise ValueError( + f"This version doesn't exist or is unsupported: {dest_version}" + ) + upgrade = dest_version > current_version + + # Upgrade or downgrade version until it matches the desired version + while current_version != dest_version: + if upgrade: + try: + self.reader_writer.song = self.reader_writer.song.upgrade() + except versions.VersionChangeError: + return + else: + try: + self.reader_writer.song = self.reader_writer.song.downgrade() + except versions.VersionChangeError: + return + + def flush(self) -> None: + """ + Flush changes to the song file to the file system. Until this method is + called, changes are only stored in memory. + """ + self.reader_writer.write() + + def backup(self, backup_folder: str) -> None: + """ + Backup the song file to a folder. + + :param backup_folder: The folder to backup the song to. + """ + raise NotImplementedError("This method is not implemented yet.") diff --git a/src/ultrastarparser/transform_funcs.py b/src/ultrastarparser/transform_funcs.py new file mode 100644 index 0000000..3fbb03b --- /dev/null +++ b/src/ultrastarparser/transform_funcs.py @@ -0,0 +1,3 @@ +""" +This file contains functions to transform attributes through versions. +""" diff --git a/src/ultrastarparser/versions.py b/src/ultrastarparser/versions.py new file mode 100644 index 0000000..a2af7c9 --- /dev/null +++ b/src/ultrastarparser/versions.py @@ -0,0 +1,542 @@ +from collections.abc import Callable +from typing import Optional +from functools import total_ordering + + +class AttributeMapping: + def __init__( + self, + new_name: str, + transform: Optional[Callable[[str, dict[str, str]], str]] | None = None, + ) -> None: + self.new_name = new_name + self.transform = transform + + new_name: str + # the callable takes the new value name and the attributes dict and returns the new value + transform: Optional[Callable[[str, dict[str, str]], str]] | None = None + + +class VersionChangeError(ValueError): + def __init__( + self, version: "FormatVersion", message: str = "Unable to change version" + ) -> None: + self.version = version + self.message = message + super().__init__(f"{message}: {version}") + + +@total_ordering +class FormatVersion: + major: int + minor: int + patch: int + + def __init__(self, version: str | tuple[int, int, int]) -> None: + if isinstance(version, str): + self.major, self.minor, self.patch = map(int, version.split(".")) + else: + self.major, self.minor, self.patch = version + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, FormatVersion): + return False + return ( + self.major == other.major + and self.minor == other.minor + and self.patch == other.patch + ) + + def __lt__(self, other: object) -> bool: + if not isinstance(other, FormatVersion): + return False + if self.major != other.major: + return self.major < other.major + if self.minor != other.minor: + return self.minor < other.minor + return self.patch < other.patch + + def __hash__(self) -> int: + return hash((self.major, self.minor, self.patch)) + + +@total_ordering +class BaseUltrastarVersion: + def __init__(self) -> None: + self._version: FormatVersion + self._attributes: dict[str, str] = {} + self._body: list[str] = [] + + self.required_attributes: list[str] + self.optional_attributes: list[str] + self.attribute_mappings: dict[str, dict[str, AttributeMapping]] + + def parse(self, file: str) -> None: + attributes = {} + body = [] + + lines = file.splitlines() + if len(lines) < 1: + return + headerFinished: bool = False + for line in lines: + line = line.strip() + if line.startswith("#") and not headerFinished: + key, value = line[1:].split(":", 1) + key = key.upper() + attributes[key.strip()] = value.strip() + elif line == "" and not headerFinished: + continue + elif (line is None or line.isspace()) and headerFinished: + body.append(line) + elif line == "E": + break + else: + headerFinished = True + body.append(line) + + self._set_attributes(attributes) + self._set_body(body) + + def downgrade(self) -> "BaseUltrastarVersion": + version_keys = list(versions.keys()) + current_index = version_keys.index(self._version) + if current_index == 0: + raise VersionChangeError( + self.get_version(), + "Cannot downgrade from version because it is the latest version.", + ) + + previous_version_key = version_keys[current_index - 1] + previous_version_class: BaseUltrastarVersion = versions[previous_version_key]() + + attribute_mapping = self.attribute_mappings.get("downgrade", {}) + previous_version_class._attributes = {} + for k, v in self._attributes.items(): + if k in attribute_mapping: + mapping = attribute_mapping[k] + new_key = mapping.new_name + transform = mapping.transform + previous_version_class._attributes[new_key] = ( + transform(v) if transform else v + ) + else: + previous_version_class._attributes[k] = v + + previous_version_class._version = previous_version_key + previous_version_class._set_body(self._body) + + return previous_version_class + + def upgrade(self) -> "BaseUltrastarVersion": + version_keys = list(versions.keys()) + current_index = version_keys.index(self._version) + if current_index == len(version_keys) - 1: + raise VersionChangeError( + self.get_version(), + "Cannot upgrade from version because it is the latest version.", + ) + + next_version_key = version_keys[current_index + 1] + next_version_class: BaseUltrastarVersion = versions[next_version_key]() + + attribute_mapping = next_version_class.attribute_mappings.get("upgrade", {}) + next_version_class._attributes = {} + next_version_class._version = next_version_key + for k, v in self._attributes.items(): + if k in attribute_mapping: + mapping = attribute_mapping[k] + new_key = mapping.new_name + transform = mapping.transform + next_version_class._attributes[new_key] = ( + transform(v) if transform else v + ) + else: + next_version_class._attributes[k] = v + + next_version_class._version = next_version_key + next_version_class._set_body(self._body) + + return next_version_class + + def _set_attributes(self, attributes: dict[str, str]) -> None: + self._attributes = attributes + + def _set_body(self, body: list[str]) -> None: + self._body = body + + def get_attributes(self) -> dict[str, str]: + return self._attributes + + def get_attribute(self, attribute: str) -> str | None: + if attribute in self._attributes: + return self._attributes.get(attribute) + return None + + def get_body(self) -> list[str]: + return self._body + + def get_version(self) -> FormatVersion: + return self._version + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BaseUltrastarVersion): + return False + return self.get_version() == other.get_version() + + def __lt__(self, other: object) -> bool: + if not isinstance(other, BaseUltrastarVersion): + return False + return self.get_version() < other.get_version() + + +class UltrastarVersion010(BaseUltrastarVersion): + _version: FormatVersion = FormatVersion("0.1.0") + required_attributes = [ + "VERSION", + "TITLE", + "ARTIST", + "MP3", + "BPM", + ] + optional_attributes = [] + + +class UltrastarVersion020(BaseUltrastarVersion): + _version: FormatVersion = FormatVersion("0.2.0") + required_attributes = [ + "VERSION", + "TITLE", + "ARTIST", + "MP3", + "BPM", + ] + optional_attributes = [ + "GAP", + "COVER", + "BACKGROUND", + "VIDEO", + "VIDEOGAP", + "GENRE", + "EDITION", + "CREATOR", + "LANGUAGE", + "YEAR", + "START", + "END", + "PREVIEWSTART", + "MEDLEYSTARTBEAT", + "MEDLEYENDBEAT", + "CALCMEDLEY", + "DUETSINGERP1", + "DUETSINGERP2", + "P1", + "P2", + "COMMENT", + "RESOLUTION", + "NOTESGAP", + "RELATIVE", + "ENCODING", + ] + attribute_mappings = {} + + +class UltrastarVersion030(BaseUltrastarVersion): + _version: FormatVersion = FormatVersion("0.3.0") + required_attributes = [ + "VERSION", + "TITLE", + "ARTIST", + "MP3", + "BPM", + ] + optional_attributes = [ + "GAP", + "COVER", + "BACKGROUND", + "VIDEO", + "VIDEOGAP", + "GENRE", + "EDITION", + "CREATOR", + "LANGUAGE", + "YEAR", + "START", + "END", + "PREVIEWSTART", + "MEDLEYSTARTBEAT", + "MEDLEYENDBEAT", + "CALCMEDLEY", + "DUETSINGERP1", + "DUETSINGERP2", + "P1", + "P2", + "COMMENT", + "RESOLUTION", + "NOTESGAP", + "RELATIVE", + "ENCODING", + ] + attribute_mappings = {} + + +class UltrastarVersion100(BaseUltrastarVersion): + _version: FormatVersion = FormatVersion("1.0.0") + required_attributes = [ + "VERSION", + "TITLE", + "ARTIST", + "MP3", + "BPM", + ] + optional_attributes = [ + "GAP", + "COVER", + "BACKGROUND", + "VIDEO", + "VIDEOGAP", + "GENRE", + "EDITION", + "CREATOR", + "LANGUAGE", + "YEAR", + "START", + "END", + "PREVIEWSTART", + "MEDLEYSTARTBEAT", + "MEDLEYENDBEAT", + "CALCMEDLEY", + "P1", + "P2", + "COMMENT", + ] + attribute_mappings = {} + + +class UltrastarVersion110(BaseUltrastarVersion): + _version: FormatVersion = FormatVersion("1.1.0") + required_attributes = [ + "VERSION", + "TITLE", + "ARTIST", + "MP3", + "AUDIO", + "BPM", + ] + optional_attributes = [ + "VOCALS", + "INSTRUMENTAL", + "GAP", + "COVER", + "BACKGROUND", + "VIDEO", + "VIDEOGAP", + "GENRE", + "EDITION", + "TAGS", + "CREATOR", + "LANGUAGE", + "YEAR", + "START", + "END", + "PREVIEWSTART", + "MEDLEYSTARTBEAT", + "MEDLEYENDBEAT", + "CALCMEDLEY", + "P1", + "P2", + "COMMENT", + "PROVIDEDBY", + ] + attribute_mappings = { + "upgrade": { + "MP3": AttributeMapping(new_name="AUDIO", transform=None), + }, + "downgrade": { + "AUDIO": AttributeMapping(new_name="MP3", transform=None), + }, + } + + +class UltrastarVersion120(BaseUltrastarVersion): + _version: FormatVersion = FormatVersion("1.1.0") + required_attributes = [ + "VERSION", + "TITLE", + "ARTIST", + "MP3", + "AUDIO", + "BPM", + ] + optional_attributes = [ + "AUDIOURL", + "VOCALS", + "INSTRUMENTAL", + "GAP", + "COVER", + "COVERURL", + "BACKGROUND", + "BACKGROUNDURL", + "VIDEO", + "VIDEOURL", + "VIDEOGAP", + "GENRE", + "EDITION", + "TAGS", + "CREATOR", + "LANGUAGE", + "YEAR", + "START", + "END", + "PREVIEWSTART", + "MEDLEYSTARTBEAT", + "MEDLEYENDBEAT", + "CALCMEDLEY", + "P1", + "P2", + "COMMENT", + "PROVIDEDBY", + ] + attribute_mappings = { + "upgrade": { + "MP3": AttributeMapping(new_name="AUDIO", transform=None), + }, + "downgrade": { + "AUDIO": AttributeMapping(new_name="MP3", transform=None), + }, + } + + +class UltrastarVersion200(BaseUltrastarVersion): + _version: FormatVersion = FormatVersion("2.0.0") + required_attributes = [ + "VERSION", + "TITLE", + "ARTIST", + "AUDIO", + "BPM", + ] + optional_attributes = [ + "GAP", + "COVER", + "BACKGROUND", + "VIDEO", + "VIDEOGAP", + "GENRE", + "EDITION", + "CREATOR", + "LANGUAGE", + "YEAR", + "START", + "END", + "PREVIEWSTART", + "MEDLEYSTARTBEAT", + "MEDLEYENDBEAT", + "CALCMEDLEY", + "P1", + "P2", + "COMMENT", + ] + attribute_mappings = { + "upgrade": { + "MP3": AttributeMapping(new_name="AUDIO", transform=None), + "MEDLEYSTARTBEAT": AttributeMapping( + new_name="MEDLEYSTART", + transform=None, # FIXME: transform + ), + "MEDLEYENDBEAT": AttributeMapping( + new_name="MEDLEYEND", + transform=None, # FIXME: transform + ), + }, + "downgrade": { + "AUDIO": AttributeMapping(new_name="MP3", transform=None), + "MEDLEYSTART": AttributeMapping(new_name="MEDLEYSTARTBEAT", transform=None), + "MEDLEYEND": AttributeMapping(new_name="MEDLEYENDBEAT", transform=None), + }, + } + + +versions: dict[FormatVersion, BaseUltrastarVersion] = { + FormatVersion("0.1.0"): UltrastarVersion010, + FormatVersion("0.2.0"): UltrastarVersion020, + FormatVersion("0.3.0"): UltrastarVersion030, + FormatVersion("1.0.0"): UltrastarVersion100, + FormatVersion("1.1.0"): UltrastarVersion110, + FormatVersion("1.2.0"): UltrastarVersion120, + FormatVersion("2.0.0"): UltrastarVersion200, +} +""" +Contains all supported Ultrastar file versions +""" + + +def _detect_version(attributes: dict[str, str]) -> FormatVersion: + if ( + "VERSION" in attributes + and FormatVersion(attributes["VERSION"]) in versions.keys() + ): + return attributes["VERSION"] + + best_version: FormatVersion = None + max_optional_matches = -1 + + for version_key in sorted(versions.keys(), reverse=True): + version_class = versions.get(version_key) + required_attrs = version_class.required_attributes + optional_attrs = version_class.optional_attributes + + # Check if all required attributes are present + if all(attr in attributes for attr in required_attrs): + # Count matching optional attributes + optional_matches = sum(1 for attr in optional_attrs if attr in attributes) + + # Select the version with the highest number of optional matches + if optional_matches > max_optional_matches or ( + optional_matches == max_optional_matches + and (best_version is None or version_key > best_version) + ): + best_version = version_key + max_optional_matches = optional_matches + + if best_version is not None: + return best_version + + # If no version was found, default to 1.0.0. This could be dangerous when upgrading/downgrading, + # but we want to avoid crashing under any circumstances. + return FormatVersion("1.0.0") + + +def ultrastar_version_factory(file: str) -> BaseUltrastarVersion: + attributes = {} + lines = file.splitlines() + for line in lines: + line = line.strip() + if line.startswith("#"): + key, value = line[1:].split(":", 1) + key = key.upper() + attributes[key.strip()] = value.strip() + elif line != "": + break + version = _detect_version(attributes) + return versions.get(version)() + + +if __name__ == "__main__": + file = """ +#VERSION: +#TITLE:Sample Song +#ARTIST:John Doe +#MP3:sample.mp3 +#BPM:120 +this is the body +and it has multiple lines +""" + version = ultrastar_version_factory(file) + version.parse(file=file) + print(version._attributes) + print(version._body) + print(version._version) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f2c7d28 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,16 @@ +import ultrastarparser.song + + +def test_song(): + song = ultrastarparser.song.Song( + "C:\\Users\\Simon\\Vocaluxe\\Songs\\ABBA - S.O.S.txt" + ) + print(song.get_attribute("AUDIO")) + print(song.get_version()) + song.set_version("2.0.0") + print(song.get_version()) + song.flush() + + +if __name__ == "__main__": + test_song()