Merge pull request #417 from arabcoders/dev

Remove deprecated features and flags
This commit is contained in:
Abdulmohsen 2025-09-12 22:59:26 +03:00 committed by GitHub
commit 2323739c49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 4159 additions and 853 deletions

View file

@ -53,6 +53,7 @@
"Errno",
"esac",
"euuo",
"eventbus",
"excepthook",
"faststart",
"Fetc",
@ -132,6 +133,7 @@
"SUPPRESSHELP",
"tebibytes",
"testpaths",
"threadsafe",
"tiktok",
"timespec",
"tmpfilename",
@ -169,5 +171,10 @@
"**/.venv": true,
},
"eslint.format.enable": true,
"eslint.ignoreUntitled": true
"eslint.ignoreUntitled": true,
"python.testing.pytestArgs": [
"app/tests"
],
"python.testing.unittestEnabled": true,
"python.testing.pytestEnabled": true
}

View file

@ -26,7 +26,7 @@ live streams, and includes features like scheduling downloads, sending notificat
* Supports `curl-cffi`. See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation)
* Bundled `pot provider plugin`. See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide)
* Automatic updates for `yt-dlp` and custom `pip` packages.
* Conditions feature.
* Conditions feature to apply custom options based on `yt-dlp` returned info.
* Custom browser extensions, bookmarklets and iOS shortcuts to send links to YTPTube instance.
* A bundled executable version for Windows, macOS and Linux. `For non-docker users`.

View file

@ -40,19 +40,15 @@ class Archiver(metaclass=ThreadSafe):
"""
def __init__(self) -> None:
if getattr(self, "_initialized", False):
return
self._cache: dict[str, _Entry] = {}
self._locks: dict[str, threading.RLock] = {}
self._global_lock = threading.RLock()
self._stats_check: bool = True
self._stats_ttl: float = 0.2
self._initialized = True
@classmethod
def get_instance(cls) -> "Archiver":
return cls()
@staticmethod
def get_instance() -> "Archiver":
return Archiver()
def _key(self, file: str | Path) -> str:
"""

View file

@ -22,15 +22,17 @@ class BackgroundWorker(metaclass=Singleton):
It is designed to run in a separate thread and uses asyncio to handle asynchronous tasks.
"""
_instance = None
"""The instance of the Notification class."""
thread: threading.Thread
"""The thread that runs the background worker."""
def __init__(self):
self.queue = Queue()
self.queue: Queue = Queue()
"The queue to hold the tasks."
self.running = True
"Whether the background worker is running or not."
self.thread: threading.Thread = None
"The thread that runs the background worker."
@staticmethod
def get_instance() -> "BackgroundWorker":
return BackgroundWorker()
def attach(self, app: web.Application):
app.on_shutdown.append(self.on_shutdown)
@ -49,13 +51,6 @@ class BackgroundWorker(metaclass=Singleton):
except Exception as e:
LOG.error(f"Failed to shut down background worker: {e}")
@staticmethod
def get_instance() -> "BackgroundWorker":
if BackgroundWorker._instance is None:
BackgroundWorker._instance = BackgroundWorker()
return BackgroundWorker._instance
def _run(self):
asyncio.set_event_loop(asyncio.new_event_loop())
loop = asyncio.get_event_loop()

View file

@ -11,12 +11,12 @@ from .Download import Download
from .ItemDTO import ItemDTO
from .Utils import init_class
LOG = logging.getLogger("datastore")
LOG: logging.Logger = logging.getLogger("datastore")
class StoreType(str, Enum):
HISTORY = "done"
QUEUE = "queue"
HISTORY: str = "done"
QUEUE: str = "queue"
@classmethod
def all(cls) -> list[str]:
@ -53,19 +53,13 @@ class DataStore:
Persistent queue.
"""
_type: StoreType = None
"""Type of the store, e.g., DONE, QUEUE, PENDING."""
_dict: OrderedDict[str, Download] = None
"""Ordered dictionary to store Download objects."""
_connection: Connection
"""SQLite connection to the database."""
def __init__(self, type: StoreType, connection: Connection):
self._dict = OrderedDict()
self._type = type
self._connection = connection
self._dict: OrderedDict[str, Download] = OrderedDict()
"The dictionary of items."
self._type: StoreType = type
"The type of the datastore."
self._connection: Connection = connection
"The database connection."
def load(self) -> None:
for id, item in self.saved_items():
@ -136,7 +130,7 @@ class DataStore:
)
for row in cursor:
rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
rowDate: datetime = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data: dict = json.loads(row["data"])
data.pop("_id", None)
item: ItemDTO = init_class(ItemDTO, data)

View file

@ -10,6 +10,7 @@ from copy import deepcopy
from datetime import UTC, datetime
from email.utils import formatdate
from pathlib import Path
from typing import Any
import yt_dlp.utils
@ -47,22 +48,8 @@ class Download:
Download task.
"""
id: str = None
download_dir: str = None
temp_dir: str = None
temp_disabled: bool = False
"Disable the temporary files feature."
template: str = None
template_chapter: str = None
info: ItemDTO = None
debug: bool = False
temp_path: str = None
cancelled: bool = False
is_live: bool = False
info_dict: dict = None
"yt-dlp metadata dict."
update_task = None
cancel_in_progress: bool = False
final_update = False
@ -86,12 +73,6 @@ class Download:
)
"Fields to be extracted from yt-dlp progress hook."
temp_keep: bool = False
"Keep temp folder after download."
logs: list = []
"Logs from yt-dlp."
def __init__(self, info: ItemDTO, info_dict: dict = None, logs: list | None = None):
"""
Initialize download task.
@ -104,31 +85,54 @@ class Download:
"""
config: Config = Config.get_instance()
self.download_dir = info.download_dir
self.temp_dir = info.temp_dir
self.template = info.template
self.template_chapter = info.template_chapter
self.download_dir: str = info.download_dir
"Download directory."
self.temp_dir: str | None = info.temp_dir
"Temporary directory."
self.template: str | None = info.template
"Filename template."
self.template_chapter: str | None = info.template_chapter
"Chapter filename template."
self.download_info_expires = int(config.download_info_expires)
self.info = info
self.id = info._id
"Time in seconds before the download info is considered expired."
self.info: ItemDTO = info
"ItemDTO object."
self.id: str = info._id
"Download ID."
self.debug = bool(config.debug)
"Debug mode."
self.debug_ytdl = bool(config.ytdlp_debug)
"Debug mode for yt-dlp."
self.cancelled = False
"Download cancelled."
self.tmpfilename = None
"Temporary filename."
self.status_queue = None
"Status queue."
self.proc = None
self._notify = EventBus.get_instance()
"yt-dlp process."
self._notify: EventBus = EventBus.get_instance()
"Event bus instance."
self.max_workers = int(config.max_workers)
"Maximum number of concurrent downloads."
self.temp_keep = bool(config.temp_keep)
"Keep temp folder after download."
self.temp_disabled = bool(config.temp_disabled)
self.is_live = bool(info.is_live) or info.live_in is not None
self.info_dict = info_dict
"Disable the temporary files feature."
self.is_live: bool = bool(info.is_live) or info.live_in is not None
"Is the download a live stream."
self.info_dict: dict = info_dict
"yt-dlp metadata dict."
self.logger: logging.Logger = logging.getLogger(f"Download.{info.id if info.id else info._id}")
"Logger for the download task."
self.started_time = 0
"Time when the download started."
self.queue_time: datetime = datetime.now(tz=UTC)
self.logs = logs if logs else []
"Time when the download was queued."
self.logs: list[str] = logs if logs else []
"Logs from yt-dlp."
def _progress_hook(self, data: dict):
def _progress_hook(self, data: dict) -> None:
if self.debug:
d_copy: dict = deepcopy(data)
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
@ -144,7 +148,7 @@ class Download:
}
)
def _postprocessor_hook(self, data: dict):
def _postprocessor_hook(self, data: dict) -> None:
if self.debug:
d_copy: dict = deepcopy(data)
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
@ -156,7 +160,7 @@ class Download:
if "__finaldir" in data.get("info_dict", {}) and "filepath" in data.get("info_dict", {}):
filename = str(Path(data["info_dict"]["__finaldir"]) / Path(data["info_dict"]["filepath"]).name)
else:
filename = data.get("info_dict", {}).get("filepath", data.get("filename"))
filename: str | None = data.get("info_dict", {}).get("filepath", data.get("filename"))
self.logger.debug(f"Final filename: '{filename}'.")
self.status_queue.put({"id": self.id, "action": "moved", "status": "finished", "final_name": filename})
@ -165,13 +169,13 @@ class Download:
dataDict = {k: v for k, v in data.items() if k in self._ytdlp_fields}
self.status_queue.put({"id": self.id, "action": "postprocessing", **dataDict, "status": "postprocessing"})
def post_hooks(self, filename: str | None = None):
def post_hooks(self, filename: str | None = None) -> None:
if not filename:
return
self.status_queue.put({"id": self.id, "filename": filename})
def _download(self):
def _download(self) -> None:
if not self._notify:
self._notify = EventBus.get_instance()
@ -224,11 +228,11 @@ class Download:
load_cookies(cookie_file)
except Exception as e:
err_msg = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'."
err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'."
self.logger.error(err_msg)
raise ValueError(err_msg) from e
# Safe-guard incase downloading take too long and the info expires.
# Safe-guard in-case downloading take too long and the info expires.
if self.info_dict and isinstance(self.info_dict, dict) and self.download_info_expires > 0:
_ts: int | None = self.info_dict.get("epoch", self.info_dict.get("timestamp", None))
_ts = datetime.fromtimestamp(_ts, tz=UTC) if _ts else None
@ -239,14 +243,14 @@ class Download:
if not self.info_dict or not isinstance(self.info_dict, dict):
self.logger.info(f"Extracting info for '{self.info.url}'.")
self.logs = []
ie_params = params.copy()
ie_params: dict = params.copy()
ie_params["callback"] = {
"func": lambda _, msg: self.logs.append(msg),
"level": logging.WARNING,
"name": "callback-logger",
}
info = extract_info(
info: dict = extract_info(
config=params,
url=self.info.url,
debug=self.debug,
@ -334,8 +338,8 @@ class Download:
f'Task {self.info.name()} preset="{self.info.preset}" cookies="{bool(params.get("cookiefile"))}" completed.'
)
async def start(self):
self.status_queue = Config.get_manager().Queue()
async def start(self) -> None:
self.status_queue: multiprocessing.Queue[Any] = Config.get_manager().Queue()
# Create temp dir for each download.
if not self.temp_disabled:
@ -476,7 +480,7 @@ class Download:
return False
def delete_temp(self, by_pass: bool = False):
def delete_temp(self, by_pass: bool = False) -> None:
if self.temp_disabled or self.temp_keep is True or not self.temp_path:
return
@ -486,9 +490,7 @@ class Download:
and self.info.downloaded_bytes
and self.info.downloaded_bytes > 0
):
self.logger.warning(
f"Keeping temp folder '{self.temp_path}', as the reported status is not finished '{self.info.status}'."
)
self.logger.warning(f"Keeping temp folder '{self.temp_path}'. {self.info.status=}.")
return
tmp_dir = Path(self.temp_path)
@ -507,7 +509,7 @@ class Download:
else:
self.logger.info(f"Temp folder '{self.temp_path}' deletion is {'success' if status else 'failed'}.")
async def _process_status_update(self, status):
async def _process_status_update(self, status) -> None:
if status.get("id") != self.id or len(status) < 2:
self.logger.warning(f"Received invalid status update. {status}")
return
@ -586,7 +588,7 @@ class Download:
if not self.final_update or fl:
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
async def progress_update(self):
async def progress_update(self) -> None:
"""
Update status of download task and notify the client.
"""
@ -637,11 +639,11 @@ class Download:
return False
def __getstate__(self):
state = self.__dict__.copy()
def __getstate__(self) -> dict[str, Any]:
state: dict[str, Any] = self.__dict__.copy()
# Exclude (unpickleable) keys during pickling, this issue arise mostly on Windows.
excluded_keys = ("_notify",)
excluded_keys: tuple[str] = ("_notify",)
for key in excluded_keys:
if key in state:
state[key] = None

View file

@ -47,44 +47,29 @@ class DownloadQueue(metaclass=Singleton):
DownloadQueue class is a singleton class that manages the download queue and the download history.
"""
paused: asyncio.Event
"""Event to pause the download queue."""
event: asyncio.Event
"""Event to signal the download queue to start downloading."""
_active: dict[str, Download] = {}
"""Dictionary of active downloads."""
_instance = None
"""Instance of the DownloadQueue."""
queue: DataStore
"""DataStore for the download queue."""
done: DataStore
"""DataStore for the completed downloads."""
workers: asyncio.Semaphore
"""Semaphore to limit the number of concurrent downloads."""
processors: asyncio.Semaphore
"""Semaphore to limit the number of concurrent processors."""
def __init__(self, connection: Connection, config: Config | None = None):
DownloadQueue._instance = self
self.config = config or Config.get_instance()
self._notify = EventBus.get_instance()
self.config: Config = config or Config.get_instance()
"Configuration instance."
self._notify: EventBus = EventBus.get_instance()
"Event bus instance."
self.done = DataStore(type=StoreType.HISTORY, connection=connection)
"DataStore for the completed downloads."
self.queue = DataStore(type=StoreType.QUEUE, connection=connection)
"DataStore for the download queue."
self.workers = asyncio.Semaphore(self.config.max_workers)
"Semaphore to limit the number of concurrent downloads."
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
"Semaphore to limit the number of concurrent processors."
self.paused = asyncio.Event()
"Event to pause the download queue."
self.event = asyncio.Event()
"Event to signal the download queue to start downloading."
self._active: dict[str, Download] = {}
"""Dictionary of active downloads."""
self.done.load()
self.queue.load()
self.paused = asyncio.Event()
self.paused.set()
self.event = asyncio.Event()
self.workers = asyncio.Semaphore(self.config.max_workers)
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
@staticmethod
def get_instance() -> "DownloadQueue":
@ -95,10 +80,7 @@ class DownloadQueue(metaclass=Singleton):
DownloadQueue: The instance of the DownloadQueue
"""
if not DownloadQueue._instance:
DownloadQueue._instance = DownloadQueue()
return DownloadQueue._instance
return DownloadQueue()
def attach(self, _: web.Application) -> None:
"""
@ -639,7 +621,7 @@ class DownloadQueue(metaclass=Singleton):
item.template = _preset.template
yt_conf = {}
cookie_file = Path(self.config.temp_path) / f"c_{uuid.uuid4().hex}.txt"
cookie_file: Path = Path(self.config.temp_path) / f"c_{uuid.uuid4().hex}.txt"
LOG.info(f"Adding '{item.__repr__()}'.")
@ -780,16 +762,12 @@ class DownloadQueue(metaclass=Singleton):
self.done.put(dlInfo)
LOG.info(log_message)
await asyncio.gather(
*[
self._notify.emit(Events.LOG_INFO, data={}, title="Ignored Download", message=log_message),
self._notify.emit(
Events.ITEM_MOVED,
data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info},
title="Download History Update",
message=f"Download history updated with '{item.url}'.",
),
]
self._notify.emit(Events.LOG_INFO, data={}, title="Ignored Download", message=log_message)
self._notify.emit(
Events.ITEM_MOVED,
data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info},
title="Download History Update",
message=f"Download history updated with '{item.url}'.",
)
return {"status": "ok"}
@ -1209,6 +1187,5 @@ class DownloadQueue(metaclass=Singleton):
return
if exc := task.exception():
task_name: str = task.get_name() if task.get_name() else "unknown_task"
LOG.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {traceback.format_exc()}")

View file

@ -194,14 +194,13 @@ class Event:
class EventListener:
name: str
is_coroutine: bool = False
call_back: callable
def __init__(self, name: str, callback: callable):
self.name = name
self.call_back = callback
self.is_coroutine = asyncio.iscoroutinefunction(callback)
self.name: str = name
"The name of the listener."
self.call_back: callable = callback
"The callback function to call when the event is emitted."
self.is_coroutine: bool = asyncio.iscoroutinefunction(callback)
"Whether the callback is a coroutine function or not."
async def handle(self, event: Event, **kwargs):
if self.is_coroutine:
@ -215,20 +214,15 @@ class EventBus(metaclass=Singleton):
This class is used to subscribe to and emit events to the registered listeners.
"""
_instance = None
"""the instance of the EventsSubscriber"""
_listeners: dict[str, list[str, EventListener]] = {}
"""The listeners for the events."""
debug: bool = False
"""Whether to log debug messages or not."""
_offload: BackgroundWorker = None
"""The background worker to offload tasks to."""
def __init__(self):
EventBus._instance = self
self._listeners: dict[str, list[str, EventListener]] = {}
"The listeners for the events."
self.debug: bool = False
"Whether to log debug messages or not."
self._offload: BackgroundWorker = None
"The background worker to offload tasks to."
@staticmethod
def get_instance() -> "EventBus":
@ -239,10 +233,7 @@ class EventBus(metaclass=Singleton):
EventsSubscriber: The instance of the EventsSubscriber
"""
if not EventBus._instance:
EventBus._instance = EventBus()
return EventBus._instance
return EventBus()
def subscribe(self, event: str | list | tuple, callback: Awaitable, name: str | None = None) -> "EventBus":
"""

View file

@ -57,7 +57,7 @@ class HttpSocket:
async def event_handler(e: Event, _, **kwargs):
await self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs)
services = Services.get_instance()
services: Services = Services.get_instance()
services.add_all(
{
k: v

View file

@ -59,11 +59,9 @@ class LogWrapper:
"""
targets: list[LogTarget] = []
"""A list of dictionaries where each dictionary represents a logging target with its level and type."""
def __init__(self):
self.targets: list[LogTarget] = []
"""A list of dictionaries where each dictionary represents a logging target with its level and type."""
def add_target(self, target: logging.Logger | Callable, level: int = logging.DEBUG, name: str | None = None):
"""

View file

@ -130,12 +130,6 @@ class NotificationEvents:
class Notification(metaclass=Singleton):
_targets: list[Target] = []
"""Notification targets to send events to."""
_instance = None
"""The instance of the Notification class."""
def __init__(
self,
file: str | None = None,
@ -144,15 +138,23 @@ class Notification(metaclass=Singleton):
config: Config | None = None,
background_worker: BackgroundWorker | None = None,
):
Notification._instance = self
self._targets: list[Target] = []
"Notification targets to send events to."
config: Config = config or Config.get_instance()
self._debug: bool = config.debug
"Debug mode."
self._file: Path = Path(file) if file else Path(config.config_path).joinpath("notifications.json")
"File to store notification targets."
self._client: httpx.AsyncClient = client or httpx.AsyncClient()
"HTTP client to send requests."
self._encoder: Encoder = encoder or Encoder()
"Encoder to encode data."
self._version: str = config.app_version
"Application version."
self._offload: BackgroundWorker = background_worker or BackgroundWorker.get_instance()
"Background worker to offload tasks to."
if self._file.exists() and "600" != self._file.stat().st_mode:
try:
@ -161,11 +163,19 @@ class Notification(metaclass=Singleton):
pass
@staticmethod
def get_instance() -> "Notification":
if Notification._instance is None:
Notification._instance = Notification()
return Notification._instance
def get_instance(
file: str | None = None,
client: httpx.AsyncClient | None = None,
encoder: Encoder | None = None,
config: Config | None = None,
background_worker: BackgroundWorker | None = None,
) -> "Notification":
"""
Get the instance of the class.
"""
return Notification(
file=file, client=client, encoder=encoder, config=config, background_worker=background_worker
)
def attach(self, _: web.Application):
"""

View file

@ -37,9 +37,10 @@ class Packages:
class PackageInstaller:
user_site: Path | None = None
def __init__(self, pkg_path: Path | None = None):
self.user_site: Path | None = None
"Where to install user packages."
if pkg_path:
self.user_site = pkg_path

View file

@ -6,11 +6,11 @@ from .Utils import StreamingError, get_file_sidecar
class Playlist:
_url: str = None
def __init__(self, download_path: Path, url: str):
self.url: str = url
"The base URL for the playlist."
self.download_path: Path = download_path
"The path where files are downloaded."
async def make(self, file: Path) -> str:
ref: str = Path(str(file.relative_to(self.download_path)).strip("/"))

View file

@ -105,22 +105,20 @@ class Presets(metaclass=Singleton):
This class is used to manage the presets.
"""
_items: list[Preset] = []
"""The list of presets."""
_instance = None
"""The instance of the class."""
_config: Config = None
_default: list[Preset] = []
def __init__(self, file: str | Path | None = None, config: Config | None = None):
Presets._instance = self
self._items: list[Preset] = []
"The list of presets."
self._config: Config = None
"The config instance."
self._default: list[Preset] = []
"The list of default presets."
self._config = config or Config.get_instance()
self._file: Path = Path(file) if file else Path(self._config.config_path).joinpath("presets.json")
"The path to the presets file."
if self._file.exists() and "600" != self._file.stat().st_mode:
try:
@ -136,25 +134,20 @@ class Presets(metaclass=Singleton):
LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.")
continue
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.PRESETS_ADD, event_handler, f"{__class__.__name__}.add")
@staticmethod
def get_instance() -> "Presets":
def get_instance(file: str | Path | None = None, config: Config | None = None) -> "Presets":
"""
Get the instance of the class.
Args:
file (str|Path|None): The path to the presets file.
config (Config|None): The config instance.
Returns:
Presets: The instance of the class
"""
if not Presets._instance:
Presets._instance = Presets()
return Presets._instance
return Presets(file=file, config=config)
async def on_shutdown(self, _: web.Application):
pass
@ -176,6 +169,12 @@ class Presets(metaclass=Singleton):
LOG.error(f"Default preset '{self._config.default_preset}' not found, using 'default' preset.")
self._config.default_preset = "default"
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.PRESETS_ADD, event_handler, f"{__class__.__name__}.add")
def get_all(self) -> list[Preset]:
"""Return the items."""
return self._default + self._items

View file

@ -15,25 +15,15 @@ class Scheduler(metaclass=Singleton):
This class is used to manage the schedule.
"""
_jobs: dict[str, Cron] = {}
"""The scheduled jobs."""
_instance = None
"""The instance of the class."""
def __init__(self, loop: asyncio.AbstractEventLoop | None = None):
Scheduler._instance = self
self._jobs: dict[str, Cron] = {}
"The scheduled jobs."
self._loop = loop or asyncio.get_event_loop()
async def event_handler(data, _):
if data and data.data:
self.add(**data.data)
EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{__class__.__name__}.add")
"The event loop to use."
@staticmethod
def get_instance() -> "Scheduler":
def get_instance(loop: asyncio.AbstractEventLoop | None = None) -> "Scheduler":
"""
Get the instance of the class.
@ -41,9 +31,7 @@ class Scheduler(metaclass=Singleton):
Scheduler: The instance of the class
"""
if not Scheduler._instance:
Scheduler._instance = Scheduler()
return Scheduler._instance
return Scheduler(loop=loop)
async def on_shutdown(self, _: web.Application):
"""
@ -70,6 +58,12 @@ class Scheduler(metaclass=Singleton):
def attach(self, app: web.Application):
app.on_shutdown.append(self.on_shutdown)
async def event_handler(data, _):
if data and data.data:
self.add(**data.data)
EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{__class__.__name__}.add")
def get_all(self) -> dict[str, Cron]:
"""Return the jobs."""
return self._jobs

View file

@ -11,22 +11,32 @@ from aiohttp import web
from .config import Config
from .ffprobe import ffprobe
LOG = logging.getLogger("player.segments")
LOG: logging.Logger = logging.getLogger("player.segments")
class Segments:
def __init__(self, download_path: str, index: int, duration: float, vconvert: bool, aconvert: bool):
config = Config.get_instance()
self.download_path = download_path
config: Config = Config.get_instance()
self.download_path: str = download_path
"The path where files are downloaded."
self.index = int(index)
"The index of the segment."
self.duration = float(duration)
"The duration of the segment."
self.vconvert = bool(vconvert)
"Whether to convert video."
self.aconvert = bool(aconvert)
self.vcodec = config.streamer_vcodec
self.acodec = config.streamer_acodec
"Whether to convert audio."
self.vcodec: str = config.streamer_vcodec
"The video codec to use."
self.acodec: str = config.streamer_acodec
"The audio codec to use."
# sadly due to unforeseen circumstances, we have to convert the video for now.
self.vconvert = True
"Whether to convert video."
self.aconvert = True
"Whether to convert audio."
async def build_ffmpeg_args(self, file: Path) -> list[str]:
try:

View file

@ -9,42 +9,37 @@ LOG: logging.Logger = logging.getLogger(__name__)
class Services(metaclass=Singleton):
_dct: dict[str, T] = {}
_instance = None
"""The instance of the class."""
def __init__(self):
self._services: dict[str, T] = {}
@staticmethod
def get_instance() -> "Services":
if Services._instance is None:
Services._instance = Services()
return Services._instance
return Services()
def add(self, name: str, service: T):
self._dct[name] = service
self._services[name] = service
def add_all(self, services: dict[str, T]):
for name, service in services.items():
self.add(name, service)
def get(self, name: str) -> T | None:
return self._dct.get(name)
return self._services.get(name)
def has(self, name: str) -> bool:
return name in self._dct
return name in self._services
def remove(self, name: str):
if name not in self._dct:
if name not in self._services:
return
self._dct.pop(name, None)
self._services.pop(name, None)
def clear(self):
self._dct.clear()
self._services.clear()
def get_all(self) -> dict[str, T]:
return self._dct.copy()
return self._services.copy()
async def handle_async(self, handler: callable, **kwargs) -> Any:
context = {**self.get_all(), **kwargs}

View file

@ -8,6 +8,7 @@ class Singleton(type):
"""
_instances: dict[type, Any] = {}
"The singleton instances."
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
if cls not in cls._instances:
@ -15,6 +16,14 @@ class Singleton(type):
cls._instances[cls] = instance
return cls._instances[cls]
def _reset_singleton(cls) -> None:
"""
Clear the singleton instance for the class.
Useful for testing purposes.
"""
if cls in cls._instances:
del cls._instances[cls]
class ThreadSafe(type):
"""
@ -22,7 +31,10 @@ class ThreadSafe(type):
"""
_instances: dict[type, Any] = {}
"The singleton instances."
_lock = threading.Lock()
"A lock to ensure thread-safe singleton creation."
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
with cls._lock:
@ -30,3 +42,12 @@ class ThreadSafe(type):
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
def _reset_singleton(cls) -> None:
"""
Clear the singleton instance for the class.
Useful for testing purposes.
"""
with cls._lock:
if cls in cls._instances:
del cls._instances[cls]

View file

@ -132,12 +132,6 @@ class Tasks(metaclass=Singleton):
This class is used to manage the tasks.
"""
_tasks: list[Task] = []
"""The tasks."""
_instance = None
"""The instance of the Tasks."""
def __init__(
self,
file: str | None = None,
@ -146,19 +140,28 @@ class Tasks(metaclass=Singleton):
encoder: Encoder | None = None,
scheduler: Scheduler | None = None,
):
Tasks._instance = self
self._tasks: list[Task] = []
"The tasks."
config = config or Config.get_instance()
self._debug: bool = config.debug
"Debug mode."
self._default_preset: str = config.default_preset
"The default preset."
self._file: Path = Path(file) if file else Path(config.config_path).joinpath("tasks.json")
"The tasks file."
self._encoder: Encoder = encoder or Encoder()
"The JSON encoder."
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
"The event loop."
self._scheduler: Scheduler = scheduler or Scheduler.get_instance()
"The scheduler."
self._notify: EventBus = EventBus.get_instance()
"The event bus."
self._task_handler = HandleTask(self._scheduler, self, config)
"The task handler."
self._downloadQueue = DownloadQueue.get_instance()
"The download queue."
if self._file.exists() and "600" != self._file.stat().st_mode:
try:
@ -167,7 +170,13 @@ class Tasks(metaclass=Singleton):
pass
@staticmethod
def get_instance() -> "Tasks":
def get_instance(
file: str | None = None,
loop: asyncio.AbstractEventLoop | None = None,
config: Config | None = None,
encoder: Encoder | None = None,
scheduler: Scheduler | None = None,
) -> "Tasks":
"""
Get the instance of the class.
@ -175,10 +184,7 @@ class Tasks(metaclass=Singleton):
Tasks: The instance of the class.
"""
if not Tasks._instance:
Tasks._instance = Tasks()
return Tasks._instance
return Tasks(file=file, loop=loop, config=config, encoder=encoder, scheduler=scheduler)
async def on_shutdown(self, _: web.Application):
self.clear(shutdown=True)
@ -431,23 +437,18 @@ class Tasks(metaclass=Singleton):
timeNow = datetime.now(UTC).isoformat()
ended: float = time.time()
LOG.info(f"Task '{task.name}' completed at '{timeNow}' took '{ended - started:.2f}' seconds.")
_tasks = [
self._notify.emit(
Events.TASK_DISPATCHED,
data={**status, "preset": task.preset} if status else {"preset": task.preset},
title=f"Task '{task.name}' dispatched",
message=f"Task '{task.name}' dispatched at '{timeNow}'.",
),
self._notify.emit(
Events.LOG_SUCCESS,
data={"preset": task.preset, "lowPriority": True},
title="Task completed",
message=f"Task '{task.name}' completed in '{ended - started:.2f}'.",
),
]
await asyncio.gather(*_tasks)
self._notify.emit(
Events.TASK_DISPATCHED,
data={**status, "preset": task.preset} if status else {"preset": task.preset},
title=f"Task '{task.name}' dispatched",
message=f"Task '{task.name}' dispatched at '{timeNow}'.",
)
self._notify.emit(
Events.LOG_SUCCESS,
data={"preset": task.preset, "lowPriority": True},
title="Task completed",
message=f"Task '{task.name}' completed in '{ended - started:.2f}'.",
)
except Exception as e:
LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
self._notify.emit(
@ -459,17 +460,17 @@ class Tasks(metaclass=Singleton):
class HandleTask:
_tasks: Tasks
_handlers: list[type]
_scheduler: Scheduler
_config: Config
_task_name: str
def __init__(self, scheduler: Scheduler, tasks: Tasks, config: Config) -> None:
self._tasks = tasks
self._scheduler = scheduler
self._config = config
self._handlers: list[type] = []
"The available handlers."
self._tasks: Tasks = tasks
"The tasks manager."
self._scheduler: Scheduler = scheduler
"The scheduler."
self._config: Config = config
"The configuration."
self._task_name: str = f"{__class__.__name__}._dispatcher"
"The task name for the scheduler."
def load(self) -> None:
self._handlers: list[type] = self._discover()
@ -501,24 +502,36 @@ class HandleTask:
self._scheduler.remove(self._task_name)
def _dispatcher(self):
s: dict[list[str]] = {"h": [], "d": [], "u": [], "f": []}
for task in self._tasks.get_all():
if not task.handler_enabled:
s["d"].append(task.name)
continue
if not task.get_ytdlp_opts().get_all().get("download_archive"):
LOG.debug(f"Task '{task.name}' does not have an archive file configured.")
s["f"].append(task.name)
continue
try:
handler = self._find_handler(task)
if handler is None:
s["u"].append(task.name)
continue
coro = self.dispatch(task, handler=handler)
t = asyncio.create_task(coro, name=f"task-{task.id}")
t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t))
s["h"].append(task.name)
except Exception as e:
LOG.error(f"Failed to handle task '{task.name}'. '{e!s}'.")
s["f"].append(task.name)
if len(self._tasks.get_all()) > 0:
LOG.info(
f"Task Handler summary: Handled: {len(s['h'])}, Unhandled: {len(s['u'])}, Disabled: {len(s['d'])}, Failed: {len(s['f'])}."
)
def _handle_exception(self, fut: asyncio.Task, task: Task) -> None:
if fut.cancelled():

View file

@ -61,10 +61,13 @@ REMOVE_KEYS: list = [
"opt_list_impersonate_targets": "--list-impersonate-targets",
},
]
"Keys to remove from yt-dlp options at various levels."
YTDLP_INFO_CLS: YTDLP = None
"Cached YTDLP info class."
ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
"Allowed subtitle file extensions."
FILES_TYPE: list = [
{"rx": re.compile(r"\.(avi|ts|mkv|mp4|mp3|mpv|ogm|m4v|webm|m4b)$", re.IGNORECASE), "type": "video"},
@ -74,11 +77,15 @@ FILES_TYPE: list = [
{"rx": re.compile(r"\.(txt|nfo|md|json|yml|yaml|plexmatch)$", re.IGNORECASE), "type": "text"},
{"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"},
]
"File type patterns for sidecar files."
TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
"Regex to find tags in templates."
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Regex to match ISO 8601 datetime strings."
T = TypeVar("T")
"Generic type variable."
class StreamingError(Exception):
@ -111,11 +118,11 @@ def calc_download_path(base_path: str | Path, folder: str | None = None, create_
folder = folder.removeprefix("/")
realBasePath = base_path.resolve()
download_path = Path(realBasePath).joinpath(folder).resolve(strict=False)
realBasePath: Path = base_path.resolve()
download_path: Path = Path(realBasePath).joinpath(folder).resolve(strict=False)
if not str(download_path).startswith(str(realBasePath)):
msg = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".'
msg: str = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".'
raise Exception(msg)
try:
@ -165,7 +172,7 @@ def extract_info(
}
# Remove keys that are not needed for info extraction.
keys_to_remove = [key for key in params if str(key).startswith("write") or key in ["postprocessors"]]
keys_to_remove: list = [key for key in params if str(key).startswith("write") or key in ["postprocessors"]]
for key in keys_to_remove:
params.pop(key, None)
@ -175,8 +182,8 @@ def extract_info(
params["quiet"] = True
log_wrapper = LogWrapper()
idDict = get_archive_id(url=url)
archive_id = f".{idDict['id']}" if idDict.get("id") else None
idDict: dict[str, str | None] = get_archive_id(url=url)
archive_id: str | None = f".{idDict['id']}" if idDict.get("id") else None
log_wrapper.add_target(
target=logging.getLogger(f"yt-dlp{archive_id if archive_id else '.extract_info'}"),
@ -244,7 +251,7 @@ def _is_safe_key(key: any) -> bool:
return False
# Block only truly dangerous dunder patterns
key_stripped = key.strip()
key_stripped: str = key.strip()
# Block dunder attributes (starts AND ends with __)
return not (key_stripped.startswith("__") and key_stripped.endswith("__"))
@ -279,7 +286,7 @@ def merge_dict(
# Prevent deep recursion DoS
if _depth > max_depth:
msg = f"Recursion depth limit exceeded ({max_depth})"
msg: str = f"Recursion depth limit exceeded ({max_depth})"
raise RecursionError(msg)
# Initialize circular reference tracking
@ -294,10 +301,10 @@ def merge_dict(
raise ValueError(msg)
# Track current objects
current_seen = _seen | {source_id, dest_id}
current_seen: set[Any | int] = _seen | {source_id, dest_id}
# Create a clean copy of destination with only safe keys
destination_copy = {}
destination_copy: dict = {}
for k, v in destination.items():
if _is_safe_key(k):
# Prevent memory DoS from large lists
@ -311,7 +318,7 @@ def merge_dict(
if not _is_safe_key(key):
continue
destination_value = destination_copy.get(key)
destination_value: Any | None = destination_copy.get(key)
# Recursively merge dictionaries with safety checks
if isinstance(value, dict) and isinstance(destination_value, dict):
@ -325,8 +332,8 @@ def merge_dict(
combined_size = len(value) + len(destination_value)
if combined_size > max_list_size:
# Truncate to stay within limits
available_space = max_list_size - len(destination_value)
truncated_value = value[: max(0, available_space)]
available_space: int = max_list_size - len(destination_value)
truncated_value: list = value[: max(0, available_space)]
destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(truncated_value)
else:
destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(value)
@ -354,11 +361,13 @@ def check_id(file: Path) -> bool | str:
bool|str: False if no file found, else the file path.
"""
match = re.search(r"(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE)
match: re.Match[str] | None = re.search(
r"(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE
)
if not match:
return False
id = match.groupdict().get("id")
id: str | None = match.groupdict().get("id")
try:
for f in file.parent.iterdir():
@ -378,8 +387,8 @@ def check_id(file: Path) -> bool | str:
@lru_cache(maxsize=512)
def is_private_address(hostname: str) -> bool:
ip = socket.gethostbyname(hostname)
ip_obj = ipaddress.ip_address(ip)
ip: str = socket.gethostbyname(hostname)
ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address = ipaddress.ip_address(ip)
return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local
@ -545,7 +554,7 @@ def get_file_sidecar(file: Path) -> list[dict]:
list: List of sidecar files.
"""
files = {}
files: dict = {}
for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")):
if f == file or f.is_file() is False or f.stem.startswith("."):
@ -557,15 +566,15 @@ def get_file_sidecar(file: Path) -> list[dict]:
content_type = "Unknown"
for pattern in FILES_TYPE:
if pattern["rx"].search(f.name):
content_type = pattern["type"]
content_type: str = pattern["type"]
break
if content_type == "subtitle":
if f.suffix not in ALLOWED_SUBS_EXTENSIONS:
continue
lg = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", f.name)
lang = lg.groupdict().get("lang") if lg else "und"
content = {"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"}
lg: re.Match[str] | None = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", f.name)
lang: str | None = lg.groupdict().get("lang") if lg else "und"
content: dict[str, Any] = {"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"}
else:
content = {"file": f}
@ -574,7 +583,7 @@ def get_file_sidecar(file: Path) -> list[dict]:
files[content_type].append(content)
images = get_possible_images(str(file.parent))
images: list[dict] = get_possible_images(str(file.parent))
if len(images) > 0:
if "image" not in files:
files["image"] = []
@ -586,7 +595,7 @@ def get_file_sidecar(file: Path) -> list[dict]:
@lru_cache(maxsize=512)
def get_possible_images(dir: str) -> list[dict]:
images = []
images: list = []
path_loc = Path(dir, "test.jpg")
@ -615,7 +624,7 @@ def get_mime_type(metadata: dict, file_path: Path) -> str:
format_name = metadata.get("format_name", "")
# Define mappings for HTML5-compatible video types
format_to_mime = {
format_to_mime: dict[str, str] = {
"matroska": "video/x-matroska", # Default for MKV
"webm": "video/webm", # MKV can also be WebM
"mp4": "video/mp4",
@ -628,7 +637,7 @@ def get_mime_type(metadata: dict, file_path: Path) -> str:
for fmt in format_name.split(","):
fmt = fmt.strip().lower()
if fmt in format_to_mime:
selected = format_to_mime[fmt]
selected: str = format_to_mime[fmt]
if selected:
return selected
@ -668,7 +677,7 @@ def get_file(download_path: str | Path, file: str | Path) -> tuple[Path, int]:
LOG.error(f"Error calculating download path. {e!s}")
return (Path(file), 404)
possibleFile = check_id(file=realFile)
possibleFile: bool | str = check_id(file=realFile)
if not possibleFile:
return (realFile, 404)
@ -775,7 +784,7 @@ def get(
return default() if callable(default) else default
# Split the path by the separator and traverse the data structure.
segments = path.split(separator)
segments: list[str] = path.split(separator)
for segment in segments:
if isinstance(data, dict):
if segment in data:
@ -873,7 +882,7 @@ def get_files(base_path: Path | str, dir: str | None = None):
if not content_type:
content_type = "download"
stat = file.stat()
stat: os.stat_result = file.stat()
contents.append(
{
@ -897,9 +906,6 @@ def get_files(base_path: Path | str, dir: str | None = None):
def clean_item(item: dict, keys: list | tuple) -> tuple[dict, bool]:
"""
Remove given keys from a dictionary.
This function modifies the dictionary in place and returns a tuple
containing the modified dictionary and a boolean indicating
whether any keys were removed.
Args:
item (dict): The item to clean.
@ -913,6 +919,7 @@ def clean_item(item: dict, keys: list | tuple) -> tuple[dict, bool]:
"""
status = False
item = copy.deepcopy(item)
if not isinstance(item, dict):
msg = "Item must be a dictionary."
@ -946,7 +953,7 @@ def strip_newline(string: str) -> str:
if not string:
return ""
res = re.sub(r"(\r\n|\r|\n)", " ", string)
res: str = re.sub(r"(\r\n|\r|\n)", " ", string)
return res.strip() if res else ""
@ -978,34 +985,34 @@ async def read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
file_size = await f.tell()
file_size: int = await f.tell()
block_size = 1024
block_end = file_size
buffer = b""
lines = []
block_end: int = file_size
buffer: bytes = b""
lines: list = []
required_count = offset + limit + 1
required_count: int = offset + limit + 1
while len(lines) < required_count and block_end > 0:
block_start = max(0, block_end - block_size)
block_start: int = max(0, block_end - block_size)
await f.seek(block_start)
chunk = await f.read(block_end - block_start)
buffer = chunk + buffer # prepend the chunk
chunk: bytes = await f.read(block_end - block_start)
buffer: bytes = chunk + buffer # prepend the chunk
lines = buffer.splitlines()
block_end = block_start
if len(lines) > offset + limit:
next_offset = offset + limit
next_offset: int = offset + limit
end_is_reached = False
else:
next_offset = None
end_is_reached = True
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
line_bytes = line if isinstance(line, bytes) else line.encode()
msg = line.decode(errors="replace")
dt_match = DT_PATTERN.match(msg)
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
result.append(
{
"id": sha256(line_bytes).hexdigest(),
@ -1041,13 +1048,13 @@ async def tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
while True:
line = await f.readline()
line: bytes = await f.readline()
if not line:
await asyncio_sleep(sleep_time)
continue
msg = line.decode(errors="replace")
dt_match = DT_PATTERN.match(msg)
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
await emitter(
{
@ -1101,7 +1108,7 @@ def get_archive_id(url: str) -> dict[str, str | None]:
"""
global YTDLP_INFO_CLS # noqa: PLW0603
idDict = {
idDict: dict[str, None] = {
"id": None,
"ie_key": None,
"archive_id": None,
@ -1158,7 +1165,7 @@ def dt_delta(delta: timedelta) -> str:
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
parts = []
parts: list[str] = []
if days:
parts.append(f"{days}d")
if hours:
@ -1269,6 +1276,7 @@ def load_modules(root_path: Path, directory: Path):
full_name: str = f"{package_name}.{name}"
if name.startswith("_"):
continue
try:
importlib.import_module(full_name)
except ImportError as e:
@ -1377,37 +1385,6 @@ def ytdlp_reject(entry: dict, yt_params: dict) -> tuple[bool, str]:
return (True, "")
def find_unpickleable(obj, name="root", seen=None):
import pickle
if seen is None:
seen = set()
if id(obj) in seen:
return
seen.add(id(obj))
try:
pickle.dumps(obj)
except Exception as e:
LOG.error(f"[UNPICKLEABLE] {name}: {e}")
if isinstance(obj, dict):
for k, v in obj.items():
find_unpickleable(v, f"{name}[{repr(k)!s}]", seen)
elif hasattr(obj, "__dict__"):
for attr in vars(obj):
try:
value = getattr(obj, attr)
find_unpickleable(value, f"{name}.{attr}", seen)
except Exception as ie:
LOG.error(f"[ERROR] Accessing {name}.{attr}: {ie}")
elif isinstance(obj, list | tuple | set):
for idx, item in enumerate(obj):
find_unpickleable(item, f"{name}[{idx}]", seen)
def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]:
"""
List all folders relative to a base path, up to a specified depth limit.

View file

@ -9,20 +9,17 @@ LOG: logging.Logger = logging.getLogger("YTDLPOpts")
class YTDLPOpts:
_item_opts: dict = {}
"""The item options."""
_preset_opts: dict = {}
"""The preset options."""
_item_cli: list = []
"""The command options for yt-dlp from item."""
_preset_cli: str = ""
"""The command options for yt-dlp from preset."""
def __init__(self):
self._config: Config = Config.get_instance()
"The config instance."
self._item_opts: dict = {}
"The item options."
self._preset_opts: dict = {}
"The preset options."
self._item_cli: list = []
"The command options for yt-dlp from item."
self._preset_cli: str = ""
"The command options for yt-dlp from preset."
@staticmethod
def get_instance() -> "YTDLPOpts":
@ -47,7 +44,7 @@ class YTDLPOpts:
YTDLPOpts: The instance of the class
"""
if not args or len(args) < 2 or not isinstance(args, str):
if not args or not isinstance(args, str) or len(args) < 2:
return self
try:
@ -155,9 +152,6 @@ class YTDLPOpts:
self._item_cli = []
merge: list[str] = []
if self._config._ytdlp_cli_mutable and len(self._config._ytdlp_cli_mutable) > 1:
merge.append(self._config._ytdlp_cli_mutable)
if self._preset_cli and len(self._preset_cli) > 1:
merge.append(self._preset_cli)

View file

@ -11,12 +11,8 @@ class Cache(metaclass=ThreadSafe):
"""
Initialize the Cache.
"""
# Prevent reinitialization in singleton context.
if hasattr(self, "_initialized") and self._initialized:
return
self._cache: dict[str, tuple[Any, float | None]] = {}
self._lock = threading.Lock()
self._initialized = True
def set(self, key: str, value: Any, ttl: float | None = None) -> None:
"""

View file

@ -14,7 +14,7 @@ from .mini_filter import match_str
from .Singleton import Singleton
from .Utils import arg_converter, init_class
LOG = logging.getLogger("conditions")
LOG: logging.Logger = logging.getLogger("conditions")
@dataclass(kw_only=True)
@ -49,18 +49,14 @@ 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
self._items: list[Condition] = []
"The list of items."
config = config or Config.get_instance()
self._file: Path = Path(file) if file else Path(config.config_path) / "conditions.json"
"The path to the file where the items are stored."
if self._file.exists() and "600" != self._file.stat().st_mode:
try:
@ -68,14 +64,8 @@ class Conditions(metaclass=Singleton):
except Exception:
pass
async 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":
def get_instance(file: Path | str | None = None, config: Config | None = None) -> "Conditions":
"""
Get the instance of the class.
@ -83,10 +73,7 @@ class Conditions(metaclass=Singleton):
Conditions: The instance of the class
"""
if not Conditions._instance:
Conditions._instance = Conditions()
return Conditions._instance
return Conditions(file=file, config=config)
async def on_shutdown(self, _: web.Application):
pass
@ -104,6 +91,12 @@ class Conditions(metaclass=Singleton):
"""
self.load()
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.save")
def get_all(self) -> list[Condition]:
"""Return the items."""
return self._items

View file

@ -7,19 +7,20 @@ import time
from logging.handlers import TimedRotatingFileHandler
from multiprocessing.managers import SyncManager
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import coloredlogs
from dotenv import load_dotenv
from .Utils import FileLogFormatter, arg_converter
from .Singleton import Singleton
from .Utils import FileLogFormatter
from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION
if TYPE_CHECKING:
from subprocess import CompletedProcess
class Config:
class Config(metaclass=Singleton):
app_env: str = "production"
"""The application environment, can be 'production' or 'development'."""
@ -95,9 +96,6 @@ class Config:
debugpy_port: int = 5678
"""The port to use for the debugpy server."""
socket_timeout: int = 30
"""The socket timeout to use for yt-dlp."""
extract_info_timeout: int = 70
"""The timeout to use for extracting video information."""
@ -131,18 +129,12 @@ class Config:
app_branch: str = APP_BRANCH
"The branch of the application."
__instance = None
"The instance of the class."
started: int = 0
"The time the application was started."
ignore_ui: bool = False
"Ignore the UI and run the application in the background."
basic_mode: bool = False
"Run the frontend in basic mode."
default_preset: str = "default"
"The default preset to use when no preset is specified."
@ -161,21 +153,12 @@ class Config:
console_enabled: bool = False
"Enable direct access to yt-dlp console."
browser_enabled: bool = True
"Enable file browser access."
browser_control_enabled: bool = False
"Enable file browser control access."
ytdlp_auto_update: bool = True
"""Enable in-place auto update of yt-dlp package."""
ytdlp_cli: str = ""
"""The command line options to use for yt-dlp."""
_ytdlp_cli_mutable: str = ""
"""The command line options to use for yt-dlp."""
ytdlp_version: str | None = None
"""The version of yt-dlp to use, if not set, the latest version will be used."""
@ -206,11 +189,8 @@ class Config:
"The variables that are set manually."
_immutable: tuple = (
"__instance",
"ytdl_options",
"started",
"ytdlp_cli",
"_ytdlp_cli_mutable",
"is_native",
"app_version",
"app_commit_sha",
@ -222,7 +202,6 @@ class Config:
_int_vars: tuple = (
"port",
"max_workers",
"socket_timeout",
"extract_info_timeout",
"debugpy_port",
"playlist_items_concurrency",
@ -241,10 +220,8 @@ class Config:
"ignore_ui",
"ui_update_title",
"pip_ignore_updates",
"basic_mode",
"file_logging",
"console_enabled",
"browser_enabled",
"browser_control_enabled",
"ytdlp_auto_update",
"prevent_premiere_live",
@ -260,13 +237,10 @@ class Config:
"remove_files",
"ui_update_title",
"max_workers",
"basic_mode",
"default_preset",
"instance_title",
"console_enabled",
"browser_enabled",
"browser_control_enabled",
"ytdlp_cli",
"file_logging",
"base_path",
"is_native",
@ -284,8 +258,7 @@ class Config:
@staticmethod
def get_instance(is_native: bool = False) -> "Config":
"""Static access method."""
cls: Config = Config(is_native) if not Config.__instance else Config.__instance
cls = Config(is_native)
cls.is_native = is_native or cls.is_native
return cls
@ -297,13 +270,6 @@ class Config:
return Config._manager
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."
raise Exception(msg)
Config.__instance = self
baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute())
self.is_native = is_native
@ -362,7 +328,7 @@ class Config:
if not self.base_path.endswith("/"):
self.base_path += "/"
numeric_level = getattr(logging, self.log_level.upper(), None)
numeric_level: int | None = getattr(logging, self.log_level.upper(), None)
if not isinstance(numeric_level, int):
msg = f"Invalid log level '{self.log_level}' specified."
raise TypeError(msg)
@ -383,55 +349,26 @@ class Config:
debugpy.listen(("0.0.0.0", self.debugpy_port), in_process_debug_adapter=True)
LOG.info(f"starting debugpy server on '0.0.0.0:{self.debugpy_port}'.")
except ImportError:
LOG.error("debugpy package not found, please install it with 'pip install debugpy'.")
LOG.error("debugpy package not found, install it with 'uv sync'.")
except Exception as e:
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
opts_file: Path = Path(self.config_path) / "ytdlp.cli"
if opts_file.exists() and opts_file.stat().st_size > 2:
LOG.warning(
"Usage of 'ytdlp.cli' global config file is deprecated and will be removed in future releases. please migrate to presets."
)
with open(opts_file) as f:
self.ytdlp_cli = f.read().strip()
if self.ytdlp_cli:
self._ytdlp_cli_mutable = self.ytdlp_cli
try:
arg_converter(args=self.ytdlp_cli, level=True)
except Exception as e:
msg = f"Failed to parse yt-dlp cli options from '{opts_file}'. '{e!s}'."
raise ValueError(msg) from e
self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}"
if self.keep_archive:
LOG.warning(
"The global 'keep_archive' option is deprecated and will be removed in future releases. please use presets instead."
)
archive_file: Path = Path(self.archive_file)
if not archive_file.exists():
LOG.info(f"Creating archive file '{archive_file}'.")
archive_file.touch(exist_ok=True)
LOG.info(f"keep archive option is enabled. Using archive file '{archive_file}' by default.")
self._ytdlp_cli_mutable += f"\n--download-archive {archive_file.as_posix()!s}"
if (Path(self.config_path) / "ytdlp.cli").exists():
LOG.error("Support for ./ytdlp.cli file is removed, migrate to presets and remove the file.")
if self.temp_keep:
LOG.info("Keep temp files option is enabled.")
LOG.warning("Keep temp files option is enabled.")
if self.auth_password and self.auth_username:
LOG.warning(f"Basic authentication enabled with username '{self.auth_username}'.")
if self.basic_mode:
LOG.info("The frontend is running in basic mode.")
LOG.info(f"Authentication enabled with username '{self.auth_username}'.")
if self.file_logging:
log_level_file = getattr(logging, self.log_level_file.upper(), None)
log_level_file: int | None = getattr(logging, self.log_level_file.upper(), None)
if not isinstance(log_level_file, int):
msg = f"Invalid file log level '{self.log_level_file}' specified."
raise TypeError(msg)
loggingPath = Path(self.config_path) / "logs"
loggingPath: Path = Path(self.config_path) / "logs"
if not loggingPath.exists():
loggingPath.mkdir(parents=True, exist_ok=True)
@ -459,15 +396,17 @@ class Config:
self.started = time.time()
logging.getLogger("httpcore").setLevel(logging.INFO)
for _tool in ("httpx", "urllib3.connectionpool", "apprise"):
logging.getLogger(_tool).setLevel(logging.WARNING)
_log_levels = (
("httpx", logging.WARNING),
("urllib3.connectionpool", logging.WARNING),
("apprise", logging.WARNING),
("httpcore", logging.INFO),
)
for _tool, _level in _log_levels:
logging.getLogger(_tool).setLevel(_level)
# check env
if self.app_env not in ("production", "development"):
msg: str = (
f"Invalid application environment '{self.app_env}' specified. Must be 'production' or 'development'."
)
msg: str = f"Invalid app environment '{self.app_env}' specified. Must be 'production' or 'development'."
raise ValueError(msg)
if "dev-master" == self.app_version:
@ -481,7 +420,7 @@ class Config:
if attribute.startswith("_"):
continue
value = getattr(vClass, attribute)
value: Any = getattr(vClass, attribute)
if not callable(value):
attrs[attribute] = value
@ -507,23 +446,6 @@ class Config:
"""
return "production" == self.app_env
def get_ytdlp_args(self) -> dict:
"""
Get the yt-dlp command line options as a dictionary.
Returns:
dict: The yt-dlp command line options.
Deprecated:
Usage of global ytdlp.cli file is deprecated, please use presets instead.
"""
try:
return arg_converter(args=self._ytdlp_cli_mutable, level=True)
except Exception as e:
msg = f"Invalid ytdlp.cli options for yt-dlp. '{e!s}'."
raise ValueError(msg) from e
def frontend(self) -> dict:
"""
Returns configuration variables relevant to the frontend.
@ -532,23 +454,17 @@ class Config:
dict: A dictionary with the frontend configuration
"""
data = {k: getattr(self, k) for k in self._frontend_vars}
ytdlp_args = self.get_ytdlp_args()
# TODO: this doesn't make sense, as each item might have it's own archive file or none at all.
if not data.get("keep_archive", False) and ytdlp_args.get("download_archive", None):
data["keep_archive"] = True
data: dict[str, Any] = {k: getattr(self, k) for k in self._frontend_vars}
data["ytdlp_version"] = Config._ytdlp_version()
return data
def get_replacers(self) -> dict:
def get_replacers(self) -> dict[str, str]:
"""
Get the variables that can be used in Command options for yt-dlp.
Returns:
dict: The replacer variables.
dict[str, str]: The replacer variables.
"""
keys: tuple[str] = ("download_path", "temp_path", "config_path")

View file

@ -100,21 +100,13 @@ class DLFields(metaclass=Singleton):
This class is used to manage the DLFields.
"""
_items: list[DLField] = []
"""The list of items."""
_instance = None
"""The instance of the class."""
_config: Config = None
"""The config instance."""
def __init__(self, file: str | Path | None = None, config: Config | None = None):
DLFields._instance = self
self._config = config or Config.get_instance()
self._file: Path = Path(file) if file else Path(self._config.config_path).joinpath("dl_fields.json")
self._items: list[DLField] = []
"The list of items."
config: Config = config or Config.get_instance()
"The configuration instance."
self._file: Path = Path(file) if file else Path(config.config_path).joinpath("dl_fields.json")
"The path to the file where the items are stored."
if self._file.exists() and "600" != self._file.stat().st_mode:
try:
@ -122,14 +114,8 @@ class DLFields(metaclass=Singleton):
except Exception:
pass
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add")
@staticmethod
def get_instance() -> "DLFields":
def get_instance(file: str | Path | None = None, config: Config | None = None) -> "DLFields":
"""
Get the instance of the class.
@ -137,10 +123,7 @@ class DLFields(metaclass=Singleton):
DLFields: The instance of the class
"""
if not DLFields._instance:
DLFields._instance = DLFields()
return DLFields._instance
return DLFields(file=file, config=config)
async def on_shutdown(self, _: web.Application):
pass
@ -158,6 +141,12 @@ class DLFields(metaclass=Singleton):
"""
self.load()
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add")
def get_all(self) -> list[DLField]:
"""Return the items."""
return self._items

View file

@ -277,7 +277,7 @@ async def ffprobe(file: str) -> FFProbeResult:
msg = "ffprobe not found."
raise OSError(msg) from e
args = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", str(f)]
args: list[str] = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", str(f)]
p = await asyncio.create_subprocess_exec(
"ffprobe",
@ -287,13 +287,13 @@ async def ffprobe(file: str) -> FFProbeResult:
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
exitCode = await p.wait()
exitCode: int = await p.wait()
data, err = await p.communicate()
if 0 == exitCode:
parsed: dict = json.loads(data.decode("utf-8"))
else:
msg = f"ffprobe returned with non-0 exit code. '{err.decode('utf-8')}'"
msg: str = f"ffprobe returned with non-0 exit code. '{err.decode('utf-8')}'"
raise FFProbeError(msg)
result = FFProbeResult()

View file

@ -61,7 +61,7 @@ class YoutubeHandler(BaseHandler):
params: dict = task.get_ytdlp_opts().get_all()
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
LOG.info(f"'{task.name}': Fetching feed.")
LOG.debug(f"'{task.name}': Fetching feed.")
items: list = []
@ -106,7 +106,7 @@ class YoutubeHandler(BaseHandler):
if real_count < 1:
LOG.warning(f"'{task.name}': No entries found the RSS feed. URL: {feed_url}")
else:
LOG.info(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
LOG.debug(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
return
filtered: list = []
@ -134,7 +134,7 @@ class YoutubeHandler(BaseHandler):
filtered.append(item)
if len(filtered) < 1:
LOG.info(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
LOG.debug(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
return
LOG.info(f"'{task.name}': Found '{len(filtered)}/{real_count}' new items from feed.")

View file

@ -14,6 +14,7 @@ class _ArchiveProxy:
def __init__(self, file: str | None):
self._file: str | None = file
"The archive file path."
def __contains__(self, item: str) -> bool:
if not self._file or not item:

View file

@ -129,9 +129,6 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
Response: The response object.
"""
if not config.browser_enabled:
return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code)
req_path: str = request.match_info.get("path")
req_path: str = "/" if not req_path else unquote_plus(req_path)
@ -190,9 +187,6 @@ async def path_actions(request: Request, config: Config) -> Response:
Response: The response object.
"""
if not config.browser_enabled:
return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code)
if not config.browser_control_enabled:
return web.json_response(
data={"error": "File browser actions is disabled."}, status=web.HTTPForbidden.status_code

View file

@ -153,13 +153,8 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
preset: str = params.get("preset", config.default_preset)
key: str = cache.hash(url + str(preset))
if not cache.has(key):
opts = {}
if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None):
opts["proxy"] = ytdlp_proxy
ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all()
data = extract_info(
config=ytdlp_opts,
data: dict = extract_info(
config=YTDLPOpts.get_instance().preset(name=preset).get_all(),
url=url,
debug=False,
no_archive=True,
@ -182,7 +177,7 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
)
try:
status = match_str(cond, data)
status: bool = match_str(cond, data)
except Exception as e:
LOG.exception(e)
return web.json_response(

View file

@ -2,6 +2,7 @@ import logging
import random
import time
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlparse
import httpx
@ -14,6 +15,7 @@ from app.library.cache import Cache
from app.library.config import Config
from app.library.router import route
from app.library.Utils import validate_url
from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__)
@ -33,7 +35,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
Response: The response object.
"""
url = request.query.get("url")
url: str | None = request.query.get("url")
if not url:
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
@ -43,15 +45,28 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
try:
ytdlp_args = config.get_ytdlp_args()
opts = {
"proxy": ytdlp_args.get("proxy", None),
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
opts: dict[str, Any] = {
"headers": {
"User-Agent": ytdlp_args.get("user_agent", request.headers.get("User-Agent", random_user_agent())),
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())),
},
}
if proxy := ytdlp_args.get("proxy"):
opts["proxy"] = proxy
try:
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
opts["transport"] = AsyncCurlTransport(
impersonate="chrome",
default_headers=True,
curl_options={CurlOpt.FRESH_CONNECT: True},
)
opts.pop("headers", None)
except Exception:
pass
async with httpx.AsyncClient(**opts) as client:
LOG.debug(f"Fetching thumbnail from '{url}'.")
response = await client.request(method="GET", url=url, follow_redirects=True)
@ -115,13 +130,16 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
},
)
ytdlp_args = config.get_ytdlp_args()
opts = {
"proxy": ytdlp_args.get("proxy", None),
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
opts: dict[str, Any] = {
"headers": {
"User-Agent": ytdlp_args.get("user_agent", random_user_agent()),
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())),
},
}
if proxy := ytdlp_args.get("proxy"):
opts["proxy"] = proxy
try:
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
@ -166,7 +184,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
status=web.HTTPInternalServerError.status_code,
)
data = {
data: dict[str, Any] = {
"content": response.content,
"backend": urlparse(backend).netloc,
"headers": {

View file

@ -150,9 +150,6 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
}
return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None):
opts = opts.add({"proxy": ytdlp_proxy})
logs: list = []
ytdlp_opts: dict = {
@ -203,13 +200,14 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
"expires": time.time() + 300,
}
is_archived = False
if (archive_file := ytdlp_opts.get("download_archive")) and (
archive_id := get_archive_id(url=url).get("archive_id")
):
is_archived: bool = len(archive_read(archive_file, [archive_id])) > 0
data["is_archived"] = False
data["is_archived"] = is_archived
archive_file: str | None = ytdlp_opts.get("download_archive")
data["archive_file"] = archive_file if archive_file else None
if archive_file and (archive_id := get_archive_id(url=url).get("archive_id")):
data["archive_id"] = archive_id
data["is_archived"] = len(archive_read(archive_file, [archive_id])) > 0
data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1]))))

View file

@ -29,9 +29,7 @@ class TestCache:
def setup_method(self):
"""Set up test fixtures."""
# Clear any existing cache instance
if hasattr(Cache, "_instances"):
Cache._instances.clear()
Cache._reset_singleton()
self.cache = Cache()
def test_singleton_behavior(self):

View file

@ -21,7 +21,6 @@ from unittest.mock import MagicMock, patch
import pytest
from app.library.conditions import Condition, Conditions
from app.library.Singleton import Singleton
class TestCondition:
@ -112,11 +111,7 @@ class TestConditions:
def setup_method(self):
"""Set up test fixtures by clearing singleton instances."""
# Clear singleton instances before each test
Singleton._instances.clear()
# Reset class variable
Conditions._items = []
Conditions._instance = None
Conditions._reset_singleton()
def test_conditions_singleton(self):
"""Test that Conditions follows singleton pattern."""

View file

@ -22,7 +22,6 @@ from unittest.mock import MagicMock, patch
import pytest
from app.library.dl_fields import DLField, DLFields, FieldType
from app.library.Singleton import Singleton
class TestFieldType:
@ -156,12 +155,7 @@ class TestDLFields:
def setup_method(self):
"""Set up test fixtures."""
# Clear singleton instances before each test
Singleton._instances.clear()
if hasattr(DLFields, "_instances"):
DLFields._instances.clear()
# Also clear the class-level _items list
DLFields._items = []
DLFields._reset_singleton()
@pytest.fixture
def temp_file(self):
@ -219,11 +213,9 @@ class TestDLFields:
assert fields1 is fields2
@patch("app.library.dl_fields.Config")
@patch("app.library.dl_fields.EventBus")
def test_dl_fields_initialization(self, mock_event_bus, mock_config):
def test_dl_fields_initialization(self, mock_config):
"""Test DLFields initialization."""
mock_config.get_instance.return_value.config_path = "/tmp"
mock_event_bus.get_instance.return_value.subscribe = MagicMock()
with tempfile.TemporaryDirectory() as temp_dir:
temp_file = Path(temp_dir) / "test_fields.json"
@ -231,8 +223,6 @@ class TestDLFields:
fields = DLFields(file=str(temp_file))
assert fields._file == temp_file
assert fields._config is not None
mock_event_bus.get_instance.return_value.subscribe.assert_called_once()
@patch("app.library.dl_fields.Config")
def test_dl_fields_load_empty_file(self, mock_config, temp_file):

View file

@ -627,10 +627,7 @@ class TestEventBus:
def setup_method(self):
"""Clear EventBus listeners and reset singleton before each test."""
# Reset singleton instance to allow mocking to work
EventBus._instance = None
bus = EventBus.get_instance()
bus.clear()
EventBus._reset_singleton()
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
@ -656,9 +653,6 @@ class TestEventBus:
def test_event_bus_initialization(self):
"""Test EventBus initialization with new clean design."""
# Reset singleton to ensure fresh instance
EventBus._instance = None
# Create EventBus with clean initialization
bus = EventBus()

File diff suppressed because it is too large Load diff

599
app/tests/test_presets.py Normal file
View file

@ -0,0 +1,599 @@
import json
import tempfile
import uuid
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from app.library.Presets import DEFAULT_PRESETS, Preset, Presets
class TestPreset:
"""Test the Preset dataclass."""
def test_preset_creation_with_defaults(self):
"""Test creating a preset with default values."""
preset = Preset(name="test_preset")
assert preset.name == "test_preset"
assert preset.description == ""
assert preset.folder == ""
assert preset.template == ""
assert preset.cookies == ""
assert preset.cli == ""
assert preset.default is False
# ID should be auto-generated UUID
assert len(preset.id) == 36 # UUID4 string length
assert "-" in preset.id
def test_preset_creation_with_all_fields(self):
"""Test creating a preset with all fields specified."""
preset_id = str(uuid.uuid4())
preset = Preset(
id=preset_id,
name="test_preset",
description="Test description",
folder="test_folder",
template="test_template",
cookies="test_cookies",
cli="--test-option",
default=True,
)
assert preset.id == preset_id
assert preset.name == "test_preset"
assert preset.description == "Test description"
assert preset.folder == "test_folder"
assert preset.template == "test_template"
assert preset.cookies == "test_cookies"
assert preset.cli == "--test-option"
assert preset.default is True
def test_preset_serialize(self):
"""Test preset serialization to dictionary."""
preset = Preset(name="test_preset", description="Test description", cli="--test-option")
serialized = preset.serialize()
assert isinstance(serialized, dict)
assert serialized["name"] == "test_preset"
assert serialized["description"] == "Test description"
assert serialized["cli"] == "--test-option"
assert "id" in serialized
def test_preset_json(self):
"""Test preset JSON serialization."""
preset = Preset(name="test_preset", cli="--test-option")
json_str = preset.json()
assert isinstance(json_str, str)
# Should be valid JSON
parsed = json.loads(json_str)
assert parsed["name"] == "test_preset"
assert parsed["cli"] == "--test-option"
def test_preset_get_method(self):
"""Test preset get method for accessing fields."""
preset = Preset(name="test_preset", description="Test description")
assert preset.get("name") == "test_preset"
assert preset.get("description") == "Test description"
assert preset.get("nonexistent") is None
assert preset.get("nonexistent", "default_value") == "default_value"
def test_preset_id_generation(self):
"""Test that each preset gets a unique ID."""
preset1 = Preset(name="preset1")
preset2 = Preset(name="preset2")
assert preset1.id != preset2.id
assert len(preset1.id) == 36
assert len(preset2.id) == 36
class TestPresets:
"""Test the Presets singleton manager."""
def setup_method(self):
"""Set up test environment before each test."""
# Reset singleton completely
Presets._reset_singleton()
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_singleton(self, mock_eventbus, mock_config):
"""Test that Presets follows singleton pattern."""
mock_config.get_instance.return_value = Mock(config_path="/tmp", default_preset="default")
mock_eventbus.get_instance.return_value = Mock()
instance1 = Presets.get_instance()
instance2 = Presets.get_instance()
assert instance1 is instance2
assert isinstance(instance1, Presets)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_initialization(self, mock_eventbus, mock_config):
"""Test Presets initialization with default presets."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus_instance = Mock()
mock_eventbus.get_instance.return_value = mock_eventbus_instance
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
# Should have default presets loaded
default_presets = presets._default
assert len(default_presets) > 0
# Check that default presets are Preset instances
for preset in default_presets:
assert isinstance(preset, Preset)
assert preset.default is True
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_load_empty_file(self, mock_eventbus, mock_config):
"""Test loading presets from empty or non-existent file."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
presets.clear() # Ensure we start with empty items
result = presets.load()
assert result is presets
assert len(presets._items) == 0
assert len(presets._default) > 0 # Should still have default presets
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.arg_converter")
def test_presets_load_valid_file(self, mock_arg_converter, mock_eventbus, mock_config):
"""Test loading presets from valid JSON file."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_arg_converter.return_value = [] # Mock successful CLI parsing
test_presets = [
{"id": str(uuid.uuid4()), "name": "test_preset_1", "description": "Test preset 1", "cli": "--format best"},
{"id": str(uuid.uuid4()), "name": "test_preset_2", "description": "Test preset 2", "cli": "--format worst"},
]
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets_file.write_text(json.dumps(test_presets, indent=2))
presets = Presets(file=presets_file, config=mock_config_instance)
presets._items.clear() # Ensure we start with empty items
result = presets.load()
assert result is presets
assert len(presets._items) == 2
assert presets._items[0].name == "test_preset_1"
assert presets._items[1].name == "test_preset_2"
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.Config")
def test_presets_load_invalid_json(self, mock_eventbus, mock_config):
"""Test loading presets from invalid JSON file."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets_file.write_text("invalid json content")
presets = Presets(file=presets_file, config=mock_config_instance)
# Should handle invalid JSON gracefully
result = presets.load()
assert result is presets
assert len(presets._items) == 0
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.arg_converter")
def test_presets_load_with_format_migration(self, mock_arg_converter, mock_eventbus, mock_config):
"""Test loading presets with old format that needs migration."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_arg_converter.return_value = [] # Mock successful CLI parsing
# Old format preset with 'format' field instead of 'cli'
old_preset = {"name": "old_preset", "format": "best[height<=720]", "description": "Old format preset"}
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets_file.write_text(json.dumps([old_preset]))
presets = Presets(file=presets_file, config=mock_config_instance)
result = presets.load()
assert result is presets
assert len(presets._items) == 1
loaded_preset = presets._items[0]
assert loaded_preset.name == "old_preset"
# Should have migrated format to cli
assert "best[height<=720]" in loaded_preset.cli
assert "--format" in loaded_preset.cli
# Should have generated ID
assert loaded_preset.id is not None
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_validate_valid_preset(self, mock_eventbus, mock_config):
"""Test validating a valid preset."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
valid_preset = {"id": str(uuid.uuid4()), "name": "valid_preset", "cli": "--format best"}
result = presets.validate(valid_preset)
assert result is True
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_validate_invalid_preset(self, mock_eventbus, mock_config):
"""Test validating invalid presets."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
# Test missing ID
with pytest.raises(ValueError, match="No id found"):
presets.validate({"name": "test"})
# Test missing name
with pytest.raises(ValueError, match="No name found"):
presets.validate({"id": str(uuid.uuid4())})
# Test wrong type
with pytest.raises(ValueError, match="Unexpected"):
presets.validate("invalid_type")
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.arg_converter")
def test_presets_save(self, mock_arg_converter, mock_eventbus, mock_config):
"""Test saving presets to file."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_arg_converter.return_value = [] # Mock successful CLI parsing
test_presets = [
Preset(name="test1", cli="--format best"),
Preset(name="test2", cli="--format worst", default=True), # This should be excluded from save
]
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
result = presets.save(test_presets)
assert result is presets
assert presets_file.exists()
# Should only save non-default presets
saved_data = json.loads(presets_file.read_text())
assert len(saved_data) == 1
assert saved_data[0]["name"] == "test1"
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_all(self, mock_eventbus, mock_config):
"""Test getting all presets (default + custom)."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
# Add a custom preset
custom_preset = Preset(name="custom", cli="--custom")
presets._items.append(custom_preset)
all_presets = presets.get_all()
# Should include both default and custom presets
assert len(all_presets) > 1
assert any(p.name == "custom" for p in all_presets)
assert any(p.default is True for p in all_presets)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_by_id(self, mock_eventbus, mock_config):
"""Test getting preset by ID."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
test_id = str(uuid.uuid4())
test_preset = Preset(id=test_id, name="test_preset")
presets._items.append(test_preset)
found_preset = presets.get(test_id)
assert found_preset is not None
assert found_preset.id == test_id
assert found_preset.name == "test_preset"
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_by_name(self, mock_eventbus, mock_config):
"""Test getting preset by name."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
test_preset = Preset(name="test_preset", cli="--test")
presets._items.append(test_preset)
found_preset = presets.get("test_preset")
assert found_preset is not None
assert found_preset.name == "test_preset"
assert found_preset.cli == "--test"
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_nonexistent(self, mock_eventbus, mock_config):
"""Test getting non-existent preset returns None."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
found_preset = presets.get("nonexistent")
assert found_preset is None
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_get_empty_parameter(self, mock_eventbus, mock_config):
"""Test getting preset with empty string returns None."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
assert presets.get("") is None
assert presets.get(None) is None
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_has_method(self, mock_eventbus, mock_config):
"""Test has method for checking preset existence."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
test_preset = Preset(name="existing_preset")
presets._items.append(test_preset)
assert presets.has("existing_preset") is True
assert presets.has(test_preset.id) is True
assert presets.has("nonexistent") is False
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_clear(self, mock_eventbus, mock_config):
"""Test clearing all custom presets."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
# Add some custom presets
presets._items.append(Preset(name="custom1"))
presets._items.append(Preset(name="custom2"))
assert len(presets._items) == 2
result = presets.clear()
assert result is presets
assert len(presets._items) == 0
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_file_permissions(self, mock_eventbus, mock_config):
"""Test that presets file gets correct permissions."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
# Create file with different permissions
presets_file.write_text("{}")
presets_file.chmod(0o644)
# Creating Presets instance should attempt to fix permissions
presets = Presets(file=presets_file, config=mock_config_instance)
# Note: The actual chmod might fail in test environment,
# but we're testing that the code attempts it
assert presets is not None
def test_default_presets_validation(self):
"""Test that all default presets are valid."""
# This test verifies that the DEFAULT_PRESETS constant contains valid presets
for i, preset_data in enumerate(DEFAULT_PRESETS):
assert "id" in preset_data, f"Default preset {i} missing id"
assert "name" in preset_data, f"Default preset {i} missing name"
assert "default" in preset_data, f"Default preset {i} missing default flag"
assert preset_data["default"] is True, f"Default preset {i} should have default=True"
# Should be able to create Preset instance
preset = Preset(
id=preset_data["id"],
name=preset_data["name"],
description=preset_data.get("description", ""),
folder=preset_data.get("folder", ""),
template=preset_data.get("template", ""),
cli=preset_data.get("cli", ""),
default=preset_data["default"],
)
assert isinstance(preset, Preset)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_attach_method(self, mock_eventbus, mock_config):
"""Test the attach method for aiohttp integration."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_app = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
# Mock the get method to return a preset for default_preset check
with patch.object(presets, "get", return_value=Mock()) as mock_get:
presets.attach(mock_app)
mock_get.assert_called_once_with("default")
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
def test_presets_attach_default_preset_not_found(self, mock_eventbus, mock_config):
"""Test attach method when default preset is not found."""
mock_config_instance = Mock(config_path="/tmp", default_preset="nonexistent")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_app = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
with patch.object(presets, "get", return_value=None):
# The actual config instance stored in presets should be checked
presets.attach(mock_app)
# Should have reset default_preset to "default"
assert presets._config.default_preset == "default"
@pytest.mark.asyncio
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
async def test_presets_on_shutdown(self, mock_eventbus, mock_config):
"""Test the on_shutdown method."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_app = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
# Should not raise an exception
await presets.on_shutdown(mock_app)
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.LOG")
@patch("app.library.Presets.arg_converter")
def test_presets_load_invalid_preset_in_file(self, mock_arg_converter, mock_log, mock_eventbus, mock_config):
"""Test loading file with some invalid presets."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_arg_converter.return_value = [] # Mock successful CLI parsing
# Mix of valid and invalid presets
test_presets = [
{"id": str(uuid.uuid4()), "name": "valid_preset", "cli": "--format best"},
{
# Missing required fields
"description": "Invalid preset"
},
]
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets_file.write_text(json.dumps(test_presets))
# Clear any existing items first
presets = Presets(file=presets_file, config=mock_config_instance)
presets.load()
# Should only load the valid preset
assert len(presets._items) == 1
assert presets._items[0].name == "valid_preset", f"Expected 'valid_preset', got '{presets._items}'"
# Should have logged an error for the invalid preset
mock_log.error.assert_called()
@patch("app.library.Presets.Config")
@patch("app.library.Presets.EventBus")
@patch("app.library.Presets.LOG")
def test_presets_save_with_invalid_preset(self, mock_log, mock_eventbus, mock_config):
"""Test saving presets with some invalid ones."""
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
# Create preset with invalid CLI that will fail validation
valid_preset = Preset(name="valid", cli="--valid-option")
# Create invalid preset data
invalid_preset_data = {"name": "invalid"} # Missing ID
presets_to_save = [valid_preset, invalid_preset_data]
with tempfile.TemporaryDirectory() as temp_dir:
presets_file = Path(temp_dir) / "presets.json"
presets = Presets(file=presets_file, config=mock_config_instance)
presets.save(presets_to_save)
# Should have logged errors for invalid presets
mock_log.error.assert_called()

552
app/tests/test_services.py Normal file
View file

@ -0,0 +1,552 @@
import logging
from unittest.mock import MagicMock, patch
import pytest
from app.library.Services import Services
class TestServices:
"""Test the Services singleton class."""
def setup_method(self):
"""Clear services before each test."""
Services._reset_singleton()
def test_singleton_behavior(self):
"""Test that Services follows singleton pattern."""
service1 = Services()
service2 = Services()
service3 = Services.get_instance()
assert service1 is service2, "Multiple Services() calls should return same instance"
assert service1 is service3, "get_instance() should return same instance"
assert id(service1) == id(service2) == id(service3), "All references should point to same object"
def test_add_and_get_service(self):
"""Test adding and retrieving services."""
services = Services()
test_service = "test_value"
services.add("test_service", test_service)
retrieved = services.get("test_service")
assert retrieved == test_service, "Should retrieve the same service that was added"
def test_get_nonexistent_service(self):
"""Test retrieving a service that doesn't exist."""
services = Services()
result = services.get("nonexistent")
assert result is None, "Should return None for nonexistent service"
def test_has_service(self):
"""Test checking if service exists."""
services = Services()
services.add("existing", "value")
assert services.has("existing") is True, "Should return True for existing service"
assert services.has("nonexistent") is False, "Should return False for nonexistent service"
def test_remove_service(self):
"""Test removing a service."""
services = Services()
services.add("to_remove", "value")
assert services.has("to_remove") is True, "Service should exist before removal"
services.remove("to_remove")
assert services.has("to_remove") is False, "Service should not exist after removal"
def test_remove_nonexistent_service(self):
"""Test removing a service that doesn't exist."""
services = Services()
# Should not raise an exception
services.remove("nonexistent")
assert services.has("nonexistent") is False
def test_clear_services(self):
"""Test clearing all services."""
services = Services()
services.add("service1", "value1")
services.add("service2", "value2")
assert len(services.get_all()) == 2, "Should have 2 services before clear"
services.clear()
assert len(services.get_all()) == 0, "Should have 0 services after clear"
def test_add_all_services(self):
"""Test adding multiple services at once."""
services = Services()
services_dict = {
"service1": "value1",
"service2": "value2",
"service3": "value3"
}
services.add_all(services_dict)
assert services.get("service1") == "value1"
assert services.get("service2") == "value2"
assert services.get("service3") == "value3"
assert len(services.get_all()) == 3
def test_get_all_returns_copy(self):
"""Test that get_all returns a copy, not the original dict."""
services = Services()
services.add("test", "value")
all_services = services.get_all()
all_services["injected"] = "malicious"
assert "injected" not in services.get_all(), "Modifying returned dict should not affect internal state"
def test_handle_sync_with_matching_args(self):
"""Test synchronous handler with matching arguments."""
services = Services()
services.add("db", "database_connection")
services.add("logger", "logger_instance")
def test_handler(db, logger):
return f"Handler called with {db} and {logger}"
result = services.handle_sync(test_handler)
expected = "Handler called with database_connection and logger_instance"
assert result == expected
def test_handle_sync_with_extra_kwargs(self):
"""Test synchronous handler with additional kwargs."""
services = Services()
services.add("db", "database_connection")
def test_handler(db, user_id):
return f"Handler called with {db} and {user_id}"
result = services.handle_sync(test_handler, user_id=123)
expected = "Handler called with database_connection and 123"
assert result == expected
def test_handle_sync_with_missing_args(self):
"""Test synchronous handler with missing arguments."""
services = Services()
services.add("db", "database_connection")
def test_handler(db_param, missing_service_param): # noqa: ARG001
return "Should not reach here"
with patch("app.library.Services.LOG") as mock_logger:
# The current implementation still calls the handler even with missing args
# This causes a TypeError, which is the actual current behavior
with pytest.raises(TypeError, match=r"missing .* required positional argument"):
services.handle_sync(test_handler)
# Should still log error about missing arguments
mock_logger.error.assert_called_once()
error_call = mock_logger.error.call_args[0][0]
assert "Missing arguments" in error_call
assert "missing_service_param" in error_call
def test_handle_sync_no_args_handler(self):
"""Test synchronous handler that takes no arguments."""
services = Services()
services.add("unused", "value")
def test_handler():
return "No args handler"
result = services.handle_sync(test_handler)
assert result == "No args handler"
@pytest.mark.asyncio
async def test_handle_async_with_matching_args(self):
"""Test asynchronous handler with matching arguments."""
services = Services()
services.add("db", "database_connection")
services.add("logger", "logger_instance")
async def test_handler(db, logger):
return f"Async handler called with {db} and {logger}"
result = await services.handle_async(test_handler)
expected = "Async handler called with database_connection and logger_instance"
assert result == expected
@pytest.mark.asyncio
async def test_handle_async_with_extra_kwargs(self):
"""Test asynchronous handler with additional kwargs."""
services = Services()
services.add("db", "database_connection")
async def test_handler(db, user_id):
return f"Async handler called with {db} and {user_id}"
result = await services.handle_async(test_handler, user_id=456)
expected = "Async handler called with database_connection and 456"
assert result == expected
@pytest.mark.asyncio
async def test_handle_async_with_missing_args(self):
"""Test asynchronous handler with missing arguments."""
services = Services()
services.add("db", "database_connection")
async def test_handler(db_param, missing_service_param): # noqa: ARG001
return "Should not reach here"
with patch("app.library.Services.LOG") as mock_logger:
# The current implementation still calls the handler even with missing args
# This causes a TypeError, which is the actual current behavior
with pytest.raises(TypeError, match=r"missing .* required positional argument"):
await services.handle_async(test_handler)
# Should still log error about missing arguments
mock_logger.error.assert_called_once()
error_call = mock_logger.error.call_args[0][0]
assert "Missing arguments" in error_call
assert "missing_service_param" in error_call
@pytest.mark.asyncio
async def test_handle_async_no_args_handler(self):
"""Test asynchronous handler that takes no arguments."""
services = Services()
services.add("unused", "value")
async def test_handler():
return "No args async handler"
result = await services.handle_async(test_handler)
assert result == "No args async handler"
def test_handle_sync_kwargs_override_services(self):
"""Test that kwargs override services with same name."""
services = Services()
services.add("param", "service_value")
def test_handler(param):
return f"Received: {param}"
result = services.handle_sync(test_handler, param="override_value")
assert result == "Received: override_value"
@pytest.mark.asyncio
async def test_handle_async_kwargs_override_services(self):
"""Test that kwargs override services with same name in async handler."""
services = Services()
services.add("param", "service_value")
async def test_handler(param):
return f"Received: {param}"
result = await services.handle_async(test_handler, param="override_value")
assert result == "Received: override_value"
def test_handle_sync_with_complex_signature(self):
"""Test synchronous handler with complex function signature."""
services = Services()
services.add("db", "database")
services.add("cache", "redis")
def complex_handler(db, cache, *args, **kwargs):
return f"db:{db}, cache:{cache}, args:{args}, kwargs:{kwargs}"
result = services.handle_sync(complex_handler, extra="value")
expected = "db:database, cache:redis, args:(), kwargs:{}"
assert result == expected
@pytest.mark.asyncio
async def test_handle_async_with_complex_signature(self):
"""Test asynchronous handler with complex function signature."""
services = Services()
services.add("db", "database")
services.add("cache", "redis")
async def complex_handler(db, cache, *args, **kwargs):
return f"db:{db}, cache:{cache}, args:{args}, kwargs:{kwargs}"
result = await services.handle_async(complex_handler, extra="value")
expected = "db:database, cache:redis, args:(), kwargs:{}"
assert result == expected
def test_service_types_preserved(self):
"""Test that different service types are preserved correctly."""
services = Services()
# Test various types
string_service = "string_value"
int_service = 42
list_service = [1, 2, 3]
dict_service = {"key": "value"}
custom_object = MagicMock()
services.add("string", string_service)
services.add("int", int_service)
services.add("list", list_service)
services.add("dict", dict_service)
services.add("object", custom_object)
assert services.get("string") == string_service
assert services.get("int") == int_service
assert services.get("list") == list_service
assert services.get("dict") == dict_service
assert services.get("object") is custom_object
def test_add_none_service(self):
"""Test adding None as a service value."""
services = Services()
services.add("none_service", None)
assert services.has("none_service") is True
assert services.get("none_service") is None
def test_service_name_edge_cases(self):
"""Test edge cases for service names."""
services = Services()
# Empty string name
services.add("", "empty_name_value")
assert services.get("") == "empty_name_value"
# Numeric string name
services.add("123", "numeric_name")
assert services.get("123") == "numeric_name"
# Special characters in name
services.add("special-chars_123!@#", "special_value")
assert services.get("special-chars_123!@#") == "special_value"
def test_overwrite_existing_service(self):
"""Test overwriting an existing service."""
services = Services()
services.add("service", "original_value")
services.add("service", "new_value")
assert services.get("service") == "new_value"
def test_singleton_persistence_across_operations(self):
"""Test that singleton behavior persists across various operations."""
# Get instance and add a service
services1 = Services()
services1.add("persistent", "value")
# Get another instance and verify service exists
services2 = Services.get_instance()
assert services2.get("persistent") == "value"
# Clear from one instance
services1.clear()
# Verify cleared in other instance
assert services2.get("persistent") is None
def test_handler_exception_propagation(self):
"""Test that exceptions in handlers are properly propagated."""
services = Services()
def failing_handler():
msg = "Handler failed"
raise ValueError(msg)
with pytest.raises(ValueError, match="Handler failed"):
services.handle_sync(failing_handler)
@pytest.mark.asyncio
async def test_async_handler_exception_propagation(self):
"""Test that exceptions in async handlers are properly propagated."""
services = Services()
async def failing_async_handler():
msg = "Async handler failed"
raise RuntimeError(msg)
with pytest.raises(RuntimeError, match="Async handler failed"):
await services.handle_async(failing_async_handler)
def test_handle_sync_with_callable_object(self):
"""Test handle_sync with callable object instead of function."""
services = Services()
services.add("data", "test_data")
class CallableHandler:
def __call__(self, data):
return f"Callable received: {data}"
handler = CallableHandler()
result = services.handle_sync(handler)
assert result == "Callable received: test_data"
@pytest.mark.asyncio
async def test_handle_async_with_callable_object(self):
"""Test handle_async with callable object instead of function."""
services = Services()
services.add("data", "test_data")
class AsyncCallableHandler:
async def __call__(self, data):
return f"Async callable received: {data}"
handler = AsyncCallableHandler()
result = await services.handle_async(handler)
assert result == "Async callable received: test_data"
def test_inspect_signature_edge_cases(self):
"""Test that inspect.signature works correctly with edge cases."""
services = Services()
# Lambda function replacement
def lambda_handler(x):
return f"Lambda: {x}"
services.add("x", "lambda_value")
result = services.handle_sync(lambda_handler)
assert result == "Lambda: lambda_value"
def test_logging_configuration(self):
"""Test that logging is properly configured."""
# This test verifies the module-level logger setup
from app.library.Services import LOG
assert isinstance(LOG, logging.Logger)
assert LOG.name == "app.library.Services"
def test_service_container_isolation(self):
"""Test that services don't interfere with each other."""
services = Services()
# Add services with potentially conflicting names
services.add("data", {"type": "database"})
services.add("data_backup", {"type": "backup"})
assert services.get("data")["type"] == "database"
assert services.get("data_backup")["type"] == "backup"
# Remove one, other should remain
services.remove("data")
assert services.get("data") is None
assert services.get("data_backup")["type"] == "backup"
def test_large_number_of_services(self):
"""Test handling a large number of services."""
services = Services()
# Add many services
num_services = 1000
for i in range(num_services):
services.add(f"service_{i}", f"value_{i}")
# Verify all exist
assert len(services.get_all()) == num_services
# Verify specific services
assert services.get("service_0") == "value_0"
assert services.get("service_500") == "value_500"
assert services.get("service_999") == "value_999"
# Clear should work efficiently
services.clear()
assert len(services.get_all()) == 0
def test_add_all_overwrites_existing(self):
"""Test that add_all overwrites existing services."""
services = Services()
services.add("existing", "original")
new_services = {
"existing": "overwritten",
"new": "value"
}
services.add_all(new_services)
assert services.get("existing") == "overwritten"
assert services.get("new") == "value"
def test_add_all_empty_dict(self):
"""Test add_all with empty dictionary."""
services = Services()
services.add("existing", "value")
services.add_all({})
# Should not affect existing services
assert services.get("existing") == "value"
assert len(services.get_all()) == 1
def test_type_var_generic_behavior(self):
"""Test that TypeVar T is handled correctly."""
services = Services()
# Add different types and ensure they're returned correctly
services.add("string", "text")
services.add("number", 42)
services.add("boolean", True) # noqa: FBT003
# Type should be preserved (runtime check)
assert isinstance(services.get("string"), str)
assert isinstance(services.get("number"), int)
assert isinstance(services.get("boolean"), bool)
def test_concurrent_access_safety(self):
"""Test basic thread safety aspects of singleton."""
import threading
import time
results = []
def get_instance():
time.sleep(0.01) # Small delay to increase chance of race condition
instance = Services()
results.append(id(instance))
# Create multiple threads
threads = []
for _ in range(10):
thread = threading.Thread(target=get_instance)
threads.append(thread)
thread.start()
# Wait for all threads
for thread in threads:
thread.join()
# All should be the same instance
assert len(set(results)) == 1, "All threads should get the same singleton instance"
def test_method_chaining_possibility(self):
"""Test that methods can be potentially chained."""
services = Services()
# While current implementation doesn't return self, test the pattern works
services.add("test1", "value1")
services.add("test2", "value2")
services.remove("test1")
assert services.get("test1") is None
assert services.get("test2") == "value2"
def test_edge_case_empty_handler_name(self):
"""Test handlers with minimal or no names."""
services = Services()
services.add("param", "value")
# Anonymous lambda
result = services.handle_sync(lambda param: f"anon: {param}")
assert result == "anon: value"
# Function with minimal signature info
def minimal(param): return param
result = services.handle_sync(minimal)
assert result == "value"
def test_services_state_isolation(self):
"""Test that different Services instances share state properly."""
# This test verifies the singleton behavior more thoroughly
s1 = Services()
s1.add("shared", "data")
s2 = Services.get_instance()
assert s2.get("shared") == "data"
s2.add("another", "value")
assert s1.get("another") == "value"
# Clear from s1 affects s2
s1.clear()
assert len(s2.get_all()) == 0

864
app/tests/test_tasks.py Normal file
View file

@ -0,0 +1,864 @@
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.library.Tasks import Task, Tasks
class TestTask:
"""Test the Task dataclass."""
def test_task_creation_with_required_fields(self):
"""Test creating a task with only required fields."""
task = Task(id="test_id", name="test_task", url="https://example.com/video")
assert task.id == "test_id"
assert task.name == "test_task"
assert task.url == "https://example.com/video"
assert task.folder == ""
assert task.preset == ""
assert task.timer == ""
assert task.template == ""
assert task.cli == ""
assert task.auto_start is True
assert task.handler_enabled is True
def test_task_creation_with_all_fields(self):
"""Test creating a task with all fields specified."""
task = Task(
id="test_id",
name="test_task",
url="https://example.com/video",
folder="test_folder",
preset="test_preset",
timer="0 */6 * * *",
template="%(title)s.%(ext)s",
cli="--extract-flat",
auto_start=False,
handler_enabled=False,
)
assert task.id == "test_id"
assert task.name == "test_task"
assert task.url == "https://example.com/video"
assert task.folder == "test_folder"
assert task.preset == "test_preset"
assert task.timer == "0 */6 * * *"
assert task.template == "%(title)s.%(ext)s"
assert task.cli == "--extract-flat"
assert task.auto_start is False
assert task.handler_enabled is False
def test_task_serialize(self):
"""Test task serialization to dictionary."""
task = Task(
id="test_id",
name="test_task",
url="https://example.com/video",
preset="test_preset",
)
serialized = task.serialize()
assert isinstance(serialized, dict)
assert serialized["id"] == "test_id"
assert serialized["name"] == "test_task"
assert serialized["url"] == "https://example.com/video"
assert serialized["preset"] == "test_preset"
assert serialized["folder"] == ""
assert serialized["auto_start"] is True
@patch("app.library.Tasks.Encoder")
def test_task_json(self, mock_encoder):
"""Test task JSON serialization."""
mock_encoder_instance = Mock()
mock_encoder_instance.encode.return_value = '{"id": "test_id"}'
mock_encoder.return_value = mock_encoder_instance
task = Task(id="test_id", name="test_task", url="https://example.com/video")
json_str = task.json()
assert json_str == '{"id": "test_id"}'
mock_encoder_instance.encode.assert_called_once()
def test_task_get_method(self):
"""Test task get method for accessing fields."""
task = Task(
id="test_id",
name="test_task",
url="https://example.com/video",
preset="test_preset",
)
assert task.get("id") == "test_id"
assert task.get("name") == "test_task"
assert task.get("preset") == "test_preset"
assert task.get("nonexistent") is None
assert task.get("nonexistent", "default_value") == "default_value"
@patch("app.library.Tasks.YTDLPOpts")
def test_task_get_ytdlp_opts_without_preset_or_cli(self, mock_ytdlp_opts):
"""Test getting YTDLPOpts without preset or CLI options."""
mock_opts_instance = Mock()
mock_ytdlp_opts.get_instance.return_value = mock_opts_instance
task = Task(id="test_id", name="test_task", url="https://example.com/video")
result = task.get_ytdlp_opts()
assert result == mock_opts_instance
mock_ytdlp_opts.get_instance.assert_called_once()
@patch("app.library.Tasks.YTDLPOpts")
def test_task_get_ytdlp_opts_with_preset_and_cli(self, mock_ytdlp_opts):
"""Test getting YTDLPOpts with preset and CLI options."""
mock_opts_instance = Mock()
mock_preset_opts = Mock()
mock_final_opts = Mock()
mock_opts_instance.preset.return_value = mock_preset_opts
mock_preset_opts.add_cli.return_value = mock_final_opts
mock_ytdlp_opts.get_instance.return_value = mock_opts_instance
task = Task(
id="test_id",
name="test_task",
url="https://example.com/video",
preset="test_preset",
cli="--extract-flat",
)
result = task.get_ytdlp_opts()
assert result == mock_final_opts
mock_opts_instance.preset.assert_called_once_with(name="test_preset")
mock_preset_opts.add_cli.assert_called_once_with("--extract-flat", from_user=True)
@patch("app.library.Tasks.archive_add")
@patch("app.library.Tasks.extract_info")
def test_task_mark_success(self, mock_extract_info, mock_archive_add):
"""Test successful task marking."""
# Mock extract_info to return playlist data
mock_extract_info.return_value = {
"_type": "playlist",
"entries": [
{
"_type": "video",
"id": "video1",
"ie_key": "Youtube",
},
{
"_type": "video",
"id": "video2",
"ie_key": "Youtube",
},
],
}
mock_archive_add.return_value = True
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
task = Task(id="test_id", name="test_task", url="https://example.com/playlist")
success, message = task.mark()
assert success is True
assert "marked as downloaded" in message
mock_archive_add.assert_called_once()
# Check that archive_add was called with the correct items
call_args = mock_archive_add.call_args[0]
archive_file = call_args[0]
items = call_args[1]
assert str(archive_file) == "/tmp/archive.txt"
assert "youtube video1" in items
assert "youtube video2" in items
def test_task_mark_no_url(self):
"""Test task marking with no URL."""
task = Task(id="test_id", name="test_task", url="")
success, message = task.mark()
assert success is False
assert "No URL found" in message
def test_task_mark_no_archive_file(self):
"""Test task marking with no archive file configured."""
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
mock_get_opts.return_value.get_all.return_value = {}
task = Task(id="test_id", name="test_task", url="https://example.com/video")
success, message = task.mark()
assert success is False
assert "No archive file found" in message
@patch("app.library.Tasks.archive_delete")
@patch("app.library.Tasks.extract_info")
def test_task_unmark_success(self, mock_extract_info, mock_archive_delete):
"""Test successful task unmarking."""
# Mock extract_info to return playlist data
mock_extract_info.return_value = {
"_type": "playlist",
"entries": [
{
"_type": "video",
"id": "video1",
"ie_key": "Youtube",
},
],
}
mock_archive_delete.return_value = True
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
task = Task(id="test_id", name="test_task", url="https://example.com/playlist")
success, message = task.unmark()
assert success is True
assert "Removed" in message
assert "items from archive file" in message
mock_archive_delete.assert_called_once()
@patch("app.library.Tasks.extract_info")
def test_task_mark_logic_invalid_extract_info(self, mock_extract_info):
"""Test _mark_logic with invalid extract_info response."""
mock_extract_info.return_value = None
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
task = Task(id="test_id", name="test_task", url="https://example.com/video")
result = task._mark_logic()
assert isinstance(result, tuple)
success, message = result
assert success is False
assert "Failed to extract information" in message
@patch("app.library.Tasks.extract_info")
def test_task_mark_logic_not_playlist(self, mock_extract_info):
"""Test _mark_logic with non-playlist type."""
mock_extract_info.return_value = {
"_type": "video",
"id": "video1",
}
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
task = Task(id="test_id", name="test_task", url="https://example.com/video")
result = task._mark_logic()
assert isinstance(result, tuple)
success, message = result
assert success is False
assert "Expected a playlist type" in message
class TestTasks:
"""Test the Tasks singleton manager."""
def setup_method(self):
"""Set up test environment before each test."""
# Reset singleton instance to ensure clean state
Tasks._reset_singleton()
def teardown_method(self):
"""Clean up after each test."""
# Reset singleton instance to prevent test pollution
Tasks._reset_singleton()
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_singleton(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test that Tasks follows singleton pattern."""
mock_config.get_instance.return_value = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue.get_instance.return_value = Mock()
instance1 = Tasks.get_instance()
instance2 = Tasks.get_instance()
assert instance1 is instance2
assert isinstance(instance1, Tasks)
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_initialization(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test Tasks initialization with proper dependencies."""
mock_config_instance = Mock(
debug=True, default_preset="test_preset", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus_instance = Mock()
mock_eventbus.get_instance.return_value = mock_eventbus_instance
mock_scheduler_instance = Mock()
mock_scheduler.get_instance.return_value = mock_scheduler_instance
mock_download_queue_instance = Mock()
mock_download_queue.get_instance.return_value = mock_download_queue_instance
with tempfile.TemporaryDirectory() as temp_dir:
tasks_file = Path(temp_dir) / "tasks.json"
tasks = Tasks(file=tasks_file, config=mock_config_instance)
# Check initialization
assert tasks._debug is True
assert tasks._default_preset == "test_preset"
assert tasks._file == tasks_file
assert tasks._tasks == []
assert tasks._scheduler == mock_scheduler_instance
assert tasks._notify == mock_eventbus_instance
assert tasks._downloadQueue == mock_download_queue_instance
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_get_all_empty(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test getting all tasks when no tasks exist."""
mock_config.get_instance.return_value = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue.get_instance.return_value = Mock()
tasks = Tasks.get_instance()
result = tasks.get_all()
assert result == []
assert isinstance(result, list)
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_get_by_id_not_found(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test getting task by ID when task doesn't exist."""
mock_config.get_instance.return_value = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue.get_instance.return_value = Mock()
tasks = Tasks.get_instance()
result = tasks.get("nonexistent_id")
assert result is None
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_load_empty_file(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test loading tasks from empty or non-existent file."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
tasks_file = Path(temp_dir) / "nonexistent.json"
tasks = Tasks(file=tasks_file, config=mock_config_instance)
result = tasks.load()
assert result == tasks # Should return self
assert tasks.get_all() == []
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_load_valid_file(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test loading tasks from valid JSON file."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler_instance = Mock()
mock_scheduler.get_instance.return_value = mock_scheduler_instance
mock_download_queue.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
tasks_file = Path(temp_dir) / "tasks.json"
# Create valid JSON file with task data
task_data = [
{
"id": "task1",
"name": "Test Task 1",
"url": "https://example.com/video1",
"preset": "default",
"timer": "0 6 * * *",
"folder": "",
"template": "",
"cli": "",
"auto_start": True,
"handler_enabled": True,
}
]
tasks_file.write_text("[\n" + "\n".join(f" {line}" for line in str(task_data).split("\n")) + "\n]")
# Mock json.loads to return our test data
with patch("json.loads", return_value=task_data):
tasks = Tasks(file=tasks_file, config=mock_config_instance)
# Mock the scheduler.add method
mock_scheduler_instance.add = Mock()
result = tasks.load()
assert result == tasks
loaded_tasks = tasks.get_all()
assert len(loaded_tasks) == 1
assert loaded_tasks[0].id == "task1"
assert loaded_tasks[0].name == "Test Task 1"
assert loaded_tasks[0].url == "https://example.com/video1"
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_load_invalid_json(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test loading tasks from file with invalid JSON."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
tasks_file = Path(temp_dir) / "tasks.json"
tasks_file.write_text("invalid json {")
tasks = Tasks(file=tasks_file, config=mock_config_instance)
result = tasks.load()
assert result == tasks
assert tasks.get_all() == [] # Should be empty due to invalid JSON
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_save_valid_tasks(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test saving valid tasks to file."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue.get_instance.return_value = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
tasks_file = Path(temp_dir) / "tasks.json"
tasks = Tasks(file=tasks_file, config=mock_config_instance)
task_data = [
{
"id": "task1",
"name": "Test Task",
"url": "https://example.com/video",
"preset": "default",
"folder": "",
"template": "",
"cli": "",
"timer": "",
"auto_start": True,
"handler_enabled": True,
}
]
result = tasks.save(task_data)
assert result == tasks
assert tasks_file.exists()
# Verify file content was written
assert tasks_file.stat().st_size > 0
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_get_by_id_found(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test getting task by ID when task exists."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue.get_instance.return_value = Mock()
tasks = Tasks(file=None, config=mock_config_instance)
# Manually add a task to the internal list
test_task = Task(id="test_id", name="Test Task", url="https://example.com/video")
tasks._tasks.append(test_task)
result = tasks.get("test_id")
assert result is not None
assert result.id == "test_id"
assert result.name == "Test Task"
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_get_all_with_tasks(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test getting all tasks when tasks exist."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue.get_instance.return_value = Mock()
tasks = Tasks(file=None, config=mock_config_instance)
# Manually add tasks to the internal list
task1 = Task(id="task1", name="Task 1", url="https://example.com/video1")
task2 = Task(id="task2", name="Task 2", url="https://example.com/video2")
tasks._tasks.extend([task1, task2])
result = tasks.get_all()
assert len(result) == 2
assert result[0].id == "task1"
assert result[1].id == "task2"
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_clear_with_scheduler_cleanup(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test clearing tasks with scheduler cleanup."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler_instance = Mock()
mock_scheduler_instance.has.return_value = True
mock_scheduler_instance.remove = Mock()
mock_scheduler.get_instance.return_value = mock_scheduler_instance
mock_download_queue.get_instance.return_value = Mock()
tasks = Tasks(file=None, config=mock_config_instance)
# Add a task with timer to the internal list
task_with_timer = Task(id="task1", name="Task 1", url="https://example.com/video1", timer="0 6 * * *")
tasks._tasks.append(task_with_timer)
result = tasks.clear()
assert result == tasks
assert len(tasks.get_all()) == 0
mock_scheduler_instance.has.assert_called_with("task1")
mock_scheduler_instance.remove.assert_called_with("task1")
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_clear_no_tasks(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test clearing when no tasks exist."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler_instance = Mock()
mock_scheduler.get_instance.return_value = mock_scheduler_instance
mock_download_queue.get_instance.return_value = Mock()
tasks = Tasks(file=None, config=mock_config_instance)
result = tasks.clear()
assert result == tasks
assert len(tasks.get_all()) == 0
# Should not call scheduler methods when no tasks exist
mock_scheduler_instance.has.assert_not_called()
mock_scheduler_instance.remove.assert_not_called()
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
def test_tasks_validate_valid_task_dict(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
"""Test task validation with valid task dictionary."""
mock_config.get_instance.return_value = Mock()
mock_eventbus.get_instance.return_value = Mock()
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue.get_instance.return_value = Mock()
valid_task = {
"name": "Test Task",
"url": "https://example.com/video",
"timer": "0 6 * * *",
"cli": "--write-info-json",
}
with patch("app.library.Tasks.validate_url") as mock_validate_url:
mock_validate_url.return_value = True
result = Tasks.validate(valid_task)
assert result is True
mock_validate_url.assert_called_once_with("https://example.com/video", allow_internal=True)
def test_tasks_validate_invalid_task_type(self):
"""Test task validation with invalid task type."""
invalid_task = "not a dict or Task"
with pytest.raises(ValueError, match="Invalid task type"):
Tasks.validate(invalid_task)
def test_tasks_validate_missing_name(self):
"""Test task validation with missing name."""
invalid_task = {
"url": "https://example.com/video",
}
with pytest.raises(ValueError, match="No name found"):
Tasks.validate(invalid_task)
def test_tasks_validate_missing_url(self):
"""Test task validation with missing URL."""
invalid_task = {
"name": "Test Task",
}
with pytest.raises(ValueError, match="No URL found"):
Tasks.validate(invalid_task)
def test_tasks_validate_invalid_url(self):
"""Test task validation with invalid URL."""
invalid_task = {
"name": "Test Task",
"url": "invalid-url",
}
with patch("app.library.Tasks.validate_url") as mock_validate_url:
mock_validate_url.side_effect = ValueError("Invalid URL")
with pytest.raises(ValueError, match="Invalid URL format"):
Tasks.validate(invalid_task)
def test_tasks_validate_invalid_timer(self):
"""Test task validation with invalid timer format."""
invalid_task = {
"name": "Test Task",
"url": "https://example.com/video",
"timer": "invalid timer",
}
with patch("app.library.Tasks.validate_url") as mock_validate_url:
mock_validate_url.return_value = True
# Import error will be raised by cronsim for invalid format
with patch("cronsim.CronSim") as mock_cronsim:
mock_cronsim.side_effect = Exception("Invalid cron format")
with pytest.raises(ValueError, match="Invalid timer format"):
Tasks.validate(invalid_task)
def test_tasks_validate_invalid_cli(self):
"""Test task validation with invalid CLI options."""
invalid_task = {
"name": "Test Task",
"url": "https://example.com/video",
"cli": "invalid --cli options",
}
with patch("app.library.Tasks.validate_url") as mock_validate_url:
mock_validate_url.return_value = True
with patch("app.library.Utils.arg_converter") as mock_arg_converter:
mock_arg_converter.side_effect = Exception("Invalid CLI args")
with pytest.raises(ValueError, match="Invalid command options for yt-dlp"):
Tasks.validate(invalid_task)
@pytest.mark.asyncio
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
@patch("app.library.Tasks.Item")
@patch("app.library.Tasks.datetime")
@patch("app.library.Tasks.time")
async def test_tasks_runner_success(
self, mock_time, mock_datetime, mock_item, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
):
"""Test successful task runner execution."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus_instance = Mock()
mock_eventbus.get_instance.return_value = mock_eventbus_instance
mock_scheduler.get_instance.return_value = Mock()
# Mock download queue
mock_download_queue_instance = Mock()
mock_download_queue_instance.add = AsyncMock(return_value={"success": True, "id": "download123"})
mock_download_queue.get_instance.return_value = mock_download_queue_instance
# Mock time and datetime
mock_time.time.side_effect = [1000.0, 1005.5] # start and end times
mock_datetime_instance = Mock()
mock_datetime_instance.isoformat.return_value = "2024-01-01T12:00:00Z"
mock_datetime.now.return_value = mock_datetime_instance
# Mock Item.format
mock_item.format.return_value = {"formatted": "item"}
tasks = Tasks(file=None, config=mock_config_instance)
test_task = Task(
id="task1",
name="Test Task",
url="https://example.com/video",
preset="test_preset",
folder="test_folder",
template="test_template",
cli="--write-info-json",
auto_start=True,
)
await tasks._runner(test_task)
# Verify download queue was called
mock_download_queue_instance.add.assert_called_once()
# Verify Item.format was called with correct parameters
mock_item.format.assert_called_once_with(
{
"url": "https://example.com/video",
"preset": "test_preset",
"folder": "test_folder",
"template": "test_template",
"cli": "--write-info-json",
"auto_start": True,
}
)
# Verify events were emitted
assert mock_eventbus_instance.emit.call_count == 2
@pytest.mark.asyncio
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
@patch("app.library.Tasks.datetime")
async def test_tasks_runner_no_url(
self, mock_datetime, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
):
"""Test task runner with no URL."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus_instance = Mock()
mock_eventbus.get_instance.return_value = mock_eventbus_instance
mock_scheduler.get_instance.return_value = Mock()
mock_download_queue_instance = Mock()
mock_download_queue.get_instance.return_value = mock_download_queue_instance
mock_datetime_instance = Mock()
mock_datetime_instance.isoformat.return_value = "2024-01-01T12:00:00Z"
mock_datetime.now.return_value = mock_datetime_instance
tasks = Tasks(file=None, config=mock_config_instance)
test_task = Task(
id="task1",
name="Test Task",
url="", # Empty URL
)
await tasks._runner(test_task)
# Verify download queue was NOT called
mock_download_queue_instance.add.assert_not_called()
@pytest.mark.asyncio
@patch("app.library.Tasks.Config")
@patch("app.library.Tasks.EventBus")
@patch("app.library.Tasks.Scheduler")
@patch("app.library.Tasks.DownloadQueue")
@patch("app.library.Tasks.Item")
@patch("app.library.Tasks.datetime")
async def test_tasks_runner_download_queue_failure(
self, mock_datetime, mock_item, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
):
"""Test task runner when download queue add fails."""
mock_config_instance = Mock(
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
)
mock_config.get_instance.return_value = mock_config_instance
mock_eventbus_instance = Mock()
mock_eventbus.get_instance.return_value = mock_eventbus_instance
mock_scheduler.get_instance.return_value = Mock()
# Mock download queue to raise exception
mock_download_queue_instance = Mock()
mock_download_queue_instance.add = AsyncMock(side_effect=Exception("Queue error"))
mock_download_queue.get_instance.return_value = mock_download_queue_instance
mock_datetime_instance = Mock()
mock_datetime_instance.isoformat.return_value = "2024-01-01T12:00:00Z"
mock_datetime.now.return_value = mock_datetime_instance
mock_item.format.return_value = {"formatted": "item"}
tasks = Tasks(file=None, config=mock_config_instance)
test_task = Task(
id="task1",
name="Test Task",
url="https://example.com/video",
)
await tasks._runner(test_task)
# Verify error event was emitted (check last call)
calls = mock_eventbus_instance.emit.call_args_list
assert len(calls) > 0
# Verify the last call was an error event
last_call = calls[-1]
assert "Task failed" in str(last_call)

View file

@ -26,7 +26,6 @@ from app.library.Utils import (
encrypt_data,
extract_info,
extract_ytdlp_logs,
find_unpickleable,
get,
get_archive_id,
get_file,
@ -1424,30 +1423,6 @@ class TestYtdlpReject:
assert isinstance(message, str)
class TestFindUnpickleable:
"""Test the find_unpickleable function."""
def test_find_unpickleable_simple(self):
"""Test with simple pickleable object."""
obj = {"key": "value", "number": 42}
try:
find_unpickleable(obj)
# Should not find any unpickleable items
except Exception:
# Function might raise exceptions for complex objects
pass
def test_find_unpickleable_complex(self):
"""Test with complex object."""
obj = {"func": lambda x: x} # Lambda is not pickleable
try:
find_unpickleable(obj)
except Exception:
pass
class TestInitClass:
"""Test the init_class function."""

508
app/tests/test_ytdlpopts.py Normal file
View file

@ -0,0 +1,508 @@
"""Tests for YTDLPOpts class."""
from unittest.mock import Mock, patch
import pytest
from app.library.Presets import Preset
from app.library.YTDLPOpts import YTDLPOpts
class TestYTDLPOpts:
"""Test the YTDLPOpts class."""
def test_constructor_initializes_correctly(self):
"""Test that YTDLPOpts constructor initializes all attributes."""
with patch("app.library.YTDLPOpts.Config") as mock_config:
mock_config_instance = Mock()
mock_config.get_instance.return_value = mock_config_instance
opts = YTDLPOpts()
assert opts._config is mock_config_instance
assert opts._item_opts == {}
assert opts._preset_opts == {}
assert opts._item_cli == []
assert opts._preset_cli == ""
def test_get_instance_returns_reset_instance(self):
"""Test that get_instance returns a reset YTDLPOpts instance."""
with patch("app.library.YTDLPOpts.Config"):
opts = YTDLPOpts.get_instance()
assert isinstance(opts, YTDLPOpts)
assert opts._item_opts == {}
assert opts._preset_opts == {}
assert opts._item_cli == []
assert opts._preset_cli == ""
def test_add_cli_with_valid_args(self):
"""Test adding valid CLI arguments."""
with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.arg_converter") as mock_converter:
mock_converter.return_value = {"format": "best"}
opts = YTDLPOpts()
result = opts.add_cli("--format best", from_user=False)
assert result is opts # Returns self for chaining
assert "--format best" in opts._item_cli
mock_converter.assert_called_once_with(args="--format best", level=False)
def test_add_cli_with_invalid_args_raises_error(self):
"""Test that invalid CLI arguments raise ValueError."""
with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.arg_converter") as mock_converter:
mock_converter.side_effect = Exception("Invalid argument")
opts = YTDLPOpts()
with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"):
opts.add_cli("--invalid-arg", from_user=True)
def test_add_cli_with_empty_args_returns_self(self):
"""Test that empty or invalid args return self without processing."""
with patch("app.library.YTDLPOpts.Config"):
opts = YTDLPOpts()
# Test empty string
result1 = opts.add_cli("", from_user=False)
assert result1 is opts
assert len(opts._item_cli) == 0
# Test short string
result2 = opts.add_cli("a", from_user=False)
assert result2 is opts
assert len(opts._item_cli) == 0
# Test non-string (this should be handled gracefully)
result3 = opts.add_cli(123, from_user=False) # type: ignore
assert result3 is opts
assert len(opts._item_cli) == 0
def test_add_with_valid_config(self):
"""Test adding configuration options."""
with patch("app.library.YTDLPOpts.Config"):
opts = YTDLPOpts()
config = {"format": "best", "quality": "720p"}
result = opts.add(config, from_user=False)
assert result is opts
assert opts._item_opts["format"] == "best"
assert opts._item_opts["quality"] == "720p"
def test_add_with_user_config_filters_bad_options(self):
"""Test that user config filters out dangerous options."""
with (
patch("app.library.YTDLPOpts.Config"),
patch("app.library.YTDLPOpts.REMOVE_KEYS", [{"paths": "-P, --paths", "outtmpl": "-o, --output"}]),
):
opts = YTDLPOpts()
config = {
"format": "best",
"paths": "/dangerous/path", # Should be filtered
"outtmpl": "dangerous_template", # Should be filtered
"quality": "720p",
}
result = opts.add(config, from_user=True)
assert result is opts
assert opts._item_opts["format"] == "best"
assert opts._item_opts["quality"] == "720p"
assert "paths" not in opts._item_opts
assert "outtmpl" not in opts._item_opts
def test_preset_with_valid_preset(self):
"""Test applying a valid preset."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.Presets") as mock_presets,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
):
# Mock config
mock_config_instance = Mock()
mock_config_instance.config_path = "/test/config"
mock_config_instance.download_path = "/test/downloads"
mock_config_instance.temp_path = "/test/temp"
mock_config_instance.output_template_chapter = "chapter_template"
mock_config.get_instance.return_value = mock_config_instance
# Mock preset
mock_preset = Mock(spec=Preset)
mock_preset.id = "test_preset"
mock_preset.name = "Test Preset"
mock_preset.cli = "--format best"
mock_preset.cookies = None
mock_preset.template = "custom_template"
mock_preset.folder = "custom_folder"
mock_presets_instance = Mock()
mock_presets_instance.get.return_value = mock_preset
mock_presets.get_instance.return_value = mock_presets_instance
mock_converter.return_value = {"format": "best"}
with patch("app.library.YTDLPOpts.calc_download_path") as mock_calc_path:
mock_calc_path.return_value = "/test/downloads/custom_folder"
opts = YTDLPOpts()
result = opts.preset("test_preset")
assert result is opts
assert opts._preset_cli == "--format best"
assert opts._preset_opts["outtmpl"]["default"] == "custom_template"
assert opts._preset_opts["paths"]["home"] == "/test/downloads/custom_folder"
def test_preset_with_nonexistent_preset(self):
"""Test applying a nonexistent preset returns self."""
with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.Presets") as mock_presets:
mock_presets_instance = Mock()
mock_presets_instance.get.return_value = None
mock_presets.get_instance.return_value = mock_presets_instance
opts = YTDLPOpts()
result = opts.preset("nonexistent")
assert result is opts
assert opts._preset_cli == ""
assert opts._preset_opts == {}
def test_preset_with_cookies_creates_file(self):
"""Test that preset with cookies creates cookie file."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.Presets") as mock_presets,
patch("app.library.YTDLPOpts.load_cookies") as mock_load_cookies,
):
# Mock config
mock_config_instance = Mock()
mock_config_instance.config_path = "/test/config"
mock_config.get_instance.return_value = mock_config_instance
# Mock preset with cookies
mock_preset = Mock(spec=Preset)
mock_preset.id = "cookie_preset"
mock_preset.name = "Cookie Preset"
mock_preset.cli = None
mock_preset.cookies = "cookie_data"
mock_preset.template = None
mock_preset.folder = None
mock_presets_instance = Mock()
mock_presets_instance.get.return_value = mock_preset
mock_presets.get_instance.return_value = mock_presets_instance
# Use real Path but mock file operations
with (
patch("pathlib.Path.exists", return_value=False),
patch("pathlib.Path.mkdir"),
patch("pathlib.Path.write_text"),
):
opts = YTDLPOpts()
opts.preset("cookie_preset")
# Check that the cookie file would be created at the right path
expected_path = "/test/config/cookies/cookie_preset.txt"
assert opts._preset_opts["cookiefile"] == expected_path
mock_load_cookies.assert_called_once()
def test_preset_with_invalid_cli_raises_error(self):
"""Test that preset with invalid CLI raises ValueError."""
with (
patch("app.library.YTDLPOpts.Config"),
patch("app.library.YTDLPOpts.Presets") as mock_presets,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
):
mock_preset = Mock(spec=Preset)
mock_preset.id = "bad_preset"
mock_preset.name = "Bad Preset"
mock_preset.cli = "--invalid-option"
mock_preset.cookies = None
mock_preset.template = None
mock_preset.folder = None
mock_presets_instance = Mock()
mock_presets_instance.get.return_value = mock_preset
mock_presets.get_instance.return_value = mock_presets_instance
mock_converter.side_effect = Exception("Invalid CLI")
opts = YTDLPOpts()
with pytest.raises(ValueError, match="Invalid preset 'Bad Preset' command options for yt-dlp"):
opts.preset("bad_preset")
def test_get_all_with_default_options(self):
"""Test get_all returns correct default options."""
with patch("app.library.YTDLPOpts.Config") as mock_config:
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
mock_config_instance.temp_path = "/temp"
mock_config_instance.output_template = "default_template"
mock_config_instance.output_template_chapter = "chapter_template"
mock_config_instance.debug = False
mock_config.get_instance.return_value = mock_config_instance
with patch("app.library.YTDLPOpts.merge_dict") as mock_merge:
mock_merge.return_value = {
"paths": {"home": "/downloads", "temp": "/temp"},
"outtmpl": {"default": "default_template", "chapter": "chapter_template"},
}
opts = YTDLPOpts()
result = opts.get_all(keep=True)
assert result["paths"]["home"] == "/downloads"
assert result["outtmpl"]["default"] == "default_template"
def test_get_all_processes_cli_arguments(self):
"""Test get_all processes CLI arguments correctly."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
mock_config_instance.temp_path = "/temp"
mock_config_instance.output_template = "default_template"
mock_config_instance.output_template_chapter = "chapter_template"
mock_config_instance.debug = False
mock_config_instance.get_replacers.return_value = {"home": "/test"}
mock_config.get_instance.return_value = mock_config_instance
mock_converter.return_value = {"format": "best"}
mock_merge.return_value = {"format": "best"}
opts = YTDLPOpts()
opts._item_cli = ["--format best"]
opts._preset_cli = "--quality 720p"
opts.get_all(keep=True)
# Should join CLI args and process replacers
expected_cli = "--quality 720p\n--format best"
mock_converter.assert_called_once_with(args=expected_cli, level=True)
def test_get_all_handles_format_special_cases(self):
"""Test get_all handles special format values correctly."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
mock_config_instance.temp_path = "/temp"
mock_config_instance.output_template = "default_template"
mock_config_instance.output_template_chapter = "chapter_template"
mock_config_instance.debug = False
mock_config.get_instance.return_value = mock_config_instance
opts = YTDLPOpts()
# Test "not_set" format
mock_merge.return_value = {"format": "not_set"}
result = opts.get_all(keep=True)
assert result["format"] is None
# Test "default" format
mock_merge.return_value = {"format": "default"}
result = opts.get_all(keep=True)
assert result["format"] is None
# Test "best" format
mock_merge.return_value = {"format": "best"}
result = opts.get_all(keep=True)
assert result["format"] is None
# Test "-best" format (should remove leading dash)
mock_merge.return_value = {"format": "-best"}
result = opts.get_all(keep=True)
assert result["format"] == "best"
def test_get_all_with_invalid_cli_raises_error(self):
"""Test get_all raises error for invalid CLI arguments."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
mock_config_instance.temp_path = "/temp"
mock_config_instance.output_template = "default_template"
mock_config_instance.output_template_chapter = "chapter_template"
mock_config_instance.debug = False
mock_config_instance.get_replacers.return_value = {}
mock_config.get_instance.return_value = mock_config_instance
mock_converter.side_effect = Exception("Invalid CLI")
opts = YTDLPOpts()
opts._item_cli = ["--invalid-arg"]
with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"):
opts.get_all()
def test_get_all_resets_unless_keep_true(self):
"""Test get_all resets instance unless keep=True."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
mock_config_instance.temp_path = "/temp"
mock_config_instance.output_template = "default_template"
mock_config_instance.output_template_chapter = "chapter_template"
mock_config_instance.debug = False
mock_config_instance.get_replacers.return_value = {} # Return empty dict
mock_config.get_instance.return_value = mock_config_instance
mock_merge.return_value = {}
opts = YTDLPOpts()
opts._item_opts = {"test": "value"}
opts._item_cli = ["--test"]
# Test with keep=False (default)
opts.get_all(keep=False)
assert opts._item_opts == {}
assert opts._item_cli == []
# Reset test data
opts._item_opts = {"test": "value"}
opts._item_cli = ["--test"]
# Test with keep=True
opts.get_all(keep=True)
assert opts._item_opts == {"test": "value"}
assert opts._item_cli == ["--test"]
def test_reset_clears_all_options(self):
"""Test reset clears all internal state."""
with patch("app.library.YTDLPOpts.Config"):
opts = YTDLPOpts()
# Set some state
opts._item_opts = {"format": "best"}
opts._preset_opts = {"quality": "720p"}
opts._item_cli = ["--format best"]
opts._preset_cli = "--quality 720p"
result = opts.reset()
assert result is opts
assert opts._item_opts == {}
assert opts._preset_opts == {}
assert opts._item_cli == []
assert opts._preset_cli == ""
def test_method_chaining(self):
"""Test that methods support chaining."""
with (
patch("app.library.YTDLPOpts.Config"),
patch("app.library.YTDLPOpts.arg_converter"),
patch("app.library.YTDLPOpts.Presets") as mock_presets,
):
mock_presets_instance = Mock()
mock_presets_instance.get.return_value = None
mock_presets.get_instance.return_value = mock_presets_instance
opts = YTDLPOpts()
# Test chaining
result = opts.add_cli("--format best").add({"quality": "720p"}).preset("nonexistent").reset()
assert result is opts
def test_debug_logging(self):
"""Test debug logging when enabled."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
patch("app.library.YTDLPOpts.LOG") as mock_log,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
mock_config_instance.temp_path = "/temp"
mock_config_instance.output_template = "default_template"
mock_config_instance.output_template_chapter = "chapter_template"
mock_config_instance.debug = True # Enable debug
mock_config.get_instance.return_value = mock_config_instance
test_data = {"format": "best"}
mock_merge.return_value = test_data
opts = YTDLPOpts()
opts.get_all(keep=True)
mock_log.debug.assert_called_once_with(f"Final yt-dlp options: '{test_data!s}'.")
def test_cookie_loading_error_handling(self):
"""Test error handling when cookie loading fails."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.Presets") as mock_presets,
patch("app.library.YTDLPOpts.load_cookies") as mock_load_cookies,
patch("app.library.YTDLPOpts.LOG") as mock_log,
):
# Mock config
mock_config_instance = Mock()
mock_config_instance.config_path = "/test/config"
mock_config.get_instance.return_value = mock_config_instance
# Mock preset with cookies
mock_preset = Mock(spec=Preset)
mock_preset.id = "cookie_preset"
mock_preset.name = "Cookie Preset"
mock_preset.cli = None
mock_preset.cookies = "invalid_cookie_data"
mock_preset.template = None
mock_preset.folder = None
mock_presets_instance = Mock()
mock_presets_instance.get.return_value = mock_preset
mock_presets.get_instance.return_value = mock_presets_instance
# Mock cookie loading failure
mock_load_cookies.side_effect = ValueError("Invalid cookies")
with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.write_text"):
opts = YTDLPOpts()
opts.preset("cookie_preset")
# Should log error but not raise
mock_log.error.assert_called_once()
error_args = mock_log.error.call_args[0][0]
assert "Failed to load 'Cookie Preset' cookies" in error_args
# cookiefile should not be set
assert "cookiefile" not in opts._preset_opts
def test_replacer_substitution_in_cli(self):
"""Test that CLI arguments get replacer substitution."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
mock_config_instance.temp_path = "/temp"
mock_config_instance.output_template = "default_template"
mock_config_instance.output_template_chapter = "chapter_template"
mock_config_instance.debug = False
mock_config_instance.get_replacers.return_value = {"home": "/actual/home", "user": "testuser"}
mock_config.get_instance.return_value = mock_config_instance
mock_converter.return_value = {"output": "/actual/home/testuser"}
mock_merge.return_value = {}
opts = YTDLPOpts()
opts._item_cli = ["--output %(home)s/%(user)s"]
opts.get_all(keep=True)
# Should replace %(home)s and %(user)s
expected_cli = "--output /actual/home/testuser"
mock_converter.assert_called_once_with(args=expected_cli, level=True)

View file

@ -209,4 +209,9 @@ testpaths = ["app/tests"]
addopts = "-v --tb=short"
[dependency-groups]
dev = ["pytest>=8.4.2", "pytest-asyncio>=1.1.0", "ruff>=0.13.0"]
dev = [
"debugpy>=1.8.16",
"pytest>=8.4.2",
"pytest-asyncio>=1.1.0",
"ruff>=0.13.0",
]

View file

@ -83,7 +83,8 @@
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp <code>[--match-filters]</code> logic.</span>
<span>yt-dlp <code>--match-filters</code> logic with <code>OR</code>, <code>||</code>
support.</span>
</span>
</div>
</div>
@ -211,7 +212,7 @@
</div>
</div>
</div>
<span class="help">
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The url to test the filter against.</span>
</span>
@ -226,9 +227,10 @@
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="test_data.in_progress"
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'" required>
</div>
<span class="help">
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--match-filters]</code> filter logic.</span><br>
<span>yt-dlp <code>--match-filters</code> logic with <code>OR</code>, <code>||</code>
support.</span>
</span>
</div>

View file

@ -175,7 +175,7 @@
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
</button>
</div>
<div class="control is-expanded" v-if="item.url && !config.app.basic_mode">
<div class="control is-expanded" v-if="item.url">
<Dropdown icons="fa-solid fa-cogs" @open_state="(s: boolean) => table_container = !s"
:button_classes="'is-small'" label="Actions">
<template v-if="'finished' === item.status && item.filename">
@ -347,7 +347,7 @@
</a>
</div>
<div class="column" v-if="!config.app.basic_mode">
<div class="column">
<Dropdown icons="fa-solid fa-cogs" label="Actions">
<template v-if="'finished' === item.status && item.filename">
<NuxtLink @click="playVideo(item)" class="dropdown-item">
@ -366,14 +366,12 @@
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"
v-if="!config.app.basic_mode">
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)"
v-if="!config.app.basic_mode">
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
<span>Local Information</span>
</NuxtLink>

View file

@ -25,7 +25,7 @@
</div>
</div>
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="column is-4-tablet is-12-mobile">
<div class="field has-addons">
<div class="control" @click="show_description = !show_description">
<label class="button is-static">
@ -54,7 +54,7 @@
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="column is-6-tablet is-12-mobile">
<div class="field has-addons" v-tooltip="'Folder relative to ' + config.app.download_path">
<div class="control">
<label class="button is-static">
@ -70,7 +70,7 @@
</div>
</div>
<div class="column" v-if="!config.app.basic_mode">
<div class="column">
<button type="button" class="button is-info" @click="showAdvanced = !showAdvanced"
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
<span class="icon"><i class="fa-solid fa-cog" /></span>
@ -79,7 +79,7 @@
</div>
<div class="column is-12"
v-if="show_description && !config.app.basic_mode && !hasFormatInConfig && get_preset(form.preset)?.description">
v-if="show_description && !hasFormatInConfig && get_preset(form.preset)?.description">
<div class="is-overflow-auto" style="max-height: 150px;">
<div class="is-ellipsis is-clickable" @click="expand_description">
<span class="icon"><i class="fa-solid fa-info" /></span> {{ get_preset(form.preset)?.description }}
@ -88,7 +88,7 @@
</div>
</div>
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
<div class="columns is-multiline is-mobile" v-if="showAdvanced">
<div class="column is-3-tablet is-12-mobile">
<DLInput id="force_download" type="bool" label="Force download"
v-model="dlFields['--no-download-archive']" icon="fa-solid fa-download"
@ -137,7 +137,7 @@
</div>
<div class="column is-6-tablet is-12-mobile">
<DLInput id="ytdlpCookies" type="text" label="Cookies for yt-dlp" v-model="form.cookies"
<DLInput id="ytdlpCookies" type="text" label="Cookies" v-model="form.cookies"
icon="fa-solid fa-cookie" :disabled="!socket.isConnected || addInProgress"
:placeholder="getDefault('cookies', '')">
<template #help>
@ -290,37 +290,35 @@ const addDownload = async () => {
return false;
}
if (false === config.app.basic_mode) {
if (dlFields.value && Object.keys(dlFields.value).length > 0) {
const joined = []
for (const [key, value] of Object.entries(dlFields.value)) {
if (false === is_valid(key)) {
continue
}
if ([undefined, null, '', false].includes(value as any)) {
continue
}
const keyRegex = new RegExp(`(^|\\s)${key}(\\s|$)`);
if (form_cli && keyRegex.test(form_cli)) {
continue;
}
joined.push(true === value ? `${key}` : `${key} ${value}`)
if (dlFields.value && Object.keys(dlFields.value).length > 0) {
const joined = []
for (const [key, value] of Object.entries(dlFields.value)) {
if (false === is_valid(key)) {
continue
}
if (joined.length > 0) {
form_cli = form_cli ? `${form_cli} ${joined.join(' ')}` : joined.join(' ')
if ([undefined, null, '', false].includes(value as any)) {
continue
}
const keyRegex = new RegExp(`(^|\\s)${key}(\\s|$)`);
if (form_cli && keyRegex.test(form_cli)) {
continue;
}
joined.push(true === value ? `${key}` : `${key} ${value}`)
}
if (form_cli && form_cli.trim()) {
const options = await convertOptions(form_cli)
if (null === options) {
return
}
if (joined.length > 0) {
form_cli = form_cli ? `${form_cli} ${joined.join(' ')}` : joined.join(' ')
}
}
if (form_cli && form_cli.trim()) {
const options = await convertOptions(form_cli)
if (null === options) {
return
}
}
@ -333,12 +331,12 @@ const addDownload = async () => {
const data = {
url: url,
preset: config.app.basic_mode ? config.app.default_preset : form.value.preset,
folder: config.app.basic_mode ? null : form.value.folder,
template: config.app.basic_mode ? null : form.value.template,
cookies: config.app.basic_mode ? '' : form.value.cookies,
cli: config.app.basic_mode ? null : form_cli,
auto_start: config.app.basic_mode ? true : auto_start.value
preset: form.value.preset || config.app.default_preset,
folder: form.value.folder,
template: form.value.template,
cookies: form.value.cookies,
cli: form_cli,
auto_start: auto_start.value
} as item_request
if (form.value?.extras && Object.keys(form.value.extras).length > 0) {

View file

@ -147,19 +147,17 @@
</NuxtLink>
</template>
<template v-if="!config.app.basic_mode">
<hr class="dropdown-divider" />
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
<span>Local Information</span>
</NuxtLink>
</template>
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
<span>Local Information</span>
</NuxtLink>
</Dropdown>
</td>
</tr>
@ -272,17 +270,15 @@
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" v-if="!config.app.basic_mode" />
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"
v-if="!config.app.basic_mode">
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)"
v-if="!config.app.basic_mode">
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
<span>Local Information</span>
</NuxtLink>

View file

@ -24,9 +24,8 @@
</div>
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
<div class="navbar-start" v-if="!config.app.basic_mode">
<NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.browser_enabled">
<div class="navbar-start">
<NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span>Files</span>
</NuxtLink>
@ -55,7 +54,7 @@
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown" v-if="!config.app.basic_mode">
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
<span class="icon"><i class="fas fa-tools" /></span>
<span>Other</span>

View file

@ -49,9 +49,6 @@
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">Files Browser</span>
</div>
</div>
</div>
@ -278,14 +275,6 @@ watch(masterSelectAll, v => {
}
})
watch(() => config.app.basic_mode, async v => {
if (!config.isLoaded() || !v) {
return
}
await navigateTo('/')
}, { immediate: true })
const filteredItems = computed<FileItem[]>(() => {
if (!search.value) {
return sortedItems(items.value)
@ -335,20 +324,6 @@ const sortedItems = (items: FileItem[]): FileItem[] => {
const model_item = ref<any>()
const closeModel = (): void => { model_item.value = null }
watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) {
return
}
await navigateTo('/')
})
watch(() => config.app.browser_enabled, async () => {
if (config.app.browser_enabled) {
return
}
await navigateTo('/')
})
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(path.value, true)

View file

@ -104,8 +104,10 @@
<div class="column is-12" v-if="items && items.length > 0 && !toggleForm">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>The filtering rely on yt-dlp <code>--match-filter</code> logic, whatever works there works here as well
and uses the same logic boolean and operators.</li>
<li>Filtering is based on yt-dlps <code>--match-filter</code> logic. Any expression that works with yt-dlp
will also work here, including the same boolean operators. We added extended support for the <code>OR</code>
( <code>||</code> ) operator, which yt-dlp does not natively support. This allows you to combine multiple
conditions more flexibly.</li>
<li>
The primary use case for this feature is to apply custom cli arguments to specific returned info.
</li>
@ -116,7 +118,8 @@
</li>
<li>
The data which the filter is applied on is the same data that yt-dlp returns, simply, click on the
information button, and check the data to craft your filter.
information button, and check the data to craft your filter. You will get instant feedback if the
filter matches or not.
</li>
</ul>
</Message>
@ -130,7 +133,6 @@ import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
type ConditionItemWithUI = ConditionItem & { raw?: boolean }
const toast = useNotification()
const config = useConfigStore()
const socket = useSocketStore()
const box = useConfirm()
const isMobile = useMediaQuery({ maxWidth: 1024 })
@ -144,13 +146,6 @@ const initialLoad = ref(true)
const addInProgress = ref(false)
const remove_keys = ['in_progress', 'raw']
watch(() => config.app.basic_mode, async v => {
if (!config.isLoaded() || !v) {
return
}
await navigateTo("/")
}, { immediate: true })
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)

View file

@ -81,13 +81,6 @@ watch(() => isLoading.value, async value => {
focusInput()
}, { immediate: true })
watch(() => config.app.basic_mode, async () => {
if (!config.isLoaded() || !config.app.basic_mode) {
return
}
await navigateTo('/')
}, { immediate: true })
watch(() => config.app.console_enabled, async () => {
if (config.app.console_enabled) {
return
@ -108,7 +101,7 @@ const runCommand = async () => {
return
}
if (config.app.basic_mode || !config.app.console_enabled) {
if (true !== config.app.console_enabled) {
await navigateTo('/')
toast.error('Console is disabled in the configuration. Please enable it to use this feature.')
return
@ -183,13 +176,6 @@ const writer = (s: string) => {
const loader = () => isLoading.value = false
watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) {
return
}
await navigateTo('/')
})
onMounted(async () => {
document.addEventListener('resize', handle_event);
focusInput()

View file

@ -24,7 +24,7 @@
</button>
</p>
<p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode">
<p class="control">
<button class="button is-warning" @click="pauseDownload" v-if="false === config.paused">
<span class="icon"><i class="fas fa-pause" /></span>
<span v-if="!isMobile">Pause</span>
@ -35,7 +35,7 @@
</button>
</p>
<p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode">
<p class="control">
<button class="button is-primary has-tooltip-bottom" @click="config.showForm = !config.showForm">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span v-if="!isMobile">New Download</span>
@ -62,54 +62,7 @@
</div>
</div>
<div v-if="config.is_loaded" class="columns is-multiline">
<div class="column is-12">
<DeprecatedNotice :version="config.app.app_version" title="Deprecation Notice" tone="warning"
icon="fas fa-exclamation-triangle fa-fade fa-spin-10">
<p>
The following environment variables and features are deprecated and will be removed in
<strong class="has-text-danger">v0.10.x</strong>
</p>
<ul>
<li>
The following ENVs <strong>YTP_KEEP_ARCHIVE</strong> and <strong>YTP_SOCKET_TIMEOUT</strong> will be
removed.
Their behavior will be part of the <strong>default presets</strong>. To keep your current behavior
<strong>and avoid re-downloading</strong>, please add the following <strong>Command options for
yt-dlp</strong> to your presets:
<code>--socket-timeout 30 --download-archive %(config_path)s/archive.log</code>
</li>
<li>
The global yt-dlp config file <strong>/config/ytdlp.cli</strong> will be removed. Please migrate to
presets.
</li>
<li>The <strong>archive.manual.log</strong> feature has been removed.</li>
</ul>
<p>
These changes help reduce confusion from multiple sources of truth. Going forward, <strong>presets</strong>
and the <strong>Command options for yt-dlp</strong> will be the single source of truth.
</p>
<p>
Notable changes in <strong>v0.10.x</strong>:
</p>
<ul>
<li>
The file browser feature is going to be enabled by default. and the associated ENV
<strong>YTP_BROWSER_ENABLED</strong> will be removed, <strong>YTP_BROWSER_CONTROL_ENABLED</strong> will
remain and
will default to <strong>false</strong>.
</li>
<li>
The <strong>Basic mode</strong> (which limited the interface to just the new download form) along it's
associated ENV <strong>YTP_BASIC_MODE</strong> is being removed. Everything except what is available
behind configurable flag will become part of the standard interface.
</li>
</ul>
</DeprecatedNotice>
</div>
</div>
<NewDownload v-if="config.showForm || config.app.basic_mode"
<NewDownload v-if="config.showForm"
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:item="item_form" @clear_form="item_form = {}" />
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
@ -128,7 +81,6 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import DeprecatedNotice from '~/components/DeprecatedNotice.vue'
import type { item_request } from '~/types/item'
import type { StoreItem } from '~/types/store'

View file

@ -155,13 +155,6 @@ watch(toggleFilter, () => {
}
});
watch(() => config.app.basic_mode, async v => {
if (!config.isLoaded() || !v) {
return
}
await navigateTo('/')
}, { immediate: true })
watch(() => config.app.file_logging, async v => {
if (v) {
return

View file

@ -221,7 +221,6 @@ import { useStorage } from '@vueuse/core'
import type { notification, notificationImport } from '~/types/notification'
const toast = useNotification()
const config = useConfigStore()
const socket = useSocketStore()
const box = useConfirm()
const display_style = useStorage<string>("tasks_display_style", "cards")
@ -244,13 +243,6 @@ const isLoading = ref(false)
const initialLoad = ref(true)
const addInProgress = ref(false)
watch(() => config.app.basic_mode, async v => {
if (!config.isLoaded() || !v) {
return
}
await navigateTo('/')
}, { immediate: true })
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)

View file

@ -39,8 +39,8 @@
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">Custom presets. The presets are simply pre-defined yt-dlp settings
that you want to apply to given download.</span>
<span class="subtitle">Presets are pre-defined command options for yt-dlp that you want to apply to given
download.</span>
</div>
</div>
</div>
@ -192,16 +192,7 @@
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>
When you export preset, it doesn't include <code>Cookies</code> field for security reasons.
</li>
<li>
If you have created a global <code>config/ytdlp.cli</code> file, it will be appended to your exported
preset
<code><i class="fa-solid fa-terminal" /> Command options for yt-dlp</code> field for better
compatibility
and completeness.
</li>
<li>When you export preset, it doesn't include <code>Cookies</code> field for security reasons.</li>
</ul>
</Message>
</div>
@ -233,13 +224,6 @@ const remove_keys = ['raw', 'toggle_description']
const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default))
watch(() => config.app.basic_mode, async v => {
if (!config.isLoaded() || !v) {
return
}
await navigateTo('/')
}, { immediate: true })
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)
@ -391,11 +375,6 @@ const exportItem = (item: Preset) => {
}
}
if (config?.app?.ytdlp_cli) {
const val = `# exported from ytdlp.cli #\n${config.app.ytdlp_cli}\n# exported from ytdlp.cli #\n`
userData.cli = userData.cli ? val + '\n' + userData.cli : val
}
userData['_type'] = 'preset'
userData['_version'] = '2.5'

View file

@ -444,13 +444,6 @@ watch(masterSelectAll, value => {
}
})
watch(() => config.app.basic_mode, async v => {
if (!config.isLoaded() || !v) {
return
}
await navigateTo('/')
}, { immediate: true })
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
socket.on('item_status', statusHandler)

View file

@ -11,13 +11,10 @@ export const useConfigStore = defineStore('config', () => {
output_template: '',
ytdlp_version: '',
max_workers: 1,
basic_mode: true,
default_preset: 'default',
instance_title: null,
console_enabled: false,
browser_enabled: false,
browser_control_enabled: false,
ytdlp_cli: '',
file_logging: false,
is_native: false,
app_version: '',
@ -30,7 +27,7 @@ export const useConfigStore = defineStore('config', () => {
presets: [
{
'name': 'default',
'description': 'Default preset for downloads',
'description': 'Default preset',
'folder': '',
'template': '',
'cookies': '',

View file

@ -14,20 +14,14 @@ type AppConfig = {
ytdlp_version: string
/** Maximum number of concurrent download workers */
max_workers: number
/** Indicates if the app is in basic mode, which may limit some features */
basic_mode: boolean
/** Default preset name, e.g. "default" */
default_preset: string
/** Instance title for the app, null if not set */
instance_title: string | null
/** Indicates if the console is enabled */
console_enabled: boolean
/** Indicates if the file browser is enabled */
browser_enabled: boolean
/** Indicates if the file browser control is enabled */
browser_control_enabled: boolean
/** Command options for yt-dlp */
ytdlp_cli: string
/** Indicates if file logging is enabled */
file_logging: boolean
/** Indicates if the app is running in a native environment (e.g., Electron) */

View file

@ -1513,6 +1513,7 @@ installer = [
[package.dev-dependencies]
dev = [
{ name = "debugpy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
@ -1556,6 +1557,7 @@ provides-extras = ["installer"]
[package.metadata.requires-dev]
dev = [
{ name = "debugpy", specifier = ">=1.8.16" },
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.1.0" },
{ name = "ruff", specifier = ">=0.13.0" },