refactor Paths and update build workflow

+ rename app_dir -> config_dir and config_dir to config_parent
+ bundle web client as zip
+ bundle and extract client zip when running pyinstaller builds
+ installer pyinstaller as main dependency
+ remove fallback client flag
+ handle already used port
+ add assethandler class
+ remove some startup logs
+ ignore wheels and client.zip files
This commit is contained in:
cwilvx
2025-08-28 16:38:49 +03:00
parent e770606567
commit ebc740a5a5
15 changed files with 1037 additions and 955 deletions
+81 -35
View File
@@ -1,16 +1,41 @@
name: Build and Upload name: New Release
run-name: Release v${{ github.event.inputs.tag }}
on: on:
workflow_dispatch: workflow_dispatch:
release: inputs:
types: tag:
- prereleased description: "Version number"
- released required: true
default: "0.0.0"
binary_build:
description: "Build binaries"
required: true
default: "true"
type: choice
options:
- true
- false
is_latest:
description: "Set as latest"
required: true
default: "false"
type: choice
options:
- true
- false
build_docker:
description: "Build Docker image"
required: true
type: choice
default: "true"
options:
- true
- false
env: env:
PIP_USE_PEP517: true PIP_USE_PEP517: true
jobs: jobs:
build-client: build-client:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Build client name: Build client
@@ -18,7 +43,7 @@ jobs:
- name: Clone client - name: Clone client
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
repository: 'swingmx/webclient' repository: "swingmx/webclient"
path: swingmusic-client path: swingmusic-client
- name: Setup Node 20 - name: Setup Node 20
@@ -42,11 +67,12 @@ jobs:
with: with:
path: "client/" path: "client/"
compression-level: 0 compression-level: 0
name: 'client' name: "client"
build-wheels: build-wheels:
name: Build wheels name: Build wheels
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-client]
steps: steps:
- name: Checkout swingmusic - name: Checkout swingmusic
@@ -54,9 +80,25 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Download client artifact
uses: actions/download-artifact@v4
with:
name: client
path: client
- name: Compress client and copy to src/swingmusic/client.zip
run: |
zip -r client.zip client
rm -r client
cp client.zip src/swingmusic/client.zip
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: "3.11"
- name: Create git tag
run: |
git tag v${{ github.event.inputs.tag }}
- name: Build wheels - name: Build wheels
run: pip wheel . -w wheelhouse --no-deps run: pip wheel . -w wheelhouse --no-deps
@@ -66,22 +108,23 @@ jobs:
# name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} # name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
path: ./wheelhouse/*.whl path: ./wheelhouse/*.whl
compression-level: 0 compression-level: 0
name: 'wheels' name: "wheels"
build-appimage: build-appimage:
name: Build Appimage name: Build Appimage
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
needs: [ build-client ] needs: [build-client]
if: ${{ github.event.inputs.binary_build == 'true' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ ubuntu-latest, ubuntu-24.04-arm] os: [ubuntu-latest, ubuntu-24.04-arm]
steps: steps:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: "3.11"
- name: Install linux dependencies - name: Install linux dependencies
run: sudo apt-get install libev-dev libfuse-dev -y > /dev/null run: sudo apt-get install libev-dev libfuse-dev -y > /dev/null
@@ -134,8 +177,9 @@ jobs:
docker: docker:
name: Build and push Docker image name: Build and push Docker image
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ build-client, build-wheels ] needs: [build-client, build-wheels]
permissions: write-all permissions: write-all
if: ${{ github.event.inputs.build_docker == 'true' }}
steps: steps:
- name: Checkout into repo - name: Checkout into repo
@@ -166,32 +210,33 @@ jobs:
images: | images: |
ghcr.io/${{ github.repository }} 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 - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64, linux/arm64 #,linux/arm platforms: linux/amd64, linux/arm64 #,linux/arm
push: ${{ env.UPLOAD }} push: true
tags: ghcr.io/${{github.repository}}:${{format('{0}', github.ref_name)}}, ghcr.io/${{github.repository}}:latest tags: ghcr.io/${{github.repository}}:${{format('{0}', github.ref_name)}}, ghcr.io/${{github.repository}}:latest
labels: org.opencontainers.image.title=Docker labels: org.opencontainers.image.title=Docker
build-pyinstaller: build-pyinstaller:
name: Build binary on ${{ matrix.os }} name: Build binary on ${{ matrix.os }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
needs: [ build-client, build-wheels ] needs: [build-client, build-wheels]
if: ${{ github.event.inputs.binary_build == 'true' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ ubuntu-latest, ubuntu-24.04-arm, windows-latest, windows-11-arm, macos-13, macos-latest ] os:
[
ubuntu-latest,
ubuntu-24.04-arm,
windows-latest,
windows-11-arm,
macos-13,
macos-latest,
]
steps: steps:
- name: Checkout swingmusic - name: Checkout swingmusic
@@ -212,6 +257,10 @@ jobs:
name: client name: client
path: client path: client
- name: Compress client
run: |
zip -r client.zip client
- name: Download wheel artifact - name: Download wheel artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@@ -251,7 +300,7 @@ jobs:
upload-builds: upload-builds:
name: Uploading builds to release name: Uploading builds to release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ build-client, build-wheels, build-pyinstaller, build-appimage ] needs: [build-client, build-wheels, build-pyinstaller, build-appimage]
steps: steps:
- name: Download client artifact - name: Download client artifact
@@ -286,18 +335,15 @@ jobs:
path: appimage path: appimage
merge-multiple: true 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 - name: Upload artifacts to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
draft: ${{ env.DRAFT }} draft: true
name: ${{ format('v{0}',github.event.inputs.tag) }}
body_path: .github/changelog.md
fail_on_unmatched_files: true
target_commitish: ${{ github.sha }}
make_latest: ${{github.event.inputs.is_latest == 'true' }}
files: | files: |
client.zip client.zip
wheels/** wheels/**
+2
View File
@@ -38,3 +38,5 @@ nohup.out
.DS_Store .DS_Store
*.egg-info *.egg-info
/wheels/ /wheels/
client.zip
*.whl
+1 -1
View File
@@ -1 +1 @@
exec "${APPDIR}/usr/bin/python" -m swingmusic --fallback-client "${APPDIR}/client" "$@" exec "${APPDIR}/usr/bin/python" -m swingmusic --client "${APPDIR}/client" "$@"
+4 -3
View File
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "swingmusic" name = "swingmusic"
description = "Swing Music" description = "Swing Music"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11, <=3.12" requires-python = ">=3.11, <=3.12.9"
dynamic = ["version"] dynamic = ["version"]
dependencies = [ dependencies = [
@@ -38,7 +38,8 @@ dependencies = [
"pendulum>=3.0.0", "pendulum>=3.0.0",
"pystray>=0.19.5", "pystray>=0.19.5",
"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'",
"pyinstaller>=6.12.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -66,4 +67,4 @@ Issues = "https://github.com/swingmx/swingmusic/issues"
[tool.setuptools_scm] [tool.setuptools_scm]
version_scheme = "only-version" version_scheme = "only-version"
local_scheme = "no-local-version" local_scheme = "no-local-version"
fallback_version = "v0.0.0" fallback_version = "v0.0.0"
+14 -6
View File
@@ -1,16 +1,24 @@
# Launcher script # Launcher script
import swingmusic.__main__ as app
import sys import sys
import zipfile
import multiprocessing import multiprocessing
from pathlib import Path
import swingmusic.__main__ as app
if __name__ == "__main__": if __name__ == "__main__":
# this entry should only be used by pyinstaller. # this entry should only be used by pyinstaller.
# add freeze support here as pyinstaller uses this entry only # add freeze support here as pyinstaller uses this entry
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
client = sys._MEIPASS + "/client" # INFO: extract client.zip to sys._MEIPASS
sys.argv.extend(["--fallback-client", client]) with zipfile.ZipFile(sys._MEIPASS + "/client.zip", "r") as zip_ref:
sys.orig_argv.extend(["--fallback-client", client]) zip_ref.extractall(sys._MEIPASS)
client = Path(sys._MEIPASS) / "client"
client_str = str(client)
sys.argv.extend(["--client", client_str])
sys.orig_argv.extend(["--client", client_str])
multiprocessing.freeze_support() multiprocessing.freeze_support()
app.run() app.run()
+47 -56
View File
@@ -1,67 +1,45 @@
import argparse import sys
import pathlib import pathlib
import argparse
import multiprocessing
from importlib.metadata import version from importlib.metadata import version
import multiprocessing from swingmusic import settings
from swingmusic.logger import setup_logger 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 from swingmusic import tools as swing_tools
from swingmusic.settings import AssetHandler
from swingmusic.start_swingmusic import start_swingmusic
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog='swingmusic', prog="swingmusic",
description='Awesome Music', description="Awesome Music",
formatter_class=argparse.ArgumentDefaultsHelpFormatter formatter_class=argparse.ArgumentDefaultsHelpFormatter,
) )
parser.add_argument( parser.add_argument(
'-v', '--version', "-v", "--version", action="version", version=f"swingmusic v{version('swingmusic')}"
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("--host", default="0.0.0.0", help="Host to run the app on.")
parser.add_argument( parser.add_argument(
"--port", "--port", default=1970, help="HTTP port to run the app on.", type=int
default=1970,
help="HTTP port to run the app on.",
type=int
) )
parser.add_argument( parser.add_argument(
"--debug", "--debug",
default=False, default=False,
action="store_true", action="store_true",
help="If swingmusic should start in debug mode" help="If swingmusic should start in debug mode",
) )
parser.add_argument( parser.add_argument(
"--config", "--config",
default=default_base_path(), default=settings.Paths.get_default_config_parent_dir(),
help="Path to the config file.", help="The directory to setup the config folder.",
type=pathlib.Path type=pathlib.Path,
)
parser.add_argument(
"--client",
help="Path to the Web UI folder.",
type=pathlib.Path
) )
parser.add_argument("--client", help="Path to the Web UI folder.", type=pathlib.Path)
parser.add_argument( tools = parser.add_argument_group(title="Tools")
"--fallback-client", tools.add_argument("--password-reset", help="Reset the password.", action="store_true")
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): def run(*args, **kwargs):
""" """
@@ -70,26 +48,39 @@ def run(*args, **kwargs):
args = parser.parse_args() args = parser.parse_args()
args = vars(args) args = vars(args)
path = { config_parent = args["config"]
"config": args["config"], client_path = args["client"]
"client": args["client"],
"fallback": args["fallback_client"]
}
setup_logger(debug=args["debug"], app_dir=path["config"]) # INFO: Validate client path
if client_path is not None:
client_path = pathlib.Path(client_path).resolve()
if not client_path.exists():
print(
f"Client path {client_path} does not exist. Please provide a valid path"
)
sys.exit(1)
else:
# INFO: check if client path has index.html
if not (client_path / "index.html").exists():
print(
f"Client path {client_path} does not contain an index.html file. Please provide a valid path"
)
sys.exit(1)
# check tools settings.Paths(config_parent=config_parent, client_dir=client_path)
AssetHandler.copy_assets_dir()
AssetHandler.setup_default_client()
setup_logger(debug=args["debug"], app_dir=settings.Paths().config_dir)
# handle tools
if args["password_reset"]: if args["password_reset"]:
swing_tools.handle_password_reset(path) swing_tools.handle_password_reset(config_parent)
sys.exit(0)
# else start swingmusic # start swingmusic
else: start_swingmusic(host=args["host"], port=args["port"])
start_swingmusic(
host=args["host"],
port=args["port"],
path=path
)
if __name__ == "__main__": if __name__ == "__main__":
+3 -4
View File
@@ -60,7 +60,7 @@ def config_jwt(web):
return user.todict() return user.todict()
def load_endpoints(web): def load_endpoints(web: OpenAPI):
# Register all the API blueprints # Register all the API blueprints
with web.app_context(): with web.app_context():
web.register_api(swing_api.album.api) web.register_api(swing_api.album.api)
@@ -88,7 +88,7 @@ def load_endpoints(web):
web.register_api(swing_api.auth.api) web.register_api(swing_api.auth.api)
def load_plugins(web): def load_plugins(web: OpenAPI):
# TODO: rework plugin support # TODO: rework plugin support
# Plugins # Plugins
web.register_api(swing_api.plugins.api) web.register_api(swing_api.plugins.api)
@@ -165,7 +165,7 @@ def serve_client_files(path: str):
# INFO: Safari doesn't support gzip encoding # INFO: Safari doesn't support gzip encoding
# See issue: https://github.com/swingmx/swingmusic/issues/155 # See issue: https://github.com/swingmx/swingmusic/issues/155
user_agent = request.headers.get("User-Agent", "") user_agent = request.headers.get("User-Agent", "")
if "Safari" in user_agent and not "Chrome" in user_agent: if "Safari" in user_agent and "Chrome" not in user_agent:
return app.send_static_file(path) return app.send_static_file(path)
if "gzip" in request.headers.get("Accept-Encoding", ""): if "gzip" in request.headers.get("Accept-Encoding", ""):
@@ -200,7 +200,6 @@ def build() -> OpenAPI:
# set late state config # set late state config
app.static_folder = Paths().client_path app.static_folder = Paths().client_path
log.info(f"Serving client from '{app.static_folder}'")
@app.before_request @app.before_request
def verify_auth(): def verify_auth():
+1 -1
View File
@@ -36,7 +36,7 @@ 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 = Paths().app_dir / "artist_split_ignore.txt" user_file = Paths().config_dir / "artist_split_ignore.txt"
if user_file.exists(): if user_file.exists():
lines = user_file.read_text().splitlines() lines = user_file.read_text().splitlines()
return set([ line.strip() for line in lines if line.strip()]) return set([ line.strip() for line in lines if line.strip()])
-1
View File
@@ -229,4 +229,3 @@ def setup_logger(app_dir:Path, debug=False):
global log global log
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.info("setup successfully")
+187 -180
View File
@@ -4,10 +4,12 @@ All Variables should be read only after an initial set.
Contains default configs Contains default configs
""" """
import io import io
import multiprocessing import multiprocessing
import pathlib import pathlib
import shutil import shutil
import sys
import tempfile import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -23,6 +25,7 @@ log = logging.getLogger(__name__)
# Meta-classes # # Meta-classes #
# # # # # # # # # # # # # # # # # #
class Singleton(type): class Singleton(type):
_instances = {} _instances = {}
@@ -31,144 +34,135 @@ class Singleton(type):
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls] return cls._instances[cls]
# # # # # # # # # # # # # # # #
# Downloader # # Downloader #
# # # # # # # # # # # # # # # #
def populate_client(path:Path) -> bool:
"""
Checks if client folder contains content.
Client needs to have at least an index.html file.
If not, latest client is parsed from GitHub builds.
:param path: path to client folder class AssetHandler:
:return: True if successful else False """
Handles all assets configuration
"""
CLIENT_RELEASES_URL = (
"https://api.github.com/repos/swingmx/swingmusic/releases/latest"
)
@staticmethod
def copy_assets_dir():
"""
Copies assets to the app directory.
""" """
CLIENT_RELEASES_URL = "https://api.github.com/repos/michilyy/swingmusic/releases/latest" assets_source = imres.files("swingmusic") / "assets"
# TODO: update to real client repo assets_path = Paths().assets_path
# INFO: this only works for wheels and source
# TODO: Handle this for pyinstaller builds
# TODO: check for new releases. Currently only download when client is not found if assets_path.exists():
# TODO: move this outside. `Paths` only does path routing. # no need to copy what's already copied?
return
index = path / "index.html" if assets_source.exists():
if not index.exists(): shutil.copytree(
log.warning(f"'index.html' could not be found in '{path.as_posix()}'.") Path(assets_source),
log.warning("Try downloading latest client from GitHub.") assets_path,
try: ignore=shutil.ignore_patterns(
"*.pyc",
),
copy_function=shutil.copy2,
dirs_exist_ok=True,
)
else:
log.error(f"Assets dir could not be found: {assets_source.as_posix()}")
answer = requests.get(CLIENT_RELEASES_URL).json() @staticmethod
def extract_default_client(path: Path) -> bool:
"""
Extracts the default client which is bundled with the wheel
into the swingmusic client folder.
"""
# INFO: Locate the client.zip file using imres, extract it to the swingmusic client folder
client_zip_path = imres.files("swingmusic") / "client.zip"
if not client_zip_path.exists():
log.error("Client zip could not be found. Please provide a valid path.")
return False
for asset in answer["assets"]: with zipfile.ZipFile(client_zip_path, "r") as zip_ref:
if asset["name"] == "client.zip": zip_ref.extractall(path)
# download and convert client
client = requests.get(asset["browser_download_url"])
mem_file = io.BytesIO(client.content)
file = zipfile.ZipFile(mem_file)
# create new dir for extraction
log.info(f"Storing client in '{path.as_posix()}'.")
with tempfile.TemporaryDirectory() as temp_folder:
file.extractall(temp_folder)
shutil.copytree(
Path(temp_folder) / "client",
path,
copy_function=shutil.copy2,
dirs_exist_ok=True,
)
break
except (requests.exceptions.RequestException, KeyError, requests.exceptions.ConnectionError)as e:
log.error(f"Client could not be downloaded from releases. NETWORK ERROR", exc_info=e)
return False
except requests.exceptions.InvalidJSONError as e:
log.error(f"Client could not be downloaded from releases. JSON ERROR", exc_info=e)
return False
except zipfile.BadZipfile as e:
log.error(f"Client could not be unpacked. ZIP ERROR", exc_info=e)
return False
return True return True
# # # # # # # # # @staticmethod
# Path Logic # def download_client_from_github():
# # # # # # # # # """
Downloads the latest supported client from Github
and places it in the swingmusic client folder.
"""
path = Paths().config_parent / "client"
def default_client_path(app_dir:Path, fallback_client:Path|None=None) -> Path: try:
""" answer = requests.get(AssetHandler.CLIENT_RELEASES_URL).json()
| Calculates the default config path for ``client``.
| Checks for the first valid path.
Check order: for asset in answer["assets"]:
if asset["name"] == "client.zip":
# download and convert client
client = requests.get(asset["browser_download_url"])
mem_file = io.BytesIO(client.content)
file = zipfile.ZipFile(mem_file)
1. Env:``SWINGMUSIC_CLIENT_DIR`` # create new dir for extraction
2. if ``<app_dir>/client/`` exists log.info(f"Storing client in '{path.as_posix()}'.")
1. use ``<app_dir>/client/`` with tempfile.TemporaryDirectory() as temp_folder:
3. if ``<app_dir>/client/`` not exists file.extractall(temp_folder)
1. try downloading client from GitHub
2. if successful
1. use ``<app_dir>/client/``
3. if not successful
1. use ``<fallback_client>``
:param app_dir: shutil.copytree(
:param fallback_client: optional path to client. Used in pyinstaller/AppImage build Path(temp_folder) / "client",
:return: Calculated Path path,
""" copy_function=shutil.copy2,
dirs_exist_ok=True,
)
client_path = app_dir / 'client' break
env_client_dir = os.environ.get("SWINGMUSIC_CLIENT_DIR") except (
requests.exceptions.RequestException,
KeyError,
requests.exceptions.ConnectionError,
) as e:
log.error(
"Client could not be downloaded from releases. NETWORK ERROR",
exc_info=e,
)
return False
except requests.exceptions.InvalidJSONError as e:
log.error(
"Client could not be downloaded from releases. JSON ERROR",
exc_info=e,
)
return False
except zipfile.BadZipfile as e:
log.error("Client could not be unpacked. ZIP ERROR", exc_info=e)
return False
if not env_client_dir is None: @classmethod
return Path(env_client_dir) def setup_default_client(cls):
"""
Runs on startup to ensure the default client is present.
"""
client_path = Paths().client_path
extracted = False
if not client_path.exists() or not (client_path / "index.html").exists():
extracted = cls.extract_default_client(Paths().config_dir)
if (client_path / "index.html").exists(): if not extracted:
return client_path extracted = cls.download_client_from_github()
else:
if populate_client(client_path):
return client_path
elif fallback_client is not None:
return fallback_client
else:
raise NotImplementedError(f"Client could not be determined. Neither download or fallback.")
if not (client_path / "index.html").exists():
def default_base_path() -> pathlib.Path: log.error("Web client not found. Exiting ...")
""" sys.exit(1)
| Calculates the default config path for ``swingmusic``.
| Checks for the first valid path.
| If no Path is valid, will use Home dir (4.)
Check order:
1. Env:``SWINGMUSIC_CONFIG_DIR``
2. Env:``xdg_config_home``
3. <User Home>/.config
4. <User Home>
:return: Calculated Path
"""
swing_xdg_config_home = os.environ.get("SWINGMUSIC_CONFIG_DIR")
xdg_config_home = os.environ.get("xdg_config_home")
alt_dir = pathlib.Path.home() / ".config"
base_path = pathlib.Path.home()
if not swing_xdg_config_home is None:
base_path = pathlib.Path(swing_xdg_config_home)
elif not xdg_config_home is None:
base_path = pathlib.Path(xdg_config_home)
elif alt_dir.exists():
base_path = alt_dir
return base_path
class Paths(metaclass=Singleton): class Paths(metaclass=Singleton):
@@ -183,13 +177,21 @@ class Paths(metaclass=Singleton):
You cannot change the config path later. You cannot change the config path later.
""" """
base_path:Path = Path.home().resolve() config_parent: Path = Path.home().resolve()
"""
The parent directory of the config folder.
This is the directory where the config folder is located.
"""
USER_HOME_DIR = Path.home().resolve() USER_HOME_DIR = Path.home().resolve()
APP_DB_NAME = "swingmusic.db" APP_DB_NAME = "swingmusic.db"
USER_DATA_DB_NAME = "userdata.db" USER_DATA_DB_NAME = "userdata.db"
def __init__(
def __init__(self, config:Path|None=None, client:Path|None=None, fallback:Path|None=None): self,
config_parent: Path | None = None,
client_dir: Path | None = None,
):
""" """
Create config-folder structure and check permissions. Create config-folder structure and check permissions.
Copy all assets if needed. Copy all assets if needed.
@@ -210,34 +212,63 @@ class Paths(metaclass=Singleton):
user's home directory. user's home directory.
""" """
if config is not None: if config_parent is not None:
self.base_path = config.resolve() self.config_parent = config_parent.resolve()
else: else:
self.base_path = default_base_path() self.config_parent = Paths.get_default_config_parent_dir()
env_client_dir = os.environ.get("SWINGMUSIC_CLIENT_DIR")
if client is not None:
self.client_path = client.resolve()
elif not env_client_dir is None:
self.client_path = Path(env_client_dir)
else:
self.client_path = default_client_path(self.app_dir, fallback)
if multiprocessing.current_process().name == "MainProcess": if multiprocessing.current_process().name == "MainProcess":
# INFO: Setup client path
env_client_dir = os.environ.get("SWINGMUSIC_CLIENT_DIR")
if client_dir is not None:
self.client_path = client_dir.resolve()
elif env_client_dir is not None:
self.client_path = Path(env_client_dir).resolve()
else:
self.client_path = self.config_dir / "client"
# Path copy only on MainProcess # Path copy only on MainProcess
if not self.app_dir.exists(): if not self.config_dir.exists():
self.app_dir.mkdir(parents=True) self.config_dir.mkdir(parents=True)
# TODO: find a platform independent way to access module globals like `Paths` # TODO: find a platform independent way to access module globals like `Paths`
# TODO: move this into multithreading management class # TODO: move this into multithreading management class
os.environ["SWINGMUSIC_CONFIG_DIR"] = self.base_path.resolve().as_posix() os.environ["SWINGMUSIC_CONFIG_DIR"] = (
self.config_parent.resolve().as_posix()
)
os.environ["SWINGMUSIC_CLIENT_DIR"] = self.client_path.resolve().as_posix() os.environ["SWINGMUSIC_CLIENT_DIR"] = self.client_path.resolve().as_posix()
self.mkdir_config_folders() self.setup_config_dirs()
self.copy_assets_dir()
@classmethod
def get_default_config_parent_dir(cls) -> pathlib.Path:
"""
Determines the default config path in the following order:
def mkdir_config_folders(self): 1. Env:``SWINGMUSIC_CONFIG_DIR``
2. Env:``xdg_config_home``
3. <User Home>/.config
4. <User Home>
:return: First valid path
"""
config_dir_from_env = os.environ.get("SWINGMUSIC_CONFIG_DIR")
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
if config_dir_from_env is not None:
return pathlib.Path(config_dir_from_env)
if xdg_config_home is not None:
return pathlib.Path(xdg_config_home)
fallback_dir = pathlib.Path.home() / ".config"
if fallback_dir.exists():
return fallback_dir
return pathlib.Path.home()
def setup_config_dirs(self):
""" """
Create the config/cache folder structure. Create the config/cache folder structure.
@@ -262,10 +293,9 @@ class Paths(metaclass=Singleton):
└───lyrics └───lyrics
""" """
# all dirs relative to `swingmusic` config dir # all dirs relative to `swingmusic` config dir
dirs = [ dirs = [
"", # `swingmusic` or `.swingmusic` "", # `swingmusic` or `.swingmusic`
"plugins/lyrics", "plugins/lyrics",
"images/playlists", "images/playlists",
"images/thumbnails/small", "images/thumbnails/small",
@@ -282,7 +312,7 @@ class Paths(metaclass=Singleton):
] ]
for folder in dirs: for folder in dirs:
path = self.base_path / self.config_folder_name / folder path = self.config_parent / self.config_folder_name / folder
if not path.exists(): if not path.exists():
path.mkdir(parents=True) path.mkdir(parents=True)
path.chmod(mode=0o755) path.chmod(mode=0o755)
@@ -290,7 +320,9 @@ class Paths(metaclass=Singleton):
# Empty files to create # Empty files to create
empty_files = [ empty_files = [
# artist split ignore list # artist split ignore list
self.app_dir / "data" / "artist_split_ignore.txt" # TODO: use USERCONFIG -> circular import error self.config_dir
/ "data"
/ "artist_split_ignore.txt" # TODO: use USERCONFIG -> circular import error
] ]
for file in empty_files: for file in empty_files:
@@ -301,32 +333,6 @@ class Paths(metaclass=Singleton):
file.parent.mkdir(parents=True, exist_ok=True) file.parent.mkdir(parents=True, exist_ok=True)
file.touch() file.touch()
def copy_assets_dir(self):
"""
Copies assets to the app directory.
"""
assets_source = imres.files("swingmusic") / "assets"
if self.assets_path.exists():
# no need to copy what's already copied
return
if assets_source.exists():
shutil.copytree(
Path(assets_source),
self.assets_path,
ignore=shutil.ignore_patterns(
"*.pyc",
),
copy_function=shutil.copy2,
dirs_exist_ok=True,
)
else:
log.error(f"Assets dir could not be found: {assets_source.as_posix()}")
@property @property
def config_folder_name(self) -> str: def config_folder_name(self) -> str:
""" """
@@ -335,18 +341,18 @@ class Paths(metaclass=Singleton):
When the base path is the same as the home dir, When the base path is the same as the home dir,
it returns `.swingmusic` else `swingmusic` it returns `.swingmusic` else `swingmusic`
""" """
if self.base_path == self.USER_HOME_DIR: if self.config_parent == self.USER_HOME_DIR:
return ".swingmusic" return ".swingmusic"
else: else:
return "swingmusic" return "swingmusic"
@property @property
def app_dir(self) -> Path: def config_dir(self) -> Path:
return self.base_path / self.config_folder_name return self.config_parent / self.config_folder_name
@property @property
def img_path(self) -> Path: def img_path(self) -> Path:
return self.app_dir / "images" return self.config_dir / "images"
# ARTISTS # ARTISTS
@property @property
@@ -384,7 +390,7 @@ class Paths(metaclass=Singleton):
@property @property
def lg_thumb_path(self) -> pathlib.Path: def lg_thumb_path(self) -> pathlib.Path:
return self.thumbs_path/ "large" return self.thumbs_path / "large"
# OTHERS # OTHERS
@property @property
@@ -393,11 +399,11 @@ class Paths(metaclass=Singleton):
@property @property
def assets_path(self) -> pathlib.Path: def assets_path(self) -> pathlib.Path:
return self.app_dir / "assets" return self.config_dir / "assets"
@property @property
def plugins_path(self) -> pathlib.Path: def plugins_path(self) -> pathlib.Path:
return self.app_dir / "plugins" return self.config_dir / "plugins"
@property @property
def lyrics_plugins_path(self) -> pathlib.Path: def lyrics_plugins_path(self) -> pathlib.Path:
@@ -405,23 +411,23 @@ class Paths(metaclass=Singleton):
@property @property
def config_file_path(self) -> pathlib.Path: def config_file_path(self) -> pathlib.Path:
return self.app_dir/ "settings.json" return self.config_dir / "settings.json"
@property @property
def mixes_img_path(self) -> pathlib.Path: def mixes_img_path(self) -> pathlib.Path:
return self.img_path/ "mixes" return self.img_path / "mixes"
@property @property
def artist_mixes_img_path(self) -> pathlib.Path: def artist_mixes_img_path(self) -> pathlib.Path:
return self.mixes_img_path/ "artists" return self.mixes_img_path / "artists"
@property @property
def og_mixes_img_path(self) -> pathlib.Path: def og_mixes_img_path(self) -> pathlib.Path:
return self.mixes_img_path/ "original" return self.mixes_img_path / "original"
@property @property
def md_mixes_img_path(self) -> pathlib.Path: def md_mixes_img_path(self) -> pathlib.Path:
return self.mixes_img_path/ "medium" return self.mixes_img_path / "medium"
@property @property
def sm_mixes_img_path(self) -> pathlib.Path: def sm_mixes_img_path(self) -> pathlib.Path:
@@ -433,15 +439,15 @@ class Paths(metaclass=Singleton):
@property @property
def app_db_path(self): def app_db_path(self):
return Paths().app_dir / self.APP_DB_NAME return Paths().config_dir / self.APP_DB_NAME
@property @property
def userdata_db_path(self): def userdata_db_path(self):
return Paths().app_dir / self.USER_DATA_DB_NAME return Paths().config_dir / self.USER_DATA_DB_NAME
@property @property
def json_config_path(self): def json_config_path(self):
return Paths().app_dir / "config.json" return Paths().config_dir / "config.json"
# # # # # # # # # # # # # # # # # # # # # # # # # #
@@ -478,6 +484,7 @@ class Defaults:
API_TRACKNAME = "Martin & Gina" API_TRACKNAME = "Martin & Gina"
API_CARD_LIMIT = 6 API_CARD_LIMIT = 6
class TCOLOR: class TCOLOR:
""" """
Terminal colors Terminal colors
@@ -492,4 +499,4 @@ class TCOLOR:
ENDC = "\033[0m" ENDC = "\033[0m"
BOLD = "\033[1m" BOLD = "\033[1m"
UNDERLINE = "\033[4m" UNDERLINE = "\033[4m"
# credits: https://stackoverflow.com/a/287944 # credits: https://stackoverflow.com/a/287944
+1 -1
View File
@@ -18,4 +18,4 @@ def log_startup_info(host: str, port: int):
f"{TCOLOR.OKGREEN}http://{address}:{port}{TCOLOR.ENDC}" f"{TCOLOR.OKGREEN}http://{address}:{port}{TCOLOR.ENDC}"
) )
print(f"\n{TCOLOR.YELLOW}Data folder: {Paths().app_dir}{TCOLOR.ENDC}\n") print(f"\n{TCOLOR.YELLOW}Data folder: {Paths().config_dir}{TCOLOR.ENDC}\n")
+31 -9
View File
@@ -1,18 +1,17 @@
from swingmusic import settings, app_builder import socket
import sys
from swingmusic import app_builder
from swingmusic.crons import start_cron_jobs from swingmusic.crons import start_cron_jobs
from swingmusic.plugins.register import register_plugins from swingmusic.plugins.register import register_plugins
from swingmusic.setup import load_into_mem, run_setup from swingmusic.setup import load_into_mem, run_setup
from swingmusic.start_info_logger import log_startup_info from swingmusic.start_info_logger import log_startup_info
from swingmusic.utils.threading import background from swingmusic.utils.threading import background
from swingmusic.logger import setup_logger
import pathlib
import setproctitle import setproctitle
import mimetypes import mimetypes
def config_mimetypes(): def config_mimetypes():
# Load mimetypes for the web client's static files # Load mimetypes for the web client's static files
# Loading mimetypes should happen automaticaly but # Loading mimetypes should happen automaticaly but
@@ -35,7 +34,24 @@ def config_mimetypes():
mimetypes.add_type("application/manifest+json", ".webmanifest") mimetypes.add_type("application/manifest+json", ".webmanifest")
def start_swingmusic(host: str, port: int, path:dict[str,pathlib.Path|None]): class PortManager:
def __init__(self, host: str):
self.host = host
def test_port(self, port: int):
try:
http_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
http_server.bind((self.host, port))
http_server.close()
return True
except socket.error as e:
if e.errno == 48:
return False
else:
raise e
def start_swingmusic(host: str, port: int):
""" """
Creates and starts the Flask application server for Swing Music. Creates and starts the Flask application server for Swing Music.
@@ -51,13 +67,20 @@ def start_swingmusic(host: str, port: int, path:dict[str,pathlib.Path|None]):
:param host: The host address to bind the server to (e.g., 'localhost' or '0.0.0.0') :param host: The host address to bind the server to (e.g., 'localhost' or '0.0.0.0')
:param port: The port number to run the server on :param port: The port number to run the server on
:param path: dict with all path config
""" """
port_manager = PortManager(host)
# Try starting a server on port 1970
# If it fails, exit with error
if not port_manager.test_port(port):
print(f"Error 48: Port {port} already in use.")
print("Please specify a different port using the --port argument.")
sys.exit(1)
# Example: Setting up dirs, database, and loading stuff into memory. # Example: Setting up dirs, database, and loading stuff into memory.
# TIP: Be careful with the order of the setup functions. # TIP: Be careful with the order of the setup functions.
# NOTE: concurrent and multithreading create own sys.modules -> no globals # NOTE: concurrent and multithreading create own sys.modules -> no globals
settings.Paths(**path)
config_mimetypes() config_mimetypes()
run_setup() run_setup()
@@ -69,7 +92,6 @@ def start_swingmusic(host: str, port: int, path:dict[str,pathlib.Path|None]):
setproctitle.setproctitle(f"swingmusic {host}:{port}") setproctitle.setproctitle(f"swingmusic {host}:{port}")
start_cron_jobs() start_cron_jobs()
app = app_builder.build() app = app_builder.build()
log_startup_info(host, port) log_startup_info(host, port)
@@ -95,4 +117,4 @@ def start_swingmusic(host: str, port: int, path:dict[str,pathlib.Path|None]):
threads=100, threads=100,
ipv6=True, ipv6=True,
ipv4=True, ipv4=True,
) )
+4 -3
View File
@@ -10,12 +10,13 @@ from swingmusic.settings import Paths
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
def handle_password_reset(path:dict[str,Path]):
def handle_password_reset(config_parent: Path):
""" """
Handles the --password-reset argument. Resets the password. Handles the --password-reset argument. Resets the password.
""" """
Paths(**path) Paths(config_parent=config_parent)
setup_sqlite() setup_sqlite()
@@ -64,4 +65,4 @@ def create_image(width, height, color1, color2):
# Paste the resized image onto the padded image # Paste the resized image onto the padded image
padded_image.paste(image, (x, y), image) padded_image.paste(image, (x, y), image)
return padded_image return padded_image
+1 -1
View File
@@ -5,7 +5,7 @@ import pathlib
hiddenimports =[] hiddenimports =[]
# hiddenimports += collect_submodules('swingmusic') # hiddenimports += collect_submodules('swingmusic')
datas = [('client', 'client')] datas = [('client.zip', '.')]
datas += collect_data_files('swingmusic', True, excludes=['**/*.py'], includes=['**/*.*']) datas += collect_data_files('swingmusic', True, excludes=['**/*.py'], includes=['**/*.*'])
datas += collect_data_files('flask_openapi3', True, excludes=['**/*.py'], includes=['**/*.*']) datas += collect_data_files('flask_openapi3', True, excludes=['**/*.py'], includes=['**/*.*'])
Generated
+660 -654
View File
File diff suppressed because it is too large Load Diff