merge refactors pr #364 from @michilyy

* Save to DB only unique trackhashes

* Add check if track already exists in playlist

* replace all paths with `pathlib.Path`

* `architecture.md`:
* add config folder layout

`config.py`:
* fix bug where `pathlib.Path` cannot be serialized

`files.py`:
* remove unused imports
* update path concatenation to `pathlib.Path`
* add config-folder creation

`imgserver.py`:
* fix serialisation bug

`playlistlib.py`:
* update path concatenation to `pathlib.Path`

* update all `settings.Paths` usages to new singleton `Paths` class.

* update all usages of `settings.Paths`

* `files.py`:
* rework assets copy function.
* remove unused loop and unused `shutil.copy2` function

`settings.py`
* fix recursion exception in `Paths`

* `settings.py`:
* remove Singleton and `@property` todos from `Paths`

* `__init__.py`:
* remove now unused function `create_config_dir()`

`setup.files`:
* remove because merged into `settings.Paths()`
  for more central and clear flow how the base path gets decided

`settings.py`:
* add `copy_assets` function

`start_swingmusic.py`:
* add configurable settings.Paths class

`__main__.py`:
* update click to used correct default path

* remove wrong commited egg files

* remove change in the wrong branch

* add forgotten `property` decorator
update `get_files_and_dirs` to use pathlib where possible

`config.py`:
* update type annotation

`folders.py`:
* convert `pathlib` to posix path where needed for sub-functions

`folderlib.py`:
* rework `get_files_and_dirs` to use `pathlib` where possible

`settings.py`:
* add forgotten `@property`

`start_swingmusic.py`:
* remove second `log_startup_info()`

* `artistlib.py`:
* fix calling property
`tagger.py`:
* fix comparing elements in `pathlib.Path`

* add support for repeating lyrics.

* rework lyrics api and lib

* update most path functions.
add type-hint pathlib where needed

* for serialization paths are converted to posix path

* use `open` instead of `os.open`
update `metaclass` with constant

* fix initial config exception if empty file existed

* update `userConfig` with `InitVar` to be excluded from `asdict`

* remove `is_windows_slash()`
rework path function to use pathlib

* convert `pathlib.Path` to `str` for serialization

* fixing bug with str + pathlib

* `__main__.py`:
* update click to use package version
* remove now unused function `print_version`

`filesystem.py`:
* rework `CWD` to use importlib

`pyproject.toml`:
* disable namespace for `importlib.resources` to work correctly

* update `lyrics.py`:
* remove unused functions
* simplify functions

* fix bug where assets get created on root

* remove unused code

* update lyrics for clearer structure.

* add support for unsynced lyrics

* fix wrong return type in unsynced lyrics

* update `/check` to use `send_lyrics`

* prefer tags to duplicates

* `lyrics.py`:
* add docs to a function group

* `logger.py`:
* add logging config dict.
* combine Logging into one file
* add socket logger
* add debug mode to logger
* add JSONL formater

* `logger.py`:
* update config to directly use the formater.
resolves circular import exception

`__main__.py`:
* add logger setup to main

`start_swingmusic.py`:
* add debug option to cli

* `lyrics.py`:
* add offset support

* add `setuptools-scm` to get version from git

* add support for docker build with scm

* add support for docker build with scm
need someone who can test the changes workflow

* update all usage of `version.txt` to `metadata.version()`

* 2x update all usage of `version.txt` to `metadata.version()`

* update to no local_scheme version

* provide fix for #331.
convert `sql.Row` and `TrackTable` to dict before converting to dataclass.

* fix `__main__.py`:
* wrong import and uncommited changes
* add debug and base_path parameter

* fix logger pathlib

* add client build workflow

* set name

* split client from build

* try fixing builds

* try another fix

* try also another fix

* try again something new

* try again something new

* change runner

* fix failed run because of malformed runner

* add wheel builds

* remove systems from pure python build

* add isolated pyinstaller build

* artifacts with names

* wrong wheel path

* try fetch-depth for tag fetch

* disable fail-fast.
add wheel installation

* add install system packages

* add debug

* fix wheel install
fix pyinstaller spec file

* try fix for pyinstaller

* try another fix

* build on release

* add concrete release types

* only run on released or pre-released

* try release upload

* reformat upload

* fix needs tag

* identifiable pyinstaller builds

* compress client folder before uploading

* update to src build

* remove no more needed aarch64 build script
rename pyinstaller assets to lowercase

* remove unneeded code

* fix: save to DB only unique track hashes

* replace click with argparse

* set concrete types in argparse

* replace manuall path usages with pathlib

* remove unused `configs.py` file

* reformat `start_swingmusic.py`

* fix empty set startup exception

* optimizing static files serve function

* fixing bug in optimisation of static files serve function

* fix folder view bug

* colorlib.py:
* fix wrong type exception
* remove singe use Index_everything class
* update logging of populate.py

* cleanup files

* fix settings.py Paths copy function.
Created folder on file.

* add exist check to folder

* remove unused `INFO` class

* fix multiprocessing bug on windows

* potential icon fix for pyinstaller
fix multiple logging bug

* fix argparse config path bug
add jobs file

* cleanup code fragments
fix logging issue
add notes to function

* note that concurrent creates own sys.modules

* refactor some lyrics plugin condition
remove unused import from hashing

* refactor taglib.py

* update import statements to be static

* playlistlib.py:
* refactoring and more doc strings
populate.py:
* add poc bugfix
settings.py:
* add typehint

* possible bugfix for multitreading globals

* folder.py:
* add check if provided path is absolute
populate.py:
* add bug note
settings.py:
* add possible error from Singleton implementation
start_swingmusic.py:
* correct spelling
* pass resolved path to Paths
tagger.py:
* add logging

* trying out fixes for multithreading

* only upload results not metadata

* fix build action again

* folder.py:
* strictly use pathlib where possible

folderlib.py:
* add missing docstring to function, who really need it.

track.py:
* refactor some code

folder.py:
* refactor some more code

* Merge DBPath class and Paths class.
Update all usages of DBPath

folderslib.py:
* fix bug with logging

taglib.py:
* add missing docstring

settings.py:
* merge classes
* refactor

* network.py:
* add more docstring

config.py:
* update pathlib usage

tools.py:
* refactor
* add docstrings

* colorlib.py:
* add docstring

Refactor App builder into grouped config settings.

* update assets access for migration

* Update FUNDING.yml

* Update FUNDING.yml

* upgrade tinytag in requirements.txt

* update readme

* update license

* update readme

* Update README.md

* Update README.md

* cleanup requirements.txt
remove unused import in audio_segment.py
add entrypoint.sh for appimage support
update pyproject.toml for optional dependencies
add appimage to github workflow

* fix invalid workflow file

* AppImage build needs more research.
Commenting for now

* testing a new build workflow

* add libev installation

* update workflow to new optional dependencies

* trying again another fix

* finally fix all optional deps installation correctly

* remove AppImage poc

* albumslib.py:
* add docstring

folder.py:
* add unix path fix

update logger name to `__name__`

* update build with docker
update Dockerfile with git
fix typo in lyrics.py
add dynamic deps back

* add log for static folder

* add missing import

* add some more todos

* add support for AppImages even when it's not perfect.

* quick bugfix for wrong appimage config path

* fix uploading not finding AppImages builds aka wrong pattern

* optimise docker build by using artifacts.
Add client path option.
change docstring to sphinx format

* add todos

* Now support AppImages for real:
manually build AppImage as we are building a complex project.

* fix missing dep in AppImage build

* add full AppImage metadata

* add missing image file.

* only update swingmusic appimage not tool

* add todo and fix AppImage build again.

* Try fixing some path mixup in AppImage build

* add debug tag to action

* correct path to appimage folder

* do not download tool before checkout

* Another fix for path in appimage build

* extend config files with more information

* default client dir is now inside the config dir.
TODOs updated.

* default client dir is now inside the config dir.
TODOs updated.
Add priority todos.

* Auto download client when client not found.
Respects user provided dir.

* rename `requests` submodule to `request`

* poc for arm AppImage builds

* try out another fix

* fix typo in build.yml

* add missing arch tag

* fix uploading double names

* unique naming

* enable fallback version for project.

* do not download client into readonly dir.

* fix relative client download path. Client was resolved into parent of config.

* remove client backup path as client is now downloadable

* `Paths` checks if config folder exists and creates it if necessary.
logger no more creates the config folder.
`app_builder.py`: static route no more with '/client'

* path are only created in MainProcess.
fix gz file not found.

* move assets into src and update usages accordingly

* remove solved todos

* Only upload artefacts if not draft/master aka only on tag

* wrong type in assets copy

* update log with correct priority

* add debug statements and logging to Paths

* remove debugging statement

* remove double version tag from docker build

* fork save release protection

* fix typo

* add fallback client dir for static builds.

* update argparse to new param

* add missing import pathlib

* add sparse checkout as we do not need everything downloaded

* add assets copy check

* init logger bevor Paths

* remove unused import

* check if logdir exists and create if not

* only add exec info to file

* remove exception log from cli

* move logging into main.
Allows tools support again.

* UserConfig now correctly uses _finished key.
Bug where _finished was never written

* double save serverId.
update root_dir to trow no exception on init.
remove debug param

* clean up TODOs

---------

Co-authored-by: skilletfun <skilletfun.laptew.sergey@yandex.ru>
Co-authored-by: Mungai Njoroge <geoffreymungai45@gmail.com>
This commit is contained in:
michily
2025-08-28 09:28:11 +00:00
committed by GitHub
parent b4b0a6e11f
commit e770606567
197 changed files with 2961 additions and 2150 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ The data folder is used to store all the files and data used in Swing Music. Thi
This folder contains a few folders inside: This folder contains a few folders inside:
- `/assets` - stores static assets used by the clients. THink logos, etc. - `/assets` - stores static assets used by the clients. Think logos, etc.
- `/images` - stores thumbnails, artist images and playlist covers. - `/images` - stores thumbnails, artist images and playlist covers.
- `/plugins` - stores data used by plugins - `/plugins` - stores data used by plugins
+305
View File
@@ -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/**
+20 -30
View File
@@ -51,65 +51,50 @@ jobs:
steps: steps:
- name: Clone client - name: Clone client
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Node 20
uses: actions/setup-node@v3
with: with:
node-version: 20.x fetch-tags: true
- name: Install yarn
run: |
npm install -g yarn
- name: Clone client
run: |
git clone https://github.com/cwilvx/swingmusic-client.git
- name: Install dependencies & Build client
run: |
cd swingmusic-client
yarn install
yarn build --outDir ../client
cd ..
- name: Install Python 3.11 - name: Install Python 3.11
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: "3.11.x" python-version: "3.11.x"
- name: Create virtualenv - name: Create virtualenv
run: | run: |
python -m venv .venv python -m venv .venv
- name: Setup Homebrew - name: Setup Homebrew
if: ${{ startsWith(matrix.os, 'macos') }} if: ${{ startsWith(matrix.os, 'macos') }}
uses: Homebrew/actions/setup-homebrew@master uses: Homebrew/actions/setup-homebrew@master
- name: Install libev (macOS) - name: Install libev (macOS)
if: ${{ startsWith(matrix.os, 'macos') }} if: ${{ startsWith(matrix.os, 'macos') }}
run: | run: |
brew install libev brew install libev
- name: Install libev (Linux) - name: Install libev (Linux)
if: ${{ startsWith(matrix.os, 'ubuntu') }} if: ${{ startsWith(matrix.os, 'ubuntu') }}
run: | run: |
sudo apt-get install libev-dev -y > /dev/null sudo apt-get install libev-dev -y > /dev/null
- name: Activate virtualenv (unix) - name: Activate virtualenv (unix)
if: ${{ !startsWith(matrix.os, 'win') }} if: ${{ !startsWith(matrix.os, 'win') }}
run: | run: |
source .venv/bin/activate && echo "bjoern==3.2.2" >> requirements.txt source .venv/bin/activate
- name: Activate virtualenv (windows) - name: Activate virtualenv (windows)
if: ${{ startsWith(matrix.os, 'win') }} if: ${{ startsWith(matrix.os, 'win') }}
run: | run: |
.venv\Scripts\Activate && echo "waitress==3.0.2" >> requirements.txt .venv\Scripts\Activate
- name: Install dependencies
- name: Install swingmusic
run: | run: |
pip install -r requirements.txt pip install .
- name: Write version file
run: |
echo ${{ inputs.tag }} > version.txt
# - name: Install Poetry
# run: |
# pip install poetry
# - name: Install dependencies
# run: |
# python -m poetry install
- name: Build server - name: Build server
run: | run: |
python run.py --build python run.py --build
env:
SWINGMUSIC_APP_VERSION: ${{ inputs.tag }}
- name: Rename Unix binary - name: Rename Unix binary
if: ${{ !startsWith(matrix.os, 'win') }} if: ${{ !startsWith(matrix.os, 'win') }}
run: | run: |
@@ -124,6 +109,7 @@ jobs:
else else
mv dist/swingmusic dist/swingmusic_linux_amd64 mv dist/swingmusic dist/swingmusic_linux_amd64
fi fi
- name: Verify Unix build success - name: Verify Unix build success
if: ${{ !startsWith(matrix.os, 'win') }} if: ${{ !startsWith(matrix.os, 'win') }}
run: | run: |
@@ -150,6 +136,7 @@ jobs:
exit 1 exit 1
fi fi
fi fi
- name: Verify Windows build success - name: Verify Windows build success
if: ${{ startsWith(matrix.os, 'win') }} if: ${{ startsWith(matrix.os, 'win') }}
run: | run: |
@@ -164,6 +151,7 @@ jobs:
exit 1 exit 1
} }
} }
- name: Upload Unix binary - name: Upload Unix binary
if: ${{ !startsWith(matrix.os, 'win') }} if: ${{ !startsWith(matrix.os, 'win') }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -171,6 +159,7 @@ jobs:
name: ${{ matrix.os == 'ubuntu-22.04-arm64' && 'linux-arm64' || matrix.os == 'macos-14' && 'macos-arm64' || matrix.os == 'macos-13' && 'macos-amd64' || 'linux-amd64' }} name: ${{ matrix.os == 'ubuntu-22.04-arm64' && 'linux-arm64' || matrix.os == 'macos-14' && 'macos-arm64' || matrix.os == 'macos-13' && 'macos-amd64' || 'linux-amd64' }}
path: ${{ matrix.os == 'ubuntu-22.04-arm64' && 'dist/swingmusic_linux_arm64' || matrix.os == 'macos-14' && 'dist/swingmusic_macos_arm64' || matrix.os == 'macos-13' && 'dist/swingmusic_macos_amd64' || 'dist/swingmusic_linux_amd64' }} path: ${{ matrix.os == 'ubuntu-22.04-arm64' && 'dist/swingmusic_linux_arm64' || matrix.os == 'macos-14' && 'dist/swingmusic_macos_arm64' || matrix.os == 'macos-13' && 'dist/swingmusic_macos_amd64' || 'dist/swingmusic_linux_amd64' }}
retention-days: 1 retention-days: 1
- name: Upload Windows binary - name: Upload Windows binary
if: ${{ startsWith(matrix.os, 'win') }} if: ${{ startsWith(matrix.os, 'win') }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -277,6 +266,7 @@ jobs:
# list of Docker images to use as base name for tags # list of Docker images to use as base name for tags
images: | images: |
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
+3 -1
View File
@@ -2,6 +2,7 @@
.env.local .env.local
.env.*.local .env.*.local
venv venv
.venv
# Editor directories and files # Editor directories and files
.idea .idea
@@ -25,7 +26,7 @@ client
.gitignore .gitignore
logs.txt logs.txt
*.spec
# TODO.md # TODO.md
testdata.py testdata.py
@@ -36,3 +37,4 @@ nohup.out
.DS_Store .DS_Store
*.egg-info *.egg-info
/wheels/
+8 -27
View File
@@ -1,41 +1,22 @@
FROM node:latest AS CLIENT
RUN git clone --depth 1 https://github.com/swing-opensource/swingmusic-client.git client
WORKDIR /client
# RUN git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
# checkout the latest tag
# RUN git checkout $client_tag
RUN yarn install
RUN yarn build
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app/swingmusic WORKDIR /app/swingmusic
# Copy the files in the current dir into the container # Copy the files in the current dir into the container
COPY . . # copy wheelhouse and client
COPY wheels wheels
COPY client /config/client
COPY --from=CLIENT /client/dist/ client
LABEL "author"="swing music"
EXPOSE 1970/tcp EXPOSE 1970/tcp
VOLUME /music VOLUME /music
VOLUME /config VOLUME /config
RUN apt-get update && apt-get install -y gcc libev-dev python3-dev -y ffmpeg libavcodec-extra gcc-aarch64-linux-gnu && \ RUN apt-get update && apt-get install -y gcc git libev-dev python3-dev ffmpeg libavcodec-extra && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
RUN pip install -r requirements.txt RUN pip install --no-cache-dir --find-links=wheels/ swingmusic
RUN pip install bjoern Run rm -rf /app/swingmusic/wheels
ARG app_version ENTRYPOINT ["python", "-m", "swingmusic", "--host", "0.0.0.0", "--config", "/config", "--client", "/config/client"]
ENV SWINGMUSIC_APP_VERSION=$app_version
# dump the app_version to the version.txt file
RUN echo $app_version > version.txt
ENTRYPOINT ["python", "run.py", "--host", "0.0.0.0", "--config", "/config"]
+27
View File
@@ -1,3 +1,29 @@
# @michily TODO
## UI
* Auto update WebUI - version check + api missing
* Web UI - Remove https from index.html for http support?
* Web UI could use continues build like https://github.com/AppImage/AppImageKit/releases/download/continuous/
* Web UI - playlist not shown in folder view
* rework argparse with subparser. Currently not clear what commands allow what args
## Building:
* AppImage build is currently broken view [python-appimage: Issues 95](https://github.com/niess/python-appimage/issues/94) aka I bypassed it.
* Optimise docker/speed build up
## Server:
* Rework song name/autor/.. parsing to only support filetags. Only fall back when user-enabled and manual regex is set. see Telegram
* Publish this on PyPi
## Multithreading
* Multiprocessing creates new paths - sync between processes. <- env is recommended.
* Fix singleton global in multiprocessing - own process, own memory, own sys.modules cache <- env is recommended.
## Auth:
* more multiuser control
* audit log
* one auth method for all e.g. jwt in Header?
# TODO # TODO
- Migrations: - Migrations:
@@ -15,6 +41,7 @@
- rename userid to server id in config file - rename userid to server id in config file
- Look into seeding jwts using user password + server id - Look into seeding jwts using user password + server id
<!-- CHECKPOINT --> <!-- CHECKPOINT -->
<!-- ALBUM PAGE! --> <!-- ALBUM PAGE! -->
-50
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
exec "${APPDIR}/usr/bin/python" -m swingmusic --fallback-client "${APPDIR}/client" "$@"
+27
View File
@@ -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
+25
View File
@@ -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>
+9
View File
@@ -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

+26
View File
@@ -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
View File
@@ -1,13 +1,13 @@
[build-system] [build-system]
requires = ["setuptools"] requires = ["setuptools", "setuptools-scm"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "swingmusic" name = "swingmusic"
version = "2.0.6"
description = "Swing Music" description = "Swing Music"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11, <=3.12" requires-python = ">=3.11, <=3.12"
dynamic = ["version"]
dependencies = [ dependencies = [
"pillow>=11.1.0", "pillow>=11.1.0",
@@ -37,11 +37,15 @@ dependencies = [
"rapidfuzz==3.11.0", "rapidfuzz==3.11.0",
"pendulum>=3.0.0", "pendulum>=3.0.0",
"pystray>=0.19.5", "pystray>=0.19.5",
"pyinstaller==6.12.0",
"waitress>=3.0.2; sys_platform == 'win32'", "waitress>=3.0.2; sys_platform == 'win32'",
"bjoern >=3.2.2; sys_platform != 'win32'" "bjoern >=3.2.2; sys_platform != 'win32'"
] ]
[project.optional-dependencies]
build = [
"pyinstaller"
]
[tool.uv] [tool.uv]
dependency-metadata = [ dependency-metadata = [
{ name = "waitress", version = "3.0.2", requires-dist = [], requires-python = ">=3.11" }, { name = "waitress", version = "3.0.2", requires-dist = [], requires-python = ">=3.11" },
@@ -49,7 +53,7 @@ dependency-metadata = [
] ]
[project.scripts] [project.scripts]
swingmusic = "swingmusic.__main__:main" swingmusic = "swingmusic.__main__:run"
[project.urls] [project.urls]
@@ -59,8 +63,7 @@ Documentation = "https://swingmx.com/guide/introduction.html"
Issues = "https://github.com/swingmx/swingmusic/issues" Issues = "https://github.com/swingmx/swingmusic/issues"
[tool.setuptools] [tool.setuptools_scm]
package-dir = {"swingmusic" = "swingmusic"} version_scheme = "only-version"
local_scheme = "no-local-version"
[tool.setuptools.package-data] fallback_version = "v0.0.0"
swingmusic = ["client/*"]
+26 -60
View File
@@ -1,62 +1,28 @@
altgraph==0.17.4 pillow>=11.1.0
annotated-types==0.7.0 Flask>=3.1.0
blinker==1.9.0 Flask-Cors>=3.0.10
brotli==1.1.0 requests>=2.27.1
certifi==2025.1.31 colorgram.py>=1.2.0
charset-normalizer==3.4.1 tqdm>=4.65.0
click==8.1.8 tinytag>=2.1.1
colorgram-py==1.2.0 Unidecode>=1.3.6
configargparse==1.7 psutil>=5.9.4
ffmpeg-python==0.2.0 show-in-file-manager>=1.1.4
flask==3.1.0 flask-compress>=1.13
flask-compress==1.17 tabulate>=0.9.0
flask-cors==5.0.1 setproctitle>=1.3.2
flask-jwt-extended==4.7.1 locust>=2.20.1
flask-login==0.6.3 watchdog>=4.0.0
flask-jwt-extended>=4.6.0
sqlalchemy>=2.0.31
memory-profiler>=0.61.0
sortedcontainers>=2.4.0
xxhash>=3.4.1
ffmpeg-python>=0.2.0
schedule>=1.2.2
flask-openapi3==3.0.2 flask-openapi3==3.0.2
future==1.0.0
gevent==24.11.1
geventhttpclient==2.3.3
greenlet==3.1.1
idna==3.10
itsdangerous==2.2.0
jinja2==3.1.5
locust==2.32.10
markupsafe==3.0.2
memory-profiler==0.61.0
msgpack==1.1.0
packaging==24.2
pendulum==3.0.0
pillow==11.1.0
psutil==7.0.0
pydantic==2.10.6
pydantic-core==2.27.2
pyinstaller==6.12.0
pyinstaller-hooks-contrib==2025.1
pyjwt==2.10.1
python-dateutil==2.9.0.post0
pyxdg==0.28
pyzmq==26.2.1
rapidfuzz==3.11.0 rapidfuzz==3.11.0
requests==2.32.3 pendulum>=3.0.0
schedule==1.2.2 pystray>=0.19.5
setproctitle==1.3.5 waitress==3.0.2; sys_platform == 'win32'
setuptools==75.8.0 bjoern>=3.2.2; sys_platform != 'win32'
show-in-file-manager==1.1.5
six==1.17.0
sortedcontainers==2.4.0
sqlalchemy==2.0.38
tabulate==0.9.0
time-machine==2.16.0
tinytag==2.1.1
tqdm==4.67.1
typing-extensions==4.12.2
tzdata==2025.1
unidecode==1.3.8
urllib3==2.3.0
watchdog==6.0.0
werkzeug==3.1.3
xxhash==3.5.0
zope-event==5.0
zope-interface==7.2
zstandard==0.23.0
+10 -3
View File
@@ -1,9 +1,16 @@
# Launcher script # Launcher script
import multiprocessing
import swingmusic.__main__ as app import swingmusic.__main__ as app
import sys
import multiprocessing
if __name__ == "__main__": if __name__ == "__main__":
# this entry should only be used by pyinstaller.
# add freeze support here as pyinstaller uses this entry only
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
client = sys._MEIPASS + "/client"
sys.argv.extend(["--fallback-client", client])
sys.orig_argv.extend(["--fallback-client", client])
multiprocessing.freeze_support() multiprocessing.freeze_support()
multiprocessing.set_start_method("spawn")
app.run() app.run()
+97
View File
@@ -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()
+35
View File
@@ -0,0 +1,35 @@
"""
This module combines all API blueprints into a single Flask app instance.
"""
from swingmusic.api import (
album,
artist,
collections,
colors,
favorites,
folder,
imgserver,
playlist,
search,
settings,
lyrics,
plugins,
scrobble,
home,
getall,
auth,
stream,
backup_and_restore,
)
from swingmusic.api.plugins import lyrics as lyrics_plugin
from swingmusic.api.plugins import mixes as mixes_plugin
__all__ = [
"album", "artist", "collections", "colors", "favorites", "folder", "imgserver", "playlist", "search", "settings",
"lyrics", "plugins", "scrobble", "home", "getall", "auth", "stream", "backup_and_restore",
"lyrics_plugin",
"mixes_plugin"
]
@@ -69,7 +69,7 @@ def backup():
playlist_dicts.append(playlist) playlist_dicts.append(playlist)
# copy images # copy images
img_path = Path(Paths.get_playlist_img_path()) / str(playlist["image"]) img_path = Path(Paths().playlist_img_path) / str(playlist["image"])
if img_path.exists(): if img_path.exists():
if not img_folder_created: if not img_folder_created:
img_folder.mkdir(parents=True) img_folder.mkdir(parents=True)
@@ -1,7 +1,7 @@
""" """
Contains all the folder routes. Contains all the folder routes.
""" """
import pathlib
from datetime import datetime from datetime import datetime
import os import os
from pathlib import Path from pathlib import Path
@@ -19,7 +19,7 @@ from swingmusic.db.userdata import FavoritesTable, PlaylistTable
from swingmusic.lib.folderslib import get_files_and_dirs, get_folders from swingmusic.lib.folderslib import get_files_and_dirs, get_folders
from swingmusic.serializers.track import serialize_track, serialize_tracks from swingmusic.serializers.track import serialize_track, serialize_tracks
from swingmusic.store.tracks import TrackStore from swingmusic.store.tracks import TrackStore
from swingmusic.utils.wintools import is_windows, win_replace_slash from swingmusic.utils.wintools import is_windows
tag = Tag(name="Folders", description="Get folders and tracks in a directory") tag = Tag(name="Folders", description="Get folders and tracks in a directory")
api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag]) api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
@@ -83,16 +83,10 @@ def get_folder_tree(body: FolderTree):
config = UserConfig() config = UserConfig()
root_dirs = config.rootDirs root_dirs = config.rootDirs
try: if req_dir == "$home" and "$home" in root_dirs:
if req_dir == "$home" and root_dirs[0] == "$home": req_dir = settings.Paths().USER_HOME_DIR.as_posix()
req_dir = settings.Paths.USER_HOME_DIR
except IndexError:
pass
if req_dir == "$home": if req_dir == "$home":
if len(root_dirs) == 1:
req_dir = root_dirs[0]
else:
folders = get_folders(root_dirs) folders = get_folders(root_dirs)
return { return {
@@ -148,16 +142,14 @@ def get_folder_tree(body: FolderTree):
"path": req_dir, "path": req_dir,
} }
if is_windows(): # TODO: currently only fixed on unix. Windows/Mac still pending.
# Trailing slash needed when drive letters are passed, # note
# Remember, the trailing slash is removed in the client.
# req_dir += "/" if not pathlib.Path(req_dir).exists():
pass req_dir = "/" + req_dir
else:
req_dir = "/" + req_dir if not req_dir.startswith("/") else req_dir
results = get_files_and_dirs( results = get_files_and_dirs(
req_dir, pathlib.Path(req_dir),
start=body.start, start=body.start,
limit=body.limit, limit=body.limit,
tracks_only=tracks_only, tracks_only=tracks_only,
@@ -236,21 +228,34 @@ def list_folders(body: DirBrowserBody):
"folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)] "folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)]
} }
if is_win:
req_dir += "/" req_dir = pathlib.Path(req_dir)
else:
req_dir = "/" + req_dir + "/" if not req_dir.exists():
req_dir = str(Path(req_dir).resolve()) req_dir = "/" / req_dir
try: try:
entries = os.scandir(req_dir) entries = os.scandir(req_dir)
except PermissionError: except PermissionError:
return {"folders": []} return {"folders": []}
dirs = [e.name for e in entries if e.is_dir() and not e.name.startswith(".")] # only get dirs and remove hidden dirs
dirs = [ dirs = []
{"name": d, "path": win_replace_slash(os.path.join(req_dir, d))} for d in dirs for entry in entries:
] entry = pathlib.Path(entry)
name = entry.name
if name.startswith("$"): # ignore windows system folder
continue
if name.startswith("."): # ignore unix hidden folder
continue
if entry.is_dir(): # lastly, check if is dir
dirs.append({
"name": name,
"path": entry.as_posix()
})
return { return {
"folders": sorted(dirs, key=lambda i: i["name"]), "folders": sorted(dirs, key=lambda i: i["name"]),
@@ -23,7 +23,7 @@ def cache_thumbnails(filepath: Path, trackhash: str):
Resizes the image and stores it in the cache directory. Resizes the image and stores it in the cache directory.
""" """
image = Image.open(filepath) image = Image.open(filepath)
path = Path(Paths.get_image_cache_path()) path = Path(Paths().image_cache_path)
aspect_ratio = image.width / image.height aspect_ratio = image.width / image.height
sizes = { sizes = {
@@ -89,7 +89,7 @@ def send_fallback_img(filename: str = "default.webp"):
""" """
Returns the fallback image from the assets folder. Returns the fallback image from the assets folder.
""" """
folder = Paths.get_assets_path() folder = Paths().assets_path
img = Path(folder) / filename img = Path(folder) / filename
if not img.exists(): if not img.exists():
@@ -111,7 +111,7 @@ def send_file_or_fallback(
if pathhash != "": if pathhash != "":
# INFO: Check if the image is in the cache # INFO: Check if the image is in the cache
cache_path = Path(Paths.get_image_cache_path()) / fpath.parent.name / filename cache_path = Paths().image_cache_path / fpath.parent.name / filename
if cache_path.exists(): if cache_path.exists():
return send_from_directory(cache_path.parent, cache_path.name) return send_from_directory(cache_path.parent, cache_path.name)
@@ -162,7 +162,7 @@ def send_lg_thumbnail(path: ImagePath, query: ImageQuery):
""" """
Get large thumbnail (500 x 500) Get large thumbnail (500 x 500)
""" """
folder = Paths.get_lg_thumb_path() folder = Paths().lg_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash) return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@@ -171,7 +171,7 @@ def send_xsm_thumbnail(path: ImagePath, query: ImageQuery):
""" """
Get extra small thumbnail (64px) Get extra small thumbnail (64px)
""" """
folder = Paths.get_xsm_thumb_path() folder = Paths().xsm_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash) return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@@ -180,7 +180,7 @@ def send_sm_thumbnail(path: ImagePath, query: ImageQuery):
""" """
Get small thumbnail (96px) Get small thumbnail (96px)
""" """
folder = Paths.get_sm_thumb_path() folder = Paths().sm_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash) return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@@ -189,7 +189,7 @@ def send_md_thumbnail(path: ImagePath, query: ImageQuery):
""" """
Get medium thumbnail (256px) Get medium thumbnail (256px)
""" """
folder = Paths.get_md_thumb_path() folder = Paths().md_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash) return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@@ -199,8 +199,8 @@ def send_lg_artist_image(path: ImagePath):
""" """
Get large artist image (500 x 500) Get large artist image (500 x 500)
""" """
folder = Paths.get_lg_artist_img_path() folder = Paths().lg_artist_img_path
return send_file_or_fallback(folder, path.imgpath, "artist.webp") return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
@api.get("/artist/small/<imgpath>") @api.get("/artist/small/<imgpath>")
@@ -208,8 +208,8 @@ def send_sm_artist_image(path: ImagePath):
""" """
Get small artist image (128) Get small artist image (128)
""" """
folder = Paths.get_sm_artist_img_path() folder = Paths().sm_artist_img_path
return send_file_or_fallback(folder, path.imgpath, "artist.webp") return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
@api.get("/artist/medium/<imgpath>") @api.get("/artist/medium/<imgpath>")
@@ -217,7 +217,7 @@ def send_md_artist_image(path: ImagePath):
""" """
Get medium artist image (256px) Get medium artist image (256px)
""" """
folder = Paths.get_md_artist_img_path() folder = Paths().md_artist_img_path
return send_file_or_fallback(folder, path.imgpath, "artist.webp") return send_file_or_fallback(folder, path.imgpath, "artist.webp")
@@ -236,7 +236,7 @@ def send_playlist_image(path: PlaylistImagePath):
Images are constructed as '{playlist_id}.webp' Images are constructed as '{playlist_id}.webp'
""" """
folder = Paths.get_playlist_img_path() folder = Paths().playlist_img_path
return send_file_or_fallback(folder, path.imgpath, "playlist.svg") return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
@@ -246,7 +246,7 @@ def send_md_mix_image(path: ImagePath):
""" """
Get medium mix image Get medium mix image
""" """
folder = Paths.get_md_mixes_img_path() folder = Paths().md_mixes_img_path
return send_file_or_fallback(folder, path.imgpath, "playlist.svg") return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
@@ -255,5 +255,5 @@ def send_sm_mix_image(path: ImagePath):
""" """
Get small mix image Get small mix image
""" """
folder = Paths.get_sm_mixes_img_path() folder = Paths().sm_mixes_img_path
return send_file_or_fallback(folder, path.imgpath, "playlist.svg") return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
+77
View File
@@ -0,0 +1,77 @@
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import Field
from swingmusic.store.tracks import TrackStore
from swingmusic.api.apischemas import TrackHashSchema
from swingmusic.lib.lyrics import (
get_lyrics_file,
get_lyrics_from_duplicates,
get_lyrics_from_tags,
)
bp_tag = Tag(name="Lyrics", description="Get lyrics")
api = APIBlueprint("lyrics", __name__, url_prefix="/lyrics", abp_tags=[bp_tag])
class SendLyricsBody(TrackHashSchema):
filepath: str = Field(description="The path to the file")
@api.post("")
def send_lyrics(body: SendLyricsBody):
"""
Returns the lyrics for a track
"""
# 1. try to get lyrics by .lrc / .elrc file
# 2. try to get lyrics by extra key
# 3. try to get by duplicates
# 4. iter plugins
filepath = body.filepath
trackhash = body.trackhash
# get copyright first
copyright = ""
if entry:=TrackStore.trackhashmap.get(trackhash, None):
for track in entry.tracks:
copyright = track.copyright
if copyright:
break
lyrics = get_lyrics_file(filepath)
if not lyrics:
lyrics = get_lyrics_from_tags(trackhash) # type: ignore
if not lyrics:
lyrics = get_lyrics_from_duplicates(filepath, trackhash)
# check lyrics plugins
if not lyrics:
return {"error": "No lyrics found"}
if lyrics.is_synced:
text = lyrics.format_synced_lyrics()
else:
text = lyrics.format_unsynced_lyrics()
return {"lyrics": text, "synced": lyrics.is_synced, "copyright": copyright}, 200
@api.post("/check")
def check_lyrics(body: SendLyricsBody):
"""
Checks if lyrics file or tag exists for a track
"""
result = send_lyrics(body)
if "error" in result:
return {"exists": False}
else:
return {"exists": True}, 200
@@ -177,11 +177,13 @@ def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
""" """
itemtype = body.itemtype itemtype = body.itemtype
itemhash = body.itemhash itemhash = body.itemhash
playlist_id = path.playlistid playlist_id = int(path.playlistid)
sortoptions = body.sortoptions sortoptions = body.sortoptions
if itemtype == "tracks": if itemtype == "tracks":
trackhashes = itemhash.split(",") trackhashes = itemhash.split(",")
if len(trackhashes) == 1 and trackhashes[0] in PlaylistTable.get_trackhashes(playlist_id):
return {"msg": "Track already exists in playlist"}, 409
elif itemtype == "folder": elif itemtype == "folder":
trackhashes = get_path_trackhashes( trackhashes = get_path_trackhashes(
itemhash, itemhash,
@@ -195,7 +197,7 @@ def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
else: else:
trackhashes = [] trackhashes = []
PlaylistTable.append_to_playlist(int(playlist_id), trackhashes) PlaylistTable.append_to_playlist(playlist_id, trackhashes)
return {"msg": "Done"}, 200 return {"msg": "Done"}, 200
@@ -461,9 +463,9 @@ def save_item_as_playlist(body: SavePlaylistAsItemBody):
filename = itemhash + ".webp" filename = itemhash + ".webp"
base_path = ( base_path = (
Paths.get_lg_artist_img_path() Paths().lg_artist_img_path
if itemtype == "artist" if itemtype == "artist"
else Paths.get_lg_thumb_path() else Paths().lg_thumb_path()
) )
img_path = pathlib.Path(base_path + "/" + filename) img_path = pathlib.Path(base_path + "/" + filename)
@@ -2,7 +2,7 @@ from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint from flask_openapi3 import APIBlueprint
from pydantic import Field from pydantic import Field
from swingmusic.api.apischemas import TrackHashSchema from swingmusic.api.apischemas import TrackHashSchema
from swingmusic.lib.lyrics import format_synced_lyrics from swingmusic.lib.lyrics import Lyrics as Lyrics_class
from swingmusic.plugins.lyrics import Lyrics from swingmusic.plugins.lyrics import Lyrics
from swingmusic.settings import Defaults from swingmusic.settings import Defaults
@@ -47,18 +47,14 @@ def search_lyrics(body: LyricsSearchBody):
i_title = track["title"] i_title = track["title"]
i_album = track["album"] i_album = track["album"]
if create_hash(i_title) == create_hash(title) and create_hash( if create_hash(i_title) == create_hash(title) and create_hash(i_album) == create_hash(album):
i_album
) == create_hash(album):
perfect_match = track perfect_match = track
track_id = perfect_match["track_id"] track_id = perfect_match["track_id"]
lrc = finder.download_lyrics(track_id, filepath) lrc = finder.download_lyrics(track_id, filepath)
if lrc is not None: if lrc is not None:
lines = lrc.split("\n") lyrics = Lyrics_class(lrc)
lyrics = format_synced_lyrics(lines) return {"trackhash": trackhash, "lyrics": lyrics.format_synced_lyrics()}, 200
return {"trackhash": trackhash, "lyrics": lyrics}, 200
return {"trackhash": trackhash, "lyrics": lrc}, 200 return {"trackhash": trackhash, "lyrics": lrc}, 200
@@ -1,4 +1,5 @@
from dataclasses import asdict from dataclasses import asdict
from importlib import metadata
from typing import Any from typing import Any
from flask_openapi3 import Tag from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint from flask_openapi3 import APIBlueprint
@@ -7,7 +8,6 @@ from swingmusic.api.auth import admin_required
from swingmusic.db.userdata import PluginTable from swingmusic.db.userdata import PluginTable
from swingmusic.lib.index import index_everything from swingmusic.lib.index import index_everything
from swingmusic.settings import Info
from swingmusic.config import UserConfig from swingmusic.config import UserConfig
from swingmusic.utils.auth import get_current_userid from swingmusic.utils.auth import get_current_userid
@@ -102,7 +102,7 @@ def get_all_settings():
config[key] = sorted(list(value)) config[key] = sorted(list(value))
config["plugins"] = [p for p in PluginTable.get_all()] config["plugins"] = [p for p in PluginTable.get_all()]
config["version"] = Info.SWINGMUSIC_APP_VERSION config["version"] = metadata.version("swingmusic")
# only return lastfmSessionKey for the current user # only return lastfmSessionKey for the current user
current_user = get_current_userid() current_user = get_current_userid()
+244
View File
@@ -0,0 +1,244 @@
from importlib import metadata
import datetime as dt
import pathlib
import logging
from flask import Response, request
from flask_cors import CORS
from flask_compress import Compress
from flask_openapi3 import Info
from flask_openapi3 import OpenAPI
from flask_jwt_extended import JWTManager, create_access_token, get_jwt, get_jwt_identity, set_access_cookies, verify_jwt_in_request
from swingmusic import api as swing_api
from swingmusic.config import UserConfig
from swingmusic.db.userdata import UserTable
from swingmusic.settings import Paths
from swingmusic.utils.paths import get_client_files_extensions
from swingmusic.api.plugins import lyrics as lyrics_plugin
from swingmusic.api.plugins import mixes as mixes_plugin
log = logging.getLogger(__name__)
# # # # # # # # # # # # # # # # # #
# Grouped configuration function #
# # # # # # # # # # # # # # # # # #
def config_app(web):
# CORS
CORS(web, origins="*", supports_credentials=True)
# RESPONSE COMPRESSION
# Only compress JSON responses
Compress(web)
web.config["COMPRESS_MIMETYPES"] = [
"application/json",
]
def config_jwt(web):
# JWT CONFIGS
web.config["JWT_VERIFY_SUB"] = False
web.config["JWT_SECRET_KEY"] = UserConfig().serverId
web.config["JWT_TOKEN_LOCATION"] = ["cookies", "headers"]
web.config["JWT_COOKIE_CSRF_PROTECT"] = False
web.config["JWT_SESSION_COOKIE"] = False
jwt_expiry = int(dt.timedelta(days=30).total_seconds())
web.config["JWT_ACCESS_TOKEN_EXPIRES"] = jwt_expiry
jwt = JWTManager(web)
@jwt.user_lookup_loader
def user_lookup_callback(_jwt_header, jwt_data):
identity = jwt_data["sub"]
userid = identity["id"]
user = UserTable.get_by_id(userid)
if user:
return user.todict()
def load_endpoints(web):
# Register all the API blueprints
with web.app_context():
web.register_api(swing_api.album.api)
web.register_api(swing_api.artist.api)
web.register_api(swing_api.stream.api)
web.register_api(swing_api.search.api)
web.register_api(swing_api.folder.api)
web.register_api(swing_api.playlist.api)
web.register_api(swing_api.favorites.api)
web.register_api(swing_api.imgserver.api)
web.register_api(swing_api.settings.api)
web.register_api(swing_api.colors.api)
web.register_api(swing_api.lyrics.api)
web.register_api(swing_api.backup_and_restore.api)
web.register_api(swing_api.collections.api)
# Logger
web.register_api(swing_api.scrobble.api)
# Home
web.register_api(swing_api.home.api)
web.register_api(swing_api.getall.api)
# Auth
web.register_api(swing_api.auth.api)
def load_plugins(web):
# TODO: rework plugin support
# Plugins
web.register_api(swing_api.plugins.api)
web.register_api(lyrics_plugin.api)
web.register_api(mixes_plugin.api)
# # # # # # # # # # #
# Create App object #
# # # # # # # # # # #
api_info = Info(
title="Swing Music",
version=f"v{metadata.version('swingmusic')}",
description="The REST API exposed by your Swing Music server",
)
app = OpenAPI(__name__, info=api_info, doc_prefix="/docs")
def check_auth_need() -> bool:
"""
Check if the current request is for a static file.
We do not need auth for index or static images of index.
:return: True if static file else False
"""
# INFO: Routes that don't need authentication
urls = {
"/auth/login",
"/auth/users",
"/auth/pair",
"/auth/logout",
"/auth/refresh",
"/docs",
}
files = {
".webp",
".jpg",
*get_client_files_extensions()
}
urls = tuple(urls)
files = tuple(files)
if request.path == "/" or request.path.endswith(files):
return True
# if request path starts with any of the blacklisted routes, don't verify jwt
if request.path.startswith(urls):
return True
return False
# # # # # # # # # # # # #
# global endpoint logic #
# # # # # # # # # # # # #
@app.route("/<path:path>")
def serve_client_files(path: str):
"""
Serves the static files in the client folder.
"""
# TODO: rule out possible double /client path.
# path sometimes prepended with /client like '/client/some.js' resolves to '/client/client/some.js'
js_or_css = path.endswith(".js") or path.endswith(".css")
if not js_or_css:
return app.send_static_file(path)
# INFO: Safari doesn't support gzip encoding
# See issue: https://github.com/swingmx/swingmusic/issues/155
user_agent = request.headers.get("User-Agent", "")
if "Safari" in user_agent and not "Chrome" in user_agent:
return app.send_static_file(path)
if "gzip" in request.headers.get("Accept-Encoding", ""):
gz_name = path + ".gz"
gzipped_path = pathlib.Path(app.static_folder or "") / gz_name
if gzipped_path.exists():
response = app.make_response(app.send_static_file(gz_name))
response.headers["Content-Encoding"] = "gzip"
return response
return app.send_static_file(path)
@app.route("/")
def serve_client():
"""
Serves the index.html file at `client/index.html`.
"""
return app.send_static_file("index.html")
def build() -> OpenAPI:
"""
Call this function to obtain the final flask/openapi object.
Do not import app directly as the static_folder can only be set
when cli args are parsed.
:return: OpenApi object with all config set
"""
# set late state config
app.static_folder = Paths().client_path
log.info(f"Serving client from '{app.static_folder}'")
@app.before_request
def verify_auth():
"""
Verifies the JWT token before each request.
"""
if check_auth_need():
return
verify_jwt_in_request()
@app.after_request
def refresh_expiring_jwt(response: Response):
"""
Refreshes the cookies JWT token after each request.
"""
# INFO: If the request has an Authorization header, don't refresh the jwt
# Request is probably from the mobile client or a third party
if check_auth_need() or request.headers.get("Authorization"):
return response
try:
exp_timestamp = get_jwt()["exp"]
until = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=7)
if until.timestamp() > exp_timestamp:
access_token = create_access_token(identity=get_jwt_identity())
set_access_cookies(response, access_token)
return response
except (RuntimeError, KeyError):
return response
config_app(app)
config_jwt(app)
load_endpoints(app)
load_plugins(app)
return app

Before

Width:  |  Height:  |  Size: 654 B

After

Width:  |  Height:  |  Size: 654 B

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 737 B

After

Width:  |  Height:  |  Size: 737 B

@@ -1,32 +1,34 @@
from dataclasses import dataclass, asdict, field import importlib.resources
import json import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .settings import Paths from dataclasses import dataclass, asdict, field, InitVar
from swingmusic.settings import Paths, Singleton
# TODO: Publish this on PyPi
def load_artist_ignore_list_from_file(filepath: Path) -> set[str]: def load_artist_ignore_list_from_file(filepath: Path) -> set[str]:
""" """
Loads artist names from a text file. Loads artist names from a text file.
Returns an empty set if the file doesn't exist.
:params filepath: filepath to file
:returns: Lines with content as ``set``, else empty ``set``
""" """
try: if filepath.exists():
return { text = filepath.read_text()
line.strip() for line in filepath.read_text().splitlines() if line.strip() return set([ line.strip() for line in text.splitlines() if line.strip() ])
} else:
except FileNotFoundError:
return set() return set()
def load_default_artist_ignore_list() -> set[str]: def load_default_artist_ignore_list() -> set[str]:
""" """
Loads the default artist ignore list from the text file. Loads the default artist-ignore-list from the text file.
Returns an empty set if the file doesn't exist. Returns an empty set if the file doesn't exist.
""" """
default_file = Path(__file__).parent / "data" / "artist_split_ignore.txt" text = importlib.resources.read_text("swingmusic.data","artist_split_ignore.txt")
return load_artist_ignore_list_from_file(default_file) # only return unique and not empty lines
lines = text.splitlines()
return set([ line.strip() for line in lines if line.strip() ])
def load_user_artist_ignore_list() -> set[str]: def load_user_artist_ignore_list() -> set[str]:
@@ -34,14 +36,19 @@ def load_user_artist_ignore_list() -> set[str]:
Loads the user-defined artist ignore list from the config directory. Loads the user-defined artist ignore list from the config directory.
Returns an empty set if the file doesn't exist. Returns an empty set if the file doesn't exist.
""" """
user_file = Path(Paths.get_config_file_path()).parent / "artist_split_ignore.txt" user_file = Paths().app_dir / "artist_split_ignore.txt"
return load_artist_ignore_list_from_file(user_file) if user_file.exists():
lines = user_file.read_text().splitlines()
return set([ line.strip() for line in lines if line.strip()])
else:
return set()
@dataclass @dataclass
class UserConfig: class UserConfig(metaclass=Singleton):
_config_path: str = "" _finished: bool = field(default=False, init=False) # if post init succesfully
_artist_split_ignore_file_name: str = "artist_split_ignore.txt" _config_path: InitVar[Path] = Path("")
_artist_split_ignore_file_name: InitVar[str] = "artist_split_ignore.txt"
# NOTE: only auth stuff are used (the others are still reading/writing to db) # NOTE: only auth stuff are used (the others are still reading/writing to db)
# TODO: Move the rest of the settings to the config file # TODO: Move the rest of the settings to the config file
@@ -84,16 +91,17 @@ class UserConfig:
lastfmApiSecret: str = "5e5306fbf3e8e3bc92f039b6c6c4bd4e" lastfmApiSecret: str = "5e5306fbf3e8e3bc92f039b6c6c4bd4e"
lastfmSessionKeys: dict[str, str] = field(default_factory=dict) lastfmSessionKeys: dict[str, str] = field(default_factory=dict)
def __post_init__(self):
def __post_init__(self, _config_path, _artist_split_ignore_file_name):
""" """
Loads the config file and sets the values to this instance Loads the config file and sets the values to this instance
""" """
# set config path locally to avoid writing to file # set config path locally to avoid writing to file
config_path = Paths.get_config_file_path() config_path = Paths().config_file_path
try: if config_path.exists():
config = self.load_config(config_path) config = self.load_config(config_path)
except FileNotFoundError: else:
self._config_path = config_path self._config_path = config_path
return return
@@ -107,8 +115,10 @@ class UserConfig:
else: else:
setattr(self, key, value) setattr(self, key, value)
# finally set the config path # finally, set the config path
self._config_path = config_path self._config_path = config_path
self._finished = True
def setup_config_file(self) -> None: def setup_config_file(self) -> None:
""" """
@@ -116,18 +126,18 @@ class UserConfig:
if it doesn't exist if it doesn't exist
""" """
# if not exists, create the config file # if not exists, create the config file
if not Path(self._config_path).exists(): config = Path(self._config_path)
if not config.exists():
self.write_to_file(asdict(self)) self.write_to_file(asdict(self))
def load_config(self, path: str) -> dict[str, Any]:
def load_config(self, path: Path) -> dict[str, Any]:
""" """
Reads the settings from the config file. Reads the settings from the config file.
Returns a dictget_root_dirs Returns a dictget_root_dirs
""" """
with open(path, "r") as f: return json.loads(path.read_text())
settings = json.load(f)
return settings
def write_to_file(self, settings: dict[str, Any]): def write_to_file(self, settings: dict[str, Any]):
""" """
@@ -136,13 +146,21 @@ class UserConfig:
# remove internal attributes # remove internal attributes
settings = {k: v for k, v in settings.items() if not k.startswith("_")} settings = {k: v for k, v in settings.items() if not k.startswith("_")}
with open(self._config_path, "w") as f: with self._config_path.open(mode="w") as f:
json.dump(settings, f, indent=4, default=list) json.dump(settings, f, indent=4, default=list)
def __setattr__(self, key: str, value: Any) -> None: def __setattr__(self, key: str, value: Any) -> None:
""" """
Writes to the config file whenever a value is set Writes to the config file whenever a value is set
""" """
# protection.
# only write to file if post_init completed
if not self._finished:
super().__setattr__(key, value)
return
super().__setattr__(key, value) super().__setattr__(key, value)
# if is internal attribute, don't write to file # if is internal attribute, don't write to file
@@ -2,7 +2,7 @@ from contextlib import contextmanager
from sqlalchemy import Engine, create_engine, event from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from swingmusic.settings import DbPaths from swingmusic.settings import Paths
@event.listens_for(Engine, "connect") @event.listens_for(Engine, "connect")
@@ -38,7 +38,7 @@ class DbEngine:
def engine(cls) -> Engine: def engine(cls) -> Engine:
if not cls._engine: if not cls._engine:
cls._engine = create_engine( cls._engine = create_engine(
f"sqlite+pysqlite:///{DbPaths.get_app_db_path()}", f"sqlite+pysqlite:///{Paths().app_db_path}",
echo=False, echo=False,
max_overflow=20, max_overflow=20,
pool_size=10, pool_size=10,
@@ -66,7 +66,14 @@ class TrackTable(Base):
.where(TrackTable.filepath.contains(path)) .where(TrackTable.filepath.contains(path))
.order_by(TrackTable.last_mod) .order_by(TrackTable.last_mod)
) )
return tracks_to_dataclasses(result.fetchall())
clean = []
for row in result.fetchall():
d = row[0].__dict__
del d["_sa_instance_state"]
clean.append(d)
return tracks_to_dataclasses(clean)
@classmethod @classmethod
def remove_tracks_by_filepaths(cls, filepaths: set[str]): def remove_tracks_by_filepaths(cls, filepaths: set[str]):
@@ -90,10 +90,10 @@ class SQLiteManager:
if self.test_db_path: if self.test_db_path:
db_path = self.test_db_path db_path = self.test_db_path
else: else:
db_path = settings.DbPaths.get_app_db_path() db_path = settings.Paths().app_db_path
if self.userdata_db: if self.userdata_db:
db_path = settings.DbPaths.get_userdata_db_path() db_path = settings.Paths().userdata_db_path
self.conn = sqlite3.connect( self.conn = sqlite3.connect(
db_path, db_path,
@@ -434,15 +434,14 @@ class PlaylistTable(Base):
@classmethod @classmethod
def append_to_playlist(cls, id: int, trackhashes: list[str]): def append_to_playlist(cls, id: int, trackhashes: list[str]):
dbtrackhashes = cls.get_trackhashes(id) dbtrackhashes = cls.get_trackhashes(id) or []
if not dbtrackhashes: trackhashes = list(set(dbtrackhashes).union(set(trackhashes)))
dbtrackhashes = []
return next( return next(
cls.execute( cls.execute(
update(cls) update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid())) .where((cls.id == id) & (cls.userid == get_current_userid()))
.values(trackhashes=dbtrackhashes + trackhashes), .values(trackhashes=trackhashes),
commit=True, commit=True,
) )
) )
@@ -2,7 +2,6 @@
Contains methods relating to albums. Contains methods relating to albums.
""" """
from swingmusic.models.track import Track from swingmusic.models.track import Track
@@ -14,7 +13,14 @@ def remove_duplicate_on_merge_versions(tracks: list[Track]):
pass pass
def sort_by_track_no(tracks: list[Track]): def sort_by_track_no(tracks: list[Track]) -> list[Track]:
"""
Sort tracks by track number.
Track numbers cannot be longer than three positions.
:param tracks: List of Tracks
:return: Sorted list of Tracks
"""
for t in tracks: for t in tracks:
track = str(t.track).zfill(3) track = str(t.track).zfill(3)
t._pos = int(f"{t.disc}{track}") t._pos = int(f"{t.disc}{track}")
@@ -95,9 +95,9 @@ class DownloadImage:
if img is None: if img is None:
return return
sm_path = Path(settings.Paths.get_sm_artist_img_path()) / name sm_path = settings.Paths().sm_artist_img_path / name
lg_path = Path(settings.Paths.get_lg_artist_img_path()) / name lg_path = settings.Paths().lg_artist_img_path / name
md_path = Path(settings.Paths.get_md_artist_img_path()) / name md_path = settings.Paths().md_artist_img_path / name
entries = [ entries = [
(lg_path, None), # save in the original size (lg_path, None), # save in the original size
@@ -147,7 +147,7 @@ class CheckArtistImages:
def __init__(self): def __init__(self):
# read all files in the artist image folder # read all files in the artist image folder
storeArtists = ArtistStore.get_flat_list() storeArtists = ArtistStore.get_flat_list()
path = settings.Paths.get_sm_artist_img_path() path = settings.Paths().sm_artist_img_path
processed = set(i.replace(".webp", "") for i in os.listdir(path)) processed = set(i.replace(".webp", "") for i in os.listdir(path))
unprocessed = ( unprocessed = (
@@ -175,7 +175,7 @@ class CheckArtistImages:
:param artist: The artist name :param artist: The artist name
""" """
img_path = ( img_path = (
Path(settings.Paths.get_sm_artist_img_path()) / f"{artist.artisthash}.webp" settings.Paths().sm_artist_img_path / f"{artist.artisthash}.webp"
) )
if img_path.exists(): if img_path.exists():
@@ -1,28 +1,35 @@
""" """
Contains everything that deals with image color extraction. Contains everything that deals with image colour extraction.
""" """
import logging
import os import os
import pathlib
import colorgram import colorgram
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Generator
from swingmusic.utils.progressbar import tqdm from swingmusic.utils.progressbar import tqdm
from concurrent.futures import ProcessPoolExecutor, as_completed from concurrent.futures import ProcessPoolExecutor, as_completed
from swingmusic import settings from swingmusic import settings
from swingmusic.logger import log
from swingmusic.store.albums import AlbumStore from swingmusic.store.albums import AlbumStore
from swingmusic.db.userdata import LibDataTable from swingmusic.db.userdata import LibDataTable
from swingmusic.store.artists import ArtistStore from swingmusic.store.artists import ArtistStore
log = logging.getLogger(__name__)
def get_image_colors(image: str, count=1) -> list[str]: def get_image_colors(image: pathlib.Path, count=1) -> list[str]:
""" """
Extracts n number of the most dominant colors from an image. Extracts ``count`` numbers of the most dominant colours from an image.
:params image: Path to image.
:params count: How many colours should be extracted?
:returns: List["rgb(red, green, blue)", ...]
""" """
try:
if image.exists():
colors = sorted(colorgram.extract(image, count), key=lambda c: c.hsl.h) colors = sorted(colorgram.extract(image, count), key=lambda c: c.hsl.h)
except OSError: else:
return [] return []
formatted_colors = [] formatted_colors = []
@@ -34,36 +41,44 @@ def get_image_colors(image: str, count=1) -> list[str]:
return formatted_colors return formatted_colors
def process_color(item_hash: str, is_album=True): def process_color(item_hash: str, is_album=True) -> list[str]:
path = ( """
settings.Paths.get_sm_thumb_path() Parse colours from images associated with song
if is_album
else settings.Paths.get_sm_artist_img_path() :param item_hash: hash of item for colour calculation
) :param is_album: if item is an album
path = Path(path) / (item_hash + ".webp") :return: list with colour strings
"""
if is_album:
path = settings.Paths().sm_thumb_path
else:
path = settings.Paths().sm_artist_img_path
path = path / (item_hash + ".webp")
if not path.exists(): if not path.exists():
return return []
return get_image_colors(str(path)) return get_image_colors(path)
def extract_color_worker(item_data: dict) -> dict: def extract_color_worker(item_data: dict) -> dict:
""" """
Generic worker function for extracting colors in parallel. Generic worker function for extracting colours in parallel.
Returns data to main process for batch database operations. Returns data to main process for batch database operations.
Works for both albums and artists based on item_data configuration. Works for both albums and artists based on item_data configuration.
""" """
hash_field: str = item_data["hash_field"] hash_field: str = item_data["hash_field"]
path_func: Callable = item_data["path_func"] path_func: Path = item_data["path_func"]
item_hash: str = item_data[hash_field] item_hash: str = item_data[hash_field]
path = Path(path_func()) / (item_hash + ".webp") path = path_func / (item_hash + ".webp")
if not path.exists(): if not path.exists():
return {hash_field: item_hash, "color": None, "error": "Image not found"} return {hash_field: item_hash, "color": None, "error": "Image not found"}
colors = get_image_colors(str(path)) colors = get_image_colors(path)
if not colors: if not colors:
return { return {
@@ -85,7 +100,7 @@ class ColorProcessor:
self, self,
item_type: str, item_type: str,
store: AlbumStore | ArtistStore, store: AlbumStore | ArtistStore,
path_func: Callable, path_func: Path,
hash_field: str, hash_field: str,
): ):
""" """
@@ -251,21 +266,21 @@ class ProcessAlbumColors:
ColorProcessor( ColorProcessor(
item_type="album", item_type="album",
store=AlbumStore, store=AlbumStore,
path_func=settings.Paths.get_sm_thumb_path, path_func=settings.Paths().sm_thumb_path,
hash_field="albumhash", hash_field="albumhash",
) )
class ProcessArtistColors: class ProcessArtistColors:
""" """
Extracts the most dominant color from the artist art and saves it to the database. Extracts the most dominant colour from the artist art and saves it to the database.
Uses multiprocessing for parallel color extraction and batch database operations. Uses multiprocessing for parallel colour extraction and batch database operations.
""" """
def __init__(self) -> None: def __init__(self) -> None:
ColorProcessor( ColorProcessor(
item_type="artist", item_type="artist",
store=ArtistStore, store=ArtistStore,
path_func=settings.Paths.get_sm_artist_img_path, path_func=settings.Paths().sm_artist_img_path,
hash_field="artisthash", hash_field="artisthash",
) )
@@ -1,14 +1,14 @@
import os import pathlib
from pathlib import Path from pathlib import Path
import logging
from swingmusic.lib.sortlib import sort_folders, sort_tracks from swingmusic.lib.sortlib import sort_folders, sort_tracks
from swingmusic.logger import log
from swingmusic.models import Folder from swingmusic.models import Folder
from swingmusic.serializers.track import serialize_tracks from swingmusic.serializers.track import serialize_tracks
from swingmusic.utils.filesystem import SUPPORTED_FILES from swingmusic.utils.filesystem import SUPPORTED_FILES
from swingmusic.store.folder import FolderStore from swingmusic.store.folder import FolderStore
from swingmusic.utils.wintools import win_replace_slash
log = logging.getLogger(__name__)
def create_folder(path: str, trackcount=0) -> Folder: def create_folder(path: str, trackcount=0) -> Folder:
""" """
@@ -18,7 +18,7 @@ def create_folder(path: str, trackcount=0) -> Folder:
return Folder( return Folder(
name=folder.name, name=folder.name,
path=win_replace_slash(str(folder)) + "/", path=folder.as_posix() + "/",
is_sym=folder.is_symlink(), is_sym=folder.is_symlink(),
trackcount=trackcount, trackcount=trackcount,
) )
@@ -38,7 +38,7 @@ def get_folders(paths: list[str]):
def get_files_and_dirs( def get_files_and_dirs(
path: str, path: pathlib.Path,
start: int, start: int,
limit: int, limit: int,
tracksortby: str, tracksortby: str,
@@ -47,63 +47,86 @@ def get_files_and_dirs(
foldersort_reverse: bool, foldersort_reverse: bool,
tracks_only: bool = False, tracks_only: bool = False,
skip_empty_folders=True, skip_empty_folders=True,
): ) -> dict[str: list|int|str]:
""" """
Given a path, returns a list of tracks and folders in that immediate path. Scan folder for files and folders.
Will only return files in `swingmusic.utils.filesystem.SUPPORTED_FILES`.
If `skip_empty_folders` is True
Can recursively call itself to skip through empty folders. :param path:
:param start:
:param limit:
:param tracksortby:
:param foldersortby:
:param tracksort_reverse:
:param foldersort_reverse:
:param tracks_only: If True, will only return tracks with no folders
:param skip_empty_folders: If True, will call recursively and skip empty folders until >0 supported file found.
:returns: List of tracks and folders in that immediate path.
""" """
# TODO: Replace os.path with pathlib
try: path = pathlib.Path(path)
entries = os.scandir(path)
except FileNotFoundError: # if file or non-existent
if not path.exists() or not path.is_dir():
return { return {
"path": path, "path": path.as_posix(),
"tracks": [], "tracks": [],
"folders": [], "folders": [],
"total": 0
} }
dirs, files = [], []
for entry_ in entries: # iter through all folders
entry = Path(entry_.path) # add files with supported suffix
# ignore hidden folder
dirs, files = [], []
for entry in path.iterdir():
ext = entry.suffix.lower() ext = entry.suffix.lower()
if entry.is_dir() and not entry.name.startswith("."): if entry.is_dir() and not entry.stem.startswith("."):
dir = (entry / "").as_posix() dirs.append((entry / "").as_posix())
# only append as posix for FolderStore and sort_folder function
# TODO: rework everything to support pathlib
# add a trailing slash to the folder path # add a trailing slash to the folder path
# to avoid matching a folder starting with the same name as the root path # to avoid matching a folder starting with the same name as the root path
# eg. .../Music and .../Music VideosI # eg. .../Music and .../Music VideosI
dirs.append(dir)
elif entry.is_file() and ext in SUPPORTED_FILES: elif entry.is_file() and ext in SUPPORTED_FILES:
files.append(entry.as_posix()) files.append(entry)
files_ = []
"""
# sort files by most recent
# TODO: rework if realy needed.
files_with_mtime = []
for file in files: for file in files:
try: try:
files_.append( files_with_mtime.append(
{ {
"path": file, "path": file.as_posix(),
"time": os.path.getmtime(file), "time": file.lstat().st_mtime,
} }
) )
except OSError as e: except OSError as e:
log.error(e) log.error(e)
files_.sort(key=lambda f: f["time"]) files_with_mtime.sort(key=lambda f: f["time"])
files = [f["path"] for f in files_] files = [f["path"] for f in files_with_mtime]
"""
# if supported files were found
# convert files to tracks
tracks = [] tracks = []
if files: if len(files) > 0:
if limit == -1: if limit == -1:
limit = len(files) limit = len(files)
# only return tracks already indexed by us
tracks = list(FolderStore.get_tracks_by_filepaths(files)) tracks = list(FolderStore.get_tracks_by_filepaths(files))
tracks = sort_tracks(tracks, tracksortby, tracksort_reverse) tracks = sort_tracks(tracks, tracksortby, tracksort_reverse)
tracks = tracks[start : start + limit] tracks = tracks[start : start + limit]
folders = [] folders = []
if not tracks_only: if not tracks_only:
folders = get_folders(dirs) folders = get_folders(dirs)
@@ -126,7 +149,7 @@ def get_files_and_dirs(
) )
return { return {
"path": path, "path": path.as_posix(),
"tracks": serialize_tracks(tracks), "tracks": serialize_tracks(tracks),
"folders": folders, "folders": folders,
"total": len(files), "total": len(files),
@@ -11,6 +11,14 @@ from swingmusic.store.tracks import TrackStore
def create_items(entries: list[TrackLog], limit: int): def create_items(entries: list[TrackLog], limit: int):
"""
TODO: rework so that returns a dict with
{
"recently_played": ...,
"artist_mixes_for_you": ...
}
also keep in mind that the web-ui is beeing translated.
"""
custom_playlists = [ custom_playlists = [
{"name": "recentlyadded", "handler": get_recently_added_playlist}, {"name": "recentlyadded", "handler": get_recently_added_playlist},
{"name": "recentlyplayed", "handler": get_recently_played_playlist}, {"name": "recentlyplayed", "handler": get_recently_played_playlist},
@@ -1,4 +1,5 @@
import gc import gc
import logging
from time import time from time import time
from swingmusic.lib.mapstuff import ( from swingmusic.lib.mapstuff import (
map_album_colors, map_album_colors,
@@ -15,9 +16,10 @@ from swingmusic.store.folder import FolderStore
from swingmusic.store.tracks import TrackStore from swingmusic.store.tracks import TrackStore
from swingmusic.utils.threading import background from swingmusic.utils.threading import background
log = logging.getLogger(__name__)
class IndexEverything: @background
def __init__(self) -> None: def index_everything():
IndexTracks() IndexTracks()
key = str(time()) key = str(time())
@@ -38,8 +40,4 @@ class IndexEverything:
CordinateMedia(instance_key=str(time())) CordinateMedia(instance_key=str(time()))
gc.collect() gc.collect()
log.info("Indexing completed")
@background
def index_everything():
return IndexEverything()
+343
View File
@@ -0,0 +1,343 @@
import datetime
import pathlib
from pathlib import Path
from swingmusic.store.tracks import TrackStore
# # # # # # # # # # # # # # # # # # # #
# Functions for parsing lyrics lines #
# # # # # # # # # # # # # # # # # # # #
def parse_lyrics_lines(lyrics:str) -> list[dict]:
"""
Split lyrics into lines and determine there tag type.
Parses the tag if the following format is present: [tag]*[tags] <body>
else tag_type is unknown
tag-type and tags are lists combined by their index
:param lyrics: Full lyrics body
:return: {'tag_types', 'body', 'tags'}
"""
entries = []
for line in lyrics.splitlines():
data = {
"tag_types": [],
"tags": []
}
if line.startswith("["):
# loop until all tags are parsed in line
while True:
if "[" in line and "]" in line: # second tag
bracket_content, after_content = line.split("]", 1)
bracket_content = bracket_content.removeprefix("[")
data["tags"].append(bracket_content)
data["body"] = after_content
line = after_content
# check which tag type it is
if bracket_content[0].isnumeric():
data["tag_types"].append( "time" )
elif bracket_content[0].isalpha():
data["tag_types"].append( "meta" )
else:
# if no brackets inside the line, there is also no tag.
break
elif line.startswith("#"):
data["tag_types"].append("comment")
data["tags"] = ""
data["body"] = line
else:
data["tag_types"].append("unknown")
data["tags"] = "unknown"
data["body"] = line
entries.append(data)
return entries
def filter_parse_lyrics_lines(lines:list[dict], tag_types:list|str) -> list[dict]:
"""
filter all lyrics line to only contain given tags
:param lines: list returned by `parse_lyrics_lines`
:param tag_types: list or string of tags return should contain
"""
if isinstance(tag_types, str):
tag_types = [tag_types]
found_tags = []
# line = {"tags", "body", "tag_types"}
for line in lines:
group = {
"tag_types": [],
"tags": []
}
for (tag, tag_type) in zip(line["tags"], line["tag_types"]):
if tag_type in tag_types:
group["tag_types"].append(tag_type)
group["tags"].append(tag)
group["body"] = line["body"]
# filter out no match
if len(group["tags"]) > 0:
found_tags.append(group)
return found_tags
def parse_time_tag(lines:list[dict]) -> list[dict]:
"""
Filter time-tags from lines and parse them.
"""
# filter tag-type time
# format into dict with timestamps
parsed_times = []
time_tags = filter_parse_lyrics_lines(lines, "time")
# line = {"tags", "body", "tag_types"}
for line in time_tags:
for (tag, tag_type) in zip(line["tags"], line["tag_types"]):
minute, seconds = tag.split(":", 1)
parsed_times.append({
"minute": minute,
"seconds": seconds,
"body": line["body"],
})
return parsed_times
# # # # # # # # # # # # # # # # # # # #
# Lyrics class for simplified usage #
# # # # # # # # # # # # # # # # # # # #
class Lyrics:
SUPPORTED_METATAGS = {
"ti": "title",
"ar": "artist",
"al": "album",
"au": "author",
"lr": "lyricist",
"length": "length",
"by": "lrc_author",
"offset": "offset",
"re": "recorder",
"tool": "tool",
"ve": "version"
}
lyrics:str
parsed_lyrics:list[dict]
meta:dict = {}
is_synced:bool = False
def __init__(self, lyrics:str=""):
"""
:param lyrics: entire lyrics body
"""
if lyrics is None:
raise ValueError("Lyrics can not be None")
if isinstance(lyrics, list):
lyrics = lyrics[0]
lyrics = lyrics.replace("engdesc", "")
self.lyrics = lyrics
parsed = parse_lyrics_lines(lyrics)
# translate meta tags
meta = filter_parse_lyrics_lines(parsed, "meta")
for line in meta:
for tag in line["tags"]:
name, body = tag.split(":", 1)
name = name.lower()
dict_name = self.SUPPORTED_METATAGS.get(name, name)
self.meta[dict_name] = body
# check if synced or not.
# not fail-save:
# If even just one time tag in the entire lyrics gets flagged as synced
if len(filter_parse_lyrics_lines(parsed, "time")) > 0:
self.is_synced = True
self.parsed_lyrics = filter_parse_lyrics_lines(parsed, "time")
else:
self.is_synced = False
self.parsed_lyrics = filter_parse_lyrics_lines(parsed, "unknown")
# TODO: add support for multilanguage lyrics
def format_synced_lyrics(self):
"""
Formats synced lyrics into a list of dicts
"""
if not self.is_synced:
raise ValueError("Cannot format synced lyrics if no synced lyrics exist for track.\nPlease use `format_unsynced_lyrics()`")
lyrics = []
time_tags = parse_time_tag(self.parsed_lyrics)
for entry in time_tags:
minutes = entry["minute"]
if "." in entry["seconds"]:
seconds = entry["seconds"].split(".")[0]
milli = entry["seconds"].split(".")[-1]
else:
seconds = entry["seconds"]
milli = "0"
minutes = int(minutes)
seconds = int(seconds)
milli = int(milli)
seconds = datetime.timedelta(minutes=minutes, seconds=seconds, milliseconds=milli).total_seconds()
offset = 0
if "offset" in self.meta:
offset = int(self.meta["offset"]) # offset in milliseconds
milliseconds = seconds * 1000 - offset
lyrics.append({"time": milliseconds, "text": entry["body"]})
return lyrics
def format_unsynced_lyrics(self) -> list[str]:
"""
return unsynced lyrics.
If no lyrics provided return empty string.
"""
lyrics = [item["body"] for item in self.parsed_lyrics]
return lyrics
def __bool__(self):
"""
return True if contains anything
"""
return bool(self.parsed_lyrics)
# # # # # # # # # # # # # # # # # # # # # # # # # # #
# Path and parse function to get lyrics from track #
# # # # # # # # # # # # # # # # # # # # # # # # # # #
def get_lyrics_file(track_path: str|pathlib.Path) -> Lyrics:
"""
Try to get lyrics from a relative lrc file.
:param track_path: path of track
"""
track_path = Path(track_path)
lyrics_path = track_path.with_suffix(".lrc")
extended_path = track_path.with_suffix(".rlrc")
# check paths
if lyrics_path.exists():
lyrics = Lyrics(lyrics_path.read_text())
return lyrics
elif extended_path.exists():
lyrics = Lyrics(extended_path.read_text())
return lyrics
else:
return Lyrics()
def get_lyrics_from_duplicates(track_path: str, trackhash: str) -> Lyrics:
"""
Finds the lyrics from other duplicate tracks
:param track_path: path of track
:param trackhash: Track-hash value
"""
entry = TrackStore.trackhashmap.get(trackhash, None)
if entry is None:
return Lyrics()
for track in entry.tracks:
if track.trackhash == trackhash and track.filepath != track_path:
lyrics = get_lyrics_file(track.filepath)
if lyrics:
return lyrics
return Lyrics()
def get_lyrics_from_tags(trackhash: str) -> Lyrics:
"""
Gets the lyrics from the tags of the track
:param trackhash:
"""
entry = TrackStore.trackhashmap.get(trackhash, None)
if entry is None:
return Lyrics()
for track in entry.tracks:
if "lyrics" in track.extra:
lyrics = track.extra["lyrics"]
if lyrics:
return Lyrics(lyrics)
return Lyrics("")
def check_lyrics_file(filepath: str, trackhash: str):
"""
Checks if the lyrics file exists for a track
"""
lyrics_file = Path(filepath).with_suffix(".lrc")
if lyrics_file.exists:
return True
entry = TrackStore.trackhashmap.get(trackhash, None)
if entry is None:
return False
for track in entry.tracks:
if track.trackhash == trackhash and track.filepath != filepath:
lyrics_file = Path(track.filepath).with_suffix(".lrc")
if lyrics_file.exists():
return True
return False
@@ -1,12 +1,9 @@
""" """
This library contains all the functions related to playlists. This library contains all the functions related to playlists.
""" """
import os
import random import random
import string import string
from typing import Any import logging
from PIL import Image, ImageSequence from PIL import Image, ImageSequence
from swingmusic import settings from swingmusic import settings
@@ -14,53 +11,60 @@ from swingmusic.models.track import Track
from swingmusic.store.albums import AlbumStore from swingmusic.store.albums import AlbumStore
from swingmusic.store.tracks import TrackStore from swingmusic.store.tracks import TrackStore
logger = logging.getLogger(__name__)
def create_thumbnail(image: Any, img_path: str) -> str: def create_thumbnail(image: Image, img_name: str) -> str:
""" """
Creates a 250 x 250 thumbnail from a playlist image Creates a 250 px high thumbnail from the Image.
It will keep the aspect ratio.
Images are saved in the playlist-img path
:param image: Image object.
:param img_name: Name of image.
:return: Filename of image.
""" """
thumb_path = "thumb_" + img_path
full_thumb_path = os.path.join(
settings.Paths.get_app_dir(), "images", "playlists", thumb_path
)
aspect_ratio = image.width / image.height aspect_ratio = image.width / image.height
new_w = round(250 * aspect_ratio) new_w = round(250 * aspect_ratio)
thumb = image.resize((new_w, 250), Image.Resampling.LANCZOS) thumb = image.resize((new_w, 250), Image.Resampling.LANCZOS)
thumb.save(full_thumb_path, "webp")
return thumb_path thumb_filename = "thumb_" + img_name
thumb_path = settings.Paths().playlist_img_path / thumb_filename
thumb.save(thumb_path, "webp")
return thumb_filename
def create_gif_thumbnail(image: Any, img_path: str): def create_gif_thumbnail(image: Image, img_name: str):
""" """
Creates a 250 x 250 thumbnail from a playlist image Creates a 250 px high thumbnail from the provided GIF.
Keeps the aspect ratio.
Images are saved in the playlist-img path
:param image: Image object.
:param img_name: Name of image.
:return: Filename of image.
""" """
thumb_path = "thumb_" + img_path thumb_name = "thumb_" + img_name
full_thumb_path = os.path.join( thumb_path = settings.Paths().playlist_img_path / thumb_name
settings.Paths.get_app_dir(), "images", "playlists", thumb_path
)
frames = [] frames = []
for frame in ImageSequence.Iterator(image): for frame in ImageSequence.Iterator(image):
aspect_ratio = frame.width / frame.height aspect_ratio = frame.width / frame.height
new_w = round(250 * aspect_ratio) new_w = round(250 * aspect_ratio)
thumb = frame.resize((new_w, 250), Image.Resampling.LANCZOS) thumb = frame.resize((new_w, 250), Image.Resampling.LANCZOS)
frames.append(thumb) frames.append(thumb)
frames[0].save(full_thumb_path, save_all=True, append_images=frames[1:]) frames[0].save(thumb_path, save_all=True, append_images=frames[1:])
return thumb_path return thumb_name
def save_p_image( def save_p_image(img: Image, pid: int, content_type: str = None, filename: str = None) -> str:
img: Image, pid: int, content_type: str = None, filename: str = None
) -> str:
""" """
Saves a playlist banner image and returns the filepath. Saves a playlist banner image and returns the filepath.
""" """
@@ -71,7 +75,7 @@ def save_p_image(
if not filename: if not filename:
filename = str(pid) + str(random_str) + ".webp" filename = str(pid) + str(random_str) + ".webp"
full_img_path = os.path.join(settings.Paths.get_playlist_img_path(), filename) full_img_path = settings.Paths().playlist_img_path / filename
if content_type == "image/gif": if content_type == "image/gif":
frames = [] frames = []
@@ -85,7 +89,7 @@ def save_p_image(
return filename return filename
img.save(full_img_path, "webp") img.save(full_img_path, "webp")
create_thumbnail(img, img_path=filename) create_thumbnail(img, img_name=filename)
return filename return filename
@@ -100,9 +104,10 @@ def duplicate_images(images: list):
return images return images
# TODO: mutable var in param.
def get_first_4_images( def get_first_4_images(
tracks: list[Track] = [], trackhashes: list[str] = [] tracks: list[Track] = [],
trackhashes: list[str] = []
) -> list[dict["str", str]]: ) -> list[dict["str", str]]:
""" """
Returns images of the first 4 albums that appear in the track list. Returns images of the first 4 albums that appear in the track list.
@@ -137,10 +142,10 @@ def get_first_4_images(
return duplicate_images(images) return duplicate_images(images)
def cleanup_playlist_images(): def cleanup_playlist_images() -> None:
""" """
Cleans up unlinked playlist images by comparing files in the playlist image directory Deletes all unlinked files in playlist-img folder.
against the .image property of all playlists. All files not present in the PlaylistTable will get deleted
""" """
# Import here to avoid circular import # Import here to avoid circular import
from swingmusic.db.userdata import PlaylistTable from swingmusic.db.userdata import PlaylistTable
@@ -148,23 +153,19 @@ def cleanup_playlist_images():
playlists = PlaylistTable.get_all() playlists = PlaylistTable.get_all()
linked_images = {p.image for p in playlists if p.image and p.image != "None"} linked_images = {p.image for p in playlists if p.image and p.image != "None"}
playlist_dir = settings.Paths.get_playlist_img_path() playlist_dir = settings.Paths().playlist_img_path
all_files = os.listdir(playlist_dir)
# Find unlinked images (including thumbnails) # Find unlinked images (including thumbnails)
unlinked_files = [] for file in playlist_dir.iterdir():
for file in all_files: if not file.isfile:
if file.startswith("thumb_"): continue
base_file = file[6:] # Remove "thumb_" prefix
if base_file not in linked_images:
unlinked_files.append(file)
elif file not in linked_images: name = file.name # not stem. PlaylistTable saves with extension
unlinked_files.append(file) if file not in linked_images:
if name.removeprefix("thumb_") not in linked_images:
continue
for file in unlinked_files:
try: try:
os.remove(os.path.join(playlist_dir, file)) file.unlink(missing_ok=True)
except OSError: except OSError as e:
# Skip if file doesn't exist or can't be deleted logger.exception("could not delete file", exc_info=e)
pass
@@ -1,14 +1,15 @@
import functools
import os import os
from dataclasses import asdict from dataclasses import asdict
from typing import Generator import multiprocessing as mp
from requests import ReadTimeout from requests import ReadTimeout
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from requests import ConnectionError as RequestConnectionError from requests import ConnectionError as RequestConnectionError
import logging
from swingmusic import settings from swingmusic import settings
from swingmusic.lib.artistlib import CheckArtistImages from swingmusic.lib.artistlib import CheckArtistImages
from swingmusic.lib.taglib import extract_thumb from swingmusic.lib.taglib import extract_thumb
from swingmusic.logger import log
from swingmusic.models import Album, Artist from swingmusic.models import Album, Artist
from swingmusic.models.lastfm import SimilarArtist from swingmusic.models.lastfm import SimilarArtist
from swingmusic.models.track import Track from swingmusic.models.track import Track
@@ -16,11 +17,13 @@ from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore from swingmusic.store.artists import ArtistStore
from swingmusic.utils.network import has_connection from swingmusic.utils.network import has_connection
from swingmusic.utils.progressbar import tqdm from swingmusic.utils.progressbar import tqdm
from swingmusic.requests.artists import fetch_similar_artists from swingmusic.request.artists import fetch_similar_artists
from swingmusic.lib.colorlib import ProcessAlbumColors, ProcessArtistColors from swingmusic.lib.colorlib import ProcessAlbumColors, ProcessArtistColors
from swingmusic.db.userdata import SimilarArtistTable from swingmusic.db.userdata import SimilarArtistTable
log = logging.getLogger(__name__)
class CordinateMedia: class CordinateMedia:
""" """
@@ -55,7 +58,7 @@ class CordinateMedia:
FetchSimilarArtistsLastFM() FetchSimilarArtistsLastFM()
def get_image(tracks: list[Track]): def get_image(tracks: list[Track], paths=None):
""" """
The function retrieves an image from a list of tracks by extracting the thumbnail from the first track that has one. The function retrieves an image from a list of tracks by extracting the thumbnail from the first track that has one.
@@ -65,7 +68,7 @@ def get_image(tracks: list[Track]):
""" """
for track in tracks: for track in tracks:
extracted = extract_thumb(track.filepath, track.albumhash + ".webp") extracted = extract_thumb(track.filepath, track.albumhash + ".webp", paths)
if extracted: if extracted:
return return
@@ -78,19 +81,21 @@ class ProcessTrackThumbnails:
def extract(self, albums: list[Album]): def extract(self, albums: list[Album]):
""" """
Extracts the album art with platform specific logic. Extracts the album art with platform-specific logic.
""" """
cpus = max(1, (os.cpu_count() or 1) // 2) cpus = max(1, os.cpu_count() // 2)
albumsMap: Generator[list[Track]] = ( albumsMap = ( AlbumStore.get_album_tracks(album.albumhash) for album in albums )
AlbumStore.get_album_tracks(album.albumhash) for album in albums
) # Create process pool with worker function
with mp.Pool(processes=cpus) as pool:
worker = functools.partial(get_image, paths=settings.Paths())
# Process files and track progress
with ProcessPoolExecutor(max_workers=cpus) as executor:
results = list( results = list(
tqdm( tqdm(
executor.map(get_image, albumsMap), pool.imap_unordered(worker, albumsMap),
total=len(albums), total=len(albums),
desc="Extracting track images", desc="Extracting track images",
) )
@@ -103,10 +108,10 @@ class ProcessTrackThumbnails:
Filters out albums that already have thumbnails and Filters out albums that already have thumbnails and
extracts the thumbnail for the other albums. extracts the thumbnail for the other albums.
""" """
path = settings.Paths.get_sm_thumb_path() path = settings.Paths().sm_thumb_path
# read all the files in the thumbnail directory # read all the files in the thumbnail directory
processed = set(i.replace(".webp", "") for i in os.listdir(path)) processed = set(file.stem for file in path.iterdir())
# filter out albums that already have thumbnails # filter out albums that already have thumbnails
albums = filter( albums = filter(
lambda album: album.albumhash not in processed, lambda album: album.albumhash not in processed,
@@ -145,18 +150,21 @@ class FetchSimilarArtistsLastFM:
processed = set(a.artisthash for a in SimilarArtistTable.get_all()) processed = set(a.artisthash for a in SimilarArtistTable.get_all())
# filter out artists that already have similar artists using generator # filter out artists that already have similar artists using generator
artists = ( def artist_generator():
artist for artist in storeArtists if artist.artisthash not in processed for artist in storeArtists:
) if artist.artisthash in processed:
yield artist
cpus = max(1, (os.cpu_count() or 1) // 2) artists = list(artist_generator())
cpus = max(1, os.cpu_count() // 2)
with ProcessPoolExecutor(max_workers=cpus) as executor: with ProcessPoolExecutor(max_workers=cpus) as executor:
try: try:
# TODO: fix negative total length
results = list( results = list(
tqdm( tqdm(
executor.map(save_similar_artists, artists), executor.map(save_similar_artists, artist_generator()),
total=len(storeArtists) - len(processed), total=len(artists),
desc="Fetching similar artists", desc="Fetching similar artists",
) )
) )
@@ -164,5 +172,5 @@ class FetchSimilarArtistsLastFM:
list(results) list(results)
# any exception that can be raised by the pool # any exception that can be raised by the pool
except Exception as e: except Exception as e:
log.warn(e) log.warning(e)
return return
@@ -1,5 +1,3 @@
from __future__ import division
import array import array
import os import os
import subprocess import subprocess
@@ -3,7 +3,7 @@
""" """
import logging import logging
converter_logger = logging.getLogger("pydub.converter") converter_logger = logging.getLogger("swingmusic.pydub.converter")
def log_conversion(conversion_command): def log_conversion(conversion_command):
converter_logger.debug("subprocess.call(%s)", repr(conversion_command)) converter_logger.debug("subprocess.call(%s)", repr(conversion_command))

Some files were not shown because too many files have changed in this diff Show More