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

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
@@ -16,14 +17,19 @@ from homeassistant.components.media_player import (
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant
from .const import MEDIA_SOURCE_DATA
from .error import Unresolvable
from .helper import async_browse_media, async_resolve_media
from .models import MediaSourceItem
LOGGER = logging.getLogger(__name__)
def async_setup(hass: HomeAssistant) -> None:
"""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_resolve_media)
websocket_api.async_register_command(hass, websocket_remove_media)
frontend.async_register_built_in_panel(
hass, "media-browser", "media_browser", "hass:play-box-multiple"
)
@@ -77,3 +83,46 @@ async def websocket_resolve_media(
"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
from pathlib import Path
import shutil
from typing import Any, Protocol, cast
from typing import Protocol, cast
from aiohttp import web
from aiohttp.web_request import FileField
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.media_player import BrowseError, MediaClass
from homeassistant.core import HomeAssistant, callback
@@ -28,10 +28,6 @@ MAX_UPLOAD_SIZE = 1024 * 1024 * 10
LOGGER = logging.getLogger(__name__)
class PathNotSupportedError(HomeAssistantError):
"""Error to indicate a path is not supported."""
class InvalidFileNameError(HomeAssistantError):
"""Error to indicate an invalid file name."""
@@ -102,10 +98,10 @@ class LocalSource(MediaSource):
def _do_delete() -> None:
if not item_path.exists():
raise FileNotFoundError("Path does not exist")
raise Unresolvable("Path does not exist")
if not item_path.is_file():
raise PathNotSupportedError("Path is not a file")
raise Unresolvable("Path is not a file")
item_path.unlink()
@@ -141,7 +137,7 @@ class LocalSource(MediaSource):
target_dir.mkdir(parents=True, exist_ok=True)
except ValueError as err:
raise PathNotSupportedError("Invalid path") from err
raise Unresolvable("Invalid path") from err
with target_path.open("wb") as target_fp:
shutil.copyfileobj(uploaded_file.file, target_fp)
@@ -380,51 +376,8 @@ class UploadMediaView(http.HomeAssistantView):
except InvalidFileNameError as err:
LOGGER.error("Invalid filename uploaded: %s", data["file"].filename)
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:
LOGGER.error("Error uploading file: %s", err)
raise web.HTTPInternalServerError from err
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."""
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:
"""Initialize media source browse media."""
media_content_id = f"{URI_SCHEME}{domain or ''}"
@@ -38,6 +43,13 @@ class BrowseMediaSource(BrowseMedia):
self.domain = domain
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)
@@ -135,3 +147,7 @@ class MediaSource:
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
"""Browse media."""
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(
{
"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}",
}
)
@@ -317,23 +317,21 @@ async def test_remove_file(
websocket_api.ERR_NOT_FOUND,
),
# 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
(
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
("media-source://media_source/test_dir/..", websocket_api.ERR_INVALID_FORMAT),
# Domain != media_source
("media-source://nest/test_dir/.", websocket_api.ERR_INVALID_FORMAT),
("media-source://media_source/test_dir/..", websocket_api.ERR_NOT_FOUND),
# Completely something else
("http://bla", websocket_api.ERR_INVALID_FORMAT),
):
await client.send_json(
{
"id": msgid(),
"type": "media_source/local_source/remove",
"type": "media_source/remove_media",
"media_content_id": bad_id,
}
)
@@ -341,7 +339,7 @@ async def test_remove_file(
msg = await client.receive_json()
assert not msg["success"], bad_id
assert msg["error"]["code"] == err
assert msg["error"]["code"] == err, bad_id
assert extra_id_file.exists()
@@ -352,7 +350,7 @@ async def test_remove_file(
await client.send_json(
{
"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}",
}
)
@@ -369,7 +367,7 @@ async def test_remove_file(
await client.send_json(
{
"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}",
}
)