ytptube/ui/app/components/History.vue
2026-03-31 18:34:59 +03:00

1404 lines
43 KiB
Vue

<template>
<div class="w-full min-w-0 max-w-full space-y-4">
<div v-if="hasItems" class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-check'"
@click="toggleMasterSelection"
>
{{ masterSelectAll ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedElms.length > 0" color="error" variant="soft" size="sm">
{{ selectedElms.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="direction === 'desc' ? 'i-lucide-arrow-down-a-z' : 'i-lucide-arrow-up-a-z'"
square
@click="toggleDirection"
/>
</div>
<UAlert
v-if="paginationInfo.isLoading && !hasItems"
color="info"
variant="soft"
icon="i-lucide-loader-circle"
title="Loading history..."
/>
<div
v-if="'list' === display_style && hasItems"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
>
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-190 table-fixed w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="masterSelectAll ? 'i-lucide-square' : 'i-lucide-check'"
class="size-4"
/>
</button>
</th>
<th class="text-left">Title</th>
<th class="w-32 whitespace-nowrap">Status</th>
<th class="w-36 whitespace-nowrap">Created</th>
<th class="w-36 whitespace-nowrap">Size/Starts</th>
<th class="w-44 whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr v-for="item in displayedItems" :key="item._id" class="align-top hover:bg-muted/20">
<td class="px-3 py-3 text-center align-top">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
:id="`checkbox-${item._id}`"
v-model="selectedElms"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item._id"
/>
</label>
</td>
<td class="w-0 px-3 py-3 align-top">
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<UTooltip
:text="
show_popover
? `${item.preset}: ${item.title}`
: `[${item.preset}] - ${item.title}`
"
>
<div class="truncate font-medium text-highlighted">
<a target="_blank" :href="item.url" class="hover:underline">
{{ item.title }}
</a>
</div>
</UTooltip>
</div>
<div
v-if="item.extras?.duration || show_popover"
class="flex shrink-0 items-center gap-2"
>
<UBadge v-if="item.extras?.duration" color="info" variant="soft" size="sm">
{{ formatTime(item.extras.duration) }}
</UBadge>
<UPopover
v-if="show_popover"
:content="{ side: 'bottom', align: 'end', sideOffset: 8 }"
>
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-info"
square
/>
<template #content>
<UCard class="max-w-112.5" :ui="{ body: 'space-y-3 p-4' }">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-semibold text-highlighted">{{ item.title }}</p>
<UBadge color="info" variant="soft" size="sm">{{
item.preset
}}</UBadge>
</div>
<p v-if="getItemPath(item)" class="text-xs text-toned">
<span class="font-semibold text-default">Path:</span>
{{ getItemPath(item) }}
</p>
</div>
<img
v-if="showThumbnails && getListImage(item)"
:src="getListImage(item)"
class="max-h-56 w-full rounded-md object-cover"
/>
<div
v-if="item.description"
class="max-h-40 overflow-y-auto rounded-md border border-default bg-muted/20 px-3 py-2 text-sm text-default"
>
{{ item.description }}
</div>
</UCard>
</template>
</UPopover>
</div>
</div>
<p
v-if="item.error"
:class="messageClass(item._id, 'error', 'list', 'mt-2')"
@click="toggleMessage(item._id, 'error', 'list')"
>
{{ item.error }}
</p>
<p
v-if="showMessage(item)"
:class="messageClass(item._id, 'msg', 'list', 'mt-1')"
@click="toggleMessage(item._id, 'msg', 'list')"
>
{{ item.msg }}
</p>
</td>
<td class="px-3 py-3 text-center align-top text-sm">
<div class="inline-flex items-center gap-2 text-default">
<span class="inline-flex items-center">
<UIcon
:name="setIcon(item)"
:class="[setIconColor(item), isQueuedAnimation(item), 'size-4 shrink-0']"
/>
</span>
<span>{{ setStatus(item) }}</span>
</div>
</td>
<td class="px-3 py-3 text-center align-top text-sm text-toned whitespace-nowrap">
<UTooltip :text="moment(item.datetime).format('YYYY-M-DD H:mm Z')">
<span :date-datetime="item.datetime" v-rtime="item.datetime" />
</UTooltip>
</td>
<td class="px-3 py-3 text-center align-top text-sm text-toned whitespace-nowrap">
<template
v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)"
>
<UTooltip
:text="`Retry at: ${moment(item.live_in || item.extras?.release_in).format('YYYY-M-DD H:mm Z')}`"
>
<span
:date-datetime="item.live_in || item.extras?.release_in"
v-rtime="item.live_in || item.extras?.release_in"
/>
</UTooltip>
</template>
<template v-else>
{{ item.file_size ? formatBytes(item.file_size) : 'N/A' }}
</template>
</td>
<td class="w-44 px-3 py-3 align-top whitespace-nowrap">
<div class="flex items-center justify-end gap-1">
<UButton
v-if="!item.filename"
color="warning"
variant="outline"
size="xs"
icon="i-lucide-rotate-cw"
square
@click="() => void retryItem(item, true)"
/>
<UButton
v-if="item.filename"
color="info"
variant="outline"
size="xs"
icon="i-lucide-download"
square
external
:href="makeDownload(config, item)"
:download="item.filename?.split('/').reverse()[0]"
/>
<UButton
color="error"
variant="outline"
size="xs"
icon="i-lucide-trash"
square
@click="() => void removeItem(item)"
/>
<UDropdownMenu v-if="item.url" :items="itemActionGroups(item)" :modal="false">
<UButton
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-settings-2"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="hasItems" class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<LateLoader
v-for="item in displayedItems"
:key="item._id"
:unrender="true"
:min-height="showThumbnails ? 410 : 210"
class="min-h-0 min-w-0 w-full max-w-full"
>
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col overflow-hidden border"
:class="historyCardClass(item)"
:ui="{ body: 'flex flex-1 flex-col gap-4 p-4', header: 'p-4 pb-3', root: 'bg-default' }"
>
<template #header>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<UTooltip :text="item.title">
<div class="min-w-0 text-sm font-semibold text-highlighted">
<a target="_blank" :href="item.url" class="block truncate hover:underline">
{{ item.title }}
</a>
</div>
</UTooltip>
</div>
<div class="flex max-w-full flex-wrap items-center justify-end gap-1 sm:shrink-0">
<UBadge v-if="item.extras?.duration" color="info" variant="soft" size="sm">
{{ formatTime(item.extras.duration) }}
</UBadge>
<UPopover
v-if="show_popover && getItemPath(item)"
:content="{ side: 'bottom', align: 'end', sideOffset: 8 }"
>
<UButton color="neutral" variant="ghost" size="xs" icon="i-lucide-info" square />
<template #content>
<UCard class="max-w-137.5" :ui="{ body: 'space-y-3 p-4' }">
<div class="space-y-2">
<p class="text-sm font-semibold text-highlighted">{{ item.title }}</p>
<p class="text-xs text-toned">
<span class="font-semibold text-default">Path:</span>
{{ getItemPath(item) }}
</p>
</div>
<div
v-if="item.description"
class="max-h-40 overflow-y-auto rounded-md border border-default bg-muted/20 px-3 py-2 text-sm text-default"
>
{{ item.description }}
</div>
</UCard>
</template>
</UPopover>
<UButton
v-if="thumbnails"
color="neutral"
variant="ghost"
size="xs"
:icon="hideThumbnail ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
square
@click="hideThumbnail = !hideThumbnail"
/>
<label class="inline-flex cursor-pointer items-center justify-center px-1">
<input
:id="`checkbox-${item._id}`"
v-model="selectedElms"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item._id"
/>
</label>
</div>
</div>
</template>
<div
v-if="showThumbnails"
class="-mx-4 -mt-4 overflow-hidden border-b border-default bg-muted/20"
>
<figure :class="['relative w-full overflow-hidden', thumbnailRatioClass]">
<span v-if="item.filename" class="play-overlay" @click="playVideo(item)">
<span class="play-icon" aria-hidden="true">
<UIcon name="i-lucide-play" class="size-6 translate-x-px text-white" />
</span>
<img
v-if="getGridImage(item)"
:src="getGridImage(item)"
@load="pImg"
@error="onImgError"
/>
<img v-else src="/images/placeholder.png" />
</span>
<span
v-else-if="isEmbedable(item.url)"
class="play-overlay"
@click="embed_url = getEmbedable(item.url) as string"
>
<span class="play-icon embed-icon" aria-hidden="true">
<UIcon name="i-lucide-play" class="size-6 translate-x-px text-white" />
</span>
<img
v-if="getGridImage(item)"
:src="getGridImage(item)"
@load="pImg"
@error="onImgError"
/>
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<img
v-if="getGridImage(item)"
:src="getGridImage(item)"
@load="pImg"
@error="onImgError"
/>
<img v-else src="/images/placeholder.png" />
</template>
</figure>
</div>
<div class="flex flex-wrap gap-2 text-sm *:min-w-32 *:flex-1">
<div
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-default"
>
<span class="inline-flex items-center gap-2">
<UIcon
:name="setIcon(item)"
:class="[setIconColor(item), isQueuedAnimation(item), 'size-4 shrink-0']"
/>
<span>{{ setStatus(item) }}</span>
</span>
</div>
<div
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-default"
>
<UTooltip :text="`Preset: ${item.preset}`">
<span class="block min-w-0 truncate">{{ item.preset }}</span>
</UTooltip>
</div>
<div
v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-toned"
>
<UTooltip
:text="`Retry at: ${moment(item.live_in || item.extras?.release_in).format('YYYY-M-DD H:mm Z')}`"
>
<span
:date-datetime="item.live_in || item.extras?.release_in"
v-rtime="item.live_in || item.extras?.release_in"
/>
</UTooltip>
</div>
<div
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-toned"
>
<UTooltip :text="moment(item.datetime).format('YYYY-M-DD H:mm Z')">
<span :date-datetime="item.datetime" v-rtime="item.datetime" />
</UTooltip>
</div>
<div
v-if="item.file_size"
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-center text-toned"
>
{{ formatBytes(item.file_size) }}
</div>
</div>
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
<UButton
v-if="!item.filename"
color="warning"
variant="outline"
icon="i-lucide-rotate-cw"
class="w-full justify-center"
@click="() => void retryItem(item, false)"
>
Retry
</UButton>
<UButton
v-if="item.filename"
color="info"
variant="outline"
icon="i-lucide-download"
class="w-full justify-center"
external
:href="makeDownload(config, item)"
:download="item.filename?.split('/').reverse()[0]"
>
Download
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void removeItem(item)"
>
{{ config.app.remove_files ? 'Remove' : 'Clear' }}
</UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false" class="w-full">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-settings-2"
trailing-icon="i-lucide-chevron-down"
class="w-full justify-center"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<div
v-if="item.error || showMessage(item)"
class="space-y-2 border-t border-default pt-3"
>
<p
v-if="item.error"
:class="messageClass(item._id, 'error', 'card')"
@click="toggleMessage(item._id, 'error', 'card')"
>
{{ item.error }}
</p>
<p
v-if="showMessage(item)"
:class="messageClass(item._id, 'msg', 'card')"
@click="toggleMessage(item._id, 'msg', 'card')"
>
{{ item.msg }}
</p>
</div>
</UCard>
</LateLoader>
</div>
<div v-if="!hasItems && !paginationInfo.isLoading" class="space-y-4">
<UAlert
v-if="query"
color="warning"
variant="soft"
icon="i-lucide-search"
title="Filter results"
>
<template #description>
<div class="space-y-3 text-sm text-default">
<p>
No results found for: <code>{{ query }}</code
>.
</p>
<p>
You can search using any value shown in the item's <code>Local Information</code>. You
can also do a targeted search using <code><u>key</u>:value</code>.
</p>
<div>
<p class="mb-1 font-medium">Examples:</p>
<ul class="list-disc space-y-1 pl-5">
<li><code>youtube.com</code> - items containing that text</li>
<li><code>is_live:true</code> - only live downloads</li>
<li><code>source_name:task_name</code> - items added by the specified task.</li>
</ul>
</div>
<UButton
color="neutral"
variant="outline"
size="sm"
@click="() => emitter('clear_search')"
>
Clear filter
</UButton>
</div>
</template>
</UAlert>
<UEmpty
v-else
icon="i-lucide-triangle-alert"
title="No items"
description="Download history is empty."
class="rounded-lg border border-dashed border-default bg-muted/10 py-10"
/>
</div>
<div
v-if="paginationInfo.isLoaded && paginationInfo.page < paginationInfo.total_pages"
ref="loadMoreTrigger"
class="flex justify-center pt-2"
>
<div v-if="paginationInfo.isLoading" class="text-center text-sm text-toned">
<UIcon name="i-lucide-loader-circle" class="mx-auto size-7 animate-spin text-info" />
<p class="mt-2">Loading more items...</p>
</div>
</div>
<UModal
v-if="video_item"
:open="Boolean(video_item)"
:dismissible="true"
:title="video_item?.title || 'Player'"
:ui="{ content: 'sm:max-w-5xl', body: 'p-0' }"
@update:open="(open) => !open && closeVideo()"
>
<template #body>
<VideoPlayer
type="default"
:isMuted="false"
autoplay="true"
:isControls="true"
:item="video_item"
class="w-full"
@closeModel="closeVideo"
@error="async (error: string) => await box.alert(error)"
/>
</template>
</UModal>
<UModal
v-if="embed_url"
:open="Boolean(embed_url)"
:dismissible="true"
title="Embedded player"
:ui="{ content: 'sm:max-w-5xl', body: 'p-0' }"
@update:open="(open) => !open && (embed_url = '')"
>
<template #body>
<EmbedPlayer :url="embed_url" @closeModel="embed_url = ''" />
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
import moment from 'moment';
import { useStorage, useIntersectionObserver } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import type { StoreItem } from '~/types/store';
import { useConfirm } from '~/composables/useConfirm';
import { deepIncludes, getPath, getImage } from '~/utils';
import type { item_request } from '~/types/item';
const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string, cli: string): void;
(e: 'add_new', item: item_request): void;
(e: 'getItemInfo', id: string): void;
(e: 'clear_search'): void;
}>();
const props = defineProps<{
thumbnails?: boolean;
query?: string;
}>();
const config = useConfigStore();
const stateStore = useStateStore();
const socket = useSocketStore();
const toast = useNotification();
const box = useConfirm();
const { confirmDialog } = useDialog();
const showCompleted = useStorage<boolean>('showCompleted', true);
const hideThumbnail = useStorage<boolean>('hideThumbnailHistory', false);
const direction = useStorage<'asc' | 'desc'>('sortCompleted', 'desc');
const display_style = useStorage<'grid' | 'list'>('display_style', 'grid');
const bg_enable = useStorage<boolean>('random_bg', true);
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
const show_popover = useStorage<boolean>('show_popover', true);
const selectedElms = ref<string[]>([]);
const masterSelectAll = ref(false);
const embed_url = ref('');
const video_item = ref<StoreItem | null>(null);
const loadMoreTrigger = ref<HTMLElement | null>(null);
const expandedMessages = reactive<Record<string, Set<string>>>({});
const paginationInfo = computed(() => stateStore.getPagination());
// Function to load more history items
const loadMoreHistory = async (): Promise<void> => {
if (
paginationInfo.value.isLoading ||
paginationInfo.value.page >= paginationInfo.value.total_pages
) {
return;
}
try {
await stateStore.loadPaginated(
'history',
paginationInfo.value.page + 1,
config.app.default_pagination,
'DESC',
true,
);
} catch (error) {
console.error('Failed to load more history:', error);
toast.error('Failed to load more history');
}
};
// Setup intersection observer for infinite scroll
useIntersectionObserver(
loadMoreTrigger,
([entry]) => {
if (!socket.isConnected) {
return;
}
if (
entry?.isIntersecting &&
!paginationInfo.value.isLoading &&
paginationInfo.value.page < paginationInfo.value.total_pages
) {
loadMoreHistory();
}
},
{ threshold: 0.5 },
);
watch(showCompleted, async (isShown) => {
if (isShown && !paginationInfo.value.isLoaded && socket.isConnected) {
try {
await stateStore.loadPaginated('history', 1, config.app.default_pagination, 'DESC', true);
} catch (error) {
console.error('Failed to load history:', error);
}
}
});
onMounted(async () => {
if (showCompleted.value && !paginationInfo.value.isLoaded) {
try {
await stateStore.loadPaginated('history', 1, config.app.default_pagination, 'DESC', true);
} catch (error) {
console.error('Failed to load history on mount:', error);
toast.error('Failed to load history');
}
}
});
watch(
() => socket.isConnected,
async (connected) => {
if (connected && showCompleted.value && !paginationInfo.value.isLoaded) {
try {
await stateStore.loadPaginated('history', 1, config.app.default_pagination, 'DESC', true);
} catch (error) {
console.error('Failed to load history after socket connection:', error);
}
}
},
);
const showThumbnails = computed(() => (props.thumbnails ?? true) && !hideThumbnail.value);
const playVideo = (item: StoreItem) => {
video_item.value = item;
};
const closeVideo = () => {
video_item.value = null;
};
const filteredItems = (items: StoreItem[]) => (!props.query ? items : items.filter(filterItem));
const filterItem = (item: StoreItem) => {
const q = props.query?.toLowerCase();
if (!q) {
return true;
}
return deepIncludes(item, q, new WeakSet());
};
const sortCompleted = computed(() => {
const thisDirection = direction.value;
return Object.values(stateStore.history as Record<string, StoreItem>).sort((a, b) => {
if ('asc' === thisDirection) {
return new Date(a.datetime).getTime() - new Date(b.datetime).getTime();
}
return new Date(b.datetime).getTime() - new Date(a.datetime).getTime();
});
});
const displayedItems = computed(() => filteredItems(sortCompleted.value as StoreItem[]));
const hasSelected = computed(() => selectedElms.value.length > 0);
const hasItems = computed(() => displayedItems.value.length > 0);
const displayedItemIds = computed(() => displayedItems.value.map((item) => item._id));
const selectedDownloadableCount = computed(() =>
selectedElms.value.reduce((count, itemId) => {
const item = stateStore.get('history', itemId, {} as StoreItem) as StoreItem;
return item?.filename ? count + 1 : count;
}, 0),
);
const thumbnailRatioClass = computed(() =>
thumbnail_ratio.value === 'is-16by9' ? 'aspect-video' : 'aspect-[3/1]',
);
const toggleMasterSelection = () => {
if (masterSelectAll.value) {
selectedElms.value = [];
masterSelectAll.value = false;
return;
}
selectedElms.value = [...displayedItemIds.value];
masterSelectAll.value = true;
};
const toggleDirection = () => {
direction.value = direction.value === 'desc' ? 'asc' : 'desc';
};
watch(
displayedItemIds,
(ids) => {
if (!masterSelectAll.value) {
return;
}
selectedElms.value = [...ids];
},
{ immediate: true },
);
watch(selectedElms, (value) => {
const ids = displayedItemIds.value;
masterSelectAll.value = ids.length > 0 && ids.every((id) => value.includes(id));
});
const getItemPath = (item: StoreItem): string => getPath(config.app.download_path, item) || '';
const getListImage = (item: StoreItem): string =>
getImage(config.app.download_path, item, false) || '';
const getGridImage = (item: StoreItem): string => getImage(config.app.download_path, item) || '';
const historyCardClass = (item: StoreItem): string => {
if (item.status === 'error') {
return 'border-error';
}
if (item.live_in || item.is_live) {
return 'border-info';
}
return 'border-default';
};
const bulkActionGroups = computed(() => {
const groups: Array<Array<Record<string, unknown>>> = [
[
{
label: 'Download',
icon: 'i-lucide-download',
disabled: !hasSelected.value || selectedDownloadableCount.value < 1,
onSelect: () => void downloadSelected(),
},
{
label: config.app.remove_files ? 'Remove' : 'Clear',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value,
onSelect: () => void deleteSelectedItems(),
},
],
];
const cleanupActions: Array<Record<string, unknown>> = [];
if (hasCompleted.value) {
cleanupActions.push({
label: 'Clear Completed',
icon: 'i-lucide-circle-check-big',
onSelect: () => void clearCompleted(),
});
}
if (hasIncomplete.value) {
cleanupActions.push(
{
label: 'Clear Incomplete',
icon: 'i-lucide-circle-x',
onSelect: () => void clearIncomplete(),
},
{
label: 'Retry Incomplete',
icon: 'i-lucide-rotate-cw',
color: 'warning',
onSelect: () => void retryIncomplete(),
},
);
}
if (cleanupActions.length > 0) {
groups.push(cleanupActions);
}
return groups;
});
const itemActionGroups = (item: StoreItem) => {
const groups: Array<Array<Record<string, unknown>>> = [];
const mediaActions: Array<Record<string, unknown>> = [];
if (item.filename) {
mediaActions.push({
label: 'Play video',
icon: 'i-lucide-play',
onSelect: () => playVideo(item),
});
if ('error' === item.status) {
mediaActions.push({
label: 'Retry download',
icon: 'i-lucide-rotate-cw',
color: 'warning',
onSelect: () => void retryItem(item, true),
});
}
mediaActions.push({
label: 'Generate NFO',
icon: 'i-lucide-file-code-2',
color: 'info',
onSelect: () => void generateNfo(item),
});
} else if (isEmbedable(item.url)) {
mediaActions.push({
label: 'Play video',
icon: 'i-lucide-play',
color: 'error',
onSelect: () => {
embed_url.value = getEmbedable(item.url) as string;
},
});
}
if (mediaActions.length > 0) {
groups.push(mediaActions);
}
groups.push([
{
label: 'yt-dlp Information',
icon: 'i-lucide-info',
onSelect: () => emitter('getInfo', item.url, item.preset, item.cli),
},
{
label: 'Local Information',
icon: 'i-lucide-info',
onSelect: () => emitter('getItemInfo', item._id),
},
{
label: 'Add to download form',
icon: 'i-lucide-copy',
onSelect: () => void retryItem(item, true),
},
]);
if (item.is_archivable && !item.is_archived) {
groups.push([
{
label: 'Add to archive',
icon: 'i-lucide-archive',
color: 'error',
onSelect: () => addArchiveDialog(item),
},
]);
}
if (item.is_archivable && item.is_archived) {
groups.push([
{
label: 'Remove from archive',
icon: 'i-lucide-archive-x',
color: 'error',
onSelect: () => removeFromArchiveDialog(item),
},
]);
}
return groups;
};
const showMessage = (item: StoreItem) => {
if (!item?.msg || item.msg === item?.error) {
return false;
}
return (item.msg?.length || 0) > 0;
};
const hasIncomplete = computed(() => {
if (Object.keys(stateStore.history)?.length < 0) {
return false;
}
for (const key in stateStore.history) {
const element = stateStore.history[key] as StoreItem;
if (element.status !== 'finished') {
return true;
}
}
return false;
});
const hasCompleted = computed(() => {
if (Object.keys(stateStore.history)?.length < 0) {
return false;
}
for (const key in stateStore.history) {
const element = stateStore.history[key] as StoreItem;
if (element.status === 'finished') {
return true;
}
}
return false;
});
const deleteSelectedItems = async () => {
if (selectedElms.value.length < 1) {
toast.error('No items selected.');
return;
}
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${selectedElms.value.length}' items?`;
if (true === config.app.remove_files) {
msg += ' This will remove any associated files if they exists.';
}
if (false === (await box.confirm(msg))) {
return;
}
await stateStore.removeItems('history', [...selectedElms.value], config.app.remove_files);
selectedElms.value = [];
};
const clearCompleted = async () => {
const msg = 'Clear all completed downloads? No files will be removed.';
if (false === (await box.confirm(msg))) {
return;
}
selectedElms.value = [];
Promise.all([
stateStore.deleteItems('history', { status: 'finished', removeFile: false }),
stateStore.deleteItems('history', { status: 'skip', removeFile: false }),
]);
};
const clearIncomplete = async () => {
if (false === (await box.confirm('Clear all in-complete downloads?'))) {
return;
}
selectedElms.value = [];
await stateStore.deleteItems('history', { status: '!finished', removeFile: false });
};
const setIcon = (item: StoreItem) => {
if ('finished' === item.status) {
if (!item.filename) {
return 'i-lucide-triangle-alert';
}
if (item.extras?.is_premiere) {
return 'i-lucide-star';
}
return item.is_live ? 'i-lucide-globe' : 'i-lucide-circle-check-big';
}
if ('error' === item.status) {
return 'i-lucide-circle-x';
}
if ('cancelled' === item.status) {
return 'i-lucide-circle-off';
}
if ('not_live' === item.status) {
return item.extras?.is_premiere ? 'i-lucide-star' : 'i-lucide-headphones';
}
if ('skip' === item.status) {
return 'i-lucide-ban';
}
return 'i-lucide-circle';
};
const setIconColor = (item: StoreItem) => {
if ('finished' === item.status) {
if (!item.filename) {
return 'text-warning';
}
return 'text-success';
}
if ('not_live' === item.status) {
return 'text-info';
}
if ('cancelled' === item.status || 'skip' === item.status) {
return 'text-warning';
}
if ('error' === item.status && item.filename) {
return 'text-warning';
}
return 'text-error';
};
const setStatus = (item: StoreItem) => {
if ('finished' === item.status) {
if (item.extras?.is_premiere) {
return 'Premiered';
}
return item.is_live ? 'Streamed' : 'Completed';
}
if ('error' === item.status) {
if (item.filename) {
return 'Partial Error';
}
return 'Error';
}
if ('cancelled' === item.status) {
return 'Cancelled';
}
if ('not_live' === item.status) {
if (item.extras?.is_premiere) {
return 'Premiere';
}
return display_style.value === 'grid' ? 'Stream' : 'Live';
}
if ('skip' === item.status) {
return 'Skipped';
}
return item.status;
};
const retryIncomplete = async () => {
if (false === (await box.confirm('Retry all incomplete downloads?'))) {
return false;
}
for (const key in stateStore.history) {
const item = stateStore.get('history', key, {} as StoreItem) as StoreItem;
if ('finished' === item.status) {
continue;
}
await retryItem(item);
}
};
const addArchiveDialog = async (item: StoreItem): Promise<void> => {
const { status, value } = await confirmDialog({
title: 'Archive Item',
message: `Archive '${item.title || item.id || item.url || '??'}'?`,
confirmText: 'Archive',
confirmColor: 'warning',
options: [{ key: 'remove_history', label: 'Also, Remove from history.' }],
});
if (!status) {
return;
}
await archiveItem(item, value ?? undefined);
};
const archiveItem = async (item: StoreItem, opts = {}) => {
try {
const req = await request(`/api/history/${item._id}/archive`, { method: 'POST' });
const data = await req.json();
if (!req.ok) {
toast.error(data.error);
return;
}
toast.success(data.message ?? `Archived '${item.title || item.id || item.url || '??'}'.`);
} catch (e: any) {
console.error(e);
}
if (!(opts as any)?.remove_history) {
return;
}
await stateStore.removeItems('history', [item._id], false);
};
const removeItem = async (item: StoreItem) => {
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${item.title || item.id || item.url || '??'}'?`;
if (item.status === 'finished' && config.app.remove_files) {
msg += ' This will remove any associated files if they exists.';
}
if (false === (await box.confirm(msg))) {
return false;
}
socket.emit('item_delete', {
id: item._id,
remove_file: config.app.remove_files,
});
if (selectedElms.value.includes(item._id || '')) {
selectedElms.value = selectedElms.value.filter((i) => i !== item._id);
}
};
const retryItem = async (
item: StoreItem,
re_add: boolean = false,
remove_file: boolean = false,
) => {
const item_req: item_request = {
url: item.url,
preset: item.preset,
folder: item.folder,
cookies: item.cookies,
template: item.template,
cli: item?.cli,
extras: toRaw(item?.extras || {}) ?? {},
auto_start: item.auto_start,
};
await stateStore.removeItems('history', [item._id], remove_file);
if (selectedElms.value.includes(item._id || '')) {
selectedElms.value = selectedElms.value.filter((i) => i !== item._id);
}
if (true === re_add) {
toast.info('Cleared the item from history, and added it to the new download form.');
emitter('add_new', item_req);
return;
}
await stateStore.addDownload(item_req);
};
const pImg = (e: Event) => {
const target = e.target as HTMLImageElement;
if (target.naturalHeight > target.naturalWidth) {
target.classList.add('image-portrait');
}
};
const onImgError = (e: Event) => {
const target = e.target as HTMLImageElement;
if (target.src.endsWith('/images/placeholder.png')) {
return;
}
target.src = '/images/placeholder.png';
};
watch(video_item, (v) => {
if (!bg_enable.value) {
return;
}
document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`);
});
watch(embed_url, (v) => {
if (!bg_enable.value) {
return;
}
document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`);
});
const downloadSelected = async () => {
if (selectedElms.value.length < 1) {
toast.error('No items selected.');
return;
}
const files_list: string[] = [];
for (const key in selectedElms.value) {
const item_id = selectedElms.value[key];
if (!item_id) {
continue;
}
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem;
if (!item.filename) {
continue;
}
files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename);
}
selectedElms.value = [];
try {
const response = await request('/api/file/download', {
method: 'POST',
body: JSON.stringify(files_list),
});
const json = await response.json();
if (!response.ok) {
toast.error(json.error || 'Failed to start download.');
return;
}
const token = json.token;
const body = document.querySelector('body');
const link = document.createElement('a');
link.href = uri(`/api/file/download/${token}`);
link.setAttribute('target', '_blank');
body?.appendChild(link);
link.click();
body?.removeChild(link);
} catch (e: any) {
console.error(e);
toast.error(`Error: ${e.message}`);
return;
}
};
const toggleMessage = (itemId: string, field: 'error' | 'msg', view: 'list' | 'card') => {
const key = `${itemId}:${view}`;
if (!expandedMessages[key]) {
expandedMessages[key] = new Set();
}
if (expandedMessages[key].has(field)) {
expandedMessages[key].delete(field);
return;
}
expandedMessages[key].add(field);
};
const isMessageExpanded = (itemId: string, field: 'error' | 'msg', view: 'list' | 'card') =>
expandedMessages[`${itemId}:${view}`]?.has(field) ?? false;
const messageClass = (
itemId: string,
field: 'error' | 'msg',
view: 'list' | 'card',
spacingClass = '',
) => {
const expanded = isMessageExpanded(itemId, field, view);
const base = ['cursor-pointer', 'text-sm', 'text-error'];
if (spacingClass) {
base.push(spacingClass);
}
if ('card' === view) {
base.push(expanded ? 'whitespace-pre-wrap break-words' : 'line-clamp-2 break-words');
return base;
}
base.push(expanded ? 'whitespace-pre-wrap break-words' : 'block max-w-full truncate');
return base;
};
const removeFromArchiveDialog = async (item: StoreItem): Promise<void> => {
const options = [
{ key: 'remove_history', label: 'Remove from history.' },
{ key: 're_add', label: 'Re-add to download form.' },
];
if (config.app.remove_files) {
options.push({ key: 'dont_remove_file', label: "Don't remove associated files." });
}
const { status, value } = await confirmDialog({
title: 'Remove from Archive',
message: `Remove '${item.title || item.id || item.url || '??'}' from archive?`,
confirmText: 'Remove',
confirmColor: 'error',
options,
});
if (!status) {
return;
}
await removeFromArchive(item, value ?? undefined);
};
const removeFromArchive = async (
item: StoreItem,
opts?: { re_add?: boolean; remove_history?: boolean; dont_remove_file?: boolean },
) => {
try {
const req = await request(`/api/history/${item._id}/archive`, { method: 'DELETE' });
const data = await req.json();
if (!req.ok) {
toast.error(data.error);
} else {
toast.success(
data.message || `Removed '${item.title || item.id || item.url || '??'}' from archive.`,
);
}
} catch (e: any) {
console.error(e);
toast.error(`Error: ${e.message}`);
}
let file_delete = config.app.remove_files;
if (opts?.dont_remove_file) {
file_delete = false;
}
if (opts?.re_add) {
await retryItem(item, true, file_delete);
return;
}
if (opts?.remove_history) {
await stateStore.removeItems('history', [item._id], file_delete);
}
};
const isQueuedAnimation = (item: StoreItem) => {
if (!item?.status || 'not_live' !== item.status) {
return '';
}
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'animate-spin' : '';
};
const generateNfo = async (item: StoreItem) => {
try {
toast.info('Generating please wait...', { timeout: 2000 });
const response = await request(`/api/history/${item._id}/nfo`, {
method: 'POST',
body: JSON.stringify({ type: 'tv', overwrite: true }),
});
const data = await response.json();
if (!response.ok) {
toast.error(data.error || 'Failed to generate NFO');
return;
}
toast.success(data.message || 'NFO file generated');
} catch (e: any) {
toast.error(`Error: ${e.message}`);
}
};
</script>