From 48193608ea0a23962a0020747ad1370758b2d449 Mon Sep 17 00:00:00 2001 From: Christian Scheil Date: Tue, 24 Mar 2026 10:23:43 +0100 Subject: [PATCH 01/30] feat(presets): remove overwrite confirmation for occupied presets --- apps/frontend/src/pages/RadioPresets.tsx | 6 - .../frontend/tests/unit/RadioPresets.test.tsx | 140 +----------------- 2 files changed, 7 insertions(+), 139 deletions(-) diff --git a/apps/frontend/src/pages/RadioPresets.tsx b/apps/frontend/src/pages/RadioPresets.tsx index 799acdf6..8344371b 100644 --- a/apps/frontend/src/pages/RadioPresets.tsx +++ b/apps/frontend/src/pages/RadioPresets.tsx @@ -88,12 +88,6 @@ export default function RadioPresets({ devices = [] }: RadioPresetsProps) { const handleStationSelect = async (station: RadioStation) => { if (!assigningPreset || !currentDevice?.device_id) return; - // If preset slot already has a station, ask for overwrite confirmation - if (presets[assigningPreset]) { - setPendingStation(station); - return; - } - await doAssign(assigningPreset, station); }; diff --git a/apps/frontend/tests/unit/RadioPresets.test.tsx b/apps/frontend/tests/unit/RadioPresets.test.tsx index c241659f..982d98de 100644 --- a/apps/frontend/tests/unit/RadioPresets.test.tsx +++ b/apps/frontend/tests/unit/RadioPresets.test.tsx @@ -330,7 +330,7 @@ describe("RadioPresets Page", () => { }); describe("Preset Overwrite Flow", () => { - it("should show overwrite confirmation when assigning to occupied preset", async () => { + it("should directly overwrite an occupied preset without confirmation", async () => { vi.mocked(presetsApi.getDevicePresets).mockResolvedValue([ { id: 1, @@ -352,50 +352,11 @@ describe("RadioPresets Page", () => { expect(screen.getByTestId("preset-4-name")).toBeInTheDocument(); }); - // Click change on an occupied preset + // Click change on occupied preset → select new station fireEvent.click(screen.getByTestId("preset-4-change")); - - // Select a new station fireEvent.click(screen.getByTestId("select-station")); - // Should show overwrite confirmation - await waitFor(() => { - expect(screen.getByTestId("confirm-dialog-confirm")).toBeInTheDocument(); - }); - }); - - it("should overwrite preset when user confirms", async () => { - vi.mocked(presetsApi.getDevicePresets).mockResolvedValue([ - { - id: 1, - device_id: "AABBCC123456", - preset_number: 4, - station_uuid: "uuid-4", - station_name: "Old Radio", - station_url: "http://old.com", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - ]); - - await act(async () => { - render(); - }); - - await waitFor(() => { - expect(screen.getByTestId("preset-4-name")).toBeInTheDocument(); - }); - - // Click change → select new station → confirm overwrite - fireEvent.click(screen.getByTestId("preset-4-change")); - fireEvent.click(screen.getByTestId("select-station")); - - await waitFor(() => { - expect(screen.getByTestId("confirm-dialog-confirm")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("confirm-dialog-confirm")); - - // Should call setPreset API + // Should call setPreset API directly without confirmation dialog await waitFor(() => { expect(presetsApi.setPreset).toHaveBeenCalledWith( expect.objectContaining({ @@ -406,44 +367,6 @@ describe("RadioPresets Page", () => { ); }); }); - - it("should not overwrite preset when user cancels", async () => { - vi.mocked(presetsApi.getDevicePresets).mockResolvedValue([ - { - id: 1, - device_id: "AABBCC123456", - preset_number: 5, - station_uuid: "uuid-5", - station_name: "Radio Five", - station_url: "http://radio5.com", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - ]); - - await act(async () => { - render(); - }); - - await waitFor(() => { - expect(screen.getByTestId("preset-5-name")).toBeInTheDocument(); - }); - - // Click change → select new station → cancel - fireEvent.click(screen.getByTestId("preset-5-change")); - fireEvent.click(screen.getByTestId("select-station")); - - await waitFor(() => { - expect(screen.getByTestId("confirm-dialog-cancel")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("confirm-dialog-cancel")); - - // Should NOT call setPreset API - expect(presetsApi.setPreset).not.toHaveBeenCalled(); - - // Preset should still show old name - expect(screen.getByTestId("preset-5-name")).toHaveTextContent("Radio Five"); - }); }); describe("Preset Deletion", () => { @@ -660,7 +583,7 @@ describe("RadioPresets Page", () => { }); }); - it("should call setPreset API when overwriting existing preset", async () => { + it("should call setPreset API directly when overwriting existing preset", async () => { vi.mocked(presetsApi.getDevicePresets).mockResolvedValue([ { id: 1, @@ -682,16 +605,11 @@ describe("RadioPresets Page", () => { expect(screen.getByTestId("preset-4-name")).toBeInTheDocument(); }); - // Click change → select → confirm overwrite + // Click change → select station → should save directly fireEvent.click(screen.getByTestId("preset-4-change")); fireEvent.click(screen.getByTestId("select-station")); - await waitFor(() => { - expect(screen.getByTestId("confirm-dialog-confirm")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("confirm-dialog-confirm")); - - // Should call setPreset API + // Should call setPreset API directly without confirmation await waitFor(() => { expect(presetsApi.setPreset).toHaveBeenCalledWith( expect.objectContaining({ @@ -702,45 +620,6 @@ describe("RadioPresets Page", () => { }); }); - it("should not overwrite preset if user cancels confirmation", async () => { - // Setup preset first - vi.mocked(presetsApi.getDevicePresets).mockResolvedValue([ - { - id: 1, - device_id: "AABBCC123456", - preset_number: 5, - station_uuid: "uuid-5", - station_name: "Radio Five", - station_url: "http://radio5.com", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - ]); - - await act(async () => { - render(); - }); - - await waitFor(() => { - expect(screen.getByTestId("preset-5-name")).toBeInTheDocument(); - }); - - // Click change → select → cancel - fireEvent.click(screen.getByTestId("preset-5-change")); - fireEvent.click(screen.getByTestId("select-station")); - - await waitFor(() => { - expect(screen.getByTestId("confirm-dialog-cancel")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("confirm-dialog-cancel")); - - // Should NOT call setPreset API - expect(presetsApi.setPreset).not.toHaveBeenCalled(); - - // Preset should still be there - expect(screen.getByTestId("preset-5-name")).toBeInTheDocument(); - }); - it("should reload presets when device changes", async () => { const device1Presets = [ { @@ -880,15 +759,10 @@ describe("RadioPresets Page", () => { expect(screen.getByTestId("preset-1-name")).toBeInTheDocument(); }); - // Click change → select → confirm overwrite + // Click change → select station (saves directly, no confirmation) fireEvent.click(screen.getByTestId("preset-1-change")); fireEvent.click(screen.getByTestId("select-station")); - await waitFor(() => { - expect(screen.getByTestId("confirm-dialog-confirm")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("confirm-dialog-confirm")); - // Should display user-friendly error await waitFor(() => { expect(screen.getByTestId("error-message")).toHaveTextContent("Preset konnte nicht gespeichert werden"); From 1fda430edb2969eeecd2a6b7b2a318ce2f128403 Mon Sep 17 00:00:00 2001 From: Christian Scheil Date: Wed, 25 Mar 2026 10:48:20 +0100 Subject: [PATCH 02/30] fix(e2e): add detect-strategy and server-info intercepts to wizard tests --- .../frontend/tests/e2e/wizard-full-flow.cy.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/frontend/tests/e2e/wizard-full-flow.cy.ts b/apps/frontend/tests/e2e/wizard-full-flow.cy.ts index 83140c1e..3a166f94 100644 --- a/apps/frontend/tests/e2e/wizard-full-flow.cy.ts +++ b/apps/frontend/tests/e2e/wizard-full-flow.cy.ts @@ -96,6 +96,25 @@ function setupDeviceMocks() { message: "SSH dauerhaft aktiviert.", }, }).as("enablePermanentSSH"); + + cy.intercept("GET", "/api/setup/wizard/detect-strategy", { + statusCode: 200, + body: { + proxy_available: false, + strategy: "bmx_and_hosts", + message: "Kein Reverse-Proxy auf Port 443 erkannt. Die BMX-URL muss zusätzlich geändert werden.", + }, + }).as("detectStrategy"); + + cy.intercept("GET", "/api/setup/wizard/server-info", { + statusCode: 200, + body: { + server_url: "http://localhost:7778", + server_ip: "127.0.0.1", + default_port: 7777, + supported_protocols: ["http", "https"], + }, + }).as("serverInfo"); } // ─── Tests ─────────────────────────────────────────────────────────────────── From 0999db146f9e094bdd31abc3bfc1f0c7f75a9df4 Mon Sep 17 00:00:00 2001 From: Christian Scheil Date: Thu, 26 Mar 2026 08:14:59 +0100 Subject: [PATCH 03/30] feat(tunein): resolve TuneIn streams dynamically at playback time TuneIn stations have empty stream URLs (resolved lazily via Tune.ashx). Previously, the empty URL was stored in the preset base64 payload, causing the Bose device to load forever. Now store tuneinId in preset data and resolve fresh stream URLs via TuneIn API when the device requests playback. Also apply HTTPS-to-HTTP conversion on TuneIn stream URLs (Bose limitation). Extract convert_https_to_http into shared bmx/stream_utils module. --- .../opencloudtouch/bmx/radiobrowser_routes.py | 2 +- apps/backend/src/opencloudtouch/bmx/routes.py | 49 ++++++---- .../src/opencloudtouch/bmx/stream_utils.py | 26 +++++ apps/backend/src/opencloudtouch/bmx/tunein.py | 5 +- .../src/opencloudtouch/devices/client.py | 2 + .../opencloudtouch/devices/client_adapter.py | 4 + .../src/opencloudtouch/presets/service.py | 1 + .../tests/unit/bmx/test_orion_adapter.py | 95 ++++++++++++++++++- .../tests/unit/bmx/test_stream_utils.py | 27 ++++++ apps/backend/tests/unit/bmx/test_tunein.py | 42 ++++++++ 10 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 apps/backend/src/opencloudtouch/bmx/stream_utils.py create mode 100644 apps/backend/tests/unit/bmx/test_stream_utils.py diff --git a/apps/backend/src/opencloudtouch/bmx/radiobrowser_routes.py b/apps/backend/src/opencloudtouch/bmx/radiobrowser_routes.py index 736f0e8b..d1ad422a 100644 --- a/apps/backend/src/opencloudtouch/bmx/radiobrowser_routes.py +++ b/apps/backend/src/opencloudtouch/bmx/radiobrowser_routes.py @@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse from opencloudtouch.bmx.models import BmxAudio, BmxPlaybackResponse, BmxStream -from opencloudtouch.bmx.routes import convert_https_to_http +from opencloudtouch.bmx.stream_utils import convert_https_to_http from opencloudtouch.bmx.tunein import get_oct_base_url from opencloudtouch.radio.adapter import get_radio_adapter from opencloudtouch.radio.providers.radiobrowser import ( diff --git a/apps/backend/src/opencloudtouch/bmx/routes.py b/apps/backend/src/opencloudtouch/bmx/routes.py index 6a6e2b12..dac48d4b 100644 --- a/apps/backend/src/opencloudtouch/bmx/routes.py +++ b/apps/backend/src/opencloudtouch/bmx/routes.py @@ -25,6 +25,7 @@ BmxServicesResponse, BmxStream, ) +from opencloudtouch.bmx.stream_utils import convert_https_to_http from opencloudtouch.bmx.tunein import get_oct_base_url, resolve_tunein_station logger = logging.getLogger(__name__) @@ -32,27 +33,6 @@ router = APIRouter(tags=["bmx"]) -def convert_https_to_http(url: str) -> str: - """Convert HTTPS URLs to HTTP for Bose device compatibility. - - Bose SoundTouch devices cannot play HTTPS streams directly. - Most radio stations support both HTTP and HTTPS, so we try HTTP first. - - Args: - url: Stream URL (may be HTTPS or HTTP) - - Returns: - HTTP version of the URL (https:// → http://) - """ - if url.startswith("https://"): - http_url = "http://" + url[8:] - logger.info( - f"[BMX] Converting HTTPS to HTTP: {url[:50]}... → {http_url[:50]}..." - ) - return http_url - return url - - # ============================================================================= # BMX Registry Endpoint # ============================================================================= @@ -244,9 +224,15 @@ async def custom_stream_playback(request: Request) -> JSONResponse: json_obj = json.loads(json_str) stream_url = json_obj.get("streamUrl", "") + tunein_id = json_obj.get("tuneinId", "") image_url = json_obj.get("imageUrl", "") name = json_obj.get("name", "Custom Station") + # TuneIn stations: resolve stream URL dynamically via TuneIn API + if tunein_id and not stream_url: + logger.info(f"[BMX ORION] TuneIn station detected: {tunein_id} ({name})") + return await _resolve_tunein_for_orion(tunein_id) + # Convert HTTPS to HTTP - Bose devices can't play HTTPS streams stream_url = convert_https_to_http(stream_url) @@ -284,3 +270,24 @@ async def custom_stream_playback(request: Request) -> JSONResponse: status_code=500, headers={"Access-Control-Allow-Origin": "*"}, ) + + +async def _resolve_tunein_for_orion(tunein_id: str) -> JSONResponse: + """Resolve TuneIn station dynamically for Orion playback. + + Called when a preset contains a tuneinId but no streamUrl. + Fetches fresh stream URL from TuneIn API at playback time. + """ + try: + response = await resolve_tunein_station(tunein_id) + return JSONResponse( + content=response.model_dump(), + headers={"Access-Control-Allow-Origin": "*"}, + ) + except Exception as e: + logger.error(f"[BMX ORION] TuneIn resolution failed for {tunein_id}: {e}") + return JSONResponse( + content={"error": f"TuneIn resolution failed: {e}"}, + status_code=500, + headers={"Access-Control-Allow-Origin": "*"}, + ) diff --git a/apps/backend/src/opencloudtouch/bmx/stream_utils.py b/apps/backend/src/opencloudtouch/bmx/stream_utils.py new file mode 100644 index 00000000..8fa635ec --- /dev/null +++ b/apps/backend/src/opencloudtouch/bmx/stream_utils.py @@ -0,0 +1,26 @@ +"""Shared stream URL utilities for BMX modules.""" + +import logging + +logger = logging.getLogger(__name__) + + +def convert_https_to_http(url: str) -> str: + """Convert HTTPS URLs to HTTP for Bose device compatibility. + + Bose SoundTouch devices cannot play HTTPS streams directly. + Most radio stations support both HTTP and HTTPS, so we try HTTP first. + + Args: + url: Stream URL (may be HTTPS or HTTP) + + Returns: + HTTP version of the URL (https:// → http://) + """ + if url.startswith("https://"): + http_url = "http://" + url[8:] + logger.info( + f"[BMX] Converting HTTPS to HTTP: {url[:50]}... → {http_url[:50]}..." + ) + return http_url + return url diff --git a/apps/backend/src/opencloudtouch/bmx/tunein.py b/apps/backend/src/opencloudtouch/bmx/tunein.py index 9d0fceb2..f906ea43 100644 --- a/apps/backend/src/opencloudtouch/bmx/tunein.py +++ b/apps/backend/src/opencloudtouch/bmx/tunein.py @@ -12,6 +12,7 @@ import httpx from opencloudtouch.bmx.models import BmxAudio, BmxPlaybackResponse, BmxStream +from opencloudtouch.bmx.stream_utils import convert_https_to_http logger = logging.getLogger(__name__) @@ -96,7 +97,9 @@ async def resolve_tunein_station(station_id: str) -> BmxPlaybackResponse: stream_resp = await client.get(TUNEIN_STREAM_URL % station_id) stream_urls = [ - u.strip() for u in stream_resp.text.splitlines() if u.strip() + convert_https_to_http(u.strip()) + for u in stream_resp.text.splitlines() + if u.strip() ] if not stream_urls: diff --git a/apps/backend/src/opencloudtouch/devices/client.py b/apps/backend/src/opencloudtouch/devices/client.py index e2c2260f..8103b405 100644 --- a/apps/backend/src/opencloudtouch/devices/client.py +++ b/apps/backend/src/opencloudtouch/devices/client.py @@ -125,6 +125,7 @@ async def store_preset( station_name: str, oct_backend_url: str, station_image_url: str = "", + station_uuid: str = "", ) -> None: """ Store a preset on the Bose device. @@ -136,6 +137,7 @@ async def store_preset( station_name: Station display name oct_backend_url: OCT backend base URL station_image_url: Optional station logo URL + station_uuid: Optional station ID (used for TuneIn dynamic resolution) """ pass diff --git a/apps/backend/src/opencloudtouch/devices/client_adapter.py b/apps/backend/src/opencloudtouch/devices/client_adapter.py index 7925b9db..46c11a3d 100644 --- a/apps/backend/src/opencloudtouch/devices/client_adapter.py +++ b/apps/backend/src/opencloudtouch/devices/client_adapter.py @@ -212,6 +212,7 @@ async def store_preset( station_name: str, oct_backend_url: str, station_image_url: str = "", + station_uuid: str = "", ) -> None: """ Store a preset on the Bose device using LOCAL_INTERNET_RADIO + Orion adapter. @@ -264,6 +265,9 @@ async def store_preset( "name": station_name, "imageUrl": station_image_url, } + # TuneIn stations have empty URL - store station ID for dynamic resolution + if not station_url and station_uuid: + stream_data["tuneinId"] = station_uuid json_str = json.dumps(stream_data) base64_data = base64.urlsafe_b64encode(json_str.encode()).decode() diff --git a/apps/backend/src/opencloudtouch/presets/service.py b/apps/backend/src/opencloudtouch/presets/service.py index ef6e8cff..736b4f04 100644 --- a/apps/backend/src/opencloudtouch/presets/service.py +++ b/apps/backend/src/opencloudtouch/presets/service.py @@ -110,6 +110,7 @@ async def set_preset( station_name=station_name, oct_backend_url=oct_backend_url, station_image_url=station_favicon or "", + station_uuid=station_uuid, ) logger.info( f"✅ Bose device programmed: Preset {preset_number} = {station_name}" diff --git a/apps/backend/tests/unit/bmx/test_orion_adapter.py b/apps/backend/tests/unit/bmx/test_orion_adapter.py index ac3ffcda..bf53f8b5 100644 --- a/apps/backend/tests/unit/bmx/test_orion_adapter.py +++ b/apps/backend/tests/unit/bmx/test_orion_adapter.py @@ -9,12 +9,14 @@ import base64 import json +from unittest.mock import AsyncMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from opencloudtouch.bmx.routes import BmxAudio, BmxPlaybackResponse, BmxStream, router +from opencloudtouch.bmx.models import BmxAudio, BmxPlaybackResponse, BmxStream +from opencloudtouch.bmx.routes import router @pytest.fixture @@ -411,3 +413,94 @@ def test_german_radio_stations(self, client, name, stream_url): body = response.json() assert body["name"] == name assert body["audio"]["streamUrl"] == stream_url + + +def encode_tunein_data(tunein_id: str, name: str, image_url: str = "") -> str: + """Helper to encode TuneIn station data as base64 (empty streamUrl + tuneinId).""" + data = { + "streamUrl": "", + "name": name, + "imageUrl": image_url, + "tuneinId": tunein_id, + } + return base64.urlsafe_b64encode(json.dumps(data).encode()).decode() + + +class TestOrionTuneInResolution: + """Tests for dynamic TuneIn stream resolution via Orion adapter.""" + + def test_tunein_station_resolves_dynamically(self, client): + """TuneIn preset with empty streamUrl + tuneinId resolves at playback time.""" + data = encode_tunein_data("s158432", "Absolut Relax") + mock_response = BmxPlaybackResponse( + audio=BmxAudio( + streamUrl="http://stream.absolut.at/relax.mp3", + streams=[BmxStream(streamUrl="http://stream.absolut.at/relax.mp3")], + ), + imageUrl="https://cdn.tunein.com/s158432q.png", + name="Absolut Relax", + ) + + with patch( + "opencloudtouch.bmx.routes.resolve_tunein_station", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_resolve: + response = client.get( + f"/core02/svc-bmx-adapter-orion/prod/orion/station?data={data}" + ) + + assert response.status_code == 200 + body = response.json() + assert body["audio"]["streamUrl"] == "http://stream.absolut.at/relax.mp3" + assert body["name"] == "Absolut Relax" + mock_resolve.assert_called_once_with("s158432") + + def test_tunein_resolution_failure_returns_500(self, client): + """TuneIn resolution failure returns 500 with error details.""" + data = encode_tunein_data("s999999", "Unknown Station") + + with patch( + "opencloudtouch.bmx.routes.resolve_tunein_station", + new_callable=AsyncMock, + side_effect=ValueError("No stream URLs found for station s999999"), + ): + response = client.get( + f"/core02/svc-bmx-adapter-orion/prod/orion/station?data={data}" + ) + + assert response.status_code == 500 + body = response.json() + assert "error" in body + assert "TuneIn resolution failed" in body["error"] + + def test_regular_stream_still_works_with_tunein_id_absent(self, client): + """Regular stream (no tuneinId) continues to work as before.""" + stream_url = "http://stream.example.com/radio.mp3" + data = encode_stream_data(stream_url, "Normal Radio") + + response = client.get( + f"/core02/svc-bmx-adapter-orion/prod/orion/station?data={data}" + ) + + assert response.status_code == 200 + body = response.json() + assert body["audio"]["streamUrl"] == stream_url + + def test_tunein_id_with_streamurl_uses_streamurl(self, client): + """When both tuneinId and streamUrl are present, use streamUrl directly.""" + data_dict = { + "streamUrl": "http://direct.example.com/stream.mp3", + "name": "Station", + "imageUrl": "", + "tuneinId": "s158432", + } + data = base64.urlsafe_b64encode(json.dumps(data_dict).encode()).decode() + + response = client.get( + f"/core02/svc-bmx-adapter-orion/prod/orion/station?data={data}" + ) + + assert response.status_code == 200 + body = response.json() + assert body["audio"]["streamUrl"] == "http://direct.example.com/stream.mp3" diff --git a/apps/backend/tests/unit/bmx/test_stream_utils.py b/apps/backend/tests/unit/bmx/test_stream_utils.py new file mode 100644 index 00000000..d693d47c --- /dev/null +++ b/apps/backend/tests/unit/bmx/test_stream_utils.py @@ -0,0 +1,27 @@ +"""Unit tests for BMX stream URL utilities.""" + +from opencloudtouch.bmx.stream_utils import convert_https_to_http + + +class TestConvertHttpsToHttp: + """Tests for HTTPS → HTTP conversion (Bose device compatibility).""" + + def test_converts_https_to_http(self): + assert ( + convert_https_to_http("https://stream.example.com/radio.mp3") + == "http://stream.example.com/radio.mp3" + ) + + def test_leaves_http_unchanged(self): + assert ( + convert_https_to_http("http://stream.example.com/radio.mp3") + == "http://stream.example.com/radio.mp3" + ) + + def test_empty_string(self): + assert convert_https_to_http("") == "" + + def test_preserves_path_and_query(self): + url = "https://cdn.example.com/live/stream.mp3?token=abc123&format=mp3" + expected = "http://cdn.example.com/live/stream.mp3?token=abc123&format=mp3" + assert convert_https_to_http(url) == expected diff --git a/apps/backend/tests/unit/bmx/test_tunein.py b/apps/backend/tests/unit/bmx/test_tunein.py index 0e216594..7b5e77f2 100644 --- a/apps/backend/tests/unit/bmx/test_tunein.py +++ b/apps/backend/tests/unit/bmx/test_tunein.py @@ -72,6 +72,48 @@ async def test_resolve_station_success(self): ) assert result.streamType == "liveRadio" + @pytest.mark.asyncio + async def test_resolve_station_converts_https_to_http(self): + """Test that HTTPS stream URLs are converted to HTTP for Bose compatibility.""" + station_id = "s158432" + describe_xml = """ + + + + + HTTPS Station + + + +""" + # TuneIn returns HTTPS URLs + stream_urls = "https://secure.stream.example.com/live.mp3\nhttps://backup.stream.example.com/live.aac" + + mock_response_describe = MagicMock() + mock_response_describe.text = describe_xml + mock_response_stream = MagicMock() + mock_response_stream.text = stream_urls + + with patch("opencloudtouch.bmx.tunein.httpx.AsyncClient") as mock_client: + mock_context = AsyncMock() + mock_context.__aenter__.return_value.get = AsyncMock( + side_effect=[mock_response_describe, mock_response_stream] + ) + mock_client.return_value = mock_context + + result = await resolve_tunein_station(station_id) + + # HTTPS should be converted to HTTP + assert result.audio.streamUrl == "http://secure.stream.example.com/live.mp3" + assert ( + result.audio.streams[0].streamUrl + == "http://secure.stream.example.com/live.mp3" + ) + assert ( + result.audio.streams[1].streamUrl + == "http://backup.stream.example.com/live.aac" + ) + @pytest.mark.asyncio async def test_resolve_station_minimal_xml(self): """Test resolution with minimal XML (missing logo).""" From 3b9c30fc0b25a0a9c2eb4eac451e006a4dd34ec6 Mon Sep 17 00:00:00 2001 From: Christian Scheil Date: Thu, 26 Mar 2026 08:53:49 +0100 Subject: [PATCH 04/30] fix(e2e): add detect-strategy and server-info intercepts to wizard UX screenshot tests --- .../tests/e2e/ux/ux-wizard-screenshots.cy.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/frontend/tests/e2e/ux/ux-wizard-screenshots.cy.ts b/apps/frontend/tests/e2e/ux/ux-wizard-screenshots.cy.ts index 5bef6246..b84dcfb5 100644 --- a/apps/frontend/tests/e2e/ux/ux-wizard-screenshots.cy.ts +++ b/apps/frontend/tests/e2e/ux/ux-wizard-screenshots.cy.ts @@ -251,6 +251,25 @@ function setupWizardMocks() { statusCode: 200, body: { success: true, message: "Neustart eingeleitet" }, }).as("rebootDevice"); + + // Intercept detect-strategy and server-info to prevent real backend calls + // that could return proxy_available=true and hide the config modification button + cy.intercept("GET", "/api/setup/wizard/detect-strategy", { + statusCode: 200, + body: { + proxy_available: false, + strategy: "bmx_and_hosts", + message: "Standard-Strategie: BMX + Hosts", + }, + }).as("detectStrategy"); + + cy.intercept("GET", "/api/setup/wizard/server-info", { + statusCode: 200, + body: { + server_url: "http://localhost:7778", + server_ip: "127.0.0.1", + }, + }).as("serverInfo"); } /** Wait for wizard to be ready at Step 1 (mode selector was removed; wizard starts directly) */ From 7e99ea8d4b7a6c60ea03cdd5cf9efe3ca58ba281 Mon Sep 17 00:00:00 2001 From: Christian Scheil Date: Fri, 27 Mar 2026 07:52:05 +0100 Subject: [PATCH 05/30] feat(streaming): improve error handling for upstream connections and response status --- .../devices/api/preset_stream_routes.py | 136 +++++++++--------- .../devices/test_preset_stream_endpoint.py | 3 - .../devices/api/test_preset_stream_routes.py | 66 ++++----- apps/frontend/src/hooks/usePresets.ts | 1 + apps/frontend/src/pages/RadioPresets.tsx | 17 ++- .../e2e/preset-management-advanced.cy.ts | 24 ++-- 6 files changed, 123 insertions(+), 124 deletions(-) diff --git a/apps/backend/src/opencloudtouch/devices/api/preset_stream_routes.py b/apps/backend/src/opencloudtouch/devices/api/preset_stream_routes.py index 91ce0698..3d1a3110 100644 --- a/apps/backend/src/opencloudtouch/devices/api/preset_stream_routes.py +++ b/apps/backend/src/opencloudtouch/devices/api/preset_stream_routes.py @@ -106,85 +106,87 @@ async def stream_device_preset( }, ) - # Stream generator that manages the upstream connection + # Open upstream connection and check status BEFORE sending response headers. + # This ensures we can return proper HTTP error codes (502) instead of + # failing inside a StreamingResponse generator (where headers are already sent). + http_client = httpx.AsyncClient(timeout=30.0, follow_redirects=True) + try: + upstream_response = await http_client.send( + httpx.Request( + "GET", + preset.station_url, + headers={ + "User-Agent": "OpenCloudTouch/0.2.0 (Bose SoundTouch Proxy)", + "Icy-MetaData": "1", + }, + ), + stream=True, + ) + except httpx.RequestError as e: + await http_client.aclose() + logger.error( + f"[502] Failed to fetch RadioBrowser stream: {e}", + extra={ + "device_id": device_id, + "preset_id": preset_id, + "upstream_url": preset.station_url, + "error": str(e), + }, + exc_info=True, + ) + raise HTTPException( + status_code=502, + detail=f"Failed to connect to RadioBrowser: {e}", + ) + + # Check upstream status before committing to StreamingResponse + if upstream_response.status_code != 200: + await upstream_response.aclose() + await http_client.aclose() + logger.error( + f"[502] RadioBrowser stream unavailable: HTTP {upstream_response.status_code}", + extra={ + "device_id": device_id, + "preset_id": preset_id, + "upstream_status": upstream_response.status_code, + "upstream_url": preset.station_url, + }, + ) + raise HTTPException( + status_code=502, + detail=f"RadioBrowser stream unavailable: HTTP {upstream_response.status_code}", + ) + + content_type = upstream_response.headers.get("content-type", "audio/mpeg") + logger.info( + f"[STREAMING] {preset.station_name} → Bose device (HTTP proxy active)", + extra={ + "device_id": device_id, + "preset_id": preset_id, + "content_type": content_type, + "upstream_headers": dict(upstream_response.headers), + }, + ) + async def stream_generator(): - """Generator that fetches and yields audio chunks from RadioBrowser.""" + """Generator that yields audio chunks from the already-opened upstream.""" try: - async with httpx.AsyncClient( - timeout=30.0, follow_redirects=True - ) as client: - async with client.stream( - "GET", - preset.station_url, - headers={ - "User-Agent": "OpenCloudTouch/0.2.0 (Bose SoundTouch Proxy)", - "Icy-MetaData": "1", - }, - ) as upstream_response: - # Check if stream is available - if upstream_response.status_code != 200: - logger.error( - f"[502] RadioBrowser stream unavailable: HTTP {upstream_response.status_code}", - extra={ - "device_id": device_id, - "preset_id": preset_id, - "upstream_status": upstream_response.status_code, - "upstream_url": preset.station_url, - }, - ) - raise HTTPException( - status_code=502, - detail=f"RadioBrowser stream unavailable: HTTP {upstream_response.status_code}", - ) - - # Detect content type - content_type = upstream_response.headers.get( - "content-type", "audio/mpeg" - ) - - logger.info( - f"[STREAMING] {preset.station_name} → Bose device (HTTP proxy active)", - extra={ - "device_id": device_id, - "preset_id": preset_id, - "content_type": content_type, - "upstream_headers": dict(upstream_response.headers), - }, - ) - - # Stream audio chunks - async for chunk in upstream_response.aiter_bytes( - chunk_size=8192 - ): - yield chunk - - except httpx.RequestError as e: - logger.error( - f"[502] Failed to fetch RadioBrowser stream: {e}", - extra={ - "device_id": device_id, - "preset_id": preset_id, - "upstream_url": preset.station_url, - "error": str(e), - }, - exc_info=True, - ) - raise HTTPException( - status_code=502, - detail=f"Failed to connect to RadioBrowser: {e}", - ) + async for chunk in upstream_response.aiter_bytes(chunk_size=8192): + yield chunk except Exception as e: logger.error( f"[STREAM ERROR] Proxy interrupted: {e}", extra={"device_id": device_id, "preset_id": preset_id}, exc_info=True, ) - raise + finally: + await upstream_response.aclose() + await http_client.aclose() # Return streaming response to Bose device return StreamingResponse( stream_generator(), - media_type="audio/mpeg", # Will be updated by generator + media_type=content_type, headers={ "icy-name": preset.station_name, "Cache-Control": "no-cache, no-store, must-revalidate", diff --git a/apps/backend/tests/integration/devices/test_preset_stream_endpoint.py b/apps/backend/tests/integration/devices/test_preset_stream_endpoint.py index d13da04b..680baccf 100644 --- a/apps/backend/tests/integration/devices/test_preset_stream_endpoint.py +++ b/apps/backend/tests/integration/devices/test_preset_stream_endpoint.py @@ -298,9 +298,6 @@ async def test_stream_preset_all_slots(real_api_client: AsyncClient): @pytest.mark.asyncio -@pytest.mark.skip( - reason="StreamingResponse cannot change HTTP status after headers sent - error handling limitation" -) async def test_stream_upstream_unavailable_returns_502(real_api_client: AsyncClient): """Test that upstream stream unavailable returns HTTP 502. diff --git a/apps/backend/tests/unit/devices/api/test_preset_stream_routes.py b/apps/backend/tests/unit/devices/api/test_preset_stream_routes.py index b5062db1..ff9cb697 100644 --- a/apps/backend/tests/unit/devices/api/test_preset_stream_routes.py +++ b/apps/backend/tests/unit/devices/api/test_preset_stream_routes.py @@ -61,8 +61,7 @@ def test_stream_returns_streaming_response( mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"content-type": "audio/mpeg"} - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock(return_value=False) + mock_response.aclose = AsyncMock() async def mock_aiter_bytes(chunk_size=8192): yield b"audio_data_chunk_1" @@ -70,12 +69,14 @@ async def mock_aiter_bytes(chunk_size=8192): mock_response.aiter_bytes = mock_aiter_bytes - mock_client_ctx = MagicMock() - mock_client_ctx.__aenter__ = AsyncMock(return_value=mock_client_ctx) - mock_client_ctx.__aexit__ = AsyncMock(return_value=False) - mock_client_ctx.stream = MagicMock(return_value=mock_response) + mock_http_client = MagicMock() + mock_http_client.send = AsyncMock(return_value=mock_response) + mock_http_client.aclose = AsyncMock() - with patch("httpx.AsyncClient", return_value=mock_client_ctx): + with patch( + "opencloudtouch.devices.api.preset_stream_routes.httpx.AsyncClient", + return_value=mock_http_client, + ): with client.stream("GET", "/device/689E194F7D2F/preset/1") as response: assert response.status_code == 200 assert "audio" in response.headers.get("content-type", "") @@ -89,25 +90,18 @@ def test_upstream_non_200_raises_502( mock_response = MagicMock() mock_response.status_code = 503 mock_response.headers = {} - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock(return_value=False) + mock_response.aclose = AsyncMock() - async def mock_aiter_bytes(chunk_size=8192): - return - yield # make it async generator - - mock_response.aiter_bytes = mock_aiter_bytes + mock_http_client = MagicMock() + mock_http_client.send = AsyncMock(return_value=mock_response) + mock_http_client.aclose = AsyncMock() - mock_client_ctx = MagicMock() - mock_client_ctx.__aenter__ = AsyncMock(return_value=mock_client_ctx) - mock_client_ctx.__aexit__ = AsyncMock(return_value=False) - mock_client_ctx.stream = MagicMock(return_value=mock_response) - - with patch("httpx.AsyncClient", return_value=mock_client_ctx): - # The 502 is raised inside the generator; TestClient will propagate it - with pytest.raises(Exception): - with client.stream("GET", "/device/689E194F7D2F/preset/1") as resp: - resp.read() + with patch( + "opencloudtouch.devices.api.preset_stream_routes.httpx.AsyncClient", + return_value=mock_http_client, + ): + response = client.get("/device/689E194F7D2F/preset/1") + assert response.status_code == 502 def test_httpx_request_error_raises_502( self, client, mock_preset_service, sample_preset @@ -117,22 +111,18 @@ def test_httpx_request_error_raises_502( mock_preset_service.get_preset = AsyncMock(return_value=sample_preset) - mock_client_ctx = MagicMock() - mock_client_ctx.__aenter__ = AsyncMock(return_value=mock_client_ctx) - mock_client_ctx.__aexit__ = AsyncMock(return_value=False) - - # stream() returns context manager that raises on __aenter__ - mock_stream_ctx = MagicMock() - mock_stream_ctx.__aenter__ = AsyncMock( + mock_http_client = MagicMock() + mock_http_client.send = AsyncMock( side_effect=httpx.ConnectError("Connection refused") ) - mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) - mock_client_ctx.stream = MagicMock(return_value=mock_stream_ctx) - - with patch("httpx.AsyncClient", return_value=mock_client_ctx): - with pytest.raises(Exception): - with client.stream("GET", "/device/689E194F7D2F/preset/1") as resp: - resp.read() + mock_http_client.aclose = AsyncMock() + + with patch( + "opencloudtouch.devices.api.preset_stream_routes.httpx.AsyncClient", + return_value=mock_http_client, + ): + response = client.get("/device/689E194F7D2F/preset/1") + assert response.status_code == 502 class TestGetPresetDescriptor: diff --git a/apps/frontend/src/hooks/usePresets.ts b/apps/frontend/src/hooks/usePresets.ts index 1849198f..5ae1a661 100644 --- a/apps/frontend/src/hooks/usePresets.ts +++ b/apps/frontend/src/hooks/usePresets.ts @@ -179,6 +179,7 @@ export function usePresets(deviceId: string | undefined): UsePresetsResult { } catch (err) { console.error("[usePresets] Failed to save preset:", err); setError("Preset konnte nicht gespeichert werden. Bitte versuchen Sie es erneut."); + throw err; } finally { setLoading(false); } diff --git a/apps/frontend/src/pages/RadioPresets.tsx b/apps/frontend/src/pages/RadioPresets.tsx index 8344371b..ea766a23 100644 --- a/apps/frontend/src/pages/RadioPresets.tsx +++ b/apps/frontend/src/pages/RadioPresets.tsx @@ -94,11 +94,18 @@ export default function RadioPresets({ devices = [] }: RadioPresetsProps) { const doAssign = async (presetNumber: number, station: RadioStation) => { if (!currentDevice?.device_id) return; - await assignStation(presetNumber, station, currentDevice.device_id); - setAssigningPreset(null); - setSearchOpen(false); - setPendingStation(null); - showToast(t("presets.presetSaved", { number: presetNumber }), "success"); + try { + await assignStation(presetNumber, station, currentDevice.device_id); + setAssigningPreset(null); + setSearchOpen(false); + setPendingStation(null); + showToast(t("presets.presetSaved", { number: presetNumber }), "success"); + } catch { + // Error state is already set in usePresets — keep modal closed so user sees the error + setAssigningPreset(null); + setSearchOpen(false); + setPendingStation(null); + } }; const handleConfirmOverwrite = async () => { diff --git a/apps/frontend/tests/e2e/preset-management-advanced.cy.ts b/apps/frontend/tests/e2e/preset-management-advanced.cy.ts index 41068881..c7614f9d 100644 --- a/apps/frontend/tests/e2e/preset-management-advanced.cy.ts +++ b/apps/frontend/tests/e2e/preset-management-advanced.cy.ts @@ -152,7 +152,7 @@ describe("Preset Management Advanced", () => { cy.get('[data-testid="preset-empty-2"]').should("exist"); }); - it.skip("should allow setting preset after clearing", () => { + it("should allow setting preset after clearing", () => { // Set preset 3 cy.request("POST", `${apiUrl}/presets/set`, { device_id: deviceId, @@ -186,8 +186,8 @@ describe("Preset Management Advanced", () => { }); }); - describe("Preset Overwrite (CURRENTLY BROKEN)", () => { - it.skip("should overwrite preset after clearing first", () => { + describe("Preset Overwrite", () => { + it("should overwrite preset after clearing first", () => { // Assign preset 4 with Station A cy.request("POST", `${apiUrl}/presets/set`, { device_id: deviceId, @@ -443,20 +443,21 @@ describe("Preset Management Advanced", () => { }); describe("Error Handling", () => { - it.skip("should handle preset save failure gracefully", () => { + it("should handle preset save failure gracefully", () => { cy.intercept("POST", `/api/presets/set`, { statusCode: 500, body: { detail: "Database error" }, }).as("setPresetFail"); - cy.get(".preset-empty").first().click(); + cy.get(".preset-empty").first().scrollIntoView().click({ force: true }); cy.get(".radio-search-modal", { timeout: 10000 }).should("be.visible"); cy.get(".search-input").type("BBC"); cy.get(".search-results", { timeout: 10000 }).should("be.visible"); selectFirstSearchResult(); - // Wait for error state and modal to remain open - cy.get(".error-message", { timeout: 5000 }).should("be.visible"); + // Modal closes, error message appears in page + cy.get(".radio-search-modal", { timeout: 10000 }).should("not.exist"); + cy.get('[data-testid="error-message"]', { timeout: 5000 }).should("be.visible"); }); it("should handle preset clear failure gracefully", () => { @@ -492,21 +493,22 @@ describe("Preset Management Advanced", () => { cy.get('[data-testid="preset-play-2"]').should("contain", stationA.name); }); - it.skip("should dismiss error messages", () => { + it("should dismiss error messages", () => { // Trigger error cy.intercept("POST", `/api/presets/set`, { statusCode: 400, body: { detail: "Invalid data" }, }).as("setPresetFail"); - cy.get(".preset-empty").first().click(); + cy.get(".preset-empty").first().scrollIntoView().click({ force: true }); cy.get(".radio-search-modal", { timeout: 10000 }).should("be.visible"); cy.get(".search-input").type("BBC"); cy.get(".search-results", { timeout: 10000 }).should("be.visible"); selectFirstSearchResult(); - cy.wait("@setPresetFail"); - cy.get('[data-testid="error-message"]').should("be.visible"); + // Modal closes, error message appears in page + cy.get(".radio-search-modal", { timeout: 10000 }).should("not.exist"); + cy.get('[data-testid="error-message"]', { timeout: 5000 }).should("be.visible"); // Dismiss error cy.get('[data-testid="error-message"] button').click(); From 30d420c51433e0bd0692f7af732935b05015c0d8 Mon Sep 17 00:00:00 2001 From: Christian Scheil Date: Sun, 3 May 2026 01:33:51 +0200 Subject: [PATCH 06/30] style: normalize line endings across codebase --- .gitignore | 5 + .../regression/test_device_removal_cascade.py | 229 ++++++++++ .../test_offline_device_blocks_server.py | 243 +++++++++++ apps/frontend/src/api/bugReport.ts | 43 ++ .../src/components/BugReportModal.css | 205 +++++++++ .../src/components/BugReportModal.tsx | 392 ++++++++++++++++++ apps/frontend/src/hooks/useZoneBuilder.ts | 113 +++++ apps/frontend/src/utils/logBuffer.ts | 45 ++ docs/ARCHITECTURE.md | 122 ++++++ docs/adr/001-sqlite-database.md | 25 ++ docs/adr/002-fastapi-framework.md | 26 ++ docs/adr/003-prebuilt-frontend.md | 24 ++ docs/adr/004-no-authentication.md | 26 ++ docs/adr/005-host-network-mode.md | 26 ++ 14 files changed, 1524 insertions(+) create mode 100644 apps/backend/tests/regression/test_device_removal_cascade.py create mode 100644 apps/backend/tests/regression/test_offline_device_blocks_server.py create mode 100644 apps/frontend/src/api/bugReport.ts create mode 100644 apps/frontend/src/components/BugReportModal.css create mode 100644 apps/frontend/src/components/BugReportModal.tsx create mode 100644 apps/frontend/src/hooks/useZoneBuilder.ts create mode 100644 apps/frontend/src/utils/logBuffer.ts create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/adr/001-sqlite-database.md create mode 100644 docs/adr/002-fastapi-framework.md create mode 100644 docs/adr/003-prebuilt-frontend.md create mode 100644 docs/adr/004-no-authentication.md create mode 100644 docs/adr/005-host-network-mode.md diff --git a/.gitignore b/.gitignore index b266fc69..646513c0 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,11 @@ apps/backend/openapi.yaml *_TEMP.md CONTEXT_TEMP.md *.log +result.txt +test-output.txt +test*.txt +test_full.txt +apps/backend/result.txt AGENTS.md .github/copilot-instructions.md .github/prompts/ diff --git a/apps/backend/tests/regression/test_device_removal_cascade.py b/apps/backend/tests/regression/test_device_removal_cascade.py new file mode 100644 index 00000000..081a2feb --- /dev/null +++ b/apps/backend/tests/regression/test_device_removal_cascade.py @@ -0,0 +1,229 @@ +""" +Regression test for device removal cascade failure. + +BUGFIX: Removing a device from the network causes ALL other devices to fail. + +Date: 2026-04-06 +Symptom: After removing one device from the network, navigating to the app + on mobile shows "blurred" skeleton tiles instead of presets for ALL + devices. Adding new devices also fails. Database wipe required to + recover. + +Root Cause: Multiple aiosqlite connections to the same database file without + WAL mode and without busy_timeout. When concurrent write + operations happen (e.g. health-check upsert + preset sync), + SQLITE_BUSY errors occur. Without rollback-on-error, failed + transactions leave dangling locks that block ALL subsequent + database operations across ALL repositories. + +Fix: Enable WAL journal mode and set busy_timeout in BaseRepository to + handle concurrent access from multiple connections gracefully. +""" + +import asyncio +import tempfile +from pathlib import Path + +import pytest + +from opencloudtouch.db import Device, DeviceRepository +from opencloudtouch.presets.models import Preset +from opencloudtouch.presets.repository import PresetRepository + + +@pytest.fixture +async def shared_db_path(): + """Provide a single DB file path shared by multiple repos.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "shared.db" + + +@pytest.fixture +async def device_repo(shared_db_path): + repo = DeviceRepository(str(shared_db_path)) + await repo.initialize() + yield repo + await repo.close() + + +@pytest.fixture +async def preset_repo(shared_db_path): + repo = PresetRepository(str(shared_db_path)) + await repo.initialize() + yield repo + await repo.close() + + +def _make_device(device_id: str, ip: str, name: str) -> Device: + return Device( + device_id=device_id, + ip=ip, + name=name, + model="SoundTouch 10", + mac_address=f"AA:BB:CC:DD:EE:{device_id[-2:]}", + firmware_version="28.0.3.46454", + ) + + +def _make_preset(device_id: str, number: int, station_name: str) -> Preset: + return Preset( + device_id=device_id, + preset_number=number, + station_uuid=f"uuid-{station_name.lower().replace(' ', '-')}", + station_name=station_name, + station_url=f"http://stream.example.com/{station_name.lower()}.mp3", + ) + + +class TestDeviceRemovalCascadeFailure: + """Regression: one offline device must NOT break preset loading for others.""" + + @pytest.mark.asyncio + async def test_concurrent_repo_writes_no_sqlite_busy( + self, device_repo, preset_repo + ): + """Concurrent writes from two connections must not cause SQLITE_BUSY. + + Simulates: health-check upserts device while preset sync writes presets. + Before fix (no WAL, no busy_timeout): SQLITE_BUSY → cascade failure. + After fix: both operations succeed. + """ + device = _make_device("DEV001", "192.168.1.10", "Living Room") + await device_repo.upsert(device) + + # Run concurrent writes from two different connections + errors = [] + + async def health_check_writes(): + """Simulate health-check updating last_seen for many devices.""" + for i in range(20): + try: + d = _make_device(f"HC{i:03d}", f"10.0.0.{i}", f"HC Device {i}") + await device_repo.upsert(d) + except Exception as e: + errors.append(("health_check", i, e)) + + async def preset_writes(): + """Simulate preset sync writing presets.""" + for i in range(20): + try: + p = _make_preset("DEV001", (i % 6) + 1, f"Station {i}") + await preset_repo.set_preset(p) + except Exception as e: + errors.append(("preset_sync", i, e)) + + # Run both concurrently — this triggers the SQLITE_BUSY scenario + await asyncio.gather(health_check_writes(), preset_writes()) + + assert ( + errors == [] + ), f"Concurrent writes caused {len(errors)} errors: " + "; ".join( + f"{src}[{idx}]: {err}" for src, idx, err in errors[:5] + ) + + @pytest.mark.asyncio + async def test_preset_read_during_device_writes(self, device_repo, preset_repo): + """Reading presets must work while device_repo is writing. + + This is the exact user scenario: loading presets page while + background health-check updates device last_seen timestamps. + """ + # Setup: device with presets + device = _make_device("DEV_A", "192.168.1.10", "Kitchen Speaker") + await device_repo.upsert(device) + for i in range(1, 4): + await preset_repo.set_preset(_make_preset("DEV_A", i, f"Radio {i}")) + + preset_read_results = [] + errors = [] + + async def continuous_device_upserts(): + """Simulate health-check doing rapid upserts.""" + for i in range(30): + try: + d = _make_device(f"BG{i:03d}", f"10.0.0.{i}", f"BG {i}") + await device_repo.upsert(d) + except Exception as e: + errors.append(("upsert", i, e)) + + async def read_presets_repeatedly(): + """Simulate frontend loading presets for DEV_A.""" + for _ in range(30): + try: + presets = await preset_repo.get_all_presets("DEV_A") + preset_read_results.append(len(presets)) + except Exception as e: + errors.append(("read_presets", _, e)) + + await asyncio.gather(continuous_device_upserts(), read_presets_repeatedly()) + + assert ( + errors == [] + ), f"Concurrent read+write caused {len(errors)} errors: " + "; ".join( + f"{src}[{idx}]: {err}" for src, idx, err in errors[:5] + ) + # All reads should return 3 presets consistently + assert all( + count == 3 for count in preset_read_results + ), f"Inconsistent preset reads: {set(preset_read_results)}" + + @pytest.mark.asyncio + async def test_failed_write_does_not_poison_connection( + self, device_repo, preset_repo + ): + """A failed write on one connection must not block the other. + + After a SQLITE_BUSY error (if it ever happens), subsequent + operations on both connections must still work. + """ + # Pre-populate + d1 = _make_device("GOOD_DEV", "192.168.1.50", "Good Device") + await device_repo.upsert(d1) + await preset_repo.set_preset(_make_preset("GOOD_DEV", 1, "My Radio")) + + # Force heavy concurrent writes to increase chance of contention + async def hammer_devices(): + for i in range(50): + try: + d = _make_device(f"H{i:03d}", f"10.0.0.{i}", f"H{i}") + await device_repo.upsert(d) + except Exception: + pass # Some might fail — that's fine + + async def hammer_presets(): + for i in range(50): + try: + p = _make_preset("GOOD_DEV", (i % 6) + 1, f"S{i}") + await preset_repo.set_preset(p) + except Exception: + pass + + await asyncio.gather(hammer_devices(), hammer_presets()) + + # CRITICAL: After the storm, BOTH connections MUST still work + # This is what failed before the fix — the connection was "poisoned" + devices = await device_repo.get_all() + assert len(devices) > 0, "device_repo must be usable after concurrent writes" + + presets = await preset_repo.get_all_presets("GOOD_DEV") + assert len(presets) > 0, "preset_repo must be usable after concurrent writes" + + @pytest.mark.asyncio + async def test_wal_mode_enabled(self, device_repo): + """WAL journal mode must be enabled for concurrent access support.""" + cursor = await device_repo._db.execute("PRAGMA journal_mode") + row = await cursor.fetchone() + assert row[0].lower() == "wal", ( + f"Expected WAL journal mode, got '{row[0]}'. " + "Without WAL, concurrent connections will cause SQLITE_BUSY errors." + ) + + @pytest.mark.asyncio + async def test_busy_timeout_set(self, device_repo): + """busy_timeout must be set to avoid immediate SQLITE_BUSY failures.""" + cursor = await device_repo._db.execute("PRAGMA busy_timeout") + row = await cursor.fetchone() + assert row[0] >= 3000, ( + f"Expected busy_timeout >= 3000ms, got {row[0]}ms. " + "Without busy_timeout, concurrent access fails immediately." + ) diff --git a/apps/backend/tests/regression/test_offline_device_blocks_server.py b/apps/backend/tests/regression/test_offline_device_blocks_server.py new file mode 100644 index 00000000..cb4fe2bd --- /dev/null +++ b/apps/backend/tests/regression/test_offline_device_blocks_server.py @@ -0,0 +1,243 @@ +""" +Regression test: Offline device blocks entire server via sync BoseClient calls. + +Date: 2026-04-06 +Symptom: Removing one device from the network causes ALL other devices to show + blurred skeleton tiles instead of presets. Database wipe required. + +Root Cause: BoseDeviceClientAdapter calls synchronous bosesoundtouchapi methods + (GetNowPlayingStatus, GetVolume, etc.) directly in async methods, + blocking the asyncio event loop for the full connectTimeout (5s). + During that time, the server cannot serve ANY other request. + +Fix: All synchronous BoseClient calls wrapped in asyncio.to_thread(). +""" + +import asyncio +import inspect +import re +import tempfile +import time +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from opencloudtouch.db import Device, DeviceRepository +from opencloudtouch.presets.models import Preset +from opencloudtouch.presets.repository import PresetRepository +from opencloudtouch.presets.service import PresetService + + +@pytest.fixture +async def shared_db(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "test.db" + device_repo = DeviceRepository(str(db_path)) + await device_repo.initialize() + preset_repo = PresetRepository(str(db_path)) + await preset_repo.initialize() + yield device_repo, preset_repo + await device_repo.close() + await preset_repo.close() + + +def _make_device(device_id: str, ip: str, name: str) -> Device: + return Device( + device_id=device_id, + ip=ip, + name=name, + model="SoundTouch 10", + mac_address=f"AA:BB:CC:DD:EE:{device_id[-2:]}", + firmware_version="28.0.3", + ) + + +class TestOfflineDeviceBlocksEventLoop: + """Regression: synchronous BoseClient calls must not block the event loop.""" + + @pytest.mark.asyncio + async def test_sync_bose_call_does_not_block_event_loop(self, shared_db): + """A slow device must NOT prevent fast DB reads for other devices. + + Uses the REAL BoseDeviceClientAdapter (with mocked BoseClient) to verify + that asyncio.to_thread() is correctly applied. + """ + device_repo, preset_repo = shared_db + + await device_repo.upsert(_make_device("OFFLINE01", "192.168.1.99", "Offline")) + await device_repo.upsert(_make_device("WORKING01", "192.168.1.10", "Working")) + for i in range(1, 4): + await preset_repo.set_preset( + Preset( + device_id="WORKING01", + preset_number=i, + station_uuid=f"uuid-{i}", + station_name=f"Radio {i}", + station_url=f"http://stream{i}.example.com/live.mp3", + ) + ) + + BLOCK_SECONDS = 2.0 + preset_service = PresetService(preset_repo, device_repo) + + def slow_get_now_playing(): + """Simulates BoseClient.GetNowPlayingStatus() on an unreachable + device — blocks the calling thread for connectTimeout seconds.""" + time.sleep(BLOCK_SECONDS) + raise ConnectionError("Device offline") + + mock_bose_client = MagicMock() + mock_bose_client.GetNowPlayingStatus = slow_get_now_playing + + preset_timing = [] + np_error = [] + + async def poll_offline_now_playing(): + """Call get_now_playing via the REAL adapter with mocked BoseClient.""" + try: + from opencloudtouch.devices.client_adapter import ( + BoseDeviceClientAdapter, + ) + + adapter = BoseDeviceClientAdapter.__new__(BoseDeviceClientAdapter) + adapter.base_url = "http://192.168.1.99:8090" + adapter.ip = "192.168.1.99" + adapter.timeout = 5.0 + adapter._client = mock_bose_client + + await adapter.get_now_playing() + except Exception as e: + np_error.append(str(e)) + + async def read_working_presets(): + start = time.monotonic() + presets = await preset_service.get_all_presets("WORKING01") + elapsed = time.monotonic() - start + preset_timing.append(elapsed) + return presets + + # Blocking call FIRST — it gets scheduled first in the event loop. + # Before the fix, this would block the entire event loop for BLOCK_SECONDS, + # delaying all other coroutines. After the fix (asyncio.to_thread()), + # it runs in a thread pool and preset reads proceed immediately. + await asyncio.gather(poll_offline_now_playing(), read_working_presets()) + + assert preset_timing[0] < 0.5, ( + f"Preset read took {preset_timing[0]:.2f}s — expected < 0.5s. " + f"The offline device's synchronous I/O is blocking the event loop. " + f"BoseClient calls must be wrapped in asyncio.to_thread()." + ) + assert len(np_error) == 1 + + @pytest.mark.asyncio + async def test_all_bose_methods_use_to_thread(self): + """Static analysis: every sync BoseClient call must use asyncio.to_thread. + + Reads the source of BoseDeviceClientAdapter and verifies that every + self._client.Method(...) call is wrapped in asyncio.to_thread(). + Catches regressions where someone adds a new BoseClient call without + the thread wrapper. + """ + from opencloudtouch.devices.client_adapter import BoseDeviceClientAdapter + + source = inspect.getsource(BoseDeviceClientAdapter) + + # Find all self._client.MethodName references that are method calls + # (either direct calls `self._client.Foo(...)` or passed to to_thread) + # Exclude property accesses like `self._client.Device.DeviceId` + all_method_refs = set( + re.findall(r"self\._client\.([A-Z]\w+?)(?:\(|,|\)|\s)", source) + ) + + # Property accesses (no parentheses, used as attribute lookups) + property_accesses = set(re.findall(r"self\._client\.([A-Z]\w+)\.\w+", source)) + + # Methods that are actually called (not just property access) + called_methods = all_method_refs - property_accesses + + assert called_methods, "Expected at least one self._client.Method() call" + + # Every called method must appear in an asyncio.to_thread() wrapper + for method in called_methods: + pattern = rf"asyncio\.to_thread\(self\._client\.{method}" + assert re.search(pattern, source), ( + f"self._client.{method}() is NOT wrapped in asyncio.to_thread(). " + f"This will block the event loop when the device is offline." + ) + + # Negative check: no direct calls without to_thread + # Match self._client.Method( that is NOT preceded by asyncio.to_thread( + for method in called_methods: + direct_call = rf"(?; + browser_info: string; + current_route: string; + click_timestamp: number; +} + +export interface BugReportResponse { + issue_url: string; +} + +export async function submitBugReport(payload: BugReportPayload): Promise { + const response = await fetch(`${API_BASE_URL}/api/bug-report`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Bug report failed (${response.status}): ${text}`); + } + + return response.json(); +} diff --git a/apps/frontend/src/components/BugReportModal.css b/apps/frontend/src/components/BugReportModal.css new file mode 100644 index 00000000..fb531d1f --- /dev/null +++ b/apps/frontend/src/components/BugReportModal.css @@ -0,0 +1,205 @@ +/* BugReportModal — One-click bug reporting */ +.bug-modal-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 5vh; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + overflow-y: auto; +} + +.bug-modal { + background: var(--color-bg-card, #242424); + border: 1px solid var(--color-border, #333); + border-radius: 12px; + width: calc(100% - 32px); + max-width: 520px; + max-height: calc(90vh - 5vh); + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + animation: bugModalFadeIn 0.15s ease-out; +} + +@keyframes bugModalFadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.bug-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px 12px; + position: sticky; + top: 0; + background: var(--color-bg-card, #242424); + z-index: 1; +} + +.bug-modal-header h2 { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + color: var(--color-text-primary, #fff); +} + +.bug-modal-close { + background: none; + border: none; + color: var(--color-text-secondary, #b3b3b3); + font-size: 1.2rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + min-width: 44px; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +.bug-modal-close:hover { + color: var(--color-text-primary, #fff); + background: rgba(255, 255, 255, 0.08); +} + +.bug-modal-form { + padding: 8px 24px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.bug-field { + display: flex; + flex-direction: column; + gap: 6px; + border: none; + padding: 0; + margin: 0; +} + +.bug-field--other { + margin-top: -8px; + padding-left: 16px; + border-left: 2px solid var(--color-accent, #0066cc); +} + +.bug-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--color-text-secondary, #b3b3b3); +} + +.bug-required { + color: var(--color-danger, #ef5350); +} + +.bug-modal-form textarea, +.bug-modal-form input[type="text"], +.bug-modal-form select { + width: 100%; + padding: 10px 12px; + background: var(--color-bg-dark, #1a1a1a); + border: 1px solid var(--color-border, #333); + border-radius: 8px; + color: var(--color-text-primary, #fff); + font-size: 0.9rem; + font-family: inherit; + resize: vertical; + box-sizing: border-box; +} + +.bug-modal-form textarea:focus, +.bug-modal-form input[type="text"]:focus, +.bug-modal-form select:focus { + outline: 2px solid var(--color-accent, #0066cc); + outline-offset: -1px; + border-color: transparent; +} + +.bug-modal-form select { + cursor: pointer; + appearance: auto; +} + +.bug-checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.bug-checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--color-text-primary, #fff); + cursor: pointer; + min-height: 32px; +} + +.bug-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--color-accent, #0066cc); +} + +.bug-modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 8px; +} + +.bug-btn { + min-width: 100px; + min-height: 44px; + padding: 10px 20px; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: + background 0.15s, + opacity 0.15s; +} + +.bug-btn:focus-visible { + outline: 2px solid var(--color-accent, #0066cc); + outline-offset: 2px; +} + +.bug-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.bug-btn--cancel { + background: rgba(255, 255, 255, 0.08); + color: var(--color-text-primary, #fff); +} + +.bug-btn--cancel:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.12); +} + +.bug-btn--submit { + background: var(--color-accent, #0066cc); + color: #fff; +} + +.bug-btn--submit:hover:not(:disabled) { + background: var(--color-accent-hover, #0052a3); +} diff --git a/apps/frontend/src/components/BugReportModal.tsx b/apps/frontend/src/components/BugReportModal.tsx new file mode 100644 index 00000000..05d4f992 --- /dev/null +++ b/apps/frontend/src/components/BugReportModal.tsx @@ -0,0 +1,392 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useLocation } from "react-router-dom"; +import { submitBugReport } from "../api/bugReport"; +import { getLogEntries } from "../utils/logBuffer"; +import { useToast } from "../contexts/ToastContext"; +import "./BugReportModal.css"; + +interface BugReportModalProps { + open: boolean; + onClose: () => void; +} + +const INSTALLATION_OPTIONS = [ + { value: "docker", label: "Docker (Raspberry Pi)" }, + { value: "docker-other", label: "Docker (other hardware)" }, + { value: "source", label: "From Source" }, + { value: "other", label: "Other" }, +]; + +const HARDWARE_OPTIONS = [ + { value: "raspberry-pi-4", label: "Raspberry Pi 4" }, + { value: "raspberry-pi-5", label: "Raspberry Pi 5" }, + { value: "linux-x64", label: "Linux x86_64" }, + { value: "other", label: "Other" }, +]; + +const DEVICE_OPTIONS = [ + "SoundTouch 10", + "SoundTouch 20", + "SoundTouch 30", + "SoundTouch 300", + "SoundTouch Portable", + "Lifestyle 535", + "Lifestyle 600", + "Lifestyle 650", + "Wave SoundTouch", + "Other", +]; + +const NETWORK_OPTIONS = [ + { value: "wifi", label: "Wi-Fi" }, + { value: "lan", label: "LAN / Ethernet" }, + { value: "mixed", label: "Mixed" }, +]; + +export default function BugReportModal({ open, onClose }: BugReportModalProps) { + const { show: showToast } = useToast(); + const location = useLocation(); + const closeRef = useRef(null); + const clickTimestampRef = useRef(0); + + const [submitting, setSubmitting] = useState(false); + const [description, setDescription] = useState(""); + const [steps, setSteps] = useState(""); + const [expected, setExpected] = useState(""); + const [installationType, setInstallationType] = useState(""); + const [hardware, setHardware] = useState(""); + const [selectedDevices, setSelectedDevices] = useState([]); + const [networkConfig, setNetworkConfig] = useState(""); + const [additionalInfo, setAdditionalInfo] = useState(""); + const [otherInstallation, setOtherInstallation] = useState(""); + const [otherHardware, setOtherHardware] = useState(""); + const [otherDevice, setOtherDevice] = useState(""); + + useEffect(() => { + if (open) { + clickTimestampRef.current = Date.now() / 1000; + closeRef.current?.focus(); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open, onClose]); + + const resetForm = useCallback(() => { + setDescription(""); + setSteps(""); + setExpected(""); + setInstallationType(""); + setHardware(""); + setSelectedDevices([]); + setNetworkConfig(""); + setAdditionalInfo(""); + setOtherInstallation(""); + setOtherHardware(""); + setOtherDevice(""); + }, []); + + const captureScreenshot = async (): Promise => { + try { + const { default: html2canvas } = await import("html2canvas"); + const canvas = await html2canvas(document.body, { + scale: 0.5, + logging: false, + useCORS: true, + width: Math.min(document.body.scrollWidth, 1280), + }); + return canvas.toDataURL("image/jpeg", 0.6); + } catch { + return ""; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + + try { + const screenshot = await captureScreenshot(); + const browserInfo = `${navigator.userAgent} | ${window.innerWidth}x${window.innerHeight}`; + + const result = await submitBugReport({ + description, + steps_to_reproduce: steps, + expected_behavior: expected, + installation_type: installationType, + hardware, + soundtouch_devices: selectedDevices, + network_config: networkConfig, + additional_info: additionalInfo, + other_installation: otherInstallation, + other_hardware: otherHardware, + other_device: otherDevice, + screenshot_data_url: screenshot, + frontend_logs: getLogEntries(), + browser_info: browserInfo, + current_route: location.pathname, + click_timestamp: clickTimestampRef.current, + }); + + showToast( + + Bug report created!{" "} + + View on GitHub + + , + "success", + 10000 + ); + resetForm(); + onClose(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + showToast(`Failed to submit bug report: ${msg}`, "error", 8000); + } finally { + setSubmitting(false); + } + }; + + const toggleDevice = (device: string) => { + setSelectedDevices((prev) => + prev.includes(device) ? prev.filter((d) => d !== device) : [...prev, device] + ); + }; + + const needsOtherInstallation = installationType === "other"; + const needsOtherHardware = hardware === "other"; + const needsOtherDevice = selectedDevices.includes("Other"); + + const isValid = + description.length >= 10 && + steps.length >= 10 && + expected.length >= 5 && + installationType !== "" && + hardware !== "" && + (!needsOtherInstallation || otherInstallation.length >= 2) && + (!needsOtherHardware || otherHardware.length >= 2) && + (!needsOtherDevice || otherDevice.length >= 2); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +
+

🐛 Report a Bug

+ +
+ +
+ {/* 1. Bug Description */} +