From 4367fb0937607e21b1db7ce44a24ac01b1d576e6 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 14 Jun 2025 21:29:25 +0300 Subject: [PATCH] Add option to remove item from archive, and minor ui updates. --- app/library/HttpAPI.py | 1 + app/library/HttpSocket.py | 1 + app/library/Utils.py | 37 +++++- app/library/config.py | 5 +- app/routes/api/archive.py | 168 +++++++++++++++++++++++++++ ui/components/ConfirmDialog.vue | 98 ++++++++++++++++ ui/components/Dropdown.vue | 63 ++++++++-- ui/components/History.vue | 196 +++++++++++++++++++++++++------- ui/components/NewDownload.vue | 61 ++++++++-- ui/components/Queue.vue | 11 +- ui/pages/index.vue | 3 +- ui/utils/index.js | 13 +++ 12 files changed, 585 insertions(+), 72 deletions(-) create mode 100644 app/routes/api/archive.py create mode 100644 ui/components/ConfirmDialog.vue diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index d90c384b..27f4a241 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -240,6 +240,7 @@ class HttpAPI: "cache": this.cache, "app": app, "http_api": this, + "root_path": this.rootPath, } try: diff --git a/app/library/HttpSocket.py b/app/library/HttpSocket.py index 67cc1935..6975bcd7 100644 --- a/app/library/HttpSocket.py +++ b/app/library/HttpSocket.py @@ -63,6 +63,7 @@ class HttpSocket: "sio": self.sio, "encoder": encoder, "notify": self._notify, + "root_path": self.rootPath, } self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit") diff --git a/app/library/Utils.py b/app/library/Utils.py index 95682407..11376d44 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -283,6 +283,41 @@ def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, s return (False, idDict) +def remove_from_archive(archive_file: str | Path, url: str) -> bool: + """ + Remove the downloaded video record from the archive file. + + Args: + archive_file (str): Archive file path. + url (str): URL to check and remove. + + Returns: + bool: True if the record removed, False otherwise. + + """ + if not url or not archive_file: + return False + + archive_path: Path = Path(archive_file) if not isinstance(archive_file, Path) else archive_file + if not archive_path.exists(): + return False + + idDict = get_archive_id(url=url) + archive_id: str | None = idDict.get("archive_id") + + if not archive_id: + return False + + lines: list[str] = archive_path.read_text(encoding="utf-8").splitlines() + new_lines: list[str] = [line for line in lines if archive_id not in line] + + if len(lines) == len(new_lines): + return False + + archive_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + return True + + def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]: """ Load a JSON or JSON5 file and return the contents as a dictionary @@ -1221,7 +1256,7 @@ def load_modules(root_path: Path, directory: Path): import importlib import pkgutil - package_name: str = str(directory.relative_to(root_path)).replace("/", ".") + package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".") LOG.debug(f"Loading routes from '{directory}' with package name '{package_name}'.") diff --git a/app/library/config.py b/app/library/config.py index 9c0de57d..5337a7eb 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -94,6 +94,9 @@ class Config: db_file: str = "{config_path}/ytptube.db" """The path to the database file.""" + archive_file: str = "{config_path}/archive.log" + """The path to the download archive file.""" + manual_archive: str = "{config_path}/archive.manual.log" """The path to the manual archive file.""" @@ -371,7 +374,7 @@ class Config: self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}" if self.keep_archive: - archive_file: Path = Path(self.config_path) / "archive.log" + archive_file: Path = Path(self.archive_file) if not archive_file.exists(): LOG.info(f"Creating archive file '{archive_file}'.") archive_file.touch(exist_ok=True) diff --git a/app/routes/api/archive.py b/app/routes/api/archive.py new file mode 100644 index 00000000..cbd42f23 --- /dev/null +++ b/app/routes/api/archive.py @@ -0,0 +1,168 @@ +import logging +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING + +import anyio +from aiohttp import web +from aiohttp.web import Request, Response + +from app.library.config import Config +from app.library.Download import Download +from app.library.DownloadQueue import DownloadQueue +from app.library.router import route +from app.library.Utils import is_downloaded, remove_from_archive +from app.library.YTDLPOpts import YTDLPOpts + +if TYPE_CHECKING: + from library.Download import Download + +LOG: logging.Logger = logging.getLogger(__name__) + + +@route("DELETE", r"api/archive/{id}", "archive.remove") +async def archive_remove(request: Request, queue: DownloadQueue, config: Config) -> Response: + """ + Remove an item from the archive. + + Args: + request (Request): The request object. + queue (DownloadQueue): The download queue instance. + config (Config): The configuration instance. + + Returns: + Response: The response object. + + """ + item = None + try: + data: dict | None = await request.json() + except Exception: + data = {} + + title: str = "" + + url: str | None = data.get("url", None) if data else None + + if not url: + id: str = request.match_info.get("id") + if not id: + return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) + + try: + item: Download | None = queue.done.get_by_id(id) + if not item: + return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) + + url = item.info.url + title = f" '{item.info.title}'" + except KeyError: + return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) + + if config.manual_archive: + remove_from_archive(archive_file=Path(config.manual_archive), url=url) + + archive_file: Path | None = Path(config.archive_file) if config.keep_archive else None + if item: + params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=item.info.preset) + if item.info.cli: + params.add_cli(item.info.cli, from_user=True) + + params = params.get_all() + if user_file := params.get("download_archive", None): + archive_file = Path(user_file) + + if not archive_file: + return web.json_response( + data={ + "error": "Archive file is not configured." if not config.keep_archive else "Archive file is not set." + }, + status=web.HTTPBadRequest.status_code, + ) + + if not archive_file.exists(): + return web.json_response( + data={"error": f"Archive file '{archive_file}' does not exist."}, + status=web.HTTPNotFound.status_code, + ) + + if not remove_from_archive(archive_file=archive_file, url=url): + return web.json_response( + data={"error": f"item{title} not found in '{archive_file}' archive."}, + status=web.HTTPNotFound.status_code, + ) + + return web.json_response( + data={"message": f"item{title} removed from '{archive_file}' archive."}, + status=web.HTTPOk.status_code, + ) + + +@route("POST", r"api/archive/{id}", "archive.item") +async def archive_item(request: Request, queue: DownloadQueue, config: Config): + """ + Manually mark an item as archived. + + Args: + request (Request): The request object. + queue (DownloadQueue): The download queue instance. + config (Config): The configuration instance. + + Returns: + Response: The response object. + + """ + id: str = request.match_info.get("id") + + if not id: + return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) + + try: + item: Download | None = queue.done.get_by_id(id) + if not item: + return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code) + except KeyError: + return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code) + + if config.manual_archive: + manual_archive = Path(config.manual_archive) + if manual_archive.exists(): + exists, idDict = is_downloaded(manual_archive, item.info.url) + if exists is False and idDict.get("archive_id"): + async with await anyio.open_file(manual_archive, "a") as f: + await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n") + + params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=item.info.preset) + if item.info.cli: + params.add_cli(item.info.cli, from_user=True) + + params = params.get_all() + + user_file: str | None = params.get("download_archive", None) + archive_file: Path = Path(user_file) if user_file else Path(config.archive_file) + if not archive_file.exists(): + return web.json_response( + data={"error": f"Archive file '{archive_file}' does not exist."}, + status=web.HTTPNotFound.status_code, + ) + + exists, idDict = is_downloaded(archive_file, item.info.url) + item_id: str | None = idDict.get("archive_id") + if not item_id: + return web.json_response( + data={"error": "item does not have an archive ID."}, status=web.HTTPBadRequest.status_code + ) + + if exists is True: + return web.json_response( + data={"error": f"item '{item_id}' already archived in file '{archive_file}'."}, + status=web.HTTPConflict.status_code, + ) + + async with await anyio.open_file(archive_file, "a") as f: + await f.write(f"{item_id}\n") + + return web.json_response( + data={"message": f"item '{item_id}' archived in file '{archive_file}'."}, + status=web.HTTPOk.status_code, + ) diff --git a/ui/components/ConfirmDialog.vue b/ui/components/ConfirmDialog.vue new file mode 100644 index 00000000..19998c26 --- /dev/null +++ b/ui/components/ConfirmDialog.vue @@ -0,0 +1,98 @@ + + + diff --git a/ui/components/Dropdown.vue b/ui/components/Dropdown.vue index 01218b1b..9add139f 100644 --- a/ui/components/Dropdown.vue +++ b/ui/components/Dropdown.vue @@ -1,7 +1,8 @@ - diff --git a/ui/components/History.vue b/ui/components/History.vue index 56128108..42c97606 100644 --- a/ui/components/History.vue +++ b/ui/components/History.vue @@ -76,7 +76,7 @@
-
+
@@ -106,7 +106,12 @@ :id="'checkbox-' + item._id" :value="item._id"> - @@ -202,12 +228,16 @@ v-for="item in sortCompleted" :key="item._id">
-
+
- {{ item.title }} + {{ item.title }}
+ + {{ formatTime(item.extras.duration) }} + + @@ -305,14 +335,7 @@
- +
- + + + + + + + +
@@ -361,6 +408,10 @@ + + @@ -369,6 +420,8 @@ import moment from 'moment' import { useStorage } from '@vueuse/core' import { makeDownload, formatBytes, uri } from '~/utils/index' import { isEmbedable, getEmbedable } from '~/utils/embedable' +import Dropdown from './Dropdown.vue' +import { NuxtLink } from '#components' const emitter = defineEmits(['getInfo', 'add_new']) @@ -391,10 +444,21 @@ const showCompleted = useStorage('showCompleted', true) const hideThumbnail = useStorage('hideThumbnailHistory', false) const direction = useStorage('sortCompleted', 'desc') const display_style = useStorage('display_style', 'cards') +const table_container = ref(false) const embed_url = ref('') const video_item = ref(null) +const dialog_confirm = ref({ + visible: false, + title: 'Confirm Action', + confirm: (opts) => { }, + message: '', + options: [ + { key: 'remove_history', label: 'Also, Remove from history.' }, + ], +}) + const playVideo = item => video_item.value = item const closeVideo = () => video_item.value = null @@ -622,11 +686,39 @@ const requeueIncomplete = () => { } } -const archiveItem = item => { - if (!box.confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) { +const addArchiveDialog = (item) => { + dialog_confirm.value.visible = true + dialog_confirm.value.title = 'Archive Item' + dialog_confirm.value.message = `Archive '${item.title ?? item.id ?? item.url ?? '??'}'?` + dialog_confirm.value.confirm = opts => archiveItem(item, opts) +} + +const archiveItem = async (item, opts = {}) => { + try { + + const req = await request(`/api/archive/${item._id}`, { + credentials: 'include', + method: 'POST', + }) + + const data = await req.json() + + dialog_confirm.value.visible = false + + if (!req.ok) { + toast.error(data.error) + return + } + + toast.success(data.message ?? `Archived '${item.title ?? item.id ?? item.url ?? '??'}'.`) + } catch (e) { + console.error(e) + } + + if (!opts?.remove_history) { return } - socket.emit('archive_item', item) + socket.emit('item_delete', { id: item._id, remove_file: false }) } @@ -645,7 +737,7 @@ const removeItem = item => { }) } -const reQueueItem = (item, event = null) => { +const reQueueItem = (item, re_add = false) => { const item_req = { url: item.url, preset: item.preset, @@ -658,7 +750,7 @@ const reQueueItem = (item, event = null) => { socket.emit('item_delete', { id: item._id, remove_file: false }) - if (event && (event?.altKey && true === event?.altKey)) { + if (true === re_add) { toast.info('Removed the item from history, and added it to the new download form.') emitter('add_new', item_req) return @@ -717,17 +809,37 @@ const downloadSelected = () => { const toggle_class = e => e.currentTarget.classList.toggle('is-text-overflow') -const trigger_option = (event, item) => { - if (!event?.target?.value) { - return +const removeFromArchiveDialog = (item) => { + dialog_confirm.value.visible = true + dialog_confirm.value.title = 'Remove from Archive' + dialog_confirm.value.message = `Remove '${item.title ?? item.id ?? item.url ?? '??'}' from archive?` + dialog_confirm.value.confirm = () => removeFromArchive(item) +} + +const removeFromArchive = async (item, opts) => { + try { + const req = await request(`/api/archive/${item._id}`, { + credentials: 'include', + method: 'DELETE', + }) + + const data = await req.json() + + if (!req.ok) { + toast.error(data.error) + return + } + + toast.success(data.message ?? `Removed '${item.title ?? item.id ?? item.url ?? '??'}' from archive.`) + } catch (e) { + console.error(e) + toast.error(`Error: ${e.message}`) + } finally { + dialog_confirm.value.visible = false } - const eventName = event.target.value - event.target.value = '' - - if ('get_info' === eventName) { - emitter('getInfo', item.url) - return + if (opts?.remove_history) { + socket.emit('item_delete', { id: item._id, remove_file: false }) } } diff --git a/ui/components/NewDownload.vue b/ui/components/NewDownload.vue index 4cf33d7c..da5b6173 100644 --- a/ui/components/NewDownload.vue +++ b/ui/components/NewDownload.vue @@ -172,16 +172,27 @@ -
- -
-
+
+ +
+ +
+ +
+ +
+
+ +
+ + {{ formatTime(item.extras.duration) }} + +
@@ -164,30 +169,51 @@
-
- -
-
- -
+
+ + + + Information + + + + + + + + +
-
- {{ formatBytes(item.downloaded_bytes) }} +
+ {{ formatBytes(item.downloaded_bytes) }} + + {{ formatTime(item.extras.duration) }} +
{{ item.title }}
+ + {{ formatTime(item.extras.duration) }} + + diff --git a/ui/pages/index.vue b/ui/pages/index.vue index ba2170da..465f0db1 100644 --- a/ui/pages/index.vue +++ b/ui/pages/index.vue @@ -48,7 +48,7 @@
+ @clear_form="item_form = {}" @remove_archive="" /> @@ -58,7 +58,6 @@