From 0cb90645cd16f7559762c8677b5516475a0604e0 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Thu, 27 Feb 2025 14:09:11 +0300 Subject: [PATCH 01/10] display portrait image in more clean way --- ui/assets/css/style.css | 5 +++++ ui/components/History.vue | 8 ++++++-- ui/components/Queue.vue | 5 +++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ui/assets/css/style.css b/ui/assets/css/style.css index 95632bdd..20e9fd69 100644 --- a/ui/assets/css/style.css +++ b/ui/assets/css/style.css @@ -249,3 +249,8 @@ hr { .play-active { opacity: 1; } + +.image-portrait { + object-fit: cover; + object-position: top; +} diff --git a/ui/components/History.vue b/ui/components/History.vue index d3008801..b5aab95d 100644 --- a/ui/components/History.vue +++ b/ui/components/History.vue @@ -99,11 +99,13 @@
- +
@@ -473,4 +475,6 @@ const reQueueItem = item => { template: item.template, }) } + +const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null diff --git a/ui/components/Queue.vue b/ui/components/Queue.vue index db3498e8..4ae417e8 100644 --- a/ui/components/Queue.vue +++ b/ui/components/Queue.vue @@ -53,8 +53,7 @@
- +
@@ -274,4 +273,6 @@ const cancelItems = item => { items.forEach(id => socket.emit('item_cancel', id)); } + +const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null From c5924b02cc316e171e0abc46beaba5ddf9d48cb6 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Thu, 27 Feb 2025 14:58:06 +0300 Subject: [PATCH 02/10] support format key in ytdlp.json, using the key will ignore the selected preset. --- app/library/DownloadQueue.py | 4 ++-- app/library/HttpAPI.py | 29 +++++++++++++++++++++++++++-- app/library/Utils.py | 6 +++++- ui/components/NewDownload.vue | 35 +++++++++++++++++++++++++++++------ ui/components/TaskForm.vue | 35 ++++++++++++++++++++++++++++------- ui/utils/index.js | 2 +- 6 files changed, 92 insertions(+), 19 deletions(-) diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 10079d4e..fc718055 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -395,7 +395,7 @@ class DownloadQueue(metaclass=Singleton): "func": lambda _, msg: logs.append(msg), "level": logging.WARNING, }, - **merge_config(self.config.ytdl_options, config), + **get_opts(preset, merge_config(self.config.ytdl_options, config)), } if cookies: @@ -408,7 +408,7 @@ class DownloadQueue(metaclass=Singleton): entry = await asyncio.wait_for( fut=asyncio.get_running_loop().run_in_executor( - None, extract_info, get_opts(preset, yt_conf), url, bool(self.config.ytdl_debug) + None, extract_info, yt_conf, url, bool(self.config.ytdl_debug) ), timeout=self.config.extract_info_timeout, ) diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index e49453da..7369cf46 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -32,7 +32,15 @@ from .Playlist import Playlist from .Segments import Segments from .Subtitle import Subtitle from .Tasks import Task, Tasks -from .Utils import StreamingError, arg_converter, calc_download_path, get_video_info, validate_url, validate_uuid +from .Utils import ( + IGNORED_KEYS, + StreamingError, + arg_converter, + calc_download_path, + get_video_info, + validate_url, + validate_uuid, +) LOG = logging.getLogger("http_api") MIME = magic.Magic(mime=True) @@ -347,11 +355,28 @@ class HttpAPI(Common): return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code) try: - return web.json_response(data=arg_converter(args), status=web.HTTPOk.status_code) + response = {"opts": {}, "output_template": None, "download_path": None} + + data = arg_converter(args) + + if "outtmpl" in data and "default" in data["outtmpl"]: + response["output_template"] = data["outtmpl"]["default"] + + if "paths" in data and "home" in data["paths"]: + response["download_path"] = data["paths"]["home"] + + for key in data: + if key in IGNORED_KEYS: + continue + if not key.startswith("_"): + response["opts"][key] = data[key] + + return web.json_response(data=response, status=web.HTTPOk.status_code) except Exception as e: err = str(e).strip() err = err.split("\n")[-1] if "\n" in err else err LOG.error(f"Failed to convert args. '{err}'.") + LOG.exception(e) return web.json_response( data={"error": f"Failed to convert args. '{err}'."}, status=web.HTTPBadRequest.status_code ) diff --git a/app/library/Utils.py b/app/library/Utils.py index 078ec1c3..b8e3fe7d 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -23,7 +23,6 @@ IGNORED_KEYS: tuple[str] = ( "outtmpl", "progress_hooks", "postprocessor_hooks", - "format", "download_archive", ) YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None @@ -45,6 +44,11 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict: ytdl extra options """ + if "format" in ytdl_opts and len(ytdl_opts["format"]) > 2: + format = ytdl_opts["format"] + LOG.info(f"Format '{format}' was given via yt-dlp options. Therefore, the preset will be ignored.") + return ytdl_opts + opts = copy.deepcopy(ytdl_opts) if "default" == preset: diff --git a/ui/components/NewDownload.vue b/ui/components/NewDownload.vue index 885989c5..8cf879ee 100644 --- a/ui/components/NewDownload.vue +++ b/ui/components/NewDownload.vue @@ -20,8 +20,9 @@
- @@ -81,7 +82,7 @@ @@ -93,8 +94,10 @@ Extends current global yt-dlp config with given options. Some fields are ignored like - format cookiefile, paths, and outtmpl etc. - Warning: Use with caution some of those options can break yt-dlp or the frontend. + cookiefile, paths, and outtmpl etc. Warning: Use with caution + some of those options can break yt-dlp or the frontend. If Format key is present + in the config, the preset and all it's options will be + ignored.
@@ -242,7 +245,15 @@ const convertOptions = async () => { try { convertInProgress.value = true - ytdlpConfig.value = await convertCliOptions(ytdlpConfig.value) + const response = await convertCliOptions(ytdlpConfig.value) + ytdlpConfig.value = JSON.stringify(response.opts, null, 2) + if (response.output_template) { + output_template.value = response.output_template + } + if (response.download_path) { + downloadPath.value = response.download_path + } + } catch (e) { toast.error(e.message) } finally { @@ -262,4 +273,16 @@ onUnmounted(() => { socket.off('status', statusHandler) socket.off('error', unlockDownload) }) + +const hasFormatInConfig = computed(() => { + if (!ytdlpConfig.value) { + return false + } + try { + const config = JSON.parse(ytdlpConfig.value) + return "format" in config + } catch (e) { + return false + } +}) diff --git a/ui/components/TaskForm.vue b/ui/components/TaskForm.vue index e625494f..338dbe53 100644 --- a/ui/components/TaskForm.vue +++ b/ui/components/TaskForm.vue @@ -32,7 +32,7 @@
@@ -40,7 +40,7 @@
- The YouTube channel or playlist URL + The channel or playlist URL.
@@ -69,7 +69,9 @@
- @@ -79,7 +81,8 @@
- Select the preset to use for this URL. + Select the preset to use for this URL. The preset will be ignored if format key is present in + config.
@@ -138,8 +141,8 @@ Extends current global yt-dlp config with given options. Some fields are ignored like - format cookiefile, paths, and outtmpl etc. - Warning: Use with caution some of those options can break yt-dlp or the frontend. + cookiefile, paths, and outtmpl etc. Warning: Use with caution + some of those options can break yt-dlp or the frontend. @@ -278,7 +281,14 @@ const convertOptions = async () => { try { convertInProgress.value = true - form.config = await convertCliOptions(form.config) + const response = await convertCliOptions(form.config) + form.config = JSON.stringify(response.opts, null, 2) + if (response.output_template) { + form.template = response.output_template + } + if (response.download_path) { + form.folder = response.download_path + } } catch (e) { toast.error(e.message) } finally { @@ -286,4 +296,15 @@ const convertOptions = async () => { } } +const hasFormatInConfig = computed(() => { + if (!form.config) { + return false + } + try { + const config = JSON.parse(form.config) + return "format" in config + } catch (e) { + return false + } +}) diff --git a/ui/utils/index.js b/ui/utils/index.js index 69cdfabc..47960e61 100644 --- a/ui/utils/index.js +++ b/ui/utils/index.js @@ -400,7 +400,7 @@ const convertCliOptions = async opts => { throw new Error(`Error: (${response.status}): ${data.error}`) } - return JSON.stringify(data, null, 2) + return data } export { From 259eae251a7c1d051a00580d95ce133d50ea067f Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Thu, 27 Feb 2025 15:30:52 +0300 Subject: [PATCH 03/10] initial work to support adding/deleting presets from api --- app/library/EventsSubscriber.py | 2 + app/library/HttpAPI.py | 17 +++ app/library/Presets.py | 234 ++++++++++++++++++++++++++++++++ app/main.py | 2 + 4 files changed, 255 insertions(+) create mode 100644 app/library/Presets.py diff --git a/app/library/EventsSubscriber.py b/app/library/EventsSubscriber.py index 2fb5a683..45d8b659 100644 --- a/app/library/EventsSubscriber.py +++ b/app/library/EventsSubscriber.py @@ -41,6 +41,8 @@ class Events: TASK_FINISHED = "task_finished" TASK_ERROR = "task_error" + PRESETS_ADD = "preset_add" + @dataclass(kw_only=True) class Event: diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 7369cf46..2479085e 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -29,6 +29,7 @@ from .ffprobe import ffprobe from .M3u8 import M3u8 from .Notifications import Notification, NotificationEvents from .Playlist import Playlist +from .Presets import Presets from .Segments import Segments from .Subtitle import Subtitle from .Tasks import Task, Tasks @@ -578,6 +579,22 @@ class HttpAPI(Common): dumps=self.encoder.encode, ) + @route("GET", "api/presets") + async def presets(self, _: Request) -> Response: + """ + Get the presets. + + Args: + _: The request object. + + Returns: + Response: The response object. + + """ + return web.json_response( + data=Presets.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode + ) + @route("GET", "api/tasks") async def tasks(self, _: Request) -> Response: """ diff --git a/app/library/Presets.py b/app/library/Presets.py new file mode 100644 index 00000000..3e850b6c --- /dev/null +++ b/app/library/Presets.py @@ -0,0 +1,234 @@ +import asyncio +import json +import logging +import os +from dataclasses import dataclass, field +from typing import Any + +from aiohttp import web + +from .config import Config +from .Emitter import Emitter +from .encoder import Encoder +from .EventsSubscriber import Event, Events, EventsSubscriber +from .Singleton import Singleton + +LOG = logging.getLogger("presets") + + +@dataclass(kw_only=True) +class Preset: + name: str + """The name of the preset.""" + + format: str + """The format of the preset.""" + + args: dict[str, list[str] | bool] | None = field(default_factory=dict) + """The arguments of the preset.""" + + postprocessors: list | None = field(default_factory=list) + """The postprocessors of the preset.""" + + def serialize(self) -> dict: + return self.__dict__ + + def json(self) -> str: + return Encoder().encode(self.serialize()) + + def get(self, key: str, default: Any = None) -> Any: + return self.serialize().get(key, default) + + +class Presets(metaclass=Singleton): + """ + This class is used to manage the presets. + """ + + _presets: list[Preset] = [] + """The list of presets.""" + + _instance = None + """The instance of the class.""" + + def __init__( + self, + file: str | None = None, + emitter: Emitter | None = None, + loop: asyncio.AbstractEventLoop | None = None, + config: Config | None = None, + ): + Presets._instance = self + + config = config or Config.get_instance() + + self._file: str = file or os.path.join(config.config_path, "presets.json") + self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() + self._emitter: Emitter = emitter or Emitter.get_instance() + + if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]: + try: + os.chmod(self._file, 0o600) + except Exception: + pass + + def handle_event(_, e: Event): + self.save(**e.data) + + EventsSubscriber.get_instance().subscribe(Events.PRESETS_ADD, f"{__class__}.save", handle_event) + + @staticmethod + def get_instance() -> "Presets": + """ + Get the instance of the class. + + Returns: + Presets: The instance of the class + + """ + if not Presets._instance: + Presets._instance = Presets() + + return Presets._instance + + async def on_shutdown(self, _: web.Application): + pass + + def attach(self, _: web.Application): + """ + Attach the work to the aiohttp application. + + Args: + _ (web.Application): The aiohttp application. + + Returns: + None + + """ + self.load() + + def get_all(self) -> list[Preset]: + """Return the presets.""" + return self._presets + + def load(self) -> "Presets": + """ + Load the Presets. + + Returns: + Presets: The current instance. + + """ + self.clear() + + if not os.path.exists(self._file) or os.path.getsize(self._file) < 10: + return self + + LOG.info(f"Loading presets from '{self._file}'.") + try: + with open(self._file) as f: + presets = json.load(f) + except Exception as e: + LOG.error(f"Failed to parse presets from '{self._file}'. '{e}'.") + return self + + if not presets or len(presets) < 1: + LOG.info(f"No presets were defined in '{self._file}'.") + return self + + for i, preset in enumerate(presets): + try: + preset = Preset(**preset) + self._presets.append(preset) + except Exception as e: + LOG.error(f"Failed to parse preset at list position '{i}'. '{e!s}'.") + continue + + return self + + def clear(self) -> "Presets": + """ + Clear all presets + + Returns: + Presets: The current instance. + + """ + if len(self._presets) < 1: + return self + + self._presets.clear() + + return self + + def validate(self, preset: Preset | dict) -> bool: + """ + Validate the preset. + + Args: + preset (Preset|dict): The preset to validate. + + Returns: + bool: True if the preset is valid, False otherwise. + + """ + if not isinstance(preset, dict): + if not isinstance(preset, Preset): + msg = "Invalid preset type." + raise ValueError(msg) # noqa: TRY004 + + preset = preset.serialize() + + if not preset.get("name"): + msg = "No name found." + raise ValueError(msg) + + if not preset.get("format"): + msg = "No format found." + raise ValueError(msg) + + if preset.get("args") and not isinstance(preset.get("args"), dict): + msg = "Invalid args type. expected dict." + raise ValueError(msg) + + if preset.get("postprocessors") and not isinstance(preset.get("postprocessors"), list): + msg = "Invalid postprocessors type. expected list." + raise ValueError(msg) + + return True + + def save(self, presets: list[Preset | dict]) -> "Presets": + """ + Save the presets. + + Args: + presets (list[Preset]): The presets to save. + + Returns: + Presets: The current instance. + + """ + for i, preset in enumerate(presets): + try: + if not isinstance(preset, Preset): + preset = Preset(**preset) + presets[i] = preset + except Exception as e: + LOG.error(f"Failed to save preset '{i}' due to parsing error. '{e!s}'.") + continue + + try: + self.validate(preset) + except ValueError as e: + LOG.error(f"Failed to validate preset '{i}: {preset.name}'. '{e}'.") + continue + + try: + with open(self._file, "w") as f: + json.dump(obj=[preset.serialize() for preset in presets], fp=f, indent=4) + + LOG.info(f"Presets saved to '{self._file}'.") + except Exception as e: + LOG.error(f"Failed to save presets to '{self._file}'. '{e!s}'.") + + return self diff --git a/app/main.py b/app/main.py index ee857722..10c4b658 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ from library.HttpAPI import HttpAPI from library.HttpSocket import HttpSocket from library.Notifications import Notification from library.PackageInstaller import PackageInstaller +from library.Presets import Presets from library.Tasks import Tasks LOG = logging.getLogger("app") @@ -96,6 +97,7 @@ class Main: self._http.attach(self._app) self._queue.attach(self._app) Tasks.get_instance().attach(self._app) + Presets.get_instance().attach(self._app) def started(_): LOG.info("=" * 40) From b7b72cf21e19c84faecea28b32c58a186f7866d4 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Thu, 27 Feb 2025 18:31:17 +0300 Subject: [PATCH 04/10] try to direct play video first and fallback to hls if we encounter error. --- .vscode/launch.json | 3 +- app/library/HttpAPI.py | 41 ++++++++++ app/library/Playlist.py | 41 ++-------- app/library/Utils.py | 27 +++++++ app/library/encoder.py | 13 +++- ui/components/History.vue | 39 ++-------- ui/components/VideoPlayer.vue | 138 ++++++++++++++++++++++------------ ui/utils/index.js | 5 +- 8 files changed, 186 insertions(+), 121 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 96b9c850..da500c43 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -21,7 +21,8 @@ "NUXT_API_URL": "http://localhost:8081/api/", "NUXT_PUBLIC_WSS": ":8081/", }, - "console": "internalConsole" + "console": "internalConsole", + "outputCapture": "std", }, { "name": "Python: main.py", diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 2479085e..4ffef7b1 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -38,6 +38,7 @@ from .Utils import ( StreamingError, arg_converter, calc_download_path, + get_sidecar_subtitles, get_video_info, validate_url, validate_uuid, @@ -1178,6 +1179,46 @@ class HttpAPI(Common): except Exception as e: return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + @route("GET", "api/file/info/{file:.*}") + async def get_file_info(self, request: Request) -> Response: + """ + Get file info + + Args: + request (Request): The request object. + + Returns: + Response: The response object. + + """ + file: str = request.match_info.get("file") + if not file: + return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code) + + try: + realFile: str = calc_download_path(base_path=self.config.download_path, folder=file, create_path=False) + if not os.path.exists(realFile) or not os.path.isfile(realFile): + return web.json_response( + data={"error": f"File '{file}' does not exist."}, status=web.HTTPNotFound.status_code + ) + + realFile = Path(realFile) + + response = { + "ffprobe": await ffprobe(realFile), + "sidecar": get_sidecar_subtitles(realFile), + } + + for i, f in enumerate(response["sidecar"]): + response["sidecar"][i]["file"] = str(Path(realFile).with_name(f["file"].name)).replace( + self.config.download_path, "" + ).strip("/") + + return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + except Exception as e: + LOG.exception(e) + return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + @route("GET", "api/youtube/auth") async def is_authenticated(self, request: Request) -> Response: """ diff --git a/app/library/Playlist.py b/app/library/Playlist.py index a83074ca..befd9193 100644 --- a/app/library/Playlist.py +++ b/app/library/Playlist.py @@ -1,13 +1,10 @@ -import glob -import re from pathlib import Path from urllib.parse import quote from aiohttp.web import HTTPFound, Response from .ffprobe import ffprobe -from .Subtitle import Subtitle -from .Utils import StreamingError, calc_download_path, check_id +from .Utils import StreamingError, calc_download_path, check_id, get_sidecar_subtitles class Playlist: @@ -41,28 +38,19 @@ class Playlist: msg = f"Unable to get '{rFile}' duration." raise StreamingError(msg) - duration: float = float(ff.metadata.get("duration")) - playlist = [] playlist.append("#EXTM3U") subs = "" - index = 0 - for item in self.get_sidecar_files(rFile): - if item.suffix not in Subtitle.allowed_extensions: - continue - - index += 1 - lang: str = "und" - lg = re.search(r"\.(?P\w{2,3})\.\w{3}$", item.name) - if lg: - lang = lg.groupdict().get("lang") + duration: float = float(ff.metadata.get("duration")) + for sub_file in get_sidecar_subtitles(rFile): + lang = sub_file["lang"] + item = sub_file["file"] + name = sub_file["name"] subs = ',SUBTITLES="subs"' - url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(file).with_name(item.name)))}.m3u8?duration={duration}" - name = f"{item.suffix[1:].upper()} ({index}) - {lang}" playlist.append( f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"' ) @@ -71,20 +59,3 @@ class Playlist: playlist.append(f"{self.url}api/player/m3u8/video/{quote(file)}.m3u8") return "\n".join(playlist) - - def get_sidecar_files(self, file: Path) -> list[Path]: - """ - Get sidecar files for the given file. - - :param file: File to get sidecar files for. - :return: List of sidecar files. - """ - files = [] - - for sub_file in file.parent.glob(f"{glob.escape(file.stem)}.*"): - if sub_file == file or sub_file.is_file() is False or sub_file.stem.startswith("."): - continue - - files.append(sub_file) - - return files diff --git a/app/library/Utils.py b/app/library/Utils.py index b8e3fe7d..bad19221 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -1,4 +1,5 @@ import copy +import glob import ipaddress import json import logging @@ -27,6 +28,8 @@ IGNORED_KEYS: tuple[str] = ( ) YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None +ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass") + class StreamingError(Exception): """Raised when an error occurs during streaming.""" @@ -524,3 +527,27 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool: return True except ValueError: return False + + +def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]: + """ + Get sidecar files for the given file. + + :param file: File to get sidecar files for. + :return: List of sidecar files. + """ + files = [] + + for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")): + if f == file or f.is_file() is False or f.stem.startswith("."): + continue + + if f.suffix not in ALLOWED_SUBS_EXTENSIONS: + continue + + lg = re.search(r"\.(?P\w{2,3})\.\w{3}$", f.name) + lang = lg.groupdict().get("lang") if lg else "und" + + files.append({"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"}) + + return files diff --git a/app/library/encoder.py b/app/library/encoder.py index 2a788649..0ed61c57 100644 --- a/app/library/encoder.py +++ b/app/library/encoder.py @@ -1,4 +1,5 @@ import json +from pathlib import Path class Encoder(json.JSONEncoder): @@ -9,10 +10,14 @@ class Encoder(json.JSONEncoder): """ def default(self, o): - if isinstance(o, object) and hasattr(o, "serialize"): - return o.serialize() + if isinstance(o, Path): + return str(o) - if isinstance(o, object) and hasattr(o, "__dict__"): - return o.__dict__ + if isinstance(o, object): + if hasattr(o, "serialize"): + return o.serialize() + + if hasattr(o, "__dict__"): + return o.__dict__ return json.JSONEncoder.default(self, o) diff --git a/ui/components/History.vue b/ui/components/History.vue index b5aab95d..44be0a0f 100644 --- a/ui/components/History.vue +++ b/ui/components/History.vue @@ -235,12 +235,11 @@

-