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; history: Record; 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({ 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): 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 => { if ('history' !== type) { throw new Error('Pagination is only supported for history type'); } state.pagination.isLoading = true; try { const params: Record = { 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 = {}; 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 => { 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 => { 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 => { 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 => { try { const response = await request('/api/history/live'); const data = (await response.json()) as { queue: Record; 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 => { 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 => { 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 => { 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 => { const socket = useSocketStore(); const toast = useNotification(); if (socket.isConnected) { ids.forEach((id) => socket.emit('item_cancel', id)); return; } const itemsToMove: Record = {}; 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 => { 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 => { const { ids, status, removeFile = true } = options; if (!ids && !status) { throw new Error('Either ids or status filter must be provided'); } try { const body: Record = { 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; 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, }; });