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>
This commit is contained in:
Copilot 2026-03-09 15:05:22 +10:30 committed by GitHub
parent ee2bddcb81
commit b9c5b0b7ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 175 additions and 85 deletions

1
.gitignore vendored
View file

@ -40,3 +40,4 @@ version.txt
test_impl.py test_impl.py
./eslint.config.js ./eslint.config.js
.pytest_cache .pytest_cache
ui/package-lock.json

View file

@ -32,7 +32,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", "api/system/configuration", "system.configuration") @route("GET", "api/system/configuration", "system.configuration")
async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response: async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response:
""" """
Pause non-active downloads. Get the system configuration.
Args: Args:
queue (DownloadQueue): The download queue instance. queue (DownloadQueue): The download queue instance.
@ -55,7 +55,6 @@ async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder)
depth_limit=config.download_path_depth - 1, depth_limit=config.download_path_depth - 1,
), ),
"history_count": await queue.done.get_total_count(), "history_count": await queue.done.get_total_count(),
"queue": (await queue.get("queue"))["queue"],
}, },
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
dumps=encoder.encode, dumps=encoder.encode,

View file

@ -1,10 +1,52 @@
import json
import pytest import pytest
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from app.library.config import Config from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.UpdateChecker import UpdateChecker from app.library.UpdateChecker import UpdateChecker
from app.routes.api.system import check_updates from app.routes.api.system import check_updates, system_config
class TestSystemConfigEndpoint:
"""Tests for the system configuration endpoint."""
def setup_method(self):
"""Reset singletons before each test."""
Config._reset_singleton()
@pytest.mark.asyncio
async def test_system_config_does_not_return_queue(self):
"""Test that the configuration endpoint does not include queue data."""
config = Config.get_instance()
encoder = Encoder()
mock_queue = MagicMock()
mock_queue.is_paused.return_value = False
mock_done = AsyncMock()
mock_done.get_total_count = AsyncMock(return_value=0)
mock_queue.done = mock_done
with (
patch("app.routes.api.system.Presets") as mock_presets_cls,
patch("app.routes.api.system.DLFields") as mock_dl_fields_cls,
patch("app.routes.api.system.list_folders", return_value=[]),
):
mock_presets_cls.get_instance.return_value.get_all.return_value = []
mock_dl_fields_cls.get_instance.return_value.get_all_serialized = AsyncMock(return_value=[])
response = await system_config(mock_queue, config, encoder)
assert 200 == response.status
body = json.loads(response.body.decode("utf-8"))
assert "queue" not in body, "Configuration response should not include queue data"
assert "app" in body, "Configuration response should include app data"
assert "paused" in body, "Configuration response should include paused status"
assert "history_count" in body, "Configuration response should include history_count"
assert "presets" in body, "Configuration response should include presets"
assert "dl_fields" in body, "Configuration response should include dl_fields"
assert "folders" in body, "Configuration response should include folders"
class TestCheckUpdatesEndpoint: class TestCheckUpdatesEndpoint:

View file

@ -470,6 +470,21 @@
</div> </div>
</div> </div>
<div
v-if="queuePagination.isLoaded && queuePagination.page < queuePagination.total_pages"
ref="loadMoreTrigger"
class="columns is-centered mt-4"
>
<div class="column is-narrow">
<div v-if="queuePagination.isLoading" class="has-text-centered">
<span class="icon is-large has-text-info">
<i class="fas fa-spinner fa-pulse fa-2x"></i>
</span>
<p class="is-size-7 has-text-grey mt-2">Loading more items...</p>
</div>
</div>
</div>
<div class="modal is-active" v-if="embed_url"> <div class="modal is-active" v-if="embed_url">
<div class="modal-background" @click="embed_url = ''"></div> <div class="modal-background" @click="embed_url = ''"></div>
<div class="modal-content is-unbounded-model"> <div class="modal-content is-unbounded-model">
@ -481,7 +496,7 @@
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment'; import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage, useIntersectionObserver } from '@vueuse/core';
import type { StoreItem } from '~/types/store'; import type { StoreItem } from '~/types/store';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { deepIncludes } from '~/utils'; import { deepIncludes } from '~/utils';
@ -514,12 +529,15 @@ const selectedElms = ref<string[]>([]);
const masterSelectAll = ref(false); const masterSelectAll = ref(false);
const embed_url = ref(''); const embed_url = ref('');
const isRefreshing = ref(false); const isRefreshing = ref(false);
const loadMoreTrigger = ref<HTMLElement | null>(null);
const autoRefreshInterval = ref<NodeJS.Timeout | null>(null); const autoRefreshInterval = ref<NodeJS.Timeout | null>(null);
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true); const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true);
const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000); const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000);
const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value); const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value);
const queuePagination = computed(() => stateStore.getQueuePagination());
const refreshQueue = async () => { const refreshQueue = async () => {
isRefreshing.value = true; isRefreshing.value = true;
try { try {
@ -531,6 +549,14 @@ const refreshQueue = async () => {
} }
}; };
const loadMoreQueue = async () => {
try {
await stateStore.loadNextPage('queue', true);
} catch {
toast.error('Failed to load more queue items');
}
};
const startAutoRefresh = () => { const startAutoRefresh = () => {
if (autoRefreshInterval.value) { if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value); clearInterval(autoRefreshInterval.value);
@ -573,7 +599,14 @@ watch(autoRefreshEnabled, (enabled) => {
} }
}); });
onMounted(() => { onMounted(async () => {
if (!queuePagination.value.isLoaded) {
try {
await stateStore.loadPaginated('queue', 1, config.app.default_pagination, 'ASC', false);
} catch {
toast.error('Failed to load queue');
}
}
if (!socket.isConnected && autoRefreshEnabled.value) { if (!socket.isConnected && autoRefreshEnabled.value) {
startAutoRefresh(); startAutoRefresh();
} }
@ -581,6 +614,20 @@ onMounted(() => {
onBeforeUnmount(() => stopAutoRefresh()); onBeforeUnmount(() => stopAutoRefresh());
useIntersectionObserver(
loadMoreTrigger,
([entry]) => {
if (
entry?.isIntersecting &&
!queuePagination.value.isLoading &&
queuePagination.value.page < queuePagination.value.total_pages
) {
loadMoreQueue();
}
},
{ threshold: 0.5 },
);
watch(masterSelectAll, (value) => { watch(masterSelectAll, (value) => {
if (value) { if (value) {
selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id); selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id);
@ -590,13 +637,14 @@ watch(masterSelectAll, (value) => {
}); });
const filteredItems = computed<StoreItem[]>(() => { const filteredItems = computed<StoreItem[]>(() => {
const items = Object.values(stateStore.queue)
.slice()
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
const q = props.query?.toLowerCase(); const q = props.query?.toLowerCase();
if (!q) { if (!q) {
return Object.values(stateStore.queue); return items;
} }
return Object.values(stateStore.queue).filter((i: StoreItem) => return items.filter((i: StoreItem) => deepIncludes(i, q, new WeakSet()));
deepIncludes(i, q, new WeakSet()),
);
}); });
const hasSelected = computed(() => 0 < selectedElms.value.length); const hasSelected = computed(() => 0 < selectedElms.value.length);

View file

@ -407,6 +407,8 @@ const addInProgress = ref<boolean>(false);
const showExtras = ref<boolean>(false); const showExtras = ref<boolean>(false);
const isRefreshing = ref<boolean>(false); const isRefreshing = ref<boolean>(false);
const queuePaginationInfo = computed(() => stateStore.getQueuePagination());
const refreshQueue = async (): Promise<void> => { const refreshQueue = async (): Promise<void> => {
if (isRefreshing.value) { if (isRefreshing.value) {
return; return;
@ -426,7 +428,7 @@ const paginationInfo = computed(() => stateStore.getPagination());
const queueItems = computed<StoreItem[]>(() => const queueItems = computed<StoreItem[]>(() =>
Object.values(queue.value ?? {}) Object.values(queue.value ?? {})
.slice() .slice()
.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)), .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)),
); );
const historyEntries = computed<StoreItem[]>(() => { const historyEntries = computed<StoreItem[]>(() => {
const items = Object.values(history.value ?? {}); const items = Object.values(history.value ?? {});
@ -857,7 +859,7 @@ const connectionStatusColor = computed(() => {
} }
}); });
// Load history via API on mount // Load queue and history via API on mount
onMounted(async () => { onMounted(async () => {
const route = useRoute(); const route = useRoute();
@ -870,6 +872,14 @@ onMounted(async () => {
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
} }
if (!queuePaginationInfo.value.isLoaded) {
try {
await stateStore.loadPaginated('queue', 1, DEFAULT_PAGE_SIZE, 'ASC');
} catch (error) {
console.error('Failed to load queue on mount:', error);
}
}
if (!paginationInfo.value.isLoaded) { if (!paginationInfo.value.isLoaded) {
try { try {
await stateStore.loadPaginated('history', 1, DEFAULT_PAGE_SIZE, 'DESC'); await stateStore.loadPaginated('history', 1, DEFAULT_PAGE_SIZE, 'DESC');

View file

@ -85,11 +85,6 @@ export const useConfigStore = defineStore('config', () => {
delete data.history_count; delete data.history_count;
} }
if (data.queue) {
stateStore.addAll('queue', data.queue);
delete data.queue;
}
setAll(data); setAll(data);
state.is_loaded = true; state.is_loaded = true;
last_reload = now; last_reload = now;

View file

@ -6,10 +6,7 @@ import { request } from '~/utils';
type StateType = 'queue' | 'history'; type StateType = 'queue' | 'history';
type KeyType = string; type KeyType = string;
interface State { interface PaginationState {
queue: Record<KeyType, StoreItem>;
history: Record<KeyType, StoreItem>;
pagination: {
page: number; page: number;
per_page: number; per_page: number;
total: number; total: number;
@ -18,14 +15,16 @@ interface State {
has_prev: boolean; has_prev: boolean;
isLoaded: boolean; isLoaded: boolean;
isLoading: boolean; isLoading: boolean;
};
} }
export const useStateStore = defineStore('state', () => { interface State {
const state = reactive<State>({ queue: Record<KeyType, StoreItem>;
queue: {}, history: Record<KeyType, StoreItem>;
history: {}, pagination: PaginationState;
pagination: { queue_pagination: PaginationState;
}
const defaultPagination = (): PaginationState => ({
page: 1, page: 1,
per_page: 50, per_page: 50,
total: 0, total: 0,
@ -34,7 +33,14 @@ export const useStateStore = defineStore('state', () => {
has_prev: false, has_prev: false,
isLoaded: false, isLoaded: false,
isLoading: 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 => { const add = (type: StateType, key: KeyType, value: StoreItem): void => {
@ -73,17 +79,18 @@ export const useStateStore = defineStore('state', () => {
return !!state[type][key]; return !!state[type][key];
}; };
const paginationFor = (type: StateType): PaginationState => {
return type === 'queue' ? state.queue_pagination : state.pagination;
};
const clearAll = (type: StateType): void => { const clearAll = (type: StateType): void => {
state[type] = {}; state[type] = {};
if ('queue' === type) { const pg = paginationFor(type);
return; pg.total = 0;
} pg.page = 1;
pg.total_pages = 0;
state.pagination.total = 0; pg.has_next = false;
state.pagination.page = 1; pg.has_prev = false;
state.pagination.total_pages = 0;
state.pagination.has_next = false;
state.pagination.has_prev = false;
}; };
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => { const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
@ -99,8 +106,9 @@ export const useStateStore = defineStore('state', () => {
}; };
const count = (type: StateType): number => { const count = (type: StateType): number => {
if ('history' === type && state.pagination.total > 0) { const pg = paginationFor(type);
return state.pagination.total; if (pg.total > 0) {
return pg.total;
} }
return Object.keys(state[type]).length; return Object.keys(state[type]).length;
}; };
@ -113,15 +121,14 @@ export const useStateStore = defineStore('state', () => {
append: boolean = false, append: boolean = false,
status?: string, status?: string,
): Promise<void> => { ): Promise<void> => {
if ('history' !== type) { const pg = paginationFor(type);
throw new Error('Pagination is only supported for history type'); pg.isLoading = true;
}
state.pagination.isLoading = true; const apiType = type === 'queue' ? 'queue' : 'done';
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
type: 'done', type: apiType,
page: page.toString(), page: page.toString(),
per_page: per_page.toString(), per_page: per_page.toString(),
order, order,
@ -137,7 +144,7 @@ export const useStateStore = defineStore('state', () => {
const data = await response.json(); const data = await response.json();
if (data.pagination) { if (data.pagination) {
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false }; Object.assign(pg, { ...data.pagination, isLoaded: true, isLoading: false });
const items: Record<KeyType, StoreItem> = {}; const items: Record<KeyType, StoreItem> = {};
for (const item of data.items || []) { for (const item of data.items || []) {
items[item._id] = item; items[item._id] = item;
@ -147,47 +154,47 @@ export const useStateStore = defineStore('state', () => {
} }
} catch (error) { } catch (error) {
console.error(`Failed to load ${type} page ${page}:`, error); console.error(`Failed to load ${type} page ${page}:`, error);
state.pagination.isLoading = false; 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 loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
if ('history' !== type) { const pg = paginationFor(type);
throw new Error('Pagination is only supported for history type');
}
if (!state.pagination.has_next || state.pagination.isLoading) { if (!pg.has_next || pg.isLoading) {
return; return;
} }
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page, 'DESC', append); await loadPaginated(type, pg.page + 1, pg.per_page, orderFor(type), append);
}; };
const loadPreviousPage = async (type: StateType): Promise<void> => { const loadPreviousPage = async (type: StateType): Promise<void> => {
if ('history' !== type) { const pg = paginationFor(type);
throw new Error('Pagination is only supported for history type');
}
if (!state.pagination.has_prev || state.pagination.isLoading) { if (!pg.has_prev || pg.isLoading) {
return; return;
} }
await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page); await loadPaginated(type, pg.page - 1, pg.per_page, orderFor(type));
}; };
const reloadCurrentPage = async (type: StateType): Promise<void> => { const reloadCurrentPage = async (type: StateType): Promise<void> => {
if ('history' !== type) { const pg = paginationFor(type);
throw new Error('Pagination is only supported for history type'); if (!pg.isLoaded) {
}
if (!state.pagination.isLoaded) {
return; return;
} }
await loadPaginated(type, state.pagination.page, state.pagination.per_page); await loadPaginated(type, pg.page, pg.per_page, orderFor(type));
}; };
const getPagination = () => state.pagination; const getPagination = () => state.pagination;
const getQueuePagination = () => state.queue_pagination;
const setHistoryCount = (count: number) => { const setHistoryCount = (count: number) => {
state.pagination.total = count; state.pagination.total = count;
if (count > 0 && !state.pagination.isLoaded) { if (count > 0 && !state.pagination.isLoaded) {
@ -196,25 +203,12 @@ export const useStateStore = defineStore('state', () => {
}; };
/** /**
* Load queue data from REST API. * Load queue data from REST API using the paginated history endpoint.
* Uses the /live endpoint to get real-time in-memory data with live progress.
* *
* @returns Promise that resolves when queue is loaded * @returns Promise that resolves when queue is loaded
*/ */
const loadQueue = async (): Promise<void> => { const loadQueue = async (): Promise<void> => {
try { await loadPaginated('queue', 1, state.queue_pagination.per_page, 'ASC');
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;
}
}; };
/** /**
@ -519,6 +513,7 @@ export const useStateStore = defineStore('state', () => {
loadPreviousPage, loadPreviousPage,
reloadCurrentPage, reloadCurrentPage,
getPagination, getPagination,
getQueuePagination,
setHistoryCount, setHistoryCount,
loadQueue, loadQueue,
addDownload, addDownload,