diff --git a/.github/workflows/native-build.yml b/.github/workflows/native-build.yml index 6e80b5f1..1b1d30c8 100644 --- a/.github/workflows/native-build.yml +++ b/.github/workflows/native-build.yml @@ -37,7 +37,7 @@ jobs: contents: write env: - PYTHON_VERSION: 3.11 + PYTHON_VERSION: "3.13" BUN_VERSION: latest NODE_VERSION: 20 TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }} @@ -63,6 +63,35 @@ jobs: shell: bash run: cp temp-spec/app.spec ./app.spec + - name: Update app/library/version.py + shell: bash + run: | + VERSION="${TAG_NAME}" + SHA=$(git rev-parse HEAD) + DATE=$(date -u +"%Y%m%d") + BRANCH=$(git branch --show-current) + + if [ -z "${BRANCH}" ]; then + BRANCH="${TAG_NAME}" + fi + + BRANCH=$(echo "${BRANCH}" | sed 's#/#-#g') + + echo "APP_VERSION=${VERSION}" >> "$GITHUB_ENV" + echo "APP_SHA=${SHA}" >> "$GITHUB_ENV" + echo "APP_DATE=${DATE}" >> "$GITHUB_ENV" + echo "APP_BRANCH=${BRANCH}" >> "$GITHUB_ENV" + + sed -i \ + -e "s/^APP_VERSION = \".*\"/APP_VERSION = \"${VERSION}\"/" \ + -e "s/^APP_COMMIT_SHA = \".*\"/APP_COMMIT_SHA = \"${SHA}\"/" \ + -e "s/^APP_BUILD_DATE = \".*\"/APP_BUILD_DATE = \"${DATE}\"/" \ + -e "s/^APP_BRANCH = \".*\"/APP_BRANCH = \"${BRANCH}\"/" \ + app/library/version.py + + echo "Updated version info:" + cat app/library/version.py + - name: Cache Python venv id: cache-python uses: actions/cache@v4 @@ -161,7 +190,7 @@ jobs: rm -rf "dist/${PKG_DIR}" mv "dist/YTPTube" "dist/${PKG_DIR}" - zip -r "release/${ZIP_NAME}" "dist/${PKG_DIR}" + zip -yr "release/${ZIP_NAME}" "dist/${PKG_DIR}" - name: Package artifact (Windows) if: startsWith(env.TAG_NAME, 'v') && runner.os == 'Windows' diff --git a/app/features/ytdlp/extractor.py b/app/features/ytdlp/extractor.py index 5fed3b31..55c46d62 100644 --- a/app/features/ytdlp/extractor.py +++ b/app/features/ytdlp/extractor.py @@ -1,7 +1,9 @@ import asyncio import functools import logging +import multiprocessing import pickle +import sys from concurrent.futures import ProcessPoolExecutor from pathlib import Path from typing import Any @@ -16,6 +18,14 @@ from app.library.Singleton import Singleton LOG: logging.Logger = logging.getLogger("downloads.extractor") +def _get_process_pool_kwargs() -> dict[str, Any]: + """Use a fork-based pool for frozen Linux builds.""" + if sys.platform == "linux" and getattr(sys, "frozen", False): + return {"mp_context": multiprocessing.get_context("fork")} + + return {} + + class ExtractorConfig: """Configuration for the extractor.""" @@ -83,7 +93,7 @@ class ExtractorPool(metaclass=Singleton): self._semaphore = asyncio.Semaphore(config.concurrency) if self._pool is None: - self._pool = ProcessPoolExecutor(max_workers=config.concurrency) + self._pool = ProcessPoolExecutor(max_workers=config.concurrency, **_get_process_pool_kwargs()) LOG.info("Initialized extractor process pool with %s workers", config.concurrency) def get_pool(self, config: ExtractorConfig) -> ProcessPoolExecutor: @@ -366,6 +376,7 @@ async def fetch_info( ) except Exception as exc: + LOG.exception(exc) LOG.warning("extract_info process pool failed, falling back to thread pool url=%s error=%s", url, exc) return await asyncio.wait_for( fut=loop.run_in_executor( diff --git a/app/features/ytdlp/tests/test_ytdlp_extractor.py b/app/features/ytdlp/tests/test_ytdlp_extractor.py index 16fb77de..557e0bc7 100644 --- a/app/features/ytdlp/tests/test_ytdlp_extractor.py +++ b/app/features/ytdlp/tests/test_ytdlp_extractor.py @@ -1,6 +1,48 @@ from unittest.mock import MagicMock, patch -from app.features.ytdlp.extractor import extract_info_sync +from app.features.ytdlp.extractor import ExtractorConfig, ExtractorPool, _get_process_pool_kwargs, extract_info_sync + + +class TestProcessPoolConfiguration: + def setup_method(self): + ExtractorPool._reset_singleton() + + def test_uses_fork_context_for_frozen_linux(self, monkeypatch): + monkeypatch.setattr("app.features.ytdlp.extractor.sys.platform", "linux") + monkeypatch.setattr("app.features.ytdlp.extractor.sys.frozen", True, raising=False) + + context = object() + get_context = MagicMock(return_value=context) + monkeypatch.setattr("app.features.ytdlp.extractor.multiprocessing.get_context", get_context) + + kwargs = _get_process_pool_kwargs() + + assert kwargs == {"mp_context": context} + get_context.assert_called_once_with("fork") + + def test_uses_default_context_when_not_frozen_linux(self, monkeypatch): + monkeypatch.setattr("app.features.ytdlp.extractor.sys.platform", "linux") + monkeypatch.delattr("app.features.ytdlp.extractor.sys.frozen", raising=False) + + get_context = MagicMock() + monkeypatch.setattr("app.features.ytdlp.extractor.multiprocessing.get_context", get_context) + + assert _get_process_pool_kwargs() == {} + get_context.assert_not_called() + + def test_initializes_process_pool_with_context_kwargs(self, monkeypatch): + context = object() + monkeypatch.setattr("app.features.ytdlp.extractor._get_process_pool_kwargs", lambda: {"mp_context": context}) + + executor = MagicMock() + executor_cls = MagicMock(return_value=executor) + monkeypatch.setattr("app.features.ytdlp.extractor.ProcessPoolExecutor", executor_cls) + + pool = ExtractorPool.get_instance() + pool._ensure_initialized(ExtractorConfig(concurrency=3)) + + executor_cls.assert_called_once_with(max_workers=3, mp_context=context) + assert pool.get_pool(ExtractorConfig(concurrency=3)) is executor class TestExtractInfo: diff --git a/app/local.py b/app/local.py index cae170ad..f66d00fa 100644 --- a/app/local.py +++ b/app/local.py @@ -10,6 +10,11 @@ import time import urllib.request import webbrowser +if __name__ == "__main__": + from multiprocessing import freeze_support + + freeze_support() + import dotenv os.environ["PYTHONUTF8"] = "1" @@ -135,7 +140,4 @@ def main(): if __name__ == "__main__": - from multiprocessing import freeze_support - - freeze_support() main() diff --git a/app/main.py b/app/main.py index 7ac1c9ee..78444443 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,11 @@ import sys from pathlib import Path +if __name__ == "__main__": + from multiprocessing import freeze_support + + freeze_support() + APP_ROOT = str((Path(__file__).parent / "..").resolve()) if APP_ROOT not in sys.path: sys.path.insert(0, APP_ROOT) @@ -179,7 +184,4 @@ class Main: if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - from multiprocessing import freeze_support - - freeze_support() Main().start() diff --git a/ui/app/components/shutdown.vue b/ui/app/components/shutdown.vue index bfff8e9a..771768b3 100644 --- a/ui/app/components/shutdown.vue +++ b/ui/app/components/shutdown.vue @@ -1,93 +1,64 @@ - +