Merge pull request #417 from arabcoders/dev
Remove deprecated features and flags
This commit is contained in:
commit
2323739c49
59 changed files with 4159 additions and 853 deletions
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}")
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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("/"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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]))))
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
1101
app/tests/test_notifications.py
Normal file
1101
app/tests/test_notifications.py
Normal file
File diff suppressed because it is too large
Load diff
599
app/tests/test_presets.py
Normal file
599
app/tests/test_presets.py
Normal 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
552
app/tests/test_services.py
Normal 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
864
app/tests/test_tasks.py
Normal 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)
|
||||
|
|
@ -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
508
app/tests/test_ytdlpopts.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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-dlp’s <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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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': '',
|
||||
|
|
|
|||
6
ui/app/types/config.d.ts
vendored
6
ui/app/types/config.d.ts
vendored
|
|
@ -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) */
|
||||
|
|
|
|||
2
uv.lock
2
uv.lock
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue