ytptube/app/library/DownloadQueue.py
2024-12-19 22:58:33 +03:00

439 lines
16 KiB
Python

import asyncio
from email.utils import formatdate
import json
import logging
import os
import time
import yt_dlp
from sqlite3 import Connection
from .config import Config
from .Download import Download
from .ItemDTO import ItemDTO
from .DataStore import DataStore
from .Utils import calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
from .AsyncPool import AsyncPool
from .Emitter import Emitter
LOG = logging.getLogger("DownloadQueue")
TYPE_DONE: str = "done"
TYPE_QUEUE: str = "queue"
class DownloadQueue:
config: Config = None
emitter: Emitter = None
queue: DataStore = None
done: DataStore = None
event: asyncio.Event = None
pool: AsyncPool = 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()
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} workers for downloading.")
asyncio.create_task(
self.__download_pool() if self.config.max_workers > 1 else self.__download(),
name="download_pool" if self.config.max_workers > 1 else "download_worker",
)
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
live_in: str = None
eventType = entry.get("_type") or "video"
if eventType == "playlist":
entries = entry["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)
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(res["status"] == "error" 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 (eventType == "video" 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 entry.get("live_status") == "is_upcoming":
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=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])
try:
item = self.queue.get(key=entry.get("id"), url=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 = 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)}
dl = ItemDTO(
id=entry.get("id"),
title=entry.get("title"),
url=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,
)
for property, value in entry.items():
if property.startswith("playlist"):
dl.output_template = 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)
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=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 {}
LOG.info(
f"Adding url '{url}' to folder '{folder}' with the following options '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:
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,
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), name=f"notifier-c-{id}")
self.done.put(item)
asyncio.create_task(self.emitter.completed(dl=item), name=f"notifier-d-{id}")
LOG.info(f"Deleted from queue {itemMessage}")
status[id] = "ok"
return status
async def clear(self, ids: list[str]) -> 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
itemMessage = f"{id=} {item.info.id=} {item.info.title=}"
LOG.debug(f"Deleting completed download {itemMessage}")
self.done.delete(id)
asyncio.create_task(self.emitter.cleared(id, dl=item), name=f"notifier-c-{id}")
LOG.info(f"Deleted completed download {itemMessage}")
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 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.")
await self.event.wait()
self.event.clear()
LOG.debug("Cleared wait event.")
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 __download(self):
while True:
while self.queue.empty():
LOG.info("Waiting for item to download.")
await self.event.wait()
self.event.clear()
id, entry = self.queue.next()
await self.__downloadFile(id, entry)
async def __downloadFile(self, id: str, entry: Download):
LOG.info(
f"Downloading 'id: {id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' to 'folder: {entry.info.folder}'."
)
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):
self.queue.delete(key=id)
if entry.is_canceled() is True:
asyncio.create_task(self.emitter.canceled(id, dl=entry.info), 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), name=f"notifier-d-{id}")
self.event.set()
def isDownloaded(self, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
if not url or not self.config.keep_archive:
return False, None
return isDownloaded(self.config.ytdl_options.get("download_archive", None), url)