diff --git a/app/library/PackageInstaller.py b/app/library/PackageInstaller.py index b0ed0463..c43ef67f 100644 --- a/app/library/PackageInstaller.py +++ b/app/library/PackageInstaller.py @@ -8,7 +8,7 @@ from pathlib import Path import httpx -LOG = logging.getLogger("package_installer") +LOG: logging.Logger = logging.getLogger("package_installer") def parse_version(v: str) -> tuple[int, ...]: @@ -39,16 +39,23 @@ class Packages: class PackageInstaller: user_site: Path | None = None - def __init__(self): - if base_dir := os.environ.get("YTP_CONFIG_PATH"): + def __init__(self, pkg_path: Path | None = None): + if pkg_path: + self.user_site = pkg_path + + if not self.user_site and (base_dir := os.environ.get("YTP_CONFIG_PATH")): base_dir = Path(base_dir) self.user_site = base_dir / f"python{sys.version_info.major}.{sys.version_info.minor}-packages" self.user_site.mkdir(parents=True, exist_ok=True) - if str(self.user_site) not in sys.path: - sys.path.insert(0, str(self.user_site)) + if not self.user_site: + LOG.error("No valid package path provided or found in environment. Aborting.") + return - def action(self, pkg: str, upgrade: bool = False): + if str(self.user_site) not in sys.path: + sys.path.insert(0, str(self.user_site)) + + def action(self, pkg: str, upgrade: bool = False) -> None: version: str | None = None if "==" in pkg: pkg, version = pkg.split("==", 1) @@ -79,7 +86,7 @@ class PackageInstaller: return None def _get_latest_version(self, pkg: str) -> str | None: - url = f"https://pypi.org/pypi/{pkg}/json" + url: str = f"https://pypi.org/pypi/{pkg}/json" try: with httpx.Client(timeout=5.0) as client: resp = client.get(url) @@ -90,7 +97,7 @@ class PackageInstaller: LOG.warning(f"Error while querying PyPI for '{pkg}': {e}") return None - def _install_pkg(self, pkg: str, version: str | None = None): + def _install_pkg(self, pkg: str, version: str | None = None) -> bool: cmd: list[str] = [ sys.executable, "-m", @@ -113,13 +120,18 @@ class PackageInstaller: cmd.extend(["--disable-pip-version-check", pkg]) try: - subprocess.run( + proc: subprocess.CompletedProcess[bytes] = subprocess.run( cmd, check=True, + capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, ) + self.out(out=proc.stdout, err=proc.stderr) + + return 0 == proc.returncode except subprocess.CalledProcessError as e: - LOG.error(f"Failed to install package '{pkg}'. Error: {e}") + LOG.error(f"Failed to install '{pkg}' (exit {e.returncode}). {e!s}") + self.out(out=e.stdout, err=e.stderr) raise def check(self, pkgs: Packages): @@ -150,8 +162,8 @@ class PackageInstaller: return True # Handle yt-dlp version format differences - current_parts = current.split(".") - target_parts = target.split(".") + current_parts: list[str] = current.split(".") + target_parts: list[str] = target.split(".") if len(current_parts) == len(target_parts): # Normalize parts by zero-padding single digits @@ -165,3 +177,12 @@ class PackageInstaller: return True return False + + def out(self, out: bytes | str | None = None, err=bytes | str | None) -> None: + if out: + for line in (line.strip() for line in out.strip().splitlines() if line.strip()): + LOG.info(line.decode() if isinstance(line, bytes) else line) + + if err: + for line in (line.strip() for line in err.strip().splitlines() if line.strip()): + LOG.warning(line.decode() if isinstance(line, bytes) else line) diff --git a/app/library/config.py b/app/library/config.py index dc3d68b0..d70ca70c 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -7,6 +7,7 @@ import time from logging.handlers import TimedRotatingFileHandler from multiprocessing.managers import SyncManager from pathlib import Path +from typing import TYPE_CHECKING import coloredlogs from dotenv import load_dotenv @@ -14,6 +15,9 @@ from dotenv import load_dotenv from .Utils import FileLogFormatter, arg_converter from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION +if TYPE_CHECKING: + from subprocess import CompletedProcess + class Config: app_env: str = "production" @@ -558,8 +562,8 @@ class Config: try: import subprocess - branch_result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607 + branch_result: CompletedProcess[str] = subprocess.run( + ["git", "-c", f"safe.directory={git_path.parent!s}", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607 cwd=os.path.dirname(git_path), capture_output=True, text=True, @@ -576,8 +580,8 @@ class Config: logging.warning("Git branch name is empty.") return - commit_result = subprocess.run( - ["git", "log", "-1", "--format=%ct_%H"], # noqa: S607 + commit_result: CompletedProcess[str] = subprocess.run( + ["git", "-c", f"safe.directory={git_path.parent!s}", "log", "-1", "--format=%ct_%H"], # noqa: S607 cwd=os.path.dirname(git_path), capture_output=True, text=True, diff --git a/app/upgrader.py b/app/upgrader.py index ec86102f..e02eb632 100644 --- a/app/upgrader.py +++ b/app/upgrader.py @@ -10,10 +10,9 @@ import logging import os from pathlib import Path -from dotenv import load_dotenv from library.PackageInstaller import PackageInstaller, Packages -LOG = logging.getLogger("upgrader") +LOG: logging.Logger = logging.getLogger("upgrader") class Upgrader: @@ -24,15 +23,53 @@ class Upgrader: else: os.environ.update({"YTP_CONFIG_PATH": str(config_path)}) - if config_path.exists(): - envFile: Path = config_path / ".env" - if envFile.exists(): - LOG.debug(f"loading environment variables from '{envFile}'.") - load_dotenv(str(envFile)) - else: + if not config_path.exists(): LOG.error(f"config path '{config_path}' doesn't exists.") + return - pkg_installer = PackageInstaller() + envFile: Path = config_path / ".env" + if envFile.exists(): + from dotenv import load_dotenv + + LOG.debug(f"loading environment variables from '{envFile}'.") + load_dotenv(str(envFile)) + + user_site: Path = config_path / f"python{sys.version_info.major}.{sys.version_info.minor}-packages" + user_site_ver: Path = user_site / ".version" + + from app.library.version import APP_VERSION + + if not user_site.exists(): + user_site.mkdir(parents=True, exist_ok=True) + user_site_ver.write_text(APP_VERSION) + + if user_site.is_dir(): + if str(user_site) not in sys.path: + sys.path.insert(0, str(user_site)) + + if not user_site_ver.exists() or APP_VERSION != user_site_ver.read_text().strip(): + self.clean_up(user_site, user_site_ver, APP_VERSION) + + self.check(config_path, user_site) + + def clean_up(self, user_site: Path, user_site_ver: Path, app_version: str) -> None: + LOG.info(f"Cleaning up user site packages at '{user_site}' for app version '{app_version}'.") + from app.library.Utils import delete_dir + + if not delete_dir(user_site): + LOG.error(f"Failed to clean up user site packages at '{user_site}'.") + return + + try: + user_site.mkdir(parents=True, exist_ok=True) + user_site_ver.write_text(app_version) + LOG.info(f"User site packages cleaned up and ready for app version '{app_version}'.") + except Exception as e: + LOG.exception(e) + LOG.error(f"Failed to recreate user site packages at '{user_site}'. '{e!s}'.") + + def check(self, config_path: Path, user_site: Path) -> None: + pkg_installer = PackageInstaller(pkg_path=user_site) ytdlp_auto_update: bool = os.environ.get("YTP_YTDLP_AUTO_UPDATE", "true").strip().lower() == "true" ytdlp_version: str | None = os.environ.get("YTP_YTDLP_VERSION", "").strip() or None @@ -59,7 +96,7 @@ class Upgrader: Packages( env=os.environ.get("YTP_PIP_PACKAGES", None), file=str(Path(config_path, "pip.txt")), - upgrade=not bool(os.environ.get("YTP_PIP_IGNORE_UPDATES", False)), + upgrade=not bool(os.environ.get("YTP_PIP_IGNORE_UPDATES", None)), ) ) except Exception as e: diff --git a/ui/app/components/InputAutocomplete.vue b/ui/app/components/InputAutocomplete.vue index 3e1d4ce1..6f5191f2 100644 --- a/ui/app/components/InputAutocomplete.vue +++ b/ui/app/components/InputAutocomplete.vue @@ -34,21 +34,38 @@ const props = defineProps<{ const model = defineModel() const onInput = () => { - if (false === showList.value) { - showList.value = true - } - highlightedIndex.value = filteredOptions.value.length ? 0 : -1 + showList.value = isFlagTrigger.value && filteredOptions.value.length > 0 + highlightedIndex.value = showList.value ? 0 : -1 } const showList = ref(false) const highlightedIndex = ref(-1) const dropdownItemRefs = ref<(HTMLElement | null)[]>([]) +// Extract the last non-space token and its bounds +const getLastToken = (value: string) => { + const m = (value || '').match(/(\S+)$/) + const token: string = m?.[1] ?? '' + const start = m ? (m.index as number) : value.length + const end = m ? start + token.length : value.length + return { token, start, end } +} + const filteredOptions = computed(() => { - if (!model.value) { + const value = model.value || '' + if (!value) { return props.options } - const val = model.value.toLowerCase() + const { token } = getLastToken(value) + // Hide suggestions when token has '=' or doesn't start with '--' + if (!token || token.includes('=') || !token.startsWith('--')) { + return [] + } + // Hide suggestions if token exactly matches an option value + if (props.options.some(opt => opt.value === token)) { + return [] + } + const val = token.toLowerCase() const startsWithFlag = [] const includesFlag = [] const includesDesc = [] @@ -67,7 +84,16 @@ const filteredOptions = computed(() => { }) const selectOption = (val: string) => { - model.value = val + const value = model.value || '' + const { token, start, end } = getLastToken(value) + if (token) { + // Preserve any '=value' suffix already typed for this token + const eqPos = token.indexOf('=') + const after = eqPos !== -1 ? token.slice(eqPos) : '' + model.value = value.slice(0, start) + val + after + value.slice(end) + } else { + model.value = val + } showList.value = false highlightedIndex.value = -1 } @@ -81,8 +107,8 @@ const hideList = () => { } const onFocus = () => { - showList.value = true - highlightedIndex.value = filteredOptions.value.length ? 0 : -1 + showList.value = isFlagTrigger.value && filteredOptions.value.length > 0 + highlightedIndex.value = showList.value ? 0 : -1 } const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => { @@ -100,8 +126,21 @@ watch(filteredOptions, () => { }) }) +const isFlagTrigger = computed(() => { + const { token } = getLastToken(model.value || '') + if (!token || !token.startsWith('--') || token.includes('=')) return false + // Suppress if exact match + return !props.options.some(opt => opt.value === token) +}) + const handleKeydown = (e: KeyboardEvent) => { - if (!filteredOptions.value.length) { + // Escape closes dropdown and lets arrows navigate the input + if (e.key === 'Escape') { + showList.value = false + highlightedIndex.value = -1 + return + } + if (!showList.value || !filteredOptions.value.length) { return } @@ -126,7 +165,7 @@ const handleKeydown = (e: KeyboardEvent) => { } else if (e.key === 'Enter' || e.key === 'Tab') { const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ? filteredOptions.value[highlightedIndex.value] : undefined - if (selected) { + if (selected && isFlagTrigger.value) { e.preventDefault() selectOption(selected.value) } diff --git a/ui/app/components/TextareaAutocomplete.vue b/ui/app/components/TextareaAutocomplete.vue index 15a9d02e..30034a86 100644 --- a/ui/app/components/TextareaAutocomplete.vue +++ b/ui/app/components/TextareaAutocomplete.vue @@ -1,9 +1,10 @@