YTPTube has shut down.
+You may now close this window.
+ +diff --git a/.github/workflows/native-build.yml b/.github/workflows/native-build.yml index b2d9db76..3564a3ed 100644 --- a/.github/workflows/native-build.yml +++ b/.github/workflows/native-build.yml @@ -1,4 +1,4 @@ -name: Build WebView wrappers +name: Build Native wrappers on: workflow_dispatch: @@ -52,10 +52,6 @@ jobs: restore-keys: | choco-${{ runner.os }}-${{ matrix.arch }}- - - name: Install Qt (Windows) - if: matrix.os == 'windows-latest' - run: choco install -y qt6-base-dev - - name: Cache Python venv id: cache-python uses: actions/cache@v4 @@ -77,16 +73,6 @@ jobs: uv venv --system-site-packages --relocatable uv sync --link-mode=copy --active - - name: Install PyInstaller + Qt backend (Windows) - if: matrix.os == 'windows-latest' - run: | - uv pip install pyinstaller pywebview QtPy PySide6 - - - name: Install PyInstaller + Qt backend (Other OSs) - if: matrix.os != 'windows-latest' - run: | - uv pip install pyinstaller pywebview[qt] - - name: Install pnpm uses: pnpm/action-setup@v4 with: @@ -107,7 +93,7 @@ jobs: - name: Build native binary run: | - uv run pyinstaller ./app.spec + uv tool run pyinstaller ./app.spec - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.vscode/settings.json b/.vscode/settings.json index 09c5c3df..03ae043d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,7 @@ "bilibili", "brainicism", "brotlicffi", + "choco", "consoletitle", "continuedl", "cookiesfrombrowser", @@ -41,6 +42,7 @@ "engineio", "Errno", "euuo", + "excepthook", "faststart", "finaldir", "flac", @@ -91,10 +93,6 @@ "pycryptodome", "pyinstaller", "pypi", - "pyside", - "pywebview", - "qstr", - "qtpy", "quicktime", "rejecttitle", "remux", @@ -103,6 +101,7 @@ "SIGUSR", "smhd", "socketio", + "softprops", "sstr", "SUPPRESSHELP", "tebibytes", diff --git a/app.spec b/app.spec index 73a1a074..3615ae53 100644 --- a/app.spec +++ b/app.spec @@ -1,25 +1,10 @@ import os import platform -import sys import tomllib block_cipher = None machine = platform.machine().lower() -binaries = [] -if sys.platform.startswith("linux"): - if machine in ("x86_64", "amd64"): - libdir = "/usr/lib/x86_64-linux-gnu" - elif machine in ("aarch64", "arm64"): - libdir = "/usr/lib/aarch64-linux-gnu" - else: - libdir = "/usr/lib" - binaries = [] -elif sys.platform == "darwin": - binaries = [] -elif sys.platform.startswith("win"): - # Windows DLLs - binaries = [] with open("./uv.lock", "rb") as f: lock = tomllib.load(f) @@ -37,9 +22,9 @@ hidden = [ hidden = [f.replace("-", "_") for f in hidden] a = Analysis( # noqa: F821 # type: ignore - ["app/native.py"], + ["app/local.py"], pathex=[os.getcwd()], - binaries=binaries, + binaries=[], datas=[ ("ui/exported", "ui/exported"), ("app/", "app/"), @@ -47,7 +32,7 @@ a = Analysis( # noqa: F821 # type: ignore hiddenimports=hidden, hookspath=[], runtime_hooks=[], - excludes=[], + excludes=["libstdc++.so.6"], cipher=block_cipher, ) @@ -63,7 +48,7 @@ exe = EXE( # type: ignore # noqa: F821 debug=False, strip=False, upx=True, - console=False, # Turn on to True if you want a console window for debugging. + console=True, icon="ui/public/favicon.ico", onefile=True, ) diff --git a/app/library/config.py b/app/library/config.py index adf26c72..a94d2e1c 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -185,7 +185,7 @@ class Config: """Enable yt-dlp debugging.""" is_native: bool = False - "Is the application running in webview." + "Is the application running in natively." prevent_live_premiere: bool = False """Prevent downloading of the initial premiere live broadcast.""" @@ -288,7 +288,9 @@ class Config: @staticmethod def get_instance(is_native: bool = False) -> "Config": """Static access method.""" - return Config(is_native) if not Config.__instance else Config.__instance + cls: Config = Config(is_native) if not Config.__instance else Config.__instance + cls.is_native = is_native or cls.is_native + return cls @staticmethod def get_manager() -> SyncManager: diff --git a/app/local.py b/app/local.py new file mode 100644 index 00000000..2347bf74 --- /dev/null +++ b/app/local.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# flake8: noqa: SIM115 S310 T201 + +import argparse +import os +import socket +import sys +import time +import urllib.request +import webbrowser +from pathlib import Path + +from dotenv import load_dotenv + +os.environ["PYTHONUTF8"] = "1" + +sys.path.insert(0, os.path.join(getattr(sys, "_MEIPASS", os.path.abspath(os.path.dirname(__file__))), "app")) + +APP_NAME = "YTPTube" +APP_ROOT = str((Path(__file__).parent / "..").resolve()) +if APP_ROOT not in sys.path: + sys.path.insert(0, APP_ROOT) + +import platformdirs # type: ignore + + +def set_env(): + dct = {} + + if not os.getenv("YTP_CONFIG_PATH"): + dct["YTP_CONFIG_PATH"] = platformdirs.user_config_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True) + + if not os.getenv("YTP_TEMP_PATH"): + dct["YTP_TEMP_PATH"] = platformdirs.user_cache_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True) + + if not os.getenv("YTP_DOWNLOAD_PATH"): + dct["YTP_DOWNLOAD_PATH"] = platformdirs.user_downloads_dir() + + if os.getenv("YTP_ACCESS_LOG", None) is None: + dct["YTP_ACCESS_LOG"] = "false" + + if not os.getenv("YTP_HOST"): + dct["YTP_HOST"] = "127.0.0.1" + + if dct: + os.environ.update(dct) + + +def open_browser_when_ready(url: str, timeout: float = 5.0) -> None: + import threading + + def wait_then_open(): + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as response: + if 200 == response.status: + os.environ.pop("LD_LIBRARY_PATH", None) + webbrowser.open_new(url) + return + except Exception: + time.sleep(0.5) + + print(f"Failed to open browser automatically within {timeout} seconds. Please open {url} manually.") + + threading.Thread(target=wait_then_open, daemon=True).start() + + +def app_start(host: str, port: int) -> None: + import asyncio + + from app.main import Main + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + Main(is_native=True).start(host, port) + + +def update_env_file(env_file: Path, port: int) -> None: + lines = [] + if env_file.exists(): + with env_file.open("r", encoding="utf-8") as f: + lines = f.readlines() + + port_line = f"YTP_PORT={port}\n" + for i, line in enumerate(lines): + if line.startswith("YTP_PORT="): + lines[i] = port_line + break + else: + lines.append(port_line) + + with env_file.open("w", encoding="utf-8") as f: + f.writelines(lines) + + +def main(): + parser = argparse.ArgumentParser(description="Start YTPTube server") + parser.add_argument("--no-browser", action="store_true", help="Do not open browser on start") + args = parser.parse_args() + + set_env() + + env_file: Path = Path(os.getenv("YTP_CONFIG_PATH")) / ".env" + + port = None + if env_file.exists(): + load_dotenv(env_file) + port = os.getenv("YTP_PORT") + + host = os.getenv("YTP_HOST", "127.0.0.1") + + if not port or 8081 == int(port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind((host, 0)) + port = s.getsockname()[1] + update_env_file(env_file, port) + + url = f"http://{host}:{port}" + + env_no_browser: bool = os.getenv("YTP_NO_BROWSER", "false").lower() in ("1", "true", "yes") + if not args.no_browser and not env_no_browser: + open_browser_when_ready(url) + + app_start(host, int(port)) + + +if __name__ == "__main__": + main() diff --git a/app/main.py b/app/main.py index c960d0e1..15c6ae3b 100644 --- a/app/main.py +++ b/app/main.py @@ -141,6 +141,8 @@ class Main: def started(_): LOG.info("=" * 40) LOG.info(f"YTPTube {self._config.app_version} - started on http://{host}:{port}{self._config.base_path}") + if self._config.is_native: + LOG.info("Running in native mode.") LOG.info("=" * 40) loop = asyncio.get_event_loop() diff --git a/app/native.py b/app/native.py deleted file mode 100644 index b486522a..00000000 --- a/app/native.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import json -import logging -import os -import queue -import socket -import sys -import threading -import traceback -from pathlib import Path - -import platformdirs - -sys.path.insert(0, os.path.join(getattr(sys, "_MEIPASS", os.path.abspath(os.path.dirname(__file__))), "app")) - -APP_NAME = "YTPTube" -APP_ROOT = str((Path(__file__).parent / "..").resolve()) -if APP_ROOT not in sys.path: - sys.path.insert(0, APP_ROOT) - -try: - import webview # type: ignore - - if "linux" in sys.platform: - os.environ.setdefault("LC_ALL", "C.UTF-8") - os.environ.setdefault("LANG", "C.UTF-8") - import webview.platforms.qt # type: ignore - - # monkey patch the download handler for pywebview to support qt6. - def on_download_requested(self, download): - from qtpy import QtCore # type: ignore - from qtpy.QtWidgets import QFileDialog # type: ignore - - old_path = download.url().path() - suffix = QtCore.QFileInfo(old_path).suffix() - filename, _ = QFileDialog.getSaveFileName( - self, self.localization["global.saveFile"], old_path, "*." + suffix - ) - if filename: - if hasattr(download, "setPath"): - download.setPath(filename) - else: - download.setDownloadDirectory(os.path.dirname(filename)) - download.setDownloadFileName(os.path.basename(filename)) - download.accept() - else: - download.cancel() - - webview.platforms.qt.BrowserView.on_download_requested = on_download_requested - -except ImportError as e: - pkgs = "pywebview[edgechromium]" if os.name == "nt" else "pywebview[qt]" - msg: str = f"Please run 'uv pip install {pkgs}' to run YTPTube in native mode." - raise ImportError(msg) from e - - -def error_window(exc: Exception | str) -> None: - trace: str = "\n".join(traceback.format_exception(exc)) if isinstance(exc, Exception) else exc - webview.create_window( - f"{APP_NAME} - Error", - html=f"
{trace}",
- width=600,
- height=400,
- resizable=True,
- )
- webview.start(
- gui="edgechromium" if os.name == "nt" else "qt",
- debug=False,
- storage_path=str(Path(os.getenv("YTP_TEMP_PATH", os.getcwd())) / "webview"),
- private_mode=False,
- )
- sys.exit(1)
-
-
-def set_env():
- defaults = {
- "YTP_CONFIG_PATH": lambda: platformdirs.user_config_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True),
- "YTP_TEMP_PATH": lambda: platformdirs.user_cache_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True),
- "YTP_DOWNLOAD_PATH": lambda: platformdirs.user_downloads_dir(),
- "YTP_ACCESS_LOG": "false",
- "YTP_BROWSER_ENABLED": "true",
- "YTP_BROWSER_CONTROL_ENABLED": "true",
- }
-
- for key, value in defaults.items():
- if os.getenv(key) is not None:
- continue
-
- os.environ[key] = value() if callable(value) else value
-
-
-def run_backend(host: str, port: int, ready_event: threading.Event, error_queue: queue.Queue):
- try:
- try:
- loop = asyncio.get_running_loop()
- except RuntimeError:
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
-
- from app.main import Main
-
- Main(is_native=True).start(host, port, cb=lambda: ready_event.set())
- except Exception as e:
- logging.exception(e)
- error_queue.put(e)
- ready_event.set()
-
-
-def get_usable_port(host: str = "127.0.0.1") -> int:
- """
- Get a usable port on the specified host.
- If no port is available, it will raise an OSError.
- """
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- s.bind((host, 0))
- port = s.getsockname()[1]
- s.close()
-
- return port
-
-
-def main():
- host = "127.0.0.1"
- set_env()
-
- cfg_path: Path = Path(os.getenv("YTP_CONFIG_PATH")) / "webview.json"
- win_conf: dict[str, int] = {}
-
- if cfg_path.exists():
- data = json.loads(cfg_path.read_text())
- for key in ("width", "height", "x", "y"):
- if key in data:
- win_conf[key] = data[key]
-
- port = get_usable_port(host)
-
- ready = threading.Event()
- errors: queue.Queue = queue.Queue()
- threading.Thread(target=run_backend, args=(host, port, ready, errors), daemon=False).start()
-
- ready.wait(timeout=5)
-
- if not errors.empty():
- error_window(errors.get())
- return
-
- from app.library.version import APP_VERSION
-
- gui = "edgechromium" if os.name == "nt" else "qt"
- create_kwargs = {**win_conf, "resizable": True}
- window = webview.create_window(f"{APP_NAME} - {APP_VERSION}", f"http://{host}:{port}", **create_kwargs)
-
- def save_geometry():
- cfg = {
- "port": port,
- "width": window.width,
- "height": window.height,
- "x": window.x,
- "y": window.y,
- }
- cfg_path.write_text(json.dumps(cfg))
-
- window.events.resized += lambda *_: save_geometry()
- window.events.moved += lambda *_: save_geometry()
- window.events.closing += lambda *_: os._exit(0)
-
- webview.settings["ALLOW_DOWNLOADS"] = True
- webview.settings["OPEN_DEVTOOLS_IN_DEBUG"] = False
- webview.start(
- gui=gui,
- debug=True,
- storage_path=str(Path(os.getenv("YTP_TEMP_PATH", os.getcwd())) / "webview"),
- private_mode=False,
- )
-
-
-if __name__ == "__main__":
- try:
- from multiprocessing import freeze_support
-
- freeze_support()
- main()
- except Exception as e:
- logging.exception(e)
- error_window(e)
- os._exit(1)
diff --git a/app/routes/api/system.py b/app/routes/api/system.py
new file mode 100644
index 00000000..5564ea40
--- /dev/null
+++ b/app/routes/api/system.py
@@ -0,0 +1,59 @@
+import asyncio
+import logging
+
+from aiohttp import web
+from aiohttp.web import Request, Response
+from aiohttp.web_runner import GracefulExit
+
+from app.library.config import Config
+from app.library.encoder import Encoder
+from app.library.Events import EventBus, Events
+from app.library.router import route
+
+LOG: logging.Logger = logging.getLogger(__name__)
+
+
+@route("POST", "api/system/shutdown", "system.shutdown")
+async def shutdown_system(request: Request, config: Config, encoder: Encoder, notify: EventBus) -> Response:
+ """
+ Get the presets.
+
+ Args:
+ request (Request): The request object.
+ config (Config): The config instance.
+ encoder (Encoder): The encoder instance.
+ notify (EventBus): The event bus instance.
+
+ Returns:
+ Response: The response object.
+
+ """
+ if config.is_native is not True:
+ return web.json_response(
+ {"error": "Shutdown is only available in the native mode."},
+ status=web.HTTPBadRequest.status_code,
+ )
+
+ app = request.app
+
+ async def do_shutdown():
+ await notify.emit(
+ Events.SHUTDOWN,
+ data={"app": app},
+ title="Application Shutdown",
+ message="Shutdown initiated by user request.",
+ )
+ await asyncio.sleep(0.5)
+ await app.shutdown()
+ await app.cleanup()
+ raise GracefulExit
+
+ # Schedule shutdown after response
+ asyncio.create_task(do_shutdown())
+ return web.json_response(
+ data={
+ "message": "The application shutting down.",
+ },
+ status=web.HTTPOk.status_code,
+ dumps=encoder.encode,
+ )
diff --git a/pyproject.toml b/pyproject.toml
index bbfd5975..cfe61218 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -78,12 +78,7 @@ indent-width = 4
target-version = "py311"
[project.optional-dependencies]
-webview = [
- #windows only
- "qtpy; sys_platform == 'win32'",
- "pyside6; sys_platform == 'win32'",
- # all
- "pywebview",
+installer = [
"pyinstaller",
]
diff --git a/ui/app/components/Dialog.vue b/ui/app/components/Dialog.vue
new file mode 100644
index 00000000..2c4809ae
--- /dev/null
+++ b/ui/app/components/Dialog.vue
@@ -0,0 +1,151 @@
+
+
+
+ {{ state.current?.opts.title ?? defaultTitle }}
+ ++ {{ state.current?.opts.message }} +
+ + ++ + + {{ state.errorMsg }} + +
+