Creating a YouTube Thumbnail Sensor in Home Assistant

Hello everyone.

When connected via AirPlay, the Apple TV integration in Home Assistant cannot retrieve the thumbnail of a YouTube video playing on the Apple TV’s YouTube app

I was searching for a solution to this issue when I came across matt8707’s project. youtube-watching

However, it did not work well in my environment, so I integrated it into AppDaemon.

It can be installed via HACS, and the MQTT integration is required. It’s easy to use.


You no longer need to use Appdaemon in a complicated way. Mattias Persson has provided us with an excellent solution.

However, I’ll keep maintaining the AppDaemon project for those who find the following setup difficult. Haha.

For Haos

  1. A cookies.txt file is still required.
  2. Your version of yt-dlp must be 2025.03.31 or later. Even if your Home Assistant core version is 2025.4.1, you still need to update it.
    2-1. Log in to the console window. It’s not an ssh addon or putty!
    2-2. Run login
    2-3. Run docker exec -it homeassistant /bin/bash
    2-4. Run pip install -U yt-dlp
    2-5. Run python3 -c "import yt_dlp; print(yt_dlp.version.__version__)"
    (Verify that the update is complete. If it outputs 2025.03.31, then it’s OK.)
  3. In the /config(homeassistant)/python(any folder name)/, create two files: youtube_thumbnail.py and set_entity_picture.py.
  4. Add the following to /config/secrets.yaml:
    ha_host: "http://{your_ha_ip}:8123"
    ha_token: "{your-long-lived-token}"
  5. Insert the following code into youtube_thumbnail.py:
import yt_dlp
import json

URL = "https://www.youtube.com/feed/history"

ydl_opts = {
   "cookiefile": "/config/python/.cookies.txt",
   "skip_download": True,
   "playlist_items": "1",
   "quiet": True,
   "no_warnings": True,
}

with yt_dlp.YoutubeDL(ydl_opts) as ydl:
   info = ydl.extract_info(URL, download=False)
   data = ydl.sanitize_info(info)
   entry = data.get("entries", [data])[0]
   print(
       json.dumps(
           {
               "channel": entry.get("channel"),
               "title": entry.get("fulltitle"),
               "video_id": entry.get("id"),
               "thumbnail": entry.get("thumbnail"),
               "original_url": entry.get("original_url"),
           },
           indent=2,
       )
   )
  1. Insert the following code into set_entity_picture.py:
    EDIT
    from secrets import get_secret did not work in the haos. It works successfully after loading the secrets.yaml file as shown below.
import argparse
import requests
import yaml

with open('/config/secrets.yaml') as f:
    secrets = yaml.safe_load(f)

HOST = get_secret("ha_host")
TOKEN = get_secret("ha_token")


def update_entity_picture(entity_id, entity_picture):
    url = f"{HOST}/api/states/{entity_id}"
    headers = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
    response = requests.get(url, headers=headers)
    if response.status_code == 200:
        data = response.json()
        data["attributes"]["entity_picture"] = entity_picture
        response = requests.post(url, headers=headers, json=data)
        if response.status_code == 200:
            print("ok")
        else:
            print("Error posting update: ", response.text)
    else:
        print("Error retrieving state: ", response.text)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="update entity_picture")
    parser.add_argument("--entity_id", required=True, help="entity_id")
    parser.add_argument("--entity_picture", required=True, help="entity_picture")
    args = parser.parse_args()
    update_entity_picture(args.entity_id, args.entity_picture)
  1. Add the following command_line sensor to /config/configuration.yaml:
command_line:
  - sensor:
      name: youtube_thumbnail
      command: "python3 /config/python/youtube_thumbnail.py"
      value_template: "{{ value_json.thumbnail }}"
      json_attributes:
        - channel
        - title
        - video_id
        - thumbnail
        - original_url
      scan_interval: 86400
  1. Add the following shell_command to /config/configuration.yaml:
shell_command:
  set_entity_picture: "python3 /config/python/set_entity_picture.py --entity_id '{{ entity_id }}' --entity_picture '{{ entity_picture }}'"
  1. Create an automation.
alias: Set youtube entity_picture
triggers:
  - trigger: state
    entity_id:
      - media_player.sovrum
      - media_player.vardagsrum
    to:
      - playing
      - paused
conditions:
  - condition: template
    value_template: >
      {% set entity_id = trigger.entity_id %}
      {% set youtube = 'sensor.youtube_thumbnail' %}

      {{ is_state_attr(entity_id, 'app_id', 'com.google.ios.youtube')
      and (state_attr(entity_id, 'media_artist') != state_attr(youtube, 'channel')) 
      and (state_attr(entity_id, 'media_title') != state_attr(youtube, 'title')) }}
actions:
  - action: homeassistant.update_entity
    data:
      entity_id:
        - sensor.youtube_thumbnail
  - action: shell_command.set_entity_picture
    data:
      entity_id: >
        {{ trigger.entity_id }}
      entity_picture: >
        {{ states('sensor.youtube_thumbnail') }}
mode: single
  1. It’s done. Now, when you play or pause the Apple TV, the media_player.apple_tv (used as a trigger in the automation) will have an entity_picture attribute.
1 Like

Exactly what I was looking for. I’ve followed all the steps, but I can’t get the sensors to show up, they don’t exist. What do I need to do?

Show me what appears in the AppDaemon logs.

Hey, I’ve been playing with this again. Here’s what I’ve come up with so far

apperently yt_dlp is available in home assistant so just

import yt_dlp
import json

URL = "https://www.youtube.com/feed/history"

ydl_opts = {
    "cookiefile": "/config/python/.cookies.txt",
    "skip_download": True,
    "playlist_items": "1",
    "quiet": True,
    "no_warnings": True,
}

with yt_dlp.YoutubeDL(ydl_opts) as ydl:
    info = ydl.extract_info(URL, download=False)
    data = ydl.sanitize_info(info)
    entry = data.get("entries", [data])[0]
    print(
        json.dumps(
            {
                "channel": entry.get("channel"),
                "title": entry.get("fulltitle"),
                "video_id": entry.get("id"),
                "thumbnail": entry.get("thumbnail"),
                "original_url": entry.get("original_url"),
            },
            indent=2,
        )
    )

and to “force” set entity_picture on the media player

import argparse
import requests
from secrets import get_secret

HOST = get_secret("ha_host")
TOKEN = get_secret("ha_token")


def update_entity_picture(entity_id, entity_picture):
    url = f"{HOST}/api/states/{entity_id}"
    headers = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
    response = requests.get(url, headers=headers)
    if response.status_code == 200:
        data = response.json()
        data["attributes"]["entity_picture"] = entity_picture
        response = requests.post(url, headers=headers, json=data)
        if response.status_code == 200:
            print("ok")
        else:
            print("Error posting update: ", response.text)
    else:
        print("Error retrieving state: ", response.text)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="update entity_picture")
    parser.add_argument("--entity_id", required=True, help="entity_id")
    parser.add_argument("--entity_picture", required=True, help="entity_picture")
    args = parser.parse_args()
    update_entity_picture(args.entity_id, args.entity_picture)

alias: Set youtube entity_picture
triggers:
  - trigger: state
    entity_id:
      - media_player.sovrum
      - media_player.vardagsrum
    to:
      - playing
      - paused
conditions:
  - condition: template
    value_template: >
      {% set entity_id = trigger.entity_id %}
      {% set youtube = 'sensor.youtube_thumbnail' %}

      {{ is_state_attr(entity_id, 'app_id', 'com.google.ios.youtube')
      and (state_attr(entity_id, 'media_artist') != state_attr(youtube, 'channel')) 
      and (state_attr(entity_id, 'media_title') != state_attr(youtube, 'title')) }}
actions:
  - action: homeassistant.update_entity
    data:
      entity_id:
        - sensor.youtube_thumbnail
  - action: shell_command.set_entity_picture
    data:
      entity_id: >
        {{ trigger.entity_id }}
      entity_picture: >
        {{ states('sensor.youtube_thumbnail') }}
mode: single

still some quirks, like pausing the media player will remove entity_picture, and because it’s not a session every yt fetch takes like 3 seconds. I’ve tried a container with GitHub - LuanRT/YouTube.js: A JavaScript client for YouTube's private API, known as InnerTube. and that is much faster

1 Like

Nevermind, my AppDeamon was causing trouble. Got it working, trying to figure out how to get this code show up in my media conditional, still using Mattias old dashboard…

That’s great.

- type: conditional
  conditions:
    - entity: select.conditional_media
      state: 4k
  card:
    type: custom:button-card
    entity: media_player.4k
    name: Apple tv
    triggers_update: sensor.youtube_watching
    template:
      - conditional_media

You should write it inside the dashboard’s media conditional. Of course, select.conditional_media must include the Apple TV entity. (Mine is media_player.4k)


Mattias Persson gave me homework, so I’m working hard on it. It’s so difficult, TT

I’m a huge fan of yours. I’ll try to understand the code you provided a bit more and leave my additional comments. EDIT The command_line isn’t working very well, though.

I have no idea how to execute this code,
```
import yt_dlp
import json

URL = “https://www.youtube.com/feed/history

ydl_opts = {
“cookiefile”: “/config/python/.cookies.txt”,
“skip_download”: True,
“playlist_items”: “1”,
“quiet”: True,
“no_warnings”: True,
}

with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(URL, download=False)
data = ydl.sanitize_info(info)
entry = data.get(“entries”, [data])[0]
print(
json.dumps(
{
“channel”: entry.get(“channel”),
“title”: entry.get(“fulltitle”),
“video_id”: entry.get(“id”),
“thumbnail”: entry.get(“thumbnail”),
“original_url”: entry.get(“original_url”),
},
indent=2,
)
)
```

Fewww… I finally solved it. I was still using core 3.4, and the default installed version of yt-dlp was 2025.02.19. After updating yt-dlp to version 2025.3.31 in the console, it works correctly. In core 4.1, it’s version 2025.03.26.


But I still haven’t solved the import requests issue…
EDIT2

Alright. I’ve finally solved everything. Thank you so much

                  - type: conditional
                    conditions:
                      - entity: select.conditional_media
                        state: Vardagsrum
                    card:
                      type: custom:button-card
                      entity: media_player.vardagsrum_2
                      triggers_update: sensor.youtube_watching
                      template:
                        - conditional_media
                        - icon_apple_tv
                        - progress_bar

Not showing youtube thumbnail in conditional media grid place, but both mqtt sensors are working and giving correct data.

If so, you should take a look at the conditional_media template.
In fact, rather than focusing on conditional_media, you should take a closer look at the other included templates: base_media and media.

base_media

The is_youtube and entity_picture variables must be defined among the variables of the base_media template.

variables:
  media_on: >
    [[[ return !entity || ['playing', 'paused'].indexOf(entity.state) !== -1; ]]]
  media_off: >
    [[[ return !entity || ['off', 'idle', 'standby', 'unknown', 'unavailable'].indexOf(entity.state) !== -1; ]]]
  # entity_picture: >
  #   [[[ return !entity || entity.attributes.entity_picture; ]]]
  entity_picture: >
    [[[ 
      if (!entity || entity.attributes.entity_picture === undefined) {
        return entity.attributes.title_image;
      }
      return entity.attributes.entity_picture;
    ]]]

  is_youtube: >
    [[[
      let is_youtube = entity?.attributes?.app_id === 'com.google.ios.youtube',
          sensor = this?._config?.triggers_update,
          media_title = entity?.attributes?.media_title,
          watching_title = states[sensor]?.attributes?.title;
      if (is_youtube && media_title === watching_title) {
          return true;
      }
    ]]]

media

media:
  template:
    - base
    - base_media
styles:
    custom_fields:
      icon:
        - width: 70%
        - margin-left: 2%
        - fill: '#9da0a2'
        - display: >
            [[[
            if (variables.is_youtube) {
                return `none`;
            }
            else {
                return variables.media_off || variables.entity_picture === undefined
                    ? 'initial'
                    : 'none';
            }
            ]]]
    card:
      - background-color: none
      - background-size: cover
      - background-position: center
      - background-image: >
          [[[
            if (variables.is_youtube) {
                return `linear-gradient(0deg, rgba(0,0,0,.8) 0%, rgba(0,0,0,0) 100%), url(${states[this._config?.triggers_update].state})`;
            } else {
                return variables.media_on && variables.entity_picture === undefined
                    ? 'linear-gradient(0deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.8) 100%)'
                    : variables.media_off
                        ? 'linear-gradient(0deg, rgba(115, 115, 115, 0.2) 0%, rgba(115, 115, 115, 0.2) 100%)'
                        : `linear-gradient(0deg, rgba(0,0,0,.8) 0%, rgba(0,0,0,0) 100%), url(${variables.entity_picture})`;
            }
          ]]]

conditional_media

conditional_media:
  aspect_ratio: 1000/996
  template:
    - base
    - base_media
    - icon_play_pause
  variables:
    i: >
      [[[
        if (entity) {
            let data = entity.attributes.data;
            return data === undefined || Math.floor(Math.random() * (data.length - 1)) + 1;
        }
      ]]]
  styles:
    card:
      - background: rgba(115, 115, 115, 0.2) center center/cover no-repeat
      # - background-image: &media_background_image >
      - background-image: >
          [[[
            if (entity) {
              if (variables.is_youtube) {
                  return `url(${states[this._config?.triggers_update].state})`;
              } else {
                let data = entity.attributes.data;
                return data && (data[variables.i].fanart || data[variables.i].poster)
                    ? `url("${data[variables.i].fanart}"), url("${data[variables.i].poster}")`
                    : `url("${variables.entity_picture}")`;
              }
            }

Since I made a number of modifications from the original, there may be some differences.