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; return;
} }
const { data: commits } = await github.rest.repos.compareCommits({ const { data: comparison } = await github.rest.repos.compareCommits({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
base: latestRelease.data[1]?.tag_name || '', // fallback to second latest tag base: latestRelease.data[1]?.tag_name || '', // fallback to second latest tag
head: tag, head: tag,
}); });
const commits = comparison.commits.filter(c => 1 === c.parents.length);
const changelog = commits.commits.map( 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'); ).join('\n');
if (!changelog) { 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", "copyts",
"cronsim", "cronsim",
"datas", "datas",
"dateparser",
"daterange", "daterange",
"defusedxml",
"dotenv", "dotenv",
"edgechromium", "edgechromium",
"engineio", "engineio",
@ -50,12 +52,14 @@
"matroska", "matroska",
"mbed", "mbed",
"mccabe", "mccabe",
"MEIPASS",
"Microformat", "Microformat",
"microformats", "microformats",
"mkvtoolsnix", "mkvtoolsnix",
"movflags", "movflags",
"mpegts", "mpegts",
"msvideo", "msvideo",
"multidict",
"muxdelay", "muxdelay",
"nodesc", "nodesc",
"noprogress", "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. * Apply `yt-dlp` options per custom defined conditions.
* Custom browser extensions, bookmarklets and iOS shortcuts to send links to YTPTube instance. * 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 # Installation
## Run using docker command ## Run using docker command

View file

@ -25,12 +25,13 @@ with open("./uv.lock", "rb") as f:
lock = tomllib.load(f) lock = tomllib.load(f)
hidden = [ hidden = [
*lock.get("dependencies", {}).keys(), *{pkg["name"] for pkg in lock.get("package", [])},
"aiohttp", "aiohttp",
"socketio", "socketio",
"engineio", "engineio",
"engineio.async_drivers.aiohttp", "engineio.async_drivers.aiohttp",
"socketio.async_drivers.aiohttp", "socketio.async_drivers.aiohttp",
"app"
] ]
hidden = [f.replace("-", "_") for f in hidden] hidden = [f.replace("-", "_") for f in hidden]
@ -41,8 +42,7 @@ a = Analysis( # noqa: F821 # type: ignore
binaries=binaries, binaries=binaries,
datas=[ datas=[
("ui/exported", "ui/exported"), ("ui/exported", "ui/exported"),
("app/migrations", "migrations"), ("app/", "app/"),
("app/library/presets.json", "library"),
], ],
hiddenimports=hidden, hiddenimports=hidden,
hookspath=[], hookspath=[],
@ -63,6 +63,7 @@ exe = EXE( # type: ignore # noqa: F821
debug=False, debug=False,
strip=False, strip=False,
upx=True, 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, onefile=True,
) )

View file

@ -94,8 +94,7 @@ def calc_download_path(base_path: str | Path, folder: str | None = None, create_
if not folder: if not folder:
return str(base_path) return str(base_path)
if folder.startswith("/"): folder = folder.removeprefix("/")
folder = folder[1:]
realBasePath = base_path.resolve() realBasePath = base_path.resolve()
download_path = Path(realBasePath).joinpath(folder).resolve(strict=False) download_path = Path(realBasePath).joinpath(folder).resolve(strict=False)
@ -179,8 +178,7 @@ def extract_info(
else: else:
log_wrapper.add_target(target=params["callback"], level=logging.ERROR, name="callback") log_wrapper.add_target(target=params["callback"], level=logging.ERROR, name="callback")
if "callback" in params: params.pop("callback", None)
del params["callback"]
if log_wrapper.has_targets(): if log_wrapper.has_targets():
if "logger" in params: if "logger" in params:

View file

@ -539,7 +539,7 @@ class Config:
try: try:
import subprocess import subprocess
branch_result = subprocess.run( # noqa: S603 branch_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607 ["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607
cwd=os.path.dirname(git_path), cwd=os.path.dirname(git_path),
capture_output=True, capture_output=True,
@ -556,7 +556,7 @@ class Config:
logging.warning("Git branch name is empty.") logging.warning("Git branch name is empty.")
return return
commit_result = subprocess.run( # noqa: S603 commit_result = subprocess.run(
["git", "log", "-1", "--format=%ct_%H"], # noqa: S607 ["git", "log", "-1", "--format=%ct_%H"], # noqa: S607
cwd=os.path.dirname(git_path), cwd=os.path.dirname(git_path),
capture_output=True, capture_output=True,

View file

@ -1,77 +1,120 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio
import json
import logging
import os
import queue
import socket
import sys import sys
import threading
import traceback
from pathlib import Path 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()) APP_ROOT = str((Path(__file__).parent / "..").resolve())
if APP_ROOT not in sys.path: if APP_ROOT not in sys.path:
sys.path.insert(0, APP_ROOT) 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: try:
import webview # type: ignore 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: 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 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(): 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"): os.environ[key] = value() if callable(value) else value
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)
def app_start(host: str, port: int) -> None: def run_backend(host: str, port: int, ready_event: threading.Event, error_queue: queue.Queue):
import asyncio
from main import Main
try: try:
loop = asyncio.get_running_loop() try:
except RuntimeError: loop = asyncio.get_running_loop()
loop = asyncio.new_event_loop() except RuntimeError:
asyncio.set_event_loop(loop) loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try: from app.main import Main
Main(is_native=True).start(host, port, cb=lambda: ready.set())
Main(is_native=True).start(host, port, cb=lambda: ready_event.set())
except Exception as e: except Exception as e:
exception_holder.put(e) logging.exception(e)
ready.set() error_queue.put(e)
ready_event.set()
if __name__ == "__main__": def main():
host = "127.0.0.1" host = "127.0.0.1"
set_env() set_env()
cfg_path: Path = Path(os.getenv("YTP_CONFIG_PATH")) / "webview.json" cfg_path: Path = Path(os.getenv("YTP_CONFIG_PATH")) / "webview.json"
port = None port = None
win_conf: dict[str, int] = {} win_conf: dict[str, int] = {}
if cfg_path.exists(): if cfg_path.exists():
data = json.loads(cfg_path.read_text()) data = json.loads(cfg_path.read_text())
port = data.get("port") port = data.get("port")
@ -85,18 +128,21 @@ if __name__ == "__main__":
port = s.getsockname()[1] port = s.getsockname()[1]
cfg_path.write_text(json.dumps({"port": port})) 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(): if not errors.empty():
raise exception_holder.get() 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} create_kwargs = {**win_conf, "resizable": True}
window = webview.create_window(f"{APP_NAME} - {APP_VERSION}", f"http://{host}:{port}", **create_kwargs)
webview.settings["ALLOW_DOWNLOADS"] = True
webview.settings["OPEN_DEVTOOLS_IN_DEBUG"] = False
window = webview.create_window(APP_NAME, f"http://{host}:{port}", **create_kwargs)
def save_geometry(): def save_geometry():
cfg = { cfg = {
@ -110,13 +156,22 @@ if __name__ == "__main__":
window.events.resized += lambda *_: save_geometry() window.events.resized += lambda *_: save_geometry()
window.events.moved += lambda *_: save_geometry() window.events.moved += lambda *_: save_geometry()
window.events.closing += lambda *_: os._exit(0)
gui = os.getenv("YTP_WV_GUI", None) webview.settings["ALLOW_DOWNLOADS"] = True
gui = "edgechromium" if os.name == "nt" else "qt" webview.settings["OPEN_DEVTOOLS_IN_DEBUG"] = False
webview.start( webview.start(
gui=gui, gui=gui,
debug=True, debug=True,
storage_path=str(Path(os.getenv("YTP_TEMP_PATH", os.getcwd())) / "webview"), storage_path=str(Path(os.getenv("YTP_TEMP_PATH", os.getcwd())) / "webview"),
private_mode=False, 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}/', '')}" # uri_path: str = f"/{str(file.as_posix()).replace(f'{static_dir.as_posix()!s}/', '')}"
contentType = EXT_TO_MIME.get(file.suffix) contentType = EXT_TO_MIME.get(file.suffix)
if not contentType: if not contentType:
contentType = MIME.from_file(file) contentType = MIME.from_file(str(file))
STATIC_FILES[uri_path] = { STATIC_FILES[uri_path] = {
"uri": uri_path, "uri": uri_path,

View file

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

View file

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

View file

@ -76,7 +76,8 @@
<span>Opts</span> <span>Opts</span>
</button> </button>
</div> </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-overflow-auto" style="max-height: 150px;">
<div class="is-ellipsis is-clickable" @click="expand_description"> <div class="is-ellipsis is-clickable" @click="expand_description">
<span class="icon"><i class="fa-solid fa-info" /></span> {{ get_preset(form.preset)?.description }} <span class="icon"><i class="fa-solid fa-info" /></span> {{ get_preset(form.preset)?.description }}