ytptube/ui/app/stores/StateStore.ts
Copilot b9c5b0b7ad
Decouple queue from config endpoint; load via paginated API with correct ordering (#2)
* Initial plan

* Refactor: remove queue from /api/system/configuration, use existing paginated /api/history endpoint for queue data

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Remove accidentally committed package-lock.json and add to gitignore

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Simplify loadMoreQueue to use loadNextPage for reduced duplication

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Order in-progress downloads oldest-to-newest (order of processing)

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Fix queue ordering: remove client-side timestamp sort, rely on backend created_at ASC

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Revert "Fix queue ordering: remove client-side timestamp sort, rely on backend created_at ASC"

This reverts commit 9b53112e0050a45e4db95c85064577d22ed47a50. Changes not needed

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>
Co-authored-by: Jesse Bate <noreply@idump.me>
2026-03-09 15:05:22 +10:30

526 lines
14 KiB
TypeScript

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 PaginationState {
page: number;
per_page: number;
total: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
isLoaded: boolean;
isLoading: boolean;
}
interface State {
queue: Record<KeyType, StoreItem>;
history: Record<KeyType, StoreItem>;
pagination: PaginationState;
queue_pagination: PaginationState;
}
const defaultPagination = (): PaginationState => ({
page: 1,
per_page: 50,
total: 0,
total_pages: 0,
has_next: false,
has_prev: false,
isLoaded: false,
isLoading: false,
});
export const useStateStore = defineStore('state', () => {
const state = reactive<State>({
queue: {},
history: {},
pagination: defaultPagination(),
queue_pagination: defaultPagination(),
});
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 paginationFor = (type: StateType): PaginationState => {
return type === 'queue' ? state.queue_pagination : state.pagination;
};
const clearAll = (type: StateType): void => {
state[type] = {};
const pg = paginationFor(type);
pg.total = 0;
pg.page = 1;
pg.total_pages = 0;
pg.has_next = false;
pg.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 => {
const pg = paginationFor(type);
if (pg.total > 0) {
return pg.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> => {
const pg = paginationFor(type);
pg.isLoading = true;
const apiType = type === 'queue' ? 'queue' : 'done';
try {
const params: Record<string, string> = {
type: apiType,
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) {
Object.assign(pg, { ...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);
pg.isLoading = false;
}
};
const orderFor = (type: StateType): 'ASC' | 'DESC' => {
return type === 'queue' ? 'ASC' : 'DESC';
};
const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
const pg = paginationFor(type);
if (!pg.has_next || pg.isLoading) {
return;
}
await loadPaginated(type, pg.page + 1, pg.per_page, orderFor(type), append);
};
const loadPreviousPage = async (type: StateType): Promise<void> => {
const pg = paginationFor(type);
if (!pg.has_prev || pg.isLoading) {
return;
}
await loadPaginated(type, pg.page - 1, pg.per_page, orderFor(type));
};
const reloadCurrentPage = async (type: StateType): Promise<void> => {
const pg = paginationFor(type);
if (!pg.isLoaded) {
return;
}
await loadPaginated(type, pg.page, pg.per_page, orderFor(type));
};
const getPagination = () => state.pagination;
const getQueuePagination = () => state.queue_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 using the paginated history endpoint.
*
* @returns Promise that resolves when queue is loaded
*/
const loadQueue = async (): Promise<void> => {
await loadPaginated('queue', 1, state.queue_pagination.per_page, 'ASC');
};
/**
* 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,
getQueuePagination,
setHistoryCount,
loadQueue,
addDownload,
startItems,
pauseItems,
cancelItems,
removeItems,
deleteItems,
};
});