Feat: Paginate history items to improve performance

This commit is contained in:
arabcoders 2025-11-13 19:28:10 +03:00
parent 1107567f86
commit 739d8e702f
13 changed files with 448 additions and 66 deletions

27
API.md
View file

@ -81,6 +81,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [Connection Events](#connection-events) - [Connection Events](#connection-events)
- [`connect` (Built-in)](#connect-built-in) - [`connect` (Built-in)](#connect-built-in)
- [`disconnect` (Built-in)](#disconnect-built-in) - [`disconnect` (Built-in)](#disconnect-built-in)
- [`configuration` (Server → Client)](#configuration-server--client)
- [`connected` (Server → Client)](#connected-server--client) - [`connected` (Server → Client)](#connected-server--client)
- [Subscription Events](#subscription-events) - [Subscription Events](#subscription-events)
- [`subscribe` (Client → Server)](#subscribe-client--server) - [`subscribe` (Client → Server)](#subscribe-client--server)
@ -450,7 +451,7 @@ or an error:
- `queue`: Returns only queue items (with pagination) - `queue`: Returns only queue items (with pagination)
- `done`: Returns only history items (with pagination) - `done`: Returns only history items (with pagination)
- `page` (optional): Page number (1-indexed). Default: `1`. Only used when `type != all` - `page` (optional): Page number (1-indexed). Default: `1`. Only used when `type != all`
- `per_page` (optional): Items per page. Default: `50`, Max: `200`. Only used when `type != all` - `per_page` (optional): Items per page. Default: `config.default_pagination`, Max: `200`. Only used when `type != all`
- `order` (optional): Sort order. Default: `DESC`. Only used when `type != all` - `order` (optional): Sort order. Default: `DESC`. Only used when `type != all`
- `DESC`: Newest items first (descending by creation date) - `DESC`: Newest items first (descending by creation date)
- `ASC`: Oldest items first (ascending by creation date) - `ASC`: Oldest items first (ascending by creation date)
@ -1733,25 +1734,35 @@ Fired when WebSocket connection is closed. No data payload.
socket.on('disconnect', (reason: string) => console.log('WebSocket disconnected:', reason)); socket.on('disconnect', (reason: string) => console.log('WebSocket disconnected:', reason));
``` ```
##### `connected` (Server → Client) ##### `configuration` (Server → Client)
Initial connection event with full application state. Sends the current application configuration.
**Data Fields**: **Data Fields**:
- `config`: Global configuration object - `config`: Global configuration object
- `queue`: Current download queue (array of items)
- `done`: Download history (array of completed items)
- `tasks`: Scheduled tasks
- `presets`: Available download presets - `presets`: Available download presets
- `dl_fields`: Available download fields - `dl_fields`: Available download fields
- `folders`: Directory structure for downloads
- `paused`: Queue pause status (boolean) - `paused`: Queue pause status (boolean)
**Example**:
```typescript
socket.on('connected', (data: string) => {
const json = JSON.parse(data);
console.log('Current configuration:', json.data.config);
});
```
##### `connected` (Server → Client)
When a client connects, this events sends the folder and current queue.
**Data Fields**:
- `queue`: Current download queue (array of items)
- `folders`: Directory structure for downloads
**Example**: **Example**:
```typescript ```typescript
socket.on('connected', (data: string) => { socket.on('connected', (data: string) => {
const json = JSON.parse(data); const json = JSON.parse(data);
const queueItems = json.data.queue || {}; const queueItems = json.data.queue || {};
const historyItems = json.data.done || {};
console.log('Connected with', Object.keys(queueItems).length, 'queued downloads'); console.log('Connected with', Object.keys(queueItems).length, 'queued downloads');
}); });
``` ```

1
FAQ.md
View file

@ -48,6 +48,7 @@ or the `environment:` section in `compose.yaml` file.
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` | | YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` | | YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
| YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` | | YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` |
| YTP_DEFAULT_PAGINATION | The default number of items per page for history. | `50` |
> [!NOTE] > [!NOTE]
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`. > To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.

View file

@ -195,6 +195,9 @@ class Config(metaclass=Singleton):
auto_clear_history_days: int = 0 auto_clear_history_days: int = 0
"""Number of days after which completed download history is automatically cleared. 0 to disable.""" """Number of days after which completed download history is automatically cleared. 0 to disable."""
default_pagination: int = 50
"""The default number of items per page for pagination."""
pictures_backends: list[str] = [ pictures_backends: list[str] = [
"https://unsplash.it/1920/1080?random", "https://unsplash.it/1920/1080?random",
"https://picsum.photos/1920/1080", "https://picsum.photos/1920/1080",
@ -234,6 +237,7 @@ class Config(metaclass=Singleton):
"download_path_depth", "download_path_depth",
"download_info_expires", "download_info_expires",
"auto_clear_history_days", "auto_clear_history_days",
"default_pagination",
) )
"The variables that are integers." "The variables that are integers."
@ -281,6 +285,7 @@ class Config(metaclass=Singleton):
"app_commit_sha", "app_commit_sha",
"app_build_date", "app_build_date",
"app_branch", "app_branch",
"default_pagination",
) )
"The variables that are relevant to the frontend." "The variables that are relevant to the frontend."

View file

@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
from aiohttp import web from aiohttp import web
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.Download import Download from app.library.Download import Download
from app.library.DownloadQueue import DownloadQueue from app.library.DownloadQueue import DownloadQueue
from app.library.encoder import Encoder from app.library.encoder import Encoder
@ -20,7 +21,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", r"api/history/", "items_list") @route("GET", r"api/history/", "items_list")
async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response: async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, config: Config) -> Response:
""" """
Get the history with optional pagination support. Get the history with optional pagination support.
@ -28,6 +29,7 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder) -
request (Request): The request object. request (Request): The request object.
queue (DownloadQueue): The download queue instance. queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance. encoder (Encoder): The encoder instance.
config (Config): The configuration instance.
Returns: Returns:
Response: The response object. Response: The response object.
@ -63,7 +65,7 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder) -
try: try:
page = int(request.query.get("page", 1)) page = int(request.query.get("page", 1))
per_page = int(request.query.get("per_page", 50)) per_page = int(request.query.get("per_page", config.default_pagination))
order = request.query.get("order", "DESC").upper() order = request.query.get("order", "DESC").upper()
except ValueError: except ValueError:
return web.json_response( return web.json_response(
@ -74,7 +76,7 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder) -
if page < 1: if page < 1:
return web.json_response(data={"error": "page must be >= 1."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "page must be >= 1."}, status=web.HTTPBadRequest.status_code)
if per_page < 1 or per_page > 200: if per_page < 1 or per_page > 1000:
return web.json_response( return web.json_response(
data={"error": "per_page must be between 1 and 1000."}, data={"error": "per_page must be between 1 and 1000."},
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,

View file

@ -44,7 +44,8 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
base=Path(config.download_path), base=Path(config.download_path),
depth_limit=config.download_path_depth - 1, depth_limit=config.download_path_depth - 1,
), ),
**queue.get(), "history_count": queue.done.get_total_count(),
**queue.get()["queue"],
}, },
title="Sending initial download data", title="Sending initial download data",
message=f"Sending initial download data to client '{sid}'.", message=f"Sending initial download data to client '{sid}'.",

View file

@ -2,19 +2,26 @@
<vTooltip @show="loadContent" @hide="stopTimer"> <vTooltip @show="loadContent" @hide="stopTimer">
<slot /> <slot />
<template #popper> <template #popper>
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span> <template v-if="error_msg">
<div v-else> <Message message_class="is-danger" :newStyle="true">
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1"> {{ error_msg }}
<div class="is-block" style="word-break: all;" v-if="props.title"> </Message>
<span style="font-size: 120%;">{{ props.title }}</span> </template>
<template v-else>
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
<div v-else>
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
<div class="is-block" style="word-break: all;" v-if="props.title">
<span style="font-size: 120%;">{{ props.title }}</span>
</div>
<figure :class="['image', thumbnail_ratio, 'is-hidden-mobile']">
<img @load="e => pImg(e)" :src="url" :alt="props.title" @error="clearCache"
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
</figure>
</div> </div>
<figure :class="['image', thumbnail_ratio, 'is-hidden-mobile']">
<img @load="e => pImg(e)" :src="url" :alt="props.title" @error="clearCache"
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
</figure>
</div> </div>
</div> </template>
</template> </template>
</vTooltip> </vTooltip>
</template> </template>
@ -36,6 +43,7 @@ const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'i
const url = ref<string | null>(null) const url = ref<string | null>(null)
const error = ref(false) const error = ref(false)
const isPreloading = ref(false) const isPreloading = ref(false)
const error_msg = ref<string | null>(null)
const loadTimer: ReturnType<typeof setTimeout> | null = null const loadTimer: ReturnType<typeof setTimeout> | null = null
const cancelRequest = new AbortController() const cancelRequest = new AbortController()
@ -54,7 +62,7 @@ const defaultLoader = async (): Promise<void> => {
const response = await request(props.image, { signal: cancelRequest.signal }) const response = await request(props.image, { signal: cancelRequest.signal })
if (!response.ok) { if (!response.ok) {
toast.error(`ImageView Request error. ${response.status}: ${response.statusText}`) error_msg.value = `ImageView Request error. ${response.status}: ${response.statusText}`
return return
} }

View file

@ -5,7 +5,8 @@
<i :class="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" /> <i :class="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</span> </span>
<span> <span>
History <span v-if="hasItems">({{ stateStore.count('history') }})</span> History
<span v-if="stateStore.count('history')">({{ stateStore.count('history') }})</span>
<span v-if="selectedElms.length > 0">&nbsp;- Selected: {{ selectedElms.length }}</span> <span v-if="selectedElms.length > 0">&nbsp;- Selected: {{ selectedElms.length }}</span>
</span> </span>
</span> </span>
@ -77,6 +78,34 @@
</span> </span>
</button> </button>
</div> </div>
<div class="column is-1-tablet">
<button type="button" class="button is-info is-fullwidth" @click="() => stateStore.reloadCurrentPage('history')"
:disabled="paginationInfo.isLoading" :class="{ 'is-loading': paginationInfo.isLoading }">
<span class="icon-text is-block">
<span class="icon"><i class="fas fa-refresh" /></span>
</span>
</button>
</div>
</div>
<div class="columns is-multiline" v-if="paginationInfo.isLoading && !hasItems">
<div class="column is-12">
<div class="message is-info">
<div class="message-body">
<span class="icon-text">
<span class="icon"> <i class="fa-solid fa-spinner fa-spin fa-2x" /> </span>
<span class="ml-3">Loading history...</span>
</span>
</div>
</div>
</div>
</div>
<div class="columns is-multiline" v-if="paginationInfo.isLoaded && paginationInfo.total_pages > 1">
<div class="column is-12 has-text-centered">
<Pager :page="paginationInfo.page" :last_page="paginationInfo.total_pages" :isLoading="paginationInfo.isLoading"
@navigate="navigateToPage" />
</div>
</div> </div>
<div class="columns is-multiline" v-if="'list' === display_style"> <div class="columns is-multiline" v-if="'list' === display_style">
@ -410,19 +439,30 @@
</LateLoader> </LateLoader>
</div> </div>
<div class="columns is-multiline" v-if="!hasItems"> <div class="columns is-multiline" v-if="!hasItems && !paginationInfo.isLoading">
<div class="column is-12"> <div class="column is-12">
<Message message_class="has-background-warning-90 has-text-dark" title="No results for downloaded items." <Message message_class="is-warning" title="Filter items" icon="fas fa-search" :useClose="true"
icon="fas fa-search" :useClose="true" @close="() => emitter('clear_search')" v-if="query"> @close="() => emitter('clear_search')" v-if="query" :newStyle="true">
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span> <span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
</Message> </Message>
<Message message_class="has-background-success-90 has-text-dark" title="No records in history." <Message message_class="is-warning" title="Page is empty." icon="fas fa-exclamation-triangle"
icon="fas fa-circle-check" v-else-if="socket.isConnected" /> v-else-if="socket.isConnected && paginationInfo.total" :new-style="true">
<span class="is-block">The current page has no items. Try navigating to a different page.</span>
</Message>
<Message message_class="is-success" title="No records in history."
icon="fas fa-circle-check" v-else-if="socket.isConnected" :new-style="true" />
<Message message_class="has-background-info-90 has-text-dark" title="Connecting.." icon="fas fa-spinner fa-spin" <Message message_class="has-background-info-90 has-text-dark" title="Connecting.." icon="fas fa-spinner fa-spin"
v-else /> v-else />
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="paginationInfo.isLoaded && paginationInfo.total_pages > 1">
<div class="column is-12 has-text-centered">
<Pager :page="paginationInfo.page" :last_page="paginationInfo.total_pages" :isLoading="paginationInfo.isLoading"
@navigate="navigateToPage" />
</div>
</div>
<div class="modal is-active" v-if="video_item"> <div class="modal is-active" v-if="video_item">
<div class="modal-background" @click="closeVideo"></div> <div class="modal-background" @click="closeVideo"></div>
<div class="modal-content is-unbounded-model"> <div class="modal-content is-unbounded-model">
@ -495,6 +535,92 @@ const dialog_confirm = ref<{
options: [], options: [],
}) })
const paginationInfo = computed(() => stateStore.getPagination())
const route = useRoute()
const router = useRouter()
const currentPageFromUrl = computed(() => {
const pageParam = route.query.page
if (!pageParam) {
return 1
}
const pageStr = Array.isArray(pageParam) ? pageParam[0] : pageParam
if (!pageStr) {
return 1
}
const page = parseInt(pageStr, 10)
return isNaN(page) || page < 1 ? 1 : page
})
const navigateToPage = async (page: number) => {
if (page < 1 || page > paginationInfo.value.total_pages || paginationInfo.value.isLoading) {
return
}
try {
await router.push({ query: { ...route.query, page: page.toString() } })
await stateStore.loadPaginated('history', page, config.app.default_pagination, 'DESC')
} catch (error) {
console.error('Failed to navigate to page:', error)
toast.error('Failed to load page')
}
}
watch(showCompleted, async isShown => {
if (isShown && !paginationInfo.value.isLoaded && socket.isConnected) {
try {
const pageToLoad = currentPageFromUrl.value
await stateStore.loadPaginated('history', pageToLoad, config.app.default_pagination, 'DESC')
} catch (error) {
console.error('Failed to load history:', error)
}
}
})
watch(() => route.query.page, async newPage => {
if (!showCompleted.value || !paginationInfo.value.isLoaded) {
return
}
const pageStr = newPage ? (Array.isArray(newPage) ? newPage[0] : newPage) : undefined
const page = pageStr ? parseInt(pageStr, 10) : 1
if (isNaN(page) || page < 1) {
return
}
if (page !== paginationInfo.value.page) {
try {
await stateStore.loadPaginated('history', page, config.app.default_pagination, 'DESC')
} catch (error) {
console.error('Failed to load page from URL:', error)
}
}
})
onMounted(async () => {
if (showCompleted.value && !paginationInfo.value.isLoaded && socket.isConnected) {
try {
const pageToLoad = currentPageFromUrl.value
await stateStore.loadPaginated('history', pageToLoad, config.app.default_pagination, 'DESC')
} catch (error) {
console.error('Failed to load history on mount:', error)
toast.error('Failed to load history')
}
}
})
watch(() => socket.isConnected, async connected => {
if (connected && showCompleted.value && !paginationInfo.value.isLoaded) {
try {
const pageToLoad = currentPageFromUrl.value
await stateStore.loadPaginated('history', pageToLoad, config.app.default_pagination, 'DESC')
} catch (error) {
console.error('Failed to load history after socket connection:', error)
}
}
})
const showThumbnails = computed(() => (props.thumbnails ?? true) && !hideThumbnail.value) const showThumbnails = computed(() => (props.thumbnails ?? true) && !hideThumbnail.value)
const playVideo = (item: StoreItem) => { video_item.value = item } const playVideo = (item: StoreItem) => { video_item.value = item }

View file

@ -1,45 +1,67 @@
<template> <template>
<div class="notification" :class="props.message_class"> <div :class="[newStyle ? 'message' : 'notification', message_class]">
<button class="delete" @click="emit('close')" v-if="!props.useToggle && props.useClose"></button> <button class="delete" @click="$emit('close')" v-if="!useToggle && useClose && !newStyle"></button>
<div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle">
<div @click="emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="props.useToggle">
<span class="icon"> <span class="icon">
<i class="fas" :class="{ 'fa-arrow-up': props.toggle, 'fa-arrow-down': !props.toggle }"></i> <i class="fas" :class="{'fa-arrow-up':toggle,'fa-arrow-down':!toggle}"></i>
</span> </span>
<span>{{ props.toggle ? 'Close' : 'Open' }}</span> <span>{{ toggle ? 'Close' : 'Open' }}</span>
</div> </div>
<div class="is-unselectable"
<div class="notification-title is-unselectable" :class="{ 'is-clickable': props.useToggle }" :class="{'is-clickable':useToggle, 'notification-title': !newStyle, 'message-header': newStyle}"
v-if="props.title || props.icon" @click="props.useToggle ? emit('toggle', props.toggle) : null"> v-if="title || icon"
<template v-if="props.icon"> @click="true === useToggle ? $emit('toggle', toggle): null">
<template v-if="icon">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i :class="props.icon"></i></span> <span class="icon"><i :class="icon"></i></span>
<span>{{ props.title }}</span> <span>{{ title }}</span>
</span> </span>
</template> </template>
<template v-else>{{ props.title }}</template> <template v-else>{{ title }}</template>
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose && newStyle"/>
</div> </div>
<div class="content is-text-break" v-if="false === useToggle || toggle"
<div class="notification-content content" v-if="!props.useToggle || props.toggle"> :class="{'notification-body': !newStyle, 'message-body': newStyle}">
<template v-if="props.message">{{ props.message }}</template> <template v-if="message">{{ message }}</template>
<slot /> <slot/>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps < { import {defineEmits, defineProps} from 'vue'
title?: string
icon?: string
message?: string
message_class?: string
useToggle?: boolean
toggle?: boolean
useClose?: boolean
} > ()
const emit = defineEmits < { withDefaults(defineProps<{
(e: 'toggle', value ?: boolean): void /** Title text for the notification */
title?: string | null
/** Icon class for the notification */
icon?: string | null
/** Main message content */
message?: string | null
/** CSS class for the notification */
message_class?: string
/** If true, show toggle button */
useToggle?: boolean
/** Current toggle state */
toggle?: boolean
/** If true, show close button */
useClose?: boolean,
newStyle?: boolean
}>(), {
title: null,
icon: null,
message: null,
message_class: 'is-info',
useToggle: false,
toggle: false,
useClose: false,
newStyle: false
})
defineEmits<{
/** Emitted when the toggle button is clicked */
(e: 'toggle', value?: boolean): void
/** Emitted when the close button is clicked */
(e: 'close'): void (e: 'close'): void
}> () }>()
</script> </script>

View file

@ -0,0 +1,70 @@
<template>
<div class="field is-grouped">
<div class="control">
<button rel="first" class="button" v-if="page !== 1" @click="changePage(1)" :disabled="isLoading"
:class="{'is-loading':isLoading}">
<span class="icon"><i class="fas fa-angle-double-left"></i></span>
</button>
</div>
<div class="control">
<button rel="prev" class="button" v-if="page > 1 && (page-1) !== 1" @click="changePage(page-1)"
:disabled="isLoading" :class="{'is-loading':isLoading}">
<span class="icon"><i class="fas fa-angle-left"></i></span>
</button>
</div>
<div class="control">
<div class="select">
<select id="pager_list" v-model="currentPage" @change="changePage(currentPage)" :disabled="isLoading">
<option v-for="(item, index) in makePagination(page, last_page)" :key="`pager-${index}`"
:value="item.page" :disabled="0 === item.page">
{{ item.text }}
</option>
</select>
</div>
</div>
<div class="control">
<button rel="next" class="button" v-if="page !== last_page && ( page + 1 ) !== last_page"
@click="changePage( page + 1 )" :disabled="isLoading" :class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fas fa-angle-right"></i></span>
</button>
</div>
<div class="control">
<button rel="last" class="button" v-if="page !== last_page" @click="changePage(last_page)"
:disabled="isLoading" :class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fas fa-angle-double-right"></i></span>
</button>
</div>
</div>
</template>
<script setup>
import {makePagination} from '~/utils/index'
const emitter = defineEmits(['navigate'])
const props = defineProps({
page: {
type: Number,
required: true
},
last_page: {
type: Number,
required: true
},
isLoading: {
type: Boolean,
required: false,
default: false
},
})
const changePage = p => {
if (p < 1 || p > props.last_page) {
return
}
emitter('navigate', p)
currentPage.value = p
}
const currentPage = ref(props.page)
</script>

View file

@ -25,6 +25,7 @@ export const useConfigStore = defineStore('config', () => {
started: 0, started: 0,
app_env: 'production', app_env: 'production',
simple_mode: false, simple_mode: false,
default_pagination: 50,
}, },
presets: [ presets: [
{ {

View file

@ -99,7 +99,6 @@ export const useSocketStore = defineStore('socket', () => {
const json = JSON.parse(stream) const json = JSON.parse(stream)
config.setAll({ config.setAll({
app: json.data.config, app: json.data.config,
tasks: json.data.tasks,
presets: json.data.presets, presets: json.data.presets,
dl_fields: json.data.dl_fields, dl_fields: json.data.dl_fields,
paused: Boolean(json.data.paused) paused: Boolean(json.data.paused)
@ -108,9 +107,22 @@ export const useSocketStore = defineStore('socket', () => {
on('connected', stream => { on('connected', stream => {
const json = JSON.parse(stream); const json = JSON.parse(stream);
config.add('folders', json.data.folders) if (!json?.data) {
stateStore.addAll('queue', json.data.queue || {}) return;
stateStore.addAll('history', json.data.done || {}) }
if (json.data?.folder) {
config.add('folders', json.data.folders)
}
if (json.data?.queue) {
stateStore.addAll('queue', json.data.queue || {})
}
if (typeof json.data?.history_count === 'number') {
stateStore.setHistoryCount(json.data.history_count)
}
error.value = null; error.value = null;
}) })

View file

@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { StoreItem } from '~/types/store' import type { StoreItem } from '~/types/store'
import { request } from '~/utils'
type StateType = 'queue' | 'history' type StateType = 'queue' | 'history'
type KeyType = string type KeyType = string
@ -7,10 +8,33 @@ type KeyType = string
interface State { interface State {
queue: Record<KeyType, StoreItem> queue: Record<KeyType, StoreItem>
history: 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', () => { export const useStateStore = defineStore('state', () => {
const state = reactive<State>({ queue: {}, history: {} }) 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 => { const add = (type: StateType, key: KeyType, value: StoreItem): void => {
state[type][key] = value state[type][key] = value
@ -37,6 +61,15 @@ export const useStateStore = defineStore('state', () => {
const clearAll = (type: StateType): void => { const clearAll = (type: StateType): void => {
state[type] = {} 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 => { const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
@ -52,8 +85,96 @@ export const useStateStore = defineStore('state', () => {
} }
const count = (type: StateType): number => { const count = (type: StateType): number => {
if ('history' === type && state.pagination.total > 0) {
return state.pagination.total
}
return Object.keys(state[type]).length return Object.keys(state[type]).length
} }
return { ...toRefs(state), add, update, remove, get, has, clearAll, addAll, move, count } const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC'): Promise<void> => {
if ('history' !== type) {
throw new Error('Pagination is only supported for history type');
}
state.pagination.isLoading = true
per_page = 2 // For testing purposes, set per_page to 2
try {
const search = new URLSearchParams({ type: 'done', page: page.toString(), per_page: per_page.toString(), order });
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] = items
}
} catch (error) {
console.error(`Failed to load ${type} page ${page}:`, error)
state.pagination.isLoading = false
}
}
const loadNextPage = async (type: StateType): 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)
}
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
return {
...toRefs(state),
add,
update,
remove,
get,
has,
clearAll,
addAll,
move,
count,
loadPaginated,
loadNextPage,
loadPreviousPage,
reloadCurrentPage,
getPagination,
setHistoryCount,
}
}) })

View file

@ -42,6 +42,8 @@ type AppConfig = {
started: number, started: number,
/** Application environment, e.g. "production", "development" */ /** Application environment, e.g. "production", "development" */
app_env: "production" | "development" app_env: "production" | "development"
/** Default number of items per page for pagination */
default_pagination: number
} }
type Preset = { type Preset = {