Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions .github/workflows/native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down
13 changes: 12 additions & 1 deletion app/features/ytdlp/extractor.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,6 +18,14 @@
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."""

Expand Down Expand Up @@ -83,7 +93,7 @@ def _ensure_initialized(self, config: ExtractorConfig) -> None:
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:
Expand Down Expand Up @@ -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(
Expand Down
44 changes: 43 additions & 1 deletion app/features/ytdlp/tests/test_ytdlp_extractor.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
8 changes: 5 additions & 3 deletions app/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
import urllib.request
import webbrowser

if __name__ == "__main__":
from multiprocessing import freeze_support

freeze_support()

import dotenv

os.environ["PYTHONUTF8"] = "1"
Expand Down Expand Up @@ -135,7 +140,4 @@ def main():


if __name__ == "__main__":
from multiprocessing import freeze_support

freeze_support()
main()
8 changes: 5 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -179,7 +184,4 @@ def started(_):

if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
from multiprocessing import freeze_support

freeze_support()
Main().start()
141 changes: 56 additions & 85 deletions ui/app/components/shutdown.vue
Original file line number Diff line number Diff line change
@@ -1,93 +1,64 @@
<template>
<div id="main_container" class="container">
<div class="columns">
<div class="column">
<section
class="hero has-text-centered is-flex is-align-items-center is-justify-content-center"
>
<div class="goodbye-box">
<div class="goodbye-title">Goodbye!</div>
<p class="goodbye-subtitle">YTPTube has shut down.</p>
<p class="goodbye-hint">You may now close this window.</p>
<div class="goodbye-spinner" />
</div>
</section>
</div>
<div
class="relative flex min-h-screen flex-1 items-center justify-center overflow-hidden px-4 py-6 sm:px-6"
>
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div
class="absolute top-1/2 left-1/2 size-72 -translate-x-[68%] -translate-y-[70%] rounded-full bg-primary/12 blur-3xl"
/>
<div
class="absolute top-1/2 left-1/2 size-64 translate-x-[8%] translate-y-[4%] rounded-full bg-secondary/12 blur-3xl"
/>
</div>
</div>
</template>

<style scoped>
.goodbye-box {
animation: fadeInUp 0.6s ease-out;
background: radial-gradient(circle at top left, #6f42c1, #f66d9b);
color: white;
padding: 3rem 4rem;
border-radius: 1rem;
box-shadow: 0 0 50px rgba(255, 255, 255, 0.15);
max-width: 480px;
margin: 2rem auto;
position: relative;
overflow: hidden;
transform-style: preserve-3d;
}

.goodbye-title {
font-size: 3rem;
font-weight: bold;
margin-bottom: 1rem;
animation: floatTitle 3s ease-in-out infinite;
}

.goodbye-subtitle {
font-size: 1.5rem;
margin-bottom: 0.75rem;
opacity: 0.95;
}
<UPageCard
variant="outline"
:ui="pageCardUi"
class="relative w-full max-w-xl overflow-hidden bg-default/95"
>
<template #body>
<div class="space-y-6 px-5 py-6 sm:px-7 sm:py-8" role="status" aria-live="polite">
<div
class="inline-flex items-center gap-2 rounded-full border border-default bg-elevated/60 px-3 py-1.5 text-xs font-semibold tracking-[0.22em] text-toned uppercase"
>
<UIcon
name="i-lucide-loader-circle"
class="size-4 animate-spin text-info"
aria-hidden="true"
/>
<span>Shutdown in progress</span>
</div>

.goodbye-hint {
opacity: 0.75;
font-size: 1rem;
margin-bottom: 1rem;
}
<div class="space-y-3">
<h1 class="text-3xl font-semibold tracking-tight text-highlighted sm:text-4xl">
Goodbye!
</h1>

.goodbye-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 1rem auto 0;
}
<p class="text-base leading-7 text-default sm:text-lg">YTPTube is shutting down.</p>

@keyframes fadeInUp {
from {
transform: translateY(40px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
<p class="max-w-lg text-sm leading-6 text-toned sm:text-base">
You may now close this window. Thanks for using YTPTube.
</p>
</div>

@keyframes floatTitle {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-8px);
}
}
<UAlert
color="info"
variant="soft"
icon="i-lucide-power"
title="Wrapping things up"
description="The native app is closing background services and should exit shortly."
/>
</div>
</template>
</UPageCard>
</div>
</template>

@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<script setup lang="ts">
const pageCardUi = {
root: 'w-full bg-transparent shadow-2xl shadow-primary/5',
container: 'w-full p-0',
wrapper: 'w-full items-stretch',
body: 'w-full p-0',
};
</script>
Loading