WIP: more changes to support webview in the future.

This commit is contained in:
arabcoders 2025-06-08 23:45:00 +03:00
parent fee8939c80
commit 1f1fbbac01
8 changed files with 120 additions and 68 deletions

View file

@ -1,5 +1,6 @@
import copy import copy
import json import json
import logging
from collections import OrderedDict from collections import OrderedDict
from datetime import UTC, datetime from datetime import UTC, datetime
from email.utils import formatdate from email.utils import formatdate
@ -8,7 +9,9 @@ from sqlite3 import Connection
from .config import Config from .config import Config
from .Download import Download from .Download import Download
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Utils import clean_item from .Utils import init_class
LOG = logging.getLogger("datastore")
class DataStore: class DataStore:
@ -69,9 +72,9 @@ class DataStore:
for row in cursor: for row in cursor:
rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007 rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data, _ = clean_item(json.loads(row["data"]), keys=ItemDTO.removed_fields()) data: dict = json.loads(row["data"])
data.pop("_id", None) data.pop("_id", None)
item: ItemDTO = ItemDTO(**data) item: ItemDTO = init_class(ItemDTO, data)
item._id = row["id"] item._id = row["id"]
item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp()) item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp())
items.append((row["id"], item)) items.append((row["id"], item))
@ -147,10 +150,4 @@ class DataStore:
) )
def _delete_store_item(self, key: str) -> None: def _delete_store_item(self, key: str) -> None:
self.connection.execute( self.connection.execute('DELETE FROM "history" WHERE "type" = ? AND "id" = ?', (self.type, key))
'DELETE FROM "history" WHERE "type" = ? AND "id" = ?',
(
self.type,
key,
),
)

View file

@ -4,10 +4,10 @@ import logging
import multiprocessing import multiprocessing
import os import os
import re import re
import shutil
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from email.utils import formatdate from email.utils import formatdate
from pathlib import Path
import yt_dlp import yt_dlp
@ -16,7 +16,7 @@ from .config import Config
from .Events import EventBus, Events from .Events import EventBus, Events
from .ffprobe import ffprobe from .ffprobe import ffprobe
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Utils import extract_info, extract_ytdlp_logs, load_cookies from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies
from .YTDLPOpts import YTDLPOpts from .YTDLPOpts import YTDLPOpts
@ -137,7 +137,7 @@ class Download:
return return
if "__finaldir" in data["info_dict"]: if "__finaldir" in data["info_dict"]:
filename = os.path.join(data["info_dict"]["__finaldir"], os.path.basename(data["info_dict"]["filepath"])) filename = str(Path(data["info_dict"]["__finaldir"]) / Path(data["info_dict"]["filepath"]).name)
else: else:
filename = data["info_dict"]["filepath"] filename = data["info_dict"]["filepath"]
@ -160,8 +160,8 @@ class Download:
config={ config={
"color": "no_color", "color": "no_color",
"paths": { "paths": {
"home": self.download_dir, "home": str(self.download_dir),
"temp": self.temp_path, "temp": str(self.temp_path),
}, },
"outtmpl": { "outtmpl": {
"default": self.template, "default": self.template,
@ -189,13 +189,12 @@ class Download:
if self.info.cookies: if self.info.cookies:
try: try:
cookie_file = os.path.join(self.temp_path, f"cookie_{self.info._id}.txt") cookie_file = Path(self.temp_path) / f"cookie_{self.info._id}.txt"
self.logger.debug( self.logger.debug(
f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'." f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'."
) )
with open(cookie_file, "w") as f: cookie_file.write_text(self.info.cookies)
f.write(self.info.cookies) params["cookiefile"] = str(cookie_file.as_posix())
params["cookiefile"] = f.name
load_cookies(cookie_file) load_cookies(cookie_file)
except Exception as e: except Exception as e:
@ -280,10 +279,12 @@ class Download:
self.status_queue = Config.get_manager().Queue() self.status_queue = Config.get_manager().Queue()
# Create temp dir for each download. # Create temp dir for each download.
self.temp_path = os.path.join(self.temp_dir, hashlib.shake_256(f"D-{self.info.id}".encode()).hexdigest(5)) self.temp_path = Path(self.temp_dir) / hashlib.shake_256(f"D-{self.info.id}".encode()).hexdigest(5)
if not os.path.exists(self.temp_path): if not self.temp_path.exists():
os.makedirs(self.temp_path, exist_ok=True) self.temp_path.mkdir(parents=True, exist_ok=True)
self.info.temp_path = str(self.temp_path)
self.proc = multiprocessing.Process(name=f"download-{self.id}", target=self._download) self.proc = multiprocessing.Process(name=f"download-{self.id}", target=self._download)
self.proc.start() self.proc.start()
@ -385,17 +386,19 @@ class Download:
) )
return return
if not os.path.exists(self.temp_path): tmp_dir = Path(self.temp_path)
if not tmp_dir.exists():
return return
if self.temp_path == self.temp_dir: if str(tmp_dir) == str(self.temp_dir):
self.logger.warning( self.logger.warning(
f"Attempted to delete video temp folder: {self.temp_path}, but it is the same as main temp folder." f"Attempted to delete video temp folder '{self.temp_path}', but it is the same as main temp folder."
) )
return return
self.logger.info(f"Deleting Temp folder '{self.temp_path}'.") status = delete_dir(tmp_dir)
shutil.rmtree(self.temp_path, ignore_errors=True) self.logger.info(f"Temp folder '{self.temp_path}' deletion is {'success' if status else 'failed'}.")
async def progress_update(self): async def progress_update(self):
""" """
@ -424,11 +427,15 @@ class Download:
self.tmpfilename = status.get("tmpfilename") self.tmpfilename = status.get("tmpfilename")
if "filename" in status: if "filename" in status:
self.info.filename = os.path.relpath(status.get("filename"), self.download_dir) fl = Path(status.get("filename"))
try:
self.info.filename = str(Path(status.get("filename")).relative_to(Path(self.download_dir)))
except ValueError:
self.info.filename = str(Path(status.get("filename")).relative_to(Path(self.temp_path)))
if os.path.exists(status.get("filename")): if fl.is_file() and fl.exists():
try: try:
self.info.file_size = os.path.getsize(status.get("filename")) self.info.file_size = fl.stat().st_size
except FileNotFoundError: except FileNotFoundError:
self.info.file_size = 0 self.info.file_size = 0
@ -439,7 +446,8 @@ class Download:
self.info.error = status.get("error") self.info.error = status.get("error")
await self._notify.emit(Events.ERROR, data={"message": self.info.error, "data": self.info}) await self._notify.emit(Events.ERROR, data={"message": self.info.error, "data": self.info})
if "downloaded_bytes" in status: if "downloaded_bytes" in status and status.get("downloaded_bytes") > 0:
self.info.downloaded_bytes = status.get("downloaded_bytes")
total = status.get("total_bytes") or status.get("total_bytes_estimate") total = status.get("total_bytes") or status.get("total_bytes_estimate")
if total: if total:
try: try:
@ -451,17 +459,11 @@ class Download:
self.info.speed = status.get("speed") self.info.speed = status.get("speed")
self.info.eta = status.get("eta") self.info.eta = status.get("eta")
if ( fl = Path(status.get("filename")) if status and "filename" in status else None
"finished" == self.info.status
and "filename" in status if "finished" == self.info.status and fl and fl.is_file() and fl.exists():
and os.path.isfile(status.get("filename")) self.info.file_size = fl.stat().st_size
and os.path.exists(status.get("filename")) self.info.datetime = str(formatdate(time.time()))
):
try:
self.info.file_size = os.path.getsize(status.get("filename"))
self.info.datetime = str(formatdate(time.time()))
except FileNotFoundError:
pass
try: try:
ff = await ffprobe(status.get("filename")) ff = await ffprobe(status.get("filename"))
@ -471,7 +473,7 @@ class Download:
self.info.extras["is_video"] = True self.info.extras["is_video"] = True
self.info.extras["is_audio"] = True self.info.extras["is_audio"] = True
self.logger.exception(e) self.logger.exception(e)
self.logger.error(f"Failed to ffprobe: {status.get}. {e}") self.logger.error(f"Failed to run ffprobe. {status.get}. {e}")
await self._notify.emit(Events.UPDATED, data=self.info) await self._notify.emit(Events.UPDATED, data=self.info)

View file

@ -247,4 +247,5 @@ class ItemDTO:
"output_template", "output_template",
"output_template_chapter", "output_template_chapter",
"config", "config",
"temp_path"
) )

View file

@ -1131,3 +1131,47 @@ def extract_ytdlp_logs(logs: list[str], filters: list[str | re.Pattern] = None)
matched.extend(line for line in logs if line and any(p.search(line) for p in compiled)) matched.extend(line for line in logs if line and any(p.search(line) for p in compiled))
return list(dict.fromkeys(matched)) return list(dict.fromkeys(matched))
def delete_dir(dir: pathlib.Path) -> bool:
"""
Delete a directory and all its contents.
Args:
dir (Path): The directory to delete.
Returns:
bool: True if the directory was deleted successfully, False otherwise.
"""
if not dir or not dir.exists() or not dir.is_dir():
return False
try:
for item in dir.iterdir():
if item.is_dir():
delete_dir(item)
else:
item.unlink()
dir.rmdir()
return True
except Exception as e:
LOG.error(f"Failed to delete directory '{dir}': {e}")
return False
def init_class(cls: type, data: dict):
"""
Initialize a class instance with data from a dictionary, filtering out keys not present in the class fields.
Args:
cls (type): The class to initialize.
data (dict): The data to use for initialization.
Returns:
object: An instance of the class initialized with the provided data.
"""
from dataclasses import fields
return cls(**{k: v for k, v in data.items() if k in {f.name for f in fields(cls)}})

View file

@ -1,8 +1,8 @@
import json import json
import logging import logging
import os
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
@ -13,7 +13,7 @@ from .Events import EventBus, Events
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import arg_converter from .Utils import arg_converter
LOG = logging.getLogger("presets") LOG = logging.getLogger("conditions")
@dataclass(kw_only=True) @dataclass(kw_only=True)
@ -51,16 +51,16 @@ class Conditions(metaclass=Singleton):
_instance = None _instance = None
"""The instance of the class.""" """The instance of the class."""
def __init__(self, file: str | None = None, config: Config | None = None): def __init__(self, file: Path | str | None = None, config: Config | None = None):
Conditions._instance = self Conditions._instance = self
config = config or Config.get_instance() config = config or Config.get_instance()
self._file: str = file or os.path.join(config.config_path, "conditions.json") self._file: Path = Path(file) if file else Path(config.config_path) / "conditions.json"
if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]: if self._file.exists() and "600" != self._file.stat().st_mode:
try: try:
os.chmod(self._file, 0o600) self._file.chmod(0o600)
except Exception: except Exception:
pass pass
@ -68,7 +68,7 @@ class Conditions(metaclass=Singleton):
msg = "Not implemented" msg = "Not implemented"
raise Exception(msg) raise Exception(msg)
EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.add") EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.save")
@staticmethod @staticmethod
def get_instance() -> "Conditions": def get_instance() -> "Conditions":
@ -114,7 +114,7 @@ class Conditions(metaclass=Singleton):
""" """
self.clear() self.clear()
if not os.path.exists(self._file) or os.path.getsize(self._file) < 10: if not self._file.exists() or self._file.stat().st_size < 1:
return self return self
LOG.info(f"Loading '{self._file}'.") LOG.info(f"Loading '{self._file}'.")

View file

@ -2,7 +2,6 @@
import asyncio import asyncio
import logging import logging
import os
import sqlite3 import sqlite3
import sys import sys
from pathlib import Path from pathlib import Path
@ -34,7 +33,7 @@ class Main:
self._check_folders() self._check_folders()
caribou.upgrade(self._config.db_file, os.path.join(ROOT_PATH, "migrations")) caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations")
connection = sqlite3.connect(database=self._config.db_file, isolation_level=None) connection = sqlite3.connect(database=self._config.db_file, isolation_level=None)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
@ -46,8 +45,9 @@ class Main:
LOG.debug("Database connection closed.") LOG.debug("Database connection closed.")
try: try:
if "600" != oct(os.stat(self._config.db_file).st_mode)[-3:]: db_file = Path(self._config.db_file)
os.chmod(self._config.db_file, 0o600) if "600" != oct(db_file.stat().st_mode)[-3:]:
db_file.chmod(0o600)
except Exception: except Exception:
pass pass
@ -62,23 +62,24 @@ class Main:
folders = (self._config.download_path, self._config.temp_path, self._config.config_path) folders = (self._config.download_path, self._config.temp_path, self._config.config_path)
for folder in folders: for folder in folders:
folder = Path(folder)
try: try:
LOG.debug(f"Checking folder at '{folder}'.") LOG.debug(f"Checking folder at '{folder}'.")
if not os.path.exists(folder): if not folder.exists():
LOG.info(f"Creating folder at '{folder}'.") LOG.info(f"Creating folder at '{folder}'.")
os.makedirs(folder, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
except OSError: except OSError:
LOG.error(f"Could not create folder at '{folder}'.") LOG.error(f"Could not create folder at '{folder}'.")
raise raise
try: try:
LOG.debug(f"Checking database file at '{self._config.db_file}'.") db_file = Path(self._config.db_file)
if not os.path.exists(self._config.db_file): LOG.debug(f"Checking database file at '{db_file}'.")
LOG.info(f"Creating database file at '{self._config.db_file}'.") if not db_file.exists():
with open(self._config.db_file, "w") as _: LOG.info(f"Creating database file at '{db_file}'.")
pass db_file.touch(exist_ok=True)
except OSError: except OSError as e:
LOG.error(f"Could not create database file at '{self._config.db_file}'.") LOG.error(f"Could not create database file at '{self._config.db_file}'. {e!s}")
raise raise
def start(self, host: str | None = None, port: int | None = None, cb=None): def start(self, host: str | None = None, port: int | None = None, cb=None):

View file

@ -11,11 +11,11 @@ if [ ! -w "${YTP_CONFIG_PATH}" ]; then
CH_GRP=$(stat -c "%g" "${YTP_CONFIG_PATH}") CH_GRP=$(stat -c "%g" "${YTP_CONFIG_PATH}")
echo_err "ERROR: Unable to write to '${YTP_CONFIG_PATH}' data directory. Current user id '${UID}' while directory owner is '${CH_USER}'." echo_err "ERROR: Unable to write to '${YTP_CONFIG_PATH}' data directory. Current user id '${UID}' while directory owner is '${CH_USER}'."
echo_err "[Running under docker]" echo_err "[Running under docker]"
echo_err "change docker-compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\"" echo_err "change compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\""
echo_err "Run the following command to change the directory ownership" echo_err "Run the following command to change the directory ownership"
echo_err "chown -R \"${CH_USER}:${CH_GRP}\" ./config" echo_err "chown -R \"${CH_USER}:${CH_GRP}\" ./config"
echo_err "[Running under podman]" echo_err "[Running under podman]"
echo_err "change docker-compose.yaml user: to user:\"0:0\"" echo_err "change compose.yaml user: to user:\"0:0\""
exit 1 exit 1
fi fi
@ -24,11 +24,11 @@ if [ ! -w "${YTP_DOWNLOAD_PATH}" ]; then
CH_GRP=$(stat -c "%g" "${YTP_DOWNLOAD_PATH}") CH_GRP=$(stat -c "%g" "${YTP_DOWNLOAD_PATH}")
echo_err "ERROR: Unable to write to '${YTP_DOWNLOAD_PATH}' downloads directory. Current user id '${UID}' while directory owner is '${CH_USER}'." echo_err "ERROR: Unable to write to '${YTP_DOWNLOAD_PATH}' downloads directory. Current user id '${UID}' while directory owner is '${CH_USER}'."
echo_err "[Running under docker]" echo_err "[Running under docker]"
echo_err "change docker-compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\"" echo_err "change compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\""
echo_err "Run the following command to change the directory ownership" echo_err "Run the following command to change the directory ownership"
echo_err "chown -R \"${CH_USER}:${CH_GRP}\" ./config" echo_err "chown -R \"${CH_USER}:${CH_GRP}\" ./config"
echo_err "[Running under podman]" echo_err "[Running under podman]"
echo_err "change docker-compose.yaml user: to user:\"0:0\"" echo_err "change compose.yaml user: to user:\"0:0\""
exit 1 exit 1
fi fi

View file

@ -66,6 +66,9 @@
</label> </label>
</td> </td>
<td class="is-text-overflow is-vcentered"> <td class="is-text-overflow is-vcentered">
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes">
<span class="tag">{{ formatBytes(item.downloaded_bytes) }}</span>
</div>
<div v-if="showThumbnails && item.extras?.thumbnail"> <div v-if="showThumbnails && item.extras?.thumbnail">
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" <FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
:title="item.title"> :title="item.title">
@ -180,6 +183,10 @@
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime" <span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime"
v-rtime="item.datetime" /> v-rtime="item.datetime" />
</div> </div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"
v-if="item.downloaded_bytes">
{{ formatBytes(item.downloaded_bytes) }}
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<label class="checkbox is-block"> <label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id" <input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"