switch to uv instead of pipenv, and partially fix the code to work on windows.
This commit is contained in:
parent
e7256e65ac
commit
6d774a6d9c
14 changed files with 1219 additions and 1505 deletions
48
.github/workflows/update-yt-dlp.yml
vendored
48
.github/workflows/update-yt-dlp.yml
vendored
|
|
@ -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
|
||||
|
||||
|
||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
18
Dockerfile
18
Dockerfile
|
|
@ -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
32
Pipfile
|
|
@ -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
1383
Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
# Windows‐only
|
||||
"tzdata; sys_platform == 'win32'",
|
||||
"python-magic-bin; sys_platform == 'win32'",
|
||||
# Non‐Windows
|
||||
"python-magic>=0.4.27; sys_platform != 'win32'",
|
||||
|
||||
# Cross‐platform
|
||||
"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 = [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const CONFIG_KEYS = {
|
|||
browser_enabled: false,
|
||||
ytdlp_cli: '',
|
||||
file_logging: false,
|
||||
is_native: false,
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue