Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4819360
feat(presets): remove overwrite confirmation for occupied presets
oct-support Mar 24, 2026
1fda430
fix(e2e): add detect-strategy and server-info intercepts to wizard tests
oct-support Mar 25, 2026
0999db1
feat(tunein): resolve TuneIn streams dynamically at playback time
oct-support Mar 26, 2026
3b9c30f
fix(e2e): add detect-strategy and server-info intercepts to wizard UX…
oct-support Mar 26, 2026
7e99ea8
feat(streaming): improve error handling for upstream connections and …
oct-support Mar 27, 2026
30d420c
style: normalize line endings across codebase
oct-support May 2, 2026
e049d5e
fix(sonar): resolve security, accessibility, coverage and duplication…
oct-support May 2, 2026
6700645
fix(sonar): resolve security hotspot, reliability and coverage issues
oct-support May 3, 2026
5784472
fix(sonar): move NOSONAR to flagged line for S5332 hotspot suppression
oct-support May 3, 2026
cbe575e
fix(sonar): remove event listeners from dialog, use native onCancel (…
oct-support May 3, 2026
a6ae8d4
ci: add license compatibility check workflow
oct-support May 3, 2026
14d3b47
fix(ci): remove conflicting --failOn flag from license-checker (use -…
oct-support May 3, 2026
ef83cb0
fix(ci): add PSF-2.0 to pip-licenses allow-list (typing_extensions)
oct-support May 3, 2026
2cd0fe1
fix(ci): add EPL-2.0 and PSF-2.0 SPDX identifiers to pip allow-list
oct-support May 3, 2026
bd2682f
fix(ci): switch pip license check to deny-list, approve asyncssh EPL-2.0
oct-support May 3, 2026
0c14247
fix(ci): remove LGPL from deny-list (safe for MIT projects, zeroconf …
oct-support May 3, 2026
eea1a0c
fix(frontend): add missing useCallback deps in useZoneBuilder (stale …
oct-support May 3, 2026
dc071ab
fix(ci): remove continue-on-error from E2E step (fail fast on red tests)
oct-support May 3, 2026
e3c92f4
fix(e2e): custom webpack preprocessor with transpileOnly to fix TS5101
oct-support May 3, 2026
81960fe
fix(e2e): force German locale in CI via window:before:load hook
oct-support May 3, 2026
bc63bad
fix(e2e): locale hook - use global de, visitEn() helper for i18n Engl…
oct-support May 3, 2026
8b0732e
fix(e2e): per-spec visitDe() helper for German locale in CI
oct-support May 3, 2026
af573d9
fix(e2e): intercept now-playing in error handling tests to prevent de…
oct-support May 3, 2026
f9d31e4
fix(e2e): fix infinite recursion in visitDe (PowerShell regex replace…
oct-support May 3, 2026
e5a4c96
fix(e2e): fix all 37 E2E test failures
oct-support May 4, 2026
2ddbaf7
chore: remove temp encoding check script
oct-support May 4, 2026
ae40db5
test: fix remaining 15 E2E test failures on feat/tunein-support
oct-support May 4, 2026
18c25e8
fix(e2e): fix remaining 8 CI failures across 3 specs
oct-support May 4, 2026
bbde9fd
fix(e2e): fix wizard-ui-rendering presets intercept pattern
oct-support May 4, 2026
90fbc2d
feat(toggle): add build-time TuneIn feature toggle (kill switch) (#128)
scheilch May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -292,16 +292,16 @@ jobs:
curl --retry 10 --retry-delay 2 --retry-connrefused http://localhost:4173

- name: Run Cypress E2E tests
id: cypress
working-directory: apps/frontend
run: npm run test:e2e
continue-on-error: true
env:
CYPRESS_BASE_URL: http://localhost:4173
CYPRESS_API_URL: http://localhost:7778/api

- name: Upload Cypress screenshots on failure
uses: actions/upload-artifact@v7
if: failure()
if: failure() && steps.cypress.outcome == 'failure'
with:
name: cypress-screenshots
path: apps/frontend/tests/e2e/screenshots
Expand Down Expand Up @@ -336,7 +336,7 @@ jobs:
path: .out/coverage/frontend

- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v5
uses: SonarSource/sonarqube-scan-action@v6
with:
args: >
-Dsonar.projectKey=scheilch_opencloudtouch
Expand Down
82 changes: 82 additions & 0 deletions .github/workflows/license-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# ==============================================================================
# LICENSE CHECK - Verify OSS dependency licenses are compatible
#
# Fails on: GPL, AGPL, LGPL, CPAL, EUPL (copyleft incompatible with MIT)
# Allowed: MIT, Apache-2.0, BSD-*, ISC, Python-2.0, CC0-1.0, 0BSD, Unlicense
# ==============================================================================

name: License Check

on:
push:
branches:
- main
paths:
- 'apps/frontend/package.json'
- 'package-lock.json'
- 'apps/backend/requirements.txt'
- 'apps/backend/requirements-dev.txt'
pull_request:
paths:
- 'apps/frontend/package.json'
- 'package-lock.json'
- 'apps/backend/requirements.txt'
- 'apps/backend/requirements-dev.txt'
workflow_dispatch:

jobs:
license-check:
name: License Check
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v6

# ── Frontend (npm) ──────────────────────────────────────────────────────
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json

- name: Install frontend dependencies
run: npm ci

- name: Check frontend licenses
run: |
npx license-checker-rseidelsohn \
--start apps/frontend \
--production \
--excludePrivatePackages \
--onlyAllow "MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;Unlicense;CC0-1.0;CC-BY-3.0;CC-BY-4.0;Python-2.0;BlueOak-1.0.0;Artistic-2.0" \
--summary

# ── Backend (pip) ───────────────────────────────────────────────────────
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.13'

- name: Install backend dependencies
run: |
pip install pip-licenses
pip install -r apps/backend/requirements.txt

- name: Check backend licenses
# asyncssh is an approved dependency: EPL-2.0 (Eclipse Public License 2.0)
# is weak-copyleft — only applies to modifications of asyncssh itself, not
# to embedding projects. Excluded via --ignore-packages because its SPDX
# composite expression "EPL-2.0 OR GPL-2.0-or-later" contains "GPL-2.0-or-later"
# as a substring, which would otherwise trigger the --fail-on check.
#
# LGPL is intentionally NOT in the deny-list: LGPL-2.1 only requires open-
# sourcing modifications to the LGPL library itself, not embedding code.
# zeroconf (mDNS discovery) is LGPL-2.1 and safe to use here.
run: |
pip-licenses \
--format=markdown \
--order=license \
--ignore-packages asyncssh \
--fail-on="GNU General Public License;GNU Affero General Public License;European Union Public Licence;GPL-2.0-only;GPL-2.0-or-later;GPL-3.0-only;GPL-3.0-or-later;AGPL-3.0-only;AGPL-3.0-or-later;EUPL-1.1;EUPL-1.2"
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/opencloudtouch/bmx/radiobrowser_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
49 changes: 28 additions & 21 deletions apps/backend/src/opencloudtouch/bmx/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,34 +25,14 @@
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__)

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
# =============================================================================
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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": "*"},
)
26 changes: 26 additions & 0 deletions apps/backend/src/opencloudtouch/bmx/stream_utils.py
Original file line number Diff line number Diff line change
@@ -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:] # NOSONAR - intentional HTTP for Bose SoundTouch
logger.info(
f"[BMX] Converting HTTPS to HTTP: {url[:50]}... → {http_url[:50]}..."
)
return http_url
return url
5 changes: 4 additions & 1 deletion apps/backend/src/opencloudtouch/bmx/tunein.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading