Add option to remove item from archive, and minor ui updates.

This commit is contained in:
arabcoders 2025-06-14 21:29:25 +03:00
parent 1c8de32e72
commit 4367fb0937
12 changed files with 585 additions and 72 deletions

View file

@ -240,6 +240,7 @@ class HttpAPI:
"cache": this.cache,
"app": app,
"http_api": this,
"root_path": this.rootPath,
}
try:

View file

@ -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")

View file

@ -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}'.")

View file

@ -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
View 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,
)

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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()

View file

@ -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,
}