mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
rewrite options with click
+ fix cpu count multiprocessing errors
This commit is contained in:
+89
-203
@@ -2,239 +2,125 @@
|
|||||||
Handles arguments passed to the program.
|
Handles arguments passed to the program.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from getpass import getpass
|
|
||||||
import os.path
|
|
||||||
import sys
|
import sys
|
||||||
|
from getpass import getpass
|
||||||
|
|
||||||
|
import click
|
||||||
import PyInstaller.__main__ as bundler
|
import PyInstaller.__main__ as bundler
|
||||||
|
|
||||||
from app import settings
|
from app import settings
|
||||||
from app.config import UserConfig
|
|
||||||
from app.db.userdata import UserTable
|
from app.db.userdata import UserTable
|
||||||
from app.logger import log
|
from app.logger import log
|
||||||
from app.print_help import HELP_MESSAGE
|
|
||||||
from app.setup.sqlite import setup_sqlite
|
from app.setup.sqlite import setup_sqlite
|
||||||
from app.utils.auth import hash_password
|
from app.utils.auth import hash_password
|
||||||
from app.utils.paths import getFlaskOpenApiPath
|
from app.utils.paths import getFlaskOpenApiPath
|
||||||
from app.utils.xdg_utils import get_xdg_config_dir
|
|
||||||
from app.utils.wintools import is_windows
|
from app.utils.wintools import is_windows
|
||||||
|
|
||||||
ALLARGS = settings.ALLARGS
|
ALLARGS = settings.ALLARGS
|
||||||
ARGS = sys.argv[1:]
|
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.
|
||||||
"""
|
"""
|
||||||
|
if not args[2]:
|
||||||
|
return
|
||||||
|
|
||||||
def __init__(self) -> None:
|
if settings.IS_BUILD:
|
||||||
# resolve config path
|
click.echo("Can't build the project. Exiting ...")
|
||||||
self.handle_config_path() # 1
|
sys.exit(0)
|
||||||
|
|
||||||
# handles that exit
|
info_keys = [
|
||||||
self.handle_password_recovery()
|
"SWINGMUSIC_APP_VERSION",
|
||||||
self.handle_build()
|
"GIT_LATEST_COMMIT_HASH",
|
||||||
self.handle_help()
|
"GIT_CURRENT_BRANCH",
|
||||||
self.handle_version()
|
]
|
||||||
|
|
||||||
# non-exiting handles
|
lines = []
|
||||||
self.handle_host()
|
|
||||||
self.handle_port()
|
|
||||||
self.handle_periodic_scan()
|
|
||||||
self.handle_periodic_scan_interval()
|
|
||||||
|
|
||||||
@staticmethod
|
for key in info_keys:
|
||||||
def handle_build():
|
value = settings.Info.get(key)
|
||||||
"""
|
|
||||||
Runs Pyinstaller.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if ALLARGS.build not in ARGS:
|
if not value:
|
||||||
return
|
log.error(
|
||||||
|
f"WARNING: {key} not resolved. Can't build the project. Exiting ..."
|
||||||
if settings.IS_BUILD:
|
|
||||||
print("Do the cha cha slide instead!")
|
|
||||||
print("https://www.youtube.com/watch?v=wZv62ShoStY")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
info_keys = [
|
|
||||||
"SWINGMUSIC_APP_VERSION",
|
|
||||||
"GIT_LATEST_COMMIT_HASH",
|
|
||||||
"GIT_CURRENT_BRANCH",
|
|
||||||
]
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
for key in info_keys:
|
|
||||||
value = settings.Info.get(key)
|
|
||||||
|
|
||||||
if not value:
|
|
||||||
log.error(f"WARNING: {key} not resolved. Exiting ...")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
lines.append(f'{key} = "{value}"\n')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# write the info to the config file
|
|
||||||
with open("./app/configs.py", "w", encoding="utf-8") as file:
|
|
||||||
# copy the api keys to the config file
|
|
||||||
file.writelines(lines)
|
|
||||||
|
|
||||||
_s = ";" if is_windows() else ":"
|
|
||||||
|
|
||||||
flask_openapi_path = getFlaskOpenApiPath()
|
|
||||||
|
|
||||||
bundler.run(
|
|
||||||
[
|
|
||||||
"manage.py",
|
|
||||||
"--onefile",
|
|
||||||
"--name",
|
|
||||||
"swingmusic",
|
|
||||||
"--clean",
|
|
||||||
f"--add-data=assets{_s}assets",
|
|
||||||
f"--add-data=client{_s}client",
|
|
||||||
f"--add-data={flask_openapi_path}/templates/static{_s}flask_openapi3/templates/static",
|
|
||||||
f"--icon=assets/logo-fill.light.ico",
|
|
||||||
"-y",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
# revert and remove the api keys for dev mode
|
|
||||||
with open("./app/configs.py", "w", encoding="utf-8") as file:
|
|
||||||
lines = [f'{key} = ""\n' for key in info_keys]
|
|
||||||
file.writelines(lines)
|
|
||||||
|
|
||||||
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():
|
|
||||||
"""
|
|
||||||
Modifies the config path.
|
|
||||||
"""
|
|
||||||
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)
|
|
||||||
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)
|
sys.exit(0)
|
||||||
|
|
||||||
@staticmethod
|
lines.append(f'{key} = "{value}"\n')
|
||||||
def handle_password_recovery():
|
|
||||||
if ALLARGS.pswd in ARGS:
|
|
||||||
print("SWING MUSIC v2.0.0 ")
|
|
||||||
print("PASSWORD RECOVERY \n")
|
|
||||||
setup_sqlite()
|
|
||||||
|
|
||||||
username: str = ""
|
try:
|
||||||
password: str = ""
|
# write the info to the config file
|
||||||
|
with open("./app/configs.py", "w", encoding="utf-8") as file:
|
||||||
|
# copy the api keys to the config file
|
||||||
|
file.writelines(lines)
|
||||||
|
|
||||||
# collect username
|
_s = ";" if is_windows() else ":"
|
||||||
try:
|
|
||||||
username = input("Enter username: ")
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nOperation cancelled! Exiting ...")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
username = username.strip()
|
flask_openapi_path = getFlaskOpenApiPath()
|
||||||
user = UserTable.get_by_username(username)
|
|
||||||
|
|
||||||
if not user:
|
bundler.run(
|
||||||
print(f"User {username} not found")
|
[
|
||||||
sys.exit(0)
|
"main.py",
|
||||||
|
"--onefile",
|
||||||
|
"--name",
|
||||||
|
"swingmusic",
|
||||||
|
"--clean",
|
||||||
|
f"--add-data=assets{_s}assets",
|
||||||
|
f"--add-data=client{_s}client",
|
||||||
|
f"--add-data={flask_openapi_path}/templates/static{_s}flask_openapi3/templates/static",
|
||||||
|
f"--icon=assets/logo-fill.light.ico",
|
||||||
|
"-y",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# revert and remove the api keys for dev mode
|
||||||
|
with open("./app/configs.py", "w", encoding="utf-8") as file:
|
||||||
|
lines = [f'{key} = ""\n' for key in info_keys]
|
||||||
|
file.writelines(lines)
|
||||||
|
|
||||||
# collect password
|
sys.exit(0)
|
||||||
try:
|
|
||||||
password = getpass("Enter new password: ")
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nOperation cancelled! Exiting ...")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
UserTable.update_one({"id": user.id, "password": hash_password(password)})
|
|
||||||
|
|
||||||
sys.exit(0)
|
def handle_password_reset(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
Handles the --password-reset argument. Resets the password.
|
||||||
|
"""
|
||||||
|
if not args[2]:
|
||||||
|
return
|
||||||
|
|
||||||
|
setup_sqlite()
|
||||||
|
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
|
|
||||||
|
# collect username
|
||||||
|
try:
|
||||||
|
username = input("Enter username: ")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
click.echo("\nOperation cancelled! Exiting ...")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
username = username.strip()
|
||||||
|
user = UserTable.get_by_username(username)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
click.echo(f"User {username} not found")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# collect password
|
||||||
|
try:
|
||||||
|
password = getpass("Enter new password: ")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
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)
|
||||||
|
|||||||
@@ -157,8 +157,7 @@ class CheckArtistImages:
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Use number of CPU cores minus 1 to leave one core free for system processes
|
# 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))
|
num_workers = max(1, os.cpu_count() // 2)
|
||||||
print("num_workers", num_workers)
|
|
||||||
|
|
||||||
with ProcessPoolExecutor(max_workers=num_workers) as executor:
|
with ProcessPoolExecutor(max_workers=num_workers) as executor:
|
||||||
res = list(
|
res = list(
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ from app.lib.sortlib import sort_folders, sort_tracks
|
|||||||
from app.logger import log
|
from app.logger import log
|
||||||
from app.models import Folder
|
from app.models import Folder
|
||||||
from app.serializers.track import serialize_tracks
|
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.store.folder import FolderStore
|
||||||
from app.utils.wintools import win_replace_slash
|
from app.utils.wintools import win_replace_slash
|
||||||
|
|
||||||
# from app.db.libdata import TrackTable as TrackDB
|
|
||||||
|
|
||||||
|
|
||||||
def create_folder(path: str, trackcount=0) -> Folder:
|
def create_folder(path: str, trackcount=0) -> Folder:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+3
-10
@@ -22,6 +22,7 @@ from app.utils.progressbar import tqdm
|
|||||||
|
|
||||||
from app.db.userdata import SimilarArtistTable
|
from app.db.userdata import SimilarArtistTable
|
||||||
|
|
||||||
|
|
||||||
class CordinateMedia:
|
class CordinateMedia:
|
||||||
"""
|
"""
|
||||||
Cordinates the extracting of thumbnails
|
Cordinates the extracting of thumbnails
|
||||||
@@ -72,14 +73,6 @@ def get_image(album: Album):
|
|||||||
return
|
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:
|
class ProcessTrackThumbnails:
|
||||||
"""
|
"""
|
||||||
Extracts the album art from all albums in album store.
|
Extracts the album art from all albums in album store.
|
||||||
@@ -93,7 +86,7 @@ class ProcessTrackThumbnails:
|
|||||||
if platform.system() == "Linux":
|
if platform.system() == "Linux":
|
||||||
# INFO: Processess are forked with access to global stores
|
# INFO: Processess are forked with access to global stores
|
||||||
# It's "safe" to use a process pool
|
# 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:
|
with ProcessPoolExecutor(max_workers=cpus) as executor:
|
||||||
results = list(
|
results = list(
|
||||||
tqdm(
|
tqdm(
|
||||||
@@ -160,7 +153,7 @@ class FetchSimilarArtistsLastFM:
|
|||||||
)
|
)
|
||||||
artists = list(artists)
|
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:
|
try:
|
||||||
print("Processing similar artists")
|
print("Processing similar artists")
|
||||||
results = list(
|
results = list(
|
||||||
|
|||||||
@@ -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
@@ -7,6 +7,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from app import configs
|
from app import configs
|
||||||
|
from app.utils.filesystem import get_home_res_path
|
||||||
|
|
||||||
join = os.path.join
|
join = os.path.join
|
||||||
|
|
||||||
@@ -159,10 +160,6 @@ class Defaults:
|
|||||||
API_CARD_LIMIT = 6
|
API_CARD_LIMIT = 6
|
||||||
|
|
||||||
|
|
||||||
FILES = ["flac", "mp3", "wav", "m4a", "ogg", "wma", "opus", "alac", "aiff"]
|
|
||||||
SUPPORTED_FILES = tuple(f".{file}" for file in FILES)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== SQLite =====
|
# ===== SQLite =====
|
||||||
class DbPaths:
|
class DbPaths:
|
||||||
APP_DB_NAME = "swingmusic.db"
|
APP_DB_NAME = "swingmusic.db"
|
||||||
@@ -304,6 +301,15 @@ class Info:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SWINGMUSIC_APP_VERSION = os.environ.get("SWINGMUSIC_APP_VERSION")
|
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_LATEST_COMMIT_HASH = "<unset>"
|
||||||
GIT_CURRENT_BRANCH = "<unset>"
|
GIT_CURRENT_BRANCH = "<unset>"
|
||||||
|
|
||||||
|
|||||||
+14
-18
@@ -1,30 +1,26 @@
|
|||||||
import os
|
from app.settings import TCOLOR, Info, Paths
|
||||||
|
|
||||||
from app.settings import FLASKVARS, TCOLOR, Info, Paths
|
|
||||||
from app.utils.network import get_ip
|
from app.utils.network import get_ip
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
def log_startup_info():
|
def log_startup_info(host: str, port: int):
|
||||||
lines = "------------------------------"
|
lines = "-"*30
|
||||||
# clears terminal 👇
|
# clears terminal 👇
|
||||||
# os.system("cls" if os.name == "nt" else "echo -e \\\\033c")
|
# os.system("cls" if os.name == "nt" else "echo -e \\\\033c")
|
||||||
|
|
||||||
print(lines)
|
click.echo(f"{TCOLOR.HEADER}Swing Music v{Info.SWINGMUSIC_APP_VERSION} {TCOLOR.ENDC}")
|
||||||
print(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()
|
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:")
|
click.echo("Server running on:\n")
|
||||||
for address in adresses:
|
for address in addresses:
|
||||||
# noinspection HttpUrlsUsage
|
click.echo(
|
||||||
print(
|
f"{TCOLOR.OKGREEN}http://{address}:{port}{TCOLOR.ENDC}"
|
||||||
f"➤ {TCOLOR.OKGREEN}http://{address}:{FLASKVARS.get_flask_port()}{TCOLOR.ENDC}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
print(lines + "\n")
|
click.echo("")
|
||||||
|
click.echo(f"{TCOLOR.YELLOW}Data folder: {Paths.get_app_dir()}{TCOLOR.ENDC}\n")
|
||||||
print(f"{TCOLOR.YELLOW}Data folder: {Paths.get_app_dir()}{TCOLOR.ENDC}")
|
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ class TrackStore:
|
|||||||
else:
|
else:
|
||||||
cls.trackhashmap[track.trackhash].append(track)
|
cls.trackhashmap[track.trackhash].append(track)
|
||||||
|
|
||||||
|
print("Done!")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def add_track(cls, track: Track):
|
def add_track(cls, track: Track):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.settings import SUPPORTED_FILES
|
|
||||||
from app.utils.wintools import win_replace_slash
|
from app.utils.wintools import win_replace_slash
|
||||||
|
|
||||||
CWD = Path(__file__).parent.resolve()
|
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]]:
|
def run_fast_scandir(_dir: str, full=False) -> tuple[list[str], list[str]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -2,14 +2,11 @@
|
|||||||
This file is used to run the application.
|
This file is used to run the application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import psutil
|
import logging
|
||||||
import waitress
|
|
||||||
# import bjoern
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import setproctitle
|
import setproctitle
|
||||||
import multiprocessing
|
from pathlib import Path
|
||||||
|
|
||||||
from flask_jwt_extended import (
|
from flask_jwt_extended import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
@@ -21,231 +18,181 @@ from flask_jwt_extended import (
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask import Response, request
|
from flask import Response, request
|
||||||
|
|
||||||
|
from app import settings
|
||||||
from app.api import create_api
|
from app.api import create_api
|
||||||
from app.crons import start_cron_jobs
|
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.utils.threading import background
|
||||||
from app.setup import load_into_mem, run_setup
|
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.plugins.register import register_plugins
|
||||||
from app.start_info_logger import log_startup_info
|
from app.start_info_logger import log_startup_info
|
||||||
from app.utils.filesystem import get_home_res_path
|
from app.utils.filesystem import get_home_res_path
|
||||||
from app.utils.paths import getClientFilesExtensions
|
from app.utils.paths import getClientFilesExtensions
|
||||||
|
|
||||||
# Load mimetypes for the web client's static files
|
|
||||||
# Loading mimetypes should happen automatically but
|
|
||||||
# sometimes the mimetypes are not loaded correctly
|
|
||||||
# eg. when the Registry is messed up on Windows.
|
|
||||||
|
|
||||||
# See the following issues:
|
def run_app(host: str, port: int, config: Path):
|
||||||
# https://github.com/swingmx/swingmusic/issues/137
|
settings.Paths.set_config_dir(config)
|
||||||
|
|
||||||
mimetypes.add_type("text/css", ".css")
|
# Load mimetypes for the web client's static files
|
||||||
mimetypes.add_type("text/javascript", ".js")
|
# Loading mimetypes should happen automatically but
|
||||||
mimetypes.add_type("text/plain", ".txt")
|
# sometimes the mimetypes are not loaded correctly
|
||||||
mimetypes.add_type("text/html", ".html")
|
# eg. when the Registry is messed up on Windows.
|
||||||
mimetypes.add_type("image/webp", ".webp")
|
|
||||||
mimetypes.add_type("image/svg+xml", ".svg")
|
|
||||||
mimetypes.add_type("image/png", ".png")
|
|
||||||
mimetypes.add_type("image/vnd.microsoft.icon", ".ico")
|
|
||||||
mimetypes.add_type("image/gif", ".gif")
|
|
||||||
mimetypes.add_type("font/woff", ".woff")
|
|
||||||
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
|
||||||
|
|
||||||
# logging.disable(logging.CRITICAL)
|
# See the following issues:
|
||||||
# werkzeug = logging.getLogger("werkzeug")
|
# https://github.com/swingmx/swingmusic/issues/137
|
||||||
# werkzeug.setLevel(logging.ERROR)
|
|
||||||
|
|
||||||
waitress_logger = logging.getLogger("waitress")
|
mimetypes.add_type("text/css", ".css")
|
||||||
waitress_logger.setLevel(logging.ERROR)
|
mimetypes.add_type("text/javascript", ".js")
|
||||||
|
mimetypes.add_type("text/plain", ".txt")
|
||||||
|
mimetypes.add_type("text/html", ".html")
|
||||||
|
mimetypes.add_type("image/webp", ".webp")
|
||||||
|
mimetypes.add_type("image/svg+xml", ".svg")
|
||||||
|
mimetypes.add_type("image/png", ".png")
|
||||||
|
mimetypes.add_type("image/vnd.microsoft.icon", ".ico")
|
||||||
|
mimetypes.add_type("image/gif", ".gif")
|
||||||
|
mimetypes.add_type("font/woff", ".woff")
|
||||||
|
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||||||
|
|
||||||
# # logging.basicConfig()
|
# logging.disable(logging.CRITICAL)
|
||||||
# logging.getLogger("sqlalchemy.engine").setLevel(logging.ERROR)
|
# werkzeug = logging.getLogger("werkzeug")
|
||||||
|
# werkzeug.setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
waitress_logger = logging.getLogger("waitress")
|
||||||
|
waitress_logger.setLevel(logging.ERROR)
|
||||||
|
|
||||||
# Background tasks
|
log_startup_info(host, port)
|
||||||
# @background
|
|
||||||
def bg_run_setup():
|
|
||||||
IndexEverything()
|
|
||||||
|
|
||||||
|
@background
|
||||||
|
def run_swingmusic():
|
||||||
|
register_plugins()
|
||||||
|
|
||||||
# @background
|
setproctitle.setproctitle(f"swingmusic {host}:{port}")
|
||||||
# def start_watchdog():
|
start_cron_jobs()
|
||||||
# WatchDog().run()
|
|
||||||
|
|
||||||
|
# Setup function calls
|
||||||
|
settings.Info.load()
|
||||||
|
run_setup()
|
||||||
|
|
||||||
@background
|
# Create the Flask app
|
||||||
def run_swingmusic():
|
app = create_api()
|
||||||
log_startup_info()
|
app.static_folder = get_home_res_path("client")
|
||||||
register_plugins()
|
|
||||||
|
|
||||||
# start_watchdog()
|
# INFO: Routes that don't need authentication
|
||||||
|
whitelisted_routes = {
|
||||||
|
"/auth/login",
|
||||||
|
"/auth/users",
|
||||||
|
"/auth/pair",
|
||||||
|
"/auth/logout",
|
||||||
|
"/auth/refresh",
|
||||||
|
"/docs",
|
||||||
|
}
|
||||||
|
blacklist_extensions = {".webp", ".jpg"}.union(getClientFilesExtensions())
|
||||||
|
|
||||||
setproctitle.setproctitle(f"swingmusic ::{FLASKVARS.get_flask_port()}")
|
def skipAuthAction():
|
||||||
# bg_run_setup()
|
"""
|
||||||
start_cron_jobs()
|
Skips the JWT verification for the current request.
|
||||||
|
"""
|
||||||
|
if request.path == "/" or any(
|
||||||
|
request.path.endswith(ext) for ext in blacklist_extensions
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# if request path starts with any of the blacklisted routes, don't verify jwt
|
||||||
|
if any(request.path.startswith(route) for route in whitelisted_routes):
|
||||||
|
return True
|
||||||
|
|
||||||
# Setup function calls
|
return False
|
||||||
Info.load()
|
|
||||||
ProcessArgs()
|
|
||||||
run_setup()
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def verify_auth():
|
||||||
|
"""
|
||||||
|
Verifies the JWT token before each request.
|
||||||
|
"""
|
||||||
|
if skipAuthAction():
|
||||||
|
return
|
||||||
|
|
||||||
# Create the Flask app
|
verify_jwt_in_request()
|
||||||
|
|
||||||
app = create_api()
|
@app.after_request
|
||||||
app.static_folder = get_home_res_path("client")
|
def refresh_expiring_jwt(response: Response):
|
||||||
|
"""
|
||||||
|
Refreshes the cookies JWT token after each request.
|
||||||
|
"""
|
||||||
|
|
||||||
# INFO: Routes that don't need authentication
|
# INFO: If the request has an Authorization header, don't refresh the jwt
|
||||||
whitelisted_routes = {
|
# Request is probably from the mobile client or a third party
|
||||||
"/auth/login",
|
if skipAuthAction() or request.headers.get("Authorization"):
|
||||||
"/auth/users",
|
|
||||||
"/auth/pair",
|
|
||||||
"/auth/logout",
|
|
||||||
"/auth/refresh",
|
|
||||||
"/docs",
|
|
||||||
}
|
|
||||||
blacklist_extensions = {".webp", ".jpg"}.union(getClientFilesExtensions())
|
|
||||||
|
|
||||||
|
|
||||||
def skipAuthAction():
|
|
||||||
"""
|
|
||||||
Skips the JWT verification for the current request.
|
|
||||||
"""
|
|
||||||
if request.path == "/" or any(
|
|
||||||
request.path.endswith(ext) for ext in blacklist_extensions
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# if request path starts with any of the blacklisted routes, don't verify jwt
|
|
||||||
if any(request.path.startswith(route) for route in whitelisted_routes):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
|
||||||
def verify_auth():
|
|
||||||
"""
|
|
||||||
Verifies the JWT token before each request.
|
|
||||||
"""
|
|
||||||
if skipAuthAction():
|
|
||||||
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 skipAuthAction() or request.headers.get("Authorization"):
|
|
||||||
return response
|
|
||||||
|
|
||||||
try:
|
|
||||||
exp_timestamp = get_jwt()["exp"]
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
target_timestamp = datetime.timestamp(now) + 60 * 60 * 24 * 7 # 7 days
|
|
||||||
|
|
||||||
if target_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
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/<path:path>")
|
|
||||||
def serve_client_files(path: str):
|
|
||||||
"""
|
|
||||||
Serves the static files in the client folder.
|
|
||||||
"""
|
|
||||||
js_or_css = path.endswith(".js") or path.endswith(".css")
|
|
||||||
if not js_or_css:
|
|
||||||
return app.send_static_file(path)
|
|
||||||
|
|
||||||
gzipped_path = path + ".gz"
|
|
||||||
user_agent = request.headers.get("User-Agent")
|
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_safari:
|
|
||||||
return app.send_static_file(path)
|
|
||||||
|
|
||||||
accepts_gzip = request.headers.get("Accept-Encoding", "").find("gzip") >= 0
|
|
||||||
|
|
||||||
if accepts_gzip:
|
|
||||||
if os.path.exists(os.path.join(app.static_folder or "", gzipped_path)):
|
|
||||||
response = app.make_response(app.send_static_file(gzipped_path))
|
|
||||||
response.headers["Content-Encoding"] = "gzip"
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
return app.send_static_file(path)
|
try:
|
||||||
|
exp_timestamp = get_jwt()["exp"]
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
target_timestamp = datetime.timestamp(now) + 60 * 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
|
if target_timestamp > exp_timestamp:
|
||||||
|
access_token = create_access_token(identity=get_jwt_identity())
|
||||||
|
set_access_cookies(response, access_token)
|
||||||
|
|
||||||
@app.route("/")
|
return response
|
||||||
def serve_client():
|
except (RuntimeError, KeyError):
|
||||||
"""
|
return response
|
||||||
Serves the index.html file at `client/index.html`.
|
|
||||||
"""
|
|
||||||
return app.send_static_file("index.html")
|
|
||||||
|
|
||||||
|
@app.route("/<path:path>")
|
||||||
|
def serve_client_files(path: str):
|
||||||
|
"""
|
||||||
|
Serves the static files in the client folder.
|
||||||
|
"""
|
||||||
|
js_or_css = path.endswith(".js") or path.endswith(".css")
|
||||||
|
if not js_or_css:
|
||||||
|
return app.send_static_file(path)
|
||||||
|
|
||||||
prev_memory = 0
|
gzipped_path = path + ".gz"
|
||||||
|
user_agent = request.headers.get("User-Agent")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
# INFO: For debugging memory usage
|
if is_safari:
|
||||||
# @app.after_request
|
return app.send_static_file(path)
|
||||||
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())
|
accepts_gzip = request.headers.get("Accept-Encoding", "").find("gzip") >= 0
|
||||||
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)
|
if accepts_gzip:
|
||||||
print(
|
if os.path.exists(os.path.join(app.static_folder or "", gzipped_path)):
|
||||||
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"
|
response = app.make_response(app.send_static_file(gzipped_path))
|
||||||
)
|
response.headers["Content-Encoding"] = "gzip"
|
||||||
|
return response
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
multiprocessing.freeze_support()
|
|
||||||
load_into_mem()
|
load_into_mem()
|
||||||
run_swingmusic()
|
run_swingmusic()
|
||||||
# TrackStore.export()
|
# TrackStore.export()
|
||||||
# ArtistStore.export()
|
# ArtistStore.export()
|
||||||
|
|
||||||
host = FLASKVARS.get_flask_host()
|
try:
|
||||||
port = FLASKVARS.get_flask_port()
|
import bjoern
|
||||||
|
|
||||||
waitress.serve(
|
bjoern.run(app, host, port)
|
||||||
app,
|
except ImportError:
|
||||||
host=host,
|
import waitress
|
||||||
port=port,
|
|
||||||
threads=100,
|
waitress.serve(
|
||||||
ipv6=True,
|
app,
|
||||||
ipv4=True,
|
host=host,
|
||||||
)
|
port=port,
|
||||||
# app.run(host=host, port=port, debug=False)
|
threads=100,
|
||||||
# bjoern.run(app, host, port)
|
ipv6=True,
|
||||||
|
ipv4=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ dependencies = [
|
|||||||
"rapidfuzz==3.11.0",
|
"rapidfuzz==3.11.0",
|
||||||
"waitress>=3.0.2",
|
"waitress>=3.0.2",
|
||||||
"pendulum>=3.0.0",
|
"pendulum>=3.0.0",
|
||||||
|
"bjoern>=3.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
|
revision = 1
|
||||||
requires-python = "==3.11.*"
|
requires-python = "==3.11.*"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"platform_python_implementation != 'PyPy'",
|
"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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "blinker"
|
name = "blinker"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -826,6 +833,7 @@ name = "swingmusic"
|
|||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "bjoern" },
|
||||||
{ name = "colorgram-py" },
|
{ name = "colorgram-py" },
|
||||||
{ name = "ffmpeg-python" },
|
{ name = "ffmpeg-python" },
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
@@ -861,6 +869,7 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "bjoern", specifier = ">=3.2.2" },
|
||||||
{ name = "colorgram-py", specifier = ">=1.2.0" },
|
{ name = "colorgram-py", specifier = ">=1.2.0" },
|
||||||
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
|
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
|
||||||
{ name = "flask", specifier = ">=3.1.0" },
|
{ name = "flask", specifier = ">=3.1.0" },
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
2.0.3
|
||||||
Reference in New Issue
Block a user