simplified the codebase and removed all backwards compat flags and checks.

This commit is contained in:
arabcoders 2025-04-01 22:56:28 +03:00
parent 9b4d86041a
commit f7101f6a38
10 changed files with 288 additions and 327 deletions

View file

@ -8,6 +8,7 @@ from sqlite3 import Connection
from .config import Config from .config import Config
from .Download import Download from .Download import Download
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Utils import clean_item
class DataStore: class DataStore:
@ -68,7 +69,7 @@ class DataStore:
for row in cursor: for row in cursor:
rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007 rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data: dict = json.loads(row["data"]) data, _ = clean_item(json.loads(row["data"]), keys=ItemDTO.removed_fields())
key: str = data.pop("_id") key: str = data.pop("_id")
item: ItemDTO = ItemDTO(**data) item: ItemDTO = ItemDTO(**data)
item._id = key item._id = key

View file

@ -48,7 +48,6 @@ class Download:
temp_dir: str = None temp_dir: str = None
template: str = None template: str = None
template_chapter: str = None template_chapter: str = None
ytdl_opts: dict = None
info: ItemDTO = None info: ItemDTO = None
default_ytdl_opts: dict = None default_ytdl_opts: dict = None
debug: bool = False debug: bool = False
@ -92,7 +91,6 @@ class Download:
self.template = info.template self.template = info.template
self.template_chapter = info.template_chapter self.template_chapter = info.template_chapter
self.preset = info.preset self.preset = info.preset
self.ytdl_opts = info.config if info.config else {}
self.info = info self.info = info
self.id = info._id self.id = info._id
self.default_ytdl_opts = config.ytdl_options self.default_ytdl_opts = config.ytdl_options
@ -147,7 +145,7 @@ class Download:
YTDLPOpts.get_instance() YTDLPOpts.get_instance()
.preset(self.preset) .preset(self.preset)
.add({"break_on_existing": True}) .add({"break_on_existing": True})
.add(self.ytdl_opts, from_user=True) .add_cli(self.info.cli, from_user=True)
.add( .add(
{ {
"color": "no_color", "color": "no_color",

View file

@ -1,5 +1,6 @@
import asyncio import asyncio
import functools import functools
import glob
import json import json
import logging import logging
import os import os
@ -7,6 +8,7 @@ import time
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from email.utils import formatdate from email.utils import formatdate
from pathlib import Path
from sqlite3 import Connection from sqlite3 import Connection
import anyio import anyio
@ -18,10 +20,10 @@ from .config import Config
from .DataStore import DataStore from .DataStore import DataStore
from .Download import Download from .Download import Download
from .Events import EventBus, Events from .Events import EventBus, Events
from .ItemDTO import ItemDTO from .ItemDTO import Item, ItemDTO
from .Presets import Presets from .Presets import Presets
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import arg_converter, calc_download_path, extract_info, is_downloaded, strip_newline from .Utils import arg_converter, calc_download_path, extract_info, is_downloaded
from .YTDLPOpts import YTDLPOpts from .YTDLPOpts import YTDLPOpts
LOG = logging.getLogger("DownloadQueue") LOG = logging.getLogger("DownloadQueue")
@ -168,45 +170,23 @@ class DownloadQueue(metaclass=Singleton):
except Exception as e: except Exception as e:
LOG.error(f"Failed to cancel downloads. {e!s}") LOG.error(f"Failed to cancel downloads. {e!s}")
async def __add_entry( async def __add_entry(self, entry: dict, item: Item, already=None):
self,
entry: dict,
preset: str,
folder: str,
config: dict | None = None,
cookies: str = "",
template: str = "",
extras: dict | None = None,
cli: str = "",
already=None,
):
""" """
Add an entry to the download queue. Add an entry to the download queue.
Args: Args:
entry (dict): The entry to add to the download queue. entry (dict): The entry to add to the download queue.
preset (str): The preset to use for the download. item (Item): The item to add to the download queue.
folder (str): The folder to save the download to.
config (dict): The yt-dlp configuration to use for the download.
cookies (str): The cookies to use for the download.
template (str): The output template to use for the download.
extras (dict): The extra information to add to the download.
cli (str): The yt-dlp command line options to use for the download.
already (set): The set of already downloaded items. already (set): The set of already downloaded items.
Returns: Returns:
dict: The status of the operation. dict: The status of the operation.
""" """
if not config: if item.has_extras():
config = {} for key in item.extras.copy():
if not extras:
extras = {}
else:
for key in extras.copy():
if not str(key).startswith("playlist"): if not str(key).startswith("playlist"):
extras.pop(key, None) item.extras.pop(key, None)
if not entry: if not entry:
return {"status": "error", "msg": "Invalid/empty data was given."} return {"status": "error", "msg": "Invalid/empty data was given."}
@ -239,13 +219,7 @@ class DownloadQueue(metaclass=Singleton):
results.append( results.append(
await self.__add_entry( await self.__add_entry(
entry=etr, entry=etr,
preset=preset, item=item.new_with(url=etr.get("url") or etr.get("webpage_url")),
folder=folder,
config=config,
cookies=cookies,
template=template,
extras=extras,
cli=cli,
already=already, already=already,
) )
) )
@ -291,7 +265,7 @@ class DownloadQueue(metaclass=Singleton):
is_live = bool(entry.get("is_live") or live_in or entry.get("live_status") in live_status) is_live = bool(entry.get("is_live") or live_in or entry.get("live_status") in live_status)
try: try:
download_dir = calc_download_path(base_path=self.config.download_path, folder=folder) download_dir = calc_download_path(base_path=self.config.download_path, folder=item.folder)
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)
return {"status": "error", "msg": str(e)} return {"status": "error", "msg": str(e)}
@ -299,31 +273,30 @@ class DownloadQueue(metaclass=Singleton):
fields: tuple = ("uploader", "channel", "thumbnail") fields: tuple = ("uploader", "channel", "thumbnail")
for field in fields: for field in fields:
if entry.get(field): if entry.get(field):
extras[field] = entry.get(field) item.extras[field] = entry.get(field)
for key in entry: for key in entry:
if isinstance(key, str) and key.startswith("playlist") and entry.get(key): if isinstance(key, str) and key.startswith("playlist") and entry.get(key):
extras[key] = entry.get(key) item.extras[key] = entry.get(key)
dl = ItemDTO( dl = ItemDTO(
id=str(entry.get("id")), id=str(entry.get("id")),
title=str(entry.get("title")), title=str(entry.get("title")),
url=str(entry.get("webpage_url") or entry.get("url")), url=str(entry.get("webpage_url") or entry.get("url")),
preset=preset, preset=item.preset,
folder=folder, folder=item.folder,
download_dir=download_dir, download_dir=download_dir,
temp_dir=self.config.temp_path, temp_dir=self.config.temp_path,
cookies=cookies, cookies=item.cookies,
config=config, template=item.template if item.template else self.config.output_template,
template=template if template else self.config.output_template,
template_chapter=self.config.output_template_chapter, template_chapter=self.config.output_template_chapter,
datetime=formatdate(time.time()), datetime=formatdate(time.time()),
error=error, error=error,
is_live=is_live, is_live=is_live,
live_in=live_in, live_in=live_in,
options=options, options=options,
cli=cli, cli=item.cli,
extras=extras, extras=item.extras,
) )
dlInfo: Download = Download(info=dl, info_dict=entry) dlInfo: Download = Download(info=dl, info_dict=entry)
@ -347,44 +320,16 @@ class DownloadQueue(metaclass=Singleton):
return {"status": "ok"} return {"status": "ok"}
if eventType.startswith("url"): if eventType.startswith("url"):
return await self.add( return await self.add(item=item.new_with(url=entry.get("url")), already=already)
url=str(entry.get("url")),
preset=preset,
folder=folder,
config=config,
cookies=cookies,
template=template,
extras=extras,
cli=cli,
already=already,
)
return {"status": "error", "msg": f'Unsupported resource "{eventType}"'} return {"status": "error", "msg": f'Unsupported resource "{eventType}"'}
async def add( async def add(self, item: Item, already: set | None = None):
self,
url: str,
preset: str,
folder: str,
config: dict | None = None,
cookies: str = "",
template: str = "",
cli: str = "",
extras: dict | None = None,
already=None,
):
""" """
Add an item to the download queue. Add an item to the download queue.
Args: Args:
url (str): The url to be added to the queue. item (Item): The item to be added to the queue.
preset (str): The preset to be used for the download.
folder (str): The folder to save the download to.
config (dict): The yt-dlp config to be used for the download.
cookies (str): The cookies to be used for the download.
template (str): The template to be used for the download.
cli (str): The yt-dlp cli options to be used for the download.
extras (dict): Extra data to be added to the download
already (set): Set of already downloaded items. already (set): Set of already downloaded items.
Returns: Returns:
@ -392,37 +337,29 @@ class DownloadQueue(metaclass=Singleton):
{ "status": "text" } { "status": "text" }
""" """
_preset = Presets.get_instance().get(preset) _preset = Presets.get_instance().get(item.preset)
config = config if config else {} if item.has_cli():
if cli:
try: try:
config = arg_converter(args=cli, level=True) config = arg_converter(args=item.cli, level=True)
except Exception as e: except Exception as e:
LOG.error(f"Invalid cli options '{cli}'. {e!s}") LOG.error(f"Invalid cli options '{item.cli}'. {e!s}")
return {"status": "error", "msg": f"Invalid cli options '{cli}'. {e!s}"} return {"status": "error", "msg": f"Invalid cli options '{item.cli}'. {e!s}"}
folder = str(folder) if folder else ""
if not extras:
extras = {}
if _preset: if _preset:
if _preset.folder and not folder: if _preset.folder and not item.folder:
folder = _preset.folder item.folder = _preset.folder
if _preset.template and not template: if _preset.template and not item.template:
template = _preset.template item.template = _preset.template
if _preset.cookies and not cookies: if _preset.cookies and not item.cookies:
cookies = _preset.cookies item.cookies = _preset.cookies
filePath = calc_download_path(base_path=self.config.download_path, folder=folder)
yt_conf = {} yt_conf = {}
cookie_file = os.path.join(self.config.temp_path, f"c_{uuid.uuid4().hex}.txt") cookie_file = os.path.join(self.config.temp_path, f"c_{uuid.uuid4().hex}.txt")
LOG.info( LOG.info(f"Adding '{item.__repr__()}'.")
f"Adding 'URL: {url}' to 'Folder: {filePath}' with 'Preset: {preset}' 'Naming: {template}', 'Cookies: {len(cookies)}/chars' 'CLI: {strip_newline(cli)}' 'Extras: {extras}'."
)
if isinstance(config, str): if isinstance(config, str):
try: try:
@ -433,21 +370,21 @@ class DownloadQueue(metaclass=Singleton):
already = set() if already is None else already already = set() if already is None else already
if url in already: if item.url in already:
LOG.warning(f"Recursion detected with url '{url}' skipping.") LOG.warning(f"Recursion detected with url '{item.url}' skipping.")
return {"status": "ok"} return {"status": "ok"}
already.add(url) already.add(item.url)
try: try:
downloaded, id_dict = self._is_downloaded(url) downloaded, id_dict = self._is_downloaded(item.url)
if downloaded is True and id_dict: 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." message = f"This url with ID '{id_dict.get('id')}' has been downloaded already and recorded in archive."
LOG.info(message) LOG.info(message)
return {"status": "error", "msg": message} return {"status": "error", "msg": message}
started = time.perf_counter() started = time.perf_counter()
LOG.debug(f"extract_info: checking {url=}") LOG.debug(f"extract_info: checking '{item.url}'.")
logs = [] logs = []
@ -456,13 +393,13 @@ class DownloadQueue(metaclass=Singleton):
"func": lambda _, msg: logs.append(msg), "func": lambda _, msg: logs.append(msg),
"level": logging.WARNING, "level": logging.WARNING,
}, },
**YTDLPOpts.get_instance().preset(name=preset).add(config=config, from_user=True).get_all(), **YTDLPOpts.get_instance().preset(name=item.preset).add(config=config, from_user=True).get_all(),
} }
if cookies: if item.cookies:
try: try:
async with await anyio.open_file(cookie_file, "w") as f: async with await anyio.open_file(cookie_file, "w") as f:
await f.write(cookies) await f.write(item.cookies)
yt_conf["cookiefile"] = f.name yt_conf["cookiefile"] = f.name
except ValueError as e: except ValueError as e:
LOG.error(f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'.") LOG.error(f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'.")
@ -473,7 +410,7 @@ class DownloadQueue(metaclass=Singleton):
functools.partial( functools.partial(
extract_info, extract_info,
config=yt_conf, config=yt_conf,
url=url, url=item.url,
debug=bool(self.config.ytdl_debug), debug=bool(self.config.ytdl_debug),
no_archive=False, no_archive=False,
follow_redirect=True, follow_redirect=True,
@ -486,7 +423,7 @@ class DownloadQueue(metaclass=Singleton):
return {"status": "error", "msg": "Unable to extract info." + "\n".join(logs)} return {"status": "error", "msg": "Unable to extract info." + "\n".join(logs)}
LOG.debug( LOG.debug(
f"extract_info: for 'URL: {url}' is done in '{time.perf_counter() - started}'. Length: '{len(entry)}'." f"extract_info: for 'URL: {item.url}' is done in '{time.perf_counter() - started}'. Length: '{len(entry)}/keys'."
) )
except yt_dlp.utils.ExistingVideoReached as exc: except yt_dlp.utils.ExistingVideoReached as exc:
LOG.error(f"Video has been downloaded already and recorded in archive.log file. '{exc!s}'.") LOG.error(f"Video has been downloaded already and recorded in archive.log file. '{exc!s}'.")
@ -508,17 +445,7 @@ class DownloadQueue(metaclass=Singleton):
except Exception as e: except Exception as e:
LOG.error(f"Failed to remove cookie file '{yt_conf['cookiefile']}'. {e!s}") LOG.error(f"Failed to remove cookie file '{yt_conf['cookiefile']}'. {e!s}")
return await self.__add_entry( return await self.__add_entry(entry=entry, item=item, already=already)
entry=entry,
preset=preset,
folder=folder,
config=config,
cookies=cookies,
template=template,
already=already,
extras=extras,
cli=cli,
)
async def cancel(self, ids: list[str]) -> dict[str, str]: async def cancel(self, ids: list[str]) -> dict[str, str]:
""" """
@ -588,7 +515,7 @@ class DownloadQueue(metaclass=Singleton):
continue continue
itemRef: str = f"{id=} {item.info.id=} {item.info.title=}" itemRef: str = f"{id=} {item.info.id=} {item.info.title=}"
fileDeleted: bool = False removed_files = 0
filename: str = "" filename: str = ""
if remove_file and self.config.remove_files and "finished" == item.info.status: if remove_file and self.config.remove_files and "finished" == item.info.status:
@ -602,9 +529,13 @@ class DownloadQueue(metaclass=Singleton):
folder=filename, folder=filename,
create_path=False, create_path=False,
) )
if realFile and os.path.exists(realFile): rf = Path(realFile)
os.remove(realFile) if rf.is_file() and rf.exists():
fileDeleted = True for f in rf.parent.glob(f"{glob.escape(rf.stem)}.*"):
if f.is_file() and f.exists() and not f.name.startswith("."):
removed_files += 1
LOG.debug(f"Removing '{itemRef}' local file '{f.name}'.")
os.remove(f)
else: else:
LOG.warning(f"Failed to remove '{itemRef}' local file '{filename}'. File not found.") LOG.warning(f"Failed to remove '{itemRef}' local file '{filename}'. File not found.")
except Exception as e: except Exception as e:
@ -614,8 +545,8 @@ class DownloadQueue(metaclass=Singleton):
await self._notify.emit(Events.CLEARED, data=item.info.serialize()) await self._notify.emit(Events.CLEARED, data=item.info.serialize())
msg = f"Deleted completed download '{itemRef}'." msg = f"Deleted completed download '{itemRef}'."
if fileDeleted and filename: if removed_files > 0:
msg += f" and removed local file '{filename}'." msg += f" and removed '{removed_files}' local files."
LOG.info(msg=msg) LOG.info(msg=msg)
status[id] = "ok" status[id] = "ok"

View file

@ -27,6 +27,7 @@ from .DownloadQueue import DownloadQueue
from .encoder import Encoder from .encoder import Encoder
from .Events import EventBus, Events, message from .Events import EventBus, Events, message
from .ffprobe import ffprobe from .ffprobe import ffprobe
from .ItemDTO import Item
from .M3u8 import M3u8 from .M3u8 import M3u8
from .Notifications import Notification, NotificationEvents from .Notifications import Notification, NotificationEvents
from .Playlist import Playlist from .Playlist import Playlist
@ -684,7 +685,7 @@ class HttpAPI(Common):
data["preset"] = preset data["preset"] = preset
try: try:
status = await self.add(**self.format_item(data)) status = await self.add(item=Item.format(data))
except ValueError as e: except ValueError as e:
return web.json_response(data={"status": False, "message": str(e)}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"status": False, "message": str(e)}, status=web.HTTPBadRequest.status_code)
@ -711,15 +712,16 @@ class HttpAPI(Common):
if isinstance(data, dict): if isinstance(data, dict):
data = [data] data = [data]
items = []
for item in data: for item in data:
try: try:
self.format_item(item) items.append(Item.format(item))
except ValueError as e: except ValueError as e:
return web.json_response(data={"error": str(e), "data": item}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": str(e), "data": item}, status=web.HTTPBadRequest.status_code)
return web.json_response( return web.json_response(
data=await asyncio.wait_for( data=await asyncio.wait_for(
fut=asyncio.gather(*[self.add(**self.format_item(item)) for item in data]), fut=asyncio.gather(*[self.add(item=item) for item in items]),
timeout=None, timeout=None,
), ),
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,

View file

@ -17,6 +17,7 @@ from .config import Config
from .DownloadQueue import DownloadQueue from .DownloadQueue import DownloadQueue
from .encoder import Encoder from .encoder import Encoder
from .Events import Event, EventBus, Events, error from .Events import Event, EventBus, Events, error
from .ItemDTO import Item
from .Presets import Presets from .Presets import Presets
from .Utils import is_downloaded from .Utils import is_downloaded
@ -79,7 +80,7 @@ class HttpSocket(Common):
self._notify.subscribe( self._notify.subscribe(
Events.ADD_URL, Events.ADD_URL,
lambda data, _, **kwargs: self.add(**self.format_item(data.data)), # noqa: ARG005 lambda data, _, **kwargs: self.add(item=Item.format(data.data)), # noqa: ARG005
f"{__class__.__name__}.socket_add_url", f"{__class__.__name__}.socket_add_url",
) )
@ -188,14 +189,16 @@ class HttpSocket(Common):
return return
try: try:
item = self.format_item(data) await self._notify.emit(
event=Events.STATUS,
data=await self.add(item=Item.format(data)),
to=sid,
)
except ValueError as e: except ValueError as e:
LOG.exception(e)
await self._notify.emit(Events.ERROR, data=error(str(e)), to=sid) await self._notify.emit(Events.ERROR, data=error(str(e)), to=sid)
return return
status = await self.add(**item)
await self._notify.emit(Events.STATUS, data=status, to=sid)
@ws_event @ws_event
async def item_cancel(self, sid: str, id: str): async def item_cancel(self, sid: str, id: str):
if not id: if not id:

View file

@ -1,10 +1,171 @@
import json import json
import logging
import time import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from email.utils import formatdate from email.utils import formatdate
from typing import Any from typing import Any
LOG = logging.getLogger("ItemDTO")
@dataclass(kw_only=True)
class Item:
"""
This class represents a single download request for data transfer purposes.
"""
url: str
"""The URL of the item to be downloaded."""
preset: str = field(default_factory=lambda: Item._default_preset())
"""The preset to be used for the download."""
folder: str = ""
"""The folder to save the download to."""
cookies: str = ""
"""The cookies to be used for the download."""
template: str = ""
"""The template to be used for the download."""
cli: str = ""
"""The yt-dlp cli options to be used for the download."""
extras: dict = field(default_factory=dict)
"""Extra data to be added to the download."""
def serialize(self) -> dict:
return self.__dict__.copy()
def json(self) -> str:
return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4)
def get(self, key: str, default: Any = None) -> Any:
return self.__dict__.get(key, default)
def has_extras(self) -> bool:
"""
Check if the item has any extras data associated with it.
Returns:
bool: True if the item has extras, False otherwise.
"""
return self.extras and len(self.extras) > 0
def has_cli(self) -> bool:
"""
Check if the item has any yt-dlp cli options associated with it.
Returns:
bool: True if the item has yt-dlp cli options, False otherwise.
"""
return self.cli and len(self.cli) > 2
def _default_preset() -> str:
from .config import Config
return Config.get_instance().default_preset
def new_with(self, **kwargs) -> "Item":
"""
Create a new instance of Item with the given parameters.
Args:
**kwargs: The parameters to be used for creating the new instance.
Returns:
Item: A new instance of Item with the given parameters.
"""
return Item.format({**self.serialize(), **kwargs})
def __repr__(self):
from .config import Config
from .Utils import calc_download_path, strip_newline
data = {}
for k, v in self.serialize().items():
if not v:
continue
if k == "cli":
data[k] = strip_newline(v)
elif k == "extras":
data[k] = f"{len(v)} items"
elif k == "cookies":
data[k] = f"{len(v)}/chars"
elif k == "folder":
data[k] = calc_download_path(base_path=Config.get_instance().download_path, folder=v)
else:
data[k] = v
items = " ".join(f'{k}="{v}", ' for k, v in data.items() if v)
return f"Item({items.strip(', ')})"
@staticmethod
def format(item: dict) -> "Item":
"""
Format the item to be added to the download queue.
Args:
item (dict): The item to be formatted.
Raises:
ValueError: If the url is not provided.
ValueError: If the yt-cli command line arguments are not valid.
Returns:
Item: The formatted item.
"""
url: str = item.get("url")
if not url or not isinstance(url, str):
msg = "url param is required."
raise ValueError(msg)
data = {
"url": url,
}
preset = item.get("preset")
if preset and isinstance(preset, str) and preset != Item._default_preset():
data["preset"] = preset
if item.get("folder") and isinstance(item.get("folder"), str):
data["folder"] = item.get("folder")
if item.get("cookies") and isinstance(item.get("cookies"), str):
data["cookies"] = item.get("cookies")
if item.get("template") and isinstance(item.get("template"), str):
data["template"] = item.get("template")
extras = item.get("extras")
if extras and isinstance(extras, dict) and len(extras) > 0:
data["extras"] = extras
cli = item.get("cli")
if cli and len(cli) > 2:
from .Utils import arg_converter
try:
removed_options = []
arg_converter(args=cli, level=True, removed_options=removed_options)
if len(removed_options) > 0:
LOG.warning("Removed the following options '%s' for '%s'.", ", ".join(removed_options), url)
data["cli"] = cli
except Exception as e:
msg = f"Failed to parse yt-dlp cli options. {e!s}"
raise ValueError(msg) from e
return Item(**data)
@dataclass(kw_only=True) @dataclass(kw_only=True)
class ItemDTO: class ItemDTO:
@ -49,45 +210,8 @@ class ItemDTO:
speed: str | None = None speed: str | None = None
eta: str | None = None eta: str | None = None
# @Deprecated: These fields are deprecated and will be removed in the future.
thumbnail: str | None = None
ytdlp_cookies: str | None = None
ytdlp_config: dict = field(default_factory=dict)
output_template: str | None = None
output_template_chapter: str | None = None
config: dict = field(default_factory=dict)
def serialize(self) -> dict: def serialize(self) -> dict:
deprecated: tuple = ( return self.__dict__.copy()
"thumbnail",
"quality",
"format",
"ytdlp_cookies",
"ytdlp_config",
"output_template",
"output_template_chapter",
"config",
)
if self.thumbnail and "thumbnail" not in self.extras:
self.extras["thumbnail"] = self.thumbnail
mapper: dict = {
"cookies": "ytdlp_cookies",
"config": "ytdlp_config",
"template": "output_template",
"template_chapter": "output_template_chapter",
}
for k, v in mapper.items():
if not self.get(k) and self.get(v):
self.__dict__[k] = self.get(v)
dump = self.__dict__.copy()
for f in deprecated:
dump.pop(f, None)
return dump
def json(self) -> str: def json(self) -> str:
return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4) return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4)
@ -97,3 +221,17 @@ class ItemDTO:
def get_id(self) -> str: def get_id(self) -> str:
return self._id return self._id
@staticmethod
def removed_fields() -> tuple:
"""Fields that once existed but are no longer used."""
return (
"thumbnail",
"quality",
"format",
"ytdlp_cookies",
"ytdlp_config",
"output_template",
"output_template_chapter",
"config",
)

View file

@ -371,7 +371,9 @@ class Notification(metaclass=Singleton):
if "form" == target.request.type.lower(): if "form" == target.request.type.lower():
reqBody["data"]["data"] = self._encoder.encode(reqBody["data"]["data"]) reqBody["data"]["data"] = self._encoder.encode(reqBody["data"]["data"])
logging.getLogger("httpx").setLevel(logging.WARNING) if not self._debug:
logging.getLogger("httpx").setLevel(logging.WARNING)
response = await self._client.request(**reqBody) response = await self._client.request(**reqBody)
respData = {"url": target.request.url, "status": response.status_code, "text": response.text} respData = {"url": target.request.url, "status": response.status_code, "text": response.text}
@ -404,7 +406,7 @@ class Notification(metaclass=Singleton):
data[k] = [self._deep_unpack(i) for i in v] data[k] = [self._deep_unpack(i) for i in v]
if isinstance(v, datetime): if isinstance(v, datetime):
data[k] = v.isoformat() data[k] = v.isoformat()
if isinstance(v, ItemDTO): if isinstance(v, object) and hasattr(v, "serialize"):
data[k] = v.serialize() data[k] = v.serialize()
return data return data

View file

@ -36,6 +36,30 @@ class YTDLPOpts(metaclass=Singleton):
return YTDLPOpts._instance return YTDLPOpts._instance
def add_cli(self, args: str, from_user: int | bool = False) -> "YTDLPOpts":
"""
Prase and add yt-dlp cli options to the item options.
Args:
args (str): The cli options to add
from_user (bool): If the options are from the user
Returns:
YTDLPOpts: The instance of the class
"""
if not args or len(args) < 2 or not isinstance(args, str):
return self
removed_options = []
self._item_opts.update(arg_converter(args=args, level=from_user, removed_options=removed_options))
if len(removed_options) > 0:
LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options))
return self
def add(self, config: dict, from_user: bool = False) -> "YTDLPOpts": def add(self, config: dict, from_user: bool = False) -> "YTDLPOpts":
""" """
Add the options to the item options. Add the options to the item options.
@ -147,25 +171,4 @@ class YTDLPOpts(metaclass=Singleton):
if "format" in data and data["format"] in ["not_set", "default"]: if "format" in data and data["format"] in ["not_set", "default"]:
data["format"] = None data["format"] = None
# @Deprecated - To be removed in future versions, All the checks below this line are deprecated.
if "daterange" in data and isinstance(data["daterange"], dict):
from yt_dlp.utils import DateRange
data["daterange"] = DateRange(data["daterange"]["start"], data["daterange"]["end"])
if "impersonate" in data and isinstance(data["impersonate"], str):
from yt_dlp.networking.impersonate import ImpersonateTarget
data["impersonate"] = ImpersonateTarget.from_str(data["impersonate"])
if (
"match_filter" in data
and isinstance(data["match_filter"], dict)
and "filters" in data["match_filter"]
and len(data["match_filter"]["filters"]) > 0
):
from yt_dlp.utils import match_filter_func
data["match_filter"] = match_filter_func(data["match_filter"]["filters"])
return data return data

View file

@ -1,10 +1,9 @@
import json
import logging import logging
from .config import Config from .config import Config
from .DownloadQueue import DownloadQueue from .DownloadQueue import DownloadQueue
from .encoder import Encoder from .encoder import Encoder
from .Utils import arg_converter from .ItemDTO import Item
LOG = logging.getLogger("common") LOG = logging.getLogger("common")
@ -27,105 +26,16 @@ class Common:
config = config or Config.get_instance() config = config or Config.get_instance()
self.default_preset = config.default_preset self.default_preset = config.default_preset
async def add( async def add(self, item: Item) -> dict[str, str]:
self,
url: str,
preset: str,
folder: str,
cookies: str,
# @deprecated: config: dict,
config: dict,
template: str,
extras: dict | None = None,
cli: str = "",
) -> dict[str, str]:
""" """
Add an item to the download queue. Add an item to the download queue.
Args: Args:
url (str): The url to be added to the queue. item (Item): The item to be added to the queue.
preset (str): The preset to be used for the download.
folder (str): The folder to save the download to.
cookies (str): The cookies to be used for the download.
config (dict): The yt-dlp config to be used for the download.
template (str): The template to be used for the download.
extras (dict): Extra data to be added to the download
cli (str): The yt-dlp cli options to be used for the download.
Returns: Returns:
dict[str, str]: The status of the download. dict[str, str]: The status of the download.
{ "status": "text" } { "status": "text" }
""" """
if cookies and isinstance(cookies, dict): return await self.queue.add(item=item)
cookies = self.encoder.encode(cookies)
return await self.queue.add(
url=url,
preset=preset if preset else self.config.default_preset,
folder=folder,
cookies=cookies,
config=config if isinstance(config, dict) else {},
template=template,
cli=cli,
extras=extras,
)
def format_item(self, item: dict) -> dict:
"""
Format the item to be added to the download queue.
Args:
item (dict): The item to be formatted.
Raises:
ValueError: If the url is not provided.
ValueError: If the yt-dlp config is not a valid json.
Returns:
dict: The formatted item
"""
url: str = item.get("url")
if not url:
msg = "url param is required."
raise ValueError(msg)
preset: str = str(item.get("preset", self.config.default_preset))
folder: str = str(item.get("folder")) if item.get("folder") else ""
cookies: str = str(item.get("cookies")) if item.get("cookies") else ""
template: str = str(item.get("template")) if item.get("template") else ""
extras = item.get("extras", {})
config = item.get("config")
if isinstance(config, str) and config:
try:
config = json.loads(config)
except Exception as e:
msg = f"Failed to parse json yt-dlp config for '{url}'. {e!s}"
raise ValueError(msg) from e
cli = item.get("cli")
if cli and len(cli) > 2:
try:
removed_options = []
arg_converter(args=cli, level=True, removed_options=removed_options)
if len(removed_options) > 0:
LOG.warning("Removed the following options '%s'.", ", ".join(removed_options))
config = {}
except Exception as e:
msg = f"Failed to parse yt-dlp cli options. {e!s}"
raise ValueError(msg) from e
return {
"url": url,
"preset": preset,
"folder": folder,
"cookies": cookies,
"config": config if isinstance(config, dict) else {},
"template": template,
"cli": cli if isinstance(cli, str) else "",
"extras": extras if isinstance(extras, dict) else {},
}

View file

@ -12,7 +12,7 @@ import coloredlogs
from dotenv import load_dotenv from dotenv import load_dotenv
from yt_dlp.version import __version__ as YTDLP_VERSION from yt_dlp.version import __version__ as YTDLP_VERSION
from .Utils import REMOVE_KEYS, arg_converter, load_file, merge_dict from .Utils import arg_converter
from .version import APP_VERSION from .version import APP_VERSION
@ -321,11 +321,10 @@ class Config:
except Exception as e: except Exception as e:
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}") LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
ytdlp_cli: str = os.path.join(self.config_path, "ytdlp.cli") opts_file: str = os.path.join(self.config_path, "ytdlp.cli")
optsFile: str = os.path.join(self.config_path, "ytdlp.json") if os.path.exists(opts_file) and os.path.getsize(opts_file) > 2:
if os.path.exists(ytdlp_cli) and os.path.getsize(ytdlp_cli) > 2: LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.")
LOG.info(f"Loading yt-dlp custom options from '{ytdlp_cli}'.") with open(opts_file) as f:
with open(ytdlp_cli) as f:
ytdlp_cli_opts = f.read().strip() ytdlp_cli_opts = f.read().strip()
if ytdlp_cli_opts: if ytdlp_cli_opts:
try: try:
@ -343,41 +342,15 @@ class Config:
if len(removed_options) > 0: if len(removed_options) > 0:
LOG.warning( LOG.warning(
"Removed the following options: '%s' from '%s'", ", ".join(removed_options), ytdlp_cli "Removed the following options: '%s' from '%s'", ", ".join(removed_options), opts_file
) )
except Exception as e: except Exception as e:
msg = f"Failed to parse yt-dlp cli options from '{ytdlp_cli}'. '{e!s}'." msg = f"Failed to parse yt-dlp cli options from '{opts_file}'. '{e!s}'."
raise ValueError(msg) from e raise ValueError(msg) from e
else: else:
LOG.warning(f"Empty yt-dlp custom options file '{ytdlp_cli}'.") LOG.warning(f"Empty yt-dlp custom options file '{opts_file}'.")
# @Deprecated - To be removed in future versions.
elif os.path.exists(optsFile) and os.path.getsize(optsFile) > 5:
LOG.warning("The JSON ytdlp.json options file is deprecated, please use 'ytdlp.cli' file instead.")
LOG.info(f"Loading yt-dlp custom options from '{optsFile}'.")
(opts, status, error) = load_file(optsFile, dict)
if not status:
LOG.error(f"Could not load yt-dlp custom options from '{optsFile}'. {error}")
sys.exit(1)
if isinstance(opts, dict):
bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
removed_options = []
for key in opts.copy():
if key not in bad_options:
continue
removed_options.append(bad_options[key])
opts.pop(key, None)
if len(removed_options) > 0:
LOG.warning(
"Removed the following options: '%s' from 'ytdlp.json' file.", ", ".join(removed_options)
)
self.ytdl_options = merge_dict(self.ytdl_options, opts)
else:
LOG.error(f"Invalid yt-dlp custom options file '{optsFile}'.")
else: else:
LOG.info(f"No yt-dlp custom options found at '{ytdlp_cli}'.") LOG.info(f"No yt-dlp custom options found at '{opts_file}'.")
self.ytdl_options["socket_timeout"] = self.socket_timeout self.ytdl_options["socket_timeout"] = self.socket_timeout