From b969902ac58f07c9b00689d3e44cd7df3bb80a9f Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Tue, 5 May 2026 15:11:11 -0500 Subject: [PATCH] Add navigation by path --- frontend.html | 5 +- main.py | 254 ++++++++++++++++++++++++++++++++++++++-- tests/test_endpoints.py | 13 ++ tests/test_navigate.py | 217 ++++++++++++++++++++++++++++++++++ 4 files changed, 475 insertions(+), 14 deletions(-) create mode 100644 tests/test_navigate.py diff --git a/frontend.html b/frontend.html index 730aef3..49fc0c8 100644 --- a/frontend.html +++ b/frontend.html @@ -56,10 +56,11 @@
-
+
$play_button +
diff --git a/main.py b/main.py index 7b41e7f..9f32764 100644 --- a/main.py +++ b/main.py @@ -144,6 +144,20 @@ class ZipFileIndexer(FileIndexer): return None return self._file_mapping[file_hash] + def get_hash_by_path(self, path: str) -> str | None: + """Reverse-lookup: given an internal ZIP filename, find its hash. + + Args: + path: The internal filename inside the zip. + + Returns: + The hash if found, None otherwise. + """ + for file_hash, filename in self._file_mapping.items(): + if filename == path: + return file_hash + return None + INDEXER_MAP = {".zip": ZipFileIndexer} @@ -297,30 +311,69 @@ def _render_page( play_button: str = "", current_order: str | None = None, current_delay: int | None = None, + use_navigate_urls: bool = False, ) -> HTMLResponse: - """Render the frontend page with navigation data""" + """Render the frontend page with navigation data. + + Args: + navigation_data: Dictionary with navigation info. + extra_meta: Extra tags (e.g., auto-refresh). + image_click_url: URL navigated to on image click. + play_button: Play/pause button HTML. + current_order: Current navigation order ('next' or 'random'). + current_delay: Delay in seconds for auto-navigation. + use_navigate_urls: If True, use /navigate/{path} URLs for prev/next + instead of /{hash} URLs. + """ with open("frontend.html") as f: content = f.read() template = string.Template(content) # Generate navigation URLs based on current mode - if current_order is not None: - # Timer mode: preserve current order and delay via query params - next_url = _build_url( - navigation_data["next_hash"], - order=current_order, - delay=current_delay, - ) - prev_url = _build_url( - navigation_data["prev_hash"], + if use_navigate_urls: + next_path = navigation_data["next_path"] + prev_path = navigation_data["prev_path"] + if current_order is not None: + next_url = _build_navigate_url( + next_path, order=current_order, delay=current_delay + ) + prev_url = _build_navigate_url( + prev_path, order=current_order, delay=current_delay + ) + else: + next_url = f"/navigate/{next_path}" + prev_url = f"/navigate/{prev_path}" + else: + if current_order is not None: + next_url = _build_url( + navigation_data["next_hash"], + order=current_order, + delay=current_delay, + ) + prev_url = _build_url( + navigation_data["prev_hash"], + order=current_order, + delay=current_delay, + ) + else: + next_url = "/{next_hash}".format(next_hash=navigation_data["next_hash"]) + prev_url = "/{prev_hash}".format(prev_hash=navigation_data["prev_hash"]) + + # Build the toggle URL (alternate view mode) + file_path = navigation_data.get("filename", "") + if use_navigate_urls: + toggle_url = _build_url( + navigation_data["file_hash"], order=current_order, delay=current_delay, ) else: - # Browse mode: generate browse mode URLs - next_url = "/{next_hash}".format(next_hash=navigation_data["next_hash"]) - prev_url = "/{prev_hash}".format(prev_hash=navigation_data["prev_hash"]) + toggle_url = _build_navigate_url( + file_path, + order=current_order, + delay=current_delay, + ) content = template.substitute( img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]), @@ -328,6 +381,9 @@ def _render_page( next_url=next_url, prev_url=prev_url, filename=navigation_data["filename"], + file_hash=navigation_data["file_hash"], + file_path=file_path, + toggle_url=toggle_url, extra_meta=extra_meta, play_button=play_button, ) @@ -467,6 +523,178 @@ def _get_random_hash() -> str: return random.choice(keys) +def _get_random_navigate_path() -> str | None: + """Get a random internal path from ZipFileIndexers only.""" + zip_paths: list[str] = [] + for indexer in indexers: + if isinstance(indexer, ZipFileIndexer): + zip_paths.extend(indexer._file_mapping.values()) + if not zip_paths: + return None + return random.choice(zip_paths) + + +def _find_hash_by_path(path: str) -> str | None: + """Find the hash for a given internal ZIP path across all ZipFileIndexers. + + Args: + path: The internal filename inside a zip. + + Returns: + The hash if found, None otherwise. + """ + for idx in indexers: + if isinstance(idx, ZipFileIndexer): + file_hash = idx.get_hash_by_path(path) + if file_hash is not None: + return file_hash + return None + + +def _get_path_from_hash(file_hash: str) -> str | None: + """Get the internal ZIP path for a given hash. + + Args: + file_hash: The hash of the file. + + Returns: + The internal filename if found, None otherwise. + """ + indexer = _find_indexer_for_hash(file_hash) + if indexer is not None: + return indexer.get_filename_by_hash(file_hash) + return None + + +def _build_navigate_url( + path: str, order: str | None = None, delay: int | None = None +) -> str: + """Build a /navigate URL with optional order/delay query parameters.""" + base = f"/navigate/{path}" + if order is not None and delay is not None: + return f"{base}?order={order}&delay={delay}" + return base + + +def _get_navigation_data_by_path( + path: str, order: str | None = None +) -> dict[str, str] | None: + """Get navigation data for a file identified by its internal ZIP path. + + Args: + path: The internal filename inside a zip. + order: Navigation order - 'next' for sequential, 'random' for random, + or None for default browse mode. + + Returns: + Dictionary with navigation paths and filename, or None if path not found. + """ + file_hash = _find_hash_by_path(path) + if file_hash is None: + return None + + keys = list(file_mapping.keys()) + idx = keys.index(file_hash) + + if order == "random": + next_hash = _get_random_hash() + prev_hash = _get_random_hash() + else: + next_hash = keys[(idx + 1) % len(keys)] + prev_hash = keys[idx - 1] if idx > 0 else keys[-1] + + next_path = _get_path_from_hash(next_hash) or "" + prev_path = _get_path_from_hash(prev_hash) or "" + + return { + "file_hash": file_hash, + "next_path": next_path, + "prev_path": prev_path, + "filename": path, + } + + +@app.get("/navigate/{path:path}") +async def navigate_page( + path: str, + order: str | None = None, + delay: int | None = None, + request: Request = None, +): + """Serve a page for a file identified by its internal ZIP path. + + Navigation links use /navigate/{path} URLs instead of hashed URLs. + + Args: + path: The internal filename inside a zip. + order: Navigation order - 'next' for sequential, 'random' for random. + delay: Delay in seconds before auto-navigating to next file. + """ + if order is not None and order not in ("next", "random"): + raise HTTPException( + status_code=400, detail="Invalid order. Must be 'next' or 'random'" + ) + + navigation_data = _get_navigation_data_by_path(path, order=order) + if navigation_data is None: + raise HTTPException(status_code=404, detail="File not found") + + file_hash = navigation_data["file_hash"] + + # Check if the file is encrypted and requires auth + if request is not None: + password = _get_zip_password(file_hash, request) + if password is None: + indexer = _find_indexer_for_hash(file_hash) + if isinstance(indexer, ZipFileIndexer) and indexer.is_file_encrypted( + file_hash + ): + _raise_unauthorized() + + if order is not None and delay is not None: + # Timer mode: auto-refresh with query params + refresh_url = _build_navigate_url( + navigation_data["next_path"], order=order, delay=delay + ) + refresh_meta = ( + f'' + ) + image_click_url = _build_navigate_url(path) + + # Create pause button to stop auto-refresh + pause_button = ( + f'' + ) + + return _render_page( + navigation_data, + refresh_meta, + image_click_url, + play_button=pause_button, + current_order=order, + current_delay=delay, + use_navigate_urls=True, + ) + else: + # Browse mode + play_button = ( + f'' + ) + random_path = _get_random_navigate_path() + image_click_url = ( + f"/navigate/{random_path}" if random_path else _get_random_hash() + ) + return _render_page( + navigation_data, + image_click_url=image_click_url, + play_button=play_button, + current_order=None, + current_delay=None, + use_navigate_urls=True, + ) + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Run the file server") parser.add_argument( diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index f94ce2e..7cc7018 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -160,6 +160,19 @@ class TestHashPage: response = await client_dir.get("/nonexistent-hash") assert response.status_code == 404 + async def test_toggle_url_points_to_navigate_mode( + self, client_zip: AsyncClient + ) -> None: + """Hash page toggle URL points to navigate-based URL.""" + import re + + file_hash = list(main.file_mapping.keys())[0] + response = await client_zip.get(f"/{file_hash}") + match = re.search(r'id="toggle-link" href="(/[^"]*?)"', response.text) + assert match is not None + toggle_href = match.group(1) + assert toggle_href.startswith("/navigate/") + class TestHashPageWithRefresh: """Tests for GET /{file_hash}?order=...&delay=... (auto-refresh mode).""" diff --git a/tests/test_navigate.py b/tests/test_navigate.py new file mode 100644 index 0000000..a01e63a --- /dev/null +++ b/tests/test_navigate.py @@ -0,0 +1,217 @@ +"""Tests for the /navigate/{path} endpoint.""" + +from httpx import AsyncClient + +import main + + +class TestNavigatePage: + """Tests for GET /navigate/{path}.""" + + async def test_returns_html_page(self, client_zip: AsyncClient) -> None: + """Returns an HTML page for a valid internal ZIP path.""" + response = await client_zip.get("/navigate/top.txt") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + async def test_page_contains_image_url(self, client_zip: AsyncClient) -> None: + """HTML page contains the image URL using hash-based /api endpoint.""" + response = await client_zip.get("/navigate/top.txt") + # The image URL should use the hash-based /api endpoint + assert "/api/" in response.text + assert "/data" in response.text + + async def test_page_contains_navigate_nav_links( + self, client_zip: AsyncClient + ) -> None: + """HTML page contains /navigate/ URLs for prev and next navigation.""" + response = await client_zip.get("/navigate/top.txt") + assert "/navigate/" in response.text + + async def test_page_contains_prev_next_buttons( + self, client_zip: AsyncClient + ) -> None: + """HTML page contains prev and next navigation buttons.""" + response = await client_zip.get("/navigate/top.txt") + assert 'class="chevron left"' in response.text + assert 'class="chevron right"' in response.text + + async def test_page_contains_play_button(self, client_zip: AsyncClient) -> None: + """HTML page contains play button with /navigate query param URL.""" + response = await client_zip.get("/navigate/top.txt") + assert "/navigate/" in response.text + assert "?order=next&delay=5" in response.text + + async def test_subdirectory_path(self, client_zip: AsyncClient) -> None: + """Paths with subdirectories work correctly.""" + response = await client_zip.get("/navigate/folder/deep.txt") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + async def test_returns_404_for_invalid_path(self, client_zip: AsyncClient) -> None: + """Returns 404 for a path that doesn't exist in the zip.""" + response = await client_zip.get("/navigate/nonexistent.txt") + assert response.status_code == 404 + + async def test_returns_404_for_directory_path( + self, client_zip: AsyncClient + ) -> None: + """Returns 404 for a directory path (not a file).""" + response = await client_zip.get("/navigate/folder/") + assert response.status_code == 404 + + async def test_returns_404_on_directory_indexer( + self, client_dir: AsyncClient + ) -> None: + """Returns 404 when server has only directory indexers (no ZIP).""" + response = await client_dir.get("/navigate/some/path.txt") + assert response.status_code == 404 + + async def test_page_has_data_hash_attribute(self, client_zip: AsyncClient) -> None: + """HTML page has data-hash attribute on image for toggle support.""" + response = await client_zip.get("/navigate/top.txt") + assert "data-hash=" in response.text + + async def test_page_has_data_path_attribute(self, client_zip: AsyncClient) -> None: + """HTML page has data-path attribute on image for toggle support.""" + response = await client_zip.get("/navigate/top.txt") + assert 'data-path="top.txt"' in response.text + + async def test_toggle_url_points_to_hash_mode( + self, client_zip: AsyncClient + ) -> None: + """Navigate page toggle URL points to hash-based URL.""" + import re + + response = await client_zip.get("/navigate/top.txt") + match = re.search(r'id="toggle-link" href="(/[^/][^"]*?)"', response.text) + assert match is not None + toggle_href = match.group(1) + assert not toggle_href.startswith("/navigate/") + + async def test_toggle_url_preserves_query_params( + self, client_zip: AsyncClient + ) -> None: + """Navigate page toggle URL preserves order/delay query params.""" + import re + + response = await client_zip.get( + "/navigate/top.txt", params={"order": "next", "delay": 5} + ) + match = re.search(r'id="toggle-link" href="(/[^"]*?)"', response.text) + assert match is not None + toggle_href = match.group(1) + assert "order=next" in toggle_href + assert "delay=5" in toggle_href + + +class TestNavigatePageWithRefresh: + """Tests for GET /navigate/{path}?order=...&delay=... (auto-refresh mode).""" + + async def test_next_order_returns_html(self, client_zip: AsyncClient) -> None: + """Next order returns HTML with refresh meta tag.""" + response = await client_zip.get( + "/navigate/top.txt", params={"order": "next", "delay": 5} + ) + assert response.status_code == 200 + assert 'http-equiv="refresh"' in response.text + + async def test_random_order_returns_html(self, client_zip: AsyncClient) -> None: + """Random order returns HTML with refresh meta tag.""" + response = await client_zip.get( + "/navigate/top.txt", params={"order": "random", "delay": 3} + ) + assert response.status_code == 200 + assert 'http-equiv="refresh"' in response.text + + async def test_invalid_order_returns_400(self, client_zip: AsyncClient) -> None: + """Invalid order parameter returns 400.""" + response = await client_zip.get( + "/navigate/top.txt", params={"order": "shuffle", "delay": 5} + ) + assert response.status_code == 400 + + async def test_returns_404_for_invalid_path(self, client_zip: AsyncClient) -> None: + """Returns 404 for a path that doesn't exist.""" + response = await client_zip.get( + "/navigate/nonexistent.txt", params={"order": "next", "delay": 5} + ) + assert response.status_code == 404 + + async def test_refresh_url_uses_navigate_path( + self, client_zip: AsyncClient + ) -> None: + """Refresh meta tag uses /navigate/ URLs with paths.""" + response = await client_zip.get( + "/navigate/top.txt", params={"order": "next", "delay": 5} + ) + assert "/navigate/" in response.text + assert "order=next" in response.text + assert "delay=5" in response.text + + async def test_pause_button_uses_navigate_url( + self, client_zip: AsyncClient + ) -> None: + """Pause button links to /navigate/{path} without query params.""" + response = await client_zip.get( + "/navigate/top.txt", params={"order": "next", "delay": 5} + ) + assert '/navigate/top.txt" class="play-btn"' in response.text + + +class TestNavigateHelperFunctions: + """Tests for navigate helper functions.""" + + def test_find_hash_by_path_returns_hash(self, initialized_zip: None) -> None: + """_find_hash_by_path returns the correct hash for a valid path.""" + file_hash = main._find_hash_by_path("top.txt") + assert file_hash is not None + assert file_hash in main.file_mapping + + def test_find_hash_by_path_returns_none_for_invalid( + self, initialized_zip: None + ) -> None: + """_find_hash_by_path returns None for a path that doesn't exist.""" + file_hash = main._find_hash_by_path("nonexistent.txt") + assert file_hash is None + + def test_find_hash_by_path_returns_none_for_directory( + self, initialized_zip: None + ) -> None: + """_find_hash_by_path returns None for a directory path.""" + file_hash = main._find_hash_by_path("folder/") + assert file_hash is None + + def test_get_path_from_hash(self, initialized_zip: None) -> None: + """_get_path_from_hash returns the correct path for a valid hash.""" + # Find a hash that maps to a known path + for file_hash, filename in main.indexers[0]._file_mapping.items(): + result = main._get_path_from_hash(file_hash) + assert result == filename + break + + def test_build_navigate_url_no_params(self) -> None: + """_build_navigate_url builds correct URL without query params.""" + url = main._build_navigate_url("folder/deep.txt") + assert url == "/navigate/folder/deep.txt" + + def test_build_navigate_url_with_params(self) -> None: + """_build_navigate_url builds correct URL with query params.""" + url = main._build_navigate_url("folder/deep.txt", order="next", delay=5) + assert url == "/navigate/folder/deep.txt?order=next&delay=5" + + def test_get_navigation_data_by_path(self, initialized_zip: None) -> None: + """_get_navigation_data_by_path returns correct navigation data.""" + data = main._get_navigation_data_by_path("top.txt") + assert data is not None + assert "file_hash" in data + assert "next_path" in data + assert "prev_path" in data + assert data["filename"] == "top.txt" + + def test_get_navigation_data_by_path_returns_none_for_invalid( + self, initialized_zip: None + ) -> None: + """_get_navigation_data_by_path returns None for invalid path.""" + data = main._get_navigation_data_by_path("nonexistent.txt") + assert data is None