Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

This repository is the continuation of the original [fondberg/spotcast](https://github.com/fondberg/spotcast) project. For the history of releases prior to v6, see the [original project's releases](https://github.com/fondberg/spotcast/releases). Releases v6.3.0 through v6.5.2 are documented in the [GitHub release notes](https://github.com/Mincka/spotcast/releases).

## Unreleased

### Fixes

- A cast to an unreachable or refusing Chromecast now fails with a clear error instead of hanging the service call. Spotcast waited on the device with no timeout, both when building the player and again inside the app launch, so a powered-down or unreachable device blocked `spotcast.play` indefinitely. Every wait is now bounded to 20 seconds and reports "Could not connect to `<device>` within 20s". Separately, when the device refused the Spotify credentials, the refusal was raised on pychromecast's socket thread where it was logged and discarded, and the launch loop kept polling for an answer that had already arrived, so the caller only ever saw "Timeout when waiting for status response from Spotify app". The refusal is now recorded and raised to the caller with the device's own account of it, for example "Spotify refused the credentials for this device (status=108, statusString=ERROR-CANNOT-LOAD, spotifyError=409)" (thanks @chsienki, [#67](https://github.com/Mincka/spotcast/pull/67)).

### Project changes

- The development environment now tracks Home Assistant 2026.9.1 (from 2026.7.2) and the PyJWT override in `pyproject.toml` is gone, since Home Assistant pins PyJWT 2.13.0 itself from 2026.8.0 onward. A dependency audit of the locked environment found no known vulnerabilities, and every runtime dependency (spotipy 2.26.0, RapidFuzz 3.14.6, PyChromecast 14.0.10) is at its latest release.

## v6.6.1 (2026-08-23)

### Fixes
Expand Down
6 changes: 5 additions & 1 deletion custom_components/spotcast/chromecast/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

Classes:
- SpotifyController

Functions:
- wait_for_connection
"""

from custom_components.spotcast.chromecast.spotify_controller import (
SpotifyController
SpotifyController,
wait_for_connection,
)
87 changes: 80 additions & 7 deletions custom_components/spotcast/chromecast/spotify_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from pychromecast.controllers import BaseController
from pychromecast.controllers import CastMessage
from pychromecast.error import RequestTimeout
from requests import post, Response, HTTPError

from custom_components.spotcast.spotify import SpotifyAccount
Expand All @@ -29,15 +30,45 @@
# a `getInfo` request reliably. Single devices do not need this.
GROUP_LAUNCH_DELAY = 3.0

# `Chromecast.wait()` blocks forever when called without a timeout, which
# is how an unreachable device used to block the caller instead of failing
# it. Given a timeout it raises `RequestTimeout` instead of returning.
CONNECT_TIMEOUT = 20.0

class SpotifyController(BaseController):

def wait_for_connection(device: Chromecast):
"""Waits for a device to be reachable, bounded by CONNECT_TIMEOUT

Args:
- device(Chromecast): the device to wait for

Raises:
- AppLaunchError: when the device is not reachable in time
"""
try:
device.wait(timeout=CONNECT_TIMEOUT)
except RequestTimeout as exc:
raise AppLaunchError(
f"Could not connect to `{device.name}` within "
f"{CONNECT_TIMEOUT:.0f}s. The device may be powered down or "
"unreachable on the network."
) from exc


class SpotifyController( # pylint: disable=too-many-instance-attributes
BaseController
):
"""A Chromcast controller for interacting with Spotify

Attributes:
- account(SpotifyAccount): The spotify account in charge of the
spotify controller
- waiting(threading.Event): A threading Event loop manager
- is_launched(bool): True if the app is currently launched
- credential_error(bool): True if the device refused the
credentials or the playback transfer
- launch_error(str): what the device said when it refused,
for the waiting thread to raise

Constants:
- APP_ID(str): the chromecast app code for spotify
Expand Down Expand Up @@ -96,6 +127,10 @@ def __init__(
self.activated_device_id: str = None
self.credential_error = False

# What the device said when it refused, for the waiting thread to
# raise -- the handlers cannot raise it themselves.
self.launch_error: str = None

def _send_message_callback(self, *_):
"""Call back method to send a message after the launch method"""
if self.current_device is not None and self.current_device.is_group:
Expand All @@ -115,6 +150,8 @@ def launch_app(self, device: Chromecast, max_attempts=10):
self.is_launched = False
self.current_device = device
self.activated_device_id = None
self.credential_error = False
self.launch_error = None

self._current_message = {
"type": self.TYPE_GET_INFO,
Expand All @@ -126,12 +163,12 @@ def launch_app(self, device: Chromecast, max_attempts=10):
}

LOGGER.debug("Waiting for `%s` to be ready", device.name)
device.wait()
wait_for_connection(device)

LOGGER.debug("Starting Spotify on `%s`", device.name)
device.start_app(self.APP_ID)
LOGGER.debug("Waiting for `%s` to be available", device.name)
device.wait()
wait_for_connection(device)

self.waiting.clear()

Expand All @@ -149,6 +186,14 @@ def launch_app(self, device: Chromecast, max_attempts=10):
)
return

# Raised here because the handler that detected it runs on the
# socket client's thread, where an exception is logged and dropped.
if self.credential_error:
raise AppLaunchError(
self.launch_error
or "Spotify refused the credentials for this device"
)

if max_attempts is not None and counter >= max_attempts:
raise AppLaunchError(
"Timeout when waiting for status response from Spotify app"
Expand Down Expand Up @@ -252,21 +297,49 @@ def _add_user_response_handler(self, *_, **__) -> bool:

return True

def _add_user_error_handler(self, *_, **__) -> bool:
def _add_user_error_handler(
self, _message: CastMessage, data: dict
) -> bool:
"""Handler for the add user error message"""
self.current_device = None
self.launch_error = self._describe(
"Spotify refused the credentials for this device", data
)
# Set last: the waiting thread polls, so it can read this flag
# without waking on `waiting.set()`, and must not find it set
# while `launch_error` is still empty.
self.credential_error = True
LOGGER.error("%s", self.launch_error)
self.waiting.set()

raise AppLaunchError("Credentials error. Laucnhgin spotify failed")
return True

def _transfer_error_handler(self, *_, **__) -> bool:
def _transfer_error_handler(
self, _message: CastMessage, data: dict
) -> bool:
"""Handler for the transfer error message"""
self.current_device = None
self.launch_error = self._describe(
"Spotify refused to transfer playback to this device", data
)
# Set last, see `_add_user_error_handler`.
self.credential_error = True
LOGGER.error("%s", self.launch_error)
self.waiting.set()

raise AppLaunchError("Device took too much time to start playback")
return True

@staticmethod
def _describe(summary: str, data: dict) -> str:
"""Adds the device's own account of the refusal to a summary"""
payload = (data or {}).get("payload") or {}
detail = ", ".join(
f"{key}={payload[key]}"
for key in ("status", "statusString", "spotifyError", "reason")
if key in payload
)

return f"{summary} ({detail})" if detail else summary

def _transfer_success_handler(self, *_, **__) -> bool:
"""Handles the transfer success message"""
Expand Down
7 changes: 5 additions & 2 deletions custom_components/spotcast/media_player/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
SpotifyAccount,
)

from custom_components.spotcast.chromecast import SpotifyController
from custom_components.spotcast.chromecast import (
SpotifyController,
wait_for_connection,
)

LOGGER = getLogger(__name__)

Expand Down Expand Up @@ -199,7 +202,7 @@ async def async_build_from_type(
zconf=ChromeCastZeroconf.get_zeroconf()
)

await hass.async_add_executor_job(media_player.wait)
await hass.async_add_executor_job(wait_for_connection, media_player)

spotify_controller = SpotifyController(account)
media_player.register_handler(spotify_controller)
Expand Down
3 changes: 1 addition & 2 deletions custom_components/spotcast/spotify/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ def register(self, retry_after: str | int | None) -> float:

# a longer window already registered wins: the api does
# not shorten a penalty because we asked again
if retry_at > self._retry_at:
self._retry_at = retry_at
self._retry_at = max(self._retry_at, retry_at)

level = DEBUG if self._announced else WARNING
self._announced = True
Expand Down
12 changes: 1 addition & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dependencies = [
"requests>=2.33.1",
"spotifyaio>=2.0.2",
"RapidFuzz>=3.14.5",
"homeassistant>=2026.5.2",
"homeassistant>=2026.8.0",
]

[project.urls]
Expand Down Expand Up @@ -77,16 +77,6 @@ convention = "google"
[tool.ruff.per-file-ignores]
"**/__init__.py" = ["D104"]

[tool.uv]
# Home Assistant exact-pins PyJWT==2.12.1, which has known
# vulnerabilities (all fixed in 2.13.0, GHSA-xgmm-8j9v-c9wx and
# friends). Spotcast never imports pyjwt itself; this only affects the
# dev/test environment. Drop the override once Home Assistant's pin
# reaches 2.13.0.
override-dependencies = [
"pyjwt>=2.13.0",
]

[dependency-groups]
dev = [
"time-machine>=3.2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
SpotifyController,
SpotifyAccount,
CastMessage,
AppLaunchError,
)

TEST_MODULE = "custom_components.spotcast.chromecast.spotify_controller"
Expand All @@ -21,13 +20,18 @@ def setUp(self, mock_event: MagicMock):
mock_account = MagicMock(spec=SpotifyAccount)
self.controller = SpotifyController(mock_account)

try:
self.controller._add_user_error_handler(
MagicMock(spec=CastMessage),
{}
)
except AppLaunchError:
pass
# The handler records the refusal rather than raising it, since it
# runs on a thread where a raise would be logged and dropped.
self.controller._add_user_error_handler(
MagicMock(spec=CastMessage),
{
"payload": {
"status": 108,
"statusString": "ERROR-CANNOT-LOAD",
"spotifyError": 409,
}
}
)

def test_device_removed(self):
self.assertIsNone(self.controller.current_device)
Expand All @@ -40,3 +44,39 @@ def test_credential_error_set(self):

def test_credentials_error_set(self):
self.assertTrue(self.controller.credential_error)

def test_records_what_the_device_said(self):
self.assertIn("ERROR-CANNOT-LOAD", self.controller.launch_error)
self.assertIn("409", self.controller.launch_error)


class TestAddUserErrorRecordOrdering(TestCase):
"""`launch_error` must be readable as soon as the flag is set

The waiting thread polls `credential_error` every second rather
than only waking on `waiting.set()`, so a poll landing between
the two assignments would report the generic message and drop
what the device actually said.
"""

@patch(f"{TEST_MODULE}.threading.Event")
def test_detail_is_recorded_before_the_flag_is_raised(
self,
mock_event: MagicMock, # pylint: disable=W0613
):
controller = SpotifyController(MagicMock(spec=SpotifyAccount))
observed = []

with patch.object(
SpotifyController,
"_describe",
side_effect=lambda *_: observed.append(
controller.credential_error
) or "detail",
):
controller._add_user_error_handler(
MagicMock(spec=CastMessage),
{},
)

self.assertEqual(observed, [False])
50 changes: 50 additions & 0 deletions test/chromecast/spotify_controller/test_launch_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
from unittest.mock import MagicMock, patch
from threading import Event

from pychromecast.error import RequestTimeout

from custom_components.spotcast.chromecast.spotify_controller import (
SpotifyController,
SpotifyAccount,
Chromecast,
AppLaunchError,
CONNECT_TIMEOUT,
)

TEST_MODULE = "custom_components.spotcast.chromecast.spotify_controller."
Expand Down Expand Up @@ -93,3 +96,50 @@ def test_app_launch_fails(

def set_is_launched(self, *_, **__):
self.controller.is_launched = True


class TestCredentialRefusal(TestCase):
"""A refusal reported by the device must reach the caller"""

@patch.object(SpotifyController, "launch")
def test_credential_error_is_raised_to_the_caller(
self,
mock_launch: MagicMock,
):
controller = SpotifyController(MagicMock(spec=SpotifyAccount))

def refuse(*_, **__):
controller._add_user_error_handler(
MagicMock(),
{"payload": {"statusString": "ERROR-CANNOT-LOAD",
"spotifyError": 409}},
)

mock_launch.side_effect = refuse

with self.assertRaises(AppLaunchError) as caught:
controller.launch_app(MagicMock(spec=Chromecast), max_attempts=10)

self.assertIn("ERROR-CANNOT-LOAD", str(caught.exception))

@patch.object(SpotifyController, "launch")
def test_an_unreachable_device_does_not_block(
self,
mock_launch: MagicMock,
):
"""An unreachable device fails the call rather than blocking it

`Chromecast.wait(timeout=...)` raises `RequestTimeout` rather
than returning, so that is what the device does here.
"""
controller = SpotifyController(MagicMock(spec=SpotifyAccount))

device = MagicMock(spec=Chromecast)
device.wait.side_effect = RequestTimeout("wait", CONNECT_TIMEOUT)

with self.assertRaises(AppLaunchError) as caught:
controller.launch_app(device, max_attempts=2)

self.assertIn("Could not connect", str(caught.exception))
mock_launch.assert_not_called()
device.wait.assert_called_once_with(timeout=CONNECT_TIMEOUT)
Loading
Loading