From 7d311451cb884de7d22135681fa5496ca4c623ec Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:44:36 -0700 Subject: [PATCH 1/2] feat: URL-based deep linking for SPA navigation - Flask catch-all route serves index.html for client-side paths, excluding api/static/auth/callback/status prefixes.- navigateToPage pushes history state so URL reflects current page.- popstate listener handles browser back/forward without reloading.- Initial load reads window.location to restore the page after refresh or direct link.- artist-detail and playlist-explorer fall back to parent pages since they need runtime context. --- web_server.py | 9 ++++++++- webui/static/script.js | 45 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/web_server.py b/web_server.py index 8133fe86..348bfb28 100644 --- a/web_server.py +++ b/web_server.py @@ -21,7 +21,7 @@ from pathlib import Path from urllib.parse import urljoin from concurrent.futures import ThreadPoolExecutor, as_completed -from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g +from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g, abort from flask_socketio import SocketIO, emit, join_room, leave_room from utils.logging_config import get_logger, setup_logging from utils.async_helpers import run_async @@ -4875,6 +4875,13 @@ def run_detection(server_type): def index(): return render_template('index.html') +@app.route('/') +def spa_catch_all(page): + # Serve index.html for client-side routes; let Flask handle real routes first. + if page.startswith(('api/', 'static/', 'auth/', 'callback', 'tidal/', 'status')): + abort(404) + return render_template('index.html') + # --- API Endpoints --- # Tracks cumulative item-processed totals over time for windowed counting. diff --git a/webui/static/script.js b/webui/static/script.js index 81c84b60..710aacce 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -2773,6 +2773,31 @@ function initializeNavigation() { navigateToPage(page); }); }); + + window.addEventListener('popstate', (event) => { + const page = (event.state && event.state.page) || _getPageFromPath(); + if (page && page !== currentPage) { + navigateToPage(page, { skipPushState: true }); + } + }); +} + +const _DEEPLINK_VALID_PAGES = new Set([ + 'dashboard', 'sync', 'downloads', 'discover', 'artists', 'automations', + 'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist', + 'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer', + 'hydrabase', 'tools' +]); + +function _getPageFromPath() { + const path = window.location.pathname.replace(/^\/+|\/+$/g, ''); + if (!path) return 'dashboard'; + const basePage = path.split('/')[0]; + if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard'; + // Context-dependent pages fall back to a sensible parent + if (basePage === 'artist-detail') return 'artists'; + if (basePage === 'playlist-explorer') return 'library'; + return basePage; } // =============================== @@ -2905,7 +2930,7 @@ function initializeDownloadManagerToggle() { console.log('Download manager toggle initialized'); } -function navigateToPage(pageId) { +function navigateToPage(pageId, options = {}) { if (pageId === currentPage) return; // Permission guard — redirect to home page if not allowed @@ -2937,6 +2962,13 @@ function navigateToPage(pageId) { currentPage = pageId; + if (!options.skipPushState) { + const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId; + if (window.location.pathname !== urlPath) { + history.pushState({ page: pageId }, '', urlPath); + } + } + // Show/hide global search bar (hide on downloads page where enhanced search exists) if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility(); @@ -10233,8 +10265,15 @@ async function loadInitialData() { // Navigate to user's home page (or dashboard for admin) const homePage = getProfileHomePage(); - if (homePage !== 'dashboard') { - navigateToPage(homePage); + const urlPage = _getPageFromPath(); + const targetPage = (urlPage && urlPage !== 'dashboard' && isPageAllowed(urlPage)) + ? urlPage + : homePage; + + history.replaceState({ page: targetPage }, '', (targetPage === 'dashboard' ? '/' : '/' + targetPage) + window.location.search + window.location.hash); + + if (targetPage !== 'dashboard') { + navigateToPage(targetPage, { skipPushState: true }); } else { await loadDashboardData(); loadDashboardSyncHistory(); From 5af4dc7853f0d0a87d0b543164ec85884b48749c Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:48:54 -0700 Subject: [PATCH 2/2] test: add unit tests for SPA deep-linking catch-all route --- tests/test_spa_deep_linking.py | 205 +++++++++++++++++++++++++++++++++ web_server.py | 2 +- 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/test_spa_deep_linking.py diff --git a/tests/test_spa_deep_linking.py b/tests/test_spa_deep_linking.py new file mode 100644 index 00000000..cd28f096 --- /dev/null +++ b/tests/test_spa_deep_linking.py @@ -0,0 +1,205 @@ +"""Tests for the SPA deep-linking catch-all route. + +The real web_server.py cannot be imported at test time (it initializes +Spotify, Soulseek, Plex, etc.), so we replicate the routing behavior in a +minimal Flask app that matches the real implementation verbatim. +""" + +import pytest +from flask import Flask, abort + + +# --------------------------------------------------------------------------- +# App factory — mirrors the routes added in web_server.py +# --------------------------------------------------------------------------- + +def _build_app(): + app = Flask(__name__) + app.testing = True + + @app.route('/') + def index(): + return 'INDEX_HTML', 200 + + @app.route('/') + def spa_catch_all(page): + if page.startswith(('api/', 'static/', 'auth/', 'callback', 'deezer/', 'tidal/', 'status')): + abort(404) + return 'INDEX_HTML', 200 + + # Stand-ins for real routes so we can verify the catch-all does not shadow them + @app.route('/api/ping') + def api_ping(): + return {'ok': True}, 200 + + @app.route('/auth/spotify') + def auth_spotify(): + return 'AUTH_SPOTIFY', 200 + + @app.route('/callback') + def oauth_callback(): + return 'OAUTH_CALLBACK', 200 + + @app.route('/tidal/callback') + def tidal_callback(): + return 'TIDAL_CALLBACK', 200 + + @app.route('/deezer/callback') + def deezer_callback(): + return 'DEEZER_CALLBACK', 200 + + @app.route('/status') + def status(): + return 'STATUS', 200 + + return app + + +@pytest.fixture +def client(): + return _build_app().test_client() + + +# --------------------------------------------------------------------------- +# Group A — SPA routes serve index.html +# --------------------------------------------------------------------------- + +class TestSpaRoutes: + """Deep-link paths for valid client pages should serve index.html.""" + + @pytest.mark.parametrize("page", [ + 'dashboard', 'sync', 'downloads', 'discover', 'artists', + 'automations', 'library', 'import', 'settings', 'help', + 'issues', 'stats', 'watchlist', 'wishlist', 'active-downloads', + 'artist-detail', 'playlist-explorer', 'hydrabase', 'tools', + ]) + def test_valid_page_serves_index(self, client, page): + resp = client.get(f'/{page}') + assert resp.status_code == 200 + assert resp.data == b'INDEX_HTML' + + def test_root_still_serves_index(self, client): + resp = client.get('/') + assert resp.status_code == 200 + assert resp.data == b'INDEX_HTML' + + def test_unknown_page_still_serves_index(self, client): + # Standard SPA behavior — unknown paths fall through to the client router. + resp = client.get('/this-page-does-not-exist') + assert resp.status_code == 200 + assert resp.data == b'INDEX_HTML' + + def test_nested_sub_path_serves_index(self, client): + # Future-proofing: /artists/Linkin%20Park-style deep links. + resp = client.get('/artists/Linkin%20Park') + assert resp.status_code == 200 + assert resp.data == b'INDEX_HTML' + + +# --------------------------------------------------------------------------- +# Group B — Reserved prefixes are not shadowed +# --------------------------------------------------------------------------- + +class TestReservedPrefixes: + """The catch-all must never swallow real API / auth / static routes.""" + + def test_real_api_route_wins(self, client): + resp = client.get('/api/ping') + assert resp.status_code == 200 + assert resp.get_json() == {'ok': True} + + def test_unknown_api_path_returns_404(self, client): + # Catch-all's abort(404) prevents /api/* from being answered with index.html. + resp = client.get('/api/not-a-real-endpoint') + assert resp.status_code == 404 + assert b'INDEX_HTML' not in resp.data + + def test_unknown_static_path_returns_404(self, client): + resp = client.get('/static/does-not-exist.js') + assert resp.status_code == 404 + assert b'INDEX_HTML' not in resp.data + + def test_unknown_auth_path_returns_404(self, client): + resp = client.get('/auth/unknown-provider') + assert resp.status_code == 404 + assert b'INDEX_HTML' not in resp.data + + def test_real_auth_route_wins(self, client): + resp = client.get('/auth/spotify') + assert resp.status_code == 200 + assert resp.data == b'AUTH_SPOTIFY' + + def test_real_callback_route_wins(self, client): + resp = client.get('/callback') + assert resp.status_code == 200 + assert resp.data == b'OAUTH_CALLBACK' + + def test_callback_prefix_without_real_route_returns_404(self, client): + # Anything starting with 'callback' is reserved even if Flask has no match. + resp = client.get('/callback-fake') + assert resp.status_code == 404 + assert b'INDEX_HTML' not in resp.data + + def test_real_tidal_callback_wins(self, client): + resp = client.get('/tidal/callback') + assert resp.status_code == 200 + assert resp.data == b'TIDAL_CALLBACK' + + def test_unknown_tidal_path_returns_404(self, client): + resp = client.get('/tidal/other') + assert resp.status_code == 404 + assert b'INDEX_HTML' not in resp.data + + def test_real_deezer_callback_wins(self, client): + resp = client.get('/deezer/callback') + assert resp.status_code == 200 + assert resp.data == b'DEEZER_CALLBACK' + + def test_unknown_deezer_path_returns_404(self, client): + resp = client.get('/deezer/other') + assert resp.status_code == 404 + assert b'INDEX_HTML' not in resp.data + + def test_real_status_route_wins(self, client): + resp = client.get('/status') + assert resp.status_code == 200 + assert resp.data == b'STATUS' + + def test_status_prefix_without_real_route_returns_404(self, client): + resp = client.get('/status-extra') + assert resp.status_code == 404 + assert b'INDEX_HTML' not in resp.data + + +# --------------------------------------------------------------------------- +# Group C — HTTP method restrictions +# --------------------------------------------------------------------------- + +class TestHttpMethods: + """Catch-all should only respond to GET (Flask default).""" + + def test_post_to_spa_path_not_allowed(self, client): + resp = client.post('/discover') + assert resp.status_code == 405 + + def test_put_to_spa_path_not_allowed(self, client): + resp = client.put('/discover') + assert resp.status_code == 405 + + +# --------------------------------------------------------------------------- +# Group D — Query string and fragment handling +# --------------------------------------------------------------------------- + +class TestQueryStrings: + """Query strings must not affect routing decisions.""" + + def test_spa_path_with_query_string(self, client): + resp = client.get('/discover?q=linkin+park') + assert resp.status_code == 200 + assert resp.data == b'INDEX_HTML' + + def test_setup_wizard_query_preserved_on_root(self, client): + resp = client.get('/?setup=1') + assert resp.status_code == 200 + assert resp.data == b'INDEX_HTML' diff --git a/web_server.py b/web_server.py index 348bfb28..347d4c30 100644 --- a/web_server.py +++ b/web_server.py @@ -4878,7 +4878,7 @@ def index(): @app.route('/') def spa_catch_all(page): # Serve index.html for client-side routes; let Flask handle real routes first. - if page.startswith(('api/', 'static/', 'auth/', 'callback', 'tidal/', 'status')): + if page.startswith(('api/', 'static/', 'auth/', 'callback', 'deezer/', 'tidal/', 'status')): abort(404) return render_template('index.html')