├── custom_components ├── __init__.py └── kodi_recently_added │ ├── const.py │ ├── types.py │ ├── manifest.json │ ├── translations │ └── en.json │ ├── strings.json │ ├── utils.py │ ├── __init__.py │ ├── sensor.py │ ├── config_flow.py │ └── entities.py ├── tests ├── __init__.py ├── conftest.py ├── bandit.yaml ├── test_entities.py ├── test_utils.py └── test_config_flow.py ├── assets └── example.png ├── requirements.test.txt ├── hacs.json ├── .github ├── workflows │ ├── hassfest.yaml │ └── pythonpackage.yaml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── LICENSE ├── setup.cfg ├── .pre-commit-config.yaml └── README.md /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """tests.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """pytest fixtures.""" 2 | -------------------------------------------------------------------------------- /assets/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boralyl/kodi-recently-added/HEAD/assets/example.png -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | pykodi 2 | pytest 3 | pytest-cov 4 | pytest-homeassistant-custom-component>=0.4.8 5 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "kodi_recently_added" 2 | 3 | CONF_HIDE_WATCHED = "hide_watched" 4 | CONF_KODI_INSTANCE = "kodi_entry_id" 5 | -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 18 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "config_flow": true, 3 | "documentation": "https://github.com/boralyl/kodi-recently-added", 4 | "domains": ["sensor"], 5 | "homeassistant": "2025.6", 6 | "iot_class": "Local Polling", 7 | "name": "Kodi Recently Added Media", 8 | "render_readme": true 9 | } 10 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/types.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, TypedDict 2 | 3 | 4 | class KodiConfig(TypedDict): 5 | host: str 6 | name: Optional[str] 7 | password: str 8 | port: int 9 | ssl: bool 10 | timeout: int 11 | username: str 12 | ws_port: int 13 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "codeowners": ["@boralyl"], 3 | "config_flow": true, 4 | "dependencies": ["kodi"], 5 | "documentation": "https://github.com/boralyl/kodi-recently-added", 6 | "issue_tracker": "https://github.com/boralyl/kodi-recently-added/issues", 7 | "iot_class": "local_polling", 8 | "domain": "kodi_recently_added", 9 | "name": "Kodi Recently Added Media", 10 | "requirements": [], 11 | "version": "2.1.0" 12 | } 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yaml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | max-parallel: 4 12 | matrix: 13 | python-version: [3.12] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r requirements.test.txt 26 | 27 | - name: Run pytest 28 | run: | 29 | pytest 30 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "kodi_not_configured": "You must fist add Kodi via the integrations page." 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "kodi_entry_id": "Kodi integration" 10 | }, 11 | "description": "Select which Kodi integration to use.", 12 | "title": "Setup" 13 | } 14 | } 15 | }, 16 | "options": { 17 | "step": { 18 | "init": { 19 | "title": "Kodi Recently Added Media Options", 20 | "description": "Additional settings for the component.", 21 | "data": { 22 | "hide_watched": "Exclude media that is marked as watched." 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "kodi_not_configured": "You must first add Kodi via the integrations page." 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "kodi_entry_id": "Kodi integration" 10 | }, 11 | "description": "Select which Kodi integration to use.", 12 | "title": "Setup" 13 | } 14 | } 15 | }, 16 | "options": { 17 | "step": { 18 | "init": { 19 | "title": "Kodi Recently Added Media Options", 20 | "description": "Additional settings for the component.", 21 | "data": { 22 | "hide_watched": "Exclude media that is marked as watched." 23 | } 24 | } 25 | } 26 | }, 27 | "title": "Kodi Recently Added Media" 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Home Assistant and Custom Component versions (please complete the following information):** 27 | Home Assistant version: 28 | kodi-recently-added component version: 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Aaron Godfrey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from homeassistant import config_entries, core 4 | from homeassistant.components.kodi.const import DOMAIN as KODI_DOMAIN 5 | from homeassistant.const import CONF_HOST 6 | 7 | 8 | def find_matching_config_entry( 9 | hass: core.HomeAssistant, entry_id: str 10 | ) -> Optional[config_entries.ConfigEntry]: 11 | """Search existing config entries for one matching the entry_id.""" 12 | for entry in hass.config_entries.async_entries(KODI_DOMAIN): 13 | # Skip any entry whose source is marked as ignored. 14 | if entry.entry_id == entry_id and entry.source != "ignore": 15 | return entry 16 | return None 17 | 18 | 19 | def find_matching_config_entry_for_host( 20 | hass: core.HomeAssistant, host: str 21 | ) -> Optional[config_entries.ConfigEntry]: 22 | """Search existing config entries for one matching the host.""" 23 | for entry in hass.config_entries.async_entries(KODI_DOMAIN): 24 | # Skip any entry whose source is marked as ignored. 25 | if entry.data.get(CONF_HOST) == host and entry.source != "ignore": 26 | return entry 27 | return None 28 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | show_missing = true 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | norecursedirs = .git 16 | addopts = 17 | --strict 18 | --cov=custom_components 19 | 20 | [flake8] 21 | # https://github.com/ambv/black#line-length 22 | max-line-length = 88 23 | # E501: line too long 24 | # W503: Line break occurred before a binary operator 25 | # E203: Whitespace before ':' 26 | # D202 No blank lines allowed after function docstring 27 | # W504 line break after binary operator 28 | ignore = 29 | E501, 30 | W503, 31 | E203, 32 | D202, 33 | W504 34 | 35 | [isort] 36 | # https://github.com/timothycrosley/isort 37 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 38 | # splits long import on multiple lines indented by 4 spaces 39 | multi_line_output = 3 40 | include_trailing_comma=True 41 | force_grid_wrap=0 42 | use_parentheses=True 43 | line_length=88 44 | indent = " " 45 | # will group `import x` and `from x import` of the same module. 46 | force_sort_within_sections = true 47 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 48 | default_section = THIRDPARTY 49 | known_first_party = custom_components,tests 50 | forced_separate = tests 51 | combine_as_imports = true 52 | 53 | [mypy] 54 | python_version = 3.7 55 | ignore_errors = true 56 | follow_imports = silent 57 | ignore_missing_imports = true 58 | warn_incomplete_stub = true 59 | warn_redundant_casts = true 60 | warn_unused_configs = true 61 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v2.7.2 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py38-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 20.8b1 9 | hooks: 10 | - id: black 11 | args: 12 | - --safe 13 | - --quiet 14 | files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v1.17.1 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing 21 | - --skip="./.*,*.csv,*.json" 22 | - --quiet-level=2 23 | exclude_types: [csv, json] 24 | - repo: https://gitlab.com/pycqa/flake8 25 | rev: 3.8.4 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: 29 | - flake8-docstrings==1.5.0 30 | - pydocstyle==5.0.2 31 | files: ^(homeassistant|script|tests)/.+\.py$ 32 | - repo: https://github.com/PyCQA/bandit 33 | rev: 1.6.2 34 | hooks: 35 | - id: bandit 36 | args: 37 | - --quiet 38 | - --format=custom 39 | - --configfile=tests/bandit.yaml 40 | files: ^(homeassistant|script|tests)/.+\.py$ 41 | - repo: https://github.com/pre-commit/mirrors-isort 42 | rev: v5.6.4 43 | hooks: 44 | - id: isort 45 | - repo: https://github.com/pre-commit/pre-commit-hooks 46 | rev: v3.2.0 47 | hooks: 48 | - id: check-executables-have-shebangs 49 | stages: [manual] 50 | - id: check-json 51 | - repo: https://github.com/pre-commit/mirrors-mypy 52 | rev: v0.790 53 | hooks: 54 | - id: mypy 55 | args: 56 | - --pretty 57 | - --show-error-codes 58 | - --show-error-context 59 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/__init__.py: -------------------------------------------------------------------------------- 1 | """The Kodi Recently Added integration.""" 2 | 3 | import asyncio 4 | import logging 5 | 6 | from homeassistant import config_entries, core 7 | 8 | from .const import CONF_HIDE_WATCHED, DOMAIN 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | PLATFORMS = ["sensor"] 12 | 13 | 14 | async def async_setup_entry( 15 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 16 | ) -> bool: 17 | """Set up platforms from a ConfigEntry.""" 18 | kodi_entry_id = entry.data["kodi_entry_id"] 19 | unsub_options_update_listener = entry.add_update_listener(options_update_listener) 20 | hass.data[DOMAIN][entry.entry_id] = { 21 | "hide_watched": entry.options.get(CONF_HIDE_WATCHED, False), 22 | "kodi_config_entry_id": kodi_entry_id, 23 | "unsub_options_update_listener": unsub_options_update_listener, 24 | } 25 | 26 | if not entry.unique_id: 27 | hass.config_entries.async_update_entry( 28 | entry, unique_id="kodi_recently_added_media" 29 | ) 30 | 31 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 32 | 33 | return True 34 | 35 | 36 | async def async_unload_entry( 37 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 38 | ) -> bool: 39 | """Unload a config entry.""" 40 | unload_ok = all( 41 | await asyncio.gather( 42 | *[ 43 | hass.config_entries.async_forward_entry_unload(entry, platform) 44 | for platform in PLATFORMS 45 | ] 46 | ) 47 | ) 48 | # Remove options_update_listener. 49 | hass.data[DOMAIN][entry.entry_id]["unsub_options_update_listener"]() 50 | 51 | # Remove config entry from domain. 52 | if unload_ok: 53 | hass.data[DOMAIN].pop(entry.entry_id) 54 | 55 | return unload_ok 56 | 57 | 58 | async def options_update_listener( 59 | hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry 60 | ): 61 | """Handle options update.""" 62 | await hass.config_entries.async_reload(config_entry.entry_id) 63 | 64 | 65 | async def async_setup(hass: core.HomeAssistant, config: dict) -> bool: 66 | """Set up the Kodi Recently Added Media component from yaml configuration.""" 67 | hass.data.setdefault(DOMAIN, {}) 68 | return True 69 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import voluptuous as vol 4 | 5 | from homeassistant import config_entries, core 6 | from homeassistant.components.kodi.const import DOMAIN as KODI_DOMAIN 7 | from homeassistant.components.sensor import PLATFORM_SCHEMA 8 | from homeassistant.const import CONF_HOST 9 | import homeassistant.helpers.config_validation as cv 10 | 11 | from .const import CONF_HIDE_WATCHED, DOMAIN 12 | from .entities import KodiRecentlyAddedMoviesEntity, KodiRecentlyAddedTVEntity 13 | from .utils import find_matching_config_entry, find_matching_config_entry_for_host 14 | 15 | PLATFORM_SCHEMA = vol.Any( 16 | PLATFORM_SCHEMA.extend( 17 | { 18 | vol.Required(CONF_HOST): cv.string, 19 | vol.Optional(CONF_HIDE_WATCHED, default=False): bool, 20 | } 21 | ), 22 | ) 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | async def async_setup_entry( 27 | hass: core.HomeAssistant, 28 | config_entry: config_entries.ConfigEntry, 29 | async_add_entities, 30 | ): 31 | """Setup sensors from a config entry created in the integrations UI.""" 32 | conf = hass.data[DOMAIN][config_entry.entry_id] 33 | kodi_config_entry = find_matching_config_entry(hass, conf["kodi_config_entry_id"]) 34 | 35 | kodi = kodi_config_entry.runtime_data.kodi 36 | tv_entity = KodiRecentlyAddedTVEntity( 37 | kodi, kodi_config_entry.data, hide_watched=conf.get(CONF_HIDE_WATCHED, False) 38 | ) 39 | movies_entity = KodiRecentlyAddedMoviesEntity( 40 | kodi, kodi_config_entry.data, hide_watched=conf.get(CONF_HIDE_WATCHED, False) 41 | ) 42 | async_add_entities([tv_entity, movies_entity]) 43 | 44 | 45 | async def async_setup_platform( 46 | hass: core.HomeAssistant, config: dict, async_add_entities, discovery_info=None 47 | ) -> None: 48 | """Setup sensors from yaml configuration.""" 49 | host = config[CONF_HOST] 50 | hide_watched = config[CONF_HIDE_WATCHED] 51 | config_entry = find_matching_config_entry_for_host(hass, host) 52 | if config_entry is None: 53 | hosts = [ 54 | entry.data["host"] 55 | for entry in hass.config_entries.async_entries(KODI_DOMAIN) 56 | ] 57 | _LOGGER.error( 58 | "Failed to setup sensor. Could not find config entry for kodi host `%s` from configured hosts: %s", 59 | host, 60 | hosts, 61 | ) 62 | return 63 | 64 | try: 65 | data = hass.data[KODI_DOMAIN][config_entry.entry_id] 66 | except KeyError: 67 | config_entries = [ 68 | entry.as_dict() for entry in hass.config_entries.async_entries(KODI_DOMAIN) 69 | ] 70 | _LOGGER.error( 71 | "Failed to setup sensor. Could not find kodi data from existing config entries: %s", 72 | config_entries, 73 | ) 74 | return 75 | kodi = data[DATA_KODI] 76 | 77 | tv_entity = KodiRecentlyAddedTVEntity(kodi, config_entry.data, hide_watched) 78 | movies_entity = KodiRecentlyAddedMoviesEntity(kodi, config_entry.data, hide_watched) 79 | async_add_entities([tv_entity, movies_entity]) 80 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict, Optional 3 | 4 | import voluptuous as vol 5 | from homeassistant import config_entries 6 | from homeassistant.components.kodi.const import DOMAIN as KODI_DOMAIN 7 | from homeassistant.core import callback 8 | 9 | from .const import CONF_HIDE_WATCHED, CONF_KODI_INSTANCE, DOMAIN 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | OPTIONS_SCHEMA = vol.Schema({vol.Optional(CONF_HIDE_WATCHED, default=False): bool}) 13 | 14 | 15 | class KodiRecentlyAddedConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 16 | """Kodi Recently Added config flow.""" 17 | 18 | async def async_step_user(self, user_input: Optional[Dict[str, Any]]): 19 | """Handle a flow initialized via the user interface.""" 20 | # Find all configured kodi instances to allow the user to select one. 21 | kodi_instances: Dict[str, str] = { 22 | entry.entry_id: entry.title 23 | for entry in self.hass.config_entries.async_entries(KODI_DOMAIN) 24 | if entry.source != "ignore" 25 | } 26 | data_schema = vol.Schema( 27 | {vol.Required(CONF_KODI_INSTANCE): vol.In(list(kodi_instances.values()))} 28 | ) 29 | 30 | errors = {} 31 | if not kodi_instances: 32 | errors["base"] = "kodi_not_configured" 33 | 34 | if user_input is not None: 35 | config_entry_id: Optional[str] = None 36 | for entry_id, title in kodi_instances.items(): 37 | if title == user_input[CONF_KODI_INSTANCE]: 38 | config_entry_id = entry_id 39 | break 40 | if config_entry_id is None: 41 | errors["base"] = "kodi_not_configured" 42 | 43 | if not errors: 44 | return self.async_create_entry( 45 | title="Kodi Recently Added", data={"kodi_entry_id": config_entry_id} 46 | ) 47 | 48 | return self.async_show_form( 49 | step_id="user", 50 | data_schema=data_schema, 51 | errors=errors, 52 | ) 53 | 54 | @staticmethod 55 | @callback 56 | def async_get_options_flow(config_entry): 57 | """Get the options flow for this handler.""" 58 | return OptionsFlowHandler(config_entry) 59 | 60 | 61 | class OptionsFlowHandler(config_entries.OptionsFlow): 62 | """Handles options flow for the component.""" 63 | 64 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 65 | self.config_entry = config_entry 66 | 67 | async def async_step_init(self, user_input=None): 68 | """Manage the options.""" 69 | if user_input is not None: 70 | return self.async_create_entry(title="", data=user_input) 71 | 72 | hide_watched = self.config_entry.options.get(CONF_HIDE_WATCHED, False) 73 | options_schema = vol.Schema( 74 | {vol.Optional(CONF_HIDE_WATCHED, default=hide_watched): bool} 75 | ) 76 | return self.async_show_form( 77 | step_id="init", 78 | data_schema=options_schema, 79 | ) 80 | -------------------------------------------------------------------------------- /tests/test_entities.py: -------------------------------------------------------------------------------- 1 | """Tests for entities.py.""" 2 | 3 | from unittest import mock 4 | 5 | import pykodi 6 | 7 | from custom_components.kodi_recently_added.entities import KodiMediaEntity 8 | 9 | 10 | def test_kodi_media_entity_init_base_web_url_https(): 11 | """Test base web url property with ssl enabled.""" 12 | config = { 13 | "host": "127.0.0.1", 14 | "password": "password", 15 | "port": 8080, 16 | "ssl": True, 17 | "username": "username", 18 | } 19 | entity = KodiMediaEntity(mock.Mock(), config) 20 | expected = "https://username:password@127.0.0.1:8080/image/image%3A%2F%2F" 21 | assert expected == entity.base_web_image_url 22 | 23 | 24 | def test_kodi_media_entity_init_base_web_url_http(): 25 | """Test base web url property with ssl disabled.""" 26 | config = { 27 | "host": "127.0.0.1", 28 | "password": "password", 29 | "port": 8080, 30 | "ssl": False, 31 | "username": "username", 32 | } 33 | entity = KodiMediaEntity(mock.Mock(), config) 34 | expected = "http://username:password@127.0.0.1:8080/image/image%3A%2F%2F" 35 | assert expected == entity.base_web_image_url 36 | 37 | 38 | def test_kodi_media_entity_init_base_web_url_no_auth(): 39 | """Test base web url property when there is no authentication configured.""" 40 | config = { 41 | "host": "127.0.0.1", 42 | "password": None, 43 | "port": 8080, 44 | "ssl": False, 45 | "username": None, 46 | } 47 | entity = KodiMediaEntity(mock.Mock(), config) 48 | expected = "http://127.0.0.1:8080/image/image%3A%2F%2F" 49 | assert expected == entity.base_web_image_url 50 | 51 | 52 | def test_get_web_url_http_already(): 53 | """Test get_web_url when path is an http url.""" 54 | config = { 55 | "host": "127.0.0.1", 56 | "password": "password", 57 | "port": 8080, 58 | "ssl": False, 59 | "username": "username", 60 | } 61 | entity = KodiMediaEntity(mock.Mock(), config) 62 | path = "http://localhost/path/to/image.jpg" 63 | expected = path 64 | assert expected == entity.get_web_url(path) 65 | 66 | 67 | def test_get_web_url_non_http(): 68 | """Test get_web_url when path is not a http url.""" 69 | config = { 70 | "host": "127.0.0.1", 71 | "password": "password", 72 | "port": 8080, 73 | "ssl": False, 74 | "username": "username", 75 | } 76 | entity = KodiMediaEntity(mock.Mock(), config) 77 | path = "nfs://127.0.0.2/volume1/image.png" 78 | expected = "http://username:password@127.0.0.1:8080/image/image%3A%2F%2Fnfs%253A%252F%252F127.0.0.2%252Fvolume1%252Fimage.png" 79 | assert expected == entity.get_web_url(path) 80 | 81 | 82 | @mock.patch("custom_components.kodi_recently_added.entities._LOGGER") 83 | async def test_async_update_skips_if_not_connected(logger): 84 | """Test we skip the update if kodi is not connected.""" 85 | config = { 86 | "host": "127.0.0.1", 87 | "password": "password", 88 | "port": 8080, 89 | "ssl": False, 90 | "username": "username", 91 | } 92 | kodi = mock.Mock(spec=pykodi.Kodi) 93 | kodi._conn = mock.Mock(connected=False) 94 | entity = KodiMediaEntity(kodi, config) 95 | await entity.async_update() 96 | assert kodi.call_method.called is False 97 | expected_call = mock.call("Kodi is not connected, skipping update.") 98 | assert expected_call == logger.debug.call_args 99 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for utils.py.""" 2 | from unittest.mock import Mock 3 | 4 | from pytest_homeassistant_custom_component.common import MockConfigEntry 5 | 6 | from custom_components.kodi_recently_added.utils import ( 7 | KODI_DOMAIN, 8 | find_matching_config_entry, 9 | find_matching_config_entry_for_host, 10 | ) 11 | 12 | 13 | def test_find_matching_config_entry(): 14 | """Test that we find a matching entry.""" 15 | config_entries = [ 16 | MockConfigEntry(entry_id="foo", domain=KODI_DOMAIN), 17 | MockConfigEntry(entry_id="bar", domain=KODI_DOMAIN), 18 | ] 19 | mock_hass = Mock() 20 | mock_hass.config_entries.async_entries.return_value = config_entries 21 | 22 | assert config_entries[1] == find_matching_config_entry(mock_hass, "bar") 23 | 24 | 25 | def test_find_matching_config_entry_does_not_exist(): 26 | """Test that we do not find a matching entry.""" 27 | config_entries = [ 28 | MockConfigEntry(entry_id="foo", domain=KODI_DOMAIN), 29 | MockConfigEntry(entry_id="bar", domain=KODI_DOMAIN), 30 | ] 31 | mock_hass = Mock() 32 | mock_hass.config_entries.async_entries.return_value = config_entries 33 | 34 | assert find_matching_config_entry(mock_hass, "foobar") is None 35 | 36 | 37 | def test_find_matching_config_entry_skip_source_ignore(): 38 | """Test we skip matches whose source is ignore.""" 39 | config_entries = [ 40 | MockConfigEntry(entry_id="foo", domain=KODI_DOMAIN, source="ignore"), 41 | MockConfigEntry(entry_id="bar", domain=KODI_DOMAIN), 42 | ] 43 | mock_hass = Mock() 44 | mock_hass.config_entries.async_entries.return_value = config_entries 45 | 46 | 47 | def test_find_matching_config_entry_for_host(): 48 | """Test we find a matching config entry.""" 49 | config_entries = [MockConfigEntry(domain=KODI_DOMAIN, data={"host": "127.0.0.1"})] 50 | mock_hass = Mock() 51 | mock_hass.config_entries.async_entries.return_value = config_entries 52 | 53 | assert config_entries[0] == find_matching_config_entry_for_host( 54 | mock_hass, "127.0.0.1" 55 | ) 56 | 57 | 58 | def test_find_matching_config_entry_for_host_does_not_exist(): 59 | """Test we find no matching config entry.""" 60 | config_entries = [MockConfigEntry(domain=KODI_DOMAIN, data={"host": "127.0.0.1"})] 61 | mock_hass = Mock() 62 | mock_hass.config_entries.async_entries.return_value = config_entries 63 | 64 | assert find_matching_config_entry_for_host(mock_hass, "192.168.1.1") is None 65 | 66 | 67 | def test_find_matching_config_entry_for_host_skip_source_ignore(): 68 | """Test we skip matches whose source is ignore.""" 69 | config_entries = [ 70 | MockConfigEntry( 71 | domain=KODI_DOMAIN, data={"host": "127.0.0.1"}, source="ignore" 72 | ), 73 | MockConfigEntry(domain=KODI_DOMAIN, data={"host": "127.0.0.1"}, source="user"), 74 | ] 75 | mock_hass = Mock() 76 | mock_hass.config_entries.async_entries.return_value = config_entries 77 | 78 | assert config_entries[1] == find_matching_config_entry_for_host( 79 | mock_hass, "127.0.0.1" 80 | ) 81 | 82 | 83 | def test_find_matching_config_entry_for_host_no_host_key(): 84 | """Test we do not find a matching config entry. 85 | 86 | Also ensure we don't raise an error if the config entry's data has no host key. 87 | """ 88 | config_entries = [MockConfigEntry(domain=KODI_DOMAIN, data={})] 89 | mock_hass = Mock() 90 | mock_hass.config_entries.async_entries.return_value = config_entries 91 | 92 | assert find_matching_config_entry_for_host(mock_hass, "127.0.0.1") is None 93 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow tests.""" 2 | from unittest import mock 3 | 4 | from pytest_homeassistant_custom_component.common import MockConfigEntry 5 | 6 | from custom_components.kodi_recently_added.const import ( 7 | CONF_HIDE_WATCHED, 8 | CONF_KODI_INSTANCE, 9 | DOMAIN, 10 | ) 11 | from custom_components.kodi_recently_added.utils import KODI_DOMAIN 12 | 13 | 14 | async def test_flow_init_kodi_not_configured(hass): 15 | """Test the initial flow when kodi is not configured.""" 16 | result = await hass.config_entries.flow.async_init( 17 | DOMAIN, context={"source": "user"} 18 | ) 19 | assert { 20 | "data_schema": mock.ANY, 21 | "description_placeholders": None, 22 | "errors": {"base": "kodi_not_configured"}, 23 | "flow_id": mock.ANY, 24 | "handler": "kodi_recently_added", 25 | "last_step": None, 26 | "step_id": "user", 27 | "type": "form", 28 | } == result 29 | 30 | 31 | async def test_flow_init_kodi_is_configured(hass): 32 | """Test the initial flow when kodi IS configured.""" 33 | config_entries = [ 34 | MockConfigEntry( 35 | entry_id="foo", 36 | title="Android", 37 | data={ 38 | "host": "127.0.0.1", 39 | "port": 8081, 40 | "ws_port": 1234, 41 | "username": None, 42 | "password": None, 43 | "ssl": False, 44 | }, 45 | domain=KODI_DOMAIN, 46 | ), 47 | MockConfigEntry( 48 | entry_id="bar", 49 | title="HTPC", 50 | data={ 51 | "host": "127.0.0.2", 52 | "port": 8080, 53 | "ws_port": 5678, 54 | "username": None, 55 | "password": None, 56 | "ssl": False, 57 | }, 58 | domain=KODI_DOMAIN, 59 | ), 60 | ] 61 | hass.config_entries.async_entries = mock.Mock(return_value=config_entries) 62 | result = await hass.config_entries.flow.async_init( 63 | DOMAIN, context={"source": "user"} 64 | ) 65 | 66 | assert { 67 | "data_schema": mock.ANY, 68 | "description_placeholders": None, 69 | "errors": {}, 70 | "flow_id": mock.ANY, 71 | "handler": "kodi_recently_added", 72 | "last_step": None, 73 | "step_id": "user", 74 | "type": "form", 75 | } == result 76 | 77 | # Verify the schema populated with config entry titles. 78 | assert ["Android", "HTPC"] == result["data_schema"].schema[ 79 | CONF_KODI_INSTANCE 80 | ].container 81 | 82 | 83 | async def test_flow_user_setp_success(hass): 84 | """Test the user flow when successfully completed by the user.""" 85 | config_entries = [ 86 | MockConfigEntry( 87 | entry_id="foo", 88 | title="Android", 89 | data={ 90 | "host": "127.0.0.1", 91 | "port": 8081, 92 | "ws_port": 1234, 93 | "username": None, 94 | "password": None, 95 | "ssl": False, 96 | }, 97 | domain=KODI_DOMAIN, 98 | ), 99 | MockConfigEntry( 100 | entry_id="bar", 101 | title="HTPC", 102 | data={ 103 | "host": "127.0.0.2", 104 | "port": 8080, 105 | "ws_port": 5678, 106 | "username": None, 107 | "password": None, 108 | "ssl": False, 109 | }, 110 | domain=KODI_DOMAIN, 111 | ), 112 | ] 113 | hass.config_entries.async_entries = mock.Mock(return_value=config_entries) 114 | _result = await hass.config_entries.flow.async_init( 115 | DOMAIN, context={"source": "user"} 116 | ) 117 | result = await hass.config_entries.flow.async_configure( 118 | _result["flow_id"], user_input={CONF_KODI_INSTANCE: "HTPC"} 119 | ) 120 | expected = { 121 | "version": 1, 122 | "type": "create_entry", 123 | "flow_id": mock.ANY, 124 | "handler": "kodi_recently_added", 125 | "title": "Kodi Recently Added", 126 | "data": {"kodi_entry_id": "bar"}, 127 | "description": None, 128 | "description_placeholders": None, 129 | "result": mock.ANY, 130 | "options": {}, 131 | } 132 | assert expected == result 133 | 134 | 135 | async def test_options_flow(hass): 136 | """Test config flow options.""" 137 | config_entry = MockConfigEntry( 138 | domain=DOMAIN, 139 | unique_id="kodi_recently_added_media", 140 | data={"kodi_entry_id": "abc"}, 141 | ) 142 | config_entry.add_to_hass(hass) 143 | assert await hass.config_entries.async_setup(config_entry.entry_id) 144 | await hass.async_block_till_done() 145 | 146 | # show initial form 147 | result = await hass.config_entries.options.async_init(config_entry.entry_id) 148 | assert "form" == result["type"] 149 | assert "init" == result["step_id"] 150 | 151 | # submit form with options 152 | result = await hass.config_entries.options.async_configure( 153 | result["flow_id"], user_input={CONF_HIDE_WATCHED: True} 154 | ) 155 | expected = { 156 | "data": {CONF_HIDE_WATCHED: True}, 157 | "description": None, 158 | "description_placeholders": None, 159 | "flow_id": mock.ANY, 160 | "handler": mock.ANY, 161 | "result": True, 162 | "title": "", 163 | "type": "create_entry", 164 | "version": 1, 165 | } 166 | assert expected == result 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kodi Recently Added Media for Home Assistant 2 | 3 | [![](https://img.shields.io/github/release/boralyl/kodi-recently-added/all.svg?style=for-the-badge)](https://github.com/boralyl/kodi-recently-added/releases) 4 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) 5 | [![](https://img.shields.io/github/license/boralyl/kodi-recently-added?style=for-the-badge)](LICENSE) 6 | [![](https://img.shields.io/github/workflow/status/boralyl/kodi-recently-added/Python%20package?style=for-the-badge)](https://github.com/boralyl/kodi-recently-added/actions) 7 | 8 | Home Assistant component to feed [Upcoming Media Card](https://github.com/custom-cards/upcoming-media-card) with 9 | Kodi's recently added media. 10 | 11 | ![Kodi Recently Added Media](https://raw.githubusercontent.com/boralyl/kodi-recently-added/master/assets/example.png) 12 | 13 | # Table of Contents 14 | 15 | - [Installation](#installation) 16 | - [Pre-Installation](#pre-installation) 17 | - [HACS Install](#hacs-install) 18 | - [Manual Install](#manual-install) 19 | - [Configuration](#configuration) 20 | - [Configuring via YAML](#configuring-via-yaml) 21 | - [Configuring via Integrations](#configuring-via-integrations) 22 | - [Card Configuration](#card-configuration) 23 | - [Upgrading from configuration.yaml to UI integration](#upgrading-from-configurationyaml-to-ui-integration) 24 | - [Known Issues](#known-issues) 25 | 26 | ## Installation 27 | 28 | ### Pre-Installation 29 | 30 | **NOTE: This component only works with Home Assistant version 0.115 and above. Additionally Kodi must be setup via the UI in the integrations section of the Home Assistant configuration.** 31 | 32 | ### HACS Install 33 | 34 | 1. Search for `Kodi Recently Added Component` under `Integrations` in the HACS Store tab. 35 | 2. Install the card: [Upcoming Media Card](https://github.com/custom-cards/upcoming-media-card) 36 | 3. ~~Add the code to your `configuration.yaml` using the config options below. [See Conifuring via YAML](#configuring-via-yaml).~~ (This is deprecated and it's recommended to add the integration via the Home Assistant Integrations if you are using >= v2.0.0. See [Configuring via Integrations](#configuring-via-integrations)) 37 | 4. Add the code for the card to your `ui-lovelace.yaml`, or via the lovelace dashboard. 38 | 5. **You will need to restart after installation for the component to start working.** 39 | 6. [Configure](#configuration) your sensor. 40 | 41 | ### Manual Install 42 | 43 | 1. In your `/config` directory, create a `custom_components` folder if one does not exist. 44 | 2. Copy the [kodi_recently_added](https://github.com/boralyl/kodi-recently-added/tree/master/custom_components) folder and all of it's contents from to your `custom_components` directory. 45 | 3. Restart Home Assistant. 46 | 4. [Configure](#configuration) your sensor. 47 | 48 | ## Configuration 49 | 50 | ### Configuring via YAML (Deprecated: See [Configuring via Integrations](#configuring-via-integrations)) 51 | 52 | | key | required | default | description | 53 | | ------------ | -------- | ------- | ------------------------------------------------------------------------------------------------------------------- | 54 | | host | yes | -- | The host Kodi is running on. This is the same host that was configured when adding the Kodi integration via the UI. | 55 | | hide_watched | no | false | Indicates if watched media should be skipped or not. | 56 | 57 | The host is the same host you entered when configuring Kodi via the integrations page. 58 | 59 | #### Sample configuration.yaml: 60 | 61 | ```yaml 62 | sensor: 63 | - platform: kodi_recently_added 64 | host: 10.1.1.2 65 | hide_watched: true 66 | 67 | - platform: kodi_recently_added 68 | host: 10.1.1.3 69 | ``` 70 | 71 | ### Configuring via Integrations 72 | 73 | 1. Navigate to the Integrations page under the Configuration section. 74 | 2. Click the button to add a new integration and search for `Kodi Recently Added Media`. 75 | 3. Select which instance of Kodi you would like to use and click Submit. 76 | 4. 2 entities will automatically be created. One tracks your recently added tv shows and 77 | is named `sensor.kodi_recently_added_tv`. The other tracks your recently added movies 78 | and is named `sensor.kodi_recently_added_movies`. 79 | 80 | An `Options` button will appear on the integration. Clicking this will allow you to 81 | toggle additional options. Currently the only option is whether or not the sensor should 82 | ignore watched media or not. By default it does not. 83 | 84 | ### Card Configuration 85 | 86 | #### Sample for ui-lovelace.yaml: 87 | 88 | ```yaml 89 | - type: custom:upcoming-media-card 90 | entity: sensor.kodi_recently_added_tv 91 | title: Recently Added Episodes 92 | image_style: fanart 93 | 94 | - type: custom:upcoming-media-card 95 | entity: sensor.kodi_recently_added_movies 96 | title: Recently Added Movies 97 | image_style: fanart 98 | ``` 99 | 100 | ## Upgrading from configuration.yaml to UI Integration 101 | 102 | 1. Remove any sensors in your `configuration.yaml` that reference the `kodi_recently_added` 103 | platform. 104 | 2. Restart Home Assistant. 105 | 3. Navigate to the Integrations page under the Configuration section. 106 | 4. Click the button to add a new integration and search for `Kodi Recently Added Media`. 107 | 5. Select which instance of Kodi you would like to use and click Submit. 108 | 6. 2 entities will automatically be created. One tracks your recently added tv shows and 109 | is named `sensor.kodi_recently_added_tv`. The other tracks your recently added movies 110 | and is named `sensor.kodi_recently_added_movies`. 111 | 112 | ## Known Issues 113 | 114 | Below is a list of known issues that either can't be fixed by changes to the component 115 | itself due to external factors. 116 | 117 | ### Artwork does not load when using the upcoming-media-card 118 | 119 | One reason this could occur is if you setup you Home Assistance instance to use SSL and 120 | your Kodi instance does not use SSL. When the upcoming-media-card tries to load the 121 | artwork it will fail to do so since modern browsers do not allow loading insecure requests. 122 | See [#6](https://github.com/boralyl/kodi-recently-added/issues/6) for more details and 123 | possible workarounds. 124 | 125 | ### Genres, ratings and studios don't show up for TV Shows 126 | 127 | Currently genres, rating, and studio are only populated for Movies. This is a limitation 128 | of the data Kodi stores for TV shows. 129 | -------------------------------------------------------------------------------- /custom_components/kodi_recently_added/entities.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Any, Dict, List, Mapping, Optional 4 | from urllib import parse 5 | 6 | import jsonrpc_base 7 | from pykodi import Kodi 8 | 9 | from homeassistant.const import STATE_OFF, STATE_ON, STATE_PROBLEM, STATE_UNKNOWN 10 | from homeassistant.helpers.entity import Entity 11 | 12 | from .types import KodiConfig 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class KodiMediaEntity(Entity): 18 | properties: List[str] = NotImplemented 19 | result_key: str = NotImplemented 20 | update_method: str = NotImplemented 21 | 22 | def __init__( 23 | self, kodi: Kodi, config: KodiConfig, hide_watched: bool = False 24 | ) -> None: 25 | super().__init__() 26 | self.kodi = kodi 27 | self.hide_watched = hide_watched 28 | self.data = [] 29 | self._state = None 30 | 31 | protocol = "https" if config["ssl"] else "http" 32 | auth = "" 33 | if config["username"] is not None and config["password"] is not None: 34 | auth = f"{config['username']}:{config['password']}@" 35 | self.base_web_url = f"{protocol}://{auth}{config['host']}:{config['port']}" 36 | self.base_web_image_url = f"{self.base_web_url}/image/image%3A%2F%2F" 37 | 38 | @property 39 | def state(self) -> Optional[str]: 40 | return self._state 41 | 42 | async def async_update(self) -> None: 43 | if not self.kodi._conn.connected: 44 | _LOGGER.debug("Kodi is not connected, skipping update.") 45 | return 46 | 47 | result = None 48 | try: 49 | result = await self.kodi.call_method( 50 | self.update_method, properties=self.properties 51 | ) 52 | except jsonrpc_base.jsonrpc.ProtocolError as exc: 53 | error = exc.args[2]["error"] 54 | _LOGGER.error( 55 | "Run API method %s.%s(%s) error: %s", 56 | self.entity_id, 57 | self.update_method, 58 | self.properties, 59 | error, 60 | ) 61 | except jsonrpc_base.jsonrpc.TransportError: 62 | _LOGGER.debug( 63 | "TransportError trying to run API method %s.%s(%s)", 64 | self.entity_id, 65 | self.update_method, 66 | self.properties, 67 | ) 68 | except Exception: 69 | _LOGGER.exception("Error updating sensor, is kodi running?") 70 | self._state = STATE_OFF 71 | 72 | if result: 73 | self._handle_result(result) 74 | else: 75 | self._state = STATE_OFF 76 | 77 | def _handle_result(self, result) -> None: 78 | error = result.get("error") 79 | if error: 80 | _LOGGER.error( 81 | "Error while fetching %s: [%d] %s" 82 | % (self.result_key, error.get("code"), error.get("message")) 83 | ) 84 | self._state = STATE_PROBLEM 85 | return 86 | 87 | new_data: List[Dict[str, Any]] = result.get(self.result_key, []) 88 | if not new_data: 89 | _LOGGER.warning( 90 | "No %s found after requesting data from Kodi, assuming empty." 91 | % self.result_key 92 | ) 93 | self._state = STATE_UNKNOWN 94 | return 95 | 96 | self.data = new_data 97 | self._state = STATE_ON 98 | 99 | def get_web_url(self, path: str) -> str: 100 | """Get the web URL for the provided path. 101 | 102 | This is used for fanart/poster images that are not a http url. For 103 | example the path is local to the kodi installation or a path to 104 | an NFS share. 105 | 106 | :param path: The local/nfs/samba/etc. path. 107 | :returns: The web url to access the image over http. 108 | """ 109 | if path.lower().startswith("http"): 110 | return path 111 | # This looks strange, but the path needs to be quoted twice in order 112 | # to work. 113 | quoted_path = parse.quote(parse.quote(path, safe="")) 114 | return self.base_web_image_url + quoted_path 115 | 116 | 117 | class KodiRecentlyAddedTVEntity(KodiMediaEntity): 118 | properties = [ 119 | "art", 120 | "dateadded", 121 | "episode", 122 | "fanart", 123 | "firstaired", 124 | "playcount", 125 | "plot", 126 | "rating", 127 | "runtime", 128 | "season", 129 | "seasonid", 130 | "showtitle", 131 | "title", 132 | "tvshowid", 133 | ] 134 | update_method = "VideoLibrary.GetRecentlyAddedEpisodes" 135 | result_key = "episodes" 136 | 137 | @property 138 | def unique_id(self) -> str: 139 | """The unique ID of the entity. 140 | 141 | It's important to define this, otherwise the entities created will not show up 142 | on the configured integration card as associated with the integration. 143 | """ 144 | return self.name 145 | 146 | @property 147 | def name(self) -> str: 148 | return "kodi_recently_added_tv" 149 | 150 | @property 151 | def extra_state_attributes(self) -> Mapping[str, Any]: 152 | attrs = {} 153 | card_json = [ 154 | { 155 | "title_default": "$title", 156 | "line1_default": "$episode", 157 | "line2_default": "$release", 158 | "line3_default": "$rating - $runtime", 159 | "line4_default": "$number", 160 | "icon": "mdi:eye-off", 161 | } 162 | ] 163 | for show in self.data: 164 | if self.hide_watched and show["playcount"] > 0: 165 | continue 166 | try: 167 | card = { 168 | "airdate": show["dateadded"].replace(" ", "T") + "Z", 169 | "episode": show["title"], 170 | "deep_link": f"{self.base_web_url}/#tvshow/{show['tvshowid']}/{show['seasonid']}/{show['episodeid']}", 171 | "fanart": "", 172 | "flag": show["playcount"] == 0, 173 | "genres": "", 174 | "number": "S{:0>2}E{:0>2}".format(show["season"], show["episode"]), 175 | "poster": "", 176 | "release": "$day, $date", 177 | "runtime": show["runtime"] // 60, 178 | "title": show["showtitle"], 179 | "studio": "", 180 | "summary": show["plot"], 181 | } 182 | rating = round(show["rating"], 1) 183 | if rating: 184 | rating = f"\N{BLACK STAR} {rating}" 185 | card["rating"] = rating 186 | fanart = show["art"].get("tvshow.fanart", "") 187 | poster = show["art"].get("tvshow.poster", "") 188 | if fanart: 189 | card["fanart"] = self.get_web_url( 190 | parse.unquote(fanart)[8:].strip("/") 191 | ) 192 | if poster: 193 | card["poster"] = self.get_web_url( 194 | parse.unquote(poster)[8:].strip("/") 195 | ) 196 | except KeyError: 197 | _LOGGER.warning("Error parsing key from tv blob: %s", show) 198 | continue 199 | card_json.append(card) 200 | 201 | attrs["data"] = json.dumps(card_json) 202 | return attrs 203 | 204 | 205 | class KodiRecentlyAddedMoviesEntity(KodiMediaEntity): 206 | properties = [ 207 | "art", 208 | "dateadded", 209 | "genre", 210 | "playcount", 211 | "plot", 212 | "premiered", 213 | "rating", 214 | "runtime", 215 | "studio", 216 | "title", 217 | ] 218 | update_method = "VideoLibrary.GetRecentlyAddedMovies" 219 | result_key = "movies" 220 | 221 | @property 222 | def unique_id(self) -> str: 223 | return self.name 224 | 225 | @property 226 | def name(self) -> str: 227 | return "kodi_recently_added_movies" 228 | 229 | @property 230 | def extra_state_attributes(self) -> Mapping[str, Any]: 231 | attrs = {} 232 | card_json = [ 233 | { 234 | "title_default": "$title", 235 | "line1_default": "$genres", 236 | "line2_default": "$release", 237 | "line3_default": "$rating - $runtime", 238 | "line4_default": "$studio", 239 | "icon": "mdi:eye-off", 240 | } 241 | ] 242 | for movie in self.data: 243 | if self.hide_watched and movie["playcount"] > 0: 244 | continue 245 | try: 246 | card = { 247 | "aired": movie["premiered"], 248 | "airdate": movie["dateadded"].replace(" ", "T") + "Z", 249 | "deep_link": f"{self.base_web_url}/#movie/{movie['movieid']}", 250 | "flag": movie["playcount"] == 0, 251 | "genres": ",".join(movie["genre"]), 252 | "rating": round(movie["rating"], 1), 253 | "release": "$date", 254 | "runtime": movie["runtime"] // 60, 255 | "title": movie["title"], 256 | "studio": ",".join(movie["studio"]), 257 | "summary": movie["plot"], 258 | } 259 | rating = round(movie["rating"], 1) 260 | if rating: 261 | rating = f"\N{BLACK STAR} {rating}" 262 | card["rating"] = rating 263 | fanart = movie["art"].get("fanart", "") 264 | poster = movie["art"].get("poster", "") 265 | except KeyError: 266 | _LOGGER.warning("Error parsing key from movie blob: %s", movie) 267 | continue 268 | if fanart: 269 | fanart = self.get_web_url(parse.unquote(fanart)[8:].strip("/")) 270 | if poster: 271 | poster = self.get_web_url(parse.unquote(poster)[8:].strip("/")) 272 | card["fanart"] = fanart 273 | card["poster"] = poster 274 | card_json.append(card) 275 | 276 | attrs["data"] = json.dumps(card_json) 277 | return attrs 278 | --------------------------------------------------------------------------------