Compare commits

...

1 Commits

Author SHA1 Message Date
Paulus Schoutsen
d184037f5a Add delete support to media source 2025-09-23 22:42:15 -04:00
5 changed files with 79 additions and 65 deletions

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Protocol from typing import Protocol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.integration_platform import ( from homeassistant.helpers.integration_platform import (
@@ -73,7 +72,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Local sources support # Local sources support
await _process_media_source_platform(hass, DOMAIN, local_source) await _process_media_source_platform(hass, DOMAIN, local_source)
hass.http.register_view(local_source.UploadMediaView) hass.http.register_view(local_source.UploadMediaView)
websocket_api.async_register_command(hass, local_source.websocket_remove_media)
await async_process_integration_platforms( await async_process_integration_platforms(
hass, DOMAIN, _process_media_source_platform hass, DOMAIN, _process_media_source_platform

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
@@ -16,14 +17,19 @@ from homeassistant.components.media_player import (
from homeassistant.components.websocket_api import ActiveConnection from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import MEDIA_SOURCE_DATA
from .error import Unresolvable from .error import Unresolvable
from .helper import async_browse_media, async_resolve_media from .helper import async_browse_media, async_resolve_media
from .models import MediaSourceItem
LOGGER = logging.getLogger(__name__)
def async_setup(hass: HomeAssistant) -> None: def async_setup(hass: HomeAssistant) -> None:
"""Set up the HTTP views and WebSocket commands for media sources.""" """Set up the HTTP views and WebSocket commands for media sources."""
websocket_api.async_register_command(hass, websocket_browse_media) websocket_api.async_register_command(hass, websocket_browse_media)
websocket_api.async_register_command(hass, websocket_resolve_media) websocket_api.async_register_command(hass, websocket_resolve_media)
websocket_api.async_register_command(hass, websocket_remove_media)
frontend.async_register_built_in_panel( frontend.async_register_built_in_panel(
hass, "media-browser", "media_browser", "hass:play-box-multiple" hass, "media-browser", "media_browser", "hass:play-box-multiple"
) )
@@ -77,3 +83,46 @@ async def websocket_resolve_media(
"mime_type": media.mime_type, "mime_type": media.mime_type,
}, },
) )
@websocket_api.websocket_command(
{
vol.Required("type"): "media_source/remove_media",
vol.Required("media_content_id"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def websocket_remove_media(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Remove media."""
try:
item = MediaSourceItem.from_uri(hass, msg["media_content_id"], None)
except ValueError as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
return
if item.domain is None:
connection.send_error(
msg["id"],
websocket_api.ERR_INVALID_FORMAT,
"Media source domain required",
)
return
source = hass.data[MEDIA_SOURCE_DATA][item.domain]
try:
await source.async_delete_media(item)
except NotImplementedError:
connection.send_error(
msg["id"], websocket_api.ERR_NOT_SUPPORTED, "Delete not supported"
)
except Unresolvable as err:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
except Exception as err: # pylint: disable=broad-except
LOGGER.exception("Unexpected error removing media")
connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
else:
connection.send_result(msg["id"])

View File

@@ -7,13 +7,13 @@ import logging
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
import shutil import shutil
from typing import Any, Protocol, cast from typing import Protocol, cast
from aiohttp import web from aiohttp import web
from aiohttp.web_request import FileField from aiohttp.web_request import FileField
import voluptuous as vol import voluptuous as vol
from homeassistant.components import http, websocket_api from homeassistant.components import http
from homeassistant.components.http import require_admin from homeassistant.components.http import require_admin
from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -28,10 +28,6 @@ MAX_UPLOAD_SIZE = 1024 * 1024 * 10
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
class PathNotSupportedError(HomeAssistantError):
"""Error to indicate a path is not supported."""
class InvalidFileNameError(HomeAssistantError): class InvalidFileNameError(HomeAssistantError):
"""Error to indicate an invalid file name.""" """Error to indicate an invalid file name."""
@@ -102,10 +98,10 @@ class LocalSource(MediaSource):
def _do_delete() -> None: def _do_delete() -> None:
if not item_path.exists(): if not item_path.exists():
raise FileNotFoundError("Path does not exist") raise Unresolvable("Path does not exist")
if not item_path.is_file(): if not item_path.is_file():
raise PathNotSupportedError("Path is not a file") raise Unresolvable("Path is not a file")
item_path.unlink() item_path.unlink()
@@ -141,7 +137,7 @@ class LocalSource(MediaSource):
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
except ValueError as err: except ValueError as err:
raise PathNotSupportedError("Invalid path") from err raise Unresolvable("Invalid path") from err
with target_path.open("wb") as target_fp: with target_path.open("wb") as target_fp:
shutil.copyfileobj(uploaded_file.file, target_fp) shutil.copyfileobj(uploaded_file.file, target_fp)
@@ -380,51 +376,8 @@ class UploadMediaView(http.HomeAssistantView):
except InvalidFileNameError as err: except InvalidFileNameError as err:
LOGGER.error("Invalid filename uploaded: %s", data["file"].filename) LOGGER.error("Invalid filename uploaded: %s", data["file"].filename)
raise web.HTTPBadRequest from err raise web.HTTPBadRequest from err
except PathNotSupportedError as err:
LOGGER.error("Invalid path for upload: %s", data["media_content_id"])
raise web.HTTPBadRequest from err
except OSError as err: except OSError as err:
LOGGER.error("Error uploading file: %s", err) LOGGER.error("Error uploading file: %s", err)
raise web.HTTPInternalServerError from err raise web.HTTPInternalServerError from err
return self.json({"media_content_id": uploaded_media_source_id}) return self.json({"media_content_id": uploaded_media_source_id})
@websocket_api.websocket_command(
{
vol.Required("type"): "media_source/local_source/remove",
vol.Required("media_content_id"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def websocket_remove_media(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Remove media."""
try:
item = MediaSourceItem.from_uri(hass, msg["media_content_id"], None)
except ValueError as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
return
if item.domain != DOMAIN:
connection.send_error(
msg["id"], websocket_api.ERR_INVALID_FORMAT, "Invalid media source domain"
)
return
source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][item.domain])
try:
await source.async_delete_media(item)
except Unresolvable as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
except FileNotFoundError as err:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
except PathNotSupportedError as err:
connection.send_error(msg["id"], websocket_api.ERR_NOT_SUPPORTED, str(err))
except OSError as err:
connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
else:
connection.send_result(msg["id"])

View File

@@ -27,7 +27,12 @@ class BrowseMediaSource(BrowseMedia):
"""Represent a browsable media file.""" """Represent a browsable media file."""
def __init__( def __init__(
self, *, domain: str | None, identifier: str | None, **kwargs: Any self,
*,
domain: str | None,
identifier: str | None,
can_delete: bool = False,
**kwargs: Any,
) -> None: ) -> None:
"""Initialize media source browse media.""" """Initialize media source browse media."""
media_content_id = f"{URI_SCHEME}{domain or ''}" media_content_id = f"{URI_SCHEME}{domain or ''}"
@@ -38,6 +43,13 @@ class BrowseMediaSource(BrowseMedia):
self.domain = domain self.domain = domain
self.identifier = identifier self.identifier = identifier
self.can_delete = can_delete
def as_dict(self, *, parent: bool = True) -> dict[str, Any]:
"""Convert BrowseMediaSource to a dictionary."""
response = super().as_dict(parent=parent)
response["can_delete"] = self.can_delete
return response
@dataclass(slots=True) @dataclass(slots=True)
@@ -135,3 +147,7 @@ class MediaSource:
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
"""Browse media.""" """Browse media."""
raise NotImplementedError raise NotImplementedError
async def async_delete_media(self, item: MediaSourceItem) -> None:
"""Delete media."""
raise NotImplementedError

View File

@@ -297,7 +297,7 @@ async def test_remove_file(
await client.send_json( await client.send_json(
{ {
"id": msgid(), "id": msgid(),
"type": "media_source/local_source/remove", "type": "media_source/remove_media",
"media_content_id": f"media-source://media_source/test_dir/{to_delete.name}", "media_content_id": f"media-source://media_source/test_dir/{to_delete.name}",
} }
) )
@@ -317,23 +317,21 @@ async def test_remove_file(
websocket_api.ERR_NOT_FOUND, websocket_api.ERR_NOT_FOUND,
), ),
# Only a dir # Only a dir
("media-source://media_source/test_dir", websocket_api.ERR_NOT_SUPPORTED), ("media-source://media_source/test_dir", websocket_api.ERR_NOT_FOUND),
# File with extra identifiers # File with extra identifiers
( (
f"media-source://media_source/test_dir/bla/../{extra_id_file.name}", f"media-source://media_source/test_dir/bla/../{extra_id_file.name}",
websocket_api.ERR_INVALID_FORMAT, websocket_api.ERR_NOT_FOUND,
), ),
# Location is invalid # Location is invalid
("media-source://media_source/test_dir/..", websocket_api.ERR_INVALID_FORMAT), ("media-source://media_source/test_dir/..", websocket_api.ERR_NOT_FOUND),
# Domain != media_source
("media-source://nest/test_dir/.", websocket_api.ERR_INVALID_FORMAT),
# Completely something else # Completely something else
("http://bla", websocket_api.ERR_INVALID_FORMAT), ("http://bla", websocket_api.ERR_INVALID_FORMAT),
): ):
await client.send_json( await client.send_json(
{ {
"id": msgid(), "id": msgid(),
"type": "media_source/local_source/remove", "type": "media_source/remove_media",
"media_content_id": bad_id, "media_content_id": bad_id,
} }
) )
@@ -341,7 +339,7 @@ async def test_remove_file(
msg = await client.receive_json() msg = await client.receive_json()
assert not msg["success"], bad_id assert not msg["success"], bad_id
assert msg["error"]["code"] == err assert msg["error"]["code"] == err, bad_id
assert extra_id_file.exists() assert extra_id_file.exists()
@@ -352,7 +350,7 @@ async def test_remove_file(
await client.send_json( await client.send_json(
{ {
"id": msgid(), "id": msgid(),
"type": "media_source/local_source/remove", "type": "media_source/remove_media",
"media_content_id": f"media-source://media_source/test_dir/{to_delete_2.name}", "media_content_id": f"media-source://media_source/test_dir/{to_delete_2.name}",
} }
) )
@@ -369,7 +367,7 @@ async def test_remove_file(
await client.send_json( await client.send_json(
{ {
"id": msgid(), "id": msgid(),
"type": "media_source/local_source/remove", "type": "media_source/remove_media",
"media_content_id": f"media-source://media_source/test_dir/{to_delete_3.name}", "media_content_id": f"media-source://media_source/test_dir/{to_delete_3.name}",
} }
) )