Merge pull request #323 from arabcoders/dev

PR to support webview
This commit is contained in:
Abdulmohsen 2025-07-06 19:26:47 +03:00 committed by GitHub
commit 6607a233c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 251 additions and 152 deletions

View file

@ -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 repositorys 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<<EOF" >> "$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 }}

View file

@ -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) {

110
.github/workflows/native-build.yml vendored Normal file
View file

@ -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 }}

View file

@ -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",

View file

@ -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

View file

@ -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,
)

View file

@ -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:

View file

@ -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,

View file

@ -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"<h1 style='color:red;'>An error occurred</h1><pre>{trace}</pre>",
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)

View file

@ -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,

View file

@ -159,6 +159,8 @@ ignore = [
"ERA001",
"S101",
"LOG015",
"PLC0415",
"S603",
]
# Allow fix for all enabled rules (when `--fix`) is provided.

View file

@ -173,7 +173,7 @@
</div>
<div class="control">
<button class="button is-danger is-fullwidth is-small" @click="removeItem(item)"
v-tooltip="'Remove video'">
v-tooltip="config.app.remove_files ? 'Remove video' : 'Clear video'">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
</button>
</div>
@ -340,7 +340,7 @@
<a class="button is-danger is-fullwidth" @click="removeItem(item)">
<span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span>Remove</span>
<span>{{ config.app.remove_files ? 'Remove' : 'Clear' }}</span>
</span>
</a>
</div>

View file

@ -76,7 +76,8 @@
<span>Opts</span>
</button>
</div>
<div class="column is-12" v-if="!hasFormatInConfig && get_preset(form.preset)?.description">
<div class="column is-12"
v-if="!config.app.basic_mode && !hasFormatInConfig && get_preset(form.preset)?.description">
<div class="is-overflow-auto" style="max-height: 150px;">
<div class="is-ellipsis is-clickable" @click="expand_description">
<span class="icon"><i class="fa-solid fa-info" /></span> {{ get_preset(form.preset)?.description }}