diff --git a/.vscode/settings.json b/.vscode/settings.json index 7485ca2d..b5623dc6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -74,4 +74,13 @@ "spellright.documentTypes": [ "latex" ], + "search.exclude": { + "ui/.nuxt": true, + "ui/.output": true, + "ui/dist": true, + "ui/exported": true, + "ui/node_modules": true, + "ui/.out": true, + "**/.venv": true, + } } diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index f922e84a..b323f713 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -871,6 +871,83 @@ class HttpAPI(Common): await self._notify.emit(Events.CONDITIONS_UPDATE, data=items) return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + @route("POST", "api/conditions/test") + async def conditions_test(self, request: Request) -> Response: + """ + Test condition against URL. + + Args: + request (Request): The request object. + + Returns: + Response: The response object + + """ + params = await request.json() + + if not isinstance(params, dict): + return web.json_response( + {"error": "Invalid request body expecting dict."}, + status=web.HTTPBadRequest.status_code, + ) + + url = params.get("url") + if not url: + return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code) + + cond = params.get("condition") + if not cond: + return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code) + + try: + preset = params.get("preset", self.config.default_preset) + key = self.cache.hash(url + str(preset)) + if not self.cache.has(key): + opts = {} + if ytdlp_proxy := self.config.get_ytdlp_args().get("proxy", None): + opts["proxy"] = ytdlp_proxy + ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset, with_cookies=True).add(opts).get_all() + + data = extract_info( + config=ytdlp_opts, + url=url, + debug=False, + no_archive=True, + follow_redirect=True, + sanitize_info=True, + ) + if not data: + return web.json_response( + data={"error": f"Failed to extract info from '{url!s}'."}, + status=web.HTTPBadRequest.status_code, + ) + self.cache.set(key=key, value=data, ttl=600) + else: + data = self.cache.get(key) + except Exception as e: + LOG.exception(e) + return web.json_response( + data={"error": f"Failed to extract video info. '{e!s}'"}, + status=web.HTTPInternalServerError.status_code, + ) + + try: + from yt_dlp.utils import match_str + + status = match_str(cond, data) + except Exception as e: + LOG.exception(e) + return web.json_response( + data={"error": str(e)}, + status=web.HTTPBadRequest.status_code, + ) + + return web.json_response( + data={"status": status, "condition": cond, "data": data}, + status=web.HTTPOk.status_code, + dumps=self.encoder.encode, + ) + @route("GET", "api/presets") async def presets(self, request: Request) -> Response: """ @@ -1007,9 +1084,6 @@ class HttpAPI(Common): if not item.get("id", None) or not validate_uuid(item.get("id"), version=4): item["id"] = str(uuid.uuid4()) - if not item.get("timer", None) or str(item.get("timer")).strip() == "": - item["timer"] = f"{random.randint(1,59)} */1 * * *" # noqa: S311 - if not item.get("template", None): item["template"] = "" diff --git a/app/library/Tasks.py b/app/library/Tasks.py index ed3a448e..e529a54f 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -108,7 +108,7 @@ class Tasks(metaclass=Singleton): self.load() self._notify.subscribe( Events.TASKS_ADD, - lambda data, _, **kwargs: self.add(**data.data), # noqa: ARG005 + lambda data, _, **kwargs: self.save(**data.data), # noqa: ARG005 f"{__class__.__name__}.add", ) @@ -145,6 +145,7 @@ class Tasks(metaclass=Singleton): for i, task in enumerate(tasks): try: task, task_status = clean_item(task, keys=("cookies", "config")) + self.validate(task) task = Task(**task) if task_status: need_save = True @@ -152,9 +153,13 @@ class Tasks(metaclass=Singleton): LOG.error(f"Failed to parse task at list position '{i}'. '{e!s}'.") continue + self._tasks.append(task) + + if not task.timer: + continue + try: self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task.id) - self._tasks.append(task) try: from cronsim import CronSim @@ -221,14 +226,19 @@ class Tasks(metaclass=Singleton): msg = "No name found." raise ValueError(msg) - if not task.get("timer"): - msg = "No timer found." - raise ValueError(msg) - if not task.get("url"): msg = "No URL found." raise ValueError(msg) + if task.get("timer"): + try: + from cronsim import CronSim + + CronSim(task.get("timer"), datetime.now(UTC)) + except Exception as e: + msg = f"Invalid timer format. '{e!s}'." + raise ValueError(msg) from e + if task.get("cli"): try: from .Utils import arg_converter diff --git a/app/library/Utils.py b/app/library/Utils.py index cc5f5018..b3e61ec1 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -453,7 +453,7 @@ def arg_converter( if isinstance(matchFilter, set): diff["match_filter"] = {"filters": list(matchFilter)} - return json.loads(json.dumps(diff, cls=Encoder)) + return json.loads(json.dumps(diff, cls=Encoder, default=str)) return diff @@ -760,15 +760,40 @@ def get_files(base_path: str, dir: str | None = None): else: dir_path = base_path - if not os.path.isdir(dir_path): + dir_path = pathlib.Path(dir_path) + if dir_path.is_symlink(): + try: + dir_path = dir_path.resolve() + except OSError as e: + LOG.warning(f"Skipping broken symlink: {dir_path} - {e}") + return [] + + if not str(dir_path).startswith(base_path): + msg = f"Invalid path: '{dir_path}' - must be inside '{base_path}'." + LOG.warning(msg) + return [] + + if not dir_path.is_dir(): msg = f"Invalid path: '{dir_path}' - must be a directory." - raise OSError(msg) + LOG.warning(msg) + return [] contents: list = [] - for file in pathlib.Path(dir_path).iterdir(): + for file in dir_path.iterdir(): if file.name.startswith(".") or file.name.startswith("_"): continue + if file.is_symlink(): + try: + test: pathlib.Path = file.resolve() + if not str(test).startswith(base_path): + msg = f"Invalid symlink: '{file}' - must resolve inside '{base_path}'." + LOG.warning(msg) + continue + except OSError: + LOG.warning(f"Skipping broken symlink: {file}") + continue + content_type = None for pattern in FILES_TYPE: diff --git a/app/library/conditions.py b/app/library/conditions.py index b91beba1..cb764052 100644 --- a/app/library/conditions.py +++ b/app/library/conditions.py @@ -317,3 +317,26 @@ class Conditions(metaclass=Singleton): continue return None + + def single_match(self, name: str, info: dict) -> Condition | None: + """ + Check if condition matches the info dict. + + Args: + name (str): The condition name to check. + info (dict): The info dict to check. + + Returns: + Condition|None: The condition if found, None otherwise. + + """ + if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1: + return None + + item = self.get(name) + if not item or not item.filter: + return None + + from yt_dlp.utils import match_str + + return item if match_str(item.filter, info) else None diff --git a/ui/assets/css/style.css b/ui/assets/css/style.css index 1894c280..fb8781d3 100644 --- a/ui/assets/css/style.css +++ b/ui/assets/css/style.css @@ -325,7 +325,10 @@ hr { white-space: nowrap; } - .is-max-width { max-width: calc(100% - 10px); } + +.is-bold { + font-weight: bold; +} diff --git a/ui/components/ConditionForm.vue b/ui/components/ConditionForm.vue index 144940c6..4e7b4f7c 100644 --- a/ui/components/ConditionForm.vue +++ b/ui/components/ConditionForm.vue @@ -57,7 +57,7 @@
+ placeholder="For the problematic channel or video name.">
@@ -70,15 +70,20 @@
+ placeholder="availability = 'needs_auth' & channel_id = 'channel_id'">
- The yt-dlp [--match-filters] filter logic. + + The yt-dlp [--match-filters] filter logic. + + Test filter logic + +
@@ -108,29 +113,102 @@ + +
+ +
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+ + + The url to test the filter against. + +
+ +
+ +
+ +
+ + + The yt-dlp [--match-filters] filter logic.
+
+
+ +
+ + + Filter Status: {{ test_data?.data?.status === null ? 'Not tested' : test_data?.data?.status ? + 'Matched' : 'Not matched' }} + +
+ +
+
{{ show_data() }}
+
+ +
+
+ +
+
diff --git a/ui/components/FloatingImage.vue b/ui/components/FloatingImage.vue index 1ac1e289..2c346b57 100644 --- a/ui/components/FloatingImage.vue +++ b/ui/components/FloatingImage.vue @@ -43,7 +43,7 @@ const props = defineProps({ }); const cache = useSessionCache() -const toast = useToast() +const toast = useNotification() const url = ref() const error = ref(false) const isPreloading = ref(false) diff --git a/ui/components/GetInfo.vue b/ui/components/GetInfo.vue index 273312a9..5135dd2c 100644 --- a/ui/components/GetInfo.vue +++ b/ui/components/GetInfo.vue @@ -40,7 +40,7 @@ import { request } from '~/utils/index' const emitter = defineEmits(['closeModel']) const isLoading = ref(false) const data = ref({}) -const toast = useToast() +const toast = useNotification() const props = defineProps({ link: { diff --git a/ui/components/History.vue b/ui/components/History.vue index a5954de4..368b9657 100644 --- a/ui/components/History.vue +++ b/ui/components/History.vue @@ -385,7 +385,8 @@ const emitter = defineEmits(['getInfo', 'add_new']) const config = useConfigStore() const stateStore = useStateStore() const socket = useSocketStore() -const toast = useToast() +const toast = useNotification() +const box = useConfirm() const selectedElms = ref([]) const masterSelectAll = ref(false) @@ -488,7 +489,7 @@ const deleteSelectedItems = () => { msg += '\nThis will delete the files from the server if they exist.' } - if (false === confirm(msg)) { + if (false === box.confirm(msg, config.app.remove_files)) { return } @@ -506,7 +507,7 @@ const deleteSelectedItems = () => { const clearCompleted = () => { let msg = 'Are you sure you want to clear all completed downloads?' - if (false === confirm(msg)) { + if (false === box.confirm(msg)) { return } @@ -518,7 +519,7 @@ const clearCompleted = () => { } const clearIncomplete = () => { - if (false === confirm('Are you sure you want to clear all in-complete downloads?')) { + if (false === box.confirm('Are you sure you want to clear all in-complete downloads?')) { return } @@ -598,7 +599,7 @@ const setStatus = item => { } const requeueIncomplete = () => { - if (false === confirm('Are you sure you want to re-queue all incomplete downloads?')) { + if (false === box.confirm('Are you sure you want to re-queue all incomplete downloads?')) { return false } @@ -612,7 +613,7 @@ const requeueIncomplete = () => { } const archiveItem = item => { - if (!confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) { + if (!box.confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) { return } socket.emit('archive_item', item) @@ -621,7 +622,7 @@ const archiveItem = item => { const removeItem = item => { const msg = `Remove '${item.title ?? item.id ?? item.url ?? '??'}'?\n this will delete the file from the server.` - if (config.app.remove_files && !confirm(msg)) { + if (false === box.confirm(msg, config.app.remove_files)) { return false } diff --git a/ui/components/ImageView.vue b/ui/components/ImageView.vue index 669316d0..8a5a22a4 100644 --- a/ui/components/ImageView.vue +++ b/ui/components/ImageView.vue @@ -23,7 +23,7 @@ import { request } from '~/utils/index' const emitter = defineEmits(['closeModel']) const isLoading = ref(false) const data = ref({}) -const toast = useToast() +const toast = useNotification() const image = ref('') const props = defineProps({ diff --git a/ui/components/Modal.vue b/ui/components/Modal.vue new file mode 100644 index 00000000..20582c57 --- /dev/null +++ b/ui/components/Modal.vue @@ -0,0 +1,35 @@ + + + diff --git a/ui/components/NewDownload.vue b/ui/components/NewDownload.vue index 172a0d48..9507d583 100644 --- a/ui/components/NewDownload.vue +++ b/ui/components/NewDownload.vue @@ -189,7 +189,8 @@ const props = defineProps({ const emitter = defineEmits(['getInfo', 'clear_form']) const config = useConfigStore() const socket = useSocketStore() -const toast = useToast() +const toast = useNotification() +const box = useConfirm() const showAdvanced = useStorage('show_advanced', false) const addInProgress = ref(false) @@ -261,7 +262,7 @@ const addDownload = async () => { } const resetConfig = () => { - if (true !== confirm('Reset your local configuration?')) { + if (true !== box.confirm('Reset your local configuration?')) { return } diff --git a/ui/components/NotificationForm.vue b/ui/components/NotificationForm.vue index 87130c3f..ded92e10 100644 --- a/ui/components/NotificationForm.vue +++ b/ui/components/NotificationForm.vue @@ -268,7 +268,7 @@ diff --git a/ui/components/TaskForm.vue b/ui/components/TaskForm.vue index 2694803b..fcf0b1ef 100644 --- a/ui/components/TaskForm.vue +++ b/ui/components/TaskForm.vue @@ -111,8 +111,9 @@ Select the preset to use for this URL. If the - -f, --format argument is present in the command line options, the preset and all - it's options will be ignored. + -f, --format + argument is present in the command line options, the preset and all + it's options will be ignored.
@@ -125,16 +126,15 @@ CRON expression timer.
- +
- The CRON timer expression to use for this task. If not set, the task will run once an hour in a - random - minute. For more information on CRON expressions, see crontab.guru. + The CRON timer expression to use for this task. If not set, the task will be disabled. For more + information on CRON expressions, see + crontab.guru. @@ -152,8 +152,8 @@ - Paths are relative to global download path, defaults to preset download path if set otherwise, - fallback root path if not set. + Current download folder: {{ get_download_folder() }}. All folders are + sub-folders of {{ config.app.download_path }}. @@ -170,11 +170,7 @@ - Use this output template if non are given with URL. if not set, it will defaults to - {{ config.app.output_template }}. - For more information visit this url. - + Current output format: {{ get_output_template() }}. @@ -232,11 +228,12 @@ import { useStorage } from '@vueuse/core' import { CronExpressionParser } from 'cron-parser' -const emitter = defineEmits(['cancel', 'submit']); -const toast = useToast(); -const config = useConfigStore(); -const showImport = useStorage('showImport', false); -const import_string = ref(''); +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({ reference: { @@ -255,49 +252,49 @@ const props = defineProps({ }, }) -const form = reactive(props.task); +const form = reactive(props.task) onMounted(() => { if (!props.task?.preset || '' === props.task.preset) { - form.preset = toRaw(config.app.default_preset); + form.preset = toRaw(config.app.default_preset) } }) const checkInfo = async () => { - const required = ['name', 'url']; + const required = ['name', 'url'] for (const key of required) { if (!form[key]) { - toast.error(`The ${key} field is required.`); - return; + toast.error(`The ${key} field is required.`) + return } } if (form.timer) { try { - CronExpressionParser.parse(form.timer); + CronExpressionParser.parse(form.timer) } catch (e) { console.error(e) - toast.error(`Invalid CRON expression. ${e.message}`); - return; + toast.error(`Invalid CRON expression. ${e.message}`) + return } } try { - new URL(form.url); + new URL(form.url) } catch (e) { - toast.error('Invalid URL'); - return; + toast.error('Invalid URL') + return } if (form?.cli && '' !== form.cli) { - const options = await convertOptions(form.cli); + const options = await convertOptions(form.cli) if (null === options) { return } form.cli = form.cli.trim(" ") } - emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) }); + emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) }) } const importItem = async () => { @@ -327,7 +324,7 @@ const importItem = async () => { } if (form.url || form.timer) { - if (false === confirm('This will overwrite the current form fields. Are you sure?')) { + if (false === box.confirm('This will overwrite the current form fields. Are you sure?', true)) { return } } @@ -391,7 +388,7 @@ const convertOptions = async args => { toast.error(e.message) } - return null; + return null } const hasFormatInConfig = computed(() => { @@ -404,4 +401,23 @@ const hasFormatInConfig = computed(() => { const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag) +const get_download_folder = () => { + if (form.preset && !hasFormatInConfig.value) { + const preset = config.presets.find(p => p.name === form.preset) + if (preset && preset.folder) { + return preset.folder.replace(config.app.download_path, '') + } + } + return '/' +} + +const get_output_template = () => { + if (form.preset && !hasFormatInConfig.value) { + const preset = config.presets.find(p => p.name === form.preset) + if (preset && preset.template) { + return preset.template + } + } + return config.app.output_template || '%(title)s.%(ext)s' +} diff --git a/ui/components/VideoPlayer.vue b/ui/components/VideoPlayer.vue index 6a1dcd22..52c6ef1a 100644 --- a/ui/components/VideoPlayer.vue +++ b/ui/components/VideoPlayer.vue @@ -31,7 +31,7 @@ import { onMounted, onUpdated, ref, onUnmounted } from 'vue' import Hls from 'hls.js' import { makeDownload } from '~/utils/index' const config = useConfigStore() -const toast = useToast() +const toast = useNotification() const props = defineProps({ item: { diff --git a/ui/composables/useConfirm.ts b/ui/composables/useConfirm.ts new file mode 100644 index 00000000..90ed293d --- /dev/null +++ b/ui/composables/useConfirm.ts @@ -0,0 +1,23 @@ +import { useStorage } from '@vueuse/core' + +const reduceConfirm = useStorage('reduce_confirm', false) + +function confirm(msg: string, force: boolean = false): boolean { + if (false === force && true === reduceConfirm.value) { + return true + } + + return window.confirm(msg) +} + +function alert(msg: string): boolean { + return window.confirm(msg) +} + +function prompt(msg: string, defaultValue: string = ''): string | null { + return window.prompt(msg, defaultValue) +} + +export default function useConfirm() { + return { confirm, alert, prompt, reduceConfirm } +} diff --git a/ui/composables/useNotification.ts b/ui/composables/useNotification.ts new file mode 100644 index 00000000..dda3f462 --- /dev/null +++ b/ui/composables/useNotification.ts @@ -0,0 +1,60 @@ +import { useStorage } from '@vueuse/core' +import { POSITION, useToast } from "vue-toastification" + +type notificationType = 'info' | 'success' | 'warning' | 'error' +type notificationOptions = { + timeout?: number, + force?: boolean, + closeOnClick?: boolean, + position?: POSITION +} + +const allowToast = useStorage('allow_toasts', true) +const toastPosition = useStorage('toast_position', POSITION.TOP_RIGHT) +const toastDismissOnClick = useStorage('toast_dismiss_on_click', true) +const toast = useToast() + +function notify(type: notificationType, message: string, opts?: notificationOptions): void { + let force = opts?.force || false; + if (false === allowToast.value && false === force) { + return; + } + + if (!opts) { + opts = {} + } + + if (opts?.force) { + delete opts.force + } + + opts.closeOnClick = toastDismissOnClick.value + opts.position = toastPosition.value ?? POSITION.TOP_RIGHT + + switch (type) { + case 'info': + toast.info(message, opts) + break; + case 'success': + toast.success(message, opts) + break; + case 'warning': + toast.warning(message, opts) + break; + case 'error': + toast.error(message, opts) + break; + default: + toast.error(`Unknown notification type: ${type}. ${message}`, opts) + break; + } +} + +export default function useNotification() { + return { + info: (message: string, opts?: notificationOptions) => notify('info', message, opts), + success: (message: string, opts?: notificationOptions) => notify('success', message, opts), + warning: (message: string, opts?: notificationOptions) => notify('warning', message, opts), + error: (message: string, opts?: notificationOptions) => notify('error', message, opts), + } +} diff --git a/ui/composables/useToast.js b/ui/composables/useToast.js deleted file mode 100644 index 4738a4ca..00000000 --- a/ui/composables/useToast.js +++ /dev/null @@ -1,2 +0,0 @@ -import { useToast } from "vue-toastification"; -export default () => useToast() diff --git a/ui/layouts/default.vue b/ui/layouts/default.vue index 11bb176d..9319a463 100644 --- a/ui/layouts/default.vue +++ b/ui/layouts/default.vue @@ -87,7 +87,7 @@
-
{{ filterItem(cond) }}
+
{{ JSON.stringify(cleanObject(cond, remove_keys), null, 2) }}
@@ -113,9 +117,10 @@ diff --git a/ui/stores/SocketStore.js b/ui/stores/SocketStore.js index 4b83f2b2..2441b3ae 100644 --- a/ui/stores/SocketStore.js +++ b/ui/stores/SocketStore.js @@ -5,7 +5,7 @@ export const useSocketStore = defineStore('socket', () => { const runtimeConfig = useRuntimeConfig() const config = useConfigStore() const stateStore = useStateStore() - const toast = useToast() + const toast = useNotification() const socket = ref(null); const isConnected = ref(false); diff --git a/ui/utils/index.js b/ui/utils/index.js index d7fefd9d..8bf347ea 100644 --- a/ui/utils/index.js +++ b/ui/utils/index.js @@ -1,6 +1,4 @@ -import { useStorage } from '@vueuse/core' - -const toast = useToast() +const toast = useNotification() const AG_SEPARATOR = '.' /** @@ -221,7 +219,12 @@ const encodePath = item => { } item = item.replace(/#/g, '%23') - return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/') + try { + return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/') + } catch (e) { + console.error('Error encoding path:', e, item) + return item + } } /** @@ -473,6 +476,22 @@ const toggleClass = (target, className) => { target.classList.add(className) } +const cleanObject = (item, fields = []) => { + if (!item || typeof item !== 'object' || fields.length < 1) { + return item + } + + const cleaned = {} + + for (const key of Object.keys(item)) { + if (false === fields.includes(key)) { + cleaned[key] = item[key] + } + } + + return cleaned +} + export { ag_set, ag, @@ -499,4 +518,5 @@ export { base64UrlDecode, has_data, toggleClass, + cleanObject, }