rewrite options with click

+ fix cpu count multiprocessing errors
This commit is contained in:
cwilvx
2025-03-23 23:54:13 +03:00
parent bea853fcd3
commit f891c28c2e
14 changed files with 344 additions and 465 deletions
+21 -135
View File
@@ -2,60 +2,33 @@
Handles arguments passed to the program.
"""
from getpass import getpass
import os.path
import sys
from getpass import getpass
import click
import PyInstaller.__main__ as bundler
from app import settings
from app.config import UserConfig
from app.db.userdata import UserTable
from app.logger import log
from app.print_help import HELP_MESSAGE
from app.setup.sqlite import setup_sqlite
from app.utils.auth import hash_password
from app.utils.paths import getFlaskOpenApiPath
from app.utils.xdg_utils import get_xdg_config_dir
from app.utils.wintools import is_windows
ALLARGS = settings.ALLARGS
ARGS = sys.argv[1:]
class ProcessArgs:
def handle_build(*args, **kwargs):
"""
Processes the arguments passed to the program.
Handles the --build argument. Builds the project into a single executable.
"""
def __init__(self) -> None:
# resolve config path
self.handle_config_path() # 1
# handles that exit
self.handle_password_recovery()
self.handle_build()
self.handle_help()
self.handle_version()
# non-exiting handles
self.handle_host()
self.handle_port()
self.handle_periodic_scan()
self.handle_periodic_scan_interval()
@staticmethod
def handle_build():
"""
Runs Pyinstaller.
"""
if ALLARGS.build not in ARGS:
if not args[2]:
return
if settings.IS_BUILD:
print("Do the cha cha slide instead!")
print("https://www.youtube.com/watch?v=wZv62ShoStY")
click.echo("Can't build the project. Exiting ...")
sys.exit(0)
info_keys = [
@@ -70,7 +43,9 @@ class ProcessArgs:
value = settings.Info.get(key)
if not value:
log.error(f"WARNING: {key} not resolved. Exiting ...")
log.error(
f"WARNING: {key} not resolved. Can't build the project. Exiting ..."
)
sys.exit(0)
lines.append(f'{key} = "{value}"\n')
@@ -87,7 +62,7 @@ class ProcessArgs:
bundler.run(
[
"manage.py",
"main.py",
"--onefile",
"--name",
"swingmusic",
@@ -107,108 +82,14 @@ class ProcessArgs:
sys.exit(0)
@staticmethod
def handle_port():
if ALLARGS.port in ARGS:
index = ARGS.index(ALLARGS.port)
try:
port = ARGS[index + 1]
except IndexError:
print("ERROR: Port not specified")
sys.exit(0)
try:
settings.FLASKVARS.FLASK_PORT = int(port) # type: ignore
except ValueError:
print("ERROR: Port should be a number")
sys.exit(0)
@staticmethod
def handle_host():
if ALLARGS.host in ARGS:
index = ARGS.index(ALLARGS.host)
try:
host = ARGS[index + 1]
except IndexError:
print("ERROR: Host not specified")
sys.exit(0)
settings.FLASKVARS.set_flask_host(host) # type: ignore
@staticmethod
def handle_config_path():
def handle_password_reset(*args, **kwargs):
"""
Modifies the config path.
Handles the --password-reset argument. Resets the password.
"""
if ALLARGS.config in ARGS:
index = ARGS.index(ALLARGS.config)
try:
config_path = ARGS[index + 1]
resolved = os.path.abspath(config_path)
if os.path.exists(resolved):
settings.Paths.set_config_dir(resolved)
if not args[2]:
return
log.warn(f"Config path {resolved} doesn't exist")
sys.exit(0)
except IndexError:
pass
settings.Paths.set_config_dir(get_xdg_config_dir())
@staticmethod
def handle_periodic_scan():
if any((a in ARGS for a in ALLARGS.no_periodic_scan)):
UserConfig().enablePeriodicScans = False
@staticmethod
def handle_periodic_scan_interval():
if any((a in ARGS for a in ALLARGS.periodic_scan_interval)):
index = [
ARGS.index(a) for a in ALLARGS.periodic_scan_interval if a in ARGS
][0]
try:
interval = ARGS[index + 1]
except IndexError:
print("ERROR: Interval not specified")
sys.exit(0)
try:
psi = int(interval)
except ValueError:
print("ERROR: Interval should be a number")
sys.exit(0)
if psi < 0:
print("WHAT ARE YOU TRYING?")
sys.exit(0)
UserConfig().scanInterval = psi
@staticmethod
def handle_help():
if any((a in ARGS for a in ALLARGS.help)):
print(HELP_MESSAGE)
sys.exit(0)
@staticmethod
def handle_version():
if any((a in ARGS for a in ALLARGS.version)):
print(f"VERSION: v{settings.Info.SWINGMUSIC_APP_VERSION}")
print(
f"COMMIT#: {settings.Info.GIT_CURRENT_BRANCH}/{settings.Info.GIT_LATEST_COMMIT_HASH}"
)
sys.exit(0)
@staticmethod
def handle_password_recovery():
if ALLARGS.pswd in ARGS:
print("SWING MUSIC v2.0.0 ")
print("PASSWORD RECOVERY \n")
setup_sqlite()
username: str = ""
@@ -218,23 +99,28 @@ class ProcessArgs:
try:
username = input("Enter username: ")
except KeyboardInterrupt:
print("\nOperation cancelled! Exiting ...")
click.echo("\nOperation cancelled! Exiting ...")
sys.exit(0)
username = username.strip()
user = UserTable.get_by_username(username)
if not user:
print(f"User {username} not found")
click.echo(f"User {username} not found")
sys.exit(0)
# collect password
try:
password = getpass("Enter new password: ")
except KeyboardInterrupt:
print("\nOperation cancelled! Exiting ...")
click.echo("\nOperation cancelled! Exiting ...")
sys.exit(0)
try:
UserTable.update_one({"id": user.id, "password": hash_password(password)})
click.echo("Password reset successfully!")
except Exception as e:
click.echo(f"Error resetting password: {e}")
sys.exit(0)
sys.exit(0)
+1 -2
View File
@@ -157,8 +157,7 @@ class CheckArtistImages:
]
# Use number of CPU cores minus 1 to leave one core free for system processes
num_workers = max(1, math.floor(multiprocessing.cpu_count() / 2))
print("num_workers", num_workers)
num_workers = max(1, os.cpu_count() // 2)
with ProcessPoolExecutor(max_workers=num_workers) as executor:
res = list(
+1 -3
View File
@@ -5,12 +5,10 @@ from app.lib.sortlib import sort_folders, sort_tracks
from app.logger import log
from app.models import Folder
from app.serializers.track import serialize_tracks
from app.settings import SUPPORTED_FILES
from app.utils.filesystem import SUPPORTED_FILES
from app.store.folder import FolderStore
from app.utils.wintools import win_replace_slash
# from app.db.libdata import TrackTable as TrackDB
def create_folder(path: str, trackcount=0) -> Folder:
"""
+3 -10
View File
@@ -22,6 +22,7 @@ from app.utils.progressbar import tqdm
from app.db.userdata import SimilarArtistTable
class CordinateMedia:
"""
Cordinates the extracting of thumbnails
@@ -72,14 +73,6 @@ def get_image(album: Album):
return
def get_cpu_count():
"""
Returns the number of CPUs on the machine.
"""
cpu_count = os.cpu_count() or 0
return cpu_count // 2 if cpu_count > 2 else cpu_count
class ProcessTrackThumbnails:
"""
Extracts the album art from all albums in album store.
@@ -93,7 +86,7 @@ class ProcessTrackThumbnails:
if platform.system() == "Linux":
# INFO: Processess are forked with access to global stores
# It's "safe" to use a process pool
cpus = math.floor(get_cpu_count() / 2)
cpus = max(1, os.cpu_count() // 2)
with ProcessPoolExecutor(max_workers=cpus) as executor:
results = list(
tqdm(
@@ -160,7 +153,7 @@ class FetchSimilarArtistsLastFM:
)
artists = list(artists)
with ProcessPoolExecutor(max_workers=get_cpu_count()) as executor:
with ProcessPoolExecutor(max_workers=max(1, os.cpu_count() // 2)) as executor:
try:
print("Processing similar artists")
results = list(
-35
View File
@@ -1,35 +0,0 @@
from app.settings import ALLARGS, Info
from tabulate import tabulate
args = ALLARGS
help_args_list = [
["--help", "-h", "Show this help message"],
["--version", "-v", "Show the app version"],
["--host", "", "Set the host"],
["--port", "", "Set the port"],
["--config", "", "Set the config path"],
["--no-periodic-scan", "-nps", "Disable periodic scan"],
["--pswd", "", "Recover a password"],
[
"--scan-interval",
"-psi",
"Set the scan interval in seconds. Default 600s (10 minutes)",
],
[
"--build",
"",
"Build the application (in development)",
],
]
HELP_MESSAGE = f"""
Swing Music v{Info.SWINGMUSIC_APP_VERSION}
A beautiful, self-hosted music player for your local audio files.
Like Spotify ... but bring your own music.
Usage: ./swingmusic [options] [args]
{tabulate(help_args_list, headers=["Option", "Alias", "Description"], tablefmt="psql", maxcolwidths=[None, None, 40])}
"""
+10 -4
View File
@@ -7,6 +7,7 @@ import subprocess
import sys
from app import configs
from app.utils.filesystem import get_home_res_path
join = os.path.join
@@ -159,10 +160,6 @@ class Defaults:
API_CARD_LIMIT = 6
FILES = ["flac", "mp3", "wav", "m4a", "ogg", "wma", "opus", "alac", "aiff"]
SUPPORTED_FILES = tuple(f".{file}" for file in FILES)
# ===== SQLite =====
class DbPaths:
APP_DB_NAME = "swingmusic.db"
@@ -304,6 +301,15 @@ class Info:
"""
SWINGMUSIC_APP_VERSION = os.environ.get("SWINGMUSIC_APP_VERSION")
if not SWINGMUSIC_APP_VERSION:
path = get_home_res_path("version.txt")
if not path:
raise ValueError("Version file not found")
with open(path, "r") as f:
SWINGMUSIC_APP_VERSION = f.read().strip()
GIT_LATEST_COMMIT_HASH = "<unset>"
GIT_CURRENT_BRANCH = "<unset>"
+14 -18
View File
@@ -1,30 +1,26 @@
import os
from app.settings import FLASKVARS, TCOLOR, Info, Paths
from app.settings import TCOLOR, Info, Paths
from app.utils.network import get_ip
import click
def log_startup_info():
lines = "------------------------------"
def log_startup_info(host: str, port: int):
lines = "-"*30
# clears terminal 👇
# os.system("cls" if os.name == "nt" else "echo -e \\\\033c")
print(lines)
print(f"{TCOLOR.HEADER}Swing Music v{Info.SWINGMUSIC_APP_VERSION} {TCOLOR.ENDC}")
click.echo(f"{TCOLOR.HEADER}Swing Music v{Info.SWINGMUSIC_APP_VERSION} {TCOLOR.ENDC}")
adresses = [FLASKVARS.get_flask_host()]
addresses = [host]
if FLASKVARS.get_flask_host() == "0.0.0.0":
if host == "0.0.0.0":
remote_ip = get_ip()
adresses = ["localhost"] + ([remote_ip] if remote_ip else [])
addresses.extend(["127.0.0.1"] + ([remote_ip] if remote_ip else []))
print("Started app on:")
for address in adresses:
# noinspection HttpUrlsUsage
print(
f"{TCOLOR.OKGREEN}http://{address}:{FLASKVARS.get_flask_port()}{TCOLOR.ENDC}"
click.echo("Server running on:\n")
for address in addresses:
click.echo(
f"{TCOLOR.OKGREEN}http://{address}:{port}{TCOLOR.ENDC}"
)
print(lines + "\n")
print(f"{TCOLOR.YELLOW}Data folder: {Paths.get_app_dir()}{TCOLOR.ENDC}")
click.echo("")
click.echo(f"{TCOLOR.YELLOW}Data folder: {Paths.get_app_dir()}{TCOLOR.ENDC}\n")
+2
View File
@@ -114,6 +114,8 @@ class TrackStore:
else:
cls.trackhashmap[track.trackhash].append(track)
print("Done!")
@classmethod
def add_track(cls, track: Track):
"""
+3 -1
View File
@@ -1,11 +1,13 @@
import os
from pathlib import Path
from app.settings import SUPPORTED_FILES
from app.utils.wintools import win_replace_slash
CWD = Path(__file__).parent.resolve()
FILES = ["flac", "mp3", "wav", "m4a", "ogg", "wma", "opus", "alac", "aiff"]
SUPPORTED_FILES = tuple(f".{file}" for file in FILES)
def run_fast_scandir(_dir: str, full=False) -> tuple[list[str], list[str]]:
"""
+74
View File
@@ -0,0 +1,74 @@
import multiprocessing
import pathlib
import click
import sys
from app.arg_handler import handle_build, handle_password_reset
from app.utils.filesystem import get_home_res_path
from app.utils.xdg_utils import get_xdg_config_dir
from manage import run_app
def version(*args, **kwargs):
if not args[2]:
return
path = get_home_res_path("version.txt")
if not path:
click.echo("Version file not found.")
sys.exit(1)
with open(path, "r") as f:
version = f.read()
click.echo(version)
sys.exit(0)
@click.command(options_metavar="<options>", context_settings={"show_default": True})
@click.option(
"--build",
default=False,
help="Build the project.",
is_eager=True,
callback=handle_build,
is_flag=True,
)
@click.option("--host", default="0.0.0.0", help="Host to run the app on.")
@click.option("--port", default=1970, help="HTTP port to run the app on.")
@click.option(
"--config",
default=lambda: get_xdg_config_dir(),
show_default="XDG_CONFIG_HOME",
help="Path to the config file.",
type=click.Path(
exists=True,
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
allow_dash=False,
path_type=pathlib.Path,
),
)
@click.option(
"--password-reset",
is_flag=True,
help="Reset the password.",
is_eager=True,
callback=handle_password_reset,
)
@click.option(
"--version",
is_flag=True,
default=False,
callback=version,
help="Show the version and exit",
is_eager=True,
)
def run(*args, **kwargs):
run_app(kwargs["host"], kwargs["port"], kwargs["config"])
if __name__ == "__main__":
multiprocessing.freeze_support()
run()
+19 -72
View File
@@ -2,14 +2,11 @@
This file is used to run the application.
"""
import logging
import os
import psutil
import waitress
# import bjoern
import logging
import mimetypes
import setproctitle
import multiprocessing
from pathlib import Path
from flask_jwt_extended import (
create_access_token,
@@ -21,18 +18,20 @@ from flask_jwt_extended import (
from datetime import datetime, timezone
from flask import Response, request
from app import settings
from app.api import create_api
from app.crons import start_cron_jobs
from app.arg_handler import ProcessArgs
from app.lib.index import IndexEverything
from app.utils.threading import background
from app.setup import load_into_mem, run_setup
from app.settings import FLASKVARS, TCOLOR, Info
from app.plugins.register import register_plugins
from app.start_info_logger import log_startup_info
from app.utils.filesystem import get_home_res_path
from app.utils.paths import getClientFilesExtensions
def run_app(host: str, port: int, config: Path):
settings.Paths.set_config_dir(config)
# Load mimetypes for the web client's static files
# Loading mimetypes should happen automatically but
# sometimes the mimetypes are not loaded correctly
@@ -60,41 +59,20 @@ mimetypes.add_type("application/manifest+json", ".webmanifest")
waitress_logger = logging.getLogger("waitress")
waitress_logger.setLevel(logging.ERROR)
# # logging.basicConfig()
# logging.getLogger("sqlalchemy.engine").setLevel(logging.ERROR)
# Background tasks
# @background
def bg_run_setup():
IndexEverything()
# @background
# def start_watchdog():
# WatchDog().run()
log_startup_info(host, port)
@background
def run_swingmusic():
log_startup_info()
register_plugins()
# start_watchdog()
setproctitle.setproctitle(f"swingmusic ::{FLASKVARS.get_flask_port()}")
# bg_run_setup()
setproctitle.setproctitle(f"swingmusic {host}:{port}")
start_cron_jobs()
# Setup function calls
Info.load()
ProcessArgs()
settings.Info.load()
run_setup()
# Create the Flask app
app = create_api()
app.static_folder = get_home_res_path("client")
@@ -109,7 +87,6 @@ whitelisted_routes = {
}
blacklist_extensions = {".webp", ".jpg"}.union(getClientFilesExtensions())
def skipAuthAction():
"""
Skips the JWT verification for the current request.
@@ -125,7 +102,6 @@ def skipAuthAction():
return False
@app.before_request
def verify_auth():
"""
@@ -136,7 +112,6 @@ def verify_auth():
verify_jwt_in_request()
@app.after_request
def refresh_expiring_jwt(response: Response):
"""
@@ -161,7 +136,6 @@ def refresh_expiring_jwt(response: Response):
except (RuntimeError, KeyError):
return response
@app.route("/<path:path>")
def serve_client_files(path: str):
"""
@@ -177,7 +151,9 @@ def serve_client_files(path: str):
# INFO: Safari doesn't support gzip encoding
# See issue: https://github.com/swingmx/swingmusic/issues/155
is_safari = (
user_agent and user_agent.find("Safari") >= 0 and user_agent.find("Chrome") < 0
user_agent
and user_agent.find("Safari") >= 0
and user_agent.find("Chrome") < 0
)
if is_safari:
@@ -193,7 +169,6 @@ def serve_client_files(path: str):
return app.send_static_file(path)
@app.route("/")
def serve_client():
"""
@@ -201,43 +176,17 @@ def serve_client():
"""
return app.send_static_file("index.html")
prev_memory = 0
# INFO: For debugging memory usage
# @app.after_request
def print_memory_usage(response: Response):
# INFO: Ignore assets
if (
request.path.startswith("/img")
or request.path.endswith(".js")
or request.path.endswith(".css")
):
return response
process = psutil.Process(os.getpid())
global prev_memory
current_mem = process.memory_info().rss
diff = (current_mem - prev_memory) / 1024**2
prev_memory = current_mem
# INFO: Print memory usage (highlights if diff is more than 0.1 MB)
print(
f"\n{request.path} | TOTAL: {current_mem/1024**2} MB | DIFF: {TCOLOR.FAIL if diff > 0.1 else ''}{diff} MB{TCOLOR.ENDC if diff > 0.1 else ''} \n"
)
return response
if __name__ == "__main__":
multiprocessing.freeze_support()
load_into_mem()
run_swingmusic()
# TrackStore.export()
# ArtistStore.export()
host = FLASKVARS.get_flask_host()
port = FLASKVARS.get_flask_port()
try:
import bjoern
bjoern.run(app, host, port)
except ImportError:
import waitress
waitress.serve(
app,
@@ -247,5 +196,3 @@ if __name__ == "__main__":
ipv6=True,
ipv4=True,
)
# app.run(host=host, port=port, debug=False)
# bjoern.run(app, host, port)
+1
View File
@@ -33,6 +33,7 @@ dependencies = [
"rapidfuzz==3.11.0",
"waitress>=3.0.2",
"pendulum>=3.0.0",
"bjoern>=3.2.2",
]
[dependency-groups]
Generated
+9
View File
@@ -1,4 +1,5 @@
version = 1
revision = 1
requires-python = "==3.11.*"
resolution-markers = [
"platform_python_implementation != 'PyPy'",
@@ -23,6 +24,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
name = "bjoern"
version = "3.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/fba55eb58a502dabc0915137ff44eaacaba58a60196fd94dc8cd4e9fe67d/bjoern-3.2.2.tar.gz", hash = "sha256:16e5a02a9a17a7f5f8bea0d7c58650e78ab80ead6fe3e390037573d4355baf31", size = 45716 }
[[package]]
name = "blinker"
version = "1.9.0"
@@ -826,6 +833,7 @@ name = "swingmusic"
version = "2.0.2"
source = { virtual = "." }
dependencies = [
{ name = "bjoern" },
{ name = "colorgram-py" },
{ name = "ffmpeg-python" },
{ name = "flask" },
@@ -861,6 +869,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "bjoern", specifier = ">=3.2.2" },
{ name = "colorgram-py", specifier = ">=1.2.0" },
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
{ name = "flask", specifier = ">=3.1.0" },
+1
View File
@@ -0,0 +1 @@
2.0.3