refactor: drop pinia stores

This commit is contained in:
arabcoders 2026-04-21 20:51:47 +03:00
parent fc5cf84dd2
commit 210712cee2
35 changed files with 871 additions and 1421 deletions

View file

@ -538,7 +538,7 @@ const props = defineProps<{
const toast = useNotification();
const showImport = useStorage('showImport', false);
const box = useConfirm();
const config = useConfigStore();
const config = useYtpConfig();
const form = reactive<Condition>(normalizeCondition(props.item));
const importString = ref('');

View file

@ -260,7 +260,7 @@ const props = defineProps<{
const toast = useNotification();
const box = useConfirm();
const config = useConfigStore();
const config = useYtpConfig();
const fieldTypes = ['string', 'text', 'bool'] as const;
const fieldTypeItems = [...fieldTypes];

View file

@ -520,7 +520,7 @@ const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void;
(e: 'clear_form'): void;
}>();
const config = useConfigStore();
const config = useYtpConfig();
const toast = useNotification();
const dialog = useDialog();
const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions();

View file

@ -138,9 +138,10 @@
<script setup lang="ts">
import moment from 'moment';
import { useNotificationCenter } from '~/composables/useNotificationCenter';
import type { notificationType } from '~/composables/useNotification';
const store = useNotificationStore();
const store = useNotificationCenter();
const copiedId = ref<string | null>(null);
const expandedId = ref<string | null>(null);

View file

@ -370,7 +370,7 @@ const props = defineProps<{
presets?: Preset[];
}>();
const config = useConfigStore();
const config = useYtpConfig();
const toast = useNotification();
const dialog = useDialog();
const { presets, findPreset, selectItems } = usePresetOptions(() => props.presets);

View file

@ -275,7 +275,6 @@
<script setup lang="ts">
import { watch, onMounted, onBeforeUnmount, ref, computed } from 'vue';
import { useStorage } from '@vueuse/core';
import { useConfigStore } from '~/stores/ConfigStore';
import { useNotification } from '~/composables/useNotification';
import type { notificationTarget, toastPosition } from '~/composables/useNotification';
@ -294,7 +293,7 @@ const props = withDefaults(
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
const config = useConfigStore();
const config = useYtpConfig();
const notification = useNotification();
const bg_enable = useStorage<boolean>('random_bg', true);

View file

@ -521,7 +521,7 @@ const emitter = defineEmits<{
}>();
const toast = useNotification();
const config = useConfigStore();
const config = useYtpConfig();
const dialog = useDialog();
const { findPreset, getPresetDefault, selectItems } = usePresetOptions();
const showImport = useStorage('showTaskImport', false);

View file

@ -127,7 +127,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { request } from '~/utils';
import { useConfigStore } from '~/stores/ConfigStore';
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect';
const props = defineProps<{
@ -138,7 +137,7 @@ const props = defineProps<{
const { selectItems } = usePresetOptions();
const config = useConfigStore();
const config = useYtpConfig();
const url = ref(props.url ?? '');
const preset = ref(props.preset || config.app.default_preset || '');
const handler = ref(props.handler ?? '');

View file

@ -291,7 +291,7 @@ import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';
import type { StoreItem } from '~/types/store';
import type { file_info, video_source_element, video_track_element } from '~/types/video';
const config = useConfigStore();
const config = useYtpConfig();
const props = defineProps<{ item: StoreItem }>();
const emitter = defineEmits<{

View file

@ -0,0 +1,403 @@
import { proxyRefs, readonly, ref } from 'vue';
import { useYtpConfig } from '~/composables/useYtpConfig';
import { useQueueState } from '~/composables/useQueueState';
import type { StoreItem } from '~/types/store';
import type {
ConfigUpdatePayload,
WebSocketClientEmits,
WebSocketEnvelope,
WSEP as WSEP,
} from '~/types/sockets';
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
type SocketHandler = (...args: unknown[]) => void;
type HandlerRegistry = Map<SocketHandler, SocketHandler>;
type KnownEvent = keyof WSEP;
const getRuntimeConfig = () => useRuntimeConfig();
const getConfig = () => useYtpConfig();
const getQueueState = () => useQueueState();
const getToast = () => useNotification();
const socket = ref<WebSocket | null>(null);
const isConnected = ref<boolean>(false);
const connectionStatus = ref<connectionStatus>('disconnected');
const error = ref<string | null>(null);
const error_count = ref<number>(0);
const wasHidden = ref<boolean>(false);
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
const manualDisconnect = ref<boolean>(false);
const reconnectAttempts = ref<number>(0);
const handlers = new Map<string, HandlerRegistry>();
const emit = <K extends keyof WebSocketClientEmits>(
event: K,
data: WebSocketClientEmits[K],
): void => {
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
return;
}
socket.value.send(JSON.stringify({ event, data }));
};
function on<K extends KnownEvent>(event: K | K[], callback: (payload: WSEP[K]) => void): void;
function on<K extends KnownEvent>(
event: K | K[],
callback: (event: K, payload: WSEP[K]) => void,
withEvent: true,
): void;
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void;
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
const events = Array.isArray(event) ? event : [event];
events.forEach((eventName) => {
if (!handlers.has(eventName)) {
handlers.set(eventName, new Map());
}
const registry = handlers.get(eventName) as HandlerRegistry;
const handler =
true === withEvent
? (payload: unknown) => callback(eventName, payload)
: (payload: unknown) => callback(payload);
registry.set(callback, handler);
});
}
function off<K extends KnownEvent>(event: K | K[], callback?: (payload: WSEP[K]) => void): void;
function off(event: string | string[], callback?: SocketHandler): void;
function off(event: string | string[], callback?: SocketHandler): void {
const events = Array.isArray(event) ? event : [event];
events.forEach((eventName) => {
const registry = handlers.get(eventName);
if (!registry) {
return;
}
if (!callback) {
registry.clear();
handlers.delete(eventName);
return;
}
registry.delete(callback);
if (0 === registry.size) {
handlers.delete(eventName);
}
});
}
const getSessionId = (): string | null => null;
const dispatch = (eventName: string, payload: unknown): void => {
const registry = handlers.get(eventName);
if (!registry) {
return;
}
registry.forEach((handler) => handler(payload));
};
const handleVisibilityChange = () => {
if (document.hidden) {
wasHidden.value = true;
return;
}
if (true === wasHidden.value && false === isConnected.value) {
if (null !== reconnectTimeout.value) {
clearTimeout(reconnectTimeout.value);
reconnectTimeout.value = null;
}
reconnectTimeout.value = setTimeout(() => {
if (false === isConnected.value) {
console.debug('[SocketStore] Page visible after background, reconnecting...');
reconnect();
}
reconnectTimeout.value = null;
}, 100);
}
wasHidden.value = false;
};
const setupVisibilityListener = () => {
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
};
const cleanupVisibilityListener = () => {
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (null !== reconnectTimeout.value) {
clearTimeout(reconnectTimeout.value);
reconnectTimeout.value = null;
}
};
const scheduleReconnect = () => {
if (true === manualDisconnect.value || true === isConnected.value) {
return;
}
if (reconnectAttempts.value >= 50) {
return;
}
if (null !== reconnectTimeout.value) {
return;
}
reconnectTimeout.value = setTimeout(() => {
reconnectAttempts.value += 1;
reconnectTimeout.value = null;
connect();
}, 5000);
};
const reconnect = () => {
if (true === isConnected.value) {
return;
}
connect();
connectionStatus.value = 'connecting';
};
const disconnect = () => {
manualDisconnect.value = true;
if (null === socket.value) {
return;
}
socket.value.close();
socket.value = null;
isConnected.value = false;
connectionStatus.value = 'disconnected';
cleanupVisibilityListener();
};
const buildWsUrl = (): string => {
const runtimeConfig = getRuntimeConfig();
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '');
const wsPath = `${basePath}/ws?_=${Date.now()}`;
const configuredBase = runtimeConfig.public.wss?.trim();
if (configuredBase) {
return new URL(wsPath, configuredBase).toString();
}
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws';
return new URL(wsPath, `${scheme}://${window.location.host}`).toString();
};
const connect = () => {
const runtimeConfig = getRuntimeConfig();
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
return;
}
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
return;
}
manualDisconnect.value = false;
connectionStatus.value = 'connecting';
socket.value = new WebSocket(buildWsUrl());
if ('development' === runtimeConfig.public?.APP_ENV) {
window.ws = socket.value;
}
socket.value.addEventListener('open', () => {
isConnected.value = true;
connectionStatus.value = 'connected';
error.value = null;
error_count.value = 0;
reconnectAttempts.value = 0;
dispatch('connect', null);
});
socket.value.addEventListener('close', () => {
isConnected.value = false;
connectionStatus.value = 'disconnected';
error.value = 'Disconnected from server.';
dispatch('disconnect', null);
scheduleReconnect();
});
socket.value.addEventListener('error', () => {
isConnected.value = false;
connectionStatus.value = 'disconnected';
error.value = 'Connection error: Unknown error';
error_count.value += 1;
dispatch('connect_error', { message: 'Unknown error' });
scheduleReconnect();
});
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
let payload: WebSocketEnvelope | null = null;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (!payload?.event || 'string' != typeof payload.event) {
return;
}
let data = payload.data;
if ('string' === typeof data) {
try {
data = JSON.parse(data);
} catch {
data = payload.data;
}
}
dispatch(payload.event, data);
});
setupVisibilityListener();
};
on('connect', () => getConfig().loadConfig(false));
on('connected', () => {
error.value = null;
getConfig().loadConfig(false);
});
on('item_added', (data: WSEP['item_added']) => {
const queueState = getQueueState();
const toast = getToast();
queueState.add(data.data._id, data.data);
toast.success(`Item queued: ${ag(queueState.get(data.data._id, {} as StoreItem), 'title')}`);
});
on(
['log_info', 'log_success', 'log_warning', 'log_error'],
(event, data: WSEP['log_info']) => {
const toast = getToast();
const message =
'string' === typeof data?.message
? data.message
: String((data?.data as Record<string, unknown>)?.message ?? '');
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {};
switch (event) {
case 'log_info':
toast.info(message, extra);
break;
case 'log_success':
toast.success(message, extra);
break;
case 'log_warning':
toast.warning(message, extra);
break;
case 'log_error':
toast.error(message, extra);
break;
}
},
true,
);
on('item_cancelled', (data: WSEP['item_cancelled']) => {
const queueState = getQueueState();
const toast = getToast();
const id = data.data._id;
if (true !== queueState.has(id)) {
return;
}
toast.warning(`Download cancelled: ${ag(queueState.get(id, {} as StoreItem), 'title')}`);
if (true === queueState.has(id)) {
queueState.remove(id);
}
});
on('item_deleted', (data: WSEP['item_deleted']) => {
const queueState = getQueueState();
const id = data.data._id;
if (true === queueState.has(id)) {
queueState.remove(id);
}
});
on('item_updated', (data: WSEP['item_updated']) => {
const queueState = getQueueState();
const id = data.data._id;
if (true === queueState.has(id)) {
queueState.update(id, data.data);
}
});
on('item_moved', (data: WSEP['item_moved']) => {
const queueState = getQueueState();
const to = data.data.to;
const id = data.data.item._id;
if ('queue' === to) {
queueState.add(id, data.data.item);
}
if ('history' === to) {
if (true === queueState.has(id)) {
queueState.remove(id);
}
}
});
on(
['paused', 'resumed'],
(event, data: WSEP['paused']) => {
const config = getConfig();
const toast = getToast();
const pausedState = Boolean(data.data.paused);
config.update('paused', pausedState);
if ('resumed' === event) {
toast.success('Download queue resumed.');
return;
}
toast.warning('Download queue paused.', { timeout: 10000 });
},
true,
);
on('config_update', (data: WSEP['config_update']) => {
const config = getConfig();
const configUpdate = data.data as ConfigUpdatePayload;
if (!configUpdate) {
return;
}
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data);
});
const appSocketApi = proxyRefs({
connect,
reconnect,
disconnect,
on,
off,
emit,
isConnected,
getSessionId,
connectionStatus: readonly(connectionStatus),
error: readonly(error),
error_count: readonly(error_count),
});
export const useAppSocket = () => appSocketApi;

View file

@ -1,7 +1,6 @@
import { ref, readonly, computed, toRaw } from 'vue';
import { useNotification } from '~/composables/useNotification';
import { useConfigStore } from '~/stores/ConfigStore';
import { request, parse_api_error, sTrim, encodePath } from '~/utils';
import type { FileItem, Pagination } from '~/types/filebrowser';
@ -50,7 +49,7 @@ const handleError = (error: unknown): void => {
};
const buildQueryParams = (page?: number): string => {
const config = useConfigStore();
const config = useYtpConfig();
const params = new URLSearchParams();
params.set('page', String(page ?? pagination.value.page));
params.set('per_page', String(config.app.default_pagination || 50));

View file

@ -1,4 +1,5 @@
import { useStorage } from '@vueuse/core';
import { useNotificationCenter } from '~/composables/useNotificationCenter';
import { getNuxtToastManager } from '~/utils/nuxtToastManager';
export type notificationType = 'info' | 'success' | 'warning' | 'error';
@ -70,7 +71,7 @@ const sendMessage = (
message: string,
opts?: notificationOptions,
): void => {
const notificationStore = useNotificationStore();
const notificationStore = useNotificationCenter();
const useToastNotification =
!window.isSecureContext ||
@ -115,7 +116,7 @@ const sendMessage = (
};
const notify = (type: notificationType, message: string, opts?: notificationOptions): void => {
const notificationStore = useNotificationStore();
const notificationStore = useNotificationCenter();
if (!opts) {
opts = {};

View file

@ -0,0 +1,115 @@
import { computed, proxyRefs } from 'vue';
import { useStorage } from '@vueuse/core';
import type { notification, notificationType } from '~/composables/useNotification';
const NOTIFICATION_META: Record<notificationType, { level: number; color: string; icon: string }> =
{
error: { level: 3, color: 'error', icon: 'i-lucide-triangle-alert' },
warning: { level: 2, color: 'warning', icon: 'i-lucide-circle-alert' },
success: { level: 1, color: 'success', icon: 'i-lucide-badge-check' },
info: { level: 0, color: 'info', icon: 'i-lucide-info' },
};
const notifications = useStorage<notification[]>('notifications', []);
const unreadCount = computed<number>(() => notifications.value.filter((n) => !n.seen).length);
const severityLevel = computed<notificationType | null>(() => {
const unread = notifications.value.filter((n) => !n.seen);
if (0 === unread.length) {
return null;
}
return unread.reduce((highest, current) =>
NOTIFICATION_META[current.level].level > NOTIFICATION_META[highest.level].level
? current
: highest,
).level;
});
const severityColor = computed<string>(() => {
const level = severityLevel.value;
return level ? NOTIFICATION_META[level].color : '';
});
const severityIcon = computed<string>(() => {
const level = severityLevel.value;
return level ? NOTIFICATION_META[level].icon : '';
});
const sortedNotifications = computed<notification[]>(() => {
return [...notifications.value].sort((left, right) => {
const severityDiff = NOTIFICATION_META[right.level].level - NOTIFICATION_META[left.level].level;
if (0 !== severityDiff) {
return severityDiff;
}
return new Date(right.created).getTime() - new Date(left.created).getTime();
});
});
const add = (level: notificationType, message: string, seen: boolean = false): string => {
const id = Array.from(window.crypto.getRandomValues(new Uint8Array(14 / 2)), (dec: number) =>
dec.toString(16).padStart(2, '0'),
).join('');
notifications.value.unshift({
id,
message,
level,
seen,
created: new Date(),
});
if (notifications.value.length > 99) {
notifications.value.length = 99;
}
return id;
};
const clear = (): void => {
notifications.value = [];
};
const markAllRead = (): void => {
notifications.value.forEach((item) => {
item.seen = true;
});
};
const markRead = (id: string): void => {
const entry = notifications.value.find((item) => item.id === id);
if (!entry) {
return;
}
entry.seen = true;
};
const get = (id: string): notification | undefined =>
notifications.value.find((item) => item.id === id);
const remove = (id: string): void => {
notifications.value = notifications.value.filter((item) => item.id !== id);
};
const notificationCenterApi = proxyRefs({
notifications,
unreadCount,
severityLevel,
severityColor,
severityIcon,
sortedNotifications,
add,
get,
markAllRead,
clear,
markRead,
remove,
});
export const useNotificationCenter = () => notificationCenterApi;

View file

@ -1,5 +1,6 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { useYtpConfig } from '~/composables/useYtpConfig';
import type { Preset } from '~/types/presets';
import { prettyName } from '~/utils';
@ -19,7 +20,7 @@ export const usePresetOptions = (
source?: MaybeRefOrGetter<Preset[] | readonly Preset[] | undefined>,
options: UsePresetOptionsOptions = {},
) => {
const config = useConfigStore();
const config = useYtpConfig();
const presets = computed<Preset[]>(() => {
const items = source ? toValue(source) : config.presets;

View file

@ -0,0 +1,219 @@
import { proxyRefs, reactive, toRefs } from 'vue';
import { useStorage } from '@vueuse/core';
import type { ConfigState } from '~/types/config';
import type { DLField } from '~/types/dl_fields';
import type { Preset } from '~/types/presets';
import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
import { useQueueState } from '~/composables/useQueueState';
import { request } from '~/utils';
let last_reload = 0;
const CONFIG_TTL = 10;
const state = reactive<ConfigState>({
showForm: useStorage('showForm', true),
app: {
download_path: '/downloads',
remove_files: false,
ui_update_title: true,
output_template: '',
ytdlp_version: '',
max_workers: 20,
max_workers_per_extractor: 2,
default_preset: 'default',
instance_title: null,
console_enabled: false,
browser_control_enabled: false,
file_logging: false,
is_native: false,
app_version: '',
app_commit_sha: '',
app_build_date: '',
app_branch: '',
started: 0,
app_env: 'production',
simple_mode: false,
default_pagination: 50,
check_for_updates: true,
new_version: '',
yt_new_version: '',
},
presets: [
{
name: 'default',
description: 'Default preset',
folder: '',
template: '',
cookies: '',
cli: '',
default: true,
priority: 0,
},
],
dl_fields: [],
folders: [],
ytdlp_options: [],
paused: false,
is_loaded: false,
is_loading: false,
});
const loadConfig = async (force: boolean = false) => {
if (state.is_loading) {
return;
}
const now = Date.now();
if (state.is_loaded && !force && last_reload > 0) {
const age = (now - last_reload) / 1000;
if (age < CONFIG_TTL) {
return;
}
}
state.is_loaded = false;
state.is_loading = true;
try {
const resp = await request('/api/system/configuration', { timeout: 10 });
if (!resp.ok) {
return;
}
const data = await resp.json();
const queueState = useQueueState();
if ('number' === typeof data.history_count) {
delete data.history_count;
}
if (data.queue) {
queueState.addAll(data.queue);
delete data.queue;
}
setAll(data);
state.is_loaded = true;
last_reload = now;
} catch (e: any) {
console.error(`Failed to load configuration: ${e}`);
} finally {
state.is_loading = false;
}
};
const add = (key: string, value: any) => {
if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
state[parentKey][subKey] = value;
return;
}
(state as any)[key] = value;
};
const get = (key: string, defaultValue: any = null): any => {
if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
const parent = state[parentKey] as any;
return parent?.[subKey] ?? defaultValue;
}
return (state as any)[key] ?? defaultValue;
};
const isLoaded = () => state.is_loaded;
const update = add;
const getAll = (): ConfigState => state;
const setAll = (data: Record<string, any>) => {
Object.keys(data).forEach((key) => {
if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
const parent = state[parentKey] as any;
parent[subKey] = data[key];
return;
}
(state as any)[key] = data[key];
});
state.is_loaded = true;
};
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets'];
if (!supportedFeatures.includes(feature)) {
return;
}
if ('presets' === feature) {
const item = data as Preset;
const current = get(feature, []) as Array<Preset>;
if ('create' === action) {
current.push(item);
return;
}
if ('delete' === action) {
const index = current.findIndex((i) => i.id === item.id);
if (-1 !== index) {
current.splice(index, 1);
}
return;
}
if ('update' === action) {
const target = current.find((i) => i.id === item.id);
if (target) {
Object.assign(target, item);
}
return;
}
return;
}
if ('dl_fields' === feature) {
const item = data as DLField;
const current = get(feature, []) as Array<DLField>;
if ('create' === action) {
current.push(item);
return;
}
if ('delete' === action) {
const index = current.findIndex((i) => i.id === item.id);
if (-1 !== index) {
current.splice(index, 1);
}
return;
}
if ('update' === action) {
const target = current.find((i) => i.id === item.id);
if (target) {
Object.assign(target, item);
}
return;
}
if ('replace' === action) {
state.dl_fields = data as Array<DLField>;
}
}
};
const ytpConfigApi = proxyRefs({
...toRefs(state),
add,
get,
update,
getAll,
setAll,
isLoaded,
patch,
loadConfig,
});
export const useYtpConfig = () => ytpConfigApi;

View file

@ -1,7 +1,11 @@
<template>
<UApp :toaster="toasterConfig">
<Transition name="shell-mode" mode="out-in">
<div v-if="simpleMode" key="simple" class="shell-stage flex flex-col">
<div
v-if="simpleMode"
key="simple"
class="shell-stage flex flex-col bg-default/95 backdrop-blur-sm"
>
<UAlert
v-if="showConnectionBanner"
color="warning"
@ -517,8 +521,8 @@ type SwipeMode = 'open' | 'close';
const MOBILE_SIDEBAR_EDGE_WIDTH = 32;
const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64;
const socket = useSocketStore();
const config = useConfigStore();
const socket = useAppSocket();
const config = useYtpConfig();
const route = useRoute();
const colorMode = useColorMode();
const loadedImage = ref();

View file

@ -153,12 +153,15 @@
</p>
</div>
<Pager
<UPagination
v-if="pagination.total_pages > 1"
:page="pagination.page"
:last_page="pagination.total_pages"
:isLoading="isLoading"
@navigate="handlePageChange"
:total="pagination.total"
:items-per-page="pagination.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="handlePageChange"
/>
<div
@ -481,12 +484,15 @@
description="You can enable rename, delete, move, and create directory controls by setting YTP_BROWSER_CONTROL_ENABLED=true and restarting the application."
/>
<Pager
<UPagination
v-if="pagination.total_pages > 1"
:page="pagination.page"
:last_page="pagination.total_pages"
:isLoading="isLoading"
@navigate="handlePageChange"
:total="pagination.total"
:items-per-page="pagination.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="handlePageChange"
/>
<UModal
@ -536,7 +542,7 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
const route = useRoute();
const toast = useNotification();
const config = useConfigStore();
const config = useYtpConfig();
const dialog = useDialog();
const browser = useBrowser();

View file

@ -190,7 +190,7 @@ import { request, ucFirst, uri } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const toast = useNotification();
const config = useConfigStore();
const config = useYtpConfig();
const pageShell = requirePageShell('changelog');
useHead({ title: 'CHANGELOG' });

View file

@ -82,12 +82,15 @@
</div>
</div>
<Pager
<UPagination
v-if="paging?.total_pages > 1"
:page="paging.page"
:last_page="paging.total_pages"
:isLoading="isLoading"
@navigate="navigatePage"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="navigatePage"
/>
<div

View file

@ -338,7 +338,7 @@ let terminalResizeObserver: ResizeObserver | null = null;
let didInitialRender = false;
let renderedChunkCount = 0;
const config = useConfigStore();
const config = useYtpConfig();
const toast = useNotification();
const dialog = useDialog();
const consoleSession = useConsoleSession();

View file

@ -82,12 +82,15 @@
</div>
</div>
<Pager
<UPagination
v-if="paging?.total_pages > 1"
:page="paging.page"
:last_page="paging.total_pages"
:isLoading="isLoading"
@navigate="navigatePage"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="navigatePage"
/>
<div

View file

@ -173,7 +173,7 @@ const FILTER_CONTEXT_REGEX = /context:(\d+)/;
let scrollTimeout: NodeJS.Timeout | null = null;
const toast = useNotification();
const config = useConfigStore();
const config = useYtpConfig();
const route = useRoute();
const pageShell = requirePageShell('logs');

View file

@ -95,12 +95,15 @@
</div>
</div>
<Pager
<UPagination
v-if="paging?.total_pages > 1"
:page="paging.page"
:last_page="paging.total_pages"
:isLoading="isLoading"
@navigate="navigatePage"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="navigatePage"
/>
<div

View file

@ -468,7 +468,7 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
const presetsStore = usePresets();
const config = useConfigStore();
const config = useYtpConfig();
const box = useConfirm();
const editor = usePresetEditor();
const pageShell = requirePageShell('presets');

View file

@ -718,9 +718,9 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
const box = useConfirm();
const toast = useNotification();
const config = useConfigStore();
const socket = useSocketStore();
const stateStore = useStateStore();
const config = useYtpConfig();
const socket = useAppSocket();
const stateStore = useQueueState();
const pageShell = requirePageShell('tasks');
const { confirmDialog } = useDialog();
const sessionCache = useSessionCache();

View file

@ -1,227 +0,0 @@
import { useStorage } from '@vueuse/core';
import type { ConfigState } from '~/types/config';
import type { DLField } from '~/types/dl_fields';
import type { Preset } from '~/types/presets';
import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
import { request } from '~/utils';
let last_reload = 0;
const CONFIG_TTL = 10;
export const useConfigStore = defineStore('config', () => {
const state = reactive<ConfigState>({
showForm: useStorage('showForm', true),
app: {
download_path: '/downloads',
remove_files: false,
ui_update_title: true,
output_template: '',
ytdlp_version: '',
max_workers: 20,
max_workers_per_extractor: 2,
default_preset: 'default',
instance_title: null,
console_enabled: false,
browser_control_enabled: false,
file_logging: false,
is_native: false,
app_version: '',
app_commit_sha: '',
app_build_date: '',
app_branch: '',
started: 0,
app_env: 'production',
simple_mode: false,
default_pagination: 50,
check_for_updates: true,
new_version: '',
yt_new_version: '',
},
presets: [
{
name: 'default',
description: 'Default preset',
folder: '',
template: '',
cookies: '',
cli: '',
default: true,
priority: 0,
},
],
dl_fields: [],
folders: [],
ytdlp_options: [],
paused: false,
is_loaded: false,
is_loading: false,
});
const loadConfig = async (force: boolean = false) => {
if (state.is_loading) {
return;
}
const now = Date.now();
if (state.is_loaded && !force && last_reload > 0) {
const age = (now - last_reload) / 1000;
if (age < CONFIG_TTL) {
return;
}
}
state.is_loaded = false;
state.is_loading = true;
try {
const resp = await request('/api/system/configuration', { timeout: 10 });
if (!resp.ok) {
return;
}
const data = await resp.json();
const stateStore = useStateStore();
if ('number' === typeof data.history_count) {
stateStore.setHistoryCount(data.history_count);
delete data.history_count;
}
if (data.queue) {
stateStore.addAll('queue', data.queue);
delete data.queue;
}
setAll(data);
state.is_loaded = true;
last_reload = now;
} catch (e: any) {
console.error(`Failed to load configuration: ${e}`);
} finally {
state.is_loading = false;
}
};
const add = (key: string, value: any) => {
if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
state[parentKey][subKey] = value;
return;
}
(state as any)[key] = value;
};
const get = (key: string, defaultValue: any = null): any => {
if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
const parent = state[parentKey] as any;
return parent?.[subKey] ?? defaultValue;
}
return (state as any)[key] ?? defaultValue;
};
const isLoaded = () => state.is_loaded;
const update = add;
const getAll = (): ConfigState => state;
const setAll = (data: Record<string, any>) => {
Object.keys(data).forEach((key) => {
if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
const parent = state[parentKey] as any;
parent[subKey] = data[key];
return;
}
(state as any)[key] = data[key];
});
state.is_loaded = true;
};
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets'];
if (!supportedFeatures.includes(feature)) {
return;
}
if ('presets' === feature) {
const item = data as Preset;
const current = get(feature, []) as Array<Preset>;
if ('create' === action) {
current.push(item);
return;
}
if ('delete' === action) {
const index = current.findIndex((i) => i.id === item.id);
if (-1 !== index) {
current.splice(index, 1);
}
return;
}
if ('update' === action) {
const target = current.find((i) => i.id === item.id);
if (target) {
Object.assign(target, item);
}
return;
}
return;
}
if ('dl_fields' === feature) {
const item = data as DLField;
const current = get(feature, []) as Array<DLField>;
if ('create' === action) {
current.push(item);
return;
}
if ('delete' === action) {
const index = current.findIndex((i) => i.id === item.id);
if (-1 !== index) {
current.splice(index, 1);
}
return;
}
if ('update' === action) {
const target = current.find((i) => i.id === item.id);
if (target) {
Object.assign(target, item);
}
return;
}
if ('replace' === action) {
state.dl_fields = data as Array<DLField>;
return;
}
return;
}
};
return {
...toRefs(state),
add,
get,
update,
getAll,
setAll,
isLoaded,
patch,
loadConfig,
} as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & {
add: typeof add;
get: typeof get;
update: typeof update;
getAll: typeof getAll;
setAll: typeof setAll;
patch: typeof patch;
isLoaded: typeof isLoaded;
loadConfig: typeof loadConfig;
};
});

View file

@ -1,96 +0,0 @@
import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core';
import type { notification, notificationType } from '~/composables/useNotification';
const _map: Record<notificationType, { level: number; color: string; icon: string }> = {
error: { level: 3, color: 'error', icon: 'i-lucide-triangle-alert' },
warning: { level: 2, color: 'warning', icon: 'i-lucide-circle-alert' },
success: { level: 1, color: 'success', icon: 'i-lucide-badge-check' },
info: { level: 0, color: 'info', icon: 'i-lucide-info' },
};
export const useNotificationStore = defineStore('notifications', () => {
const notifications = useStorage<notification[]>('notifications', []);
const unreadCount = computed<number>(() => notifications.value.filter((n) => !n.seen).length);
const severityLevel = computed<notificationType | null>(() => {
const unread = notifications.value.filter((n) => !n.seen);
if (0 === unread.length) {
return null;
}
return unread.reduce((h, n) => (_map[n.level].level > _map[h.level].level ? n : h)).level;
});
const severityColor = computed<string>(() => {
const level = severityLevel.value;
return level ? _map[level].color : '';
});
const severityIcon = computed<string>(() => {
const level = severityLevel.value;
return level ? _map[level].icon : '';
});
const sortedNotifications = computed<notification[]>(() => {
return [...notifications.value].sort((a, b) => {
const severityDiff = _map[b.level].level - _map[a.level].level;
if (0 !== severityDiff) {
return severityDiff;
}
return new Date(b.created).getTime() - new Date(a.created).getTime();
});
});
const add = (level: notificationType, message: string, seen: boolean = false): string => {
const id = Array.from(window.crypto.getRandomValues(new Uint8Array(14 / 2)), (dec: any) =>
dec.toString(16).padStart(2, '0'),
).join('');
notifications.value.unshift({
id: id,
message,
level,
seen: seen,
created: new Date(),
});
if (notifications.value.length > 99) {
notifications.value.length = 99;
}
return id;
};
const clear = () => (notifications.value = []);
const markAllRead = () => notifications.value.forEach((n) => (n.seen = true));
const markRead = (id: string) => {
const n = notifications.value.find((n) => n.id === id);
if (!n) {
return;
}
n.seen = true;
};
const get = (id: string): notification | undefined =>
notifications.value.find((n) => n.id === id);
const remove = (id: string) =>
(notifications.value = notifications.value.filter((n) => n.id !== id));
return {
notifications,
unreadCount,
severityLevel,
severityColor,
severityIcon,
sortedNotifications,
add,
get,
markAllRead,
clear,
markRead,
remove,
};
});

View file

@ -1,404 +0,0 @@
import { ref, readonly } from 'vue';
import { defineStore } from 'pinia';
import type { StoreItem } from '~/types/store';
import type {
ConfigUpdatePayload,
WebSocketClientEmits,
WebSocketEnvelope,
WSEP as WSEP,
} from '~/types/sockets';
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
type SocketHandler = (...args: unknown[]) => void;
type HandlerRegistry = Map<SocketHandler, SocketHandler>;
type KnownEvent = keyof WSEP;
export const useSocketStore = defineStore('socket', () => {
const runtimeConfig = useRuntimeConfig();
const config = useConfigStore();
const stateStore = useStateStore();
const toast = useNotification();
const socket = ref<WebSocket | null>(null);
const isConnected = ref<boolean>(false);
const connectionStatus = ref<connectionStatus>('disconnected');
const error = ref<string | null>(null);
const error_count = ref<number>(0);
const wasHidden = ref<boolean>(false);
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
const manualDisconnect = ref<boolean>(false);
const reconnectAttempts = ref<number>(0);
const handlers = new Map<string, HandlerRegistry>();
const emit = <K extends keyof WebSocketClientEmits>(
event: K,
data: WebSocketClientEmits[K],
): void => {
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
return;
}
socket.value.send(JSON.stringify({ event, data }));
};
function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void;
function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void;
function on<K extends KnownEvent>(
event: K | K[],
callback: (event: K, payload: WSEP[K]) => void,
withEvent: true,
): void;
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void;
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
const events = Array.isArray(event) ? event : [event];
events.forEach((eventName) => {
if (!handlers.has(eventName)) {
handlers.set(eventName, new Map());
}
const registry = handlers.get(eventName) as HandlerRegistry;
const handler =
true === withEvent
? (payload: unknown) => callback(eventName, payload)
: (payload: unknown) => callback(payload);
registry.set(callback, handler);
});
}
function off<K extends KnownEvent>(event: K, callback?: (payload: WSEP[K]) => void): void;
function off<K extends KnownEvent>(event: K[], callback?: (payload: WSEP[K]) => void): void;
function off(event: string | string[], callback?: SocketHandler): void;
function off(event: string | string[], callback?: SocketHandler): void {
const events = Array.isArray(event) ? event : [event];
events.forEach((eventName) => {
const registry = handlers.get(eventName);
if (!registry) {
return;
}
if (!callback) {
registry.clear();
handlers.delete(eventName);
return;
}
registry.delete(callback);
if (0 === registry.size) {
handlers.delete(eventName);
}
});
}
const getSessionId = (): string | null => null;
const dispatch = (eventName: string, payload: unknown): void => {
const registry = handlers.get(eventName);
if (!registry) {
return;
}
registry.forEach((handler) => handler(payload));
};
const handleVisibilityChange = () => {
if (document.hidden) {
wasHidden.value = true;
return;
}
if (true === wasHidden.value && false === isConnected.value) {
if (null !== reconnectTimeout.value) {
clearTimeout(reconnectTimeout.value);
reconnectTimeout.value = null;
}
reconnectTimeout.value = setTimeout(() => {
if (false === isConnected.value) {
console.debug('[SocketStore] Page visible after background, reconnecting...');
reconnect();
}
reconnectTimeout.value = null;
}, 100);
}
wasHidden.value = false;
};
const setupVisibilityListener = () => {
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
};
const cleanupVisibilityListener = () => {
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (null !== reconnectTimeout.value) {
clearTimeout(reconnectTimeout.value);
reconnectTimeout.value = null;
}
};
const scheduleReconnect = () => {
if (true === manualDisconnect.value || true === isConnected.value) {
return;
}
if (reconnectAttempts.value >= 50) {
return;
}
if (null !== reconnectTimeout.value) {
return;
}
reconnectTimeout.value = setTimeout(() => {
reconnectAttempts.value += 1;
reconnectTimeout.value = null;
connect();
}, 5000);
};
const reconnect = () => {
if (true === isConnected.value) {
return;
}
connect();
connectionStatus.value = 'connecting';
};
const disconnect = () => {
manualDisconnect.value = true;
if (null === socket.value) {
return;
}
socket.value.close();
socket.value = null;
isConnected.value = false;
connectionStatus.value = 'disconnected';
cleanupVisibilityListener();
};
const buildWsUrl = (): string => {
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '');
const wsPath = `${basePath}/ws?_=${Date.now()}`;
const configuredBase = runtimeConfig.public.wss?.trim();
if (configuredBase) {
return new URL(wsPath, configuredBase).toString();
}
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws';
return new URL(wsPath, `${scheme}://${window.location.host}`).toString();
};
const connect = () => {
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
return;
}
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
return;
}
manualDisconnect.value = false;
connectionStatus.value = 'connecting';
socket.value = new WebSocket(buildWsUrl());
if ('development' === runtimeConfig.public?.APP_ENV) {
window.ws = socket.value;
}
socket.value.addEventListener('open', () => {
isConnected.value = true;
connectionStatus.value = 'connected';
error.value = null;
error_count.value = 0;
reconnectAttempts.value = 0;
dispatch('connect', null);
});
socket.value.addEventListener('close', () => {
isConnected.value = false;
connectionStatus.value = 'disconnected';
error.value = 'Disconnected from server.';
dispatch('disconnect', null);
scheduleReconnect();
});
socket.value.addEventListener('error', () => {
isConnected.value = false;
connectionStatus.value = 'disconnected';
error.value = 'Connection error: Unknown error';
error_count.value += 1;
dispatch('connect_error', { message: 'Unknown error' });
scheduleReconnect();
});
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
let payload: WebSocketEnvelope | null = null;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (!payload?.event || 'string' != typeof payload.event) {
return;
}
let data = payload.data;
if ('string' === typeof data) {
try {
data = JSON.parse(data);
} catch {
data = payload.data;
}
}
dispatch(payload.event, data);
});
setupVisibilityListener();
};
on('connect', () => config.loadConfig(false));
on('connected', () => {
error.value = null;
config.loadConfig(false);
});
on('item_added', (data: WSEP['item_added']) => {
stateStore.add('queue', data.data._id, data.data);
toast.success(
`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`,
);
});
on(
['log_info', 'log_success', 'log_warning', 'log_error'],
(event, data: WSEP['log_info']) => {
const message =
'string' === typeof data?.message
? data.message
: String((data?.data as Record<string, unknown>)?.message ?? '');
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {};
switch (event) {
case 'log_info':
toast.info(message, extra);
break;
case 'log_success':
toast.success(message, extra);
break;
case 'log_warning':
toast.warning(message, extra);
break;
case 'log_error':
toast.error(message, extra);
break;
}
},
true,
);
on('item_cancelled', (data: WSEP['item_cancelled']) => {
const id = data.data._id;
if (true !== stateStore.has('queue', id)) {
return;
}
toast.warning(
`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`,
);
if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id);
}
});
on('item_deleted', (data: WSEP['item_deleted']) => {
const id = data.data._id;
if (true !== stateStore.has('history', id)) {
return;
}
stateStore.remove('history', id);
});
on('item_updated', (data: WSEP['item_updated']) => {
const id = data.data._id;
if (true === stateStore.has('history', id)) {
stateStore.update('history', id, data.data);
return;
}
if (true === stateStore.has('queue', id)) {
stateStore.update('queue', id, data.data);
}
});
on('item_moved', (data: WSEP['item_moved']) => {
const to = data.data.to;
const id = data.data.item._id;
if ('queue' === to) {
if (true === stateStore.has('history', id)) {
stateStore.remove('history', id);
}
stateStore.add('queue', id, data.data.item);
}
if ('history' === to) {
if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id);
}
stateStore.add('history', id, data.data.item);
}
});
on(
['paused', 'resumed'],
(event, data: WSEP['paused']) => {
const pausedState = Boolean(data.data.paused);
config.update('paused', pausedState);
if ('resumed' === event) {
toast.success('Download queue resumed.');
return;
}
toast.warning('Download queue paused.', { timeout: 10000 });
},
true,
);
on('config_update', (data: WSEP['config_update']) => {
const configUpdate = data.data as ConfigUpdatePayload;
if (!configUpdate) {
return;
}
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data);
});
return {
connect,
reconnect,
disconnect,
on,
off,
emit,
isConnected,
getSessionId,
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
error: readonly(error) as Readonly<Ref<string | null>>,
error_count: readonly(error_count) as Readonly<Ref<number>>,
};
});

View file

@ -1,531 +0,0 @@
import { defineStore } from 'pinia';
import type { item_request } from '~/types/item';
import type { StoreItem } from '~/types/store';
import { request } from '~/utils';
type StateType = 'queue' | 'history';
type KeyType = string;
interface State {
queue: Record<KeyType, StoreItem>;
history: Record<KeyType, StoreItem>;
pagination: {
page: number;
per_page: number;
total: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
isLoaded: boolean;
isLoading: boolean;
};
}
export const useStateStore = defineStore('state', () => {
const state = reactive<State>({
queue: {},
history: {},
pagination: {
page: 1,
per_page: 50,
total: 0,
total_pages: 0,
has_next: false,
has_prev: false,
isLoaded: false,
isLoading: false,
},
});
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
if ('history' === type && state.pagination.total > 0) {
state.pagination.total += 1;
}
state[type][key] = value;
};
const update = (type: StateType, key: KeyType, value: StoreItem): void => {
state[type][key] = value;
};
const remove = (type: StateType, key: KeyType): void => {
if (!state[type][key]) {
return;
}
if ('history' === type && state.pagination.total > 0) {
state.pagination.total -= 1;
}
const { [key]: _, ...rest } = state[type];
state[type] = rest;
};
const get = (
type: StateType,
key: KeyType,
defaultValue: StoreItem | null = null,
): StoreItem | null => {
return state[type][key] || defaultValue;
};
const has = (type: StateType, key: KeyType): boolean => {
return !!state[type][key];
};
const clearAll = (type: StateType): void => {
state[type] = {};
if ('queue' === type) {
return;
}
state.pagination.total = 0;
state.pagination.page = 1;
state.pagination.total_pages = 0;
state.pagination.has_next = false;
state.pagination.has_prev = false;
};
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
state[type] = data;
};
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
if (true === has(fromType, key)) {
remove(fromType, key);
}
add(toType, key, get(fromType, key, {} as StoreItem) as StoreItem);
};
const count = (type: StateType): number => {
if ('history' === type && state.pagination.total > 0) {
return state.pagination.total;
}
return Object.keys(state[type]).length;
};
const loadPaginated = async (
type: StateType,
page: number = 1,
per_page: number = 50,
order: 'ASC' | 'DESC' = 'DESC',
append: boolean = false,
status?: string,
): Promise<void> => {
if ('history' !== type) {
throw new Error('Pagination is only supported for history type');
}
state.pagination.isLoading = true;
try {
const params: Record<string, string> = {
type: 'done',
page: page.toString(),
per_page: per_page.toString(),
order,
};
if (status) {
params.status = status;
}
const search = new URLSearchParams(params);
const response = await request(`/api/history?${search}`);
const data = await response.json();
if (data.pagination) {
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false };
const items: Record<KeyType, StoreItem> = {};
for (const item of data.items || []) {
items[item._id] = item;
}
state[type] = append ? { ...state[type], ...items } : items;
}
} catch (error) {
console.error(`Failed to load ${type} page ${page}:`, error);
state.pagination.isLoading = false;
}
};
const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
if ('history' !== type) {
throw new Error('Pagination is only supported for history type');
}
if (!state.pagination.has_next || state.pagination.isLoading) {
return;
}
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page, 'DESC', append);
};
const loadPreviousPage = async (type: StateType): Promise<void> => {
if ('history' !== type) {
throw new Error('Pagination is only supported for history type');
}
if (!state.pagination.has_prev || state.pagination.isLoading) {
return;
}
await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page);
};
const reloadCurrentPage = async (type: StateType): Promise<void> => {
if ('history' !== type) {
throw new Error('Pagination is only supported for history type');
}
if (!state.pagination.isLoaded) {
return;
}
await loadPaginated(type, state.pagination.page, state.pagination.per_page);
};
const getPagination = () => state.pagination;
const setHistoryCount = (count: number) => {
state.pagination.total = count;
if (count > 0 && !state.pagination.isLoaded) {
state.pagination.isLoaded = false;
}
};
/**
* Load queue data from REST API.
* Uses the /live endpoint to get real-time in-memory data with live progress.
*
* @returns Promise that resolves when queue is loaded
*/
const loadQueue = async (): Promise<void> => {
try {
const response = await request('/api/history/live');
const data = (await response.json()) as {
queue: Record<KeyType, StoreItem>;
history_count: number;
};
state.queue = data.queue || {};
setHistoryCount(data.history_count);
} catch (error) {
console.error('Failed to load queue:', error);
throw error;
}
};
/**
* Add a download using WebSocket if connected, fallback to REST API.
*
* @param data - Download data (url, preset, folder, etc.)
* @returns Promise that resolves when download is added
*/
const addDownload = async (data: item_request): Promise<void> => {
const socket = useSocketStore();
const toast = useNotification();
if (socket.isConnected) {
socket.emit('add_url', data);
return;
}
try {
const response = await request('/api/history/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
toast.error(error.error || 'Failed to add download');
throw new Error(error.error || 'Failed to add download');
}
toast.success('Download added successfully');
await loadQueue();
} catch (error) {
console.error('Failed to add download:', error);
if (error instanceof Error && !error.message.includes('Failed to add download')) {
toast.error('Failed to add download');
}
throw error;
}
};
/**
* Start one or more downloads using WebSocket if connected, fallback to REST API.
*
* @param ids - Array of item IDs to start
* @returns Promise that resolves when items are started
*/
const startItems = async (ids: string[]): Promise<void> => {
const socket = useSocketStore();
const toast = useNotification();
if (socket.isConnected) {
ids.forEach((id) => socket.emit('item_start', id));
return;
}
try {
const response = await request('/api/history/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!response.ok) {
const error = await response.json();
toast.error(error.error || 'Failed to start items');
throw new Error(error.error || 'Failed to start items');
}
const result = await response.json();
for (const id of ids) {
if ('started' === result[id]) {
const item = get('queue', id);
if (item) {
update('queue', id, { ...item, auto_start: true });
}
}
}
toast.success(`Started ${ids.length} item${1 === ids.length ? '' : 's'}`);
} catch (error) {
console.error('Failed to start items:', error);
if (error instanceof Error && !error.message.includes('Failed to start items')) {
toast.error('Failed to start items');
}
throw error;
}
};
/**
* Pause one or more downloads using WebSocket if connected, fallback to REST API.
*
* @param ids - Array of item IDs to pause
* @returns Promise that resolves when items are paused
*/
const pauseItems = async (ids: string[]): Promise<void> => {
const socket = useSocketStore();
const toast = useNotification();
if (socket.isConnected) {
ids.forEach((id) => socket.emit('item_pause', id));
return;
}
try {
const response = await request('/api/history/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!response.ok) {
const error = await response.json();
toast.error(error.error || 'Failed to pause items');
throw new Error(error.error || 'Failed to pause items');
}
const result = await response.json();
for (const id of ids) {
if ('paused' === result[id]) {
const item = get('queue', id);
if (item) {
update('queue', id, { ...item, auto_start: false });
}
}
}
toast.success(`Paused ${ids.length} item${1 === ids.length ? '' : 's'}`);
} catch (error) {
console.error('Failed to pause items:', error);
if (error instanceof Error && !error.message.includes('Failed to pause items')) {
toast.error('Failed to pause items');
}
throw error;
}
};
/**
* Cancel one or more downloads using WebSocket if connected, fallback to REST API.
*
* @param ids - Array of item IDs to cancel
* @returns Promise that resolves when items are cancelled
*/
const cancelItems = async (ids: string[]): Promise<void> => {
const socket = useSocketStore();
const toast = useNotification();
if (socket.isConnected) {
ids.forEach((id) => socket.emit('item_cancel', id));
return;
}
const itemsToMove: Record<string, StoreItem> = {};
for (const id of ids) {
const item = get('queue', id);
if (item) {
itemsToMove[id] = { ...item };
}
}
try {
const response = await request('/api/history/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!response.ok) {
const error = await response.json();
toast.error(error.error || 'Failed to cancel items');
throw new Error(error.error || 'Failed to cancel items');
}
const result = await response.json();
for (const id of ids) {
if ('ok' === result[id] && itemsToMove[id]) {
remove('queue', id);
const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem;
add('history', id, cancelledItem);
}
}
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`);
} catch (error) {
console.error('Failed to cancel items:', error);
if (error instanceof Error && !error.message.includes('Failed to cancel items')) {
toast.error('Failed to cancel items');
}
throw error;
}
};
/**
* Remove items using WebSocket if connected, fallback to REST API.
*
* @param type - The store type ('queue' or 'history')
* @param ids - Array of item IDs to remove
* @param removeFile - Whether to remove files from disk (default: false)
* @returns Promise that resolves when items are removed
*/
const removeItems = async (
type: StateType,
ids: string[],
removeFile: boolean = false,
): Promise<void> => {
const socket = useSocketStore();
const toast = useNotification();
if (socket.isConnected) {
ids.forEach((id) => socket.emit('item_delete', { id, remove_file: removeFile }));
return;
}
try {
await deleteItems(type, { ids, removeFile });
} catch (error) {
console.error('Failed to remove items:', error);
toast.error('Failed to remove items');
throw error;
}
};
/**
* Delete items by specific IDs or status filter.
*
* @param type - The store type ('queue' or 'history')
* @param options - Delete options
* @param options.ids - Array of item IDs to delete (if provided, status is ignored)
* @param options.status - Status filter (e.g., 'finished' or '!finished')
* @param options.removeFile - Whether to remove files from disk (default: true)
*
* @returns Number of items deleted
*/
const deleteItems = async (
type: StateType,
options: { ids?: string[]; status?: string; removeFile?: boolean } = {},
): Promise<number> => {
const { ids, status, removeFile = true } = options;
if (!ids && !status) {
throw new Error('Either ids or status filter must be provided');
}
try {
const body: Record<string, unknown> = {
type: type === 'queue' ? 'queue' : 'done',
remove_file: removeFile,
};
if (ids) {
body.ids = ids;
}
if (status) {
body.status = status;
}
const response = await request('/api/history', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const result = (await response.json()) as {
items: Record<string, string>;
deleted: number;
error?: string;
message?: string;
};
if (result.error || result.message || !response.ok) {
throw new Error(result.error || result.message || 'Failed to delete items.');
}
for (const id of Object.keys(result.items)) {
remove(type, id);
}
return result.deleted;
} catch (error) {
console.error(`Failed to delete items:`, error);
throw error;
}
};
return {
...toRefs(state),
add,
update,
remove,
get,
has,
clearAll,
addAll,
move,
count,
loadPaginated,
loadNextPage,
loadPreviousPage,
reloadCurrentPage,
getPagination,
setHistoryCount,
loadQueue,
addDownload,
startItems,
pauseItems,
cancelItems,
removeItems,
deleteItems,
};
});

View file

@ -142,45 +142,6 @@ const dEvent = (eventName: string, detail: Record<string, any> = {}): boolean =>
return window.dispatchEvent(new CustomEvent(eventName, { detail }));
};
/**
* Generate a pagination list based on current page, total pages, and delta range.
*
* @param current - The current active page number.
* @param last - The last page number.
* @param delta - How many pages to show before/after the current page.
* @returns An array of pagination entries including optional gaps.
*/
const makePagination = (
current: number,
last: number,
delta: number = 5,
): Array<{ page: number; text: string; selected: boolean }> => {
const pagination: Array<{ page: number; text: string; selected: boolean }> = [];
if (last < 2) {
return pagination;
}
const strR = '-'.repeat(9 + `${last}`.length);
const left = current - delta;
const right = current + delta + 1;
for (let i = 1; i <= last; i++) {
if (1 === i || last === i || (i >= left && i < right)) {
if (i === left && i > 2) {
pagination.push({ page: 0, text: strR, selected: false });
}
pagination.push({ page: i, text: `Page #${i}`, selected: i === current });
if (i === right - 1 && i < last - 1) {
pagination.push({ page: 0, text: strR, selected: false });
}
}
}
return pagination;
};
/**
* Safely encode a path string for use in a URL.
*
@ -1027,7 +988,6 @@ export {
r,
copyText,
dEvent,
makePagination,
encodePath,
request,
removeANSIColors,

View file

@ -5,41 +5,39 @@
"": {
"name": "nuxt-app",
"dependencies": {
"@iconify-json/lucide": "latest",
"@microsoft/fetch-event-source": "latest",
"@nuxt/eslint": "latest",
"@nuxt/eslint-config": "latest",
"@nuxt/ui": "latest",
"@pinia/nuxt": "latest",
"@vueuse/core": "latest",
"@vueuse/nuxt": "latest",
"@xterm/addon-fit": "latest",
"@xterm/xterm": "latest",
"cron-parser": "latest",
"cronstrue": "latest",
"hls.js": "latest",
"marked": "latest",
"marked-alert": "latest",
"marked-base-url": "latest",
"marked-gfm-heading-id": "latest",
"moment": "latest",
"nuxt": "latest",
"pinia": "latest",
"tailwindcss": "latest",
"vue": "latest",
"vue-router": "latest",
"@iconify-json/lucide": "^1.2.102",
"@microsoft/fetch-event-source": "^2.0.1",
"@nuxt/eslint": "^1.15.2",
"@nuxt/eslint-config": "^1.15.2",
"@nuxt/ui": "^4.6.1",
"@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"cron-parser": "^5.5.0",
"cronstrue": "^3.14.0",
"hls.js": "^1.6.16",
"marked": "^18.0.0",
"marked-alert": "^2.1.2",
"marked-base-url": "^1.1.9",
"marked-gfm-heading-id": "^4.1.4",
"moment": "^2.30.1",
"nuxt": "^4.4.2",
"tailwindcss": "^4.2.2",
"vue": "^3.5.32",
"vue-router": "^5.0.4",
},
"devDependencies": {
"@types/bun": "latest",
"@types/jsdom": "latest",
"@types/node": "latest",
"@typescript-eslint/parser": "latest",
"eslint": "latest",
"jsdom": "latest",
"oxfmt": "latest",
"typescript": "latest",
"vue-eslint-parser": "latest",
"vue-tsc": "latest",
"@types/bun": "^1.3.12",
"@types/jsdom": "^28.0.1",
"@types/node": "25.6.0",
"@typescript-eslint/parser": "^8.58.2",
"eslint": "^10.2.0",
"jsdom": "^29.0.2",
"oxfmt": "^0.45.0",
"typescript": "^6.0.2",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.6",
},
},
},
@ -512,8 +510,6 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@pinia/nuxt": ["@pinia/nuxt@0.11.3", "", { "dependencies": { "@nuxt/kit": "^4.2.0" }, "peerDependencies": { "pinia": "^3.0.4" } }, "sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
@ -838,7 +834,7 @@
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw=="],
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
"@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
"@vue/devtools-core": ["@vue/devtools-core@8.1.0", "", { "dependencies": { "@vue/devtools-kit": "^8.1.0", "@vue/devtools-shared": "^8.1.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-LvD1VgDpoHmYL00IgKRLKktF6SsPAb0yaV8wB8q2jRwsAWvqhS8+vsMLEGKNs7uoKyymXhT92dhxgf/wir6YGQ=="],
@ -2262,7 +2258,7 @@
"@vue/compiler-ssr/@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="],
"@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
"@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
"@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
@ -2386,6 +2382,8 @@
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"pinia/@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
@ -2448,8 +2446,6 @@
"vue-eslint-parser/espree": ["espree@11.1.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
"vue-router/mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="],
"whatwg-url/@exodus/bytes": ["@exodus/bytes@1.14.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="],
@ -2580,14 +2576,12 @@
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
"@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
"@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"@vue/language-core/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
@ -2628,10 +2622,10 @@
"nuxt/vue/@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
"nuxt/vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
"nuxt/vue-router/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
"pinia/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"rollup-plugin-visualizer/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
@ -2666,8 +2660,6 @@
"vue-eslint-parser/espree/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
"vue-router/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"@dxup/nuxt/@nuxt/kit/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
@ -2742,8 +2734,6 @@
"nuxt/mlly/pkg-types/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
"nuxt/vue-router/mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"nuxt/vue-router/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
@ -2764,6 +2754,14 @@
"nuxt/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"unplugin-vue-components/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
@ -2786,12 +2784,6 @@
"vaul-vue/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
"vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
"vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"vue-router/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"vue-router/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"vue-router/mlly/pkg-types/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
@ -2848,12 +2840,6 @@
"nuxt/mlly/pkg-types/mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"nuxt/vue-router/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"nuxt/vue/@vue/compiler-dom/@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],

View file

@ -59,11 +59,12 @@ export default defineNuxtConfig({
},
pageTransition: { name: 'page' },
},
modules: ['@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt', '@nuxt/eslint'],
modules: ['@nuxt/ui', '@vueuse/nuxt', '@nuxt/eslint'],
icon: {
provider: 'none',
fallbackToApi: false,
clientBundle: {
icons: isProd ? [] : ['lucide:book'],
scan: {
globInclude: ['app/**/*.{vue,ts,js}', 'node_modules/@nuxt/ui/dist/shared/ui*.mjs'],
globExclude: ['dist', 'build', 'coverage', 'test', 'tests', '.*'],

View file

@ -26,7 +26,6 @@
"@nuxt/eslint": "^1.15.2",
"@nuxt/eslint-config": "^1.15.2",
"@nuxt/ui": "^4.6.1",
"@pinia/nuxt": "^0.11.3",
"@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"@xterm/addon-fit": "^0.11.0",
@ -40,7 +39,6 @@
"marked-gfm-heading-id": "^4.1.4",
"moment": "^2.30.1",
"nuxt": "^4.4.2",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.2",
"vue": "^3.5.32",
"vue-router": "^5.0.4"

View file

@ -1,8 +1,24 @@
import { describe, expect, it } from 'bun:test'
import { beforeAll, describe, expect, it, mock } from 'bun:test'
import { usePresetOptions } from '~/composables/usePresetOptions'
import type { Preset } from '~/types/presets'
let configState = {
presets: [] as Preset[],
app: {
download_path: '/downloads',
},
}
mock.module('~/composables/useYtpConfig', () => ({
useYtpConfig: () => configState,
}))
let usePresetOptions: typeof import('~/composables/usePresetOptions').usePresetOptions
beforeAll(async () => {
;({ usePresetOptions } = await import('~/composables/usePresetOptions'))
})
const buildPreset = (name: string, isDefault: boolean): Preset => ({
name,
default: isDefault,
@ -15,12 +31,12 @@ const buildPreset = (name: string, isDefault: boolean): Preset => ({
})
const setConfigStore = (presets: Preset[]) => {
;(globalThis as any).useConfigStore = () => ({
configState = {
presets,
app: {
download_path: '/downloads',
},
})
}
}
describe('usePresetOptions', () => {

View file

@ -326,15 +326,6 @@ describe('data conversion helpers', () => {
expect(utils.decode(encoded)).toEqual(payload);
});
it('makePagination builds a ranged pagination list', () => {
const pages = utils.makePagination(5, 10, 1);
const selected = pages.find((page: any) => page.selected);
expect(selected?.page).toBe(5);
expect(pages.length).toBeGreaterThan(0);
expect(pages[0]?.page).toBe(1);
expect(pages[pages.length - 1]?.page).toBe(10);
});
it('getQueryParams parses query strings', () => {
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' });
});