Merge pull request #414 from arabcoders/dev
Make it possible to ignore item download via conditions
This commit is contained in:
commit
125a1a81a0
13 changed files with 852 additions and 617 deletions
|
|
@ -24,6 +24,7 @@ from .Presets import Presets
|
||||||
from .Scheduler import Scheduler
|
from .Scheduler import Scheduler
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
from .Utils import (
|
from .Utils import (
|
||||||
|
archive_add,
|
||||||
arg_converter,
|
arg_converter,
|
||||||
calc_download_path,
|
calc_download_path,
|
||||||
dt_delta,
|
dt_delta,
|
||||||
|
|
@ -675,8 +676,10 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.")
|
LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.")
|
||||||
item.extras.update({"external_downloader": True})
|
item.extras.update({"external_downloader": True})
|
||||||
|
|
||||||
|
archive_id = item.get_archive_id()
|
||||||
|
|
||||||
if item.is_archived():
|
if item.is_archived():
|
||||||
if archive_id := item.get_archive_id():
|
if archive_id:
|
||||||
store_type, _ = self.get_item(archive_id=archive_id)
|
store_type, _ = self.get_item(archive_id=archive_id)
|
||||||
if not store_type:
|
if not store_type:
|
||||||
dlInfo = Download(
|
dlInfo = Download(
|
||||||
|
|
@ -750,7 +753,55 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
|
|
||||||
if not item.requeued and (condition := Conditions.get_instance().match(info=entry)):
|
if not item.requeued and (condition := Conditions.get_instance().match(info=entry)):
|
||||||
already.pop()
|
already.pop()
|
||||||
LOG.info(f"Matched '{condition.name}' for '{item.url}' Adding '{condition.cli}' to request.")
|
|
||||||
|
item_title = entry.get("title") or entry.get("id") or item.url
|
||||||
|
message = f"Condition '{condition.name}' matched for '{item_title}'."
|
||||||
|
|
||||||
|
if condition.cli:
|
||||||
|
message += f" Re-queuing with '{condition.cli}'."
|
||||||
|
|
||||||
|
LOG.info(message)
|
||||||
|
|
||||||
|
if condition.extras.get("ignore_download", False):
|
||||||
|
extra_msg: str = ""
|
||||||
|
if yt_conf.get("download_archive") and not condition.extras.get("no_archive", False):
|
||||||
|
archive_add(yt_conf.get("download_archive"), [archive_id])
|
||||||
|
extra_msg = f" and added to archive '{yt_conf.get('download_archive')}'"
|
||||||
|
|
||||||
|
log_message = f"Ignoring download of '{item_title}' as per condition '{condition.name}'{extra_msg}."
|
||||||
|
|
||||||
|
store_type, _ = self.get_item(archive_id=archive_id)
|
||||||
|
if not store_type:
|
||||||
|
dlInfo = Download(
|
||||||
|
info=ItemDTO(
|
||||||
|
id=entry.get("id"),
|
||||||
|
title=item_title,
|
||||||
|
url=item.url,
|
||||||
|
preset=item.preset,
|
||||||
|
folder=item.folder,
|
||||||
|
status="skip",
|
||||||
|
cookies=item.cookies,
|
||||||
|
template=item.template,
|
||||||
|
msg=log_message,
|
||||||
|
extras=item.extras,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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}'.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
return await self.add(item=item.new_with(requeued=True, cli=condition.cli), already=already)
|
return await self.add(item=item.new_with(requeued=True, cli=condition.cli), already=already)
|
||||||
|
|
||||||
_status, _msg = ytdlp_reject(entry=entry, yt_params=yt_conf)
|
_status, _msg = ytdlp_reject(entry=entry, yt_params=yt_conf)
|
||||||
|
|
|
||||||
|
|
@ -263,14 +263,14 @@ class HttpAPI:
|
||||||
response: Response = await handler(request)
|
response: Response = await handler(request)
|
||||||
|
|
||||||
contentType: str | None = response.headers.get("content-type", None)
|
contentType: str | None = response.headers.get("content-type", None)
|
||||||
if contentType and "/" != base_path and contentType.startswith("text/html"):
|
if contentType and contentType.startswith("text/html"):
|
||||||
rewrite_path: str = base_path.rstrip("/")
|
rewrite_path: str = base_path.rstrip("/")
|
||||||
async with await anyio.open_file(response._path, "rb") as f:
|
async with await anyio.open_file(response._path, "rb") as f:
|
||||||
content = await f.read()
|
content = await f.read()
|
||||||
content: str = (
|
content: str = (
|
||||||
content.decode("utf-8")
|
content.decode("utf-8")
|
||||||
.replace('<base href="/">', f'<base href="{rewrite_path}/">')
|
.replace('<base href="/">', f'<base href="{rewrite_path}/">')
|
||||||
.replace('baseURL:""', f'baseURL:"{rewrite_path}/"')
|
.replace("/_base_path/", f"{rewrite_path}/")
|
||||||
)
|
)
|
||||||
|
|
||||||
new_response = web.Response(text=content, content_type="text/html")
|
new_response = web.Response(text=content, content_type="text/html")
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,12 @@ class Condition:
|
||||||
filter: str
|
filter: str
|
||||||
"""The filter to run on info dict."""
|
"""The filter to run on info dict."""
|
||||||
|
|
||||||
cli: str
|
cli: str = ""
|
||||||
"""If matched append this to the download request and retry."""
|
"""If matched append this to the download request and retry."""
|
||||||
|
|
||||||
|
extras: dict[str, Any] = field(default_factory=dict)
|
||||||
|
"""Any extra data to store with the condition."""
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
def serialize(self) -> dict:
|
||||||
return self.__dict__
|
return self.__dict__
|
||||||
|
|
||||||
|
|
@ -137,6 +140,10 @@ class Conditions(metaclass=Singleton):
|
||||||
item["id"] = str(uuid.uuid4())
|
item["id"] = str(uuid.uuid4())
|
||||||
need_save = True
|
need_save = True
|
||||||
|
|
||||||
|
if "extras" not in item:
|
||||||
|
item["extras"] = {}
|
||||||
|
need_save = True
|
||||||
|
|
||||||
item: Condition = init_class(Condition, item)
|
item: Condition = init_class(Condition, item)
|
||||||
|
|
||||||
self._items.append(item)
|
self._items.append(item)
|
||||||
|
|
@ -145,7 +152,7 @@ class Conditions(metaclass=Singleton):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if need_save:
|
if need_save:
|
||||||
LOG.info("Saving conditions due to format, or id change.")
|
LOG.info("Saving conditions due changes.")
|
||||||
self.save(self._items)
|
self.save(self._items)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
@ -203,15 +210,16 @@ class Conditions(metaclass=Singleton):
|
||||||
msg = f"Invalid filter. '{e!s}'."
|
msg = f"Invalid filter. '{e!s}'."
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
if not item.get("cli"):
|
if item.get("cli"):
|
||||||
msg = "No command options for yt-dlp were found."
|
try:
|
||||||
raise ValueError(msg)
|
arg_converter(args=item.get("cli"))
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Invalid command options for yt-dlp. '{e!s}'."
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
try:
|
if not isinstance(item.get("extras"), dict):
|
||||||
arg_converter(args=item.get("cli"))
|
msg = "Extras must be a dictionary."
|
||||||
except Exception as e:
|
raise ValueError(msg) # noqa: TRY004
|
||||||
msg = f"Invalid command options for yt-dlp. '{e!s}'."
|
|
||||||
raise ValueError(msg) from e
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,9 @@ class Config:
|
||||||
if isinstance(self.pictures_backends, str) and self.pictures_backends:
|
if isinstance(self.pictures_backends, str) and self.pictures_backends:
|
||||||
self.pictures_backends = self.pictures_backends.split(",")
|
self.pictures_backends = self.pictures_backends.split(",")
|
||||||
|
|
||||||
|
if not self.base_path.endswith("/"):
|
||||||
|
self.base_path += "/"
|
||||||
|
|
||||||
numeric_level = getattr(logging, self.log_level.upper(), None)
|
numeric_level = getattr(logging, self.log_level.upper(), None)
|
||||||
if not isinstance(numeric_level, int):
|
if not isinstance(numeric_level, int):
|
||||||
msg = f"Invalid log level '{self.log_level}' specified."
|
msg = f"Invalid log level '{self.log_level}' specified."
|
||||||
|
|
@ -371,7 +374,7 @@ class Config:
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
LOG = logging.getLogger("config")
|
LOG: logging.Logger = logging.getLogger("config")
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@ class YoutubeHandler(BaseHandler):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_handle(task: Task) -> bool:
|
def can_handle(task: Task) -> bool:
|
||||||
if not task.get_ytdlp_opts().get_all().get("download_archive"):
|
if not task.get_ytdlp_opts().get_all().get("download_archive"):
|
||||||
LOG.debug(f"Task '{task.name}' does not have an archive file configured.")
|
LOG.debug(f"'{task.name}': Task does not have an archive file configured.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
LOG.debug(f"Checking if task '{task.name}' is using parsable YouTube URL: {task.url}")
|
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
|
||||||
return YoutubeHandler.parse(task.url) is not None
|
return YoutubeHandler.parse(task.url) is not None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -56,16 +56,15 @@ class YoutubeHandler(BaseHandler):
|
||||||
|
|
||||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
LOG.error(f"Cannot parse '{task.name}' URL: {task.url}")
|
LOG.error(f"'{task.name}': Cannot parse task URL: {task.url}")
|
||||||
return
|
return
|
||||||
|
|
||||||
params: dict = task.get_ytdlp_opts().get_all()
|
params: dict = task.get_ytdlp_opts().get_all()
|
||||||
|
|
||||||
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
|
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
|
||||||
LOG.debug(f"Fetching '{task.name}' feed.")
|
LOG.info(f"'{task.name}': Fetching feed.")
|
||||||
|
|
||||||
items: list = []
|
items: list = []
|
||||||
has_items = False
|
|
||||||
|
|
||||||
response = await YoutubeHandler.request(url=feed_url, ytdlp_opts=params)
|
response = await YoutubeHandler.request(url=feed_url, ytdlp_opts=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -76,11 +75,12 @@ class YoutubeHandler(BaseHandler):
|
||||||
"yt": "http://www.youtube.com/xml/schemas/2015",
|
"yt": "http://www.youtube.com/xml/schemas/2015",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
real_count: int = 0
|
||||||
for entry in root.findall("atom:entry", ns):
|
for entry in root.findall("atom:entry", ns):
|
||||||
vid_elem: Element[str] | None = entry.find("yt:videoId", ns)
|
vid_elem: Element[str] | None = entry.find("yt:videoId", ns)
|
||||||
vid: str | None = vid_elem.text if vid_elem is not None else ""
|
vid: str | None = vid_elem.text if vid_elem is not None else ""
|
||||||
if not vid:
|
if not vid:
|
||||||
LOG.warning(f"Entry in '{task.name}' feed is missing a video ID. Skipping entry.")
|
LOG.warning(f"'{task.name}': Entry in the feed is missing a video ID. Skipping.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
url: str = f"https://www.youtube.com/watch?v={vid}"
|
url: str = f"https://www.youtube.com/watch?v={vid}"
|
||||||
|
|
@ -88,7 +88,7 @@ class YoutubeHandler(BaseHandler):
|
||||||
id_dict: dict[str, str | None] = get_archive_id(url)
|
id_dict: dict[str, str | None] = get_archive_id(url)
|
||||||
archive_id: str | None = id_dict.get("archive_id")
|
archive_id: str | None = id_dict.get("archive_id")
|
||||||
if not archive_id:
|
if not archive_id:
|
||||||
LOG.warning(f"Could not compute archive ID for video '{vid}' in '{task.name}' feed. Skipping entry.")
|
LOG.warning(f"'{task.name}': Could not compute archive ID for video '{vid}' in feed. Skipping.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
title_elem: Element[str] | None = entry.find("atom:title", ns)
|
title_elem: Element[str] | None = entry.find("atom:title", ns)
|
||||||
|
|
@ -96,7 +96,7 @@ class YoutubeHandler(BaseHandler):
|
||||||
|
|
||||||
pub_elem: Element[str] | None = entry.find("atom:published", ns)
|
pub_elem: Element[str] | None = entry.find("atom:published", ns)
|
||||||
published: str | None = pub_elem.text if pub_elem is not None else ""
|
published: str | None = pub_elem.text if pub_elem is not None else ""
|
||||||
has_items = True
|
real_count += 1
|
||||||
|
|
||||||
if archive_id in YoutubeHandler.queued:
|
if archive_id in YoutubeHandler.queued:
|
||||||
continue
|
continue
|
||||||
|
|
@ -104,10 +104,10 @@ class YoutubeHandler(BaseHandler):
|
||||||
items.append({"id": vid, "url": url, "title": title, "published": published, "archive_id": archive_id})
|
items.append({"id": vid, "url": url, "title": title, "published": published, "archive_id": archive_id})
|
||||||
|
|
||||||
if len(items) < 1:
|
if len(items) < 1:
|
||||||
if not has_items:
|
if real_count < 1:
|
||||||
LOG.warning(f"No entries found in '{task.name}' feed. URL: {feed_url}")
|
LOG.warning(f"'{task.name}': No entries found the RSS feed. URL: {feed_url}")
|
||||||
else:
|
else:
|
||||||
LOG.debug(f"No new items found in '{task.name}' feed.")
|
LOG.info(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
|
||||||
return
|
return
|
||||||
|
|
||||||
filtered: list = []
|
filtered: list = []
|
||||||
|
|
@ -135,10 +135,10 @@ class YoutubeHandler(BaseHandler):
|
||||||
filtered.append(item)
|
filtered.append(item)
|
||||||
|
|
||||||
if len(filtered) < 1:
|
if len(filtered) < 1:
|
||||||
LOG.debug(f"No new items found in '{task.name}' feed.")
|
LOG.info(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
|
||||||
return
|
return
|
||||||
|
|
||||||
LOG.info(f"Found '{len(filtered)}' new items from '{task.name}' feed.")
|
LOG.info(f"'{task.name}': Found '{len(filtered)}/{real_count}' new items from feed.")
|
||||||
|
|
||||||
rItem: Item = Item.format(
|
rItem: Item = Item.format(
|
||||||
{
|
{
|
||||||
|
|
@ -158,7 +158,7 @@ class YoutubeHandler(BaseHandler):
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
LOG.error(f"Error while adding items from '{task.name}'. {e!s}")
|
LOG.error(f"'{task.name}': Error while adding items from task feed. {e!s}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -71,19 +71,25 @@ async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -
|
||||||
|
|
||||||
if not item.get("name"):
|
if not item.get("name"):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "name is required.", "data": item}, status=web.HTTPBadRequest.status_code
|
{"error": "Name is required.", "data": item}, status=web.HTTPBadRequest.status_code
|
||||||
)
|
)
|
||||||
|
|
||||||
if not item.get("filter"):
|
if not item.get("filter"):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "filter is required.", "data": item}, status=web.HTTPBadRequest.status_code
|
{"error": "Filter is required.", "data": item}, status=web.HTTPBadRequest.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
if not item.get("cli") and len(item.get("extras", {}).keys()) < 1:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "A Condition Must have cli options or extras", "data": item},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not item.get("cli"):
|
if not item.get("cli"):
|
||||||
return web.json_response(
|
item["cli"] = ""
|
||||||
{"error": "command options for yt-dlp is required.", "data": item},
|
|
||||||
status=web.HTTPBadRequest.status_code,
|
if not item.get("extras"):
|
||||||
)
|
item["extras"] = {}
|
||||||
|
|
||||||
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
||||||
item["id"] = str(uuid.uuid4())
|
item["id"] = str(uuid.uuid4())
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,60 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-inline">
|
||||||
|
<span class="icon"><i class="fa-solid fa-plus-square" /></span>
|
||||||
|
Extra Options
|
||||||
|
</label>
|
||||||
|
<div class="content">
|
||||||
|
<div v-if="form.extras && Object.keys(form.extras).length > 0" class="mb-3">
|
||||||
|
<div v-for="(value, key) in form.extras" :key="key" class="field is-grouped mb-2">
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" class="input" :value="key" @input="updateExtraKey($event, key)"
|
||||||
|
:disabled="addInProgress" placeholder="key_name">
|
||||||
|
</div>
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input type="text" class="input" :value="value" @input="updateExtraValue(key, $event)"
|
||||||
|
:disabled="addInProgress" placeholder="value">
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="button" class="button is-danger" @click="removeExtra(key)"
|
||||||
|
:disabled="addInProgress">
|
||||||
|
<span class="icon"><i class="fa-solid fa-times" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" class="input" v-model="newExtraKey" :disabled="addInProgress"
|
||||||
|
placeholder="new_key" @keyup.enter="addExtra">
|
||||||
|
</div>
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input type="text" class="input" v-model="newExtraValue" :disabled="addInProgress"
|
||||||
|
placeholder="new_value" @keyup.enter="addExtra">
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="button" class="button is-primary" @click="addExtra"
|
||||||
|
:disabled="addInProgress || !newExtraKey || !newExtraValue">
|
||||||
|
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||||
|
<span>Add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="help is-bold">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span>
|
||||||
|
For advanced users only. This feature is meant to be expanded later. the only supported key right
|
||||||
|
now. <code>ignore_download</code> with value of <code>true</code>. Keys must be lowercase with
|
||||||
|
underscores (e.g., custom_field).</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -231,6 +285,8 @@ const config = useConfigStore()
|
||||||
|
|
||||||
const form = reactive<ConditionItem>(JSON.parse(JSON.stringify(props.item)))
|
const form = reactive<ConditionItem>(JSON.parse(JSON.stringify(props.item)))
|
||||||
const import_string = ref('')
|
const import_string = ref('')
|
||||||
|
const newExtraKey = ref('')
|
||||||
|
const newExtraValue = ref('')
|
||||||
const test_data = ref<{
|
const test_data = ref<{
|
||||||
show: boolean,
|
show: boolean,
|
||||||
url: string,
|
url: string,
|
||||||
|
|
@ -241,10 +297,14 @@ const test_data = ref<{
|
||||||
const showOptions = ref<boolean>(false)
|
const showOptions = ref<boolean>(false)
|
||||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||||
|
|
||||||
|
// Initialize extras if not present
|
||||||
|
if (!form.extras) {
|
||||||
|
form.extras = {}
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||||
.filter(opt => !opt.ignored)
|
.filter(opt => !opt.ignored)
|
||||||
.flatMap(opt => opt.flags
|
.flatMap(opt => opt.flags.filter(flag => flag.startsWith('--'))
|
||||||
.filter(flag => flag.startsWith('--'))
|
|
||||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
@ -252,7 +312,7 @@ watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||||
watch(() => form.filter, () => test_data.value.changed = true)
|
watch(() => form.filter, () => test_data.value.changed = true)
|
||||||
|
|
||||||
const checkInfo = async (): Promise<void> => {
|
const checkInfo = async (): Promise<void> => {
|
||||||
const required: (keyof ConditionItem)[] = ['name', 'filter', 'cli']
|
const required: (keyof ConditionItem)[] = ['name', 'filter']
|
||||||
|
|
||||||
for (const key of required) {
|
for (const key of required) {
|
||||||
if (!form[key]) {
|
if (!form[key]) {
|
||||||
|
|
@ -261,6 +321,11 @@ const checkInfo = async (): Promise<void> => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((!form.cli || '' === form.cli.trim()) && Object.keys(form.extras).length < 1) {
|
||||||
|
toast.error('Either command options for yt-dlp or at least one extra option is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (form.cli && '' !== form.cli.trim()) {
|
if (form.cli && '' !== form.cli.trim()) {
|
||||||
const options = await convertOptions(form.cli)
|
const options = await convertOptions(form.cli)
|
||||||
if (options === null) {
|
if (options === null) {
|
||||||
|
|
@ -272,10 +337,10 @@ const checkInfo = async (): Promise<void> => {
|
||||||
const copy: ConditionItem = JSON.parse(JSON.stringify(form))
|
const copy: ConditionItem = JSON.parse(JSON.stringify(form))
|
||||||
|
|
||||||
for (const key in copy) {
|
for (const key in copy) {
|
||||||
if (typeof copy[key] !== 'string') {
|
if (typeof (copy as any)[key] !== 'string') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
copy[key] = copy[key].trim()
|
(copy as any)[key] = (copy as any)[key].trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) })
|
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) })
|
||||||
|
|
@ -343,7 +408,7 @@ const importItem = async (): Promise<void> => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((form.filter || form.cli) && !(await box.confirm('Overwrite the current form fields?', true))) {
|
if ((form.filter || form.cli || Object.keys(form.extras).length > 0) && !(await box.confirm('Overwrite the current form fields?', true))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,6 +424,10 @@ const importItem = async (): Promise<void> => {
|
||||||
form.cli = item.cli
|
form.cli = item.cli
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.extras) {
|
||||||
|
form.extras = { ...item.extras }
|
||||||
|
}
|
||||||
|
|
||||||
import_string.value = ''
|
import_string.value = ''
|
||||||
showImport.value = false
|
showImport.value = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -391,4 +460,82 @@ const logic_test = computed(() => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const validateKey = (key: string): boolean => /^[a-z][a-z0-9_]*$/.test(key)
|
||||||
|
|
||||||
|
const parseValue = (value: string): string | number | boolean => {
|
||||||
|
if (!isNaN(Number(value)) && !isNaN(parseFloat(value))) {
|
||||||
|
return Number(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('true' === value.toLowerCase()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ('false' === value.toLowerCase()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const addExtra = (): void => {
|
||||||
|
const key = newExtraKey.value.trim()
|
||||||
|
const value = newExtraValue.value.trim()
|
||||||
|
|
||||||
|
if (!key || !value) {
|
||||||
|
toast.error('Both key and value are required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateKey(key)) {
|
||||||
|
toast.error('Key must be lower_case.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.extras[key]) {
|
||||||
|
toast.error(`Key '${key}' already exists.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.extras[key] = parseValue(value)
|
||||||
|
newExtraKey.value = ''
|
||||||
|
newExtraValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeExtra = (key: string): void => {
|
||||||
|
delete form.extras[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateExtraKey = (event: Event, oldKey: string): void => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const newKey = target.value.trim()
|
||||||
|
|
||||||
|
if (!newKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateKey(newKey)) {
|
||||||
|
toast.error('Key must be lowercase and contain only letters, numbers, and underscores.')
|
||||||
|
target.value = oldKey
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newKey !== oldKey) {
|
||||||
|
if (form.extras[newKey]) {
|
||||||
|
toast.error(`Key '${newKey}' already exists.`)
|
||||||
|
target.value = oldKey
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = form.extras[oldKey]
|
||||||
|
delete form.extras[oldKey]
|
||||||
|
form.extras[newKey] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateExtraValue = (key: string, event: Event): void => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const value = target.value.trim()
|
||||||
|
form.extras[key] = value ? parseValue(value) : ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,14 @@
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>{{ cond.cli }}</span>
|
<span>{{ cond.cli }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="is-text-overflow" v-if="cond.extras && Object.keys(cond.extras).length > 0">
|
||||||
|
<span class="icon"><i class="fa-solid fa-list" /></span>
|
||||||
|
<span>Extras:
|
||||||
|
<span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2">
|
||||||
|
<strong>{{ key }}</strong>: {{ value }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content" v-if="cond?.raw">
|
<div class="card-content" v-if="cond?.raw">
|
||||||
|
|
@ -119,13 +127,15 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
|
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
|
||||||
|
|
||||||
|
type ConditionItemWithUI = ConditionItem & { raw?: boolean }
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
|
|
||||||
const items = ref<ConditionItem[]>([])
|
const items = ref<ConditionItemWithUI[]>([])
|
||||||
const item = ref<Partial<ConditionItem>>({})
|
const item = ref<Partial<ConditionItem>>({})
|
||||||
const itemRef = ref<string | null | undefined>("")
|
const itemRef = ref<string | null | undefined>("")
|
||||||
const toggleForm = ref(false)
|
const toggleForm = ref(false)
|
||||||
|
|
@ -188,12 +198,11 @@ const updateItems = async (newItems: ConditionItem[]): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
addInProgress.value = true
|
addInProgress.value = true
|
||||||
|
|
||||||
const validItems = newItems.map(({ id, name, filter, cli }) => {
|
const validItems = newItems.map(({ id, name, filter, cli, extras }) => {
|
||||||
if (!name || !filter || !cli) {
|
if (!name || !filter) {
|
||||||
toast.error('Name, filter and cli are required.')
|
throw new Error('Name and filter are required.')
|
||||||
throw new Error('Missing fields')
|
|
||||||
}
|
}
|
||||||
return { id, name, filter, cli }
|
return { id, name, filter, cli, extras }
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await request('/api/conditions', {
|
const response = await request('/api/conditions', {
|
||||||
|
|
@ -276,7 +285,7 @@ const exportItem = (cond: ConditionItem): void => {
|
||||||
const userData: ImportedConditionItem = {
|
const userData: ImportedConditionItem = {
|
||||||
...Object.fromEntries(Object.entries(clone).filter(([_, v]) => !!v)),
|
...Object.fromEntries(Object.entries(clone).filter(([_, v]) => !!v)),
|
||||||
_type: 'condition',
|
_type: 'condition',
|
||||||
_version: '1.0',
|
_version: '1.1',
|
||||||
} as ImportedConditionItem
|
} as ImportedConditionItem
|
||||||
|
|
||||||
copyText(encode(userData))
|
copyText(encode(userData))
|
||||||
|
|
|
||||||
2
ui/app/types/conditions.d.ts
vendored
2
ui/app/types/conditions.d.ts
vendored
|
|
@ -3,7 +3,7 @@ export type ConditionItem = {
|
||||||
name: string
|
name: string
|
||||||
filter: string
|
filter: string
|
||||||
cli: string
|
cli: string
|
||||||
[key: string]: any
|
extras: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImportedConditionItem = ConditionItem & {
|
export type ImportedConditionItem = ConditionItem & {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export default defineNuxtConfig({
|
||||||
transpile: ['vue-toastification'],
|
transpile: ['vue-toastification'],
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
baseURL: 'production' == process.env.NODE_ENV ? '' : '/',
|
baseURL: 'production' == process.env.NODE_ENV ? '/_base_path/' : '/',
|
||||||
buildAssetsDir: "assets",
|
buildAssetsDir: "assets",
|
||||||
head: {
|
head: {
|
||||||
"meta": [
|
"meta": [
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"hls.js": "^1.6.11",
|
"hls.js": "^1.6.11",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nuxt": "4.0.3",
|
"nuxt": "^4.1.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"vue": "^3.5.21",
|
"vue": "^3.5.21",
|
||||||
|
|
|
||||||
1131
ui/pnpm-lock.yaml
1131
ui/pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
12
uv.lock
12
uv.lock
|
|
@ -640,11 +640,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown"
|
name = "markdown"
|
||||||
version = "3.8.2"
|
version = "3.9"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071 }
|
sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827 },
|
{ url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1381,11 +1381,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2025.8.27"
|
version = "2025.9.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/d4/d9dd231b03f09fdfb5f0fe70f30de0b5f59454aa54fa6b2b2aea49404988/yt_dlp-2025.8.27.tar.gz", hash = "sha256:ed74768d2a93b29933ab14099da19497ef571637f7aa375140dd3d882b9c1854", size = 3038374 }
|
sdist = { url = "https://files.pythonhosted.org/packages/50/b2/fb255d991857a6a8b2539487ed6063e7bf318f19310d81f039dedb3c2ad6/yt_dlp-2025.9.5.tar.gz", hash = "sha256:9ce080f80b2258e872fe8a75f4707ea2c644e697477186e20b9a04d9a9ea37cf", size = 3045982 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/9c/b69fc0c800f80b94ea2f8eff1d1f473fecee6aa337681d297ba7c7c5d3fd/yt_dlp-2025.8.27-py3-none-any.whl", hash = "sha256:0b8fd3bb7c54bc2e7ecb5cdac7d64c30e2503ea4d3dd9ae24d4f09e22aaa95f4", size = 3267059 },
|
{ url = "https://files.pythonhosted.org/packages/06/64/b3cc116e4f209c493f23d6af033c60ba32df74e086190fbed2bdc0073d12/yt_dlp-2025.9.5-py3-none-any.whl", hash = "sha256:68a03b5c50e3d0f6af7244bd4bf491c1b12e4e2112b051cde05cdfd2647eb9a8", size = 3272317 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue