diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml deleted file mode 100644 index 05d31c5a..00000000 --- a/.github/workflows/create-release.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Create New Release - -on: - workflow_dispatch: - inputs: - new_tag: - description: 'Tag name for the new release' - required: true - -jobs: - create_release: - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Get latest release tag from GitHub - id: latest_release - uses: actions/github-script@v6 - with: - script: | - try { - const latestRelease = await github.rest.repos.getLatestRelease({ - owner: context.repo.owner, - repo: context.repo.repo - }); - core.info(`Latest release tag: ${latestRelease.data.tag_name}`); - core.setOutput('last_release', latestRelease.data.tag_name); - } catch (error) { - core.info("No previous release found."); - // If no release exists, output an empty string. - core.setOutput('last_release', ''); - } - - - name: Set new release tag from input - id: new_tag - run: | - echo "NEW_TAG=${{ github.event.inputs.new_tag }}" >> $GITHUB_OUTPUT - - - name: Generate commit log for new release - id: commits - run: | - LAST_RELEASE="${{ steps.latest_release.outputs.last_release }}" - NEW_TAG="${{ steps.new_tag.outputs.NEW_TAG }}" - - if [ -z "$NEW_TAG" ]; then - echo "No new tag provided. Exiting." - exit 1 - fi - - if [ -z "${LAST_RELEASE}" ]; then - echo "No previous release found, using the repository’s initial commit as the starting point." - FIRST_COMMIT=$(git rev-list --max-parents=0 HEAD) - RANGE="${FIRST_COMMIT}..${NEW_TAG}" - else - RANGE="${LAST_RELEASE}..${NEW_TAG}" - fi - - echo "Comparing commits between: ${RANGE}" - LOG=$(git log "${RANGE}" --no-merges --pretty=format:"- %h %s by %an") - - echo "LOG<> "$GITHUB_ENV" - echo "$LOG" >> "$GITHUB_ENV" - echo "EOF" >> "$GITHUB_ENV" - - # Create or update the GitHub release for the new tag. - - name: Create / Update GitHub Release for new tag - uses: softprops/action-gh-release@master - with: - tag_name: ${{ steps.new_tag.outputs.NEW_TAG }} - name: "${{ steps.new_tag.outputs.NEW_TAG }}" - body: ${{ env.LOG }} - append_body: false - generate_release_notes: false - make_latest: true - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c4fd07cb..1ba0c5be 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -210,15 +210,17 @@ jobs: return; } - const { data: commits } = await github.rest.repos.compareCommits({ + const { data: comparison } = await github.rest.repos.compareCommits({ owner: context.repo.owner, repo: context.repo.repo, base: latestRelease.data[1]?.tag_name || '', // fallback to second latest tag head: tag, }); + const commits = comparison.commits.filter(c => 1 === c.parents.length); + const changelog = commits.commits.map( - c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]} by ${c.commit.author.name}` + c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]} by @${c.commit.author.name}` ).join('\n'); if (!changelog) { diff --git a/.github/workflows/native-build.yml b/.github/workflows/native-build.yml new file mode 100644 index 00000000..75fd02c8 --- /dev/null +++ b/.github/workflows/native-build.yml @@ -0,0 +1,110 @@ +name: Build WebView wrappers + +on: + workflow_dispatch: + inputs: + tag: + required: true + description: "Ref to build from (e.g. v1.0.0)" + +env: + PYTHON_VERSION: 3.11 + PNPM_VERSION: 10 + NODE_VERSION: 20 + +jobs: + build: + permissions: + packages: write + contents: write + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout source repo + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag }} + + - name: Cache Chocolatey packages + if: matrix.os == 'windows-latest' + uses: actions/cache@v4 + with: + path: C:\ProgramData\chocolatey\lib + key: choco-${{ runner.os }}-qt6 + restore-keys: | + choco-${{ runner.os }}- + + - 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 + with: + path: .venv + key: uv-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }} + restore-keys: | + uv-${{ runner.os }}-${{ env.PYTHON_VERSION }}- + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + architecture: "x64" + + - name: Install Python dependencies + run: | + pip install uv + 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: + version: ${{ env.PNPM_VERSION }} + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: "ui/pnpm-lock.yaml" + + - name: Build frontend + working-directory: ui + run: | + pnpm install --production --prefer-offline --frozen-lockfile + pnpm run generate + + - name: Build native binary + run: | + uv run pyinstaller ./app.spec + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: app-${{ matrix.os }}-${{ github.event.inputs.tag }} + path: dist/* + + - name: Upload to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag }} + files: dist/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.vscode/settings.json b/.vscode/settings.json index ef14a3c0..e5d1c852 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,7 +25,9 @@ "copyts", "cronsim", "datas", + "dateparser", "daterange", + "defusedxml", "dotenv", "edgechromium", "engineio", @@ -50,12 +52,14 @@ "matroska", "mbed", "mccabe", + "MEIPASS", "Microformat", "microformats", "mkvtoolsnix", "movflags", "mpegts", "msvideo", + "multidict", "muxdelay", "nodesc", "noprogress", diff --git a/README.md b/README.md index ac6b82f6..15753c9c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ live streams, and includes features like scheduling downloads, sending notificat * Apply `yt-dlp` options per custom defined conditions. * Custom browser extensions, bookmarklets and iOS shortcuts to send links to YTPTube instance. +### Side project + +We have a side project related to YTPTube, which takes YTPTube source code and build an executable for Windows, macOS and Linux. +The project is in early stages, and might not even work yet. However keep an eye at [This build action](https://github.com/arabcoders/ytptube/actions/workflows/native-build.yml) for builds, simply select last successful build and download the executable for your platform. Help us test out the platforms +and report any issues you might find. We only have windows/linux machines to test on, so we need your help to test it out on macOS. + + # Installation ## Run using docker command diff --git a/app.spec b/app.spec index cc503913..149a3c00 100644 --- a/app.spec +++ b/app.spec @@ -25,12 +25,13 @@ with open("./uv.lock", "rb") as f: lock = tomllib.load(f) hidden = [ - *lock.get("dependencies", {}).keys(), + *{pkg["name"] for pkg in lock.get("package", [])}, "aiohttp", "socketio", "engineio", "engineio.async_drivers.aiohttp", "socketio.async_drivers.aiohttp", + "app" ] hidden = [f.replace("-", "_") for f in hidden] @@ -41,8 +42,7 @@ a = Analysis( # noqa: F821 # type: ignore binaries=binaries, datas=[ ("ui/exported", "ui/exported"), - ("app/migrations", "migrations"), - ("app/library/presets.json", "library"), + ("app/", "app/"), ], hiddenimports=hidden, hookspath=[], @@ -63,6 +63,7 @@ exe = EXE( # type: ignore # noqa: F821 debug=False, strip=False, upx=True, - console=False, + console=False, # Turn on to True if you want a console window for debugging. + icon="ui/public/favicon.ico", onefile=True, ) diff --git a/app/library/Utils.py b/app/library/Utils.py index 92505b47..f2be102f 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -94,8 +94,7 @@ def calc_download_path(base_path: str | Path, folder: str | None = None, create_ if not folder: return str(base_path) - if folder.startswith("/"): - folder = folder[1:] + folder = folder.removeprefix("/") realBasePath = base_path.resolve() download_path = Path(realBasePath).joinpath(folder).resolve(strict=False) @@ -179,8 +178,7 @@ def extract_info( else: log_wrapper.add_target(target=params["callback"], level=logging.ERROR, name="callback") - if "callback" in params: - del params["callback"] + params.pop("callback", None) if log_wrapper.has_targets(): if "logger" in params: diff --git a/app/library/config.py b/app/library/config.py index 1c3d98a5..271feb07 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -539,7 +539,7 @@ class Config: try: import subprocess - branch_result = subprocess.run( # noqa: S603 + branch_result = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607 cwd=os.path.dirname(git_path), capture_output=True, @@ -556,7 +556,7 @@ class Config: logging.warning("Git branch name is empty.") return - commit_result = subprocess.run( # noqa: S603 + commit_result = subprocess.run( ["git", "log", "-1", "--format=%ct_%H"], # noqa: S607 cwd=os.path.dirname(git_path), capture_output=True, diff --git a/app/native.py b/app/native.py index 9aa302af..eacb67da 100644 --- a/app/native.py +++ b/app/native.py @@ -1,77 +1,120 @@ #!/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) - -import json -import os -import queue -import socket -import threading - -ready = threading.Event() -exception_holder = queue.Queue() - -APP_NAME = "YTPTube" - 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: - msg = "Please run 'pipenv install pywebview[qt]' package to run YTPTube in native mode." + 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"

An error occurred

{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(): - import platformdirs + 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", + } - dct = {} + for key, value in defaults.items(): + if os.getenv(key) is not None: + continue - 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 dct: - os.environ.update(dct) + os.environ[key] = value() if callable(value) else value -def app_start(host: str, port: int) -> None: - import asyncio - - from main import Main - +def run_backend(host: str, port: int, ready_event: threading.Event, error_queue: queue.Queue): try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) - try: - Main(is_native=True).start(host, port, cb=lambda: ready.set()) + from app.main import Main + + Main(is_native=True).start(host, port, cb=lambda: ready_event.set()) except Exception as e: - exception_holder.put(e) - ready.set() + logging.exception(e) + error_queue.put(e) + ready_event.set() -if __name__ == "__main__": +def main(): host = "127.0.0.1" set_env() cfg_path: Path = Path(os.getenv("YTP_CONFIG_PATH")) / "webview.json" - port = None win_conf: dict[str, int] = {} + if cfg_path.exists(): data = json.loads(cfg_path.read_text()) port = data.get("port") @@ -85,18 +128,21 @@ if __name__ == "__main__": port = s.getsockname()[1] cfg_path.write_text(json.dumps({"port": port})) - threading.Thread(target=app_start, args=(host, port), daemon=True).start() + ready = threading.Event() + errors: queue.Queue = queue.Queue() + threading.Thread(target=run_backend, args=(host, port, ready, errors), daemon=False).start() - ready.wait() + ready.wait(timeout=5) - if not exception_holder.empty(): - raise exception_holder.get() + 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} - - webview.settings["ALLOW_DOWNLOADS"] = True - webview.settings["OPEN_DEVTOOLS_IN_DEBUG"] = False - window = webview.create_window(APP_NAME, f"http://{host}:{port}", **create_kwargs) + window = webview.create_window(f"{APP_NAME} - {APP_VERSION}", f"http://{host}:{port}", **create_kwargs) def save_geometry(): cfg = { @@ -110,13 +156,22 @@ if __name__ == "__main__": window.events.resized += lambda *_: save_geometry() window.events.moved += lambda *_: save_geometry() + window.events.closing += lambda *_: os._exit(0) - gui = os.getenv("YTP_WV_GUI", None) - gui = "edgechromium" if os.name == "nt" else "qt" - + 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: + main() + except Exception as e: + logging.exception(e) + error_window(e) + os._exit(1) diff --git a/app/routes/api/_static.py b/app/routes/api/_static.py index e1d70068..2f976420 100644 --- a/app/routes/api/_static.py +++ b/app/routes/api/_static.py @@ -100,7 +100,7 @@ def preload_static(root_path: Path, config: Config) -> None: # uri_path: str = f"/{str(file.as_posix()).replace(f'{static_dir.as_posix()!s}/', '')}" contentType = EXT_TO_MIME.get(file.suffix) if not contentType: - contentType = MIME.from_file(file) + contentType = MIME.from_file(str(file)) STATIC_FILES[uri_path] = { "uri": uri_path, diff --git a/pyproject.toml b/pyproject.toml index 0d827e3b..52421121 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,6 +159,8 @@ ignore = [ "ERA001", "S101", "LOG015", + "PLC0415", + "S603", ] # Allow fix for all enabled rules (when `--fix`) is provided. diff --git a/ui/components/History.vue b/ui/components/History.vue index dccb8a9b..855b06f1 100644 --- a/ui/components/History.vue +++ b/ui/components/History.vue @@ -173,7 +173,7 @@
@@ -340,7 +340,7 @@ - Remove + {{ config.app.remove_files ? 'Remove' : 'Clear' }} diff --git a/ui/components/NewDownload.vue b/ui/components/NewDownload.vue index 581d714e..1bb75a0b 100644 --- a/ui/components/NewDownload.vue +++ b/ui/components/NewDownload.vue @@ -76,7 +76,8 @@ Opts -
+
{{ get_preset(form.preset)?.description }}