ytptube/app/library/conditions.py
2025-06-22 18:03:12 +03:00

340 lines
8.8 KiB
Python

import json
import logging
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from aiohttp import web
from .config import Config
from .encoder import Encoder
from .Events import EventBus, Events
from .Singleton import Singleton
from .Utils import arg_converter, init_class
LOG = logging.getLogger("conditions")
@dataclass(kw_only=True)
class Condition:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
"""The id of the condition."""
name: str
"""The name of the condition."""
filter: str
"""The filter to run on info dict."""
cli: str
"""If matched append this to the download request and retry."""
def serialize(self) -> dict:
return self.__dict__
def json(self) -> str:
return Encoder().encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any:
return self.serialize().get(key, default)
class Conditions(metaclass=Singleton):
"""
This class is used to manage the download conditions.
"""
_items: list[Condition] = []
"""The list of items."""
_instance = None
"""The instance of the class."""
def __init__(self, file: Path | str | None = None, config: Config | None = None):
Conditions._instance = self
config = config or Config.get_instance()
self._file: Path = Path(file) if file else Path(config.config_path) / "conditions.json"
if self._file.exists() and "600" != self._file.stat().st_mode:
try:
self._file.chmod(0o600)
except Exception:
pass
def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.save")
@staticmethod
def get_instance() -> "Conditions":
"""
Get the instance of the class.
Returns:
Conditions: The instance of the class
"""
if not Conditions._instance:
Conditions._instance = Conditions()
return Conditions._instance
async def on_shutdown(self, _: web.Application):
pass
def attach(self, _: web.Application):
"""
Attach the work to the aiohttp application.
Args:
_ (web.Application): The aiohttp application.
Returns:
None
"""
self.load()
def get_all(self) -> list[Condition]:
"""Return the items."""
return self._items
def load(self) -> "Conditions":
"""
Load the items.
Returns:
Conditions: The current instance.
"""
self.clear()
if not self._file.exists() or self._file.stat().st_size < 1:
return self
try:
LOG.info(f"Loading '{self._file}'.")
items = json.loads(self._file.read_text())
except Exception as e:
LOG.exception(e)
LOG.error(f"Error loading '{self._file}'. '{e}'.")
return self
if not items or len(items) < 1:
LOG.debug(f"No items were found in '{self._file}'.")
return self
need_save = False
for i, item in enumerate(items):
try:
if "id" not in item:
item["id"] = str(uuid.uuid4())
need_save = True
item: Condition = init_class(Condition, item)
self._items.append(item)
except Exception as e:
LOG.error(f"Failed to parse condition at list position '{i}'. '{e!s}'.")
continue
if need_save:
LOG.info("Saving conditions due to format, or id change.")
self.save(self._items)
return self
def clear(self) -> "Conditions":
"""
Clear all items
Returns:
conditions: The current instance.
"""
if len(self._items) < 1:
return self
self._items.clear()
return self
def validate(self, item: Condition | dict) -> bool:
"""
Validate item.
Args:
item (Condition|dict): The item to validate.
Returns:
bool: True if valid, False otherwise.
"""
from yt_dlp.utils import match_str
if not isinstance(item, dict):
if not isinstance(item, Condition):
msg = f"Unexpected '{type(item).__name__}' item type."
raise ValueError(msg) # noqa: TRY004
item = item.serialize()
if not item.get("id"):
msg = "No id found."
raise ValueError(msg)
if not item.get("name"):
msg = "No name found."
raise ValueError(msg)
if not item.get("filter"):
msg = "No filter found."
raise ValueError(msg)
try:
match_str(item.get("filter"), {})
except Exception as e:
msg = f"Invalid filter. '{e!s}'."
raise ValueError(msg) from e
if not item.get("cli"):
msg = "No command options for yt-dlp were found."
raise ValueError(msg)
try:
arg_converter(args=item.get("cli"))
except Exception as e:
msg = f"Invalid command options for yt-dlp. '{e!s}'."
raise ValueError(msg) from e
return True
def save(self, items: list[Condition | dict]) -> "Conditions":
"""
Save the items.
Args:
items (list[Condition]): The items to save.
Returns:
Conditions: The current instance.
"""
for i, item in enumerate(items):
try:
if not isinstance(item, Condition):
item: Condition = init_class(Condition, item)
items[i] = item
except Exception as e:
LOG.error(f"Failed to save '{i}' due to parsing error. '{e!s}'.")
continue
try:
self.validate(item)
except ValueError as e:
LOG.error(f"Failed to validate '{i}: {item.name}'. '{e!s}'.")
continue
try:
self._file.write_text(json.dumps(obj=[item.serialize() for item in items], indent=4))
LOG.info(f"Updated '{self._file}'.")
except Exception as e:
LOG.error(f"Error saving '{self._file}'. '{e!s}'.")
return self
def has(self, id_or_name: str) -> bool:
"""
Check if the item exists by id or name.
Args:
id_or_name (str): The id or name of the preset.
Returns:
bool: True if the item exists, False otherwise.
"""
return self.get(id_or_name) is not None
def get(self, id_or_name: str) -> Condition | None:
"""
Get the item by id or name.
Args:
id_or_name (str): The id or name of the preset.
Returns:
Condition|None: The item if found, None otherwise.
"""
if not id_or_name:
return None
for condition in self.get_all():
if id_or_name not in (condition.id, condition.name):
continue
return condition
return None
def match(self, info: dict) -> Condition | None:
"""
Check if any condition matches the info dict.
Args:
info (dict): The info dict to check.
Returns:
Condition|None: The condition if found, None otherwise.
"""
if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1:
return None
from yt_dlp.utils import match_str
for item in self.get_all():
if not item.filter:
LOG.error(f"Filter is empty for '{item.name}'.")
continue
try:
if match_str(item.filter, info):
LOG.debug(f"Matched '{item.name}' with filter '{item.filter}'.")
return item
except Exception as e:
LOG.error(f"Failed to evaluate '{item.name}'. '{e!s}'.")
continue
return None
def single_match(self, name: str, info: dict) -> Condition | None:
"""
Check if condition matches the info dict.
Args:
name (str): The condition name to check.
info (dict): The info dict to check.
Returns:
Condition|None: The condition if found, None otherwise.
"""
if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1:
return None
item = self.get(name)
if not item or not item.filter:
return None
from yt_dlp.utils import match_str
return item if match_str(item.filter, info) else None