Add option to remove item from archive, and minor ui updates.
This commit is contained in:
parent
1c8de32e72
commit
4367fb0937
12 changed files with 585 additions and 72 deletions
|
|
@ -240,6 +240,7 @@ class HttpAPI:
|
||||||
"cache": this.cache,
|
"cache": this.cache,
|
||||||
"app": app,
|
"app": app,
|
||||||
"http_api": this,
|
"http_api": this,
|
||||||
|
"root_path": this.rootPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ class HttpSocket:
|
||||||
"sio": self.sio,
|
"sio": self.sio,
|
||||||
"encoder": encoder,
|
"encoder": encoder,
|
||||||
"notify": self._notify,
|
"notify": self._notify,
|
||||||
|
"root_path": self.rootPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
|
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,41 @@ def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, s
|
||||||
return (False, idDict)
|
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]:
|
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
|
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 importlib
|
||||||
import pkgutil
|
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}'.")
|
LOG.debug(f"Loading routes from '{directory}' with package name '{package_name}'.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,9 @@ class Config:
|
||||||
db_file: str = "{config_path}/ytptube.db"
|
db_file: str = "{config_path}/ytptube.db"
|
||||||
"""The path to the database file."""
|
"""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"
|
manual_archive: str = "{config_path}/archive.manual.log"
|
||||||
"""The path to the manual archive file."""
|
"""The path to the manual archive file."""
|
||||||
|
|
||||||
|
|
@ -371,7 +374,7 @@ class Config:
|
||||||
self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}"
|
self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}"
|
||||||
|
|
||||||
if self.keep_archive:
|
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():
|
if not archive_file.exists():
|
||||||
LOG.info(f"Creating archive file '{archive_file}'.")
|
LOG.info(f"Creating archive file '{archive_file}'.")
|
||||||
archive_file.touch(exist_ok=True)
|
archive_file.touch(exist_ok=True)
|
||||||
|
|
|
||||||
168
app/routes/api/archive.py
Normal file
168
app/routes/api/archive.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
98
ui/components/ConfirmDialog.vue
Normal file
98
ui/components/ConfirmDialog.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="visible" class="modal is-active">
|
||||||
|
<div class="modal-background" @click="cancel" />
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<p class="modal-card-title">{{ title }}</p>
|
||||||
|
<button class="delete" aria-label="close" @click="cancel" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="modal-card-body">
|
||||||
|
<p class="mb-3 title is-5">{{ message }}</p>
|
||||||
|
|
||||||
|
<div v-if="options?.length">
|
||||||
|
<hr class="">
|
||||||
|
<label v-for="opt in options" :key="opt.key" class="checkbox is-block mb-2 is-unselectable">
|
||||||
|
<input type="checkbox" v-model="selected[opt.key]" class="mr-2" />
|
||||||
|
{{ opt.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="modal-card-foot p-5">
|
||||||
|
<div class="field is-grouped" style="width:100%">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<button class="button is-fullwidth" :class="confirm_button_color" @click="handleConfirm">
|
||||||
|
{{ confirm_button_label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<button class="button is-success is-fullwidth" :class="cancel_button_color" @click="cancel">
|
||||||
|
{{ cancel_button_label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'Confirm'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: 'Are you sure?'
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array as () => Array<{ key: string; label: string, checked?: boolean }>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
confirm_button_label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Confirm'
|
||||||
|
},
|
||||||
|
cancel_button_label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Cancel'
|
||||||
|
},
|
||||||
|
confirm_button_color: {
|
||||||
|
type: String,
|
||||||
|
default: 'is-danger'
|
||||||
|
},
|
||||||
|
cancel_button_color: {
|
||||||
|
type: String,
|
||||||
|
default: 'is-success'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'confirm', options: Record<string, boolean>): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selected = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
watch(() => props.visible, visible => {
|
||||||
|
if (visible && props.options) {
|
||||||
|
for (const opt of props.options) {
|
||||||
|
selected[opt.key] ??= false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit('confirm', { ...selected })
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dropdown" :class="{ 'is-active': isOpen }" ref="dropdown">
|
<div class="dropdown" :class="{ 'is-active': isOpen, 'drop-up': dropUp }" ref="dropdown">
|
||||||
<div class="dropdown-trigger">
|
<div class="dropdown-trigger">
|
||||||
<button class="button is-fullwidth is-justify-content-space-between" aria-haspopup="true" aria-controls="dropdown-menu" @click="toggle">
|
<button class="button is-fullwidth is-justify-content-space-between" aria-haspopup="true"
|
||||||
|
aria-controls="dropdown-menu" @click="toggle" :class="button_classes">
|
||||||
<span class="icon" v-if="icons"><i :class="icons" /></span>
|
<span class="icon" v-if="icons"><i :class="icons" /></span>
|
||||||
<span>{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
<div class="is-pulled-right">
|
<div class="is-pulled-right">
|
||||||
|
|
@ -11,17 +12,18 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dropdown-menu" role="menu" id="dropdown-menu">
|
<div class="dropdown-menu" role="menu" id="dropdown-menu">
|
||||||
<div class="dropdown-content">
|
<div class="dropdown-content" @click="handle_slot_click">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const emitter = defineEmits(['open_state'])
|
||||||
|
defineProps({
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Select'
|
default: 'Select'
|
||||||
|
|
@ -29,27 +31,58 @@ const props = defineProps({
|
||||||
icons: {
|
icons: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
button_classes: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const dropdown = ref(null)
|
const dropUp = ref(false)
|
||||||
|
const dropdown = useTemplateRef<HTMLDivElement>('dropdown')
|
||||||
|
|
||||||
const toggle = () => isOpen.value = !isOpen.value
|
const toggle = async () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
|
||||||
const handleClickOutside = (event) => {
|
if (isOpen.value && dropdown.value) {
|
||||||
if (dropdown.value && !dropdown.value.contains(event.target)) {
|
await nextTick()
|
||||||
|
const rect = dropdown.value.getBoundingClientRect()
|
||||||
|
const menu = dropdown.value.querySelector('.dropdown-menu') as HTMLElement
|
||||||
|
const menuHeight = menu?.offsetHeight || 0
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom
|
||||||
|
dropUp.value = spaceBelow < menuHeight + 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle_slot_click = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (target.closest('.dropdown-item')) {
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
const handle_event = (event: MouseEvent) => {
|
||||||
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
|
if (!dropdown.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
|
||||||
|
if (!dropdown.value.contains(target)) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(() => emitter('open_state', isOpen.value))
|
||||||
|
onMounted(() => document.addEventListener('click', handle_event))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('click', handle_event))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dropdown {
|
.dropdown {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-trigger {
|
.dropdown-trigger {
|
||||||
|
|
@ -60,11 +93,17 @@ onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: absolute;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-content {
|
.dropdown-content {
|
||||||
|
z-index: 99;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown.drop-up .dropdown-menu {
|
||||||
|
bottom: 100%;
|
||||||
|
top: auto;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="'list' === display_style">
|
<div class="columns is-multiline" v-if="'list' === display_style">
|
||||||
<div class="column is-12" v-if="hasItems">
|
<div class="column is-12" v-if="hasItems">
|
||||||
<div class="table-container">
|
<div :class="{ 'table-container': table_container }">
|
||||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
style="min-width: 1300px; table-layout: fixed;">
|
style="min-width: 1300px; table-layout: fixed;">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -106,7 +106,12 @@
|
||||||
:id="'checkbox-' + item._id" :value="item._id">
|
:id="'checkbox-' + item._id" :value="item._id">
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered">
|
<td class="is-text-overflow is-vcentered">
|
||||||
|
<div class="is-inline is-pulled-right" v-if="item.extras?.duration">
|
||||||
|
<span class="tag is-info" v-if="item.extras?.duration">
|
||||||
|
{{ formatTime(item.extras.duration) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div v-if="showThumbnails && item.extras.thumbnail">
|
<div v-if="showThumbnails && item.extras.thumbnail">
|
||||||
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||||
:title="item.title">
|
:title="item.title">
|
||||||
|
|
@ -164,30 +169,51 @@
|
||||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control" v-if="config.app?.keep_archive && item.status != 'finished'">
|
|
||||||
<button class="button is-danger is-light is-fullwidth is-small" v-tooltip="'Add link to archive'"
|
|
||||||
@click="archiveItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="control" v-if="item.filename && item.status === 'finished'">
|
<div class="control" v-if="item.filename && item.status === 'finished'">
|
||||||
<a class="button is-link is-fullwidth is-small" :href="makeDownload(config, item)"
|
<a class="button is-link is-fullwidth is-small" :href="makeDownload(config, item)"
|
||||||
v-tooltip="'Download video'" :download="item.filename?.split('/').reverse()[0]">
|
v-tooltip="'Download video'" :download="item.filename?.split('/').reverse()[0]">
|
||||||
<span class="icon"><i class="fa-solid fa-download" /></span>
|
<span class="icon"><i class="fa-solid fa-download" /></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="control" v-if="item.url && !config.app.basic_mode">
|
|
||||||
<button class="button is-info is-fullwidth is-small" @click="emitter('getInfo', item.url)"
|
|
||||||
v-tooltip="'Show video information'">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-danger is-fullwidth is-small" @click="removeItem(item)"
|
<button class="button is-danger is-fullwidth is-small" @click="removeItem(item)"
|
||||||
v-tooltip="'Remove video'">
|
v-tooltip="'Remove video'">
|
||||||
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control" v-if="item.url && !config.app.basic_mode">
|
||||||
|
<Dropdown icons="fa-solid fa-cogs" label="" @open_state="s => table_container = !s"
|
||||||
|
:button_classes="'is-small'">
|
||||||
|
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span>Information</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<template v-if="item.status != 'finished' || !item.filename">
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
<NuxtLink class="dropdown-item" @click="reQueueItem(item, true)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||||
|
<span>Add to download form</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="'finished' !== item.status && config.app?.keep_archive">
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
<NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||||
|
<span>Archive Item</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="'finished' === item.status && item.filename && config.app?.keep_archive">
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
<NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||||
|
<span>Remove from archive</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -202,12 +228,16 @@
|
||||||
v-for="item in sortCompleted" :key="item._id">
|
v-for="item in sortCompleted" :key="item._id">
|
||||||
<div class="card is-flex is-full-height is-flex-direction-column"
|
<div class="card is-flex is-full-height is-flex-direction-column"
|
||||||
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
|
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
|
||||||
<header class="card-header ">
|
<header class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
|
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
|
||||||
<NuxtLink target="_blank" :href="item.url" class="has-tooltip">{{ item.title }}</NuxtLink>
|
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
|
<span class="tag is-info" v-if="item.extras?.duration">
|
||||||
|
{{ formatTime(item.extras.duration) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span v-if="!showThumbnails">
|
<span v-if="!showThumbnails">
|
||||||
<a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)"
|
<a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)"
|
||||||
v-tooltip="'Play video.'">
|
v-tooltip="'Play video.'">
|
||||||
|
|
@ -305,14 +335,7 @@
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile" v-if="config.app?.keep_archive && item.status != 'finished'">
|
|
||||||
<a class="button is-danger is-light is-fullwidth" @click="archiveItem(item)">
|
|
||||||
<span class="icon-text is-block">
|
|
||||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
|
||||||
<span>Archive</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="column is-half-mobile" v-if="item.filename && item.status === 'finished'">
|
<div class="column is-half-mobile" v-if="item.filename && item.status === 'finished'">
|
||||||
<a class="button is-link is-fullwidth" :href="makeDownload(config, item)"
|
<a class="button is-link is-fullwidth" :href="makeDownload(config, item)"
|
||||||
:download="item.filename?.split('/').reverse()[0]">
|
:download="item.filename?.split('/').reverse()[0]">
|
||||||
|
|
@ -323,12 +346,36 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile" v-if="item.url && !config.app.basic_mode">
|
<div class="column is-half-mobile" v-if="item.url && !config.app.basic_mode">
|
||||||
<button class="button is-info is-fullwidth" @click="emitter('getInfo', item.url)">
|
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
||||||
<span class="icon-text is-block">
|
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>Information</span>
|
<span>Information</span>
|
||||||
</span>
|
</NuxtLink>
|
||||||
</button>
|
|
||||||
|
<template v-if="item.status != 'finished' || !item.filename">
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
<NuxtLink class="dropdown-item" @click="reQueueItem(item, true)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||||
|
<span>Add to download form</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="'finished' !== item.status && config.app?.keep_archive">
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
<NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||||
|
<span>Archive Item</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="'finished' === item.status && item.filename && config.app?.keep_archive">
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
<NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||||
|
<span>Remove from archive</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -361,6 +408,10 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>
|
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
|
||||||
|
:message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
|
||||||
|
@cancel="() => dialog_confirm.visible = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -369,6 +420,8 @@ import moment from 'moment'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { makeDownload, formatBytes, uri } from '~/utils/index'
|
import { makeDownload, formatBytes, uri } from '~/utils/index'
|
||||||
import { isEmbedable, getEmbedable } from '~/utils/embedable'
|
import { isEmbedable, getEmbedable } from '~/utils/embedable'
|
||||||
|
import Dropdown from './Dropdown.vue'
|
||||||
|
import { NuxtLink } from '#components'
|
||||||
|
|
||||||
const emitter = defineEmits(['getInfo', 'add_new'])
|
const emitter = defineEmits(['getInfo', 'add_new'])
|
||||||
|
|
||||||
|
|
@ -391,10 +444,21 @@ const showCompleted = useStorage('showCompleted', true)
|
||||||
const hideThumbnail = useStorage('hideThumbnailHistory', false)
|
const hideThumbnail = useStorage('hideThumbnailHistory', false)
|
||||||
const direction = useStorage('sortCompleted', 'desc')
|
const direction = useStorage('sortCompleted', 'desc')
|
||||||
const display_style = useStorage('display_style', 'cards')
|
const display_style = useStorage('display_style', 'cards')
|
||||||
|
const table_container = ref(false)
|
||||||
|
|
||||||
const embed_url = ref('')
|
const embed_url = ref('')
|
||||||
const video_item = ref(null)
|
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 playVideo = item => video_item.value = item
|
||||||
const closeVideo = () => video_item.value = null
|
const closeVideo = () => video_item.value = null
|
||||||
|
|
||||||
|
|
@ -622,11 +686,39 @@ const requeueIncomplete = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const archiveItem = item => {
|
const addArchiveDialog = (item) => {
|
||||||
if (!box.confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
socket.emit('archive_item', item)
|
|
||||||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
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 = {
|
const item_req = {
|
||||||
url: item.url,
|
url: item.url,
|
||||||
preset: item.preset,
|
preset: item.preset,
|
||||||
|
|
@ -658,7 +750,7 @@ const reQueueItem = (item, event = null) => {
|
||||||
|
|
||||||
socket.emit('item_delete', { id: item._id, remove_file: false })
|
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.')
|
toast.info('Removed the item from history, and added it to the new download form.')
|
||||||
emitter('add_new', item_req)
|
emitter('add_new', item_req)
|
||||||
return
|
return
|
||||||
|
|
@ -717,17 +809,37 @@ const downloadSelected = () => {
|
||||||
|
|
||||||
const toggle_class = e => e.currentTarget.classList.toggle('is-text-overflow')
|
const toggle_class = e => e.currentTarget.classList.toggle('is-text-overflow')
|
||||||
|
|
||||||
const trigger_option = (event, item) => {
|
const removeFromArchiveDialog = (item) => {
|
||||||
if (!event?.target?.value) {
|
dialog_confirm.value.visible = true
|
||||||
return
|
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
|
if (opts?.remove_history) {
|
||||||
event.target.value = ''
|
socket.emit('item_delete', { id: item._id, remove_file: false })
|
||||||
|
|
||||||
if ('get_info' === eventName) {
|
|
||||||
emitter('getInfo', item.url)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -172,16 +172,27 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-6-tablet is-4-mobile has-text-left">
|
<div class="column is-12">
|
||||||
<button type="button" class="button is-info" @click="emitter('getInfo', form.url)"
|
|
||||||
:class="{ 'is-loading': !socket.isConnected }"
|
|
||||||
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span>Information</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6-tablet is-6-mobile has-text-right">
|
|
||||||
<div class="field is-grouped is-justify-self-end">
|
<div class="field is-grouped is-justify-self-end">
|
||||||
|
|
||||||
|
<div class="control">
|
||||||
|
<button type="button" class="button is-info" @click="emitter('getInfo', form.url)"
|
||||||
|
:class="{ 'is-loading': !socket.isConnected }"
|
||||||
|
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span>Information</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control">
|
||||||
|
<button type="button" class="button is-warning" @click="removeFromArchive(form.url)"
|
||||||
|
:class="{ 'is-loading': !socket.isConnected }"
|
||||||
|
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
||||||
|
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||||
|
<span>Remove from archive</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="button" class="button is-danger" @click="resetConfig"
|
<button type="button" class="button is-danger" @click="resetConfig"
|
||||||
:disabled="!socket.isConnected || form?.id" v-tooltip="'Reset local settings'">
|
:disabled="!socket.isConnected || form?.id" v-tooltip="'Reset local settings'">
|
||||||
|
|
@ -212,7 +223,7 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitter = defineEmits(['getInfo', 'clear_form'])
|
const emitter = defineEmits(['getInfo', 'clear_form', 'remove_archive'])
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
|
@ -327,6 +338,7 @@ const resetConfig = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
showAdvanced.value = false
|
showAdvanced.value = false
|
||||||
|
separator.value = separators[0].value
|
||||||
|
|
||||||
toast.success('Local configuration has been reset.')
|
toast.success('Local configuration has been reset.')
|
||||||
}
|
}
|
||||||
|
|
@ -370,13 +382,17 @@ onMounted(async () => {
|
||||||
})
|
})
|
||||||
emitter('clear_form');
|
emitter('clear_form');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
if (!separators.some(s => s.value === separator.value)) {
|
||||||
|
separator.value = separators[0].value
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasFormatInConfig = computed(() => {
|
const hasFormatInConfig = computed(() => {
|
||||||
if (!form?.value?.value) {
|
if (!form.value?.cli) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.value.cli)
|
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.value.cli)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -384,4 +400,25 @@ const filter_presets = (flag = true) => config.presets.filter(item => item.defau
|
||||||
const get_preset = name => config.presets.find(item => item.name === name)
|
const get_preset = name => config.presets.find(item => item.name === name)
|
||||||
const expand_description = e => toggleClass(e.target, ['is-ellipsis', 'is-pre-wrap'])
|
const expand_description = e => toggleClass(e.target, ['is-ellipsis', 'is-pre-wrap'])
|
||||||
|
|
||||||
|
|
||||||
|
const removeFromArchive = async url => {
|
||||||
|
try {
|
||||||
|
const req = await request(`/api/archive/0`, {
|
||||||
|
credentials: 'include',
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await req.json()
|
||||||
|
|
||||||
|
if (!req.ok) {
|
||||||
|
toast.error(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(data.message ?? `Removed item from archive.`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`Error: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,11 @@
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-text-overflow is-vcentered">
|
<td class="is-text-overflow is-vcentered">
|
||||||
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes">
|
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || item.extras?.duration">
|
||||||
<span class="tag">{{ formatBytes(item.downloaded_bytes) }}</span>
|
<span class="tag" v-if="item.downloaded_bytes">{{ formatBytes(item.downloaded_bytes) }}</span>
|
||||||
|
<span class="tag is-info" v-if="item.extras?.duration">
|
||||||
|
{{ formatTime(item.extras.duration) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showThumbnails && item.extras?.thumbnail">
|
<div v-if="showThumbnails && item.extras?.thumbnail">
|
||||||
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||||
|
|
@ -139,6 +142,10 @@
|
||||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
|
<span class="tag is-info" v-if="item.extras?.duration">
|
||||||
|
{{ formatTime(item.extras.duration) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)">
|
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)">
|
||||||
<span class="icon"><i class="fa-solid fa-copy" /></span>
|
<span class="icon"><i class="fa-solid fa-copy" /></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewDownload v-if="config.showForm || config.app.basic_mode" @getInfo="url => get_info = url" :item="item_form"
|
<NewDownload v-if="config.showForm || config.app.basic_mode" @getInfo="url => get_info = url" :item="item_form"
|
||||||
@clear_form="item_form = {}" />
|
@clear_form="item_form = {}" @remove_archive="" />
|
||||||
<Queue @getInfo="url => get_info = url" :thumbnails="show_thumbnail" />
|
<Queue @getInfo="url => get_info = url" :thumbnails="show_thumbnail" />
|
||||||
<History @getInfo="url => get_info = url" @add_new="item => toNewDownload(item)" :thumbnails="show_thumbnail" />
|
<History @getInfo="url => get_info = url" @add_new="item => toNewDownload(item)" :thumbnails="show_thumbnail" />
|
||||||
<GetInfo v-if="get_info" :link="get_info" @closeModel="get_info = ''" />
|
<GetInfo v-if="get_info" :link="get_info" @closeModel="get_info = ''" />
|
||||||
|
|
@ -58,7 +58,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
|
||||||
const emitter = defineEmits(['getInfo'])
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
|
|
|
||||||
|
|
@ -505,6 +505,18 @@ const uri = u => {
|
||||||
return eTrim(runtimeConfig.app.baseURL, '/') + '/' + sTrim(u, '/');
|
return eTrim(runtimeConfig.app.baseURL, '/') + '/' + sTrim(u, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatTime = seconds => {
|
||||||
|
const hrs = Math.floor(seconds / 3600)
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
|
||||||
|
const pad = (n) => n.toString().padStart(2, '0')
|
||||||
|
|
||||||
|
if (hrs > 0) return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`
|
||||||
|
if (mins > 0) return `${pad(mins)}:${pad(secs)}`
|
||||||
|
return `${secs}`
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ag_set,
|
ag_set,
|
||||||
ag,
|
ag,
|
||||||
|
|
@ -533,4 +545,5 @@ export {
|
||||||
toggleClass,
|
toggleClass,
|
||||||
cleanObject,
|
cleanObject,
|
||||||
uri,
|
uri,
|
||||||
|
formatTime,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue