merge refactors pr #364 from @michilyy
* Save to DB only unique trackhashes * Add check if track already exists in playlist * replace all paths with `pathlib.Path` * `architecture.md`: * add config folder layout `config.py`: * fix bug where `pathlib.Path` cannot be serialized `files.py`: * remove unused imports * update path concatenation to `pathlib.Path` * add config-folder creation `imgserver.py`: * fix serialisation bug `playlistlib.py`: * update path concatenation to `pathlib.Path` * update all `settings.Paths` usages to new singleton `Paths` class. * update all usages of `settings.Paths` * `files.py`: * rework assets copy function. * remove unused loop and unused `shutil.copy2` function `settings.py` * fix recursion exception in `Paths` * `settings.py`: * remove Singleton and `@property` todos from `Paths` * `__init__.py`: * remove now unused function `create_config_dir()` `setup.files`: * remove because merged into `settings.Paths()` for more central and clear flow how the base path gets decided `settings.py`: * add `copy_assets` function `start_swingmusic.py`: * add configurable settings.Paths class `__main__.py`: * update click to used correct default path * remove wrong commited egg files * remove change in the wrong branch * add forgotten `property` decorator update `get_files_and_dirs` to use pathlib where possible `config.py`: * update type annotation `folders.py`: * convert `pathlib` to posix path where needed for sub-functions `folderlib.py`: * rework `get_files_and_dirs` to use `pathlib` where possible `settings.py`: * add forgotten `@property` `start_swingmusic.py`: * remove second `log_startup_info()` * `artistlib.py`: * fix calling property `tagger.py`: * fix comparing elements in `pathlib.Path` * add support for repeating lyrics. * rework lyrics api and lib * update most path functions. add type-hint pathlib where needed * for serialization paths are converted to posix path * use `open` instead of `os.open` update `metaclass` with constant * fix initial config exception if empty file existed * update `userConfig` with `InitVar` to be excluded from `asdict` * remove `is_windows_slash()` rework path function to use pathlib * convert `pathlib.Path` to `str` for serialization * fixing bug with str + pathlib * `__main__.py`: * update click to use package version * remove now unused function `print_version` `filesystem.py`: * rework `CWD` to use importlib `pyproject.toml`: * disable namespace for `importlib.resources` to work correctly * update `lyrics.py`: * remove unused functions * simplify functions * fix bug where assets get created on root * remove unused code * update lyrics for clearer structure. * add support for unsynced lyrics * fix wrong return type in unsynced lyrics * update `/check` to use `send_lyrics` * prefer tags to duplicates * `lyrics.py`: * add docs to a function group * `logger.py`: * add logging config dict. * combine Logging into one file * add socket logger * add debug mode to logger * add JSONL formater * `logger.py`: * update config to directly use the formater. resolves circular import exception `__main__.py`: * add logger setup to main `start_swingmusic.py`: * add debug option to cli * `lyrics.py`: * add offset support * add `setuptools-scm` to get version from git * add support for docker build with scm * add support for docker build with scm need someone who can test the changes workflow * update all usage of `version.txt` to `metadata.version()` * 2x update all usage of `version.txt` to `metadata.version()` * update to no local_scheme version * provide fix for #331. convert `sql.Row` and `TrackTable` to dict before converting to dataclass. * fix `__main__.py`: * wrong import and uncommited changes * add debug and base_path parameter * fix logger pathlib * add client build workflow * set name * split client from build * try fixing builds * try another fix * try also another fix * try again something new * try again something new * change runner * fix failed run because of malformed runner * add wheel builds * remove systems from pure python build * add isolated pyinstaller build * artifacts with names * wrong wheel path * try fetch-depth for tag fetch * disable fail-fast. add wheel installation * add install system packages * add debug * fix wheel install fix pyinstaller spec file * try fix for pyinstaller * try another fix * build on release * add concrete release types * only run on released or pre-released * try release upload * reformat upload * fix needs tag * identifiable pyinstaller builds * compress client folder before uploading * update to src build * remove no more needed aarch64 build script rename pyinstaller assets to lowercase * remove unneeded code * fix: save to DB only unique track hashes * replace click with argparse * set concrete types in argparse * replace manuall path usages with pathlib * remove unused `configs.py` file * reformat `start_swingmusic.py` * fix empty set startup exception * optimizing static files serve function * fixing bug in optimisation of static files serve function * fix folder view bug * colorlib.py: * fix wrong type exception * remove singe use Index_everything class * update logging of populate.py * cleanup files * fix settings.py Paths copy function. Created folder on file. * add exist check to folder * remove unused `INFO` class * fix multiprocessing bug on windows * potential icon fix for pyinstaller fix multiple logging bug * fix argparse config path bug add jobs file * cleanup code fragments fix logging issue add notes to function * note that concurrent creates own sys.modules * refactor some lyrics plugin condition remove unused import from hashing * refactor taglib.py * update import statements to be static * playlistlib.py: * refactoring and more doc strings populate.py: * add poc bugfix settings.py: * add typehint * possible bugfix for multitreading globals * folder.py: * add check if provided path is absolute populate.py: * add bug note settings.py: * add possible error from Singleton implementation start_swingmusic.py: * correct spelling * pass resolved path to Paths tagger.py: * add logging * trying out fixes for multithreading * only upload results not metadata * fix build action again * folder.py: * strictly use pathlib where possible folderlib.py: * add missing docstring to function, who really need it. track.py: * refactor some code folder.py: * refactor some more code * Merge DBPath class and Paths class. Update all usages of DBPath folderslib.py: * fix bug with logging taglib.py: * add missing docstring settings.py: * merge classes * refactor * network.py: * add more docstring config.py: * update pathlib usage tools.py: * refactor * add docstrings * colorlib.py: * add docstring Refactor App builder into grouped config settings. * update assets access for migration * Update FUNDING.yml * Update FUNDING.yml * upgrade tinytag in requirements.txt * update readme * update license * update readme * Update README.md * Update README.md * cleanup requirements.txt remove unused import in audio_segment.py add entrypoint.sh for appimage support update pyproject.toml for optional dependencies add appimage to github workflow * fix invalid workflow file * AppImage build needs more research. Commenting for now * testing a new build workflow * add libev installation * update workflow to new optional dependencies * trying again another fix * finally fix all optional deps installation correctly * remove AppImage poc * albumslib.py: * add docstring folder.py: * add unix path fix update logger name to `__name__` * update build with docker update Dockerfile with git fix typo in lyrics.py add dynamic deps back * add log for static folder * add missing import * add some more todos * add support for AppImages even when it's not perfect. * quick bugfix for wrong appimage config path * fix uploading not finding AppImages builds aka wrong pattern * optimise docker build by using artifacts. Add client path option. change docstring to sphinx format * add todos * Now support AppImages for real: manually build AppImage as we are building a complex project. * fix missing dep in AppImage build * add full AppImage metadata * add missing image file. * only update swingmusic appimage not tool * add todo and fix AppImage build again. * Try fixing some path mixup in AppImage build * add debug tag to action * correct path to appimage folder * do not download tool before checkout * Another fix for path in appimage build * extend config files with more information * default client dir is now inside the config dir. TODOs updated. * default client dir is now inside the config dir. TODOs updated. Add priority todos. * Auto download client when client not found. Respects user provided dir. * rename `requests` submodule to `request` * poc for arm AppImage builds * try out another fix * fix typo in build.yml * add missing arch tag * fix uploading double names * unique naming * enable fallback version for project. * do not download client into readonly dir. * fix relative client download path. Client was resolved into parent of config. * remove client backup path as client is now downloadable * `Paths` checks if config folder exists and creates it if necessary. logger no more creates the config folder. `app_builder.py`: static route no more with '/client' * path are only created in MainProcess. fix gz file not found. * move assets into src and update usages accordingly * remove solved todos * Only upload artefacts if not draft/master aka only on tag * wrong type in assets copy * update log with correct priority * add debug statements and logging to Paths * remove debugging statement * remove double version tag from docker build * fork save release protection * fix typo * add fallback client dir for static builds. * update argparse to new param * add missing import pathlib * add sparse checkout as we do not need everything downloaded * add assets copy check * init logger bevor Paths * remove unused import * check if logdir exists and create if not * only add exec info to file * remove exception log from cli * move logging into main. Allows tools support again. * UserConfig now correctly uses _finished key. Bug where _finished was never written * double save serverId. update root_dir to trow no exception on init. remove debug param * clean up TODOs --------- Co-authored-by: skilletfun <skilletfun.laptew.sergey@yandex.ru> Co-authored-by: Mungai Njoroge <geoffreymungai45@gmail.com>
@@ -28,7 +28,7 @@ The data folder is used to store all the files and data used in Swing Music. Thi
|
|||||||
|
|
||||||
This folder contains a few folders inside:
|
This folder contains a few folders inside:
|
||||||
|
|
||||||
- `/assets` - stores static assets used by the clients. THink logos, etc.
|
- `/assets` - stores static assets used by the clients. Think logos, etc.
|
||||||
- `/images` - stores thumbnails, artist images and playlist covers.
|
- `/images` - stores thumbnails, artist images and playlist covers.
|
||||||
- `/plugins` - stores data used by plugins
|
- `/plugins` - stores data used by plugins
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
name: Build and Upload
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- prereleased
|
||||||
|
- released
|
||||||
|
|
||||||
|
env:
|
||||||
|
PIP_USE_PEP517: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build-client:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Build client
|
||||||
|
steps:
|
||||||
|
- name: Clone client
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: 'swingmx/webclient'
|
||||||
|
path: swingmusic-client
|
||||||
|
|
||||||
|
- name: Setup Node 20
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
|
||||||
|
- name: Install yarn
|
||||||
|
run: |
|
||||||
|
npm install -g yarn
|
||||||
|
|
||||||
|
- name: Install dependencies & Build client
|
||||||
|
run: |
|
||||||
|
cd swingmusic-client
|
||||||
|
yarn install
|
||||||
|
yarn build --outDir ../client
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
- name: Upload client
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
path: "client/"
|
||||||
|
compression-level: 0
|
||||||
|
name: 'client'
|
||||||
|
|
||||||
|
build-wheels:
|
||||||
|
name: Build wheels
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout swingmusic
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Build wheels
|
||||||
|
run: pip wheel . -w wheelhouse --no-deps
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
# name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
|
||||||
|
path: ./wheelhouse/*.whl
|
||||||
|
compression-level: 0
|
||||||
|
name: 'wheels'
|
||||||
|
|
||||||
|
build-appimage:
|
||||||
|
name: Build Appimage
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
needs: [ build-client ]
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ ubuntu-latest, ubuntu-24.04-arm]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install linux dependencies
|
||||||
|
run: sudo apt-get install libev-dev libfuse-dev -y > /dev/null
|
||||||
|
|
||||||
|
- name: Checkout swingmusic
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
sparse-checkout: appimage
|
||||||
|
|
||||||
|
- name: Set architecture-specific variables
|
||||||
|
run: |
|
||||||
|
if ${{ endsWith(matrix.os, 'arm') }}; then
|
||||||
|
echo "APPIMAGE_ARCH=aarch64" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "APPIMAGE_ARCH=x86_64" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install appimage builder
|
||||||
|
run: |
|
||||||
|
pip install python-appimage
|
||||||
|
wget -nv -c "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$APPIMAGE_ARCH.AppImage"
|
||||||
|
chmod +x "appimagetool-$APPIMAGE_ARCH.AppImage"
|
||||||
|
|
||||||
|
- name: Download client artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: client
|
||||||
|
path: client
|
||||||
|
|
||||||
|
- name: Download wheel artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wheels
|
||||||
|
path: wheels
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Build appimage
|
||||||
|
run: |
|
||||||
|
python-appimage build app -p 3.11 appimage/ -n "swingmusic-$APPIMAGE_ARCH" -x client --no-packaging
|
||||||
|
pip install --target "swingmusic-$APPIMAGE_ARCH/opt/python3.11/lib/python3.11/" --no-deps --find-links=wheels/ swingmusic
|
||||||
|
./appimagetool-$APPIMAGE_ARCH.AppImage --no-appstream "swingmusic-$APPIMAGE_ARCH" "swingmusic-${{github.ref_name}}-$APPIMAGE_ARCH.AppImage"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ./swing*.AppImage
|
||||||
|
compression-level: 0
|
||||||
|
name: appimage-${{ env.APPIMAGE_ARCH }}
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Build and push Docker image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ build-client, build-wheels ]
|
||||||
|
permissions: write-all
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout into repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Download artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta # you'll use this in the next step
|
||||||
|
uses: docker/metadata-action@v3
|
||||||
|
with:
|
||||||
|
# list of Docker images to use as base name for tags
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Determine if image should be uploaded
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "UPLOAD=false" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "UPLOAD=true" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64, linux/arm64 #,linux/arm
|
||||||
|
push: ${{ env.UPLOAD }}
|
||||||
|
tags: ghcr.io/${{github.repository}}:${{format('{0}', github.ref_name)}}, ghcr.io/${{github.repository}}:latest
|
||||||
|
labels: org.opencontainers.image.title=Docker
|
||||||
|
|
||||||
|
build-pyinstaller:
|
||||||
|
name: Build binary on ${{ matrix.os }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
needs: [ build-client, build-wheels ]
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ ubuntu-latest, ubuntu-24.04-arm, windows-latest, windows-11-arm, macos-13, macos-latest ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout swingmusic
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: |
|
||||||
|
swingmusic.spec
|
||||||
|
src/swingmusic/assets
|
||||||
|
|
||||||
|
- name: Install Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11.x"
|
||||||
|
|
||||||
|
- name: Download client artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: client
|
||||||
|
path: client
|
||||||
|
|
||||||
|
- name: Download wheel artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wheels
|
||||||
|
path: wheels
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
# install system packages
|
||||||
|
- name: Setup Homebrew
|
||||||
|
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||||
|
uses: Homebrew/actions/setup-homebrew@master
|
||||||
|
|
||||||
|
- name: Install libev (macOS)
|
||||||
|
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||||
|
run: |
|
||||||
|
brew install libev
|
||||||
|
|
||||||
|
- name: Install libev (Linux)
|
||||||
|
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||||
|
run: |
|
||||||
|
sudo apt-get install libev-dev -y > /dev/null
|
||||||
|
|
||||||
|
- name: Install swingmusic
|
||||||
|
run: |
|
||||||
|
pip install --find-links=wheels/ swingmusic[build]
|
||||||
|
|
||||||
|
- name: Build with Pyinstaller on ${{ matrix.os }}
|
||||||
|
run: pyinstaller swingmusic.spec
|
||||||
|
|
||||||
|
- name: upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: Pyinstaller-${{ matrix.os }}
|
||||||
|
path: ./dist/swingmusic_*
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
upload-builds:
|
||||||
|
name: Uploading builds to release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ build-client, build-wheels, build-pyinstaller, build-appimage ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download client artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: client
|
||||||
|
path: client
|
||||||
|
|
||||||
|
- name: compress client
|
||||||
|
run: |
|
||||||
|
zip -r client.zip client
|
||||||
|
rm -r client
|
||||||
|
|
||||||
|
- name: Download wheel artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wheels
|
||||||
|
path: wheels
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Download all Pyinstaller builds
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: Pyinstaller-*
|
||||||
|
path: pyinstaller
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Download AppImages build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: appimage*
|
||||||
|
path: appimage
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Determine if current run is draft
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "DRAFT=true" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "DRAFT=false" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload artifacts to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
draft: ${{ env.DRAFT }}
|
||||||
|
files: |
|
||||||
|
client.zip
|
||||||
|
wheels/**
|
||||||
|
pyinstaller/**
|
||||||
|
appimage/**
|
||||||
@@ -51,65 +51,50 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clone client
|
- name: Clone client
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Setup Node 20
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
fetch-tags: true
|
||||||
- name: Install yarn
|
|
||||||
run: |
|
|
||||||
npm install -g yarn
|
|
||||||
- name: Clone client
|
|
||||||
run: |
|
|
||||||
git clone https://github.com/cwilvx/swingmusic-client.git
|
|
||||||
- name: Install dependencies & Build client
|
|
||||||
run: |
|
|
||||||
cd swingmusic-client
|
|
||||||
yarn install
|
|
||||||
yarn build --outDir ../client
|
|
||||||
cd ..
|
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: "3.11.x"
|
python-version: "3.11.x"
|
||||||
|
|
||||||
- name: Create virtualenv
|
- name: Create virtualenv
|
||||||
run: |
|
run: |
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
|
|
||||||
- name: Setup Homebrew
|
- name: Setup Homebrew
|
||||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||||
uses: Homebrew/actions/setup-homebrew@master
|
uses: Homebrew/actions/setup-homebrew@master
|
||||||
|
|
||||||
- name: Install libev (macOS)
|
- name: Install libev (macOS)
|
||||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||||
run: |
|
run: |
|
||||||
brew install libev
|
brew install libev
|
||||||
|
|
||||||
- name: Install libev (Linux)
|
- name: Install libev (Linux)
|
||||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get install libev-dev -y > /dev/null
|
sudo apt-get install libev-dev -y > /dev/null
|
||||||
|
|
||||||
- name: Activate virtualenv (unix)
|
- name: Activate virtualenv (unix)
|
||||||
if: ${{ !startsWith(matrix.os, 'win') }}
|
if: ${{ !startsWith(matrix.os, 'win') }}
|
||||||
run: |
|
run: |
|
||||||
source .venv/bin/activate && echo "bjoern==3.2.2" >> requirements.txt
|
source .venv/bin/activate
|
||||||
|
|
||||||
- name: Activate virtualenv (windows)
|
- name: Activate virtualenv (windows)
|
||||||
if: ${{ startsWith(matrix.os, 'win') }}
|
if: ${{ startsWith(matrix.os, 'win') }}
|
||||||
run: |
|
run: |
|
||||||
.venv\Scripts\Activate && echo "waitress==3.0.2" >> requirements.txt
|
.venv\Scripts\Activate
|
||||||
- name: Install dependencies
|
|
||||||
|
- name: Install swingmusic
|
||||||
run: |
|
run: |
|
||||||
pip install -r requirements.txt
|
pip install .
|
||||||
- name: Write version file
|
|
||||||
run: |
|
|
||||||
echo ${{ inputs.tag }} > version.txt
|
|
||||||
# - name: Install Poetry
|
|
||||||
# run: |
|
|
||||||
# pip install poetry
|
|
||||||
# - name: Install dependencies
|
|
||||||
# run: |
|
|
||||||
# python -m poetry install
|
|
||||||
- name: Build server
|
- name: Build server
|
||||||
run: |
|
run: |
|
||||||
python run.py --build
|
python run.py --build
|
||||||
env:
|
|
||||||
SWINGMUSIC_APP_VERSION: ${{ inputs.tag }}
|
|
||||||
- name: Rename Unix binary
|
- name: Rename Unix binary
|
||||||
if: ${{ !startsWith(matrix.os, 'win') }}
|
if: ${{ !startsWith(matrix.os, 'win') }}
|
||||||
run: |
|
run: |
|
||||||
@@ -124,6 +109,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
mv dist/swingmusic dist/swingmusic_linux_amd64
|
mv dist/swingmusic dist/swingmusic_linux_amd64
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Verify Unix build success
|
- name: Verify Unix build success
|
||||||
if: ${{ !startsWith(matrix.os, 'win') }}
|
if: ${{ !startsWith(matrix.os, 'win') }}
|
||||||
run: |
|
run: |
|
||||||
@@ -150,6 +136,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Verify Windows build success
|
- name: Verify Windows build success
|
||||||
if: ${{ startsWith(matrix.os, 'win') }}
|
if: ${{ startsWith(matrix.os, 'win') }}
|
||||||
run: |
|
run: |
|
||||||
@@ -164,6 +151,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Unix binary
|
- name: Upload Unix binary
|
||||||
if: ${{ !startsWith(matrix.os, 'win') }}
|
if: ${{ !startsWith(matrix.os, 'win') }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -171,6 +159,7 @@ jobs:
|
|||||||
name: ${{ matrix.os == 'ubuntu-22.04-arm64' && 'linux-arm64' || matrix.os == 'macos-14' && 'macos-arm64' || matrix.os == 'macos-13' && 'macos-amd64' || 'linux-amd64' }}
|
name: ${{ matrix.os == 'ubuntu-22.04-arm64' && 'linux-arm64' || matrix.os == 'macos-14' && 'macos-arm64' || matrix.os == 'macos-13' && 'macos-amd64' || 'linux-amd64' }}
|
||||||
path: ${{ matrix.os == 'ubuntu-22.04-arm64' && 'dist/swingmusic_linux_arm64' || matrix.os == 'macos-14' && 'dist/swingmusic_macos_arm64' || matrix.os == 'macos-13' && 'dist/swingmusic_macos_amd64' || 'dist/swingmusic_linux_amd64' }}
|
path: ${{ matrix.os == 'ubuntu-22.04-arm64' && 'dist/swingmusic_linux_arm64' || matrix.os == 'macos-14' && 'dist/swingmusic_macos_arm64' || matrix.os == 'macos-13' && 'dist/swingmusic_macos_amd64' || 'dist/swingmusic_linux_amd64' }}
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
- name: Upload Windows binary
|
- name: Upload Windows binary
|
||||||
if: ${{ startsWith(matrix.os, 'win') }}
|
if: ${{ startsWith(matrix.os, 'win') }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -277,6 +266,7 @@ jobs:
|
|||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: |
|
images: |
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
venv
|
venv
|
||||||
|
.venv
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.idea
|
.idea
|
||||||
@@ -25,7 +26,7 @@ client
|
|||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
logs.txt
|
logs.txt
|
||||||
*.spec
|
|
||||||
|
|
||||||
# TODO.md
|
# TODO.md
|
||||||
testdata.py
|
testdata.py
|
||||||
@@ -36,3 +37,4 @@ nohup.out
|
|||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
/wheels/
|
||||||
|
|||||||
@@ -1,41 +1,22 @@
|
|||||||
FROM node:latest AS CLIENT
|
|
||||||
|
|
||||||
RUN git clone --depth 1 https://github.com/swing-opensource/swingmusic-client.git client
|
|
||||||
|
|
||||||
WORKDIR /client
|
|
||||||
|
|
||||||
# RUN git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
|
|
||||||
# checkout the latest tag
|
|
||||||
# RUN git checkout $client_tag
|
|
||||||
|
|
||||||
RUN yarn install
|
|
||||||
RUN yarn build
|
|
||||||
|
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
WORKDIR /app/swingmusic
|
WORKDIR /app/swingmusic
|
||||||
|
|
||||||
# Copy the files in the current dir into the container
|
# Copy the files in the current dir into the container
|
||||||
COPY . .
|
# copy wheelhouse and client
|
||||||
|
COPY wheels wheels
|
||||||
|
COPY client /config/client
|
||||||
|
|
||||||
COPY --from=CLIENT /client/dist/ client
|
|
||||||
|
|
||||||
|
LABEL "author"="swing music"
|
||||||
EXPOSE 1970/tcp
|
EXPOSE 1970/tcp
|
||||||
|
|
||||||
VOLUME /music
|
VOLUME /music
|
||||||
|
|
||||||
VOLUME /config
|
VOLUME /config
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y gcc libev-dev python3-dev -y ffmpeg libavcodec-extra gcc-aarch64-linux-gnu && \
|
RUN apt-get update && apt-get install -y gcc git libev-dev python3-dev ffmpeg libavcodec-extra && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install --no-cache-dir --find-links=wheels/ swingmusic
|
||||||
RUN pip install bjoern
|
Run rm -rf /app/swingmusic/wheels
|
||||||
|
|
||||||
ARG app_version
|
ENTRYPOINT ["python", "-m", "swingmusic", "--host", "0.0.0.0", "--config", "/config", "--client", "/config/client"]
|
||||||
ENV SWINGMUSIC_APP_VERSION=$app_version
|
|
||||||
|
|
||||||
# dump the app_version to the version.txt file
|
|
||||||
RUN echo $app_version > version.txt
|
|
||||||
|
|
||||||
ENTRYPOINT ["python", "run.py", "--host", "0.0.0.0", "--config", "/config"]
|
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
# @michily TODO
|
||||||
|
|
||||||
|
## UI
|
||||||
|
* Auto update WebUI - version check + api missing
|
||||||
|
* Web UI - Remove https from index.html for http support?
|
||||||
|
* Web UI could use continues build like https://github.com/AppImage/AppImageKit/releases/download/continuous/
|
||||||
|
* Web UI - playlist not shown in folder view
|
||||||
|
* rework argparse with subparser. Currently not clear what commands allow what args
|
||||||
|
|
||||||
|
## Building:
|
||||||
|
* AppImage build is currently broken view [python-appimage: Issues 95](https://github.com/niess/python-appimage/issues/94) aka I bypassed it.
|
||||||
|
* Optimise docker/speed build up
|
||||||
|
|
||||||
|
## Server:
|
||||||
|
* Rework song name/autor/.. parsing to only support filetags. Only fall back when user-enabled and manual regex is set. see Telegram
|
||||||
|
* Publish this on PyPi
|
||||||
|
|
||||||
|
## Multithreading
|
||||||
|
* Multiprocessing creates new paths - sync between processes. <- env is recommended.
|
||||||
|
* Fix singleton global in multiprocessing - own process, own memory, own sys.modules cache <- env is recommended.
|
||||||
|
|
||||||
|
## Auth:
|
||||||
|
* more multiuser control
|
||||||
|
* audit log
|
||||||
|
* one auth method for all e.g. jwt in Header?
|
||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- Migrations:
|
- Migrations:
|
||||||
@@ -15,6 +41,7 @@
|
|||||||
- rename userid to server id in config file
|
- rename userid to server id in config file
|
||||||
- Look into seeding jwts using user password + server id
|
- Look into seeding jwts using user password + server id
|
||||||
|
|
||||||
|
|
||||||
<!-- CHECKPOINT -->
|
<!-- CHECKPOINT -->
|
||||||
<!-- ALBUM PAGE! -->
|
<!-- ALBUM PAGE! -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# README
|
|
||||||
# Builds swingmusic binary for aarch64 aka ARM64 architecture
|
|
||||||
# Run
|
|
||||||
# ./buildswingmusic.sh
|
|
||||||
# chmod a+x swingmusicbuilder/swingmusic/dist/swingmusic
|
|
||||||
# .swingmusicbuilder/swingmusic/dist/swingmusic --port <optional_port_param> --host <optional_host_param>
|
|
||||||
# Notes
|
|
||||||
# Poetry installer and pipx install poetry are both broken on ARM64 Raspberry Pi OS
|
|
||||||
# Moving or renaming venv directory (comment inline below) will break that venv.
|
|
||||||
# Additional poetry bug ongoing https://github.com/python-poetry/poetry/issues/5250 (comment inline below)
|
|
||||||
# Changed to bash shebang above from repo build script setting of zsh
|
|
||||||
pacman-key --init > /dev/null
|
|
||||||
pacman-key --populate archlinuxarm > /dev/null
|
|
||||||
pacman -Syq --noconfirm > /dev/null
|
|
||||||
pacman -S libev --noconfirm > /dev/null
|
|
||||||
pacman -Sq yarn git wget glibc gcc bzip2 expat gdbm libffi libnsl libxcrypt openssl zlib libnghttp2 icu --noconfirm --disable-download-timeout --needed > /dev/null
|
|
||||||
wget -q https://github.com/jensgrunzer1/pyhon311-for-aarch64/raw/refs/heads/main/python311-3.11.9-2-aarch64.pkg.tar.xz
|
|
||||||
pacman -U python311-3.11.9-2-aarch64.pkg.tar.xz --noconfirm
|
|
||||||
mkdir swingmusicbuilder
|
|
||||||
cd swingmusicbuilder
|
|
||||||
|
|
||||||
git clone --quiet https://github.com/swing-opensource/swingmusic-client.git
|
|
||||||
git clone --quiet https://github.com/swing-opensource/swingmusic.git
|
|
||||||
|
|
||||||
python3.11 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
cd swingmusic
|
|
||||||
|
|
||||||
# pip install -U pip setuptools
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install bjoern
|
|
||||||
|
|
||||||
cd ../swingmusic-client
|
|
||||||
yarn install
|
|
||||||
yarn build --outDir ../swingmusic/client
|
|
||||||
|
|
||||||
cd ../swingmusic
|
|
||||||
# Fixes poetry issue 5250.
|
|
||||||
# export PYTHON_KEYRING_BACKEND=keyring.backends.fail.Keyring
|
|
||||||
# poetry env use /usr/bin/python3.11
|
|
||||||
# poetry install
|
|
||||||
# Swing gives error if this is not set. Set to version of repo you cloned.
|
|
||||||
export SWINGMUSIC_APP_VERSION="TAG"
|
|
||||||
python run.py --build
|
|
||||||
|
|
||||||
# rename binary
|
|
||||||
mv dist/swingmusic dist/swingmusic_linux_arm64
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
exec "${APPDIR}/usr/bin/python" -m swingmusic --fallback-client "${APPDIR}/client" "$@"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
pillow>=11.1.0
|
||||||
|
Flask>=3.1.0
|
||||||
|
Flask-Cors>=3.0.10
|
||||||
|
requests>=2.27.1
|
||||||
|
colorgram.py>=1.2.0
|
||||||
|
tqdm>=4.65.0
|
||||||
|
tinytag>=2.1.1
|
||||||
|
Unidecode>=1.3.6
|
||||||
|
psutil>=5.9.4
|
||||||
|
show-in-file-manager>=1.1.4
|
||||||
|
flask-compress>=1.13
|
||||||
|
tabulate>=0.9.0
|
||||||
|
setproctitle>=1.3.2
|
||||||
|
locust>=2.20.1
|
||||||
|
watchdog>=4.0.0
|
||||||
|
flask-jwt-extended>=4.6.0
|
||||||
|
sqlalchemy>=2.0.31
|
||||||
|
memory-profiler>=0.61.0
|
||||||
|
sortedcontainers>=2.4.0
|
||||||
|
xxhash>=3.4.1
|
||||||
|
ffmpeg-python>=0.2.0
|
||||||
|
schedule>=1.2.2
|
||||||
|
flask-openapi3==3.0.2
|
||||||
|
rapidfuzz==3.11.0
|
||||||
|
pendulum>=3.0.0
|
||||||
|
pystray>=0.19.5
|
||||||
|
bjoern>=3.2.2
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>swingmusic</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>AGPL-3.0</project_license>
|
||||||
|
<name>Swing Music</name>
|
||||||
|
<summary>Music server for your files running on Python {{ python-fullversion }}</summary>
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Swing Music is a fast and beautiful, self-hosted music player for your local audio files.
|
||||||
|
Like a cooler Spotify ... but bring your own music.
|
||||||
|
Just run the app and enjoy your music library in a web browser.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
<launchable type="desktop-id">swingmusic.desktop</launchable>
|
||||||
|
<url type="homepage">https://swingmx.com/</url>
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://raw.githubusercontent.com/swing-opensource/swingmusic/master/.github/images/artist.webp</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
<provides>
|
||||||
|
<id>swingmusic.desktop</id>
|
||||||
|
</provides>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=swingmusic
|
||||||
|
GenericName=Music player
|
||||||
|
Exec=swingmusic
|
||||||
|
Comment=A Music Server running on {{ python-fullversion }}
|
||||||
|
Icon=swingmusic
|
||||||
|
Categories=AudioVideo;Music;
|
||||||
|
Terminal=true
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
TODO:
|
||||||
|
* add architecture of swingmusic
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
config folder
|
||||||
|
├───swingmusic
|
||||||
|
├───assets
|
||||||
|
├───images
|
||||||
|
│ ├───artists
|
||||||
|
│ │ ├───large
|
||||||
|
│ │ ├───medium
|
||||||
|
│ │ └───small
|
||||||
|
│ ├───mixes
|
||||||
|
│ │ ├───medium
|
||||||
|
│ │ ├───original
|
||||||
|
│ │ └───small
|
||||||
|
│ ├───playlists
|
||||||
|
│ └───thumbnails
|
||||||
|
│ ├───large
|
||||||
|
│ ├───medium
|
||||||
|
│ ├───small
|
||||||
|
│ └───xsmall
|
||||||
|
└───plugins
|
||||||
|
└───lyrics
|
||||||
|
```
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools"]
|
requires = ["setuptools", "setuptools-scm"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "swingmusic"
|
name = "swingmusic"
|
||||||
version = "2.0.6"
|
|
||||||
description = "Swing Music"
|
description = "Swing Music"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11, <=3.12"
|
requires-python = ">=3.11, <=3.12"
|
||||||
|
dynamic = ["version"]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pillow>=11.1.0",
|
"pillow>=11.1.0",
|
||||||
@@ -37,11 +37,15 @@ dependencies = [
|
|||||||
"rapidfuzz==3.11.0",
|
"rapidfuzz==3.11.0",
|
||||||
"pendulum>=3.0.0",
|
"pendulum>=3.0.0",
|
||||||
"pystray>=0.19.5",
|
"pystray>=0.19.5",
|
||||||
"pyinstaller==6.12.0",
|
|
||||||
"waitress>=3.0.2; sys_platform == 'win32'",
|
"waitress>=3.0.2; sys_platform == 'win32'",
|
||||||
"bjoern >=3.2.2; sys_platform != 'win32'"
|
"bjoern >=3.2.2; sys_platform != 'win32'"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
build = [
|
||||||
|
"pyinstaller"
|
||||||
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
dependency-metadata = [
|
dependency-metadata = [
|
||||||
{ name = "waitress", version = "3.0.2", requires-dist = [], requires-python = ">=3.11" },
|
{ name = "waitress", version = "3.0.2", requires-dist = [], requires-python = ">=3.11" },
|
||||||
@@ -49,7 +53,7 @@ dependency-metadata = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
swingmusic = "swingmusic.__main__:main"
|
swingmusic = "swingmusic.__main__:run"
|
||||||
|
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -59,8 +63,7 @@ Documentation = "https://swingmx.com/guide/introduction.html"
|
|||||||
Issues = "https://github.com/swingmx/swingmusic/issues"
|
Issues = "https://github.com/swingmx/swingmusic/issues"
|
||||||
|
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools_scm]
|
||||||
package-dir = {"swingmusic" = "swingmusic"}
|
version_scheme = "only-version"
|
||||||
|
local_scheme = "no-local-version"
|
||||||
[tool.setuptools.package-data]
|
fallback_version = "v0.0.0"
|
||||||
swingmusic = ["client/*"]
|
|
||||||
@@ -1,62 +1,28 @@
|
|||||||
altgraph==0.17.4
|
pillow>=11.1.0
|
||||||
annotated-types==0.7.0
|
Flask>=3.1.0
|
||||||
blinker==1.9.0
|
Flask-Cors>=3.0.10
|
||||||
brotli==1.1.0
|
requests>=2.27.1
|
||||||
certifi==2025.1.31
|
colorgram.py>=1.2.0
|
||||||
charset-normalizer==3.4.1
|
tqdm>=4.65.0
|
||||||
click==8.1.8
|
tinytag>=2.1.1
|
||||||
colorgram-py==1.2.0
|
Unidecode>=1.3.6
|
||||||
configargparse==1.7
|
psutil>=5.9.4
|
||||||
ffmpeg-python==0.2.0
|
show-in-file-manager>=1.1.4
|
||||||
flask==3.1.0
|
flask-compress>=1.13
|
||||||
flask-compress==1.17
|
tabulate>=0.9.0
|
||||||
flask-cors==5.0.1
|
setproctitle>=1.3.2
|
||||||
flask-jwt-extended==4.7.1
|
locust>=2.20.1
|
||||||
flask-login==0.6.3
|
watchdog>=4.0.0
|
||||||
|
flask-jwt-extended>=4.6.0
|
||||||
|
sqlalchemy>=2.0.31
|
||||||
|
memory-profiler>=0.61.0
|
||||||
|
sortedcontainers>=2.4.0
|
||||||
|
xxhash>=3.4.1
|
||||||
|
ffmpeg-python>=0.2.0
|
||||||
|
schedule>=1.2.2
|
||||||
flask-openapi3==3.0.2
|
flask-openapi3==3.0.2
|
||||||
future==1.0.0
|
|
||||||
gevent==24.11.1
|
|
||||||
geventhttpclient==2.3.3
|
|
||||||
greenlet==3.1.1
|
|
||||||
idna==3.10
|
|
||||||
itsdangerous==2.2.0
|
|
||||||
jinja2==3.1.5
|
|
||||||
locust==2.32.10
|
|
||||||
markupsafe==3.0.2
|
|
||||||
memory-profiler==0.61.0
|
|
||||||
msgpack==1.1.0
|
|
||||||
packaging==24.2
|
|
||||||
pendulum==3.0.0
|
|
||||||
pillow==11.1.0
|
|
||||||
psutil==7.0.0
|
|
||||||
pydantic==2.10.6
|
|
||||||
pydantic-core==2.27.2
|
|
||||||
pyinstaller==6.12.0
|
|
||||||
pyinstaller-hooks-contrib==2025.1
|
|
||||||
pyjwt==2.10.1
|
|
||||||
python-dateutil==2.9.0.post0
|
|
||||||
pyxdg==0.28
|
|
||||||
pyzmq==26.2.1
|
|
||||||
rapidfuzz==3.11.0
|
rapidfuzz==3.11.0
|
||||||
requests==2.32.3
|
pendulum>=3.0.0
|
||||||
schedule==1.2.2
|
pystray>=0.19.5
|
||||||
setproctitle==1.3.5
|
waitress==3.0.2; sys_platform == 'win32'
|
||||||
setuptools==75.8.0
|
bjoern>=3.2.2; sys_platform != 'win32'
|
||||||
show-in-file-manager==1.1.5
|
|
||||||
six==1.17.0
|
|
||||||
sortedcontainers==2.4.0
|
|
||||||
sqlalchemy==2.0.38
|
|
||||||
tabulate==0.9.0
|
|
||||||
time-machine==2.16.0
|
|
||||||
tinytag==2.1.1
|
|
||||||
tqdm==4.67.1
|
|
||||||
typing-extensions==4.12.2
|
|
||||||
tzdata==2025.1
|
|
||||||
unidecode==1.3.8
|
|
||||||
urllib3==2.3.0
|
|
||||||
watchdog==6.0.0
|
|
||||||
werkzeug==3.1.3
|
|
||||||
xxhash==3.5.0
|
|
||||||
zope-event==5.0
|
|
||||||
zope-interface==7.2
|
|
||||||
zstandard==0.23.0
|
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
# Launcher script
|
# Launcher script
|
||||||
|
|
||||||
import multiprocessing
|
|
||||||
import swingmusic.__main__ as app
|
import swingmusic.__main__ as app
|
||||||
|
import sys
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# this entry should only be used by pyinstaller.
|
||||||
|
# add freeze support here as pyinstaller uses this entry only
|
||||||
|
|
||||||
|
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||||
|
client = sys._MEIPASS + "/client"
|
||||||
|
sys.argv.extend(["--fallback-client", client])
|
||||||
|
sys.orig_argv.extend(["--fallback-client", client])
|
||||||
|
|
||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
multiprocessing.set_start_method("spawn")
|
|
||||||
app.run()
|
app.run()
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import argparse
|
||||||
|
import pathlib
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
from swingmusic.logger import setup_logger
|
||||||
|
from swingmusic.settings import default_base_path
|
||||||
|
from swingmusic.start_swingmusic import start_swingmusic
|
||||||
|
from swingmusic import tools as swing_tools
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog='swingmusic',
|
||||||
|
description='Awesome Music',
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-v', '--version',
|
||||||
|
action='version',
|
||||||
|
version=f"swingmusic v{version('swingmusic')}")
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default="0.0.0.0",
|
||||||
|
help="Host to run the app on."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
default=1970,
|
||||||
|
help="HTTP port to run the app on.",
|
||||||
|
type=int
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
help="If swingmusic should start in debug mode"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default=default_base_path(),
|
||||||
|
help="Path to the config file.",
|
||||||
|
type=pathlib.Path
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--client",
|
||||||
|
help="Path to the Web UI folder.",
|
||||||
|
type=pathlib.Path
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--fallback-client",
|
||||||
|
help="Path to the Web UI folder if no valid client is found. Used in pyinstaller and appimage.",
|
||||||
|
type=pathlib.Path
|
||||||
|
)
|
||||||
|
|
||||||
|
tools = parser.add_argument_group(
|
||||||
|
title="Tools"
|
||||||
|
)
|
||||||
|
tools.add_argument(
|
||||||
|
"--password-reset",
|
||||||
|
help="Reset the password.",
|
||||||
|
action='store_true'
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
Swing Music entry point
|
||||||
|
"""
|
||||||
|
args = parser.parse_args()
|
||||||
|
args = vars(args)
|
||||||
|
|
||||||
|
path = {
|
||||||
|
"config": args["config"],
|
||||||
|
"client": args["client"],
|
||||||
|
"fallback": args["fallback_client"]
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_logger(debug=args["debug"], app_dir=path["config"])
|
||||||
|
|
||||||
|
|
||||||
|
# check tools
|
||||||
|
if args["password_reset"]:
|
||||||
|
swing_tools.handle_password_reset(path)
|
||||||
|
|
||||||
|
# else start swingmusic
|
||||||
|
else:
|
||||||
|
start_swingmusic(
|
||||||
|
host=args["host"],
|
||||||
|
port=args["port"],
|
||||||
|
path=path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
multiprocessing.set_start_method("spawn")
|
||||||
|
run()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
This module combines all API blueprints into a single Flask app instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from swingmusic.api import (
|
||||||
|
album,
|
||||||
|
artist,
|
||||||
|
collections,
|
||||||
|
colors,
|
||||||
|
favorites,
|
||||||
|
folder,
|
||||||
|
imgserver,
|
||||||
|
playlist,
|
||||||
|
search,
|
||||||
|
settings,
|
||||||
|
lyrics,
|
||||||
|
plugins,
|
||||||
|
scrobble,
|
||||||
|
home,
|
||||||
|
getall,
|
||||||
|
auth,
|
||||||
|
stream,
|
||||||
|
backup_and_restore,
|
||||||
|
)
|
||||||
|
|
||||||
|
from swingmusic.api.plugins import lyrics as lyrics_plugin
|
||||||
|
from swingmusic.api.plugins import mixes as mixes_plugin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"album", "artist", "collections", "colors", "favorites", "folder", "imgserver", "playlist", "search", "settings",
|
||||||
|
"lyrics", "plugins", "scrobble", "home", "getall", "auth", "stream", "backup_and_restore",
|
||||||
|
|
||||||
|
"lyrics_plugin",
|
||||||
|
"mixes_plugin"
|
||||||
|
]
|
||||||
@@ -69,7 +69,7 @@ def backup():
|
|||||||
playlist_dicts.append(playlist)
|
playlist_dicts.append(playlist)
|
||||||
|
|
||||||
# copy images
|
# copy images
|
||||||
img_path = Path(Paths.get_playlist_img_path()) / str(playlist["image"])
|
img_path = Path(Paths().playlist_img_path) / str(playlist["image"])
|
||||||
if img_path.exists():
|
if img_path.exists():
|
||||||
if not img_folder_created:
|
if not img_folder_created:
|
||||||
img_folder.mkdir(parents=True)
|
img_folder.mkdir(parents=True)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Contains all the folder routes.
|
Contains all the folder routes.
|
||||||
"""
|
"""
|
||||||
|
import pathlib
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -19,7 +19,7 @@ from swingmusic.db.userdata import FavoritesTable, PlaylistTable
|
|||||||
from swingmusic.lib.folderslib import get_files_and_dirs, get_folders
|
from swingmusic.lib.folderslib import get_files_and_dirs, get_folders
|
||||||
from swingmusic.serializers.track import serialize_track, serialize_tracks
|
from swingmusic.serializers.track import serialize_track, serialize_tracks
|
||||||
from swingmusic.store.tracks import TrackStore
|
from swingmusic.store.tracks import TrackStore
|
||||||
from swingmusic.utils.wintools import is_windows, win_replace_slash
|
from swingmusic.utils.wintools import is_windows
|
||||||
|
|
||||||
tag = Tag(name="Folders", description="Get folders and tracks in a directory")
|
tag = Tag(name="Folders", description="Get folders and tracks in a directory")
|
||||||
api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
|
api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
|
||||||
@@ -83,16 +83,10 @@ def get_folder_tree(body: FolderTree):
|
|||||||
config = UserConfig()
|
config = UserConfig()
|
||||||
root_dirs = config.rootDirs
|
root_dirs = config.rootDirs
|
||||||
|
|
||||||
try:
|
if req_dir == "$home" and "$home" in root_dirs:
|
||||||
if req_dir == "$home" and root_dirs[0] == "$home":
|
req_dir = settings.Paths().USER_HOME_DIR.as_posix()
|
||||||
req_dir = settings.Paths.USER_HOME_DIR
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if req_dir == "$home":
|
if req_dir == "$home":
|
||||||
if len(root_dirs) == 1:
|
|
||||||
req_dir = root_dirs[0]
|
|
||||||
else:
|
|
||||||
folders = get_folders(root_dirs)
|
folders = get_folders(root_dirs)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -148,16 +142,14 @@ def get_folder_tree(body: FolderTree):
|
|||||||
"path": req_dir,
|
"path": req_dir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_windows():
|
# TODO: currently only fixed on unix. Windows/Mac still pending.
|
||||||
# Trailing slash needed when drive letters are passed,
|
# note
|
||||||
# Remember, the trailing slash is removed in the client.
|
|
||||||
# req_dir += "/"
|
if not pathlib.Path(req_dir).exists():
|
||||||
pass
|
req_dir = "/" + req_dir
|
||||||
else:
|
|
||||||
req_dir = "/" + req_dir if not req_dir.startswith("/") else req_dir
|
|
||||||
|
|
||||||
results = get_files_and_dirs(
|
results = get_files_and_dirs(
|
||||||
req_dir,
|
pathlib.Path(req_dir),
|
||||||
start=body.start,
|
start=body.start,
|
||||||
limit=body.limit,
|
limit=body.limit,
|
||||||
tracks_only=tracks_only,
|
tracks_only=tracks_only,
|
||||||
@@ -236,21 +228,34 @@ def list_folders(body: DirBrowserBody):
|
|||||||
"folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)]
|
"folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)]
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_win:
|
|
||||||
req_dir += "/"
|
req_dir = pathlib.Path(req_dir)
|
||||||
else:
|
|
||||||
req_dir = "/" + req_dir + "/"
|
if not req_dir.exists():
|
||||||
req_dir = str(Path(req_dir).resolve())
|
req_dir = "/" / req_dir
|
||||||
|
|
||||||
try:
|
try:
|
||||||
entries = os.scandir(req_dir)
|
entries = os.scandir(req_dir)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return {"folders": []}
|
return {"folders": []}
|
||||||
|
|
||||||
dirs = [e.name for e in entries if e.is_dir() and not e.name.startswith(".")]
|
# only get dirs and remove hidden dirs
|
||||||
dirs = [
|
dirs = []
|
||||||
{"name": d, "path": win_replace_slash(os.path.join(req_dir, d))} for d in dirs
|
for entry in entries:
|
||||||
]
|
entry = pathlib.Path(entry)
|
||||||
|
name = entry.name
|
||||||
|
|
||||||
|
if name.startswith("$"): # ignore windows system folder
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name.startswith("."): # ignore unix hidden folder
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.is_dir(): # lastly, check if is dir
|
||||||
|
dirs.append({
|
||||||
|
"name": name,
|
||||||
|
"path": entry.as_posix()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"folders": sorted(dirs, key=lambda i: i["name"]),
|
"folders": sorted(dirs, key=lambda i: i["name"]),
|
||||||
@@ -23,7 +23,7 @@ def cache_thumbnails(filepath: Path, trackhash: str):
|
|||||||
Resizes the image and stores it in the cache directory.
|
Resizes the image and stores it in the cache directory.
|
||||||
"""
|
"""
|
||||||
image = Image.open(filepath)
|
image = Image.open(filepath)
|
||||||
path = Path(Paths.get_image_cache_path())
|
path = Path(Paths().image_cache_path)
|
||||||
aspect_ratio = image.width / image.height
|
aspect_ratio = image.width / image.height
|
||||||
|
|
||||||
sizes = {
|
sizes = {
|
||||||
@@ -89,7 +89,7 @@ def send_fallback_img(filename: str = "default.webp"):
|
|||||||
"""
|
"""
|
||||||
Returns the fallback image from the assets folder.
|
Returns the fallback image from the assets folder.
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_assets_path()
|
folder = Paths().assets_path
|
||||||
img = Path(folder) / filename
|
img = Path(folder) / filename
|
||||||
|
|
||||||
if not img.exists():
|
if not img.exists():
|
||||||
@@ -111,7 +111,7 @@ def send_file_or_fallback(
|
|||||||
|
|
||||||
if pathhash != "":
|
if pathhash != "":
|
||||||
# INFO: Check if the image is in the cache
|
# INFO: Check if the image is in the cache
|
||||||
cache_path = Path(Paths.get_image_cache_path()) / fpath.parent.name / filename
|
cache_path = Paths().image_cache_path / fpath.parent.name / filename
|
||||||
if cache_path.exists():
|
if cache_path.exists():
|
||||||
return send_from_directory(cache_path.parent, cache_path.name)
|
return send_from_directory(cache_path.parent, cache_path.name)
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ def send_lg_thumbnail(path: ImagePath, query: ImageQuery):
|
|||||||
"""
|
"""
|
||||||
Get large thumbnail (500 x 500)
|
Get large thumbnail (500 x 500)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_lg_thumb_path()
|
folder = Paths().lg_thumb_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ def send_xsm_thumbnail(path: ImagePath, query: ImageQuery):
|
|||||||
"""
|
"""
|
||||||
Get extra small thumbnail (64px)
|
Get extra small thumbnail (64px)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_xsm_thumb_path()
|
folder = Paths().xsm_thumb_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ def send_sm_thumbnail(path: ImagePath, query: ImageQuery):
|
|||||||
"""
|
"""
|
||||||
Get small thumbnail (96px)
|
Get small thumbnail (96px)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_sm_thumb_path()
|
folder = Paths().sm_thumb_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ def send_md_thumbnail(path: ImagePath, query: ImageQuery):
|
|||||||
"""
|
"""
|
||||||
Get medium thumbnail (256px)
|
Get medium thumbnail (256px)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_md_thumb_path()
|
folder = Paths().md_thumb_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
@@ -199,8 +199,8 @@ def send_lg_artist_image(path: ImagePath):
|
|||||||
"""
|
"""
|
||||||
Get large artist image (500 x 500)
|
Get large artist image (500 x 500)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_lg_artist_img_path()
|
folder = Paths().lg_artist_img_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, "artist.webp")
|
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
|
||||||
|
|
||||||
|
|
||||||
@api.get("/artist/small/<imgpath>")
|
@api.get("/artist/small/<imgpath>")
|
||||||
@@ -208,8 +208,8 @@ def send_sm_artist_image(path: ImagePath):
|
|||||||
"""
|
"""
|
||||||
Get small artist image (128)
|
Get small artist image (128)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_sm_artist_img_path()
|
folder = Paths().sm_artist_img_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, "artist.webp")
|
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
|
||||||
|
|
||||||
|
|
||||||
@api.get("/artist/medium/<imgpath>")
|
@api.get("/artist/medium/<imgpath>")
|
||||||
@@ -217,7 +217,7 @@ def send_md_artist_image(path: ImagePath):
|
|||||||
"""
|
"""
|
||||||
Get medium artist image (256px)
|
Get medium artist image (256px)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_md_artist_img_path()
|
folder = Paths().md_artist_img_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, "artist.webp")
|
return send_file_or_fallback(folder, path.imgpath, "artist.webp")
|
||||||
|
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ def send_playlist_image(path: PlaylistImagePath):
|
|||||||
|
|
||||||
Images are constructed as '{playlist_id}.webp'
|
Images are constructed as '{playlist_id}.webp'
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_playlist_img_path()
|
folder = Paths().playlist_img_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
||||||
|
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ def send_md_mix_image(path: ImagePath):
|
|||||||
"""
|
"""
|
||||||
Get medium mix image
|
Get medium mix image
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_md_mixes_img_path()
|
folder = Paths().md_mixes_img_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
||||||
|
|
||||||
|
|
||||||
@@ -255,5 +255,5 @@ def send_sm_mix_image(path: ImagePath):
|
|||||||
"""
|
"""
|
||||||
Get small mix image
|
Get small mix image
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_sm_mixes_img_path()
|
folder = Paths().sm_mixes_img_path
|
||||||
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from flask_openapi3 import Tag
|
||||||
|
from flask_openapi3 import APIBlueprint
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
from swingmusic.api.apischemas import TrackHashSchema
|
||||||
|
from swingmusic.lib.lyrics import (
|
||||||
|
get_lyrics_file,
|
||||||
|
get_lyrics_from_duplicates,
|
||||||
|
get_lyrics_from_tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Lyrics", description="Get lyrics")
|
||||||
|
api = APIBlueprint("lyrics", __name__, url_prefix="/lyrics", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
class SendLyricsBody(TrackHashSchema):
|
||||||
|
filepath: str = Field(description="The path to the file")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("")
|
||||||
|
def send_lyrics(body: SendLyricsBody):
|
||||||
|
"""
|
||||||
|
Returns the lyrics for a track
|
||||||
|
"""
|
||||||
|
# 1. try to get lyrics by .lrc / .elrc file
|
||||||
|
# 2. try to get lyrics by extra key
|
||||||
|
# 3. try to get by duplicates
|
||||||
|
# 4. iter plugins
|
||||||
|
|
||||||
|
filepath = body.filepath
|
||||||
|
trackhash = body.trackhash
|
||||||
|
|
||||||
|
# get copyright first
|
||||||
|
copyright = ""
|
||||||
|
if entry:=TrackStore.trackhashmap.get(trackhash, None):
|
||||||
|
for track in entry.tracks:
|
||||||
|
copyright = track.copyright
|
||||||
|
|
||||||
|
if copyright:
|
||||||
|
break
|
||||||
|
|
||||||
|
lyrics = get_lyrics_file(filepath)
|
||||||
|
|
||||||
|
if not lyrics:
|
||||||
|
lyrics = get_lyrics_from_tags(trackhash) # type: ignore
|
||||||
|
|
||||||
|
if not lyrics:
|
||||||
|
lyrics = get_lyrics_from_duplicates(filepath, trackhash)
|
||||||
|
|
||||||
|
|
||||||
|
# check lyrics plugins
|
||||||
|
|
||||||
|
if not lyrics:
|
||||||
|
return {"error": "No lyrics found"}
|
||||||
|
|
||||||
|
if lyrics.is_synced:
|
||||||
|
text = lyrics.format_synced_lyrics()
|
||||||
|
else:
|
||||||
|
text = lyrics.format_unsynced_lyrics()
|
||||||
|
|
||||||
|
return {"lyrics": text, "synced": lyrics.is_synced, "copyright": copyright}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/check")
|
||||||
|
def check_lyrics(body: SendLyricsBody):
|
||||||
|
"""
|
||||||
|
Checks if lyrics file or tag exists for a track
|
||||||
|
"""
|
||||||
|
result = send_lyrics(body)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
return {"exists": False}
|
||||||
|
else:
|
||||||
|
return {"exists": True}, 200
|
||||||
|
|
||||||
|
|
||||||
@@ -177,11 +177,13 @@ def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
|
|||||||
"""
|
"""
|
||||||
itemtype = body.itemtype
|
itemtype = body.itemtype
|
||||||
itemhash = body.itemhash
|
itemhash = body.itemhash
|
||||||
playlist_id = path.playlistid
|
playlist_id = int(path.playlistid)
|
||||||
sortoptions = body.sortoptions
|
sortoptions = body.sortoptions
|
||||||
|
|
||||||
if itemtype == "tracks":
|
if itemtype == "tracks":
|
||||||
trackhashes = itemhash.split(",")
|
trackhashes = itemhash.split(",")
|
||||||
|
if len(trackhashes) == 1 and trackhashes[0] in PlaylistTable.get_trackhashes(playlist_id):
|
||||||
|
return {"msg": "Track already exists in playlist"}, 409
|
||||||
elif itemtype == "folder":
|
elif itemtype == "folder":
|
||||||
trackhashes = get_path_trackhashes(
|
trackhashes = get_path_trackhashes(
|
||||||
itemhash,
|
itemhash,
|
||||||
@@ -195,7 +197,7 @@ def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
|
|||||||
else:
|
else:
|
||||||
trackhashes = []
|
trackhashes = []
|
||||||
|
|
||||||
PlaylistTable.append_to_playlist(int(playlist_id), trackhashes)
|
PlaylistTable.append_to_playlist(playlist_id, trackhashes)
|
||||||
return {"msg": "Done"}, 200
|
return {"msg": "Done"}, 200
|
||||||
|
|
||||||
|
|
||||||
@@ -461,9 +463,9 @@ def save_item_as_playlist(body: SavePlaylistAsItemBody):
|
|||||||
filename = itemhash + ".webp"
|
filename = itemhash + ".webp"
|
||||||
|
|
||||||
base_path = (
|
base_path = (
|
||||||
Paths.get_lg_artist_img_path()
|
Paths().lg_artist_img_path
|
||||||
if itemtype == "artist"
|
if itemtype == "artist"
|
||||||
else Paths.get_lg_thumb_path()
|
else Paths().lg_thumb_path()
|
||||||
)
|
)
|
||||||
img_path = pathlib.Path(base_path + "/" + filename)
|
img_path = pathlib.Path(base_path + "/" + filename)
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ from flask_openapi3 import Tag
|
|||||||
from flask_openapi3 import APIBlueprint
|
from flask_openapi3 import APIBlueprint
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from swingmusic.api.apischemas import TrackHashSchema
|
from swingmusic.api.apischemas import TrackHashSchema
|
||||||
from swingmusic.lib.lyrics import format_synced_lyrics
|
from swingmusic.lib.lyrics import Lyrics as Lyrics_class
|
||||||
|
|
||||||
from swingmusic.plugins.lyrics import Lyrics
|
from swingmusic.plugins.lyrics import Lyrics
|
||||||
from swingmusic.settings import Defaults
|
from swingmusic.settings import Defaults
|
||||||
@@ -47,18 +47,14 @@ def search_lyrics(body: LyricsSearchBody):
|
|||||||
i_title = track["title"]
|
i_title = track["title"]
|
||||||
i_album = track["album"]
|
i_album = track["album"]
|
||||||
|
|
||||||
if create_hash(i_title) == create_hash(title) and create_hash(
|
if create_hash(i_title) == create_hash(title) and create_hash(i_album) == create_hash(album):
|
||||||
i_album
|
|
||||||
) == create_hash(album):
|
|
||||||
perfect_match = track
|
perfect_match = track
|
||||||
|
|
||||||
track_id = perfect_match["track_id"]
|
track_id = perfect_match["track_id"]
|
||||||
lrc = finder.download_lyrics(track_id, filepath)
|
lrc = finder.download_lyrics(track_id, filepath)
|
||||||
|
|
||||||
if lrc is not None:
|
if lrc is not None:
|
||||||
lines = lrc.split("\n")
|
lyrics = Lyrics_class(lrc)
|
||||||
lyrics = format_synced_lyrics(lines)
|
return {"trackhash": trackhash, "lyrics": lyrics.format_synced_lyrics()}, 200
|
||||||
|
|
||||||
return {"trackhash": trackhash, "lyrics": lyrics}, 200
|
|
||||||
|
|
||||||
return {"trackhash": trackhash, "lyrics": lrc}, 200
|
return {"trackhash": trackhash, "lyrics": lrc}, 200
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
from importlib import metadata
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from flask_openapi3 import Tag
|
from flask_openapi3 import Tag
|
||||||
from flask_openapi3 import APIBlueprint
|
from flask_openapi3 import APIBlueprint
|
||||||
@@ -7,7 +8,6 @@ from swingmusic.api.auth import admin_required
|
|||||||
|
|
||||||
from swingmusic.db.userdata import PluginTable
|
from swingmusic.db.userdata import PluginTable
|
||||||
from swingmusic.lib.index import index_everything
|
from swingmusic.lib.index import index_everything
|
||||||
from swingmusic.settings import Info
|
|
||||||
from swingmusic.config import UserConfig
|
from swingmusic.config import UserConfig
|
||||||
from swingmusic.utils.auth import get_current_userid
|
from swingmusic.utils.auth import get_current_userid
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ def get_all_settings():
|
|||||||
config[key] = sorted(list(value))
|
config[key] = sorted(list(value))
|
||||||
|
|
||||||
config["plugins"] = [p for p in PluginTable.get_all()]
|
config["plugins"] = [p for p in PluginTable.get_all()]
|
||||||
config["version"] = Info.SWINGMUSIC_APP_VERSION
|
config["version"] = metadata.version("swingmusic")
|
||||||
|
|
||||||
# only return lastfmSessionKey for the current user
|
# only return lastfmSessionKey for the current user
|
||||||
current_user = get_current_userid()
|
current_user = get_current_userid()
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
from importlib import metadata
|
||||||
|
import datetime as dt
|
||||||
|
import pathlib
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Response, request
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_compress import Compress
|
||||||
|
from flask_openapi3 import Info
|
||||||
|
from flask_openapi3 import OpenAPI
|
||||||
|
from flask_jwt_extended import JWTManager, create_access_token, get_jwt, get_jwt_identity, set_access_cookies, verify_jwt_in_request
|
||||||
|
|
||||||
|
from swingmusic import api as swing_api
|
||||||
|
from swingmusic.config import UserConfig
|
||||||
|
from swingmusic.db.userdata import UserTable
|
||||||
|
from swingmusic.settings import Paths
|
||||||
|
from swingmusic.utils.paths import get_client_files_extensions
|
||||||
|
|
||||||
|
from swingmusic.api.plugins import lyrics as lyrics_plugin
|
||||||
|
from swingmusic.api.plugins import mixes as mixes_plugin
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
# # # # # # # # # # # # # # # # # #
|
||||||
|
# Grouped configuration function #
|
||||||
|
# # # # # # # # # # # # # # # # # #
|
||||||
|
|
||||||
|
def config_app(web):
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS(web, origins="*", supports_credentials=True)
|
||||||
|
|
||||||
|
# RESPONSE COMPRESSION
|
||||||
|
# Only compress JSON responses
|
||||||
|
Compress(web)
|
||||||
|
web.config["COMPRESS_MIMETYPES"] = [
|
||||||
|
"application/json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def config_jwt(web):
|
||||||
|
# JWT CONFIGS
|
||||||
|
web.config["JWT_VERIFY_SUB"] = False
|
||||||
|
web.config["JWT_SECRET_KEY"] = UserConfig().serverId
|
||||||
|
web.config["JWT_TOKEN_LOCATION"] = ["cookies", "headers"]
|
||||||
|
web.config["JWT_COOKIE_CSRF_PROTECT"] = False
|
||||||
|
web.config["JWT_SESSION_COOKIE"] = False
|
||||||
|
|
||||||
|
jwt_expiry = int(dt.timedelta(days=30).total_seconds())
|
||||||
|
web.config["JWT_ACCESS_TOKEN_EXPIRES"] = jwt_expiry
|
||||||
|
|
||||||
|
jwt = JWTManager(web)
|
||||||
|
|
||||||
|
@jwt.user_lookup_loader
|
||||||
|
def user_lookup_callback(_jwt_header, jwt_data):
|
||||||
|
identity = jwt_data["sub"]
|
||||||
|
userid = identity["id"]
|
||||||
|
user = UserTable.get_by_id(userid)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
return user.todict()
|
||||||
|
|
||||||
|
|
||||||
|
def load_endpoints(web):
|
||||||
|
# Register all the API blueprints
|
||||||
|
with web.app_context():
|
||||||
|
web.register_api(swing_api.album.api)
|
||||||
|
web.register_api(swing_api.artist.api)
|
||||||
|
web.register_api(swing_api.stream.api)
|
||||||
|
web.register_api(swing_api.search.api)
|
||||||
|
web.register_api(swing_api.folder.api)
|
||||||
|
web.register_api(swing_api.playlist.api)
|
||||||
|
web.register_api(swing_api.favorites.api)
|
||||||
|
web.register_api(swing_api.imgserver.api)
|
||||||
|
web.register_api(swing_api.settings.api)
|
||||||
|
web.register_api(swing_api.colors.api)
|
||||||
|
web.register_api(swing_api.lyrics.api)
|
||||||
|
web.register_api(swing_api.backup_and_restore.api)
|
||||||
|
web.register_api(swing_api.collections.api)
|
||||||
|
|
||||||
|
# Logger
|
||||||
|
web.register_api(swing_api.scrobble.api)
|
||||||
|
|
||||||
|
# Home
|
||||||
|
web.register_api(swing_api.home.api)
|
||||||
|
web.register_api(swing_api.getall.api)
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
web.register_api(swing_api.auth.api)
|
||||||
|
|
||||||
|
|
||||||
|
def load_plugins(web):
|
||||||
|
# TODO: rework plugin support
|
||||||
|
# Plugins
|
||||||
|
web.register_api(swing_api.plugins.api)
|
||||||
|
web.register_api(lyrics_plugin.api)
|
||||||
|
web.register_api(mixes_plugin.api)
|
||||||
|
|
||||||
|
|
||||||
|
# # # # # # # # # # #
|
||||||
|
# Create App object #
|
||||||
|
# # # # # # # # # # #
|
||||||
|
|
||||||
|
api_info = Info(
|
||||||
|
title="Swing Music",
|
||||||
|
version=f"v{metadata.version('swingmusic')}",
|
||||||
|
description="The REST API exposed by your Swing Music server",
|
||||||
|
)
|
||||||
|
|
||||||
|
app = OpenAPI(__name__, info=api_info, doc_prefix="/docs")
|
||||||
|
|
||||||
|
|
||||||
|
def check_auth_need() -> bool:
|
||||||
|
"""
|
||||||
|
Check if the current request is for a static file.
|
||||||
|
We do not need auth for index or static images of index.
|
||||||
|
|
||||||
|
:return: True if static file else False
|
||||||
|
"""
|
||||||
|
|
||||||
|
# INFO: Routes that don't need authentication
|
||||||
|
urls = {
|
||||||
|
"/auth/login",
|
||||||
|
"/auth/users",
|
||||||
|
"/auth/pair",
|
||||||
|
"/auth/logout",
|
||||||
|
"/auth/refresh",
|
||||||
|
"/docs",
|
||||||
|
}
|
||||||
|
files = {
|
||||||
|
".webp",
|
||||||
|
".jpg",
|
||||||
|
*get_client_files_extensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
urls = tuple(urls)
|
||||||
|
files = tuple(files)
|
||||||
|
|
||||||
|
if request.path == "/" or request.path.endswith(files):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# if request path starts with any of the blacklisted routes, don't verify jwt
|
||||||
|
if request.path.startswith(urls):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
# # # # # # # # # # # # #
|
||||||
|
# global endpoint logic #
|
||||||
|
# # # # # # # # # # # # #
|
||||||
|
|
||||||
|
@app.route("/<path:path>")
|
||||||
|
def serve_client_files(path: str):
|
||||||
|
"""
|
||||||
|
Serves the static files in the client folder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: rule out possible double /client path.
|
||||||
|
# path sometimes prepended with /client like '/client/some.js' resolves to '/client/client/some.js'
|
||||||
|
|
||||||
|
js_or_css = path.endswith(".js") or path.endswith(".css")
|
||||||
|
|
||||||
|
if not js_or_css:
|
||||||
|
return app.send_static_file(path)
|
||||||
|
|
||||||
|
# INFO: Safari doesn't support gzip encoding
|
||||||
|
# See issue: https://github.com/swingmx/swingmusic/issues/155
|
||||||
|
user_agent = request.headers.get("User-Agent", "")
|
||||||
|
if "Safari" in user_agent and not "Chrome" in user_agent:
|
||||||
|
return app.send_static_file(path)
|
||||||
|
|
||||||
|
if "gzip" in request.headers.get("Accept-Encoding", ""):
|
||||||
|
gz_name = path + ".gz"
|
||||||
|
gzipped_path = pathlib.Path(app.static_folder or "") / gz_name
|
||||||
|
|
||||||
|
if gzipped_path.exists():
|
||||||
|
response = app.make_response(app.send_static_file(gz_name))
|
||||||
|
response.headers["Content-Encoding"] = "gzip"
|
||||||
|
return response
|
||||||
|
|
||||||
|
return app.send_static_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def serve_client():
|
||||||
|
"""
|
||||||
|
Serves the index.html file at `client/index.html`.
|
||||||
|
"""
|
||||||
|
return app.send_static_file("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
def build() -> OpenAPI:
|
||||||
|
"""
|
||||||
|
Call this function to obtain the final flask/openapi object.
|
||||||
|
|
||||||
|
Do not import app directly as the static_folder can only be set
|
||||||
|
when cli args are parsed.
|
||||||
|
|
||||||
|
:return: OpenApi object with all config set
|
||||||
|
"""
|
||||||
|
|
||||||
|
# set late state config
|
||||||
|
app.static_folder = Paths().client_path
|
||||||
|
log.info(f"Serving client from '{app.static_folder}'")
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def verify_auth():
|
||||||
|
"""
|
||||||
|
Verifies the JWT token before each request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if check_auth_need():
|
||||||
|
return
|
||||||
|
|
||||||
|
verify_jwt_in_request()
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def refresh_expiring_jwt(response: Response):
|
||||||
|
"""
|
||||||
|
Refreshes the cookies JWT token after each request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# INFO: If the request has an Authorization header, don't refresh the jwt
|
||||||
|
# Request is probably from the mobile client or a third party
|
||||||
|
if check_auth_need() or request.headers.get("Authorization"):
|
||||||
|
return response
|
||||||
|
|
||||||
|
try:
|
||||||
|
exp_timestamp = get_jwt()["exp"]
|
||||||
|
until = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=7)
|
||||||
|
|
||||||
|
if until.timestamp() > exp_timestamp:
|
||||||
|
access_token = create_access_token(identity=get_jwt_identity())
|
||||||
|
set_access_cookies(response, access_token)
|
||||||
|
|
||||||
|
return response
|
||||||
|
except (RuntimeError, KeyError):
|
||||||
|
return response
|
||||||
|
|
||||||
|
config_app(app)
|
||||||
|
config_jwt(app)
|
||||||
|
load_endpoints(app)
|
||||||
|
load_plugins(app)
|
||||||
|
|
||||||
|
return app
|
||||||
|
Before Width: | Height: | Size: 654 B After Width: | Height: | Size: 654 B |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 737 B After Width: | Height: | Size: 737 B |
@@ -1,32 +1,34 @@
|
|||||||
from dataclasses import dataclass, asdict, field
|
import importlib.resources
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from .settings import Paths
|
from dataclasses import dataclass, asdict, field, InitVar
|
||||||
|
from swingmusic.settings import Paths, Singleton
|
||||||
# TODO: Publish this on PyPi
|
|
||||||
|
|
||||||
|
|
||||||
def load_artist_ignore_list_from_file(filepath: Path) -> set[str]:
|
def load_artist_ignore_list_from_file(filepath: Path) -> set[str]:
|
||||||
"""
|
"""
|
||||||
Loads artist names from a text file.
|
Loads artist names from a text file.
|
||||||
Returns an empty set if the file doesn't exist.
|
|
||||||
|
:params filepath: filepath to file
|
||||||
|
:returns: Lines with content as ``set``, else empty ``set``
|
||||||
"""
|
"""
|
||||||
try:
|
if filepath.exists():
|
||||||
return {
|
text = filepath.read_text()
|
||||||
line.strip() for line in filepath.read_text().splitlines() if line.strip()
|
return set([ line.strip() for line in text.splitlines() if line.strip() ])
|
||||||
}
|
else:
|
||||||
except FileNotFoundError:
|
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
|
|
||||||
def load_default_artist_ignore_list() -> set[str]:
|
def load_default_artist_ignore_list() -> set[str]:
|
||||||
"""
|
"""
|
||||||
Loads the default artist ignore list from the text file.
|
Loads the default artist-ignore-list from the text file.
|
||||||
Returns an empty set if the file doesn't exist.
|
Returns an empty set if the file doesn't exist.
|
||||||
"""
|
"""
|
||||||
default_file = Path(__file__).parent / "data" / "artist_split_ignore.txt"
|
text = importlib.resources.read_text("swingmusic.data","artist_split_ignore.txt")
|
||||||
return load_artist_ignore_list_from_file(default_file)
|
# only return unique and not empty lines
|
||||||
|
lines = text.splitlines()
|
||||||
|
return set([ line.strip() for line in lines if line.strip() ])
|
||||||
|
|
||||||
|
|
||||||
def load_user_artist_ignore_list() -> set[str]:
|
def load_user_artist_ignore_list() -> set[str]:
|
||||||
@@ -34,14 +36,19 @@ def load_user_artist_ignore_list() -> set[str]:
|
|||||||
Loads the user-defined artist ignore list from the config directory.
|
Loads the user-defined artist ignore list from the config directory.
|
||||||
Returns an empty set if the file doesn't exist.
|
Returns an empty set if the file doesn't exist.
|
||||||
"""
|
"""
|
||||||
user_file = Path(Paths.get_config_file_path()).parent / "artist_split_ignore.txt"
|
user_file = Paths().app_dir / "artist_split_ignore.txt"
|
||||||
return load_artist_ignore_list_from_file(user_file)
|
if user_file.exists():
|
||||||
|
lines = user_file.read_text().splitlines()
|
||||||
|
return set([ line.strip() for line in lines if line.strip()])
|
||||||
|
else:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UserConfig:
|
class UserConfig(metaclass=Singleton):
|
||||||
_config_path: str = ""
|
_finished: bool = field(default=False, init=False) # if post init succesfully
|
||||||
_artist_split_ignore_file_name: str = "artist_split_ignore.txt"
|
_config_path: InitVar[Path] = Path("")
|
||||||
|
_artist_split_ignore_file_name: InitVar[str] = "artist_split_ignore.txt"
|
||||||
# NOTE: only auth stuff are used (the others are still reading/writing to db)
|
# NOTE: only auth stuff are used (the others are still reading/writing to db)
|
||||||
# TODO: Move the rest of the settings to the config file
|
# TODO: Move the rest of the settings to the config file
|
||||||
|
|
||||||
@@ -84,16 +91,17 @@ class UserConfig:
|
|||||||
lastfmApiSecret: str = "5e5306fbf3e8e3bc92f039b6c6c4bd4e"
|
lastfmApiSecret: str = "5e5306fbf3e8e3bc92f039b6c6c4bd4e"
|
||||||
lastfmSessionKeys: dict[str, str] = field(default_factory=dict)
|
lastfmSessionKeys: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
|
def __post_init__(self, _config_path, _artist_split_ignore_file_name):
|
||||||
"""
|
"""
|
||||||
Loads the config file and sets the values to this instance
|
Loads the config file and sets the values to this instance
|
||||||
"""
|
"""
|
||||||
# set config path locally to avoid writing to file
|
# set config path locally to avoid writing to file
|
||||||
config_path = Paths.get_config_file_path()
|
config_path = Paths().config_file_path
|
||||||
|
|
||||||
try:
|
if config_path.exists():
|
||||||
config = self.load_config(config_path)
|
config = self.load_config(config_path)
|
||||||
except FileNotFoundError:
|
else:
|
||||||
self._config_path = config_path
|
self._config_path = config_path
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -107,8 +115,10 @@ class UserConfig:
|
|||||||
else:
|
else:
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
|
|
||||||
# finally set the config path
|
# finally, set the config path
|
||||||
self._config_path = config_path
|
self._config_path = config_path
|
||||||
|
self._finished = True
|
||||||
|
|
||||||
|
|
||||||
def setup_config_file(self) -> None:
|
def setup_config_file(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -116,18 +126,18 @@ class UserConfig:
|
|||||||
if it doesn't exist
|
if it doesn't exist
|
||||||
"""
|
"""
|
||||||
# if not exists, create the config file
|
# if not exists, create the config file
|
||||||
if not Path(self._config_path).exists():
|
config = Path(self._config_path)
|
||||||
|
if not config.exists():
|
||||||
self.write_to_file(asdict(self))
|
self.write_to_file(asdict(self))
|
||||||
|
|
||||||
def load_config(self, path: str) -> dict[str, Any]:
|
|
||||||
|
def load_config(self, path: Path) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Reads the settings from the config file.
|
Reads the settings from the config file.
|
||||||
Returns a dictget_root_dirs
|
Returns a dictget_root_dirs
|
||||||
"""
|
"""
|
||||||
with open(path, "r") as f:
|
return json.loads(path.read_text())
|
||||||
settings = json.load(f)
|
|
||||||
|
|
||||||
return settings
|
|
||||||
|
|
||||||
def write_to_file(self, settings: dict[str, Any]):
|
def write_to_file(self, settings: dict[str, Any]):
|
||||||
"""
|
"""
|
||||||
@@ -136,13 +146,21 @@ class UserConfig:
|
|||||||
# remove internal attributes
|
# remove internal attributes
|
||||||
settings = {k: v for k, v in settings.items() if not k.startswith("_")}
|
settings = {k: v for k, v in settings.items() if not k.startswith("_")}
|
||||||
|
|
||||||
with open(self._config_path, "w") as f:
|
with self._config_path.open(mode="w") as f:
|
||||||
json.dump(settings, f, indent=4, default=list)
|
json.dump(settings, f, indent=4, default=list)
|
||||||
|
|
||||||
|
|
||||||
def __setattr__(self, key: str, value: Any) -> None:
|
def __setattr__(self, key: str, value: Any) -> None:
|
||||||
"""
|
"""
|
||||||
Writes to the config file whenever a value is set
|
Writes to the config file whenever a value is set
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# protection.
|
||||||
|
# only write to file if post_init completed
|
||||||
|
if not self._finished:
|
||||||
|
super().__setattr__(key, value)
|
||||||
|
return
|
||||||
|
|
||||||
super().__setattr__(key, value)
|
super().__setattr__(key, value)
|
||||||
|
|
||||||
# if is internal attribute, don't write to file
|
# if is internal attribute, don't write to file
|
||||||
@@ -2,7 +2,7 @@ from contextlib import contextmanager
|
|||||||
from sqlalchemy import Engine, create_engine, event
|
from sqlalchemy import Engine, create_engine, event
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from swingmusic.settings import DbPaths
|
from swingmusic.settings import Paths
|
||||||
|
|
||||||
|
|
||||||
@event.listens_for(Engine, "connect")
|
@event.listens_for(Engine, "connect")
|
||||||
@@ -38,7 +38,7 @@ class DbEngine:
|
|||||||
def engine(cls) -> Engine:
|
def engine(cls) -> Engine:
|
||||||
if not cls._engine:
|
if not cls._engine:
|
||||||
cls._engine = create_engine(
|
cls._engine = create_engine(
|
||||||
f"sqlite+pysqlite:///{DbPaths.get_app_db_path()}",
|
f"sqlite+pysqlite:///{Paths().app_db_path}",
|
||||||
echo=False,
|
echo=False,
|
||||||
max_overflow=20,
|
max_overflow=20,
|
||||||
pool_size=10,
|
pool_size=10,
|
||||||
@@ -66,7 +66,14 @@ class TrackTable(Base):
|
|||||||
.where(TrackTable.filepath.contains(path))
|
.where(TrackTable.filepath.contains(path))
|
||||||
.order_by(TrackTable.last_mod)
|
.order_by(TrackTable.last_mod)
|
||||||
)
|
)
|
||||||
return tracks_to_dataclasses(result.fetchall())
|
|
||||||
|
clean = []
|
||||||
|
for row in result.fetchall():
|
||||||
|
d = row[0].__dict__
|
||||||
|
del d["_sa_instance_state"]
|
||||||
|
clean.append(d)
|
||||||
|
|
||||||
|
return tracks_to_dataclasses(clean)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
|
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
|
||||||
@@ -90,10 +90,10 @@ class SQLiteManager:
|
|||||||
if self.test_db_path:
|
if self.test_db_path:
|
||||||
db_path = self.test_db_path
|
db_path = self.test_db_path
|
||||||
else:
|
else:
|
||||||
db_path = settings.DbPaths.get_app_db_path()
|
db_path = settings.Paths().app_db_path
|
||||||
|
|
||||||
if self.userdata_db:
|
if self.userdata_db:
|
||||||
db_path = settings.DbPaths.get_userdata_db_path()
|
db_path = settings.Paths().userdata_db_path
|
||||||
|
|
||||||
self.conn = sqlite3.connect(
|
self.conn = sqlite3.connect(
|
||||||
db_path,
|
db_path,
|
||||||
@@ -434,15 +434,14 @@ class PlaylistTable(Base):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def append_to_playlist(cls, id: int, trackhashes: list[str]):
|
def append_to_playlist(cls, id: int, trackhashes: list[str]):
|
||||||
dbtrackhashes = cls.get_trackhashes(id)
|
dbtrackhashes = cls.get_trackhashes(id) or []
|
||||||
if not dbtrackhashes:
|
trackhashes = list(set(dbtrackhashes).union(set(trackhashes)))
|
||||||
dbtrackhashes = []
|
|
||||||
|
|
||||||
return next(
|
return next(
|
||||||
cls.execute(
|
cls.execute(
|
||||||
update(cls)
|
update(cls)
|
||||||
.where((cls.id == id) & (cls.userid == get_current_userid()))
|
.where((cls.id == id) & (cls.userid == get_current_userid()))
|
||||||
.values(trackhashes=dbtrackhashes + trackhashes),
|
.values(trackhashes=trackhashes),
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
Contains methods relating to albums.
|
Contains methods relating to albums.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
from swingmusic.models.track import Track
|
from swingmusic.models.track import Track
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +13,14 @@ def remove_duplicate_on_merge_versions(tracks: list[Track]):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def sort_by_track_no(tracks: list[Track]):
|
def sort_by_track_no(tracks: list[Track]) -> list[Track]:
|
||||||
|
"""
|
||||||
|
Sort tracks by track number.
|
||||||
|
Track numbers cannot be longer than three positions.
|
||||||
|
|
||||||
|
:param tracks: List of Tracks
|
||||||
|
:return: Sorted list of Tracks
|
||||||
|
"""
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
track = str(t.track).zfill(3)
|
track = str(t.track).zfill(3)
|
||||||
t._pos = int(f"{t.disc}{track}")
|
t._pos = int(f"{t.disc}{track}")
|
||||||
@@ -95,9 +95,9 @@ class DownloadImage:
|
|||||||
if img is None:
|
if img is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
sm_path = Path(settings.Paths.get_sm_artist_img_path()) / name
|
sm_path = settings.Paths().sm_artist_img_path / name
|
||||||
lg_path = Path(settings.Paths.get_lg_artist_img_path()) / name
|
lg_path = settings.Paths().lg_artist_img_path / name
|
||||||
md_path = Path(settings.Paths.get_md_artist_img_path()) / name
|
md_path = settings.Paths().md_artist_img_path / name
|
||||||
|
|
||||||
entries = [
|
entries = [
|
||||||
(lg_path, None), # save in the original size
|
(lg_path, None), # save in the original size
|
||||||
@@ -147,7 +147,7 @@ class CheckArtistImages:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
# read all files in the artist image folder
|
# read all files in the artist image folder
|
||||||
storeArtists = ArtistStore.get_flat_list()
|
storeArtists = ArtistStore.get_flat_list()
|
||||||
path = settings.Paths.get_sm_artist_img_path()
|
path = settings.Paths().sm_artist_img_path
|
||||||
processed = set(i.replace(".webp", "") for i in os.listdir(path))
|
processed = set(i.replace(".webp", "") for i in os.listdir(path))
|
||||||
|
|
||||||
unprocessed = (
|
unprocessed = (
|
||||||
@@ -175,7 +175,7 @@ class CheckArtistImages:
|
|||||||
:param artist: The artist name
|
:param artist: The artist name
|
||||||
"""
|
"""
|
||||||
img_path = (
|
img_path = (
|
||||||
Path(settings.Paths.get_sm_artist_img_path()) / f"{artist.artisthash}.webp"
|
settings.Paths().sm_artist_img_path / f"{artist.artisthash}.webp"
|
||||||
)
|
)
|
||||||
|
|
||||||
if img_path.exists():
|
if img_path.exists():
|
||||||
@@ -1,28 +1,35 @@
|
|||||||
"""
|
"""
|
||||||
Contains everything that deals with image color extraction.
|
Contains everything that deals with image colour extraction.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
|
|
||||||
import colorgram
|
import colorgram
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Generator
|
||||||
from swingmusic.utils.progressbar import tqdm
|
from swingmusic.utils.progressbar import tqdm
|
||||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
|
||||||
from swingmusic import settings
|
from swingmusic import settings
|
||||||
from swingmusic.logger import log
|
|
||||||
from swingmusic.store.albums import AlbumStore
|
from swingmusic.store.albums import AlbumStore
|
||||||
from swingmusic.db.userdata import LibDataTable
|
from swingmusic.db.userdata import LibDataTable
|
||||||
from swingmusic.store.artists import ArtistStore
|
from swingmusic.store.artists import ArtistStore
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
def get_image_colors(image: str, count=1) -> list[str]:
|
def get_image_colors(image: pathlib.Path, count=1) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Extracts n number of the most dominant colors from an image.
|
Extracts ``count`` numbers of the most dominant colours from an image.
|
||||||
|
|
||||||
|
:params image: Path to image.
|
||||||
|
:params count: How many colours should be extracted?
|
||||||
|
:returns: List["rgb(red, green, blue)", ...]
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
|
if image.exists():
|
||||||
colors = sorted(colorgram.extract(image, count), key=lambda c: c.hsl.h)
|
colors = sorted(colorgram.extract(image, count), key=lambda c: c.hsl.h)
|
||||||
except OSError:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
formatted_colors = []
|
formatted_colors = []
|
||||||
@@ -34,36 +41,44 @@ def get_image_colors(image: str, count=1) -> list[str]:
|
|||||||
return formatted_colors
|
return formatted_colors
|
||||||
|
|
||||||
|
|
||||||
def process_color(item_hash: str, is_album=True):
|
def process_color(item_hash: str, is_album=True) -> list[str]:
|
||||||
path = (
|
"""
|
||||||
settings.Paths.get_sm_thumb_path()
|
Parse colours from images associated with song
|
||||||
if is_album
|
|
||||||
else settings.Paths.get_sm_artist_img_path()
|
:param item_hash: hash of item for colour calculation
|
||||||
)
|
:param is_album: if item is an album
|
||||||
path = Path(path) / (item_hash + ".webp")
|
:return: list with colour strings
|
||||||
|
"""
|
||||||
|
|
||||||
|
if is_album:
|
||||||
|
path = settings.Paths().sm_thumb_path
|
||||||
|
else:
|
||||||
|
path = settings.Paths().sm_artist_img_path
|
||||||
|
|
||||||
|
path = path / (item_hash + ".webp")
|
||||||
|
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return
|
return []
|
||||||
|
|
||||||
return get_image_colors(str(path))
|
return get_image_colors(path)
|
||||||
|
|
||||||
|
|
||||||
def extract_color_worker(item_data: dict) -> dict:
|
def extract_color_worker(item_data: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
Generic worker function for extracting colors in parallel.
|
Generic worker function for extracting colours in parallel.
|
||||||
Returns data to main process for batch database operations.
|
Returns data to main process for batch database operations.
|
||||||
Works for both albums and artists based on item_data configuration.
|
Works for both albums and artists based on item_data configuration.
|
||||||
"""
|
"""
|
||||||
hash_field: str = item_data["hash_field"]
|
hash_field: str = item_data["hash_field"]
|
||||||
path_func: Callable = item_data["path_func"]
|
path_func: Path = item_data["path_func"]
|
||||||
item_hash: str = item_data[hash_field]
|
item_hash: str = item_data[hash_field]
|
||||||
|
|
||||||
path = Path(path_func()) / (item_hash + ".webp")
|
path = path_func / (item_hash + ".webp")
|
||||||
|
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {hash_field: item_hash, "color": None, "error": "Image not found"}
|
return {hash_field: item_hash, "color": None, "error": "Image not found"}
|
||||||
|
|
||||||
colors = get_image_colors(str(path))
|
colors = get_image_colors(path)
|
||||||
|
|
||||||
if not colors:
|
if not colors:
|
||||||
return {
|
return {
|
||||||
@@ -85,7 +100,7 @@ class ColorProcessor:
|
|||||||
self,
|
self,
|
||||||
item_type: str,
|
item_type: str,
|
||||||
store: AlbumStore | ArtistStore,
|
store: AlbumStore | ArtistStore,
|
||||||
path_func: Callable,
|
path_func: Path,
|
||||||
hash_field: str,
|
hash_field: str,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -251,21 +266,21 @@ class ProcessAlbumColors:
|
|||||||
ColorProcessor(
|
ColorProcessor(
|
||||||
item_type="album",
|
item_type="album",
|
||||||
store=AlbumStore,
|
store=AlbumStore,
|
||||||
path_func=settings.Paths.get_sm_thumb_path,
|
path_func=settings.Paths().sm_thumb_path,
|
||||||
hash_field="albumhash",
|
hash_field="albumhash",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProcessArtistColors:
|
class ProcessArtistColors:
|
||||||
"""
|
"""
|
||||||
Extracts the most dominant color from the artist art and saves it to the database.
|
Extracts the most dominant colour from the artist art and saves it to the database.
|
||||||
Uses multiprocessing for parallel color extraction and batch database operations.
|
Uses multiprocessing for parallel colour extraction and batch database operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
ColorProcessor(
|
ColorProcessor(
|
||||||
item_type="artist",
|
item_type="artist",
|
||||||
store=ArtistStore,
|
store=ArtistStore,
|
||||||
path_func=settings.Paths.get_sm_artist_img_path,
|
path_func=settings.Paths().sm_artist_img_path,
|
||||||
hash_field="artisthash",
|
hash_field="artisthash",
|
||||||
)
|
)
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import os
|
import pathlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
|
||||||
from swingmusic.lib.sortlib import sort_folders, sort_tracks
|
from swingmusic.lib.sortlib import sort_folders, sort_tracks
|
||||||
from swingmusic.logger import log
|
|
||||||
from swingmusic.models import Folder
|
from swingmusic.models import Folder
|
||||||
from swingmusic.serializers.track import serialize_tracks
|
from swingmusic.serializers.track import serialize_tracks
|
||||||
from swingmusic.utils.filesystem import SUPPORTED_FILES
|
from swingmusic.utils.filesystem import SUPPORTED_FILES
|
||||||
from swingmusic.store.folder import FolderStore
|
from swingmusic.store.folder import FolderStore
|
||||||
from swingmusic.utils.wintools import win_replace_slash
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
def create_folder(path: str, trackcount=0) -> Folder:
|
def create_folder(path: str, trackcount=0) -> Folder:
|
||||||
"""
|
"""
|
||||||
@@ -18,7 +18,7 @@ def create_folder(path: str, trackcount=0) -> Folder:
|
|||||||
|
|
||||||
return Folder(
|
return Folder(
|
||||||
name=folder.name,
|
name=folder.name,
|
||||||
path=win_replace_slash(str(folder)) + "/",
|
path=folder.as_posix() + "/",
|
||||||
is_sym=folder.is_symlink(),
|
is_sym=folder.is_symlink(),
|
||||||
trackcount=trackcount,
|
trackcount=trackcount,
|
||||||
)
|
)
|
||||||
@@ -38,7 +38,7 @@ def get_folders(paths: list[str]):
|
|||||||
|
|
||||||
|
|
||||||
def get_files_and_dirs(
|
def get_files_and_dirs(
|
||||||
path: str,
|
path: pathlib.Path,
|
||||||
start: int,
|
start: int,
|
||||||
limit: int,
|
limit: int,
|
||||||
tracksortby: str,
|
tracksortby: str,
|
||||||
@@ -47,63 +47,86 @@ def get_files_and_dirs(
|
|||||||
foldersort_reverse: bool,
|
foldersort_reverse: bool,
|
||||||
tracks_only: bool = False,
|
tracks_only: bool = False,
|
||||||
skip_empty_folders=True,
|
skip_empty_folders=True,
|
||||||
):
|
) -> dict[str: list|int|str]:
|
||||||
"""
|
"""
|
||||||
Given a path, returns a list of tracks and folders in that immediate path.
|
Scan folder for files and folders.
|
||||||
|
Will only return files in `swingmusic.utils.filesystem.SUPPORTED_FILES`.
|
||||||
|
If `skip_empty_folders` is True
|
||||||
|
|
||||||
Can recursively call itself to skip through empty folders.
|
:param path:
|
||||||
|
:param start:
|
||||||
|
:param limit:
|
||||||
|
:param tracksortby:
|
||||||
|
:param foldersortby:
|
||||||
|
:param tracksort_reverse:
|
||||||
|
:param foldersort_reverse:
|
||||||
|
:param tracks_only: If True, will only return tracks with no folders
|
||||||
|
:param skip_empty_folders: If True, will call recursively and skip empty folders until >0 supported file found.
|
||||||
|
:returns: List of tracks and folders in that immediate path.
|
||||||
"""
|
"""
|
||||||
# TODO: Replace os.path with pathlib
|
|
||||||
try:
|
path = pathlib.Path(path)
|
||||||
entries = os.scandir(path)
|
|
||||||
except FileNotFoundError:
|
# if file or non-existent
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
return {
|
return {
|
||||||
"path": path,
|
"path": path.as_posix(),
|
||||||
"tracks": [],
|
"tracks": [],
|
||||||
"folders": [],
|
"folders": [],
|
||||||
|
"total": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
dirs, files = [], []
|
|
||||||
|
|
||||||
for entry_ in entries:
|
# iter through all folders
|
||||||
entry = Path(entry_.path)
|
# add files with supported suffix
|
||||||
|
# ignore hidden folder
|
||||||
|
dirs, files = [], []
|
||||||
|
for entry in path.iterdir():
|
||||||
ext = entry.suffix.lower()
|
ext = entry.suffix.lower()
|
||||||
|
|
||||||
if entry.is_dir() and not entry.name.startswith("."):
|
if entry.is_dir() and not entry.stem.startswith("."):
|
||||||
dir = (entry / "").as_posix()
|
dirs.append((entry / "").as_posix())
|
||||||
|
# only append as posix for FolderStore and sort_folder function
|
||||||
|
# TODO: rework everything to support pathlib
|
||||||
# add a trailing slash to the folder path
|
# add a trailing slash to the folder path
|
||||||
# to avoid matching a folder starting with the same name as the root path
|
# to avoid matching a folder starting with the same name as the root path
|
||||||
# eg. .../Music and .../Music VideosI
|
# eg. .../Music and .../Music VideosI
|
||||||
dirs.append(dir)
|
|
||||||
elif entry.is_file() and ext in SUPPORTED_FILES:
|
elif entry.is_file() and ext in SUPPORTED_FILES:
|
||||||
files.append(entry.as_posix())
|
files.append(entry)
|
||||||
|
|
||||||
files_ = []
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
# sort files by most recent
|
||||||
|
# TODO: rework if realy needed.
|
||||||
|
files_with_mtime = []
|
||||||
for file in files:
|
for file in files:
|
||||||
try:
|
try:
|
||||||
files_.append(
|
files_with_mtime.append(
|
||||||
{
|
{
|
||||||
"path": file,
|
"path": file.as_posix(),
|
||||||
"time": os.path.getmtime(file),
|
"time": file.lstat().st_mtime,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
log.error(e)
|
log.error(e)
|
||||||
|
|
||||||
files_.sort(key=lambda f: f["time"])
|
files_with_mtime.sort(key=lambda f: f["time"])
|
||||||
files = [f["path"] for f in files_]
|
files = [f["path"] for f in files_with_mtime]
|
||||||
|
"""
|
||||||
|
|
||||||
|
# if supported files were found
|
||||||
|
# convert files to tracks
|
||||||
tracks = []
|
tracks = []
|
||||||
if files:
|
if len(files) > 0:
|
||||||
if limit == -1:
|
if limit == -1:
|
||||||
limit = len(files)
|
limit = len(files)
|
||||||
|
|
||||||
|
# only return tracks already indexed by us
|
||||||
tracks = list(FolderStore.get_tracks_by_filepaths(files))
|
tracks = list(FolderStore.get_tracks_by_filepaths(files))
|
||||||
tracks = sort_tracks(tracks, tracksortby, tracksort_reverse)
|
tracks = sort_tracks(tracks, tracksortby, tracksort_reverse)
|
||||||
tracks = tracks[start : start + limit]
|
tracks = tracks[start : start + limit]
|
||||||
|
|
||||||
|
|
||||||
folders = []
|
folders = []
|
||||||
if not tracks_only:
|
if not tracks_only:
|
||||||
folders = get_folders(dirs)
|
folders = get_folders(dirs)
|
||||||
@@ -126,7 +149,7 @@ def get_files_and_dirs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"path": path,
|
"path": path.as_posix(),
|
||||||
"tracks": serialize_tracks(tracks),
|
"tracks": serialize_tracks(tracks),
|
||||||
"folders": folders,
|
"folders": folders,
|
||||||
"total": len(files),
|
"total": len(files),
|
||||||
@@ -11,6 +11,14 @@ from swingmusic.store.tracks import TrackStore
|
|||||||
|
|
||||||
|
|
||||||
def create_items(entries: list[TrackLog], limit: int):
|
def create_items(entries: list[TrackLog], limit: int):
|
||||||
|
"""
|
||||||
|
TODO: rework so that returns a dict with
|
||||||
|
{
|
||||||
|
"recently_played": ...,
|
||||||
|
"artist_mixes_for_you": ...
|
||||||
|
}
|
||||||
|
also keep in mind that the web-ui is beeing translated.
|
||||||
|
"""
|
||||||
custom_playlists = [
|
custom_playlists = [
|
||||||
{"name": "recentlyadded", "handler": get_recently_added_playlist},
|
{"name": "recentlyadded", "handler": get_recently_added_playlist},
|
||||||
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
|
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import gc
|
import gc
|
||||||
|
import logging
|
||||||
from time import time
|
from time import time
|
||||||
from swingmusic.lib.mapstuff import (
|
from swingmusic.lib.mapstuff import (
|
||||||
map_album_colors,
|
map_album_colors,
|
||||||
@@ -15,9 +16,10 @@ from swingmusic.store.folder import FolderStore
|
|||||||
from swingmusic.store.tracks import TrackStore
|
from swingmusic.store.tracks import TrackStore
|
||||||
from swingmusic.utils.threading import background
|
from swingmusic.utils.threading import background
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
class IndexEverything:
|
@background
|
||||||
def __init__(self) -> None:
|
def index_everything():
|
||||||
IndexTracks()
|
IndexTracks()
|
||||||
|
|
||||||
key = str(time())
|
key = str(time())
|
||||||
@@ -38,8 +40,4 @@ class IndexEverything:
|
|||||||
|
|
||||||
CordinateMedia(instance_key=str(time()))
|
CordinateMedia(instance_key=str(time()))
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
log.info("Indexing completed")
|
||||||
|
|
||||||
@background
|
|
||||||
def index_everything():
|
|
||||||
return IndexEverything()
|
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import datetime
|
||||||
|
import pathlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
|
||||||
|
|
||||||
|
# # # # # # # # # # # # # # # # # # # #
|
||||||
|
# Functions for parsing lyrics lines #
|
||||||
|
# # # # # # # # # # # # # # # # # # # #
|
||||||
|
|
||||||
|
def parse_lyrics_lines(lyrics:str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Split lyrics into lines and determine there tag type.
|
||||||
|
|
||||||
|
Parses the tag if the following format is present: [tag]*[tags] <body>
|
||||||
|
else tag_type is unknown
|
||||||
|
tag-type and tags are lists combined by their index
|
||||||
|
|
||||||
|
|
||||||
|
:param lyrics: Full lyrics body
|
||||||
|
:return: {'tag_types', 'body', 'tags'}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for line in lyrics.splitlines():
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"tag_types": [],
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
if line.startswith("["):
|
||||||
|
|
||||||
|
# loop until all tags are parsed in line
|
||||||
|
while True:
|
||||||
|
if "[" in line and "]" in line: # second tag
|
||||||
|
bracket_content, after_content = line.split("]", 1)
|
||||||
|
bracket_content = bracket_content.removeprefix("[")
|
||||||
|
|
||||||
|
data["tags"].append(bracket_content)
|
||||||
|
data["body"] = after_content
|
||||||
|
|
||||||
|
line = after_content
|
||||||
|
|
||||||
|
# check which tag type it is
|
||||||
|
if bracket_content[0].isnumeric():
|
||||||
|
data["tag_types"].append( "time" )
|
||||||
|
|
||||||
|
elif bracket_content[0].isalpha():
|
||||||
|
data["tag_types"].append( "meta" )
|
||||||
|
else:
|
||||||
|
# if no brackets inside the line, there is also no tag.
|
||||||
|
break
|
||||||
|
|
||||||
|
elif line.startswith("#"):
|
||||||
|
data["tag_types"].append("comment")
|
||||||
|
data["tags"] = ""
|
||||||
|
data["body"] = line
|
||||||
|
|
||||||
|
else:
|
||||||
|
data["tag_types"].append("unknown")
|
||||||
|
data["tags"] = "unknown"
|
||||||
|
data["body"] = line
|
||||||
|
|
||||||
|
entries.append(data)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def filter_parse_lyrics_lines(lines:list[dict], tag_types:list|str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
filter all lyrics line to only contain given tags
|
||||||
|
|
||||||
|
:param lines: list returned by `parse_lyrics_lines`
|
||||||
|
:param tag_types: list or string of tags return should contain
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(tag_types, str):
|
||||||
|
tag_types = [tag_types]
|
||||||
|
|
||||||
|
found_tags = []
|
||||||
|
|
||||||
|
# line = {"tags", "body", "tag_types"}
|
||||||
|
for line in lines:
|
||||||
|
group = {
|
||||||
|
"tag_types": [],
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
for (tag, tag_type) in zip(line["tags"], line["tag_types"]):
|
||||||
|
if tag_type in tag_types:
|
||||||
|
group["tag_types"].append(tag_type)
|
||||||
|
group["tags"].append(tag)
|
||||||
|
group["body"] = line["body"]
|
||||||
|
|
||||||
|
# filter out no match
|
||||||
|
if len(group["tags"]) > 0:
|
||||||
|
found_tags.append(group)
|
||||||
|
|
||||||
|
return found_tags
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_tag(lines:list[dict]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Filter time-tags from lines and parse them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# filter tag-type time
|
||||||
|
# format into dict with timestamps
|
||||||
|
|
||||||
|
parsed_times = []
|
||||||
|
time_tags = filter_parse_lyrics_lines(lines, "time")
|
||||||
|
|
||||||
|
# line = {"tags", "body", "tag_types"}
|
||||||
|
for line in time_tags:
|
||||||
|
for (tag, tag_type) in zip(line["tags"], line["tag_types"]):
|
||||||
|
minute, seconds = tag.split(":", 1)
|
||||||
|
|
||||||
|
parsed_times.append({
|
||||||
|
"minute": minute,
|
||||||
|
"seconds": seconds,
|
||||||
|
"body": line["body"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed_times
|
||||||
|
|
||||||
|
|
||||||
|
# # # # # # # # # # # # # # # # # # # #
|
||||||
|
# Lyrics class for simplified usage #
|
||||||
|
# # # # # # # # # # # # # # # # # # # #
|
||||||
|
|
||||||
|
|
||||||
|
class Lyrics:
|
||||||
|
|
||||||
|
SUPPORTED_METATAGS = {
|
||||||
|
"ti": "title",
|
||||||
|
"ar": "artist",
|
||||||
|
"al": "album",
|
||||||
|
"au": "author",
|
||||||
|
"lr": "lyricist",
|
||||||
|
"length": "length",
|
||||||
|
"by": "lrc_author",
|
||||||
|
"offset": "offset",
|
||||||
|
"re": "recorder",
|
||||||
|
"tool": "tool",
|
||||||
|
"ve": "version"
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics:str
|
||||||
|
parsed_lyrics:list[dict]
|
||||||
|
meta:dict = {}
|
||||||
|
|
||||||
|
is_synced:bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def __init__(self, lyrics:str=""):
|
||||||
|
"""
|
||||||
|
|
||||||
|
:param lyrics: entire lyrics body
|
||||||
|
"""
|
||||||
|
|
||||||
|
if lyrics is None:
|
||||||
|
raise ValueError("Lyrics can not be None")
|
||||||
|
|
||||||
|
if isinstance(lyrics, list):
|
||||||
|
lyrics = lyrics[0]
|
||||||
|
|
||||||
|
lyrics = lyrics.replace("engdesc", "")
|
||||||
|
self.lyrics = lyrics
|
||||||
|
|
||||||
|
parsed = parse_lyrics_lines(lyrics)
|
||||||
|
|
||||||
|
# translate meta tags
|
||||||
|
meta = filter_parse_lyrics_lines(parsed, "meta")
|
||||||
|
for line in meta:
|
||||||
|
for tag in line["tags"]:
|
||||||
|
name, body = tag.split(":", 1)
|
||||||
|
name = name.lower()
|
||||||
|
|
||||||
|
dict_name = self.SUPPORTED_METATAGS.get(name, name)
|
||||||
|
self.meta[dict_name] = body
|
||||||
|
|
||||||
|
|
||||||
|
# check if synced or not.
|
||||||
|
# not fail-save:
|
||||||
|
# If even just one time tag in the entire lyrics gets flagged as synced
|
||||||
|
if len(filter_parse_lyrics_lines(parsed, "time")) > 0:
|
||||||
|
self.is_synced = True
|
||||||
|
self.parsed_lyrics = filter_parse_lyrics_lines(parsed, "time")
|
||||||
|
else:
|
||||||
|
self.is_synced = False
|
||||||
|
self.parsed_lyrics = filter_parse_lyrics_lines(parsed, "unknown")
|
||||||
|
|
||||||
|
# TODO: add support for multilanguage lyrics
|
||||||
|
|
||||||
|
|
||||||
|
def format_synced_lyrics(self):
|
||||||
|
"""
|
||||||
|
Formats synced lyrics into a list of dicts
|
||||||
|
"""
|
||||||
|
if not self.is_synced:
|
||||||
|
raise ValueError("Cannot format synced lyrics if no synced lyrics exist for track.\nPlease use `format_unsynced_lyrics()`")
|
||||||
|
|
||||||
|
lyrics = []
|
||||||
|
|
||||||
|
time_tags = parse_time_tag(self.parsed_lyrics)
|
||||||
|
|
||||||
|
for entry in time_tags:
|
||||||
|
minutes = entry["minute"]
|
||||||
|
if "." in entry["seconds"]:
|
||||||
|
seconds = entry["seconds"].split(".")[0]
|
||||||
|
milli = entry["seconds"].split(".")[-1]
|
||||||
|
else:
|
||||||
|
seconds = entry["seconds"]
|
||||||
|
milli = "0"
|
||||||
|
|
||||||
|
minutes = int(minutes)
|
||||||
|
seconds = int(seconds)
|
||||||
|
milli = int(milli)
|
||||||
|
|
||||||
|
seconds = datetime.timedelta(minutes=minutes, seconds=seconds, milliseconds=milli).total_seconds()
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
if "offset" in self.meta:
|
||||||
|
offset = int(self.meta["offset"]) # offset in milliseconds
|
||||||
|
|
||||||
|
milliseconds = seconds * 1000 - offset
|
||||||
|
lyrics.append({"time": milliseconds, "text": entry["body"]})
|
||||||
|
|
||||||
|
return lyrics
|
||||||
|
|
||||||
|
|
||||||
|
def format_unsynced_lyrics(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
return unsynced lyrics.
|
||||||
|
If no lyrics provided return empty string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lyrics = [item["body"] for item in self.parsed_lyrics]
|
||||||
|
return lyrics
|
||||||
|
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
"""
|
||||||
|
return True if contains anything
|
||||||
|
"""
|
||||||
|
return bool(self.parsed_lyrics)
|
||||||
|
|
||||||
|
|
||||||
|
# # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||||
|
# Path and parse function to get lyrics from track #
|
||||||
|
# # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||||
|
|
||||||
|
def get_lyrics_file(track_path: str|pathlib.Path) -> Lyrics:
|
||||||
|
"""
|
||||||
|
Try to get lyrics from a relative lrc file.
|
||||||
|
|
||||||
|
:param track_path: path of track
|
||||||
|
"""
|
||||||
|
|
||||||
|
track_path = Path(track_path)
|
||||||
|
lyrics_path = track_path.with_suffix(".lrc")
|
||||||
|
extended_path = track_path.with_suffix(".rlrc")
|
||||||
|
|
||||||
|
# check paths
|
||||||
|
if lyrics_path.exists():
|
||||||
|
lyrics = Lyrics(lyrics_path.read_text())
|
||||||
|
return lyrics
|
||||||
|
|
||||||
|
elif extended_path.exists():
|
||||||
|
lyrics = Lyrics(extended_path.read_text())
|
||||||
|
return lyrics
|
||||||
|
|
||||||
|
else:
|
||||||
|
return Lyrics()
|
||||||
|
|
||||||
|
|
||||||
|
def get_lyrics_from_duplicates(track_path: str, trackhash: str) -> Lyrics:
|
||||||
|
"""
|
||||||
|
Finds the lyrics from other duplicate tracks
|
||||||
|
|
||||||
|
:param track_path: path of track
|
||||||
|
:param trackhash: Track-hash value
|
||||||
|
"""
|
||||||
|
|
||||||
|
entry = TrackStore.trackhashmap.get(trackhash, None)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
return Lyrics()
|
||||||
|
|
||||||
|
for track in entry.tracks:
|
||||||
|
if track.trackhash == trackhash and track.filepath != track_path:
|
||||||
|
lyrics = get_lyrics_file(track.filepath)
|
||||||
|
|
||||||
|
if lyrics:
|
||||||
|
return lyrics
|
||||||
|
|
||||||
|
return Lyrics()
|
||||||
|
|
||||||
|
|
||||||
|
def get_lyrics_from_tags(trackhash: str) -> Lyrics:
|
||||||
|
"""
|
||||||
|
Gets the lyrics from the tags of the track
|
||||||
|
|
||||||
|
:param trackhash:
|
||||||
|
"""
|
||||||
|
|
||||||
|
entry = TrackStore.trackhashmap.get(trackhash, None)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
return Lyrics()
|
||||||
|
|
||||||
|
for track in entry.tracks:
|
||||||
|
if "lyrics" in track.extra:
|
||||||
|
lyrics = track.extra["lyrics"]
|
||||||
|
if lyrics:
|
||||||
|
return Lyrics(lyrics)
|
||||||
|
|
||||||
|
return Lyrics("")
|
||||||
|
|
||||||
|
|
||||||
|
def check_lyrics_file(filepath: str, trackhash: str):
|
||||||
|
"""
|
||||||
|
Checks if the lyrics file exists for a track
|
||||||
|
"""
|
||||||
|
|
||||||
|
lyrics_file = Path(filepath).with_suffix(".lrc")
|
||||||
|
if lyrics_file.exists:
|
||||||
|
return True
|
||||||
|
|
||||||
|
entry = TrackStore.trackhashmap.get(trackhash, None)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for track in entry.tracks:
|
||||||
|
if track.trackhash == trackhash and track.filepath != filepath:
|
||||||
|
lyrics_file = Path(track.filepath).with_suffix(".lrc")
|
||||||
|
|
||||||
|
if lyrics_file.exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
This library contains all the functions related to playlists.
|
This library contains all the functions related to playlists.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from typing import Any
|
import logging
|
||||||
|
|
||||||
from PIL import Image, ImageSequence
|
from PIL import Image, ImageSequence
|
||||||
|
|
||||||
from swingmusic import settings
|
from swingmusic import settings
|
||||||
@@ -14,53 +11,60 @@ from swingmusic.models.track import Track
|
|||||||
from swingmusic.store.albums import AlbumStore
|
from swingmusic.store.albums import AlbumStore
|
||||||
from swingmusic.store.tracks import TrackStore
|
from swingmusic.store.tracks import TrackStore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def create_thumbnail(image: Any, img_path: str) -> str:
|
def create_thumbnail(image: Image, img_name: str) -> str:
|
||||||
"""
|
"""
|
||||||
Creates a 250 x 250 thumbnail from a playlist image
|
Creates a 250 px high thumbnail from the Image.
|
||||||
|
It will keep the aspect ratio.
|
||||||
|
|
||||||
|
Images are saved in the playlist-img path
|
||||||
|
|
||||||
|
:param image: Image object.
|
||||||
|
:param img_name: Name of image.
|
||||||
|
:return: Filename of image.
|
||||||
"""
|
"""
|
||||||
thumb_path = "thumb_" + img_path
|
|
||||||
full_thumb_path = os.path.join(
|
|
||||||
settings.Paths.get_app_dir(), "images", "playlists", thumb_path
|
|
||||||
)
|
|
||||||
|
|
||||||
aspect_ratio = image.width / image.height
|
aspect_ratio = image.width / image.height
|
||||||
|
|
||||||
new_w = round(250 * aspect_ratio)
|
new_w = round(250 * aspect_ratio)
|
||||||
|
|
||||||
thumb = image.resize((new_w, 250), Image.Resampling.LANCZOS)
|
thumb = image.resize((new_w, 250), Image.Resampling.LANCZOS)
|
||||||
thumb.save(full_thumb_path, "webp")
|
|
||||||
|
|
||||||
return thumb_path
|
thumb_filename = "thumb_" + img_name
|
||||||
|
thumb_path = settings.Paths().playlist_img_path / thumb_filename
|
||||||
|
|
||||||
|
thumb.save(thumb_path, "webp")
|
||||||
|
|
||||||
|
return thumb_filename
|
||||||
|
|
||||||
|
|
||||||
def create_gif_thumbnail(image: Any, img_path: str):
|
def create_gif_thumbnail(image: Image, img_name: str):
|
||||||
"""
|
"""
|
||||||
Creates a 250 x 250 thumbnail from a playlist image
|
Creates a 250 px high thumbnail from the provided GIF.
|
||||||
|
Keeps the aspect ratio.
|
||||||
|
|
||||||
|
Images are saved in the playlist-img path
|
||||||
|
|
||||||
|
:param image: Image object.
|
||||||
|
:param img_name: Name of image.
|
||||||
|
:return: Filename of image.
|
||||||
"""
|
"""
|
||||||
thumb_path = "thumb_" + img_path
|
thumb_name = "thumb_" + img_name
|
||||||
full_thumb_path = os.path.join(
|
thumb_path = settings.Paths().playlist_img_path / thumb_name
|
||||||
settings.Paths.get_app_dir(), "images", "playlists", thumb_path
|
|
||||||
)
|
|
||||||
|
|
||||||
frames = []
|
frames = []
|
||||||
|
|
||||||
for frame in ImageSequence.Iterator(image):
|
for frame in ImageSequence.Iterator(image):
|
||||||
aspect_ratio = frame.width / frame.height
|
aspect_ratio = frame.width / frame.height
|
||||||
|
|
||||||
new_w = round(250 * aspect_ratio)
|
new_w = round(250 * aspect_ratio)
|
||||||
|
|
||||||
thumb = frame.resize((new_w, 250), Image.Resampling.LANCZOS)
|
thumb = frame.resize((new_w, 250), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
frames.append(thumb)
|
frames.append(thumb)
|
||||||
|
|
||||||
frames[0].save(full_thumb_path, save_all=True, append_images=frames[1:])
|
frames[0].save(thumb_path, save_all=True, append_images=frames[1:])
|
||||||
|
|
||||||
return thumb_path
|
return thumb_name
|
||||||
|
|
||||||
|
|
||||||
def save_p_image(
|
def save_p_image(img: Image, pid: int, content_type: str = None, filename: str = None) -> str:
|
||||||
img: Image, pid: int, content_type: str = None, filename: str = None
|
|
||||||
) -> str:
|
|
||||||
"""
|
"""
|
||||||
Saves a playlist banner image and returns the filepath.
|
Saves a playlist banner image and returns the filepath.
|
||||||
"""
|
"""
|
||||||
@@ -71,7 +75,7 @@ def save_p_image(
|
|||||||
if not filename:
|
if not filename:
|
||||||
filename = str(pid) + str(random_str) + ".webp"
|
filename = str(pid) + str(random_str) + ".webp"
|
||||||
|
|
||||||
full_img_path = os.path.join(settings.Paths.get_playlist_img_path(), filename)
|
full_img_path = settings.Paths().playlist_img_path / filename
|
||||||
|
|
||||||
if content_type == "image/gif":
|
if content_type == "image/gif":
|
||||||
frames = []
|
frames = []
|
||||||
@@ -85,7 +89,7 @@ def save_p_image(
|
|||||||
return filename
|
return filename
|
||||||
|
|
||||||
img.save(full_img_path, "webp")
|
img.save(full_img_path, "webp")
|
||||||
create_thumbnail(img, img_path=filename)
|
create_thumbnail(img, img_name=filename)
|
||||||
|
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
@@ -100,9 +104,10 @@ def duplicate_images(images: list):
|
|||||||
|
|
||||||
return images
|
return images
|
||||||
|
|
||||||
|
# TODO: mutable var in param.
|
||||||
def get_first_4_images(
|
def get_first_4_images(
|
||||||
tracks: list[Track] = [], trackhashes: list[str] = []
|
tracks: list[Track] = [],
|
||||||
|
trackhashes: list[str] = []
|
||||||
) -> list[dict["str", str]]:
|
) -> list[dict["str", str]]:
|
||||||
"""
|
"""
|
||||||
Returns images of the first 4 albums that appear in the track list.
|
Returns images of the first 4 albums that appear in the track list.
|
||||||
@@ -137,10 +142,10 @@ def get_first_4_images(
|
|||||||
return duplicate_images(images)
|
return duplicate_images(images)
|
||||||
|
|
||||||
|
|
||||||
def cleanup_playlist_images():
|
def cleanup_playlist_images() -> None:
|
||||||
"""
|
"""
|
||||||
Cleans up unlinked playlist images by comparing files in the playlist image directory
|
Deletes all unlinked files in playlist-img folder.
|
||||||
against the .image property of all playlists.
|
All files not present in the PlaylistTable will get deleted
|
||||||
"""
|
"""
|
||||||
# Import here to avoid circular import
|
# Import here to avoid circular import
|
||||||
from swingmusic.db.userdata import PlaylistTable
|
from swingmusic.db.userdata import PlaylistTable
|
||||||
@@ -148,23 +153,19 @@ def cleanup_playlist_images():
|
|||||||
playlists = PlaylistTable.get_all()
|
playlists = PlaylistTable.get_all()
|
||||||
linked_images = {p.image for p in playlists if p.image and p.image != "None"}
|
linked_images = {p.image for p in playlists if p.image and p.image != "None"}
|
||||||
|
|
||||||
playlist_dir = settings.Paths.get_playlist_img_path()
|
playlist_dir = settings.Paths().playlist_img_path
|
||||||
all_files = os.listdir(playlist_dir)
|
|
||||||
|
|
||||||
# Find unlinked images (including thumbnails)
|
# Find unlinked images (including thumbnails)
|
||||||
unlinked_files = []
|
for file in playlist_dir.iterdir():
|
||||||
for file in all_files:
|
if not file.isfile:
|
||||||
if file.startswith("thumb_"):
|
continue
|
||||||
base_file = file[6:] # Remove "thumb_" prefix
|
|
||||||
if base_file not in linked_images:
|
|
||||||
unlinked_files.append(file)
|
|
||||||
|
|
||||||
elif file not in linked_images:
|
name = file.name # not stem. PlaylistTable saves with extension
|
||||||
unlinked_files.append(file)
|
if file not in linked_images:
|
||||||
|
if name.removeprefix("thumb_") not in linked_images:
|
||||||
|
continue
|
||||||
|
|
||||||
for file in unlinked_files:
|
|
||||||
try:
|
try:
|
||||||
os.remove(os.path.join(playlist_dir, file))
|
file.unlink(missing_ok=True)
|
||||||
except OSError:
|
except OSError as e:
|
||||||
# Skip if file doesn't exist or can't be deleted
|
logger.exception("could not delete file", exc_info=e)
|
||||||
pass
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import functools
|
||||||
import os
|
import os
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from typing import Generator
|
import multiprocessing as mp
|
||||||
from requests import ReadTimeout
|
from requests import ReadTimeout
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from requests import ConnectionError as RequestConnectionError
|
from requests import ConnectionError as RequestConnectionError
|
||||||
|
import logging
|
||||||
|
|
||||||
from swingmusic import settings
|
from swingmusic import settings
|
||||||
from swingmusic.lib.artistlib import CheckArtistImages
|
from swingmusic.lib.artistlib import CheckArtistImages
|
||||||
from swingmusic.lib.taglib import extract_thumb
|
from swingmusic.lib.taglib import extract_thumb
|
||||||
from swingmusic.logger import log
|
|
||||||
from swingmusic.models import Album, Artist
|
from swingmusic.models import Album, Artist
|
||||||
from swingmusic.models.lastfm import SimilarArtist
|
from swingmusic.models.lastfm import SimilarArtist
|
||||||
from swingmusic.models.track import Track
|
from swingmusic.models.track import Track
|
||||||
@@ -16,11 +17,13 @@ from swingmusic.store.albums import AlbumStore
|
|||||||
from swingmusic.store.artists import ArtistStore
|
from swingmusic.store.artists import ArtistStore
|
||||||
from swingmusic.utils.network import has_connection
|
from swingmusic.utils.network import has_connection
|
||||||
from swingmusic.utils.progressbar import tqdm
|
from swingmusic.utils.progressbar import tqdm
|
||||||
from swingmusic.requests.artists import fetch_similar_artists
|
from swingmusic.request.artists import fetch_similar_artists
|
||||||
from swingmusic.lib.colorlib import ProcessAlbumColors, ProcessArtistColors
|
from swingmusic.lib.colorlib import ProcessAlbumColors, ProcessArtistColors
|
||||||
|
|
||||||
from swingmusic.db.userdata import SimilarArtistTable
|
from swingmusic.db.userdata import SimilarArtistTable
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CordinateMedia:
|
class CordinateMedia:
|
||||||
"""
|
"""
|
||||||
@@ -55,7 +58,7 @@ class CordinateMedia:
|
|||||||
FetchSimilarArtistsLastFM()
|
FetchSimilarArtistsLastFM()
|
||||||
|
|
||||||
|
|
||||||
def get_image(tracks: list[Track]):
|
def get_image(tracks: list[Track], paths=None):
|
||||||
"""
|
"""
|
||||||
The function retrieves an image from a list of tracks by extracting the thumbnail from the first track that has one.
|
The function retrieves an image from a list of tracks by extracting the thumbnail from the first track that has one.
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ def get_image(tracks: list[Track]):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
extracted = extract_thumb(track.filepath, track.albumhash + ".webp")
|
extracted = extract_thumb(track.filepath, track.albumhash + ".webp", paths)
|
||||||
|
|
||||||
if extracted:
|
if extracted:
|
||||||
return
|
return
|
||||||
@@ -78,19 +81,21 @@ class ProcessTrackThumbnails:
|
|||||||
|
|
||||||
def extract(self, albums: list[Album]):
|
def extract(self, albums: list[Album]):
|
||||||
"""
|
"""
|
||||||
Extracts the album art with platform specific logic.
|
Extracts the album art with platform-specific logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cpus = max(1, (os.cpu_count() or 1) // 2)
|
cpus = max(1, os.cpu_count() // 2)
|
||||||
|
|
||||||
albumsMap: Generator[list[Track]] = (
|
albumsMap = ( AlbumStore.get_album_tracks(album.albumhash) for album in albums )
|
||||||
AlbumStore.get_album_tracks(album.albumhash) for album in albums
|
|
||||||
)
|
# Create process pool with worker function
|
||||||
|
with mp.Pool(processes=cpus) as pool:
|
||||||
|
worker = functools.partial(get_image, paths=settings.Paths())
|
||||||
|
# Process files and track progress
|
||||||
|
|
||||||
with ProcessPoolExecutor(max_workers=cpus) as executor:
|
|
||||||
results = list(
|
results = list(
|
||||||
tqdm(
|
tqdm(
|
||||||
executor.map(get_image, albumsMap),
|
pool.imap_unordered(worker, albumsMap),
|
||||||
total=len(albums),
|
total=len(albums),
|
||||||
desc="Extracting track images",
|
desc="Extracting track images",
|
||||||
)
|
)
|
||||||
@@ -103,10 +108,10 @@ class ProcessTrackThumbnails:
|
|||||||
Filters out albums that already have thumbnails and
|
Filters out albums that already have thumbnails and
|
||||||
extracts the thumbnail for the other albums.
|
extracts the thumbnail for the other albums.
|
||||||
"""
|
"""
|
||||||
path = settings.Paths.get_sm_thumb_path()
|
path = settings.Paths().sm_thumb_path
|
||||||
|
|
||||||
# read all the files in the thumbnail directory
|
# read all the files in the thumbnail directory
|
||||||
processed = set(i.replace(".webp", "") for i in os.listdir(path))
|
processed = set(file.stem for file in path.iterdir())
|
||||||
# filter out albums that already have thumbnails
|
# filter out albums that already have thumbnails
|
||||||
albums = filter(
|
albums = filter(
|
||||||
lambda album: album.albumhash not in processed,
|
lambda album: album.albumhash not in processed,
|
||||||
@@ -145,18 +150,21 @@ class FetchSimilarArtistsLastFM:
|
|||||||
processed = set(a.artisthash for a in SimilarArtistTable.get_all())
|
processed = set(a.artisthash for a in SimilarArtistTable.get_all())
|
||||||
|
|
||||||
# filter out artists that already have similar artists using generator
|
# filter out artists that already have similar artists using generator
|
||||||
artists = (
|
def artist_generator():
|
||||||
artist for artist in storeArtists if artist.artisthash not in processed
|
for artist in storeArtists:
|
||||||
)
|
if artist.artisthash in processed:
|
||||||
|
yield artist
|
||||||
|
|
||||||
cpus = max(1, (os.cpu_count() or 1) // 2)
|
artists = list(artist_generator())
|
||||||
|
cpus = max(1, os.cpu_count() // 2)
|
||||||
|
|
||||||
with ProcessPoolExecutor(max_workers=cpus) as executor:
|
with ProcessPoolExecutor(max_workers=cpus) as executor:
|
||||||
try:
|
try:
|
||||||
|
# TODO: fix negative total length
|
||||||
results = list(
|
results = list(
|
||||||
tqdm(
|
tqdm(
|
||||||
executor.map(save_similar_artists, artists),
|
executor.map(save_similar_artists, artist_generator()),
|
||||||
total=len(storeArtists) - len(processed),
|
total=len(artists),
|
||||||
desc="Fetching similar artists",
|
desc="Fetching similar artists",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -164,5 +172,5 @@ class FetchSimilarArtistsLastFM:
|
|||||||
list(results)
|
list(results)
|
||||||
# any exception that can be raised by the pool
|
# any exception that can be raised by the pool
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warn(e)
|
log.warning(e)
|
||||||
return
|
return
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import division
|
|
||||||
|
|
||||||
import array
|
import array
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
converter_logger = logging.getLogger("pydub.converter")
|
converter_logger = logging.getLogger("swingmusic.pydub.converter")
|
||||||
|
|
||||||
def log_conversion(conversion_command):
|
def log_conversion(conversion_command):
|
||||||
converter_logger.debug("subprocess.call(%s)", repr(conversion_command))
|
converter_logger.debug("subprocess.call(%s)", repr(conversion_command))
|
||||||