RSS feed support for youtube channel/playlist.
This commit is contained in:
parent
4ee0a9962b
commit
249bb43c15
3 changed files with 371 additions and 14 deletions
|
|
@ -1,15 +1,18 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import pkgutil
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
from app.library.Services import Services
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
from .Events import EventBus, Events, error, info, success
|
from .Events import EventBus, Events, error, info, success
|
||||||
|
|
@ -58,7 +61,6 @@ class Tasks(metaclass=Singleton):
|
||||||
loop: asyncio.AbstractEventLoop | None = None,
|
loop: asyncio.AbstractEventLoop | None = None,
|
||||||
config: Config | None = None,
|
config: Config | None = None,
|
||||||
encoder: Encoder | None = None,
|
encoder: Encoder | None = None,
|
||||||
client: httpx.AsyncClient | None = None,
|
|
||||||
scheduler: Scheduler | None = None,
|
scheduler: Scheduler | None = None,
|
||||||
):
|
):
|
||||||
Tasks._instance = self
|
Tasks._instance = self
|
||||||
|
|
@ -68,11 +70,11 @@ class Tasks(metaclass=Singleton):
|
||||||
self._debug: bool = config.debug
|
self._debug: bool = config.debug
|
||||||
self._default_preset: str = config.default_preset
|
self._default_preset: str = config.default_preset
|
||||||
self._file: Path = Path(file) if file else Path(config.config_path).joinpath("tasks.json")
|
self._file: Path = Path(file) if file else Path(config.config_path).joinpath("tasks.json")
|
||||||
self._client: httpx.AsyncClient = client or httpx.AsyncClient()
|
|
||||||
self._encoder: Encoder = encoder or Encoder()
|
self._encoder: Encoder = encoder or Encoder()
|
||||||
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
|
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
|
||||||
self._scheduler: Scheduler = scheduler or Scheduler.get_instance()
|
self._scheduler: Scheduler = scheduler or Scheduler.get_instance()
|
||||||
self._notify: EventBus = EventBus.get_instance()
|
self._notify: EventBus = EventBus.get_instance()
|
||||||
|
self._task_handler = HandleTask(self._scheduler, self)
|
||||||
|
|
||||||
if self._file.exists() and "600" != self._file.stat().st_mode:
|
if self._file.exists() and "600" != self._file.stat().st_mode:
|
||||||
try:
|
try:
|
||||||
|
|
@ -301,7 +303,9 @@ class Tasks(metaclass=Singleton):
|
||||||
LOG.info(f"Dispatched '{task.id}: {task.name}' at '{timeNow}'.")
|
LOG.info(f"Dispatched '{task.id}: {task.name}' at '{timeNow}'.")
|
||||||
|
|
||||||
tasks: list = [
|
tasks: list = [
|
||||||
self._notify.emit(Events.LOG_INFO, data=info(f"Dispatched '{task.name}' at '{timeNow}'.")),
|
self._notify.emit(
|
||||||
|
Events.LOG_INFO, data=info(f"Dispatched '{task.name}' at '{timeNow}'.", data={"lowPriority": True})
|
||||||
|
),
|
||||||
self._notify.emit(
|
self._notify.emit(
|
||||||
Events.ADD_URL,
|
Events.ADD_URL,
|
||||||
data={
|
data={
|
||||||
|
|
@ -324,10 +328,89 @@ class Tasks(metaclass=Singleton):
|
||||||
|
|
||||||
await self._notify.emit(
|
await self._notify.emit(
|
||||||
Events.LOG_SUCCESS,
|
Events.LOG_SUCCESS,
|
||||||
data=success(f"Completed '{task.name}' in '{ended - started:.2f}' seconds."),
|
data=success(
|
||||||
|
f"Completed '{task.name}' in '{ended - started:.2f}' seconds.", data={"lowPriority": True}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to execute '{task.id}: {task.name}' at '{timeNow}'. '{e!s}'.")
|
LOG.error(f"Failed to execute '{task.id}: {task.name}' at '{timeNow}'. '{e!s}'.")
|
||||||
await self._notify.emit(
|
await self._notify.emit(
|
||||||
Events.ERROR, data=error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
|
Events.ERROR, data=error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HandleTask:
|
||||||
|
_tasks: Tasks
|
||||||
|
|
||||||
|
def __init__(self, scheduler: Scheduler, tasks: Tasks) -> None:
|
||||||
|
self._tasks = tasks
|
||||||
|
self._handlers: list[type] = self._discover()
|
||||||
|
|
||||||
|
scheduler.add(
|
||||||
|
timer="15 */1 * * *",
|
||||||
|
func=self._dispatcher,
|
||||||
|
id=f"{__class__.__name__}._dispatcher",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dispatcher(self):
|
||||||
|
for task in self._tasks.get_all():
|
||||||
|
if not task.timer or "[no_handler]" in task.name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
handler = self._find_handler(task)
|
||||||
|
if handler is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
asyncio.create_task(self.dispatch(task, handler=handler), name=f"task-{task.id}")
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"Failed to handle task '{task.id}: {task.name}'. '{e!s}'.")
|
||||||
|
|
||||||
|
def _find_handler(self, task: Task) -> type | None:
|
||||||
|
for cls in self._handlers:
|
||||||
|
try:
|
||||||
|
if Services.get_instance().handle_sync(handler=cls.can_handle, task=task):
|
||||||
|
return cls
|
||||||
|
except Exception as e:
|
||||||
|
LOG.exception(e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def dispatch(self, task: Task, handler: type | None = None, **kwargs) -> Any | None:
|
||||||
|
"""
|
||||||
|
Dispatch a task to the appropriate handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task (Task): The task to dispatch.
|
||||||
|
handler (type|None): Optional specific handler to use instead of finding one.
|
||||||
|
**kwargs: Additional context to pass to the handler.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any|None: The result of the handler's execution, or None if no handler found.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not handler:
|
||||||
|
handler = self._find_handler(task)
|
||||||
|
if handler is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await Services.get_instance().handle_async(handler=handler.handle, task=task, **kwargs)
|
||||||
|
|
||||||
|
def _discover(self) -> list[type]:
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import app.library.task_handlers as handlers_pkg
|
||||||
|
|
||||||
|
handlers: list[type] = []
|
||||||
|
|
||||||
|
for _, module_name, _ in pkgutil.iter_modules(handlers_pkg.__path__):
|
||||||
|
module = importlib.import_module(f"{handlers_pkg.__name__}.{module_name}")
|
||||||
|
for _, cls in inspect.getmembers(module, inspect.isclass):
|
||||||
|
if cls.__module__ != module.__name__:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if callable(getattr(cls, "can_handle", None)) and callable(getattr(cls, "handle", None)):
|
||||||
|
handlers.append(cls)
|
||||||
|
|
||||||
|
return handlers
|
||||||
|
|
|
||||||
217
app/library/task_handlers/youtube.py
Normal file
217
app/library/task_handlers/youtube.py
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.library.cache import Cache
|
||||||
|
from app.library.config import Config
|
||||||
|
from app.library.DownloadQueue import DownloadQueue
|
||||||
|
from app.library.Events import EventBus, Events
|
||||||
|
from app.library.Tasks import Task
|
||||||
|
from app.library.Utils import is_downloaded
|
||||||
|
from app.library.YTDLPOpts import YTDLPOpts
|
||||||
|
|
||||||
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubeHandler:
|
||||||
|
queued_ids: set[str] = set()
|
||||||
|
|
||||||
|
FEED_CHANNEL = "https://www.youtube.com/feeds/videos.xml?channel_id={id}"
|
||||||
|
FEED_PLAYLIST = "https://www.youtube.com/feeds/videos.xml?playlist_id={id}"
|
||||||
|
|
||||||
|
CHANNEL_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$")
|
||||||
|
|
||||||
|
PLAYLIST_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:playlist\?list=(?P<id>[A-Za-z0-9_-]+)|).*$")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_handle(task: Task, config: Config) -> bool:
|
||||||
|
has, _ = YoutubeHandler.has_archive(task, config)
|
||||||
|
if not has:
|
||||||
|
LOG.debug(f"Task '{task.id}: {task.name}' does not have an archive file configured.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
LOG.debug(f"Checking if task '{task.id}: {task.name}' can handle YouTube URL: {task.url}")
|
||||||
|
return YoutubeHandler.parse(task.url) is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle(task: Task, cache: Cache, notify: EventBus, config: Config, queue: DownloadQueue):
|
||||||
|
"""
|
||||||
|
Fetch the Atom feed for a YouTube channel or playlist, parse entries,
|
||||||
|
and return a list of videos with metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task (Task): The task containing the YouTube URL.
|
||||||
|
cache (Cache): The cache instance for storing feed data.
|
||||||
|
notify (EventBus): The event bus for notifications.
|
||||||
|
config (Config): The configuration instance.
|
||||||
|
queue (DownloadQueue): The download queue instance.
|
||||||
|
|
||||||
|
"""
|
||||||
|
archive_file, params = YoutubeHandler.has_archive(task, config)
|
||||||
|
if not archive_file:
|
||||||
|
LOG.error(f"Task '{task.id}: {task.name}' does not have an archive file configured.")
|
||||||
|
return
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from defusedxml.ElementTree import fromstring
|
||||||
|
|
||||||
|
parsed = YoutubeHandler.parse(task.url)
|
||||||
|
if not parsed:
|
||||||
|
LOG.error(f"Cannot parse URL: {task.url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
feed_id = parsed["id"]
|
||||||
|
feed_type = parsed["type"]
|
||||||
|
|
||||||
|
if "channel" == feed_type:
|
||||||
|
feed_url = YoutubeHandler.FEED_CHANNEL.format(id=feed_id)
|
||||||
|
else:
|
||||||
|
feed_url = YoutubeHandler.FEED_PLAYLIST.format(id=feed_id)
|
||||||
|
|
||||||
|
cache_key = f"youtube_feed_{feed_id}"
|
||||||
|
data = cache.get(cache_key)
|
||||||
|
if not data:
|
||||||
|
LOG.info(f"Fetching '{task.id}: {task.name}' feed.")
|
||||||
|
opts = {
|
||||||
|
"proxy": params.get("proxy", None),
|
||||||
|
"headers": {
|
||||||
|
"User-Agent": params.get(
|
||||||
|
"user_agent",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(**opts) as client:
|
||||||
|
response = await client.request(method="GET", url=feed_url, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.text
|
||||||
|
cache.set(cache_key, data, ttl=3600)
|
||||||
|
else:
|
||||||
|
LOG.info(f"Using cached '{task.id}: {task.name}' feed.")
|
||||||
|
|
||||||
|
root = fromstring(data)
|
||||||
|
ns = {"atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015"}
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for entry in root.findall("atom:entry", ns):
|
||||||
|
vid_elem = entry.find("yt:videoId", ns)
|
||||||
|
title_elem = entry.find("atom:title", ns)
|
||||||
|
pub_elem = entry.find("atom:published", ns)
|
||||||
|
vid = vid_elem.text if vid_elem is not None else ""
|
||||||
|
title = title_elem.text if title_elem is not None else ""
|
||||||
|
published = pub_elem.text if pub_elem is not None else ""
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": vid,
|
||||||
|
"url": f"https://www.youtube.com/watch?v={vid}",
|
||||||
|
"title": title,
|
||||||
|
"published": published,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(items) < 1:
|
||||||
|
LOG.warning(f"No entries found in '{task.id}: {task.name}' feed. URL: {feed_url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for item in items:
|
||||||
|
status, _ = is_downloaded(archive_file, url=item["url"])
|
||||||
|
if status is True or item["id"] in YoutubeHandler.queued_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if queue.done.exists(url=item["url"]) or queue.queue.exists(url=item["url"]):
|
||||||
|
LOG.debug(f"Item '{item['id']}' already exists in the queue or download history.")
|
||||||
|
YoutubeHandler.queued_ids.add(item["id"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
YoutubeHandler.queued_ids.add(item["id"])
|
||||||
|
filtered.append(item)
|
||||||
|
|
||||||
|
if len(filtered) < 1:
|
||||||
|
LOG.info(f"No new items found in '{task.id}: {task.name}' feed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
LOG.info(f"Found '{len(filtered)}' new items from '{task.id}: {task.name}' feed.")
|
||||||
|
|
||||||
|
preset: str = str(task.preset or config.default_preset)
|
||||||
|
folder: str = task.folder if task.folder else ""
|
||||||
|
template: str = task.template if task.template else ""
|
||||||
|
cli: str = task.cli if task.cli else ""
|
||||||
|
|
||||||
|
queued = asyncio.gather(
|
||||||
|
*[
|
||||||
|
notify.emit(
|
||||||
|
Events.ADD_URL,
|
||||||
|
data={"url": item["url"], "preset": preset, "folder": folder, "template": template, "cli": cli},
|
||||||
|
)
|
||||||
|
for item in filtered
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await queued
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"Error while adding items to the queue: {e!s}")
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def has_archive(task: Task, config: Config) -> tuple[Path | None, dict]:
|
||||||
|
archive_file: Path | None = Path(config.archive_file) if config.keep_archive else None
|
||||||
|
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||||
|
|
||||||
|
if task.preset:
|
||||||
|
params.preset(name=task.preset)
|
||||||
|
|
||||||
|
if task.cli:
|
||||||
|
params.add_cli(task.cli, from_user=True)
|
||||||
|
|
||||||
|
params = params.get_all()
|
||||||
|
if user_archive_file := params.get("download_archive", None):
|
||||||
|
archive_file = Path(user_archive_file)
|
||||||
|
|
||||||
|
if not archive_file:
|
||||||
|
return (None, params)
|
||||||
|
|
||||||
|
if not archive_file.exists():
|
||||||
|
archive_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
archive_file.touch(exist_ok=True)
|
||||||
|
|
||||||
|
return (archive_file, params)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(url: str) -> dict[str, str] | None:
|
||||||
|
"""
|
||||||
|
Parse YouTube channel or playlist URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): The YouTube URL to parse.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{'type': 'channel', 'id': <channel-id>}
|
||||||
|
{'type': 'playlist', 'id': <playlist-id>}
|
||||||
|
None if the URL is neither.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if m := YoutubeHandler.CHANNEL_REGEX.match(url):
|
||||||
|
return {"type": "channel", "id": m.group("id")}
|
||||||
|
|
||||||
|
if m := YoutubeHandler.PLAYLIST_REGEX.match(url):
|
||||||
|
return {"type": "playlist", "id": m.group("id")}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tests() -> list[str]:
|
||||||
|
"""
|
||||||
|
Return a list of test URLs to validate the parsing logic.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
"https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN",
|
||||||
|
"https://youtube.com/c/MyCustomName",
|
||||||
|
"https://youtube.com/user/SomeUser123",
|
||||||
|
"https://youtube.com/@SomeHandle",
|
||||||
|
"https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ",
|
||||||
|
"https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ",
|
||||||
|
"https://youtube.com/watch?v=foo",
|
||||||
|
]
|
||||||
|
|
@ -1,5 +1,22 @@
|
||||||
<template>
|
<template>
|
||||||
<main class="columns mt-2 is-multiline">
|
<main class="columns mt-2 is-multiline">
|
||||||
|
<div class="column is-12" v-if="form?.url && is_yt_handle(form.url)">
|
||||||
|
<Message title="Warning" class="is-background-warning-80 has-text-dark" icon="fas fa-info-circle">
|
||||||
|
<span>
|
||||||
|
<ul>
|
||||||
|
<li>You are using a YouTube link with handle instead of channel_id. To activate RSS feed support for
|
||||||
|
channel, you need to use
|
||||||
|
the channel ID. For example, <code>https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To get youtube channel_id simply visit the page, click on <b>more about this channel</b>, scroll down to
|
||||||
|
<b>share
|
||||||
|
channel</b>, click on <code>Copy channel id</code>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</Message>
|
||||||
|
</div>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
|
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -79,7 +96,10 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>The channel or playlist URL.</span>
|
<span>The channel or playlist URL. For youtube there is rss feed support if you use URL with
|
||||||
|
channel_id or playlist_id. For example, https://www.youtube.com/<span
|
||||||
|
class="has-text-danger">channel/UCUi3_cffYenmMTuWEsLHzqg</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,7 +147,7 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" id="timer" v-model="form.timer" :disabled="addInProgress"
|
<input type="text" class="input" id="timer" v-model="form.timer" :disabled="addInProgress"
|
||||||
placeholder="0 12 * * 5">
|
placeholder="0 12 * * 5">
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -218,6 +238,28 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12">
|
||||||
|
<Message title="Tips" class="is-background-info-80 has-text-dark" icon="fas fa-info-circle">
|
||||||
|
<span>
|
||||||
|
<ul>
|
||||||
|
<li>To enable YouTube RSS feed monitoring, The task URL must include a <code>channel_id</code> or
|
||||||
|
<code>playlist_id</code>. Other link types won’t work.
|
||||||
|
</li>
|
||||||
|
<li>RSS monitoring runs every hour alongside the actual task execution. It checks each feed for new videos
|
||||||
|
and automatically queues any you haven’t downloaded yet.</li>
|
||||||
|
<li>To opt out of RSS monitoring for a specific task, append <code>[no_handler]</code> to that task’s name.
|
||||||
|
</li>
|
||||||
|
<li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in command options
|
||||||
|
for ytdlp.cli, preset or task.</li>
|
||||||
|
<li>If you don't have <code>--download-archive</code> set but <code>YTP_KEEP_ARCHIVE</code> environment
|
||||||
|
option is set to <code>true</code> which is the default, It will also work.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</Message>
|
||||||
|
</div>
|
||||||
|
|
||||||
<datalist id="folders" v-if="config?.folders">
|
<datalist id="folders" v-if="config?.folders">
|
||||||
<option v-for="dir in config.folders" :key="dir" :value="dir" />
|
<option v-for="dir in config.folders" :key="dir" :value="dir" />
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
@ -228,13 +270,6 @@
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { CronExpressionParser } from 'cron-parser'
|
import { CronExpressionParser } from 'cron-parser'
|
||||||
|
|
||||||
const emitter = defineEmits(['cancel', 'submit'])
|
|
||||||
const toast = useNotification()
|
|
||||||
const config = useConfigStore()
|
|
||||||
const showImport = useStorage('showImport', false)
|
|
||||||
const import_string = ref('')
|
|
||||||
const box = useConfirm()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
reference: {
|
reference: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|
@ -252,6 +287,17 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emitter = defineEmits(['cancel', 'submit'])
|
||||||
|
|
||||||
|
const toast = useNotification()
|
||||||
|
const config = useConfigStore()
|
||||||
|
const box = useConfirm()
|
||||||
|
const showImport = useStorage('showImport', false)
|
||||||
|
|
||||||
|
const import_string = ref('')
|
||||||
|
|
||||||
|
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))\/?$/;
|
||||||
|
|
||||||
const form = reactive(props.task)
|
const form = reactive(props.task)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
@ -420,4 +466,15 @@ const get_output_template = () => {
|
||||||
}
|
}
|
||||||
return config.app.output_template || '%(title)s.%(ext)s'
|
return config.app.output_template || '%(title)s.%(ext)s'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function is_yt_handle(url) {
|
||||||
|
let m = url.match(CHANNEL_REGEX);
|
||||||
|
if (m?.groups) {
|
||||||
|
if (m.groups?.channelId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue