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,
|
||||
"app": app,
|
||||
"http_api": this,
|
||||
"root_path": this.rootPath,
|
||||
}
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}'.")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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>
|
||||
<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">
|
||||
<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>{{ label }}</span>
|
||||
<div class="is-pulled-right">
|
||||
|
|
@ -11,17 +12,18 @@
|
|||
</div>
|
||||
|
||||
<div class="dropdown-menu" role="menu" id="dropdown-menu">
|
||||
<div class="dropdown-content">
|
||||
<div class="dropdown-content" @click="handle_slot_click">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
const emitter = defineEmits(['open_state'])
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Select'
|
||||
|
|
@ -29,27 +31,58 @@ const props = defineProps({
|
|||
icons: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
button_classes: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
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 (dropdown.value && !dropdown.value.contains(event.target)) {
|
||||
if (isOpen.value && dropdown.value) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
|
||||
const handle_event = (event: MouseEvent) => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.dropdown {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
|
|
@ -60,11 +93,17 @@ onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
|
|||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
z-index: 99;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown.drop-up .dropdown-menu {
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<div class="columns is-multiline" v-if="'list' === display_style">
|
||||
<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"
|
||||
style="min-width: 1300px; table-layout: fixed;">
|
||||
<thead>
|
||||
|
|
@ -106,7 +106,12 @@
|
|||
:id="'checkbox-' + item._id" :value="item._id">
|
||||
</label>
|
||||
</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">
|
||||
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||
:title="item.title">
|
||||
|
|
@ -164,30 +169,51 @@
|
|||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||
</button>
|
||||
</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'">
|
||||
<a class="button is-link is-fullwidth is-small" :href="makeDownload(config, item)"
|
||||
v-tooltip="'Download video'" :download="item.filename?.split('/').reverse()[0]">
|
||||
<span class="icon"><i class="fa-solid fa-download" /></span>
|
||||
</a>
|
||||
</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">
|
||||
<button class="button is-danger is-fullwidth is-small" @click="removeItem(item)"
|
||||
v-tooltip="'Remove video'">
|
||||
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
||||
</button>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -202,12 +228,16 @@
|
|||
v-for="item in sortCompleted" :key="item._id">
|
||||
<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 }">
|
||||
<header class="card-header ">
|
||||
<header class="card-header">
|
||||
<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 class="card-header-icon">
|
||||
<span class="tag is-info" v-if="item.extras?.duration">
|
||||
{{ formatTime(item.extras.duration) }}
|
||||
</span>
|
||||
|
||||
<span v-if="!showThumbnails">
|
||||
<a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)"
|
||||
v-tooltip="'Play video.'">
|
||||
|
|
@ -305,14 +335,7 @@
|
|||
</span>
|
||||
</a>
|
||||
</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'">
|
||||
<a class="button is-link is-fullwidth" :href="makeDownload(config, item)"
|
||||
:download="item.filename?.split('/').reverse()[0]">
|
||||
|
|
@ -323,12 +346,36 @@
|
|||
</a>
|
||||
</div>
|
||||
<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)">
|
||||
<span class="icon-text is-block">
|
||||
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Information</span>
|
||||
</span>
|
||||
</button>
|
||||
</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>
|
||||
|
|
@ -361,6 +408,10 @@
|
|||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -172,16 +172,27 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-4-mobile has-text-left">
|
||||
<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="column is-12">
|
||||
<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">
|
||||
<button type="button" class="button is-danger" @click="resetConfig"
|
||||
: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 socket = useSocketStore()
|
||||
const toast = useNotification()
|
||||
|
|
@ -327,6 +338,7 @@ const resetConfig = () => {
|
|||
}
|
||||
|
||||
showAdvanced.value = false
|
||||
separator.value = separators[0].value
|
||||
|
||||
toast.success('Local configuration has been reset.')
|
||||
}
|
||||
|
|
@ -370,13 +382,17 @@ onMounted(async () => {
|
|||
})
|
||||
emitter('clear_form');
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
if (!separators.some(s => s.value === separator.value)) {
|
||||
separator.value = separators[0].value
|
||||
}
|
||||
})
|
||||
|
||||
const hasFormatInConfig = computed(() => {
|
||||
if (!form?.value?.value) {
|
||||
if (!form.value?.cli) {
|
||||
return false
|
||||
}
|
||||
|
||||
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 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>
|
||||
|
|
|
|||
|
|
@ -66,8 +66,11 @@
|
|||
</label>
|
||||
</td>
|
||||
<td class="is-text-overflow is-vcentered">
|
||||
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes">
|
||||
<span class="tag">{{ formatBytes(item.downloaded_bytes) }}</span>
|
||||
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || item.extras?.duration">
|
||||
<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 v-if="showThumbnails && 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>
|
||||
</div>
|
||||
<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)">
|
||||
<span class="icon"><i class="fa-solid fa-copy" /></span>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
</div>
|
||||
|
||||
<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" />
|
||||
<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 = ''" />
|
||||
|
|
@ -58,7 +58,6 @@
|
|||
<script setup>
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
const emitter = defineEmits(['getInfo'])
|
||||
const config = useConfigStore()
|
||||
const stateStore = useStateStore()
|
||||
const socket = useSocketStore()
|
||||
|
|
|
|||
|
|
@ -505,6 +505,18 @@ const uri = 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 {
|
||||
ag_set,
|
||||
ag,
|
||||
|
|
@ -533,4 +545,5 @@ export {
|
|||
toggleClass,
|
||||
cleanObject,
|
||||
uri,
|
||||
formatTime,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue