switch to uv instead of pipenv, and partially fix the code to work on windows.

This commit is contained in:
arabcoders 2025-06-08 20:45:12 +03:00
parent e7256e65ac
commit 6d774a6d9c
14 changed files with 1219 additions and 1505 deletions

View file

@ -1,48 +0,0 @@
name: update-yt-dlp
on:
workflow_dispatch:
inputs:
logLevel:
description: "Log level"
required: true
default: "warning"
type: choice
options:
- info
- warning
- debug
schedule:
- cron: "0 0 * * *"
jobs:
update-yt-dlp:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: "dev"
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Update yt-dlp
id: ytdlp_update
run: |
pip install pipenv
pipenv sync
VER=`pipenv run pip list -o | awk '$1 == "yt-dlp" {print $3}'`
if [ -n "$VER" ]; then
echo "YTLDLP_VER=${VER}" >> "$GITHUB_OUTPUT"
pipenv update yt-dlp
fi
- name: Create Pull Request
uses: peter-evans/create-pull-request@v5
with:
title: "[yt-dlp] automated update to ${{ steps.ytdlp_update.outputs.YTLDLP_VER }}"
commit-message: "Update yt-dlp to ${{ steps.ytdlp_update.outputs.YTLDLP_VER }}"
body: "This is an automated request to update yt-dlp dependency to ${{ steps.ytdlp_update.outputs.YTLDLP_VER }}"
delete-branch: true

View file

@ -15,9 +15,11 @@
"ahas", "ahas",
"ahash", "ahash",
"aiocron", "aiocron",
"anyio",
"arrowless", "arrowless",
"attl", "attl",
"autonumber", "autonumber",
"brotlicffi",
"consoletitle", "consoletitle",
"cookiesfrombrowser", "cookiesfrombrowser",
"copyts", "copyts",
@ -53,15 +55,19 @@
"noprogress", "noprogress",
"onefile", "onefile",
"pathex", "pathex",
"platformdirs",
"plexmatch", "plexmatch",
"postprocessor", "postprocessor",
"preferredcodec", "preferredcodec",
"preferredquality", "preferredquality",
"printtraffic", "printtraffic",
"pycryptodome",
"pypi",
"pywebview", "pywebview",
"quicktime", "quicktime",
"rtime", "rtime",
"smhd", "smhd",
"socketio",
"timespec", "timespec",
"tmpfilename", "tmpfilename",
"upgrader", "upgrader",

View file

@ -14,15 +14,14 @@ ENV PYTHONFAULTHANDLER=1
ENV PIP_NO_CACHE_DIR=off ENV PIP_NO_CACHE_DIR=off
ENV PIP_CACHE_DIR=/root/.cache/pip ENV PIP_CACHE_DIR=/root/.cache/pip
# Use sed to strip carriage-return characters from the entrypoint script (in case building on Windows)
# Install dependencies # Install dependencies
RUN apk add --update coreutils curl gcc g++ musl-dev libffi-dev openssl-dev curl make && pip install pipenv RUN apk add --update coreutils curl gcc g++ musl-dev libffi-dev openssl-dev curl make && pip install uv
WORKDIR /app WORKDIR /opt/
ARG PIPENV_FLAGS="--deploy" COPY ./pyproject.toml ./uv.lock ./
COPY ./Pipfile* . RUN --mount=type=cache,target=/root/.cache/pip uv --no-cache venv --system-site-packages --relocatable ./python && \
RUN --mount=type=cache,target=/root/.cache/pip PIPENV_VENV_IN_PROJECT=1 pipenv install ${PIPENV_FLAGS} VIRTUAL_ENV=/opt/python uv sync --link-mode=copy --active
FROM python:3.13-alpine FROM python:3.13-alpine
@ -51,16 +50,13 @@ RUN sed -i 's/\r$//g' /entrypoint.sh && chmod +x /entrypoint.sh
COPY --chown=app:app ./app /app/app COPY --chown=app:app ./app /app/app
COPY --chown=app:app --from=node_builder /app/exported /app/ui/exported COPY --chown=app:app --from=node_builder /app/exported /app/ui/exported
COPY --chown=app:app --from=python_builder /app/.venv /opt/python COPY --chown=app:app --from=python_builder /opt/python /opt/python
COPY --chown=app:app --from=ghcr.io/arabcoders/alpine-mp4box /usr/bin/mp4box /usr/bin/mp4box COPY --chown=app:app --from=ghcr.io/arabcoders/alpine-mp4box /usr/bin/mp4box /usr/bin/mp4box
COPY --chown=app:app ./healthcheck.sh /usr/local/bin/healthcheck COPY --chown=app:app ./healthcheck.sh /usr/local/bin/healthcheck
ENV PATH="/opt/python/bin:$PATH" ENV PATH="/opt/python/bin:$PATH"
RUN chown -R app:app /config /downloads && chmod +x /usr/local/bin/healthcheck && \ RUN chown -R app:app /config /downloads && chmod +x /usr/local/bin/healthcheck /usr/bin/mp4box
sed -i 's$#!\/app\/\.venv\/bin\/python$#!/opt/python/bin/python$' /opt/python/bin/* && \
sed -i "s%'\/app\/\.venv'%'/opt/python'%" /opt/python/bin/activate* && \
chmod +x /usr/bin/mp4box
VOLUME /config VOLUME /config
VOLUME /downloads VOLUME /downloads

32
Pipfile
View file

@ -1,32 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
python-socketio = ">=5.11.1"
aiohttp = ">=3.9.3"
caribou = ">=0.3.0"
coloredlogs = ">=15.0.1"
aiocron = ">=1.8"
python-dotenv = ">=1.0.1"
python-magic = ">=0.4.27"
debugpy = ">=1.8.1"
httpx = "*"
async-timeout = "*"
pyjson5 = "*"
curl_cffi = "0.7.1"
pysubs2 = "*"
regex = "*"
mutagen = "*"
brotli = "*"
brotlicffi = "*"
anyio = "*"
pycryptodome = "*"
yt-dlp = "*"
platformdirs = "*"
[dev-packages]
[requires]
python_version = "*"

1383
Pipfile.lock generated

File diff suppressed because it is too large Load diff

View file

@ -226,13 +226,14 @@ class HttpAPI(Common):
HttpAPI: The instance of the HttpAPI. HttpAPI: The instance of the HttpAPI.
""" """
staticDir = os.path.join(self.rootPath, "ui", "exported") staticDir = Path(os.path.join(self.rootPath, "ui", "exported")).absolute()
if not os.path.exists(staticDir): if not staticDir.exists():
staticDir = os.path.join(self.rootPath, "..", "ui", "exported") staticDir = Path(os.path.join(self.rootPath, "..", "ui", "exported")).absolute()
if not os.path.exists(staticDir): if not staticDir.exists():
msg = f"Could not find the frontend UI static assets. '{staticDir}'." msg = f"Could not find the frontend UI static assets. '{staticDir}'."
raise ValueError(msg) raise ValueError(msg)
staticDir = str(staticDir.as_posix())
preloaded = 0 preloaded = 0
base_path: str = self.config.base_path.rstrip("/") base_path: str = self.config.base_path.rstrip("/")
@ -242,7 +243,7 @@ class HttpAPI(Common):
if file.endswith(".map"): if file.endswith(".map"):
continue continue
file = os.path.join(root, file) file = str(Path(os.path.join(root, file)).as_posix())
urlPath: str = f"{base_path}/{file.replace(f'{staticDir}/', '')}" urlPath: str = f"{base_path}/{file.replace(f'{staticDir}/', '')}"
with open(file, "rb") as f: with open(file, "rb") as f:
@ -708,7 +709,7 @@ class HttpAPI(Common):
continue continue
tasks.append( tasks.append(
asyncio.get_event_loop().run_in_executor(None, lambda id=id, url=url: info_wrapper(id=id, url=url)) asyncio.get_event_loop().run_in_executor(None, lambda i=id, url=url: info_wrapper(id=i, url=url))
) )
if len(tasks) > 0: if len(tasks) > 0:

View file

@ -3,7 +3,6 @@ import errno
import functools import functools
import logging import logging
import os import os
import pty
import shlex import shlex
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
@ -121,23 +120,47 @@ class HttpSocket(Common):
} }
) )
master_fd, slave_fd = pty.openpty() try:
import pty
master_fd, slave_fd = pty.openpty()
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = stderr_arg = slave_fd
use_pty = True
except ImportError:
use_pty = False
master_fd = slave_fd = None
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = asyncio.subprocess.PIPE
stderr_arg = asyncio.subprocess.STDOUT
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*args, *args,
cwd=self.config.download_path, cwd=self.config.download_path,
stdin=asyncio.subprocess.DEVNULL, stdin=stdin_arg,
stdout=slave_fd, stdout=stdout_arg,
stderr=slave_fd, stderr=stderr_arg,
env=_env, env=_env,
) )
try: if use_pty:
os.close(slave_fd) try:
except Exception as e: os.close(slave_fd)
LOG.error(f"Error closing PTY. '{e!s}'.") except Exception as e:
LOG.error(f"Error closing PTY. '{e!s}'.")
async def reader(sid: str):
if use_pty is False:
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.rstrip(b"\n")
await self._notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
to=sid,
)
return
async def read_pty(sid: str):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
buffer = b"" buffer = b""
while True: while True:
@ -172,7 +195,7 @@ class HttpSocket(Common):
LOG.error(f"Error closing PTY. '{e!s}'.") LOG.error(f"Error closing PTY. '{e!s}'.")
# Start reading output from PTY # Start reading output from PTY
read_task = asyncio.create_task(read_pty(sid=sid)) read_task = asyncio.create_task(reader(sid=sid))
# Wait until process finishes # Wait until process finishes
returncode = await proc.wait() returncode = await proc.wait()

View file

@ -152,6 +152,9 @@ class Config:
_ytdlp_cli_mutable: str = "" _ytdlp_cli_mutable: str = ""
"""The command line options to use for yt-dlp.""" """The command line options to use for yt-dlp."""
is_native: bool = False
"Is the application running in webview."
pictures_backends: list[str] = [ pictures_backends: list[str] = [
"https://unsplash.it/1920/1080?random", "https://unsplash.it/1920/1080?random",
"https://picsum.photos/1920/1080", "https://picsum.photos/1920/1080",
@ -175,6 +178,7 @@ class Config:
"started", "started",
"ytdlp_cli", "ytdlp_cli",
"_ytdlp_cli_mutable", "_ytdlp_cli_mutable",
"is_native",
) )
"The variables that are immutable." "The variables that are immutable."
@ -223,6 +227,7 @@ class Config:
"ytdlp_cli", "ytdlp_cli",
"file_logging", "file_logging",
"base_path", "base_path",
"is_native",
) )
"The variables that are relevant to the frontend." "The variables that are relevant to the frontend."
@ -230,9 +235,9 @@ class Config:
"The manager instance." "The manager instance."
@staticmethod @staticmethod
def get_instance(): def get_instance(is_native: bool = False) -> "Config":
"""Static access method.""" """Static access method."""
return Config() if not Config.__instance else Config.__instance return Config(is_native) if not Config.__instance else Config.__instance
@staticmethod @staticmethod
def get_manager() -> SyncManager: def get_manager() -> SyncManager:
@ -241,7 +246,7 @@ class Config:
return Config._manager return Config._manager
def __init__(self): def __init__(self, is_native: bool = False):
"""Virtually private constructor.""" """Virtually private constructor."""
if Config.__instance is not None: if Config.__instance is not None:
msg = "This class is a singleton. Use Config.get_instance() instead." msg = "This class is a singleton. Use Config.get_instance() instead."
@ -251,12 +256,15 @@ class Config:
baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute()) baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute())
self.is_native = is_native
self.temp_path = os.environ.get("YTP_TEMP_PATH", None) or os.path.join(baseDefaultPath, "var", "tmp") self.temp_path = os.environ.get("YTP_TEMP_PATH", None) or os.path.join(baseDefaultPath, "var", "tmp")
self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or os.path.join(baseDefaultPath, "var", "config") self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or os.path.join(baseDefaultPath, "var", "config")
self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or os.path.join( self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or os.path.join(
baseDefaultPath, "var", "downloads" baseDefaultPath, "var", "downloads"
) )
conf_store = str(Path(self.config_path).as_posix())
envFile: str = os.path.join(self.config_path, ".env") envFile: str = os.path.join(self.config_path, ".env")
if os.path.exists(envFile): if os.path.exists(envFile):
@ -326,7 +334,7 @@ class Config:
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}") LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
ytdl_options = {} ytdl_options = {}
opts_file: str = os.path.join(self.config_path, "ytdlp.cli") opts_file: str = os.path.join(conf_store, "ytdlp.cli")
if os.path.exists(opts_file) and os.path.getsize(opts_file) > 2: if os.path.exists(opts_file) and os.path.getsize(opts_file) > 2:
LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.") LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.")
with open(opts_file) as f: with open(opts_file) as f:
@ -358,7 +366,7 @@ class Config:
if self.keep_archive: if self.keep_archive:
LOG.info("keep archive option is enabled.") LOG.info("keep archive option is enabled.")
archive_file: str = os.path.join(self.config_path, "archive.log") archive_file: str = os.path.join(conf_store, "archive.log")
self._ytdlp_cli_mutable += f"\n--download-archive {archive_file}" self._ytdlp_cli_mutable += f"\n--download-archive {archive_file}"
if cookies_file := ytdl_options.get("cookiefile", None): if cookies_file := ytdl_options.get("cookiefile", None):
@ -371,7 +379,7 @@ class Config:
ytdl_options.pop("cookiefile", None) ytdl_options.pop("cookiefile", None)
if not ytdl_options.get("cookiefile", None): if not ytdl_options.get("cookiefile", None):
cookies_file: str = os.path.join(self.config_path, "cookies.txt") cookies_file: str = os.path.join(conf_store, "cookies.txt")
if os.path.exists(cookies_file) and os.path.getsize(cookies_file) > 2: if os.path.exists(cookies_file) and os.path.getsize(cookies_file) > 2:
LOG.info(f"Using cookies from '{cookies_file}'.") LOG.info(f"Using cookies from '{cookies_file}'.")
load_cookies(cookies_file) load_cookies(cookies_file)
@ -392,12 +400,12 @@ class Config:
msg = f"Invalid file log level '{self.log_level_file}' specified." msg = f"Invalid file log level '{self.log_level_file}' specified."
raise TypeError(msg) raise TypeError(msg)
loggingPath = os.path.join(self.config_path, "logs") loggingPath = os.path.join(conf_store, "logs")
if not os.path.exists(loggingPath): if not os.path.exists(loggingPath):
os.makedirs(loggingPath, exist_ok=True) os.makedirs(loggingPath, exist_ok=True)
handler = TimedRotatingFileHandler( handler = TimedRotatingFileHandler(
filename=os.path.join(self.config_path, "logs", "app.log"), filename=os.path.join(conf_store, "logs", "app.log"),
when="midnight", when="midnight",
backupCount=3, backupCount=3,
) )
@ -406,7 +414,7 @@ class Config:
handler.setFormatter(formatter) handler.setFormatter(formatter)
logging.getLogger().addHandler(handler) logging.getLogger().addHandler(handler)
key_file: str = os.path.join(self.config_path, "secret.key") key_file: str = os.path.join(conf_store, "secret.key")
if os.path.exists(key_file) and os.path.getsize(key_file) > 5: if os.path.exists(key_file) and os.path.getsize(key_file) > 5:
with open(key_file, "rb") as f: with open(key_file, "rb") as f:

View file

@ -4,6 +4,7 @@ import asyncio
import logging import logging
import os import os
import sqlite3 import sqlite3
import sys
from pathlib import Path from pathlib import Path
import caribou import caribou
@ -27,8 +28,8 @@ ROOT_PATH: Path = Path(__file__).parent.absolute()
class Main: class Main:
def __init__(self): def __init__(self, is_native: bool = False):
self._config = Config.get_instance() self._config = Config.get_instance(is_native=is_native)
self._app = web.Application() self._app = web.Application()
self._check_folders() self._check_folders()
@ -118,7 +119,7 @@ class Main:
self._app, self._app,
host=host, host=host,
port=port, port=port,
reuse_port=True, reuse_port="win32" != sys.platform,
loop=asyncio.get_event_loop(), loop=asyncio.get_event_loop(),
access_log=HTTP_LOGGER, access_log=HTTP_LOGGER,
print=started, print=started,

View file

@ -47,7 +47,7 @@ def app_start(host: str, port: int) -> None:
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
Main().start(host, port, cb=lambda: ready.set()) Main(is_native=True).start(host, port, cb=lambda: ready.set())
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,3 +1,41 @@
[project]
name = "ytptube"
version = "1.0.0"
description = "A WebUI for yt-dlp with concurrent downloads support, presets and scheduled tasks and many more."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Abdulmohsen", email = "contact@arabcoders.org" }]
dependencies = [
# Windowsonly
"tzdata; sys_platform == 'win32'",
"python-magic-bin; sys_platform == 'win32'",
# NonWindows
"python-magic>=0.4.27; sys_platform != 'win32'",
# Crossplatform
"python-socketio>=5.11.1",
"aiohttp>=3.9.3",
"caribou>=0.3.0",
"coloredlogs>=15.0.1",
"aiocron>=1.8",
"python-dotenv>=1.0.1",
"debugpy>=1.8.1",
"httpx",
"async-timeout",
"pyjson5",
"curl_cffi==0.7.1",
"pysubs2",
"regex",
"mutagen",
"brotli",
"brotlicffi",
"anyio",
"pycryptodome",
"yt-dlp",
"platformdirs",
]
[tool.ruff] [tool.ruff]
# Exclude a variety of commonly ignored directories. # Exclude a variety of commonly ignored directories.
exclude = [ exclude = [

View file

@ -74,7 +74,7 @@
</div> </div>
</div> </div>
<div class="navbar-item is-hidden-mobile"> <div class="navbar-item is-hidden-mobile" v-if="false === config.app.is_native">
<button class="button is-dark" @click="reloadPage"> <button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
</button> </button>

View file

@ -20,6 +20,7 @@ const CONFIG_KEYS = {
browser_enabled: false, browser_enabled: false,
ytdlp_cli: '', ytdlp_cli: '',
file_logging: false, file_logging: false,
is_native: false,
}, },
presets: [ presets: [
{ {

1103
uv.lock Normal file

File diff suppressed because it is too large Load diff