Skip to content

Commit 90e07ce

Browse files
committed
feat: improve the file browser with pagination.
1 parent 19d5d60 commit 90e07ce

File tree

7 files changed

+670
-327
lines changed

7 files changed

+670
-327
lines changed

API.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,13 @@ Binary image data with the appropriate `Content-Type`.
15761576
**Path Parameter**:
15771577
- `path` = Relative path within the download directory (URL-encoded).
15781578

1579+
**Query Parameters**:
1580+
- `page` (optional): Page number (1-indexed). Default: `1`.
1581+
- `per_page` (optional): Items per page. Default: `config.default_pagination`, Max: `1000`.
1582+
- `sort_by` (optional): Sort field. Options: `name`, `size`, `date`, `type`. Default: `name`.
1583+
- `sort_order` (optional): Sort direction. Options: `asc`, `desc`. Default: `asc`.
1584+
- `search` (optional): Filter by filename (case-insensitive).
1585+
15791586
**Response**:
15801587
```json
15811588
{
@@ -1587,12 +1594,12 @@ Binary image data with the appropriate `Content-Type`.
15871594
"name": "filename.mp4",
15881595
"path": "/filename.mp4",
15891596
"size": 123456789,
1590-
"mimetype": "mime/type",
1597+
"mime": "mime/type",
15911598
"mtime": "2023-01-01T12:00:00Z",
15921599
"ctime": "2023-01-01T12:00:00Z",
15931600
"is_dir": true|false,
15941601
"is_file": true|false,
1595-
...
1602+
"is_symlink": true|false
15961603
},
15971604
{
15981605
"type": "dir",
@@ -1601,11 +1608,36 @@ Binary image data with the appropriate `Content-Type`.
16011608
"path": "/Season 2025",
16021609
...
16031610
}
1604-
]
1611+
],
1612+
"pagination": {
1613+
"page": 1,
1614+
"per_page": 50,
1615+
"total": 123,
1616+
"total_pages": 3,
1617+
"has_next": true,
1618+
"has_prev": false
1619+
}
16051620
}
16061621
```
1622+
1623+
**Examples**:
1624+
```bash
1625+
# Get first page of root directory
1626+
GET /api/file/browser/
1627+
1628+
# Get second page of videos folder, sorted by size descending
1629+
GET /api/file/browser/videos?page=2&sort_by=size&sort_order=desc
1630+
1631+
# Search for mp4 files
1632+
GET /api/file/browser/videos?search=mp4
1633+
1634+
# Sort by date, newest first
1635+
GET /api/file/browser/videos?sort_by=date&sort_order=desc
1636+
```
1637+
16071638
- Returns `403 Forbidden` if file browser is disabled.
16081639
- Returns `404 Not Found` if the path doesn't exist.
1640+
- Returns `400 Bad Request` if the path is not a directory.
16091641

16101642
---
16111643

app/library/Utils.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -814,16 +814,29 @@ def get(
814814
return data
815815

816816

817-
def get_files(base_path: Path | str, dir: str | None = None):
817+
def get_files(
818+
base_path: Path | str,
819+
dir: str | None = None,
820+
page: int = 1,
821+
per_page: int = 0,
822+
sort_by: str = "name",
823+
sort_order: str = "asc",
824+
search: str | None = None,
825+
) -> tuple[list, int]:
818826
"""
819-
Get directory contents.
827+
Get directory contents with optional pagination, sorting, and search.
820828
821829
Args:
822830
base_path (Path|str): Base download path.
823831
dir (str): Directory to check.
832+
page (int): Page number (1-indexed). Ignored if per_page is 0.
833+
per_page (int): Items per page. If 0, returns all items.
834+
sort_by (str): Sort field: name, size, date, type.
835+
sort_order (str): Sort direction: asc, desc.
836+
search (str|None): Filter by filename (case-insensitive).
824837
825838
Returns:
826-
list: List of files and directories.
839+
tuple[list, int]: List of file/directory dicts and total count (before pagination).
827840
828841
Raises:
829842
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):
909922
}
910923
)
911924

912-
return contents
925+
total: int = len(contents)
926+
927+
if search:
928+
search_lower: str = search.lower()
929+
contents = [c for c in contents if search_lower in c["name"].lower()]
930+
931+
if sort_by == "name":
932+
contents.sort(key=lambda x: x["name"].lower(), reverse=(sort_order.lower() == "desc"))
933+
elif sort_by == "size":
934+
contents.sort(key=lambda x: x["size"], reverse=(sort_order.lower() == "desc"))
935+
elif sort_by == "date":
936+
contents.sort(key=lambda x: x["mtime"], reverse=(sort_order.lower() == "desc"))
937+
elif sort_by == "type":
938+
contents.sort(key=lambda x: x["content_type"], reverse=(sort_order.lower() == "desc"))
939+
940+
if per_page > 0:
941+
offset: int = (page - 1) * per_page
942+
contents = contents[offset : offset + per_page]
943+
944+
return contents, total
913945

914946

915947
def clean_item(item: dict, keys: list | tuple) -> tuple[dict, bool]:

app/routes/api/browser.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from aiohttp import web
88
from aiohttp.web import Request, Response
99

10+
from app.features.core.utils import build_pagination, normalize_pagination
1011
from app.features.streaming.library.ffprobe import ffprobe
1112
from app.library.cache import Cache
1213
from app.library.config import Config
@@ -134,16 +135,15 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
134135
req_path: str = request.match_info.get("path")
135136
req_path: str = "/" if not req_path else unquote_plus(req_path)
136137

137-
# Normalize requested path to always be inside download root.
138138
raw_req: str = (req_path or "").strip()
139139
root_dir: Path = Path(config.download_path).resolve()
140140
if raw_req in ("", "/"):
141141
test: Path = root_dir
142-
rel_for_listing = "/"
142+
rel_for_listing: str = "/"
143143
else:
144144
# Strip leading slash so joinpath doesn't ignore the base path.
145145
test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False)
146-
rel_for_listing = raw_req.lstrip("/")
146+
rel_for_listing: str = raw_req.lstrip("/")
147147

148148
try:
149149
test.relative_to(root_dir)
@@ -163,10 +163,34 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
163163
)
164164

165165
try:
166+
page, per_page = normalize_pagination(request)
167+
sort_by: str = request.query.get("sort_by", "name")
168+
sort_order: str = request.query.get("sort_order", "asc")
169+
search: str | None = request.query.get("search")
170+
171+
if sort_by not in ("name", "size", "date", "type"):
172+
sort_by = "name"
173+
174+
if sort_order not in ("asc", "desc"):
175+
sort_order = "asc"
176+
177+
contents, total = get_files(
178+
base_path=root_dir,
179+
dir=rel_for_listing,
180+
page=page,
181+
per_page=per_page,
182+
sort_by=sort_by,
183+
sort_order=sort_order,
184+
search=search,
185+
)
186+
187+
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
188+
166189
return web.json_response(
167190
data={
168191
"path": rel_for_listing,
169-
"contents": get_files(base_path=root_dir, dir=rel_for_listing),
192+
"contents": contents,
193+
"pagination": build_pagination(total, page, per_page, total_pages),
170194
},
171195
status=web.HTTPOk.status_code,
172196
dumps=encoder.encode,

app/tests/test_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,13 +1488,13 @@ def teardown_method(self):
14881488

14891489
def test_get_files_root(self):
14901490
"""Test getting files from root directory."""
1491-
result = get_files(self.base_path)
1491+
result, total = get_files(self.base_path)
14921492
assert isinstance(result, list)
14931493
assert len(result) > 0
14941494

14951495
def test_get_files_subdir(self):
14961496
"""Test getting files from subdirectory."""
1497-
result = get_files(self.base_path, "subdir")
1497+
result, total = get_files(self.base_path, "subdir")
14981498
assert isinstance(result, list)
14991499

15001500

0 commit comments

Comments
 (0)