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 json
import logging
from collections import OrderedDict
from datetime import UTC, datetime
from email.utils import formatdate
@ -8,7 +9,9 @@ from sqlite3 import Connection
from .config import Config
from .Download import Download
from .ItemDTO import ItemDTO
from .Utils import clean_item
from .Utils import init_class
LOG = logging.getLogger("datastore")
class DataStore:
@ -69,9 +72,9 @@ class DataStore:
for row in cursor:
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)
item: ItemDTO = ItemDTO(**data)
item: ItemDTO = init_class(ItemDTO, data)
item._id = row["id"]
item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp())
items.append((row["id"], item))
@ -147,10 +150,4 @@ class DataStore:
)
def _delete_store_item(self, key: str) -> None:
self.connection.execute(
'DELETE FROM "history" WHERE "type" = ? AND "id" = ?',
(
self.type,
key,
),
)
self.connection.execute('DELETE FROM "history" WHERE "type" = ? AND "id" = ?', (self.type, key))

View file

@ -4,10 +4,10 @@ import logging
import multiprocessing
import os
import re
import shutil
import time
from datetime import UTC, datetime
from email.utils import formatdate
from pathlib import Path
import yt_dlp
@ -16,7 +16,7 @@ from .config import Config
from .Events import EventBus, Events
from .ffprobe import ffprobe
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
@ -137,7 +137,7 @@ class Download:
return
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:
filename = data["info_dict"]["filepath"]
@ -160,8 +160,8 @@ class Download:
config={
"color": "no_color",
"paths": {
"home": self.download_dir,
"temp": self.temp_path,
"home": str(self.download_dir),
"temp": str(self.temp_path),
},
"outtmpl": {
"default": self.template,
@ -189,13 +189,12 @@ class Download:
if self.info.cookies:
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(
f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'."
)
with open(cookie_file, "w") as f:
f.write(self.info.cookies)
params["cookiefile"] = f.name
cookie_file.write_text(self.info.cookies)
params["cookiefile"] = str(cookie_file.as_posix())
load_cookies(cookie_file)
except Exception as e:
@ -280,10 +279,12 @@ class Download:
self.status_queue = Config.get_manager().Queue()
# 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):
os.makedirs(self.temp_path, exist_ok=True)
if not self.temp_path.exists():
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.start()
@ -385,17 +386,19 @@ class Download:
)
return
if not os.path.exists(self.temp_path):
tmp_dir = Path(self.temp_path)
if not tmp_dir.exists():
return
if self.temp_path == self.temp_dir:
if str(tmp_dir) == str(self.temp_dir):
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
self.logger.info(f"Deleting Temp folder '{self.temp_path}'.")
shutil.rmtree(self.temp_path, ignore_errors=True)
status = delete_dir(tmp_dir)
self.logger.info(f"Temp folder '{self.temp_path}' deletion is {'success' if status else 'failed'}.")
async def progress_update(self):
"""
@ -424,11 +427,15 @@ class Download:
self.tmpfilename = status.get("tmpfilename")
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:
self.info.file_size = os.path.getsize(status.get("filename"))
self.info.file_size = fl.stat().st_size
except FileNotFoundError:
self.info.file_size = 0
@ -439,7 +446,8 @@ class Download:
self.info.error = status.get("error")
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")
if total:
try:
@ -451,17 +459,11 @@ class Download:
self.info.speed = status.get("speed")
self.info.eta = status.get("eta")
if (
"finished" == self.info.status
and "filename" in status
and os.path.isfile(status.get("filename"))
and os.path.exists(status.get("filename"))
):
try:
self.info.file_size = os.path.getsize(status.get("filename"))
self.info.datetime = str(formatdate(time.time()))
except FileNotFoundError:
pass
fl = Path(status.get("filename")) if status and "filename" in status else None
if "finished" == self.info.status and fl and fl.is_file() and fl.exists():
self.info.file_size = fl.stat().st_size
self.info.datetime = str(formatdate(time.time()))
try:
ff = await ffprobe(status.get("filename"))
@ -471,7 +473,7 @@ class Download:
self.info.extras["is_video"] = True
self.info.extras["is_audio"] = True
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)

View file

@ -247,4 +247,5 @@ class ItemDTO:
"output_template",
"output_template_chapter",
"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))
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 logging
import os
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from aiohttp import web
@ -13,7 +13,7 @@ from .Events import EventBus, Events
from .Singleton import Singleton
from .Utils import arg_converter
LOG = logging.getLogger("presets")
LOG = logging.getLogger("conditions")
@dataclass(kw_only=True)
@ -51,16 +51,16 @@ class Conditions(metaclass=Singleton):
_instance = None
"""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
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:
os.chmod(self._file, 0o600)
self._file.chmod(0o600)
except Exception:
pass
@ -68,7 +68,7 @@ class Conditions(metaclass=Singleton):
msg = "Not implemented"
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
def get_instance() -> "Conditions":
@ -114,7 +114,7 @@ class Conditions(metaclass=Singleton):
"""
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
LOG.info(f"Loading '{self._file}'.")

View file

@ -2,7 +2,6 @@
import asyncio
import logging
import os
import sqlite3
import sys
from pathlib import Path
@ -34,7 +33,7 @@ class Main:
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.row_factory = sqlite3.Row
@ -46,8 +45,9 @@ class Main:
LOG.debug("Database connection closed.")
try:
if "600" != oct(os.stat(self._config.db_file).st_mode)[-3:]:
os.chmod(self._config.db_file, 0o600)
db_file = Path(self._config.db_file)
if "600" != oct(db_file.stat().st_mode)[-3:]:
db_file.chmod(0o600)
except Exception:
pass
@ -62,23 +62,24 @@ class Main:
folders = (self._config.download_path, self._config.temp_path, self._config.config_path)
for folder in folders:
folder = Path(folder)
try:
LOG.debug(f"Checking folder at '{folder}'.")
if not os.path.exists(folder):
if not folder.exists():
LOG.info(f"Creating folder at '{folder}'.")
os.makedirs(folder, exist_ok=True)
folder.mkdir(parents=True, exist_ok=True)
except OSError:
LOG.error(f"Could not create folder at '{folder}'.")
raise
try:
LOG.debug(f"Checking database file at '{self._config.db_file}'.")
if not os.path.exists(self._config.db_file):
LOG.info(f"Creating database file at '{self._config.db_file}'.")
with open(self._config.db_file, "w") as _:
pass
except OSError:
LOG.error(f"Could not create database file at '{self._config.db_file}'.")
db_file = Path(self._config.db_file)
LOG.debug(f"Checking database file at '{db_file}'.")
if not db_file.exists():
LOG.info(f"Creating database file at '{db_file}'.")
db_file.touch(exist_ok=True)
except OSError as e:
LOG.error(f"Could not create database file at '{self._config.db_file}'. {e!s}")
raise
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}")
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 "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 "chown -R \"${CH_USER}:${CH_GRP}\" ./config"
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
fi
@ -24,11 +24,11 @@ if [ ! -w "${YTP_DOWNLOAD_PATH}" ]; then
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 "[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 "chown -R \"${CH_USER}:${CH_GRP}\" ./config"
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
fi

View file

@ -66,6 +66,9 @@
</label>
</td>
<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">
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
: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"
v-rtime="item.datetime" />
</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">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"