Add media browser support to dlna_dmr (#66425)
This commit is contained in:
parent
6a7872fc1b
commit
3c15fe8587
4 changed files with 265 additions and 14 deletions
|
@ -21,6 +21,11 @@ DEFAULT_NAME: Final = "DLNA Digital Media Renderer"
|
||||||
|
|
||||||
CONNECT_TIMEOUT: Final = 10
|
CONNECT_TIMEOUT: Final = 10
|
||||||
|
|
||||||
|
PROTOCOL_HTTP: Final = "http-get"
|
||||||
|
PROTOCOL_RTSP: Final = "rtsp-rtp-udp"
|
||||||
|
PROTOCOL_ANY: Final = "*"
|
||||||
|
STREAMABLE_PROTOCOLS: Final = [PROTOCOL_HTTP, PROTOCOL_RTSP, PROTOCOL_ANY]
|
||||||
|
|
||||||
# Map UPnP class to media_player media_content_type
|
# Map UPnP class to media_player media_content_type
|
||||||
MEDIA_TYPE_MAP: Mapping[str, str] = {
|
MEDIA_TYPE_MAP: Mapping[str, str] = {
|
||||||
"object": _mp_const.MEDIA_TYPE_URL,
|
"object": _mp_const.MEDIA_TYPE_URL,
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||||
"requirements": ["async-upnp-client==0.23.5"],
|
"requirements": ["async-upnp-client==0.23.5"],
|
||||||
"dependencies": ["ssdp"],
|
"dependencies": ["ssdp"],
|
||||||
|
"after_dependencies": ["media_source"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||||
|
|
|
@ -13,16 +13,22 @@ from async_upnp_client.const import NotificationSubType
|
||||||
from async_upnp_client.exceptions import UpnpError, UpnpResponseError
|
from async_upnp_client.exceptions import UpnpError, UpnpResponseError
|
||||||
from async_upnp_client.profiles.dlna import DmrDevice, PlayMode, TransportState
|
from async_upnp_client.profiles.dlna import DmrDevice, PlayMode, TransportState
|
||||||
from async_upnp_client.utils import async_get_local_ip
|
from async_upnp_client.utils import async_get_local_ip
|
||||||
|
from didl_lite import didl_lite
|
||||||
from typing_extensions import Concatenate, ParamSpec
|
from typing_extensions import Concatenate, ParamSpec
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.components import ssdp
|
from homeassistant.components import media_source, ssdp
|
||||||
from homeassistant.components.media_player import MediaPlayerEntity
|
from homeassistant.components.media_player import (
|
||||||
|
BrowseMedia,
|
||||||
|
MediaPlayerEntity,
|
||||||
|
async_process_play_media_url,
|
||||||
|
)
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
ATTR_MEDIA_EXTRA,
|
ATTR_MEDIA_EXTRA,
|
||||||
REPEAT_MODE_ALL,
|
REPEAT_MODE_ALL,
|
||||||
REPEAT_MODE_OFF,
|
REPEAT_MODE_OFF,
|
||||||
REPEAT_MODE_ONE,
|
REPEAT_MODE_ONE,
|
||||||
|
SUPPORT_BROWSE_MEDIA,
|
||||||
SUPPORT_NEXT_TRACK,
|
SUPPORT_NEXT_TRACK,
|
||||||
SUPPORT_PAUSE,
|
SUPPORT_PAUSE,
|
||||||
SUPPORT_PLAY,
|
SUPPORT_PLAY,
|
||||||
|
@ -61,6 +67,7 @@ from .const import (
|
||||||
MEDIA_UPNP_CLASS_MAP,
|
MEDIA_UPNP_CLASS_MAP,
|
||||||
REPEAT_PLAY_MODES,
|
REPEAT_PLAY_MODES,
|
||||||
SHUFFLE_PLAY_MODES,
|
SHUFFLE_PLAY_MODES,
|
||||||
|
STREAMABLE_PROTOCOLS,
|
||||||
)
|
)
|
||||||
from .data import EventListenAddr, get_domain_data
|
from .data import EventListenAddr, get_domain_data
|
||||||
|
|
||||||
|
@ -512,7 +519,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
||||||
if self._device.can_next:
|
if self._device.can_next:
|
||||||
supported_features |= SUPPORT_NEXT_TRACK
|
supported_features |= SUPPORT_NEXT_TRACK
|
||||||
if self._device.has_play_media:
|
if self._device.has_play_media:
|
||||||
supported_features |= SUPPORT_PLAY_MEDIA
|
supported_features |= SUPPORT_PLAY_MEDIA | SUPPORT_BROWSE_MEDIA
|
||||||
if self._device.can_seek_rel_time:
|
if self._device.can_seek_rel_time:
|
||||||
supported_features |= SUPPORT_SEEK
|
supported_features |= SUPPORT_SEEK
|
||||||
|
|
||||||
|
@ -586,9 +593,29 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
||||||
"""Play a piece of media."""
|
"""Play a piece of media."""
|
||||||
_LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs)
|
_LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs)
|
||||||
assert self._device is not None
|
assert self._device is not None
|
||||||
|
|
||||||
|
didl_metadata: str | None = None
|
||||||
|
title: str = ""
|
||||||
|
|
||||||
|
# If media is media_source, resolve it to url and MIME type, and maybe metadata
|
||||||
|
if media_source.is_media_source_id(media_id):
|
||||||
|
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
|
||||||
|
media_type = sourced_media.mime_type
|
||||||
|
media_id = sourced_media.url
|
||||||
|
_LOGGER.debug("sourced_media is %s", sourced_media)
|
||||||
|
if sourced_metadata := getattr(sourced_media, "didl_metadata", None):
|
||||||
|
didl_metadata = didl_lite.to_xml_string(sourced_metadata).decode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
title = sourced_metadata.title
|
||||||
|
|
||||||
|
# If media ID is a relative URL, we serve it from HA.
|
||||||
|
media_id = async_process_play_media_url(self.hass, media_id)
|
||||||
|
|
||||||
extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {}
|
extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {}
|
||||||
metadata: dict[str, Any] = extra.get("metadata") or {}
|
metadata: dict[str, Any] = extra.get("metadata") or {}
|
||||||
|
|
||||||
|
if not title:
|
||||||
title = extra.get("title") or metadata.get("title") or "Home Assistant"
|
title = extra.get("title") or metadata.get("title") or "Home Assistant"
|
||||||
if thumb := extra.get("thumb"):
|
if thumb := extra.get("thumb"):
|
||||||
metadata["album_art_uri"] = thumb
|
metadata["album_art_uri"] = thumb
|
||||||
|
@ -598,6 +625,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
||||||
if hass_key in metadata:
|
if hass_key in metadata:
|
||||||
metadata[didl_key] = metadata.pop(hass_key)
|
metadata[didl_key] = metadata.pop(hass_key)
|
||||||
|
|
||||||
|
if not didl_metadata:
|
||||||
# Create metadata specific to the given media type; different fields are
|
# Create metadata specific to the given media type; different fields are
|
||||||
# available depending on what the upnp_class is.
|
# available depending on what the upnp_class is.
|
||||||
upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type)
|
upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type)
|
||||||
|
@ -726,6 +754,54 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
||||||
assert self._device is not None
|
assert self._device is not None
|
||||||
await self._device.async_select_preset(sound_mode)
|
await self._device.async_select_preset(sound_mode)
|
||||||
|
|
||||||
|
async def async_browse_media(
|
||||||
|
self,
|
||||||
|
media_content_type: str | None = None,
|
||||||
|
media_content_id: str | None = None,
|
||||||
|
) -> BrowseMedia:
|
||||||
|
"""Implement the websocket media browsing helper.
|
||||||
|
|
||||||
|
Browses all available media_sources by default. Filters content_type
|
||||||
|
based on the DMR's sink_protocol_info.
|
||||||
|
"""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"async_browse_media(%s, %s)", media_content_type, media_content_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# media_content_type is ignored; it's the content_type of the current
|
||||||
|
# media_content_id, not the desired content_type of whomever is calling.
|
||||||
|
|
||||||
|
content_filter = self._get_content_filter()
|
||||||
|
|
||||||
|
return await media_source.async_browse_media(
|
||||||
|
self.hass, media_content_id, content_filter=content_filter
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_content_filter(self) -> Callable[[BrowseMedia], bool]:
|
||||||
|
"""Return a function that filters media based on what the renderer can play."""
|
||||||
|
if not self._device or not self._device.sink_protocol_info:
|
||||||
|
# Nothing is specified by the renderer, so show everything
|
||||||
|
_LOGGER.debug("Get content filter with no device or sink protocol info")
|
||||||
|
return lambda _: True
|
||||||
|
|
||||||
|
_LOGGER.debug("Get content filter for %s", self._device.sink_protocol_info)
|
||||||
|
if self._device.sink_protocol_info[0] == "*":
|
||||||
|
# Renderer claims it can handle everything, so show everything
|
||||||
|
return lambda _: True
|
||||||
|
|
||||||
|
# Convert list of things like "http-get:*:audio/mpeg:*" to just "audio/mpeg"
|
||||||
|
content_types: list[str] = []
|
||||||
|
for protocol_info in self._device.sink_protocol_info:
|
||||||
|
protocol, _, content_format, _ = protocol_info.split(":", 3)
|
||||||
|
if protocol in STREAMABLE_PROTOCOLS:
|
||||||
|
content_types.append(content_format)
|
||||||
|
|
||||||
|
def _content_type_filter(item: BrowseMedia) -> bool:
|
||||||
|
"""Filter media items by their content_type."""
|
||||||
|
return item.media_content_type in content_types
|
||||||
|
|
||||||
|
return _content_type_filter
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_title(self) -> str | None:
|
def media_title(self) -> str | None:
|
||||||
"""Title of current playing media."""
|
"""Title of current playing media."""
|
||||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncIterable, Mapping
|
from collections.abc import AsyncIterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
@ -15,6 +16,7 @@ from async_upnp_client.exceptions import (
|
||||||
UpnpResponseError,
|
UpnpResponseError,
|
||||||
)
|
)
|
||||||
from async_upnp_client.profiles.dlna import PlayMode, TransportState
|
from async_upnp_client.profiles.dlna import PlayMode, TransportState
|
||||||
|
from didl_lite import didl_lite
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant import const as ha_const
|
from homeassistant import const as ha_const
|
||||||
|
@ -29,6 +31,8 @@ from homeassistant.components.dlna_dmr.const import (
|
||||||
from homeassistant.components.dlna_dmr.data import EventListenAddr
|
from homeassistant.components.dlna_dmr.data import EventListenAddr
|
||||||
from homeassistant.components.media_player import ATTR_TO_PROPERTY, const as mp_const
|
from homeassistant.components.media_player import ATTR_TO_PROPERTY, const as mp_const
|
||||||
from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN
|
from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN
|
||||||
|
from homeassistant.components.media_source.const import DOMAIN as MS_DOMAIN
|
||||||
|
from homeassistant.components.media_source.models import PlayMedia
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import async_get as async_get_dr
|
from homeassistant.helpers.device_registry import async_get as async_get_dr
|
||||||
|
@ -418,7 +422,7 @@ async def test_feature_flags(
|
||||||
("can_stop", mp_const.SUPPORT_STOP),
|
("can_stop", mp_const.SUPPORT_STOP),
|
||||||
("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK),
|
("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK),
|
||||||
("can_next", mp_const.SUPPORT_NEXT_TRACK),
|
("can_next", mp_const.SUPPORT_NEXT_TRACK),
|
||||||
("has_play_media", mp_const.SUPPORT_PLAY_MEDIA),
|
("has_play_media", mp_const.SUPPORT_PLAY_MEDIA | mp_const.SUPPORT_BROWSE_MEDIA),
|
||||||
("can_seek_rel_time", mp_const.SUPPORT_SEEK),
|
("can_seek_rel_time", mp_const.SUPPORT_SEEK),
|
||||||
("has_presets", mp_const.SUPPORT_SELECT_SOUND_MODE),
|
("has_presets", mp_const.SUPPORT_SELECT_SOUND_MODE),
|
||||||
]
|
]
|
||||||
|
@ -760,6 +764,89 @@ async def test_play_media_metadata(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_play_media_local_source(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test play_media with a media_id from a local media_source."""
|
||||||
|
# Based on roku's test_services_play_media_local_source and cast's
|
||||||
|
# test_entity_browse_media
|
||||||
|
await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
mp_const.SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: mock_entity_id,
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_TYPE: "video/mp4",
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert dmr_device_mock.construct_play_media_metadata.await_count == 1
|
||||||
|
assert (
|
||||||
|
"/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig="
|
||||||
|
in dmr_device_mock.construct_play_media_metadata.call_args.kwargs["media_url"]
|
||||||
|
)
|
||||||
|
assert dmr_device_mock.async_set_transport_uri.await_count == 1
|
||||||
|
assert dmr_device_mock.async_play.await_count == 1
|
||||||
|
call_args = dmr_device_mock.async_set_transport_uri.call_args.args
|
||||||
|
assert "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_play_media_didl_metadata(
|
||||||
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test play_media passes available DIDL-Lite metadata to the DMR."""
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DidlPlayMedia(PlayMedia):
|
||||||
|
"""Playable media with DIDL metadata."""
|
||||||
|
|
||||||
|
didl_metadata: didl_lite.DidlObject
|
||||||
|
|
||||||
|
didl_metadata = didl_lite.VideoItem(
|
||||||
|
id="120$22$33",
|
||||||
|
restricted="false",
|
||||||
|
title="Epic Sax Guy 10 Hours",
|
||||||
|
res=[
|
||||||
|
didl_lite.Resource(uri="unused-URI", protocol_info="http-get:*:video/mp4:")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
play_media = DidlPlayMedia(
|
||||||
|
url="/media/local/Epic Sax Guy 10 Hours.mp4",
|
||||||
|
mime_type="video/mp4",
|
||||||
|
didl_metadata=didl_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
local_source = hass.data[MS_DOMAIN][MS_DOMAIN]
|
||||||
|
|
||||||
|
with patch.object(local_source, "async_resolve_media", return_value=play_media):
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
MP_DOMAIN,
|
||||||
|
mp_const.SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: mock_entity_id,
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_TYPE: "video/mp4",
|
||||||
|
mp_const.ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert dmr_device_mock.construct_play_media_metadata.await_count == 0
|
||||||
|
assert dmr_device_mock.async_set_transport_uri.await_count == 1
|
||||||
|
assert dmr_device_mock.async_play.await_count == 1
|
||||||
|
call_args = dmr_device_mock.async_set_transport_uri.call_args.args
|
||||||
|
assert "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0]
|
||||||
|
assert call_args[1] == "Epic Sax Guy 10 Hours"
|
||||||
|
assert call_args[2] == didl_lite.to_xml_string(didl_metadata).decode()
|
||||||
|
|
||||||
|
|
||||||
async def test_shuffle_repeat_modes(
|
async def test_shuffle_repeat_modes(
|
||||||
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -844,6 +931,88 @@ async def test_shuffle_repeat_modes(
|
||||||
dmr_device_mock.async_set_play_mode.assert_not_awaited()
|
dmr_device_mock.async_set_play_mode.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browse_media(
|
||||||
|
hass: HomeAssistant, hass_ws_client, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Test the async_browse_media method."""
|
||||||
|
# Based on cast's test_entity_browse_media
|
||||||
|
await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# DMR can play all media types
|
||||||
|
dmr_device_mock.sink_protocol_info = ["*"]
|
||||||
|
|
||||||
|
client = await hass_ws_client()
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "media_player/browse_media",
|
||||||
|
"entity_id": mock_entity_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
expected_child_video = {
|
||||||
|
"title": "Epic Sax Guy 10 Hours.mp4",
|
||||||
|
"media_class": "video",
|
||||||
|
"media_content_type": "video/mp4",
|
||||||
|
"media_content_id": "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
|
||||||
|
"can_play": True,
|
||||||
|
"can_expand": False,
|
||||||
|
"children_media_class": None,
|
||||||
|
"thumbnail": None,
|
||||||
|
}
|
||||||
|
assert expected_child_video in response["result"]["children"]
|
||||||
|
|
||||||
|
expected_child_audio = {
|
||||||
|
"title": "test.mp3",
|
||||||
|
"media_class": "music",
|
||||||
|
"media_content_type": "audio/mpeg",
|
||||||
|
"media_content_id": "media-source://media_source/local/test.mp3",
|
||||||
|
"can_play": True,
|
||||||
|
"can_expand": False,
|
||||||
|
"children_media_class": None,
|
||||||
|
"thumbnail": None,
|
||||||
|
}
|
||||||
|
assert expected_child_audio in response["result"]["children"]
|
||||||
|
|
||||||
|
# Device can only play MIME type audio/mpeg and audio/vorbis
|
||||||
|
dmr_device_mock.sink_protocol_info = [
|
||||||
|
"http-get:*:audio/mpeg:*",
|
||||||
|
"http-get:*:audio/vorbis:*",
|
||||||
|
]
|
||||||
|
client = await hass_ws_client()
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "media_player/browse_media",
|
||||||
|
"entity_id": mock_entity_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
# Video file should not be shown
|
||||||
|
assert expected_child_video not in response["result"]["children"]
|
||||||
|
# Audio file should appear
|
||||||
|
assert expected_child_audio in response["result"]["children"]
|
||||||
|
|
||||||
|
# Device does not specify what it can play
|
||||||
|
dmr_device_mock.sink_protocol_info = []
|
||||||
|
client = await hass_ws_client()
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "media_player/browse_media",
|
||||||
|
"entity_id": mock_entity_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
# All files should be returned
|
||||||
|
assert expected_child_video in response["result"]["children"]
|
||||||
|
assert expected_child_audio in response["result"]["children"]
|
||||||
|
|
||||||
|
|
||||||
async def test_playback_update_state(
|
async def test_playback_update_state(
|
||||||
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
Loading…
Add table
Reference in a new issue