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 <[email protected]> Co-authored-by: Mungai Njoroge <[email protected]>
@@ -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:
|
||||
|
||||
- `/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.
|
||||
- `/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:
|
||||
- name: Clone client
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node 20
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
- 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 ..
|
||||
fetch-tags: true
|
||||
|
||||
- name: Install Python 3.11
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.11.x"
|
||||
|
||||
- name: Create virtualenv
|
||||
run: |
|
||||
python -m venv .venv
|
||||
|
||||
- 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: Activate virtualenv (unix)
|
||||
if: ${{ !startsWith(matrix.os, 'win') }}
|
||||
run: |
|
||||
source .venv/bin/activate && echo "bjoern==3.2.2" >> requirements.txt
|
||||
source .venv/bin/activate
|
||||
|
||||
- name: Activate virtualenv (windows)
|
||||
if: ${{ startsWith(matrix.os, 'win') }}
|
||||
run: |
|
||||
.venv\Scripts\Activate && echo "waitress==3.0.2" >> requirements.txt
|
||||
- name: Install dependencies
|
||||
.venv\Scripts\Activate
|
||||
|
||||
- name: Install swingmusic
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
- 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
|
||||
pip install .
|
||||
|
||||
- name: Build server
|
||||
run: |
|
||||
python run.py --build
|
||||
env:
|
||||
SWINGMUSIC_APP_VERSION: ${{ inputs.tag }}
|
||||
|
||||
- name: Rename Unix binary
|
||||
if: ${{ !startsWith(matrix.os, 'win') }}
|
||||
run: |
|
||||
@@ -124,6 +109,7 @@ jobs:
|
||||
else
|
||||
mv dist/swingmusic dist/swingmusic_linux_amd64
|
||||
fi
|
||||
|
||||
- name: Verify Unix build success
|
||||
if: ${{ !startsWith(matrix.os, 'win') }}
|
||||
run: |
|
||||
@@ -150,6 +136,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Verify Windows build success
|
||||
if: ${{ startsWith(matrix.os, 'win') }}
|
||||
run: |
|
||||
@@ -164,6 +151,7 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
- name: Upload Unix binary
|
||||
if: ${{ !startsWith(matrix.os, 'win') }}
|
||||
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' }}
|
||||
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
|
||||
|
||||
- name: Upload Windows binary
|
||||
if: ${{ startsWith(matrix.os, 'win') }}
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -277,6 +266,7 @@ jobs:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.env.local
|
||||
.env.*.local
|
||||
venv
|
||||
.venv
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
@@ -25,7 +26,7 @@ client
|
||||
.gitignore
|
||||
|
||||
logs.txt
|
||||
*.spec
|
||||
|
||||
|
||||
# TODO.md
|
||||
testdata.py
|
||||
@@ -35,4 +36,5 @@ nohup.out
|
||||
|
||||
|
||||
.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
|
||||
WORKDIR /app/swingmusic
|
||||
|
||||
# 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
|
||||
|
||||
VOLUME /music
|
||||
|
||||
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 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
RUN pip install bjoern
|
||||
RUN pip install --no-cache-dir --find-links=wheels/ swingmusic
|
||||
Run rm -rf /app/swingmusic/wheels
|
||||
|
||||
ARG app_version
|
||||
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"]
|
||||
ENTRYPOINT ["python", "-m", "swingmusic", "--host", "0.0.0.0", "--config", "/config", "--client", "/config/client"]
|
||||
|
||||
@@ -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
|
||||
|
||||
- Migrations:
|
||||
@@ -15,6 +41,7 @@
|
||||
- rename userid to server id in config file
|
||||
- Look into seeding jwts using user password + server id
|
||||
|
||||
|
||||
<!-- CHECKPOINT -->
|
||||
<!-- 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]
|
||||
requires = ["setuptools"]
|
||||
requires = ["setuptools", "setuptools-scm"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "swingmusic"
|
||||
version = "2.0.6"
|
||||
description = "Swing Music"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11, <=3.12"
|
||||
dynamic = ["version"]
|
||||
|
||||
dependencies = [
|
||||
"pillow>=11.1.0",
|
||||
@@ -37,11 +37,15 @@ dependencies = [
|
||||
"rapidfuzz==3.11.0",
|
||||
"pendulum>=3.0.0",
|
||||
"pystray>=0.19.5",
|
||||
"pyinstaller==6.12.0",
|
||||
"waitress>=3.0.2; sys_platform == 'win32'",
|
||||
"bjoern >=3.2.2; sys_platform != 'win32'"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
build = [
|
||||
"pyinstaller"
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
dependency-metadata = [
|
||||
{ name = "waitress", version = "3.0.2", requires-dist = [], requires-python = ">=3.11" },
|
||||
@@ -49,7 +53,7 @@ dependency-metadata = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
swingmusic = "swingmusic.__main__:main"
|
||||
swingmusic = "swingmusic.__main__:run"
|
||||
|
||||
|
||||
[project.urls]
|
||||
@@ -59,8 +63,7 @@ Documentation = "https://swingmx.com/guide/introduction.html"
|
||||
Issues = "https://github.com/swingmx/swingmusic/issues"
|
||||
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"swingmusic" = "swingmusic"}
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
swingmusic = ["client/*"]
|
||||
[tool.setuptools_scm]
|
||||
version_scheme = "only-version"
|
||||
local_scheme = "no-local-version"
|
||||
fallback_version = "v0.0.0"
|
||||
@@ -1,62 +1,28 @@
|
||||
altgraph==0.17.4
|
||||
annotated-types==0.7.0
|
||||
blinker==1.9.0
|
||||
brotli==1.1.0
|
||||
certifi==2025.1.31
|
||||
charset-normalizer==3.4.1
|
||||
click==8.1.8
|
||||
colorgram-py==1.2.0
|
||||
configargparse==1.7
|
||||
ffmpeg-python==0.2.0
|
||||
flask==3.1.0
|
||||
flask-compress==1.17
|
||||
flask-cors==5.0.1
|
||||
flask-jwt-extended==4.7.1
|
||||
flask-login==0.6.3
|
||||
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
|
||||
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
|
||||
requests==2.32.3
|
||||
schedule==1.2.2
|
||||
setproctitle==1.3.5
|
||||
setuptools==75.8.0
|
||||
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
|
||||
pendulum>=3.0.0
|
||||
pystray>=0.19.5
|
||||
waitress==3.0.2; sys_platform == 'win32'
|
||||
bjoern>=3.2.2; sys_platform != 'win32'
|
||||
@@ -1,9 +1,16 @@
|
||||
# Launcher script
|
||||
|
||||
import multiprocessing
|
||||
import swingmusic.__main__ as app
|
||||
import sys
|
||||
import multiprocessing
|
||||
|
||||
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.set_start_method("spawn")
|
||||
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)
|
||||
|
||||
# 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 not img_folder_created:
|
||||
img_folder.mkdir(parents=True)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Contains all the folder routes.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
from datetime import datetime
|
||||
import os
|
||||
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.serializers.track import serialize_track, serialize_tracks
|
||||
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")
|
||||
api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
|
||||
@@ -83,16 +83,10 @@ def get_folder_tree(body: FolderTree):
|
||||
config = UserConfig()
|
||||
root_dirs = config.rootDirs
|
||||
|
||||
try:
|
||||
if req_dir == "$home" and root_dirs[0] == "$home":
|
||||
req_dir = settings.Paths.USER_HOME_DIR
|
||||
except IndexError:
|
||||
pass
|
||||
if req_dir == "$home" and "$home" in root_dirs:
|
||||
req_dir = settings.Paths().USER_HOME_DIR.as_posix()
|
||||
|
||||
if req_dir == "$home":
|
||||
if len(root_dirs) == 1:
|
||||
req_dir = root_dirs[0]
|
||||
else:
|
||||
folders = get_folders(root_dirs)
|
||||
|
||||
return {
|
||||
@@ -148,16 +142,14 @@ def get_folder_tree(body: FolderTree):
|
||||
"path": req_dir,
|
||||
}
|
||||
|
||||
if is_windows():
|
||||
# Trailing slash needed when drive letters are passed,
|
||||
# Remember, the trailing slash is removed in the client.
|
||||
# req_dir += "/"
|
||||
pass
|
||||
else:
|
||||
req_dir = "/" + req_dir if not req_dir.startswith("/") else req_dir
|
||||
# TODO: currently only fixed on unix. Windows/Mac still pending.
|
||||
# note
|
||||
|
||||
if not pathlib.Path(req_dir).exists():
|
||||
req_dir = "/" + req_dir
|
||||
|
||||
results = get_files_and_dirs(
|
||||
req_dir,
|
||||
pathlib.Path(req_dir),
|
||||
start=body.start,
|
||||
limit=body.limit,
|
||||
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)]
|
||||
}
|
||||
|
||||
if is_win:
|
||||
req_dir += "/"
|
||||
else:
|
||||
req_dir = "/" + req_dir + "/"
|
||||
req_dir = str(Path(req_dir).resolve())
|
||||
|
||||
req_dir = pathlib.Path(req_dir)
|
||||
|
||||
if not req_dir.exists():
|
||||
req_dir = "/" / req_dir
|
||||
|
||||
try:
|
||||
entries = os.scandir(req_dir)
|
||||
except PermissionError:
|
||||
return {"folders": []}
|
||||
|
||||
dirs = [e.name for e in entries if e.is_dir() and not e.name.startswith(".")]
|
||||
dirs = [
|
||||
{"name": d, "path": win_replace_slash(os.path.join(req_dir, d))} for d in dirs
|
||||
]
|
||||
# only get dirs and remove hidden dirs
|
||||
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 {
|
||||
"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.
|
||||
"""
|
||||
image = Image.open(filepath)
|
||||
path = Path(Paths.get_image_cache_path())
|
||||
path = Path(Paths().image_cache_path)
|
||||
aspect_ratio = image.width / image.height
|
||||
|
||||
sizes = {
|
||||
@@ -89,7 +89,7 @@ def send_fallback_img(filename: str = "default.webp"):
|
||||
"""
|
||||
Returns the fallback image from the assets folder.
|
||||
"""
|
||||
folder = Paths.get_assets_path()
|
||||
folder = Paths().assets_path
|
||||
img = Path(folder) / filename
|
||||
|
||||
if not img.exists():
|
||||
@@ -111,7 +111,7 @@ def send_file_or_fallback(
|
||||
|
||||
if pathhash != "":
|
||||
# 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():
|
||||
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)
|
||||
"""
|
||||
folder = Paths.get_lg_thumb_path()
|
||||
folder = Paths().lg_thumb_path
|
||||
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)
|
||||
"""
|
||||
folder = Paths.get_xsm_thumb_path()
|
||||
folder = Paths().xsm_thumb_path
|
||||
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)
|
||||
"""
|
||||
folder = Paths.get_sm_thumb_path()
|
||||
folder = Paths().sm_thumb_path
|
||||
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)
|
||||
"""
|
||||
folder = Paths.get_md_thumb_path()
|
||||
folder = Paths().md_thumb_path
|
||||
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)
|
||||
"""
|
||||
folder = Paths.get_lg_artist_img_path()
|
||||
return send_file_or_fallback(folder, path.imgpath, "artist.webp")
|
||||
folder = Paths().lg_artist_img_path
|
||||
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
|
||||
|
||||
|
||||
@api.get("/artist/small/<imgpath>")
|
||||
@@ -208,8 +208,8 @@ def send_sm_artist_image(path: ImagePath):
|
||||
"""
|
||||
Get small artist image (128)
|
||||
"""
|
||||
folder = Paths.get_sm_artist_img_path()
|
||||
return send_file_or_fallback(folder, path.imgpath, "artist.webp")
|
||||
folder = Paths().sm_artist_img_path
|
||||
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
|
||||
|
||||
|
||||
@api.get("/artist/medium/<imgpath>")
|
||||
@@ -217,7 +217,7 @@ def send_md_artist_image(path: ImagePath):
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ def send_playlist_image(path: PlaylistImagePath):
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ def send_md_mix_image(path: ImagePath):
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
@@ -255,5 +255,5 @@ def send_sm_mix_image(path: ImagePath):
|
||||
"""
|
||||
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")
|
||||
@@ -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
|
||||
itemhash = body.itemhash
|
||||
playlist_id = path.playlistid
|
||||
playlist_id = int(path.playlistid)
|
||||
sortoptions = body.sortoptions
|
||||
|
||||
if itemtype == "tracks":
|
||||
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":
|
||||
trackhashes = get_path_trackhashes(
|
||||
itemhash,
|
||||
@@ -195,7 +197,7 @@ def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
|
||||
else:
|
||||
trackhashes = []
|
||||
|
||||
PlaylistTable.append_to_playlist(int(playlist_id), trackhashes)
|
||||
PlaylistTable.append_to_playlist(playlist_id, trackhashes)
|
||||
return {"msg": "Done"}, 200
|
||||
|
||||
|
||||
@@ -461,9 +463,9 @@ def save_item_as_playlist(body: SavePlaylistAsItemBody):
|
||||
filename = itemhash + ".webp"
|
||||
|
||||
base_path = (
|
||||
Paths.get_lg_artist_img_path()
|
||||
Paths().lg_artist_img_path
|
||||
if itemtype == "artist"
|
||||
else Paths.get_lg_thumb_path()
|
||||
else Paths().lg_thumb_path()
|
||||
)
|
||||
img_path = pathlib.Path(base_path + "/" + filename)
|
||||
|
||||
@@ -2,7 +2,7 @@ from flask_openapi3 import Tag
|
||||
from flask_openapi3 import APIBlueprint
|
||||
from pydantic import Field
|
||||
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.settings import Defaults
|
||||
@@ -47,18 +47,14 @@ def search_lyrics(body: LyricsSearchBody):
|
||||
i_title = track["title"]
|
||||
i_album = track["album"]
|
||||
|
||||
if create_hash(i_title) == create_hash(title) and create_hash(
|
||||
i_album
|
||||
) == create_hash(album):
|
||||
if create_hash(i_title) == create_hash(title) and create_hash(i_album) == create_hash(album):
|
||||
perfect_match = track
|
||||
|
||||
track_id = perfect_match["track_id"]
|
||||
lrc = finder.download_lyrics(track_id, filepath)
|
||||
|
||||
if lrc is not None:
|
||||
lines = lrc.split("\n")
|
||||
lyrics = format_synced_lyrics(lines)
|
||||
|
||||
return {"trackhash": trackhash, "lyrics": lyrics}, 200
|
||||
lyrics = Lyrics_class(lrc)
|
||||
return {"trackhash": trackhash, "lyrics": lyrics.format_synced_lyrics()}, 200
|
||||
|
||||
return {"trackhash": trackhash, "lyrics": lrc}, 200
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import asdict
|
||||
from importlib import metadata
|
||||
from typing import Any
|
||||
from flask_openapi3 import Tag
|
||||
from flask_openapi3 import APIBlueprint
|
||||
@@ -7,7 +8,6 @@ from swingmusic.api.auth import admin_required
|
||||
|
||||
from swingmusic.db.userdata import PluginTable
|
||||
from swingmusic.lib.index import index_everything
|
||||
from swingmusic.settings import Info
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.utils.auth import get_current_userid
|
||||
|
||||
@@ -102,7 +102,7 @@ def get_all_settings():
|
||||
config[key] = sorted(list(value))
|
||||
|
||||
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
|
||||
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
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from .settings import Paths
|
||||
|
||||
# TODO: Publish this on PyPi
|
||||
from dataclasses import dataclass, asdict, field, InitVar
|
||||
from swingmusic.settings import Paths, Singleton
|
||||
|
||||
|
||||
def load_artist_ignore_list_from_file(filepath: Path) -> set[str]:
|
||||
"""
|
||||
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:
|
||||
return {
|
||||
line.strip() for line in filepath.read_text().splitlines() if line.strip()
|
||||
}
|
||||
except FileNotFoundError:
|
||||
if filepath.exists():
|
||||
text = filepath.read_text()
|
||||
return set([ line.strip() for line in text.splitlines() if line.strip() ])
|
||||
else:
|
||||
return set()
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
default_file = Path(__file__).parent / "data" / "artist_split_ignore.txt"
|
||||
return load_artist_ignore_list_from_file(default_file)
|
||||
text = importlib.resources.read_text("swingmusic.data","artist_split_ignore.txt")
|
||||
# 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]:
|
||||
@@ -34,14 +36,19 @@ def load_user_artist_ignore_list() -> set[str]:
|
||||
Loads the user-defined artist ignore list from the config directory.
|
||||
Returns an empty set if the file doesn't exist.
|
||||
"""
|
||||
user_file = Path(Paths.get_config_file_path()).parent / "artist_split_ignore.txt"
|
||||
return load_artist_ignore_list_from_file(user_file)
|
||||
user_file = Paths().app_dir / "artist_split_ignore.txt"
|
||||
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
|
||||
class UserConfig:
|
||||
_config_path: str = ""
|
||||
_artist_split_ignore_file_name: str = "artist_split_ignore.txt"
|
||||
class UserConfig(metaclass=Singleton):
|
||||
_finished: bool = field(default=False, init=False) # if post init succesfully
|
||||
_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)
|
||||
# TODO: Move the rest of the settings to the config file
|
||||
|
||||
@@ -84,16 +91,17 @@ class UserConfig:
|
||||
lastfmApiSecret: str = "5e5306fbf3e8e3bc92f039b6c6c4bd4e"
|
||||
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
|
||||
"""
|
||||
# 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)
|
||||
except FileNotFoundError:
|
||||
else:
|
||||
self._config_path = config_path
|
||||
return
|
||||
|
||||
@@ -107,8 +115,10 @@ class UserConfig:
|
||||
else:
|
||||
setattr(self, key, value)
|
||||
|
||||
# finally set the config path
|
||||
# finally, set the config path
|
||||
self._config_path = config_path
|
||||
self._finished = True
|
||||
|
||||
|
||||
def setup_config_file(self) -> None:
|
||||
"""
|
||||
@@ -116,18 +126,18 @@ class UserConfig:
|
||||
if it doesn't exist
|
||||
"""
|
||||
# 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))
|
||||
|
||||
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.
|
||||
Returns a dictget_root_dirs
|
||||
"""
|
||||
with open(path, "r") as f:
|
||||
settings = json.load(f)
|
||||
return json.loads(path.read_text())
|
||||
|
||||
return settings
|
||||
|
||||
def write_to_file(self, settings: dict[str, Any]):
|
||||
"""
|
||||
@@ -136,17 +146,25 @@ class UserConfig:
|
||||
# remove internal attributes
|
||||
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)
|
||||
|
||||
|
||||
def __setattr__(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
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)
|
||||
|
||||
# if is internal attribute, don't write to file
|
||||
if key.startswith("_") or not self._config_path:
|
||||
return
|
||||
|
||||
self.write_to_file(asdict(self))
|
||||
self.write_to_file(asdict(self))
|
||||
@@ -2,7 +2,7 @@ from contextlib import contextmanager
|
||||
from sqlalchemy import Engine, create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from swingmusic.settings import DbPaths
|
||||
from swingmusic.settings import Paths
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
@@ -38,7 +38,7 @@ class DbEngine:
|
||||
def engine(cls) -> Engine:
|
||||
if not cls._engine:
|
||||
cls._engine = create_engine(
|
||||
f"sqlite+pysqlite:///{DbPaths.get_app_db_path()}",
|
||||
f"sqlite+pysqlite:///{Paths().app_db_path}",
|
||||
echo=False,
|
||||
max_overflow=20,
|
||||
pool_size=10,
|
||||
@@ -66,7 +66,14 @@ class TrackTable(Base):
|
||||
.where(TrackTable.filepath.contains(path))
|
||||
.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
|
||||
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
|
||||
@@ -90,10 +90,10 @@ class SQLiteManager:
|
||||
if self.test_db_path:
|
||||
db_path = self.test_db_path
|
||||
else:
|
||||
db_path = settings.DbPaths.get_app_db_path()
|
||||
db_path = settings.Paths().app_db_path
|
||||
|
||||
if self.userdata_db:
|
||||
db_path = settings.DbPaths.get_userdata_db_path()
|
||||
db_path = settings.Paths().userdata_db_path
|
||||
|
||||
self.conn = sqlite3.connect(
|
||||
db_path,
|
||||
@@ -434,15 +434,14 @@ class PlaylistTable(Base):
|
||||
|
||||
@classmethod
|
||||
def append_to_playlist(cls, id: int, trackhashes: list[str]):
|
||||
dbtrackhashes = cls.get_trackhashes(id)
|
||||
if not dbtrackhashes:
|
||||
dbtrackhashes = []
|
||||
dbtrackhashes = cls.get_trackhashes(id) or []
|
||||
trackhashes = list(set(dbtrackhashes).union(set(trackhashes)))
|
||||
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where((cls.id == id) & (cls.userid == get_current_userid()))
|
||||
.values(trackhashes=dbtrackhashes + trackhashes),
|
||||
.values(trackhashes=trackhashes),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
@@ -2,7 +2,6 @@
|
||||
Contains methods relating to albums.
|
||||
"""
|
||||
|
||||
|
||||
from swingmusic.models.track import Track
|
||||
|
||||
|
||||
@@ -14,7 +13,14 @@ def remove_duplicate_on_merge_versions(tracks: list[Track]):
|
||||
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:
|
||||
track = str(t.track).zfill(3)
|
||||
t._pos = int(f"{t.disc}{track}")
|
||||
@@ -95,9 +95,9 @@ class DownloadImage:
|
||||
if img is None:
|
||||
return
|
||||
|
||||
sm_path = Path(settings.Paths.get_sm_artist_img_path()) / name
|
||||
lg_path = Path(settings.Paths.get_lg_artist_img_path()) / name
|
||||
md_path = Path(settings.Paths.get_md_artist_img_path()) / name
|
||||
sm_path = settings.Paths().sm_artist_img_path / name
|
||||
lg_path = settings.Paths().lg_artist_img_path / name
|
||||
md_path = settings.Paths().md_artist_img_path / name
|
||||
|
||||
entries = [
|
||||
(lg_path, None), # save in the original size
|
||||
@@ -147,7 +147,7 @@ class CheckArtistImages:
|
||||
def __init__(self):
|
||||
# read all files in the artist image folder
|
||||
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))
|
||||
|
||||
unprocessed = (
|
||||
@@ -175,7 +175,7 @@ class CheckArtistImages:
|
||||
:param artist: The artist name
|
||||
"""
|
||||
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():
|
||||
@@ -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 pathlib
|
||||
|
||||
import colorgram
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
from typing import Generator
|
||||
from swingmusic.utils.progressbar import tqdm
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
|
||||
from swingmusic import settings
|
||||
from swingmusic.logger import log
|
||||
from swingmusic.store.albums import AlbumStore
|
||||
from swingmusic.db.userdata import LibDataTable
|
||||
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)
|
||||
except OSError:
|
||||
else:
|
||||
return []
|
||||
|
||||
formatted_colors = []
|
||||
@@ -34,36 +41,44 @@ def get_image_colors(image: str, count=1) -> list[str]:
|
||||
return formatted_colors
|
||||
|
||||
|
||||
def process_color(item_hash: str, is_album=True):
|
||||
path = (
|
||||
settings.Paths.get_sm_thumb_path()
|
||||
if is_album
|
||||
else settings.Paths.get_sm_artist_img_path()
|
||||
)
|
||||
path = Path(path) / (item_hash + ".webp")
|
||||
def process_color(item_hash: str, is_album=True) -> list[str]:
|
||||
"""
|
||||
Parse colours from images associated with song
|
||||
|
||||
:param item_hash: hash of item for colour calculation
|
||||
:param is_album: if item is an album
|
||||
: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():
|
||||
return
|
||||
return []
|
||||
|
||||
return get_image_colors(str(path))
|
||||
return get_image_colors(path)
|
||||
|
||||
|
||||
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.
|
||||
Works for both albums and artists based on item_data configuration.
|
||||
"""
|
||||
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]
|
||||
|
||||
path = Path(path_func()) / (item_hash + ".webp")
|
||||
path = path_func / (item_hash + ".webp")
|
||||
|
||||
if not path.exists():
|
||||
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:
|
||||
return {
|
||||
@@ -85,7 +100,7 @@ class ColorProcessor:
|
||||
self,
|
||||
item_type: str,
|
||||
store: AlbumStore | ArtistStore,
|
||||
path_func: Callable,
|
||||
path_func: Path,
|
||||
hash_field: str,
|
||||
):
|
||||
"""
|
||||
@@ -251,21 +266,21 @@ class ProcessAlbumColors:
|
||||
ColorProcessor(
|
||||
item_type="album",
|
||||
store=AlbumStore,
|
||||
path_func=settings.Paths.get_sm_thumb_path,
|
||||
path_func=settings.Paths().sm_thumb_path,
|
||||
hash_field="albumhash",
|
||||
)
|
||||
|
||||
|
||||
class ProcessArtistColors:
|
||||
"""
|
||||
Extracts the most dominant color from the artist art and saves it to the database.
|
||||
Uses multiprocessing for parallel color extraction and batch database operations.
|
||||
Extracts the most dominant colour from the artist art and saves it to the database.
|
||||
Uses multiprocessing for parallel colour extraction and batch database operations.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
ColorProcessor(
|
||||
item_type="artist",
|
||||
store=ArtistStore,
|
||||
path_func=settings.Paths.get_sm_artist_img_path,
|
||||
path_func=settings.Paths().sm_artist_img_path,
|
||||
hash_field="artisthash",
|
||||
)
|
||||
@@ -1,14 +1,14 @@
|
||||
import os
|
||||
import pathlib
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
from swingmusic.lib.sortlib import sort_folders, sort_tracks
|
||||
from swingmusic.logger import log
|
||||
from swingmusic.models import Folder
|
||||
from swingmusic.serializers.track import serialize_tracks
|
||||
from swingmusic.utils.filesystem import SUPPORTED_FILES
|
||||
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:
|
||||
"""
|
||||
@@ -18,7 +18,7 @@ def create_folder(path: str, trackcount=0) -> Folder:
|
||||
|
||||
return Folder(
|
||||
name=folder.name,
|
||||
path=win_replace_slash(str(folder)) + "/",
|
||||
path=folder.as_posix() + "/",
|
||||
is_sym=folder.is_symlink(),
|
||||
trackcount=trackcount,
|
||||
)
|
||||
@@ -38,7 +38,7 @@ def get_folders(paths: list[str]):
|
||||
|
||||
|
||||
def get_files_and_dirs(
|
||||
path: str,
|
||||
path: pathlib.Path,
|
||||
start: int,
|
||||
limit: int,
|
||||
tracksortby: str,
|
||||
@@ -47,63 +47,86 @@ def get_files_and_dirs(
|
||||
foldersort_reverse: bool,
|
||||
tracks_only: bool = False,
|
||||
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:
|
||||
entries = os.scandir(path)
|
||||
except FileNotFoundError:
|
||||
|
||||
path = pathlib.Path(path)
|
||||
|
||||
# if file or non-existent
|
||||
if not path.exists() or not path.is_dir():
|
||||
return {
|
||||
"path": path,
|
||||
"path": path.as_posix(),
|
||||
"tracks": [],
|
||||
"folders": [],
|
||||
"total": 0
|
||||
}
|
||||
|
||||
dirs, files = [], []
|
||||
|
||||
for entry_ in entries:
|
||||
entry = Path(entry_.path)
|
||||
# iter through all folders
|
||||
# add files with supported suffix
|
||||
# ignore hidden folder
|
||||
dirs, files = [], []
|
||||
for entry in path.iterdir():
|
||||
ext = entry.suffix.lower()
|
||||
|
||||
if entry.is_dir() and not entry.name.startswith("."):
|
||||
dir = (entry / "").as_posix()
|
||||
|
||||
if entry.is_dir() and not entry.stem.startswith("."):
|
||||
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
|
||||
# to avoid matching a folder starting with the same name as the root path
|
||||
# eg. .../Music and .../Music VideosI
|
||||
dirs.append(dir)
|
||||
|
||||
elif entry.is_file() and ext in SUPPORTED_FILES:
|
||||
files.append(entry.as_posix())
|
||||
|
||||
files_ = []
|
||||
files.append(entry)
|
||||
|
||||
"""
|
||||
# sort files by most recent
|
||||
# TODO: rework if realy needed.
|
||||
files_with_mtime = []
|
||||
for file in files:
|
||||
try:
|
||||
files_.append(
|
||||
files_with_mtime.append(
|
||||
{
|
||||
"path": file,
|
||||
"time": os.path.getmtime(file),
|
||||
"path": file.as_posix(),
|
||||
"time": file.lstat().st_mtime,
|
||||
}
|
||||
)
|
||||
except OSError as e:
|
||||
log.error(e)
|
||||
|
||||
files_.sort(key=lambda f: f["time"])
|
||||
files = [f["path"] for f in files_]
|
||||
files_with_mtime.sort(key=lambda f: f["time"])
|
||||
files = [f["path"] for f in files_with_mtime]
|
||||
"""
|
||||
|
||||
# if supported files were found
|
||||
# convert files to tracks
|
||||
tracks = []
|
||||
if files:
|
||||
if len(files) > 0:
|
||||
if limit == -1:
|
||||
limit = len(files)
|
||||
|
||||
# only return tracks already indexed by us
|
||||
tracks = list(FolderStore.get_tracks_by_filepaths(files))
|
||||
tracks = sort_tracks(tracks, tracksortby, tracksort_reverse)
|
||||
tracks = tracks[start : start + limit]
|
||||
|
||||
|
||||
folders = []
|
||||
if not tracks_only:
|
||||
folders = get_folders(dirs)
|
||||
@@ -126,7 +149,7 @@ def get_files_and_dirs(
|
||||
)
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"path": path.as_posix(),
|
||||
"tracks": serialize_tracks(tracks),
|
||||
"folders": folders,
|
||||
"total": len(files),
|
||||
@@ -11,6 +11,14 @@ from swingmusic.store.tracks import TrackStore
|
||||
|
||||
|
||||
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 = [
|
||||
{"name": "recentlyadded", "handler": get_recently_added_playlist},
|
||||
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
|
||||
@@ -1,4 +1,5 @@
|
||||
import gc
|
||||
import logging
|
||||
from time import time
|
||||
from swingmusic.lib.mapstuff import (
|
||||
map_album_colors,
|
||||
@@ -15,31 +16,28 @@ from swingmusic.store.folder import FolderStore
|
||||
from swingmusic.store.tracks import TrackStore
|
||||
from swingmusic.utils.threading import background
|
||||
|
||||
|
||||
class IndexEverything:
|
||||
def __init__(self) -> None:
|
||||
IndexTracks()
|
||||
|
||||
key = str(time())
|
||||
TrackStore.load_all_tracks(key)
|
||||
AlbumStore.load_albums(key)
|
||||
ArtistStore.load_artists(key)
|
||||
FolderStore.load_filepaths()
|
||||
|
||||
# NOTE: Rebuild recently added items on the homepage store
|
||||
RecentlyAdded()
|
||||
|
||||
# map colors
|
||||
map_album_colors()
|
||||
map_artist_colors()
|
||||
|
||||
map_scrobble_data()
|
||||
map_favorites()
|
||||
|
||||
CordinateMedia(instance_key=str(time()))
|
||||
gc.collect()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@background
|
||||
def index_everything():
|
||||
return IndexEverything()
|
||||
IndexTracks()
|
||||
|
||||
key = str(time())
|
||||
TrackStore.load_all_tracks(key)
|
||||
AlbumStore.load_albums(key)
|
||||
ArtistStore.load_artists(key)
|
||||
FolderStore.load_filepaths()
|
||||
|
||||
# NOTE: Rebuild recently added items on the homepage store
|
||||
RecentlyAdded()
|
||||
|
||||
# map colors
|
||||
map_album_colors()
|
||||
map_artist_colors()
|
||||
|
||||
map_scrobble_data()
|
||||
map_favorites()
|
||||
|
||||
CordinateMedia(instance_key=str(time()))
|
||||
gc.collect()
|
||||
log.info("Indexing completed")
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
from PIL import Image, ImageSequence
|
||||
|
||||
from swingmusic import settings
|
||||
@@ -14,53 +11,60 @@ from swingmusic.models.track import Track
|
||||
from swingmusic.store.albums import AlbumStore
|
||||
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
|
||||
|
||||
new_w = round(250 * aspect_ratio)
|
||||
|
||||
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
|
||||
full_thumb_path = os.path.join(
|
||||
settings.Paths.get_app_dir(), "images", "playlists", thumb_path
|
||||
)
|
||||
thumb_name = "thumb_" + img_name
|
||||
thumb_path = settings.Paths().playlist_img_path / thumb_name
|
||||
|
||||
frames = []
|
||||
|
||||
for frame in ImageSequence.Iterator(image):
|
||||
aspect_ratio = frame.width / frame.height
|
||||
|
||||
new_w = round(250 * aspect_ratio)
|
||||
|
||||
thumb = frame.resize((new_w, 250), Image.Resampling.LANCZOS)
|
||||
|
||||
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(
|
||||
img: Image, pid: int, content_type: str = None, filename: str = None
|
||||
) -> str:
|
||||
def save_p_image(img: Image, pid: int, content_type: str = None, filename: str = None) -> str:
|
||||
"""
|
||||
Saves a playlist banner image and returns the filepath.
|
||||
"""
|
||||
@@ -71,7 +75,7 @@ def save_p_image(
|
||||
if not filename:
|
||||
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":
|
||||
frames = []
|
||||
@@ -85,7 +89,7 @@ def save_p_image(
|
||||
return filename
|
||||
|
||||
img.save(full_img_path, "webp")
|
||||
create_thumbnail(img, img_path=filename)
|
||||
create_thumbnail(img, img_name=filename)
|
||||
|
||||
return filename
|
||||
|
||||
@@ -100,9 +104,10 @@ def duplicate_images(images: list):
|
||||
|
||||
return images
|
||||
|
||||
|
||||
# TODO: mutable var in param.
|
||||
def get_first_4_images(
|
||||
tracks: list[Track] = [], trackhashes: list[str] = []
|
||||
tracks: list[Track] = [],
|
||||
trackhashes: list[str] = []
|
||||
) -> list[dict["str", str]]:
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def cleanup_playlist_images():
|
||||
def cleanup_playlist_images() -> None:
|
||||
"""
|
||||
Cleans up unlinked playlist images by comparing files in the playlist image directory
|
||||
against the .image property of all playlists.
|
||||
Deletes all unlinked files in playlist-img folder.
|
||||
All files not present in the PlaylistTable will get deleted
|
||||
"""
|
||||
# Import here to avoid circular import
|
||||
from swingmusic.db.userdata import PlaylistTable
|
||||
@@ -148,23 +153,19 @@ def cleanup_playlist_images():
|
||||
playlists = PlaylistTable.get_all()
|
||||
linked_images = {p.image for p in playlists if p.image and p.image != "None"}
|
||||
|
||||
playlist_dir = settings.Paths.get_playlist_img_path()
|
||||
all_files = os.listdir(playlist_dir)
|
||||
playlist_dir = settings.Paths().playlist_img_path
|
||||
|
||||
# Find unlinked images (including thumbnails)
|
||||
unlinked_files = []
|
||||
for file in all_files:
|
||||
if file.startswith("thumb_"):
|
||||
base_file = file[6:] # Remove "thumb_" prefix
|
||||
if base_file not in linked_images:
|
||||
unlinked_files.append(file)
|
||||
for file in playlist_dir.iterdir():
|
||||
if not file.isfile:
|
||||
continue
|
||||
|
||||
elif file not in linked_images:
|
||||
unlinked_files.append(file)
|
||||
name = file.name # not stem. PlaylistTable saves with extension
|
||||
if file not in linked_images:
|
||||
if name.removeprefix("thumb_") not in linked_images:
|
||||
continue
|
||||
|
||||
for file in unlinked_files:
|
||||
try:
|
||||
os.remove(os.path.join(playlist_dir, file))
|
||||
except OSError:
|
||||
# Skip if file doesn't exist or can't be deleted
|
||||
pass
|
||||
try:
|
||||
file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.exception("could not delete file", exc_info=e)
|
||||
@@ -1,14 +1,15 @@
|
||||
import functools
|
||||
import os
|
||||
from dataclasses import asdict
|
||||
from typing import Generator
|
||||
import multiprocessing as mp
|
||||
from requests import ReadTimeout
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from requests import ConnectionError as RequestConnectionError
|
||||
import logging
|
||||
|
||||
from swingmusic import settings
|
||||
from swingmusic.lib.artistlib import CheckArtistImages
|
||||
from swingmusic.lib.taglib import extract_thumb
|
||||
from swingmusic.logger import log
|
||||
from swingmusic.models import Album, Artist
|
||||
from swingmusic.models.lastfm import SimilarArtist
|
||||
from swingmusic.models.track import Track
|
||||
@@ -16,11 +17,13 @@ from swingmusic.store.albums import AlbumStore
|
||||
from swingmusic.store.artists import ArtistStore
|
||||
from swingmusic.utils.network import has_connection
|
||||
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.db.userdata import SimilarArtistTable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CordinateMedia:
|
||||
"""
|
||||
@@ -55,7 +58,7 @@ class CordinateMedia:
|
||||
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.
|
||||
|
||||
@@ -65,7 +68,7 @@ def get_image(tracks: list[Track]):
|
||||
"""
|
||||
|
||||
for track in tracks:
|
||||
extracted = extract_thumb(track.filepath, track.albumhash + ".webp")
|
||||
extracted = extract_thumb(track.filepath, track.albumhash + ".webp", paths)
|
||||
|
||||
if extracted:
|
||||
return
|
||||
@@ -78,19 +81,21 @@ class ProcessTrackThumbnails:
|
||||
|
||||
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]] = (
|
||||
AlbumStore.get_album_tracks(album.albumhash) for album in albums
|
||||
)
|
||||
albumsMap = ( 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(
|
||||
tqdm(
|
||||
executor.map(get_image, albumsMap),
|
||||
pool.imap_unordered(worker, albumsMap),
|
||||
total=len(albums),
|
||||
desc="Extracting track images",
|
||||
)
|
||||
@@ -103,10 +108,10 @@ class ProcessTrackThumbnails:
|
||||
Filters out albums that already have thumbnails and
|
||||
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
|
||||
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
|
||||
albums = filter(
|
||||
lambda album: album.albumhash not in processed,
|
||||
@@ -145,18 +150,21 @@ class FetchSimilarArtistsLastFM:
|
||||
processed = set(a.artisthash for a in SimilarArtistTable.get_all())
|
||||
|
||||
# filter out artists that already have similar artists using generator
|
||||
artists = (
|
||||
artist for artist in storeArtists if artist.artisthash not in processed
|
||||
)
|
||||
def artist_generator():
|
||||
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:
|
||||
try:
|
||||
# TODO: fix negative total length
|
||||
results = list(
|
||||
tqdm(
|
||||
executor.map(save_similar_artists, artists),
|
||||
total=len(storeArtists) - len(processed),
|
||||
executor.map(save_similar_artists, artist_generator()),
|
||||
total=len(artists),
|
||||
desc="Fetching similar artists",
|
||||
)
|
||||
)
|
||||
@@ -164,5 +172,5 @@ class FetchSimilarArtistsLastFM:
|
||||
list(results)
|
||||
# any exception that can be raised by the pool
|
||||
except Exception as e:
|
||||
log.warn(e)
|
||||
log.warning(e)
|
||||
return
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import division
|
||||
|
||||
import array
|
||||
import os
|
||||
import subprocess
|
||||
@@ -3,7 +3,7 @@
|
||||
"""
|
||||
import logging
|
||||
|
||||
converter_logger = logging.getLogger("pydub.converter")
|
||||
converter_logger = logging.getLogger("swingmusic.pydub.converter")
|
||||
|
||||
def log_conversion(conversion_command):
|
||||
converter_logger.debug("subprocess.call(%s)", repr(conversion_command))
|
||||