From 90e07ce71b455bc3c4e5ba9ecc0c64285aa25f99 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Fri, 13 Feb 2026 21:46:01 +0300 Subject: [PATCH] feat: improve the file browser with pagination. --- API.md | 38 ++- app/library/Utils.py | 40 ++- app/routes/api/browser.py | 32 +- app/tests/test_utils.py | 4 +- ui/app/composables/useBrowser.ts | 406 +++++++++++++++++++++++++ ui/app/pages/browser/[...slug].vue | 465 ++++++++++------------------- ui/app/types/filebrowser.d.ts | 12 +- 7 files changed, 670 insertions(+), 327 deletions(-) create mode 100644 ui/app/composables/useBrowser.ts diff --git a/API.md b/API.md index c9bcfb55..3dc0b6dd 100644 --- a/API.md +++ b/API.md @@ -1576,6 +1576,13 @@ Binary image data with the appropriate `Content-Type`. **Path Parameter**: - `path` = Relative path within the download directory (URL-encoded). +**Query Parameters**: +- `page` (optional): Page number (1-indexed). Default: `1`. +- `per_page` (optional): Items per page. Default: `config.default_pagination`, Max: `1000`. +- `sort_by` (optional): Sort field. Options: `name`, `size`, `date`, `type`. Default: `name`. +- `sort_order` (optional): Sort direction. Options: `asc`, `desc`. Default: `asc`. +- `search` (optional): Filter by filename (case-insensitive). + **Response**: ```json { @@ -1587,12 +1594,12 @@ Binary image data with the appropriate `Content-Type`. "name": "filename.mp4", "path": "/filename.mp4", "size": 123456789, - "mimetype": "mime/type", + "mime": "mime/type", "mtime": "2023-01-01T12:00:00Z", "ctime": "2023-01-01T12:00:00Z", "is_dir": true|false, "is_file": true|false, - ... + "is_symlink": true|false }, { "type": "dir", @@ -1601,11 +1608,36 @@ Binary image data with the appropriate `Content-Type`. "path": "/Season 2025", ... } - ] + ], + "pagination": { + "page": 1, + "per_page": 50, + "total": 123, + "total_pages": 3, + "has_next": true, + "has_prev": false + } } ``` + +**Examples**: +```bash +# Get first page of root directory +GET /api/file/browser/ + +# Get second page of videos folder, sorted by size descending +GET /api/file/browser/videos?page=2&sort_by=size&sort_order=desc + +# Search for mp4 files +GET /api/file/browser/videos?search=mp4 + +# Sort by date, newest first +GET /api/file/browser/videos?sort_by=date&sort_order=desc +``` + - Returns `403 Forbidden` if file browser is disabled. - Returns `404 Not Found` if the path doesn't exist. +- Returns `400 Bad Request` if the path is not a directory. --- diff --git a/app/library/Utils.py b/app/library/Utils.py index baebbad6..9db08df6 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -814,16 +814,29 @@ def get( return data -def get_files(base_path: Path | str, dir: str | None = None): +def get_files( + base_path: Path | str, + dir: str | None = None, + page: int = 1, + per_page: int = 0, + sort_by: str = "name", + sort_order: str = "asc", + search: str | None = None, +) -> tuple[list, int]: """ - Get directory contents. + Get directory contents with optional pagination, sorting, and search. Args: base_path (Path|str): Base download path. dir (str): Directory to check. + page (int): Page number (1-indexed). Ignored if per_page is 0. + per_page (int): Items per page. If 0, returns all items. + sort_by (str): Sort field: name, size, date, type. + sort_order (str): Sort direction: asc, desc. + search (str|None): Filter by filename (case-insensitive). Returns: - list: List of files and directories. + tuple[list, int]: List of file/directory dicts and total count (before pagination). Raises: OSError: If the directory is invalid or not a directory. @@ -909,7 +922,26 @@ def get_files(base_path: Path | str, dir: str | None = None): } ) - return contents + total: int = len(contents) + + if search: + search_lower: str = search.lower() + contents = [c for c in contents if search_lower in c["name"].lower()] + + if sort_by == "name": + contents.sort(key=lambda x: x["name"].lower(), reverse=(sort_order.lower() == "desc")) + elif sort_by == "size": + contents.sort(key=lambda x: x["size"], reverse=(sort_order.lower() == "desc")) + elif sort_by == "date": + contents.sort(key=lambda x: x["mtime"], reverse=(sort_order.lower() == "desc")) + elif sort_by == "type": + contents.sort(key=lambda x: x["content_type"], reverse=(sort_order.lower() == "desc")) + + if per_page > 0: + offset: int = (page - 1) * per_page + contents = contents[offset : offset + per_page] + + return contents, total def clean_item(item: dict, keys: list | tuple) -> tuple[dict, bool]: diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py index 720d80ba..34153d3b 100644 --- a/app/routes/api/browser.py +++ b/app/routes/api/browser.py @@ -7,6 +7,7 @@ from urllib.parse import unquote_plus from aiohttp import web from aiohttp.web import Request, Response +from app.features.core.utils import build_pagination, normalize_pagination from app.features.streaming.library.ffprobe import ffprobe from app.library.cache import Cache from app.library.config import Config @@ -134,16 +135,15 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re req_path: str = request.match_info.get("path") req_path: str = "/" if not req_path else unquote_plus(req_path) - # Normalize requested path to always be inside download root. raw_req: str = (req_path or "").strip() root_dir: Path = Path(config.download_path).resolve() if raw_req in ("", "/"): test: Path = root_dir - rel_for_listing = "/" + rel_for_listing: str = "/" else: # Strip leading slash so joinpath doesn't ignore the base path. test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False) - rel_for_listing = raw_req.lstrip("/") + rel_for_listing: str = raw_req.lstrip("/") try: test.relative_to(root_dir) @@ -163,10 +163,34 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re ) try: + page, per_page = normalize_pagination(request) + sort_by: str = request.query.get("sort_by", "name") + sort_order: str = request.query.get("sort_order", "asc") + search: str | None = request.query.get("search") + + if sort_by not in ("name", "size", "date", "type"): + sort_by = "name" + + if sort_order not in ("asc", "desc"): + sort_order = "asc" + + contents, total = get_files( + base_path=root_dir, + dir=rel_for_listing, + page=page, + per_page=per_page, + sort_by=sort_by, + sort_order=sort_order, + search=search, + ) + + total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1 + return web.json_response( data={ "path": rel_for_listing, - "contents": get_files(base_path=root_dir, dir=rel_for_listing), + "contents": contents, + "pagination": build_pagination(total, page, per_page, total_pages), }, status=web.HTTPOk.status_code, dumps=encoder.encode, diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index 39ce9674..e88cb93f 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -1488,13 +1488,13 @@ class TestGetFiles: def test_get_files_root(self): """Test getting files from root directory.""" - result = get_files(self.base_path) + result, total = get_files(self.base_path) assert isinstance(result, list) assert len(result) > 0 def test_get_files_subdir(self): """Test getting files from subdirectory.""" - result = get_files(self.base_path, "subdir") + result, total = get_files(self.base_path, "subdir") assert isinstance(result, list) diff --git a/ui/app/composables/useBrowser.ts b/ui/app/composables/useBrowser.ts new file mode 100644 index 00000000..4ffa60c2 --- /dev/null +++ b/ui/app/composables/useBrowser.ts @@ -0,0 +1,406 @@ +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' + +const items = ref([]) +const path = ref('/') +const pagination = ref({ + page: 1, + per_page: 50, + total: 0, + total_pages: 0, + has_next: false, + has_prev: false, +}) +const isLoading = ref(false) +const lastError = ref(null) +const selectedElms = ref([]) +const masterSelectAll = ref(false) +const sort_by = ref('name') +const sort_order = ref('asc') +const search = ref('') +const throwInstead = ref(false) +const notify = useNotification() + +const readJson = async (response: Response): Promise => { + try { + const clone = response.clone() + return await clone.json() + } + catch { + return null + } +} + +const ensureSuccess = async (response: Response): Promise => { + if (response.ok) { + return + } + const payload = await readJson(response) + const message = await parse_api_error(payload) + throw new Error(message) +} + +const handleError = (error: unknown): void => { + const message = error instanceof Error ? error.message : 'Unexpected error occurred.' + lastError.value = message + notify.error(message) +} + +const buildQueryParams = (page?: number): string => { + const config = useConfigStore() + const params = new URLSearchParams() + params.set('page', String(page ?? pagination.value.page)) + params.set('per_page', String(config.app.default_pagination || 50)) + params.set('sort_by', sort_by.value) + params.set('sort_order', sort_order.value) + if (search.value) { + params.set('search', search.value) + } + return params.toString() +} + +const loadContents = async (dir: string = '/', page: number = 1): Promise => { + isLoading.value = true + try { + if (typeof dir !== 'string') { + dir = '/' + } + + dir = encodePath(sTrim(dir, '/')) + const query = buildQueryParams(page) + const response = await request(`/api/file/browser/${sTrim(dir, '/')}?${query}`) + + await ensureSuccess(response) + + const data = await response.json() + + items.value = data.contents || [] + path.value = data.path || '/' + if (data.pagination) { + pagination.value = data.pagination + } + + selectedElms.value = [] + masterSelectAll.value = false + lastError.value = null + + return true + } + catch (error) { + handleError(error) + if (throwInstead.value) throw error + return false + } + finally { + isLoading.value = false + } +} + +const changeSort = async (by: string): Promise => { + if (!['name', 'size', 'date', 'type'].includes(by)) { + return + } + + if (by !== sort_by.value) { + sort_by.value = by + } + else { + sort_order.value = sort_order.value === 'asc' ? 'desc' : 'asc' + } + + await loadContents(path.value, 1) +} + +const setSearch = async (value: string): Promise => { + search.value = value + await loadContents(path.value, 1) +} + +const setSortBy = (value: string): void => { + sort_by.value = value +} + +const setSortOrder = (value: string): void => { + sort_order.value = value +} + +const setSearchValue = (value: string): void => { + search.value = value +} + +const setPage = (value: number): void => { + pagination.value.page = value +} + +const changePage = async (page: number): Promise => { + await loadContents(path.value, page) +} + +const performAction = async ( + item: FileItem, + action: string, + payload: Record, + callback?: (item: FileItem, action: string, data: Record, source: FileItem) => void, + multiple: boolean = false +): Promise => { + try { + const response = await request('/api/file/actions', { + method: 'POST', + body: JSON.stringify([{ path: item.path, action, ...payload }]), + }) + + await ensureSuccess(response) + + const results = await response.json() as Array<{ path: string, status: boolean, error?: string, [key: string]: unknown }> + + for (const result of results) { + if (!multiple && result.path !== item.path) { + continue + } + + if (!multiple && !result.status) { + notify.error(`Failed to perform action: ${result.error || 'Unknown error'}`) + return false + } + + if (callback && typeof callback === 'function') { + if (!multiple) { + callback(item, action, payload as Record, item) + } + else { + const matchedItem = items.value.find(it => it.path === result.path) + if (matchedItem) { + callback(matchedItem, action, result as Record, toRaw(item)) + } + } + } + } + + return true + } + catch (error) { + handleError(error) + if (throwInstead.value) throw error + return false + } +} + +const performMassAction = async ( + actions: Array<{ path: string, action: string, [key: string]: unknown }>, + callback?: (result: { path: string, status: boolean, error?: string }) => void +): Promise => { + try { + const response = await request('/api/file/actions', { + method: 'POST', + body: JSON.stringify(actions), + }) + + await ensureSuccess(response) + + const results = await response.json() as Array<{ path: string, status: boolean, error?: string }> + + for (const result of results) { + if (!result.status) { + notify.error(`Failed to perform action on '${result.path}': ${result.error || 'Unknown error'}`) + continue + } + + if (callback && typeof callback === 'function') { + callback(result) + } + } + + return true + } + catch (error) { + handleError(error) + if (throwInstead.value) throw error + return false + } +} + +const createDirectory = async (dir: string, newDir: string): Promise => { + const trimmedDir = sTrim(newDir, '/') + if (!trimmedDir || trimmedDir === dir) { + return false + } + + const success = await performAction( + { path: dir || '/' } as FileItem, + 'directory', + { new_dir: trimmedDir }, + () => { + notify.success(`Successfully created '${trimmedDir}'.`) + } + ) + + return success +} + +const renameItem = async (item: FileItem, newName: string): Promise => { + const trimmedName = newName.trim() + if (!trimmedName || trimmedName === item.name) { + return false + } + + return await performAction( + item, + 'rename', + { new_name: trimmedName }, + (it, _, data) => { + const source = data as { new_path?: string } + if (source.new_path) { + it.name = source.new_path.split('/').pop() || trimmedName + it.path = source.new_path + } + notify.success(`Renamed '${item.name}'.`) + }, + true + ) +} + +const deleteItem = async (item: FileItem): Promise => { + return await performAction( + item, + 'delete', + {}, + () => { + items.value = items.value.filter(i => i.path !== item.path) + notify.warning(`Deleted '${item.name}'.`) + } + ) +} + +const moveItem = async (item: FileItem, newPath: string): Promise => { + const trimmedPath = sTrim(newPath, '/') || '/' + if (!trimmedPath || trimmedPath === item.path) { + return false + } + + return await performAction( + item, + 'move', + { new_path: trimmedPath }, + (it, _, data) => { + const source = data as { new_path?: string; path?: string } + if (source.path) { + items.value = items.value.filter(i => i.path !== source.path) + } + notify.success(`Moved '${item.name}' to '${trimmedPath}'.`) + }, + true + ) +} + +const deleteSelected = async (): Promise => { + if (selectedElms.value.length < 1) { + notify.error('No items selected.') + return false + } + + const actions = selectedElms.value.map(p => { + return { path: p, action: 'delete' } + }) + + const success = await performMassAction(actions, (result) => { + const item = items.value.find(it => it.path === result.path) + if (item) { + items.value = items.value.filter(it => it.path !== result.path) + notify.warning(`Deleted '${item.name}'.`) + } + }) + + if (success) { + selectedElms.value = [] + } + + return success +} + +const moveSelected = async (newPath: string): Promise => { + if (selectedElms.value.length < 1) { + notify.error('No items selected.') + return false + } + + const trimmedPath = sTrim(newPath, '/') || '/' + const actions = selectedElms.value.map(p => ({ + path: p, + action: 'move', + new_path: trimmedPath, + })) + + const success = await performMassAction(actions, (result) => { + items.value = items.value.filter(it => it.path !== result.path) + notify.success(`Moved '${result.path}' to '${trimmedPath}'.`) + }) + + if (success) { + selectedElms.value = [] + } + + return success +} + +const clearError = (): void => { + lastError.value = null +} + +const reset = (): void => { + items.value = [] + path.value = '/' + pagination.value = { + page: 1, + per_page: 50, + total: 0, + total_pages: 0, + has_next: false, + has_prev: false, + } + selectedElms.value = [] + masterSelectAll.value = false + search.value = '' + lastError.value = null +} + +const filteredItems = computed(() => { + return items.value +}) + +export const useBrowser = () => ({ + items: readonly(items), + path: readonly(path), + pagination: readonly(pagination), + isLoading: readonly(isLoading), + lastError: readonly(lastError), + selectedElms, + masterSelectAll, + sort_by, + sort_order, + search, + filteredItems, + + loadContents, + changeSort, + setSearch, + setSortBy, + setSortOrder, + setSearchValue, + setPage, + changePage, + createDirectory, + renameItem, + deleteItem, + moveItem, + deleteSelected, + moveSelected, + performAction, + performMassAction, + clearError, + reset, + throwInstead, +}) diff --git a/ui/app/pages/browser/[...slug].vue b/ui/app/pages/browser/[...slug].vue index 73e646f2..30e812ad 100644 --- a/ui/app/pages/browser/[...slug].vue +++ b/ui/app/pages/browser/[...slug].vue @@ -5,8 +5,8 @@