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",
"ahash",
"aiocron",
"anyio",
"arrowless",
"attl",
"autonumber",
"brotlicffi",
"consoletitle",
"cookiesfrombrowser",
"copyts",
@ -53,15 +55,19 @@
"noprogress",
"onefile",
"pathex",
"platformdirs",
"plexmatch",
"postprocessor",
"preferredcodec",
"preferredquality",
"printtraffic",
"pycryptodome",
"pypi",
"pywebview",
"quicktime",
"rtime",
"smhd",
"socketio",
"timespec",
"tmpfilename",
"upgrader",

View file

@ -14,15 +14,14 @@ ENV PYTHONFAULTHANDLER=1
ENV PIP_NO_CACHE_DIR=off
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
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 ./Pipfile* .
RUN --mount=type=cache,target=/root/.cache/pip PIPENV_VENV_IN_PROJECT=1 pipenv install ${PIPENV_FLAGS}
COPY ./pyproject.toml ./uv.lock ./
RUN --mount=type=cache,target=/root/.cache/pip uv --no-cache venv --system-site-packages --relocatable ./python && \
VIRTUAL_ENV=/opt/python uv sync --link-mode=copy --active
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 --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 ./healthcheck.sh /usr/local/bin/healthcheck
ENV PATH="/opt/python/bin:$PATH"
RUN chown -R app:app /config /downloads && chmod +x /usr/local/bin/healthcheck && \
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
RUN chown -R app:app /config /downloads && chmod +x /usr/local/bin/healthcheck /usr/bin/mp4box
VOLUME /config
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.
"""
staticDir = os.path.join(self.rootPath, "ui", "exported")
if not os.path.exists(staticDir):
staticDir = os.path.join(self.rootPath, "..", "ui", "exported")
if not os.path.exists(staticDir):
staticDir = Path(os.path.join(self.rootPath, "ui", "exported")).absolute()
if not staticDir.exists():
staticDir = Path(os.path.join(self.rootPath, "..", "ui", "exported")).absolute()
if not staticDir.exists():
msg = f"Could not find the frontend UI static assets. '{staticDir}'."
raise ValueError(msg)
staticDir = str(staticDir.as_posix())
preloaded = 0
base_path: str = self.config.base_path.rstrip("/")
@ -242,7 +243,7 @@ class HttpAPI(Common):
if file.endswith(".map"):
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}/', '')}"
with open(file, "rb") as f:
@ -708,7 +709,7 @@ class HttpAPI(Common):
continue
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:

View file

@ -3,7 +3,6 @@ import errno
import functools
import logging
import os
import pty
import shlex
import time
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(
*args,
cwd=self.config.download_path,
stdin=asyncio.subprocess.DEVNULL,
stdout=slave_fd,
stderr=slave_fd,
stdin=stdin_arg,
stdout=stdout_arg,
stderr=stderr_arg,
env=_env,
)
try:
os.close(slave_fd)
except Exception as e:
LOG.error(f"Error closing PTY. '{e!s}'.")
if use_pty:
try:
os.close(slave_fd)
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()
buffer = b""
while True:
@ -172,7 +195,7 @@ class HttpSocket(Common):
LOG.error(f"Error closing PTY. '{e!s}'.")
# 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
returncode = await proc.wait()

View file

@ -152,6 +152,9 @@ class Config:
_ytdlp_cli_mutable: str = ""
"""The command line options to use for yt-dlp."""
is_native: bool = False
"Is the application running in webview."
pictures_backends: list[str] = [
"https://unsplash.it/1920/1080?random",
"https://picsum.photos/1920/1080",
@ -175,6 +178,7 @@ class Config:
"started",
"ytdlp_cli",
"_ytdlp_cli_mutable",
"is_native",
)
"The variables that are immutable."
@ -223,6 +227,7 @@ class Config:
"ytdlp_cli",
"file_logging",
"base_path",
"is_native",
)
"The variables that are relevant to the frontend."
@ -230,9 +235,9 @@ class Config:
"The manager instance."
@staticmethod
def get_instance():
def get_instance(is_native: bool = False) -> "Config":
"""Static access method."""
return Config() if not Config.__instance else Config.__instance
return Config(is_native) if not Config.__instance else Config.__instance
@staticmethod
def get_manager() -> SyncManager:
@ -241,7 +246,7 @@ class Config:
return Config._manager
def __init__(self):
def __init__(self, is_native: bool = False):
"""Virtually private constructor."""
if Config.__instance is not None:
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())
self.is_native = is_native
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.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or os.path.join(
baseDefaultPath, "var", "downloads"
)
conf_store = str(Path(self.config_path).as_posix())
envFile: str = os.path.join(self.config_path, ".env")
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}")
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:
LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.")
with open(opts_file) as f:
@ -358,7 +366,7 @@ class Config:
if self.keep_archive:
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}"
if cookies_file := ytdl_options.get("cookiefile", None):
@ -371,7 +379,7 @@ class Config:
ytdl_options.pop("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:
LOG.info(f"Using cookies from '{cookies_file}'.")
load_cookies(cookies_file)
@ -392,12 +400,12 @@ class Config:
msg = f"Invalid file log level '{self.log_level_file}' specified."
raise TypeError(msg)
loggingPath = os.path.join(self.config_path, "logs")
loggingPath = os.path.join(conf_store, "logs")
if not os.path.exists(loggingPath):
os.makedirs(loggingPath, exist_ok=True)
handler = TimedRotatingFileHandler(
filename=os.path.join(self.config_path, "logs", "app.log"),
filename=os.path.join(conf_store, "logs", "app.log"),
when="midnight",
backupCount=3,
)
@ -406,7 +414,7 @@ class Config:
handler.setFormatter(formatter)
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:
with open(key_file, "rb") as f:

View file

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

View file

@ -47,7 +47,7 @@ def app_start(host: str, port: int) -> None:
loop = asyncio.new_event_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__":

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]
# Exclude a variety of commonly ignored directories.
exclude = [

View file

@ -74,7 +74,7 @@
</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">
<span class="icon"><i class="fas fa-refresh" /></span>
</button>

View file

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

1103
uv.lock Normal file

File diff suppressed because it is too large Load diff