1 """HTTP view that converts audio from a URL to a preferred format."""
4 from collections
import defaultdict
5 from dataclasses
import dataclass, field
6 from http
import HTTPStatus
9 from typing
import Final
11 from aiohttp
import web
12 from aiohttp.abc
import AbstractStreamWriter, BaseRequest
18 from .const
import DATA_FFMPEG_PROXY
20 _LOGGER = logging.getLogger(__name__)
22 _MAX_CONVERSIONS_PER_DEVICE: Final[int] = 2
30 rate: int |
None =
None,
31 channels: int |
None =
None,
32 width: int |
None =
None,
34 """Create a use proxy URL that automatically converts the media."""
35 data: FFmpegProxyData = hass.data[DATA_FFMPEG_PROXY]
36 return data.async_create_proxy_url(
37 device_id, media_url, media_format, rate, channels, width
43 """Information for ffmpeg conversion."""
46 """Unique id for media conversion."""
49 """Source URL of media to convert."""
52 """Target format for media (mp3, flac, etc.)"""
55 """Target sample rate (None to keep source rate)."""
58 """Target number of channels (None to keep source channels)."""
61 """Target sample width in bytes (None to keep source width)."""
63 proc: asyncio.subprocess.Process |
None =
None
64 """Subprocess doing ffmpeg conversion."""
66 is_finished: bool =
False
67 """True if conversion has finished."""
72 """Data for ffmpeg proxy conversion."""
75 conversions: dict[str, list[FFmpegConversionInfo]] = field(
76 default_factory=
lambda: defaultdict(list)
88 """Create a one-time use proxy URL that automatically converts the media."""
91 device_conversions = [
92 info
for info
in self.conversions[device_id]
if not info.is_finished
95 while len(device_conversions) >= _MAX_CONVERSIONS_PER_DEVICE:
97 convert_info = device_conversions[0]
98 if (convert_info.proc
is not None)
and (
99 convert_info.proc.returncode
is None
102 "Stopping existing ffmpeg process for device: %s", device_id
104 convert_info.proc.kill()
106 device_conversions = device_conversions[1:]
108 convert_id = secrets.token_urlsafe(16)
109 device_conversions.append(
111 convert_id, media_url, media_format, rate, channels, width
114 _LOGGER.debug(
"Media URL allowed by proxy: %s", media_url)
116 self.conversions[device_id] = device_conversions
118 return f
"/api/esphome/ffmpeg_proxy/{device_id}/{convert_id}.{media_format}"
122 """HTTP streaming response that uses ffmpeg to convert audio from a URL."""
126 manager: FFmpegManager,
127 convert_info: FFmpegConversionInfo,
129 proxy_data: FFmpegProxyData,
130 chunk_size: int = 2048,
132 """Initialize response.
136 manager: FFmpegManager
138 convert_info: FFmpegConversionInfo
139 Information necessary to do the conversion
142 proxy_data: FFmpegProxyData
143 Data object to store ffmpeg process
145 Number of bytes to read from ffmpeg process at a time
157 self, request: BaseRequest, writer: AbstractStreamWriter
159 """Stream url through ffmpeg conversion and out to HTTP client."""
173 command_args.extend([
"-ac",
str(self.
convert_infoconvert_info.channels)])
177 command_args.extend([
"-sample_fmt",
"s16"])
180 command_args.extend([
"-map_metadata",
"-1",
"-vn"])
183 command_args.append(
"-nostats")
186 command_args.append(
"pipe:")
188 _LOGGER.debug(
"%s %s", self.
managermanager.binary,
" ".join(command_args))
189 proc = await asyncio.create_subprocess_exec(
192 stdout=asyncio.subprocess.PIPE,
193 stderr=asyncio.subprocess.PIPE,
201 write_task = self.
hasshass.async_create_background_task(
202 self.
_write_ffmpeg_data_write_ffmpeg_data(request, writer, proc),
"ESPHome media proxy"
208 request: BaseRequest,
209 writer: AbstractStreamWriter,
210 proc: asyncio.subprocess.Process,
212 assert proc.stdout
is not None
213 assert proc.stderr
is not None
215 stderr_task = self.
hasshass.async_create_background_task(
222 self.
hasshass.is_running
223 and (request.transport
is not None)
224 and (
not request.transport.is_closing())
225 and (chunk := await proc.stdout.read(self.
chunk_sizechunk_size))
227 await self.write(chunk)
228 except asyncio.CancelledError:
229 _LOGGER.debug(
"ffmpeg transcoding cancelled")
232 if request.transport:
233 request.transport.abort()
236 _LOGGER.exception(
"Unexpected error during ffmpeg conversion")
246 if proc.returncode
is None:
250 if request.transport
and not request.transport.is_closing():
251 await writer.write_eof()
255 proc: asyncio.subprocess.Process,
257 assert proc.stdout
is not None
258 assert proc.stderr
is not None
260 while self.
hasshass.is_running
and (chunk := await proc.stderr.readline()):
261 _LOGGER.debug(
"ffmpeg[%s] output: %s", proc.pid, chunk.decode().rstrip())
265 """FFmpeg web view to convert audio and stream back to client."""
267 requires_auth =
False
268 url =
"/api/esphome/ffmpeg_proxy/{device_id}/{filename}"
269 name =
"api:esphome:ffmpeg_proxy"
271 def __init__(self, manager: FFmpegManager, proxy_data: FFmpegProxyData) ->
None:
272 """Initialize an ffmpeg view."""
277 self, request: web.Request, device_id: str, filename: str
278 ) -> web.StreamResponse:
279 """Start a get request."""
280 device_conversions = self.
proxy_dataproxy_data.conversions[device_id]
281 if not device_conversions:
283 body=
"No proxy URL for device", status=HTTPStatus.NOT_FOUND
287 convert_id, media_format = filename.rsplit(
".")
290 convert_info: FFmpegConversionInfo |
None =
None
291 for maybe_convert_info
in device_conversions:
292 if (maybe_convert_info.convert_id == convert_id)
and (
293 maybe_convert_info.media_format == media_format
295 convert_info = maybe_convert_info
298 if convert_info
is None:
299 return web.Response(body=
"Invalid proxy URL", status=HTTPStatus.BAD_REQUEST)
304 if (convert_info.proc
is not None)
and (convert_info.proc.returncode
is None):
305 convert_info.proc.kill()
306 convert_info.proc =
None
312 writer = await resp.prepare(request)
313 assert writer
is not None
314 await resp.transcode(request, writer)
None __init__(self, FFmpegManager manager, FFmpegConversionInfo convert_info, str device_id, FFmpegProxyData proxy_data, int chunk_size=2048)
None transcode(self, BaseRequest request, AbstractStreamWriter writer)
None _dump_ffmpeg_stderr(self, asyncio.subprocess.Process proc)
None _write_ffmpeg_data(self, BaseRequest request, AbstractStreamWriter writer, asyncio.subprocess.Process proc)
str async_create_proxy_url(self, str device_id, str media_url, str media_format, int|None rate, int|None channels, int|None width)
web.StreamResponse get(self, web.Request request, str device_id, str filename)
None __init__(self, FFmpegManager manager, FFmpegProxyData proxy_data)
str async_create_proxy_url(HomeAssistant hass, str device_id, str media_url, str media_format, int|None rate=None, int|None channels=None, int|None width=None)