From 934c04c0d467b9ba7389117118ef7b25d0d6e0ff Mon Sep 17 00:00:00 2001 From: Henrijs Date: Sat, 1 Jun 2024 21:58:13 +0300 Subject: [PATCH 1/8] Implement public and private Trakt lists as sensors --- README.md | 24 ++++++ custom_components/trakt_tv/apis/trakt.py | 87 ++++++++++++++++++++- custom_components/trakt_tv/configuration.py | 6 ++ custom_components/trakt_tv/models/kind.py | 8 ++ custom_components/trakt_tv/models/media.py | 9 ++- custom_components/trakt_tv/schema.py | 15 +++- custom_components/trakt_tv/sensor.py | 28 ++++++- 7 files changed, 173 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7b9d479..ad09fbb 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,18 @@ trakt_tv: - friends only_upcoming: max_medias: 5 + lists: + - friendly_name: "Christmas Watchlist" + private_list: True # Set to True if the list is your own private list + list_id: "christmas-watchlist" # Can be the slug, because it's a private list + max_medias: 5 + - friendly_name: "2024 Academy Awards" + list_id: 26885014 + max_medias: 5 + - friendly_name: "Star Trek Movies" + list_id: 967660 + media_type: "movie" # Filters the list to only show movies + max_medias: 5 ``` #### Integration Settings @@ -175,6 +187,18 @@ There are three parameters for each sensor: - `exclude` should be a list of shows you'd like to exclude, since it's based on your watched history. To find keys to put there, go on trakt.tv, search for a show, click on it, notice the url slug, copy/paste it. So, if I want to hide "Friends", I'll do the steps mentioned above, then land on https://trakt.tv/shows/friends, I'll just have to copy/paste the last part, `friends`, that's it You can also use the Trakt.tv "hidden" function to hide a show from [your calendar](https://trakt.tv/calendars/my/shows) or the [progress page](https://trakt.tv/users//progress) +##### Lists sensor + +Lists sensor allows you to fetch both public and private lists from Trakt, each list will be a sensor. The items in the list will be sorted by their rank on Trakt. + +There are four parameters for each sensor: + + - `friendly_name` (MANDATORY) should be a string for the name of the sensor. This has to be unique for each list. + - `list_id` (MANDATORY) should be the Trakt list ID. For public lists the ID has to be numeric, for private lists the ID can be either the numeric ID or the slug from the URL. To get the numeric ID of a public list, copy the link address of the list before opening it. This will give you a URL like `https://trakt.tv/lists/2142753`. The `2142753` part is the numeric ID you need to use. + - `private_list` (OPTIONAL) has to be set to `true` if using your own private list. Default is `false` + - `media_type` (OPTIONAL) can be used to filter the media type within the list, possible values are `show`, `movie`, `episode`. Default is blank, which will show all media types + - `max_medias` (OPTIONAL) should be a positive number for how many items to grab. Default is `3` + #### Example For example, adding only the following to `configuration.yaml` will create two sensors. diff --git a/custom_components/trakt_tv/apis/trakt.py b/custom_components/trakt_tv/apis/trakt.py index 60f5560..6a77f35 100644 --- a/custom_components/trakt_tv/apis/trakt.py +++ b/custom_components/trakt_tv/apis/trakt.py @@ -17,7 +17,7 @@ from ..const import API_HOST, DOMAIN from ..exception import TraktException from ..models.kind import BASIC_KINDS, UPCOMING_KINDS, TraktKind -from ..models.media import Medias +from ..models.media import Episode, Medias, Movie, Show from ..utils import cache_insert, cache_retrieve, deserialize_json LOGGER = logging.getLogger(__name__) @@ -390,6 +390,83 @@ async def fetch_recommendations(self, configured_kinds: list[TraktKind]): return res + async def fetch_list( + self, path: str, list_id: str, user_path: bool, max_items: int, media_type: str + ): + """Fetch the list, if user_path is True, the list will be fetched from the user end-point""" + # Add the user path if needed + if user_path: + path = f"users/me/{path}" + + # Replace the list_id in the path + path = path.replace("{list_id}", list_id) + + # Add media type filter to the path + if media_type: + # Check if the media type is supported + if Medias.trakt_to_class(media_type): + path = f"{path}/{media_type}" + else: + LOGGER.warn(f"Filtering list on {media_type} is not supported") + return None + + # Add the limit to the path + path = f"{path}?limit={max_items}" + + return await self.request("get", path) + + async def fetch_lists(self, configured_kind: TraktKind): + + # Get config for all lists + configuration = Configuration(data=self.hass.data) + lists = configuration.get_sensor_config(configured_kind.value.identifier) + + # Fetch the lists + data = await gather( + *[ + self.fetch_list( + configured_kind.value.path, + list_config["list_id"], + list_config["private_list"], + list_config["max_medias"], + list_config["media_type"], + ) + for list_config in lists + ] + ) + + # Process the results + language = configuration.get_language() + + res = {} + for list_config, raw_medias in zip(lists, data): + if raw_medias is not None: + medias = [] + for media in raw_medias: + # Get model based on media type in data + media_type = media.get("type") + model = Medias.trakt_to_class(media_type) + + if model: + medias.append(model.from_trakt(media)) + else: + LOGGER.warn( + f"Media type {media_type} in {list_config['friendly_name']} is not supported" + ) + + if not medias: + LOGGER.warn( + f"No entries found for list {list_config['friendly_name']}" + ) + continue + + await gather( + *[media.get_more_information(language) for media in medias] + ) + res[list_config["friendly_name"]] = Medias(medias) + + return {configured_kind: res} + async def retrieve_data(self): async with timeout(1800): configuration = Configuration(data=self.hass.data) @@ -420,6 +497,9 @@ async def retrieve_data(self): configured_kind=TraktKind.NEXT_TO_WATCH_UPCOMING, only_upcoming=True, ), + "lists": lambda: self.fetch_lists( + configured_kind=TraktKind.LIST, + ), } """First, let's configure which sensors we need depending on configuration""" @@ -443,6 +523,11 @@ async def retrieve_data(self): sources.append(sub_source) coroutine_sources_data.append(source_function.get(sub_source)()) + """Finally let's add the lists sensors if needed""" + if configuration.source_exists("lists"): + sources.append("lists") + coroutine_sources_data.append(source_function.get("lists")()) + sources_data = await gather(*coroutine_sources_data) return { diff --git a/custom_components/trakt_tv/configuration.py b/custom_components/trakt_tv/configuration.py index 72a897b..9db2fdf 100644 --- a/custom_components/trakt_tv/configuration.py +++ b/custom_components/trakt_tv/configuration.py @@ -78,6 +78,12 @@ def recommendation_identifier_exists(self, identifier: str) -> bool: def get_recommendation_max_medias(self, identifier: str) -> int: return self.get_max_medias(identifier, "recommendation") + def get_sensor_config(self, identifier: str) -> list: + try: + return self.conf["sensors"][identifier] + except KeyError: + return [] + def source_exists(self, source: str) -> bool: try: self.conf["sensors"][source] diff --git a/custom_components/trakt_tv/models/kind.py b/custom_components/trakt_tv/models/kind.py index b098dba..8da74c2 100644 --- a/custom_components/trakt_tv/models/kind.py +++ b/custom_components/trakt_tv/models/kind.py @@ -12,6 +12,13 @@ class CalendarInformation: model: Media +@dataclass +class ListInformation: + identifier: str + name: str + path: str + + class TraktKind(Enum): SHOW = CalendarInformation("show", "Shows", "shows", Show) NEW_SHOW = CalendarInformation("new_show", "New Shows", "shows/new", Show) @@ -23,6 +30,7 @@ class TraktKind(Enum): NEXT_TO_WATCH_UPCOMING = CalendarInformation( "only_upcoming", "Only Upcoming", "shows", Show ) + LIST = ListInformation("lists", "", "lists/{list_id}/items") @classmethod def from_string(cls, string): diff --git a/custom_components/trakt_tv/models/media.py b/custom_components/trakt_tv/models/media.py index ae866ad..594486c 100644 --- a/custom_components/trakt_tv/models/media.py +++ b/custom_components/trakt_tv/models/media.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod, abstractstaticmethod from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Type from custom_components.trakt_tv.apis.tmdb import get_movie_data, get_show_data @@ -295,3 +295,10 @@ def to_homeassistant(self) -> Dict[str, Any]: medias = sorted(self.items, key=lambda media: media.released) medias = [media.to_homeassistant() for media in medias] return [first_item] + medias + + @staticmethod + def trakt_to_class( + trakt_type: str, + ) -> Type[Show] | Type[Movie] | Type[Episode] | None: + type_to_class = {"show": Show, "episode": Show, "movie": Movie} + return type_to_class.get(trakt_type, None) diff --git a/custom_components/trakt_tv/schema.py b/custom_components/trakt_tv/schema.py index 066de18..0a3f814 100644 --- a/custom_components/trakt_tv/schema.py +++ b/custom_components/trakt_tv/schema.py @@ -4,7 +4,7 @@ import pytz from dateutil.tz import tzlocal from homeassistant.helpers import config_validation as cv -from voluptuous import ALLOW_EXTRA, PREVENT_EXTRA, In, Required, Schema +from voluptuous import ALLOW_EXTRA, PREVENT_EXTRA, All, In, Required, Schema from .const import DOMAIN, LANGUAGE_CODES from .models.kind import BASIC_KINDS, NEXT_TO_WATCH_KINDS, TraktKind @@ -41,6 +41,7 @@ def sensors_schema() -> Dict[str, Any]: "all_upcoming": upcoming_schema(), "next_to_watch": next_to_watch_schema(), "recommendation": recommendation_schema(), + "lists": All([lists_schema()]), } @@ -76,4 +77,16 @@ def recommendation_schema() -> Dict[str, Any]: return subschemas +def lists_schema() -> dict[Required, Any]: + schema = { + Required("list_id"): cv.string, + Required("friendly_name"): cv.string, + Required("max_medias", default=3): cv.positive_int, + Required("private_list", default=False): cv.boolean, + Required("media_type", default=""): cv.string, + } + + return schema + + configuration_schema = dictionary_to_schema(domain_schema(), extra=ALLOW_EXTRA) diff --git a/custom_components/trakt_tv/sensor.py b/custom_components/trakt_tv/sensor.py index 7cb21d9..293786d 100644 --- a/custom_components/trakt_tv/sensor.py +++ b/custom_components/trakt_tv/sensor.py @@ -69,6 +69,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) sensors.append(sensor) + for trakt_kind in TraktKind: + if trakt_kind != TraktKind.LIST: + continue + + identifier = trakt_kind.value.identifier + + if configuration.source_exists(identifier): + for list_entry in configuration.get_sensor_config(identifier): + sensor = TraktSensor( + hass=hass, + config_entry=list_entry, + coordinator=coordinator, + trakt_kind=trakt_kind, + source=identifier, + prefix=f"Trakt List {list_entry['friendly_name']}", + mdi_icon="mdi:view-list", + ) + sensors.append(sensor) + async_add_entities(sensors) @@ -104,7 +123,12 @@ def name(self): @property def medias(self): if self.coordinator.data: - return self.coordinator.data.get(self.source, {}).get(self.trakt_kind, None) + medias = self.coordinator.data.get(self.source, {}).get( + self.trakt_kind, None + ) + if self.trakt_kind == TraktKind.LIST: + return medias.get(self.config_entry["friendly_name"], None) + return medias return None @property @@ -119,6 +143,8 @@ def configuration(self): @property def data(self): if self.medias: + if self.trakt_kind == TraktKind.LIST: + return self.medias.to_homeassistant() max_medias = self.configuration["max_medias"] return self.medias.to_homeassistant()[0 : max_medias + 1] return [] From ef2731e3b6a6379b4d487d83065b789fc72b9f2f Mon Sep 17 00:00:00 2001 From: Henrijs Date: Tue, 4 Jun 2024 13:18:38 +0300 Subject: [PATCH 2/8] Code review --- custom_components/trakt_tv/models/kind.py | 13 ++------- custom_components/trakt_tv/schema.py | 4 +-- custom_components/trakt_tv/sensor.py | 34 +++++++++++++---------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/custom_components/trakt_tv/models/kind.py b/custom_components/trakt_tv/models/kind.py index 8da74c2..b6bf738 100644 --- a/custom_components/trakt_tv/models/kind.py +++ b/custom_components/trakt_tv/models/kind.py @@ -7,16 +7,9 @@ @dataclass class CalendarInformation: identifier: str - name: str - path: str - model: Media - - -@dataclass -class ListInformation: - identifier: str - name: str + name: str | None path: str + model: Media | None class TraktKind(Enum): @@ -30,7 +23,7 @@ class TraktKind(Enum): NEXT_TO_WATCH_UPCOMING = CalendarInformation( "only_upcoming", "Only Upcoming", "shows", Show ) - LIST = ListInformation("lists", "", "lists/{list_id}/items") + LIST = CalendarInformation("lists", None, "lists/{list_id}/items", None) @classmethod def from_string(cls, string): diff --git a/custom_components/trakt_tv/schema.py b/custom_components/trakt_tv/schema.py index 0a3f814..469b5b0 100644 --- a/custom_components/trakt_tv/schema.py +++ b/custom_components/trakt_tv/schema.py @@ -4,7 +4,7 @@ import pytz from dateutil.tz import tzlocal from homeassistant.helpers import config_validation as cv -from voluptuous import ALLOW_EXTRA, PREVENT_EXTRA, All, In, Required, Schema +from voluptuous import ALLOW_EXTRA, PREVENT_EXTRA, In, Required, Schema from .const import DOMAIN, LANGUAGE_CODES from .models.kind import BASIC_KINDS, NEXT_TO_WATCH_KINDS, TraktKind @@ -41,7 +41,7 @@ def sensors_schema() -> Dict[str, Any]: "all_upcoming": upcoming_schema(), "next_to_watch": next_to_watch_schema(), "recommendation": recommendation_schema(), - "lists": All([lists_schema()]), + "lists": Schema([lists_schema()]), } diff --git a/custom_components/trakt_tv/sensor.py b/custom_components/trakt_tv/sensor.py index 293786d..24d2bd8 100644 --- a/custom_components/trakt_tv/sensor.py +++ b/custom_components/trakt_tv/sensor.py @@ -118,18 +118,24 @@ def __init__( @property def name(self): """Return the name of the sensor.""" + if not self.trakt_kind.value.name: + return f"{self.prefix}" return f"{self.prefix} {self.trakt_kind.value.name}" @property def medias(self): - if self.coordinator.data: - medias = self.coordinator.data.get(self.source, {}).get( - self.trakt_kind, None - ) - if self.trakt_kind == TraktKind.LIST: - return medias.get(self.config_entry["friendly_name"], None) - return medias - return None + if not self.coordinator.data: + return None + if self.trakt_kind == TraktKind.LIST: + try: + name = self.config_entry["friendly_name"] + return self.coordinator.data[self.source][self.trakt_kind][name] + except KeyError: + return None + try: + return self.coordinator.data[self.source][self.trakt_kind] + except KeyError: + return None @property def configuration(self): @@ -142,12 +148,12 @@ def configuration(self): @property def data(self): - if self.medias: - if self.trakt_kind == TraktKind.LIST: - return self.medias.to_homeassistant() - max_medias = self.configuration["max_medias"] - return self.medias.to_homeassistant()[0 : max_medias + 1] - return [] + if not self.medias: + return [] + if self.trakt_kind == TraktKind.LIST: + return self.medias.to_homeassistant() + max_medias = self.configuration["max_medias"] + return self.medias.to_homeassistant()[0 : max_medias + 1] @property def state(self): From 1e72ccc960e3fc192e243d00007b697ad2aac3fe Mon Sep 17 00:00:00 2001 From: Henrijs Date: Tue, 4 Jun 2024 16:42:13 +0300 Subject: [PATCH 3/8] Code cleanup --- custom_components/trakt_tv/apis/trakt.py | 2 +- custom_components/trakt_tv/const.py | 2 -- custom_components/trakt_tv/models/media.py | 27 ++++------------------ custom_components/trakt_tv/utils.py | 8 ++++++- 4 files changed, 13 insertions(+), 26 deletions(-) diff --git a/custom_components/trakt_tv/apis/trakt.py b/custom_components/trakt_tv/apis/trakt.py index 6a77f35..548666a 100644 --- a/custom_components/trakt_tv/apis/trakt.py +++ b/custom_components/trakt_tv/apis/trakt.py @@ -63,7 +63,7 @@ async def retry_request(self, wait_time, response, method, url, retry, **kwargs) guidance = f"Too many retries, if you find this error, please raise an issue at https://github.com/dylandoamaral/trakt-integration/issues." raise TraktException(f"{error} {guidance}") - async def request(self, method, url, retry=10, **kwargs) -> ClientResponse: + async def request(self, method, url, retry=10, **kwargs) -> dict[str, Any]: """Make a request.""" access_token = await self.async_get_access_token() client_id = self.hass.data[DOMAIN]["configuration"]["client_id"] diff --git a/custom_components/trakt_tv/const.py b/custom_components/trakt_tv/const.py index 54dc9f1..0b03575 100644 --- a/custom_components/trakt_tv/const.py +++ b/custom_components/trakt_tv/const.py @@ -6,8 +6,6 @@ OAUTH2_AUTHORIZE = "https://trakt.tv/oauth/authorize" OAUTH2_TOKEN = f"{API_HOST}/oauth/token" -UPCOMING_DATA_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" - TMDB_HOST = "http://api.tmdb.org" TMDB_TOKEN = "0eee347e2333d7a97b724106353ca42f" diff --git a/custom_components/trakt_tv/models/media.py b/custom_components/trakt_tv/models/media.py index 594486c..bbf6351 100644 --- a/custom_components/trakt_tv/models/media.py +++ b/custom_components/trakt_tv/models/media.py @@ -1,11 +1,10 @@ from abc import ABC, abstractmethod, abstractstaticmethod from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import datetime from typing import Any, Dict, List, Optional, Type from custom_components.trakt_tv.apis.tmdb import get_movie_data, get_show_data - -from ..const import UPCOMING_DATA_FORMAT +from custom_components.trakt_tv.utils import parse_utc_date first_item = { "title_default": "$title", @@ -107,15 +106,9 @@ def from_trakt(data) -> "Movie": """ movie = data if data.get("title") else data["movie"] - released = ( - datetime.fromisoformat(data["released"]).replace(tzinfo=timezone.utc) - if data.get("released") - else None - ) - return Movie( name=movie["title"], - released=released, + released=parse_utc_date(data.get("released")), ids=Identifiers.from_trakt(movie), ) @@ -143,9 +136,7 @@ async def get_more_information(self, language): self.studio = production_companies[0].get("name") if not self.released: if data.get("release_date"): - self.released = datetime.fromisoformat(data["release_date"]).replace( - tzinfo=timezone.utc - ) + self.released = parse_utc_date(data.get("release_date")) else: self.released = datetime.min @@ -205,20 +196,12 @@ def from_trakt(data) -> "Show": """ show = data if data.get("title") else data["show"] - released = ( - datetime.strptime(data["first_aired"], UPCOMING_DATA_FORMAT).replace( - tzinfo=timezone.utc - ) - if data.get("first_aired") - else None - ) - episode = Episode.from_trakt(data["episode"]) if data.get("episode") else None return Show( name=show["title"], ids=Identifiers.from_trakt(show), - released=released, + released=parse_utc_date(data.get("first_aired")), episode=episode, ) diff --git a/custom_components/trakt_tv/utils.py b/custom_components/trakt_tv/utils.py index edb50cc..5067edd 100644 --- a/custom_components/trakt_tv/utils.py +++ b/custom_components/trakt_tv/utils.py @@ -1,6 +1,6 @@ import json import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from math import ceil from typing import Any, Dict, List, Optional, Tuple @@ -92,3 +92,9 @@ def cache_retrieve(cache: Dict[str, Any], key: str) -> Optional[Any]: return None else: return None + +def parse_utc_date(date_str: Optional[str]) -> Optional[datetime]: + """ + Parse an ISO date string (all dates returned from Trakt) to a datetime object. + """ + return datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) if date_str else None \ No newline at end of file From 588f88f68c3dd793acb441e14f8aa4057cde35ef Mon Sep 17 00:00:00 2001 From: Henrijs Date: Tue, 4 Jun 2024 16:42:33 +0300 Subject: [PATCH 4/8] Implement rest sorting, add trakt rating to data in HA --- README.md | 20 +++++++++++++++----- custom_components/trakt_tv/apis/trakt.py | 4 ++-- custom_components/trakt_tv/const.py | 11 +++++++++++ custom_components/trakt_tv/models/media.py | 19 +++++++++++++++++-- custom_components/trakt_tv/schema.py | 4 +++- custom_components/trakt_tv/sensor.py | 5 ++++- 6 files changed, 52 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ad09fbb..b6c58f7 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ trakt_tv: - friendly_name: "2024 Academy Awards" list_id: 26885014 max_medias: 5 + sort_by: rating_trakt + sort_order: desc - friendly_name: "Star Trek Movies" list_id: 967660 media_type: "movie" # Filters the list to only show movies @@ -193,11 +195,19 @@ Lists sensor allows you to fetch both public and private lists from Trakt, each There are four parameters for each sensor: - - `friendly_name` (MANDATORY) should be a string for the name of the sensor. This has to be unique for each list. - - `list_id` (MANDATORY) should be the Trakt list ID. For public lists the ID has to be numeric, for private lists the ID can be either the numeric ID or the slug from the URL. To get the numeric ID of a public list, copy the link address of the list before opening it. This will give you a URL like `https://trakt.tv/lists/2142753`. The `2142753` part is the numeric ID you need to use. - - `private_list` (OPTIONAL) has to be set to `true` if using your own private list. Default is `false` - - `media_type` (OPTIONAL) can be used to filter the media type within the list, possible values are `show`, `movie`, `episode`. Default is blank, which will show all media types - - `max_medias` (OPTIONAL) should be a positive number for how many items to grab. Default is `3` + - `friendly_name` **MANDATORY** should be a string for the name of the sensor. This has to be unique for each list. + - `list_id` **MANDATORY** should be the Trakt list ID. For public lists the ID has to be numeric, for private lists the ID can be either the numeric ID or the slug from the URL. To get the numeric ID of a public list, copy the link address of the list before opening it or open the Report List window. This will give you a URL like `https://trakt.tv/lists/2142753`. The `2142753` part is the numeric ID you need to use. + - `private_list` _OPTIONAL_ has to be set to `true` if using your own private list. Default is `false` + - `media_type` _OPTIONAL_ can be used to filter the media type within the list, possible values are `show`, `movie`, `episode`. Default is blank, which will show all media types + - `max_medias` _OPTIONAL_ should be a positive number for how many items to grab. Default is `3` + - `sort_by` _OPTIONAL_ should be a string for how to sort the list. Default is `rank`. Possible values are: + - `rank` - Placement in the list + - `rating` - TMDB rating + - `rating_trakt` - Trakt rating + - `runtime` + - `released` + - `listed_at` - Date the item was added to the list + - `sort_order` _OPTIONAL_ should be a string for the sort order. Possible values are `asc`, `desc`. Default is `asc` #### Example diff --git a/custom_components/trakt_tv/apis/trakt.py b/custom_components/trakt_tv/apis/trakt.py index 548666a..6c5ec70 100644 --- a/custom_components/trakt_tv/apis/trakt.py +++ b/custom_components/trakt_tv/apis/trakt.py @@ -410,8 +410,8 @@ async def fetch_list( LOGGER.warn(f"Filtering list on {media_type} is not supported") return None - # Add the limit to the path - path = f"{path}?limit={max_items}" + # Add extended info used for sorting + path = f"{path}?extended=full" return await self.request("get", path) diff --git a/custom_components/trakt_tv/const.py b/custom_components/trakt_tv/const.py index 0b03575..d82b154 100644 --- a/custom_components/trakt_tv/const.py +++ b/custom_components/trakt_tv/const.py @@ -171,3 +171,14 @@ "za", "zu", ] + +SORT_BY_OPTIONS = [ + "rating", + "rating_trakt", + "rank", + "runtime", + "released", + "listed_at" +] + +SORT_HOW_OPTIONS = ["asc", "desc"] \ No newline at end of file diff --git a/custom_components/trakt_tv/models/media.py b/custom_components/trakt_tv/models/media.py index bbf6351..58e01a7 100644 --- a/custom_components/trakt_tv/models/media.py +++ b/custom_components/trakt_tv/models/media.py @@ -72,6 +72,7 @@ def common_information(self) -> Dict[str, Any]: "fanart": self.fanart, "genres": self.genres, "rating": self.rating, + "rating_trakt": self.rating_trakt, "studio": self.studio, } @@ -98,6 +99,9 @@ class Movie(Media): runtime: Optional[int] = None studio: Optional[str] = None released: Optional[datetime] = None # This one is actually mandatory + rank: Optional[int] = None + listed_at: Optional[datetime] = None + rating_trakt: Optional[int] = None @staticmethod def from_trakt(data) -> "Movie": @@ -110,6 +114,9 @@ def from_trakt(data) -> "Movie": name=movie["title"], released=parse_utc_date(data.get("released")), ids=Identifiers.from_trakt(movie), + rank=data.get("rank"), + listed_at=parse_utc_date(data.get("listed_at")), + rating_trakt=movie.get("rating"), ) async def get_more_information(self, language): @@ -188,6 +195,10 @@ class Show(Media): studio: Optional[str] = None episode: Optional[Episode] = None released: Optional[datetime] = None + runtime: Optional[int] = None + rank: Optional[int] = None + listed_at: Optional[datetime] = None + rating_trakt: Optional[int] = None @staticmethod def from_trakt(data) -> "Show": @@ -203,6 +214,10 @@ def from_trakt(data) -> "Show": ids=Identifiers.from_trakt(show), released=parse_utc_date(data.get("first_aired")), episode=episode, + rank=data.get("rank"), + listed_at=parse_utc_date(data.get("listed_at")), + runtime=show.get("runtime"), + rating_trakt=show.get("rating"), ) def update_common_information(self, data: Dict[str, Any]): @@ -268,14 +283,14 @@ def to_homeassistant(self) -> Dict[str, Any]: class Medias: items: List[Media] - def to_homeassistant(self) -> Dict[str, Any]: + def to_homeassistant(self, sort_by = 'released', sort_order = 'asc') -> Dict[str, Any]: """ Convert the List of medias to recommendation data. :return: The dictionary containing all necessary information for upcoming media card """ - medias = sorted(self.items, key=lambda media: media.released) + medias = sorted(self.items, key=lambda media: getattr(media, sort_by), reverse=sort_order == "desc") medias = [media.to_homeassistant() for media in medias] return [first_item] + medias diff --git a/custom_components/trakt_tv/schema.py b/custom_components/trakt_tv/schema.py index 469b5b0..4e0de69 100644 --- a/custom_components/trakt_tv/schema.py +++ b/custom_components/trakt_tv/schema.py @@ -6,7 +6,7 @@ from homeassistant.helpers import config_validation as cv from voluptuous import ALLOW_EXTRA, PREVENT_EXTRA, In, Required, Schema -from .const import DOMAIN, LANGUAGE_CODES +from .const import DOMAIN, LANGUAGE_CODES, SORT_BY_OPTIONS, SORT_HOW_OPTIONS from .models.kind import BASIC_KINDS, NEXT_TO_WATCH_KINDS, TraktKind @@ -84,6 +84,8 @@ def lists_schema() -> dict[Required, Any]: Required("max_medias", default=3): cv.positive_int, Required("private_list", default=False): cv.boolean, Required("media_type", default=""): cv.string, + Required("sort_by", default="rank"): In(SORT_BY_OPTIONS), + Required("sort_order", default="asc"): In(SORT_HOW_OPTIONS), } return schema diff --git a/custom_components/trakt_tv/sensor.py b/custom_components/trakt_tv/sensor.py index 24d2bd8..b5c5c23 100644 --- a/custom_components/trakt_tv/sensor.py +++ b/custom_components/trakt_tv/sensor.py @@ -151,7 +151,10 @@ def data(self): if not self.medias: return [] if self.trakt_kind == TraktKind.LIST: - return self.medias.to_homeassistant() + sort_by = self.config_entry["sort_by"] + sort_order = self.config_entry["sort_order"] + max_medias = self.config_entry["max_medias"] + return self.medias.to_homeassistant(sort_by, sort_order)[0 : max_medias + 1] max_medias = self.configuration["max_medias"] return self.medias.to_homeassistant()[0 : max_medias + 1] From f8c13367f1c37607e0159950b957bb4b559cf6f2 Mon Sep 17 00:00:00 2001 From: Henrijs Date: Tue, 4 Jun 2024 16:44:01 +0300 Subject: [PATCH 5/8] Fix linting --- custom_components/trakt_tv/const.py | 11 ++--------- custom_components/trakt_tv/models/media.py | 8 ++++++-- custom_components/trakt_tv/utils.py | 7 ++++++- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/custom_components/trakt_tv/const.py b/custom_components/trakt_tv/const.py index d82b154..32c7ee6 100644 --- a/custom_components/trakt_tv/const.py +++ b/custom_components/trakt_tv/const.py @@ -172,13 +172,6 @@ "zu", ] -SORT_BY_OPTIONS = [ - "rating", - "rating_trakt", - "rank", - "runtime", - "released", - "listed_at" -] +SORT_BY_OPTIONS = ["rating", "rating_trakt", "rank", "runtime", "released", "listed_at"] -SORT_HOW_OPTIONS = ["asc", "desc"] \ No newline at end of file +SORT_HOW_OPTIONS = ["asc", "desc"] diff --git a/custom_components/trakt_tv/models/media.py b/custom_components/trakt_tv/models/media.py index 58e01a7..41ab329 100644 --- a/custom_components/trakt_tv/models/media.py +++ b/custom_components/trakt_tv/models/media.py @@ -283,14 +283,18 @@ def to_homeassistant(self) -> Dict[str, Any]: class Medias: items: List[Media] - def to_homeassistant(self, sort_by = 'released', sort_order = 'asc') -> Dict[str, Any]: + def to_homeassistant(self, sort_by="released", sort_order="asc") -> Dict[str, Any]: """ Convert the List of medias to recommendation data. :return: The dictionary containing all necessary information for upcoming media card """ - medias = sorted(self.items, key=lambda media: getattr(media, sort_by), reverse=sort_order == "desc") + medias = sorted( + self.items, + key=lambda media: getattr(media, sort_by), + reverse=sort_order == "desc", + ) medias = [media.to_homeassistant() for media in medias] return [first_item] + medias diff --git a/custom_components/trakt_tv/utils.py b/custom_components/trakt_tv/utils.py index 5067edd..5f0373e 100644 --- a/custom_components/trakt_tv/utils.py +++ b/custom_components/trakt_tv/utils.py @@ -93,8 +93,13 @@ def cache_retrieve(cache: Dict[str, Any], key: str) -> Optional[Any]: else: return None + def parse_utc_date(date_str: Optional[str]) -> Optional[datetime]: """ Parse an ISO date string (all dates returned from Trakt) to a datetime object. """ - return datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) if date_str else None \ No newline at end of file + return ( + datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) + if date_str + else None + ) From 72bc8f264bcae451f546c0c92e0bba6b485a0aa4 Mon Sep 17 00:00:00 2001 From: Henrijs Date: Tue, 4 Jun 2024 16:45:53 +0300 Subject: [PATCH 6/8] Fix spacing --- custom_components/trakt_tv/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/trakt_tv/sensor.py b/custom_components/trakt_tv/sensor.py index b5c5c23..7bf9db3 100644 --- a/custom_components/trakt_tv/sensor.py +++ b/custom_components/trakt_tv/sensor.py @@ -126,12 +126,14 @@ def name(self): def medias(self): if not self.coordinator.data: return None + if self.trakt_kind == TraktKind.LIST: try: name = self.config_entry["friendly_name"] return self.coordinator.data[self.source][self.trakt_kind][name] except KeyError: return None + try: return self.coordinator.data[self.source][self.trakt_kind] except KeyError: @@ -150,11 +152,13 @@ def configuration(self): def data(self): if not self.medias: return [] + if self.trakt_kind == TraktKind.LIST: sort_by = self.config_entry["sort_by"] sort_order = self.config_entry["sort_order"] max_medias = self.config_entry["max_medias"] return self.medias.to_homeassistant(sort_by, sort_order)[0 : max_medias + 1] + max_medias = self.configuration["max_medias"] return self.medias.to_homeassistant()[0 : max_medias + 1] From 06c747ea3f52a0657fc2516b7fb45fb1be48589d Mon Sep 17 00:00:00 2001 From: Henrijs <70920705+downey-lv@users.noreply.github.com> Date: Tue, 4 Jun 2024 17:31:58 +0300 Subject: [PATCH 7/8] Move lists example code to its section --- README.md | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b6c58f7..d0a04f2 100644 --- a/README.md +++ b/README.md @@ -98,20 +98,6 @@ trakt_tv: - friends only_upcoming: max_medias: 5 - lists: - - friendly_name: "Christmas Watchlist" - private_list: True # Set to True if the list is your own private list - list_id: "christmas-watchlist" # Can be the slug, because it's a private list - max_medias: 5 - - friendly_name: "2024 Academy Awards" - list_id: 26885014 - max_medias: 5 - sort_by: rating_trakt - sort_order: desc - - friendly_name: "Star Trek Movies" - list_id: 967660 - media_type: "movie" # Filters the list to only show movies - max_medias: 5 ``` #### Integration Settings @@ -209,7 +195,25 @@ There are four parameters for each sensor: - `listed_at` - Date the item was added to the list - `sort_order` _OPTIONAL_ should be a string for the sort order. Possible values are `asc`, `desc`. Default is `asc` -#### Example +###### Lists Example +```yaml + lists: + - friendly_name: "Christmas Watchlist" + private_list: True # Set to True if the list is your own private list + list_id: "christmas-watchlist" # Can be the slug, because it's a private list + max_medias: 5 + - friendly_name: "2024 Academy Awards" + list_id: 26885014 + max_medias: 5 + sort_by: rating_trakt # Sort by Trakt user rating instead of lsit rank + sort_order: desc + - friendly_name: "Star Trek Movies" + list_id: 967660 + media_type: "movie" # Filters the list to only show movies + max_medias: 5 +``` + +#### Configuration Example For example, adding only the following to `configuration.yaml` will create two sensors. One with the next 10 TV episodes in the next 30 days and another with the next 5 movies coming out in the next 45 days: From b1a5e8646c485a2737a1a5de0fcfce2bf4626bb7 Mon Sep 17 00:00:00 2001 From: Henrijs Date: Tue, 11 Jun 2024 10:27:45 +0300 Subject: [PATCH 8/8] Lint after merging --- custom_components/trakt_tv/apis/trakt.py | 4 ---- custom_components/trakt_tv/sensor.py | 1 - 2 files changed, 5 deletions(-) diff --git a/custom_components/trakt_tv/apis/trakt.py b/custom_components/trakt_tv/apis/trakt.py index 9619dab..877243e 100644 --- a/custom_components/trakt_tv/apis/trakt.py +++ b/custom_components/trakt_tv/apis/trakt.py @@ -390,7 +390,6 @@ async def fetch_recommendations(self, configured_kinds: list[TraktKind]): return res - async def fetch_list( self, path: str, list_id: str, user_path: bool, max_items: int, media_type: str ): @@ -416,7 +415,6 @@ async def fetch_list( return await self.request("get", path) - async def fetch_lists(self, configured_kind: TraktKind): # Get config for all lists @@ -469,7 +467,6 @@ async def fetch_lists(self, configured_kind: TraktKind): return {configured_kind: res} - async def fetch_stats(self): # Load data data = await self.request("get", f"users/me/stats") @@ -485,7 +482,6 @@ async def fetch_stats(self): return stats - async def retrieve_data(self): async with timeout(1800): configuration = Configuration(data=self.hass.data) diff --git a/custom_components/trakt_tv/sensor.py b/custom_components/trakt_tv/sensor.py index 0b14951..a145416 100644 --- a/custom_components/trakt_tv/sensor.py +++ b/custom_components/trakt_tv/sensor.py @@ -69,7 +69,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) sensors.append(sensor) - for trakt_kind in TraktKind: if trakt_kind != TraktKind.LIST: continue