mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-10-28 10:51:15 +00:00
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]>
This commit is contained in:
197 files changed
+2961
-2150
No files matched your search
@@ -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:
|
||||
|
||||
+4
-2
@@ -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/
|
||||
+8
-27
@@ -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
|
||||
Binary file not shown.
|
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
|
||||
```
|
||||
+12
-9
@@ -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"
|
||||
+26
-60
@@ -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"
|
||||
]
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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"]),
|
||||
File renamed without changes.
File renamed without changes.
@@ -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)
|
||||
|
||||
File renamed without changes.
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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()
|
||||
File renamed without changes.
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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))
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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]):
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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,
|
||||
)
|
||||
)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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",
|
||||
)
|
||||
File renamed without changes.
File renamed without changes.
@@ -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),
|
||||
File renamed without changes.
@@ -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},
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
Vendored
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-2
@@ -1,5 +1,3 @@
|
||||
from __future__ import division
|
||||
|
||||
import array
|
||||
import os
|
||||
import subprocess
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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))
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user