HEX
Server: Apache/2.4.63 (Unix)
System: Linux Synopilou92 4.4.302+ #72806 SMP Mon Jul 21 23:16:00 CST 2025 x86_64
User: pilou92 (1026)
PHP: 8.0.30
Disabled: NONE
Upload Files
File: /volume1/docker/homeassistant2025/custom_components/tapo_control/media_source.py
"""
TODO:

- Background scheduled task which automatically downloads and caches videos per selected period (and deletes old stuff and hot/cold storage)
- Handle weird error that sometimes happens causing downloader to get stuck and never recovers until restart
"""

from __future__ import annotations


from homeassistant.components.media_player import MediaClass, MediaType
from homeassistant.components.media_source.error import Unresolvable
from homeassistant.components.media_source.models import (
    BrowseMediaSource,
    MediaSource,
    MediaSourceItem,
    PlayMedia,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.util import dt

from .const import DOMAIN, LOGGER, MEDIA_VIEW_DAYS_ORDER, MEDIA_VIEW_RECORDINGS_ORDER

from .utils import (
    getRecording,
    getFileName,
    getRecordings,
    getWebFile,
)

from pytapo import Tapo

import json
from urllib.parse import urlencode, urlparse, parse_qsl


async def async_get_media_source(hass: HomeAssistant) -> TapoMediaSource:
    """Set up Radio Browser media source."""
    LOGGER.debug("async_get_media_source")
    # TODO: handle case where cloud password was not set with nice error

    entry = hass.config_entries.async_entries(DOMAIN)[0]

    return TapoMediaSource(hass, entry)


def build_identifier(
    params: dict[str, str | list[str]] = None, base: str = DOMAIN
) -> str:
    if params is None:
        return f"{base}"
    query = urlencode(params, doseq=True)
    return f"{base}/?{query}"


def parse_identifier(identifier: str) -> dict[str, str]:
    query = urlparse(identifier).query
    return dict(parse_qsl(query, keep_blank_values=True))


class TapoMediaSource(MediaSource):
    """Provide Radio stations as media sources."""

    name = "Tapo: Recordings"

    def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
        """Initialize CameraMediaSource."""
        super().__init__(DOMAIN)
        self.hass = hass
        self.entry = entry

    async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
        path = item.identifier.split("/")
        if len(path) == 2:
            try:
                query = parse_identifier(path[1])
                LOGGER.debug(query)
                entry = query["entry"]
                date = query["date"]
                startDate = query["startDate"]
                endDate = query["endDate"]
                childID = ""
                isParent = self.hass.data[DOMAIN][entry]["isParent"]
                device = self.hass.data[DOMAIN][entry]

                if isParent is True:
                    childID = query["childID"]
                    for child in self.hass.data[DOMAIN][entry]["childDevices"]:
                        if child["camData"]["basic_info"]["dev_id"] == childID:
                            device = child
                            break

                tapoController: Tapo = device["controller"]

                if (
                    device["isDownloadingStream"]
                    and getFileName(startDate, endDate, False, childID=childID)
                    not in device["downloadedStreams"]
                ):
                    raise Unresolvable(
                        "Already downloading a recording, please try again later."
                    )

                LOGGER.debug(startDate)
                LOGGER.debug(endDate)

                await getRecording(
                    self.hass, tapoController, entry, device, date, startDate, endDate
                )
                url = await getWebFile(
                    self.hass, entry, startDate, endDate, "videos", childID=childID
                )
                LOGGER.debug(url)
            except Exception as e:
                LOGGER.error(e)
                raise Unresolvable(e)

            return PlayMedia(url, "video/mp4")
        else:
            raise Unresolvable("Unexpected path.")

    async def generateVideosForDate(self, query, title, entry, date, device):
        tapoController = device["controller"]
        childID = ""
        if "childID" in query:
            childID = query["childID"]
        media_view_recordings_order = self.hass.data[DOMAIN][entry]["entry"].data.get(
            MEDIA_VIEW_RECORDINGS_ORDER
        )
        if device["initialMediaScanDone"] is False:
            raise Unresolvable(
                "Initial local media scan still running, please try again later."
            )
        recordingsForDay = await getRecordings(self.hass, device, tapoController, date)
        videoNames = []
        for searchResult in recordingsForDay:
            for key in searchResult:
                startTS = searchResult[key]["startTime"] - device["timezoneOffset"]
                endTS = searchResult[key]["endTime"] - device["timezoneOffset"]
                startDate = dt.as_local(dt.utc_from_timestamp(startTS))
                endDate = dt.as_local(dt.utc_from_timestamp(endTS))
                videoName = (
                    f"{startDate.strftime('%H:%M:%S')} - {endDate.strftime('%H:%M:%S')}"
                )
                videoNames.append(
                    {
                        "name": videoName,
                        "startDate": searchResult[key]["startTime"],
                        "endDate": searchResult[key]["endTime"],
                    }
                )

        videoNames = sorted(
            videoNames,
            key=lambda x: x["startDate"],
            reverse=(True if media_view_recordings_order == "Descending" else False),
        )

        dateChildren = []
        for data in videoNames:
            fileName = getFileName(
                data["startDate"], data["endDate"], False, childID=childID
            )
            thumbLink = None
            if fileName in device["downloadedStreams"]:
                thumbLink = await getWebFile(
                    self.hass,
                    entry,
                    data["startDate"],
                    data["endDate"],
                    "thumbs",
                    childID=childID,
                )

            dateChildren.append(
                self.generateView(
                    build_identifier(
                        {
                            **query,
                            "title": data["name"],
                            "startDate": data["startDate"],
                            "endDate": data["endDate"],
                        }
                    ),
                    data["name"],
                    True,
                    False,
                    thumbnail=thumbLink,
                )
            )

        return self.generateView(
            build_identifier(query), title, False, True, children=dateChildren
        )

    async def generateDates(self, query, title, entry, device):
        tapoController = device["controller"]
        media_view_days_order = self.hass.data[DOMAIN][entry]["entry"].data.get(
            MEDIA_VIEW_DAYS_ORDER
        )
        if device["initialMediaScanDone"] is False:
            raise Unresolvable(
                "Initial local media scan still running, please try again later."
            )

        if device["usingCloudPassword"] is False:
            raise Unresolvable(
                "Cloud password is required in order to play recordings.\nSet cloud password inside Settings > Devices & Services > Tapo: Cameras Control > Configure."
            )
        recordingsList = await self.hass.async_add_executor_job(
            tapoController.getRecordingsList
        )
        recordingsDates = []
        for searchResult in recordingsList:
            for key in searchResult:
                recordingsDates.append(searchResult[key]["date"])

        recordingsDates.sort(
            reverse=True if media_view_days_order == "Descending" else False
        )

        return self.generateView(
            build_identifier(query),
            title,
            False,
            True,
            children=[
                self.generateView(
                    build_identifier({**query, "title": date, "date": date}),
                    date,
                    False,
                    True,
                )
                for date in recordingsDates
            ],
        )

    def generateView(
        self, identifier, title, can_play, can_expand, thumbnail=None, children=None
    ):
        return BrowseMediaSource(
            domain=DOMAIN,
            identifier=identifier,
            media_class=MediaClass.DIRECTORY,
            media_content_type=MediaType.VIDEO,
            title=title,
            can_play=can_play,
            can_expand=can_expand,
            thumbnail=thumbnail,
            children_media_class=MediaClass.DIRECTORY,
            children=children,
        )

    async def async_browse_media(
        self,
        item: MediaSourceItem,
    ) -> BrowseMediaSource:
        if item.identifier is None:
            return self.generateView(
                build_identifier(),
                self.name,
                False,
                True,
                children=[
                    self.generateView(
                        build_identifier(
                            {
                                "entry": entry,
                                "title": self.hass.data[DOMAIN][entry]["name"],
                            }
                        ),
                        self.hass.data[DOMAIN][entry]["name"],
                        False,
                        True,
                    )
                    for entry in self.hass.data[DOMAIN]
                ],
            )
        else:
            path = item.identifier.split("/")
            if len(path) != 2:
                raise Exception("Incorrect path, try navigating from Media again.")

            query = parse_identifier(path[1])
            entry = query["entry"]
            isParent = self.hass.data[DOMAIN][entry]["isParent"]
            title = query["title"]
            device = self.hass.data[DOMAIN][entry]
            if isParent is True:
                if "childID" in query:
                    childID = query["childID"]
                    for child in self.hass.data[DOMAIN][entry]["childDevices"]:
                        if child["camData"]["basic_info"]["dev_id"] == childID:
                            device = child
                            break

            if "date" in query:
                return await self.generateVideosForDate(
                    query, title, entry, query["date"], device
                )
            elif isParent is False or (isParent is True and "childID" in query):
                return await self.generateDates(query, title, entry, device)
            elif isParent is True:
                return self.generateView(
                    build_identifier(query),
                    self.hass.data[DOMAIN][entry]["name"],
                    False,
                    True,
                    children=[
                        self.generateView(
                            build_identifier(
                                {
                                    **query,
                                    "title": childDevice["name"],
                                    "childID": childDevice["camData"]["basic_info"][
                                        "dev_id"
                                    ],
                                }
                            ),
                            childDevice["name"],
                            False,
                            True,
                        )
                        for childDevice in self.hass.data[DOMAIN][entry]["childDevices"]
                    ],
                )
            else:
                raise Exception("Unexpected path.")