import asyncio import datetime import json import logging import os import time from email.utils import formatdate from sqlite3 import Connection import yt_dlp from .AsyncPool import AsyncPool from .config import Config from .DataStore import DataStore from .Download import Download from .Emitter import Emitter from .ItemDTO import ItemDTO from .Utils import ExtractInfo, get_opts, calcDownloadPath, isDownloaded, mergeConfig LOG = logging.getLogger("DownloadQueue") TYPE_DONE: str = "done" TYPE_QUEUE: str = "queue" class DownloadQueue: paused: asyncio.Event event: asyncio.Event | None = None pool: AsyncPool | None = None def __init__(self, emitter: Emitter, connection: Connection): self.config = Config.get_instance() self.emitter = emitter self.done = DataStore(type=TYPE_DONE, connection=connection) self.queue = DataStore(type=TYPE_QUEUE, connection=connection) self.done.load() self.queue.load() self.paused = asyncio.Event() self.paused.set() async def test(self) -> bool: await self.done.test() return True async def initialize(self): self.event = asyncio.Event() LOG.info( f"Using '{self.config.max_workers}' worker/s for downloading. Can be configured via `YTP_MAX_WORKERS` environment variable." ) asyncio.create_task(self.__download_pool(), name="download_pool") def pause(self): if self.paused.is_set(): self.paused.clear() LOG.warning(f"Download paused at. {datetime.datetime.now().isoformat()}") def resume(self): if not self.paused.is_set(): self.paused.set() LOG.warning(f"Downloading resumed at. {datetime.datetime.now().isoformat()}") def isPaused(self) -> bool: return False if self.paused.is_set() else True async def __add_entry( self, entry: dict, preset: str, folder: str, ytdlp_config: dict = {}, ytdlp_cookies: str = "", output_template: str = "", already=None, ): if not entry: return {"status": "error", "msg": "Invalid/empty data was given."} options: dict = {} error: str | None = None live_in: str | None = None eventType = entry.get("_type") or "video" if "playlist" == eventType: entries = entry.get("entries", []) playlist_index_digits = len(str(len(entries))) results = [] for i, etr in enumerate(entries, start=1): etr["playlist"] = entry.get("id") etr["playlist_index"] = "{{0:0{0:d}d}}".format(playlist_index_digits).format(i) for property in ("id", "title", "uploader", "uploader_id"): if property in entry: etr[f"playlist_{property}"] = entry.get(property) if "thumbnail" not in etr and "youtube:" in entry.get("extractor", ""): etr["thumbnail"] = f"https://img.youtube.com/vi/{etr['id']}/maxresdefault.jpg" results.append( await self.__add_entry( entry=etr, preset=preset, folder=folder, ytdlp_config=ytdlp_config, ytdlp_cookies=ytdlp_cookies, output_template=output_template, already=already, ) ) if any("error" == res["status"] for res in results): return { "status": "error", "msg": ", ".join(res["msg"] for res in results if res["status"] == "error" and "msg" in res), } return {"status": "ok"} elif ("video" == eventType or eventType.startswith("url")) and "id" in entry and "title" in entry: # check if the video is live stream. if "live_status" in entry and "is_upcoming" == entry.get("live_status"): if "release_timestamp" in entry and entry.get("release_timestamp"): live_in = formatdate(entry.get("release_timestamp")) else: error = "Live stream not yet started. And no date is set." else: error = entry.get("msg", None) LOG.debug( f"Entry id '{entry.get('id', None)}' url '{entry.get('webpage_url', None)} - {entry.get('url', None)}'." ) if self.done.exists(key=entry["id"], url=str(entry.get("webpage_url") or entry.get("url"))): item = self.done.get(key=entry["id"], url=entry.get("webpage_url") or entry["url"]) LOG.warning(f"Item '{item.info.id}' - '{item.info.title}' already downloaded. Removing from history.") await self.clear([item.info._id], remove_file=False) try: item = self.queue.get(key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url"))) if item is not None: err_message = f"Item ID '{item.info.id}' - '{item.info.title}' already in download queue." LOG.info(err_message) return {"status": "error", "msg": err_message} except KeyError: pass is_manifestless = entry.get("is_manifestless", False) options.update({"is_manifestless": is_manifestless}) live_status: list = ["is_live", "is_upcoming"] is_live = bool(entry.get("is_live", None) or live_in or entry.get("live_status", None) in live_status) try: download_dir = calcDownloadPath(basePath=self.config.download_path, folder=folder) except Exception as e: LOG.exception(e) return {"status": "error", "msg": str(e)} extras: dict = {} fields: tuple = ("uploader", "channel", "thumbnail") for field in fields: if entry.get(field, None): extras[field] = entry.get(field) dl = ItemDTO( id=str(entry.get("id")), title=str(entry.get("title")), url=str(entry.get("webpage_url") or entry.get("url")), preset=preset, thumbnail=entry.get("thumbnail", None), folder=folder, download_dir=download_dir, temp_dir=self.config.temp_path, ytdlp_cookies=ytdlp_cookies, ytdlp_config=ytdlp_config, output_template=output_template if output_template else self.config.output_template, output_template_chapter=self.config.output_template_chapter, datetime=formatdate(time.time()), error=error, is_live=is_live, live_in=live_in, options=options, extras=extras, ) for property, value in entry.items(): if property.startswith("playlist"): dl.output_template = str(dl.output_template).replace(f"%({property})s", str(value)) dlInfo: Download = Download(info=dl, info_dict=entry, debug=bool(self.config.ytdl_debug)) if dlInfo.info.live_in or "is_upcoming" == entry.get("live_status", None): dlInfo.info.status = "not_live" itemDownload = self.done.put(dlInfo) NotifyEvent = "completed" elif self.config.allow_manifestless is False and is_manifestless is True: dlInfo.info.status = "error" dlInfo.info.error = "Video is in post-live manifestless mode." itemDownload = self.done.put(dlInfo) NotifyEvent = "completed" else: NotifyEvent = "added" itemDownload = self.queue.put(dlInfo) if self.event: self.event.set() asyncio.create_task( self.emitter.emit(NotifyEvent, itemDownload.info), name=f"notifier-{NotifyEvent}-{itemDownload.info.id}" ) return {"status": "ok"} elif eventType.startswith("url"): return await self.add( url=str(entry.get("url")), preset=preset, folder=folder, ytdlp_config=ytdlp_config, ytdlp_cookies=ytdlp_cookies, output_template=output_template, already=already, ) return {"status": "error", "msg": f'Unsupported resource "{eventType}"'} async def add( self, url: str, preset: str, folder: str, ytdlp_config: dict = {}, ytdlp_cookies: str = "", output_template: str = "", already=None, ): ytdlp_config = ytdlp_config if ytdlp_config else {} folder = str(folder) if folder else "" filePath = calcDownloadPath(basePath=self.config.download_path, folder=folder) LOG.info( f"Adding 'URL: {url}' to 'Folder: {filePath}' with 'Preset: {preset}' 'Naming: {output_template}', 'Cookies: {ytdlp_cookies}' 'YTConfig: {ytdlp_config}'." ) if isinstance(ytdlp_config, str): try: ytdlp_config = json.loads(ytdlp_config) except Exception as e: LOG.error(f"Unable to load '{ytdlp_config=}'. {str(e)}") return {"status": "error", "msg": f"Failed to parse json yt-dlp config. {str(e)}"} already = set() if already is None else already if url in already: LOG.warning(f"Recursion detected with url '{url}' skipping.") return {"status": "ok"} already.add(url) try: downloaded, id_dict = self.isDownloaded(url) if downloaded is True and id_dict: message = f"This url with ID '{id_dict.get('id')}' has been downloaded already and recorded in archive." LOG.info(message) return {"status": "error", "msg": message} started = time.perf_counter() LOG.debug(f"extract_info: checking {url=}") entry = await asyncio.wait_for( fut=asyncio.get_running_loop().run_in_executor( None, ExtractInfo, get_opts(preset, mergeConfig(self.config.ytdl_options, ytdlp_config)), url, bool(self.config.ytdl_debug), ), timeout=self.config.extract_info_timeout, ) if not entry: return {"status": "error", "msg": "Unable to extract info check logs."} LOG.debug( f"extract_info: for 'URL: {url}' is done in '{time.perf_counter() - started}'. Length: '{len(entry)}'." ) except yt_dlp.utils.ExistingVideoReached as exc: LOG.error(f"Video has been downloaded already and recorded in archive.log file. '{str(exc)}'.") return {"status": "error", "msg": "Video has been downloaded already and recorded in archive.log file."} except yt_dlp.utils.YoutubeDLError as exc: LOG.error(f"YoutubeDLError: Unable to extract info. '{str(exc)}'.") return {"status": "error", "msg": str(exc)} except asyncio.exceptions.TimeoutError as exc: LOG.error(f"TimeoutError: Unable to extract info. '{str(exc)}'.") return { "status": "error", "msg": f"TimeoutError: {self.config.extract_info_timeout}s reached Unable to extract info.", } return await self.__add_entry( entry=entry, preset=preset, folder=folder, ytdlp_config=ytdlp_config, ytdlp_cookies=ytdlp_cookies, output_template=output_template, already=already, ) async def cancel(self, ids: list[str]) -> dict[str, str]: status: dict[str, str] = {"status": "ok"} for id in ids: try: item = self.queue.get(key=id) except KeyError as e: status[id] = str(e) status["status"] = "error" LOG.warning(f"Requested cancel for non-existent download {id=}. {str(e)}") continue itemMessage = f"{id=} {item.info.id=} {item.info.title=}" if item.running(): LOG.debug(f"Canceling {itemMessage}") item.cancel() LOG.info(f"Cancelled {itemMessage}") await item.close() else: await item.close() LOG.debug(f"Deleting from queue {itemMessage}") self.queue.delete(id) asyncio.create_task(self.emitter.canceled(id=id, dl=item.info.serialize()), name=f"notifier-c-{id}") item.info.status = "canceled" item.info.error = "Canceled by user." self.done.put(item) asyncio.create_task(self.emitter.completed(dl=item.info.serialize()), name=f"notifier-d-{id}") LOG.info(f"Deleted from queue {itemMessage}") status[id] = "ok" return status async def clear(self, ids: list[str], remove_file: bool = False) -> dict[str, str]: status: dict[str, str] = {"status": "ok"} for id in ids: try: item = self.done.get(key=id) except KeyError as e: status[id] = str(e) status["status"] = "error" LOG.warning(f"Requested delete for non-existent download {id=}. {str(e)}") continue itemRef: str = f"{id=} {item.info.id=} {item.info.title=}" fileDeleted: bool = False filename: str = "" if remove_file and self.config.remove_files and "finished" == item.info.status: filename = str(item.info.filename) if item.info.folder: filename = f"{item.info.folder}/{item.info.filename}" try: realFile: str = calcDownloadPath( basePath=self.config.download_path, folder=filename, createPath=False, ) if realFile and os.path.exists(realFile): os.remove(realFile) fileDeleted = True else: LOG.warning(f"Failed to remove '{itemRef}' local file '{filename}'. File not found.") except Exception as e: LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {str(e)}") self.done.delete(id) asyncio.create_task(self.emitter.cleared(id, dl=item.info.serialize()), name=f"notifier-c-{id}") msg = f"Deleted completed download '{itemRef}'." if fileDeleted and filename: msg += f" and removed local file '{filename}'." LOG.info(msg=msg) status[id] = "ok" return status def get(self) -> dict[str, list[dict[str, ItemDTO]]]: items = {"queue": {}, "done": {}} for k, v in self.queue.saved_items(): items["queue"][k] = v for k, v in self.done.saved_items(): items["done"][k] = v for k, v in self.queue.items(): if k not in items["queue"]: items["queue"][k] = v.info for k, v in self.done.items(): if k not in items["done"]: items["done"][k] = v.info return items async def __download_pool(self): self.pool = AsyncPool( loop=asyncio.get_running_loop(), num_workers=self.config.max_workers, worker_co=self.__downloadFile, name="download_pool", logger=logging.getLogger("WorkerPool"), ) self.pool.start() lastLog = time.time() while True: while True: if self.pool.has_open_workers() is True: break if self.config.max_workers > 1 and time.time() - lastLog > 120: lastLog = time.time() LOG.info(f"Waiting for worker to be free. {self.pool.get_workers_status()}") await asyncio.sleep(1) while not self.queue.hasDownloads(): LOG.info(f"Waiting for item to download. '{self.pool.get_available_workers()}' free workers.") if self.event: await self.event.wait() self.event.clear() LOG.debug("Cleared wait event.") if self.paused and isinstance(self.paused, asyncio.Event) and self.isPaused(): LOG.info("Download pool is paused.") await self.paused.wait() LOG.info("Download pool resumed downloading.") entry = self.queue.getNextDownload() await asyncio.sleep(0.2) if entry is None: continue LOG.debug(f"Pushing {entry=} to executor.") if entry.started() is False and entry.is_canceled() is False: await self.pool.push(id=entry.info._id, entry=entry) LOG.debug(f"Pushed {entry=} to executor.") await asyncio.sleep(1) async def __downloadFile(self, id: str, entry: Download): filePath = calcDownloadPath(basePath=self.config.download_path, folder=entry.info.folder) LOG.info( f"Downloading 'id: {id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' to 'Folder: {filePath}'." ) try: await entry.start(self.emitter) if entry.info.status != "finished": if entry.tmpfilename and os.path.isfile(entry.tmpfilename): try: os.remove(entry.tmpfilename) entry.tmpfilename = None except Exception: pass entry.info.status = "error" finally: await entry.close() if self.queue.exists(key=id): LOG.debug(f"Download '{id}' is done. Removing from queue.") self.queue.delete(key=id) if entry.is_canceled() is True: asyncio.create_task(self.emitter.canceled(id, dl=entry.info.serialize()), name=f"notifier-c-{id}") entry.info.status = "canceled" entry.info.error = "Canceled by user." self.done.put(value=entry) asyncio.create_task(self.emitter.completed(dl=entry.info.serialize()), name=f"notifier-d-{id}") else: LOG.warning(f"Download '{id}' not found in queue.") if self.event: self.event.set() def isDownloaded(self, url: str) -> tuple[bool, dict | None]: if not url or not self.config.keep_archive: return False, None return isDownloaded(self.config.ytdl_options.get("download_archive", None), url)