diff --git a/docs/configuration.rst b/docs/configuration.rst index 9c283e3b3..d3019934f 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -12,6 +12,7 @@ are optional. An example config.ini file may look like the following with all po [plexapi] container_size = 50 timeout = 30 + timezone = false [auth] myplex_username = johndoe @@ -69,6 +70,19 @@ Section [plexapi] Options When the options is set to `true` the connection procedure will be aborted with first successfully established connection (default: false). +**timezone** + Controls whether :func:`~plexapi.utils.toDatetime` returns timezone-aware datetime objects. + + * `false` (default): keep naive datetime objects (backward compatible). + * `true` or `local`: use the local machine timezone. + * IANA timezone string (for example `UTC` or `America/New_York`): use that timezone. + + This feature relies on Python's :class:`zoneinfo.ZoneInfo` and the availability of IANA tzdata + on the system. On platforms without system tzdata (notably Windows), you may need to install + the :mod:`tzdata` Python package for IANA timezone strings (such as ``America/New_York``) to + work as expected. + Toggling this option may break comparisons between aware and naive datetimes. + Section [auth] Options ---------------------- diff --git a/plexapi/__init__.py b/plexapi/__init__.py index af856aa88..db966485c 100644 --- a/plexapi/__init__.py +++ b/plexapi/__init__.py @@ -6,7 +6,7 @@ from plexapi.config import PlexConfig, reset_base_headers import plexapi.const as const -from plexapi.utils import SecretsFilter +from plexapi.utils import SecretsFilter, setDatetimeTimezone # Load User Defined Config DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini') @@ -17,6 +17,8 @@ PROJECT = 'PlexAPI' VERSION = __version__ = const.__version__ TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) +DATETIME_TIMEZONE = setDatetimeTimezone(CONFIG.get('plexapi.timezone', False)) + X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) diff --git a/plexapi/utils.py b/plexapi/utils.py index 4c14a0f9e..5e5c567f0 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -18,6 +18,7 @@ from threading import Event, Thread from urllib.parse import quote from xml.etree import ElementTree +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import requests from requests.status_codes import _codes as codes @@ -103,6 +104,9 @@ # Plex Objects - Populated at runtime PLEXOBJECTS = {} +# Global timezone for toDatetime() conversions, set by setDatetimeTimezone() +DATETIME_TIMEZONE = None + class SecretsFilter(logging.Filter): """ Logging filter to hide secrets. """ @@ -326,6 +330,67 @@ def threaded(callback, listargs): return [r for r in results if r is not None] +def setDatetimeTimezone(value): + """ Sets the timezone to use when converting values with :func:`toDatetime`. + + Parameters: + value (bool, str): + - ``False`` or ``None`` to disable timezone (default). + - ``True`` or ``"local"`` to use the local timezone. + - A valid IANA timezone (e.g. ``UTC`` or ``America/New_York``). + + Returns: + datetime.tzinfo: Resolved timezone object or ``None`` if disabled or invalid. + """ + global DATETIME_TIMEZONE + + # Disable timezone if value is False or None + if value is None or value is False: + tzinfo = None + # Use local timezone if value is True or "local" + elif value is True or (isinstance(value, str) and value.strip().lower() == 'local'): + tzinfo = datetime.now().astimezone().tzinfo + # Attempt to resolve value as an IANA timezone string or boolean-like string + else: + setting = str(value).strip() + lower = setting.lower() + # Handle common boolean-like strings from config/environment + if lower in ('true', '1'): + tzinfo = datetime.now().astimezone().tzinfo + elif lower in ('false', '0'): + tzinfo = None + else: + try: + tzinfo = ZoneInfo(setting) + except ZoneInfoNotFoundError: + tzinfo = None + log.warning('Failed to set timezone to "%s", defaulting to None', value) + + DATETIME_TIMEZONE = tzinfo + return DATETIME_TIMEZONE + + +def _parseTimestamp(value, tzinfo): + """ Helper function to parse a timestamp value into a datetime object. """ + try: + value = int(value) + except ValueError: + log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value) + return None + try: + if tzinfo: + return datetime.fromtimestamp(value, tz=tzinfo) + return datetime.fromtimestamp(value) + except (OSError, OverflowError, ValueError): + try: + if tzinfo: + return datetime.fromtimestamp(0, tz=tzinfo) + timedelta(seconds=value) + return datetime.fromtimestamp(0) + timedelta(seconds=value) + except OverflowError: + log.info('Failed to parse "%s" to datetime as timestamp (out-of-bounds), defaulting to None', value) + return None + + def toDatetime(value, format=None): """ Returns a datetime object from the specified value. @@ -334,26 +399,20 @@ def toDatetime(value, format=None): format (str): Format to pass strftime (optional; if value is a str). """ if value is not None: + tzinfo = DATETIME_TIMEZONE if format: try: - return datetime.strptime(value, format) + dt = datetime.strptime(value, format) + # If parsed datetime already contains timezone + if dt.tzinfo is not None: + return dt.astimezone(tzinfo) if tzinfo else dt + else: + return dt.replace(tzinfo=tzinfo) if tzinfo else dt except ValueError: log.info('Failed to parse "%s" to datetime as format "%s", defaulting to None', value, format) return None else: - try: - value = int(value) - except ValueError: - log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value) - return None - try: - return datetime.fromtimestamp(value) - except (OSError, OverflowError, ValueError): - try: - return datetime.fromtimestamp(0) + timedelta(seconds=value) - except OverflowError: - log.info('Failed to parse "%s" to datetime as timestamp (out-of-bounds), defaulting to None', value) - return None + return _parseTimestamp(value, tzinfo) return value diff --git a/tests/test_utils.py b/tests/test_utils.py index bdb7fc091..6180cc032 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -12,6 +12,35 @@ def test_utils_toDatetime(): # assert str(utils.toDatetime('0'))[:-9] in ['1970-01-01', '1969-12-31'] +def test_utils_setDatetimeTimezone_disabled_and_utc(): + original_tz = utils.DATETIME_TIMEZONE + try: + assert utils.setDatetimeTimezone(False) is None + assert utils.toDatetime("0").tzinfo is None + + tzinfo = utils.setDatetimeTimezone("UTC") + assert tzinfo is not None + assert utils.toDatetime("0").tzinfo == tzinfo + assert utils.toDatetime("2026-01-01", format="%Y-%m-%d").tzinfo == tzinfo + finally: # Restore for other tests + utils.DATETIME_TIMEZONE = original_tz + + +def test_utils_setDatetimeTimezone_local_and_invalid(): + original_tz = utils.DATETIME_TIMEZONE + try: + assert utils.setDatetimeTimezone(True) is not None + assert utils.toDatetime("0").tzinfo is not None + + assert utils.setDatetimeTimezone("local") is not None + assert utils.toDatetime("0").tzinfo is not None + + assert utils.setDatetimeTimezone("Not/A_Real_Timezone") is None + assert utils.toDatetime("0").tzinfo is None + finally: # Restore for other tests + utils.DATETIME_TIMEZONE = original_tz + + def test_utils_threaded(): def _squared(num, results, i, job_is_done_event=None): time.sleep(0.5) diff --git a/tests/test_video.py b/tests/test_video.py index 24fdac8de..4b16058ac 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1,10 +1,12 @@ import os -from datetime import datetime +from datetime import datetime, timedelta from time import sleep from urllib.parse import quote_plus import pytest +import plexapi.utils as plexutils from plexapi.exceptions import BadRequest, NotFound +from plexapi.utils import setDatetimeTimezone from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p from . import conftest as utils @@ -21,6 +23,37 @@ def test_video_Movie_attributeerror(movie): movie.asshat +def test_video_Movie_datetime_timezone(movie): + original_tz = plexutils.DATETIME_TIMEZONE + try: + # no timezone configured, should be naive + setDatetimeTimezone(False) + movie.reload() + dt_naive = movie.updatedAt + assert dt_naive.tzinfo is None + + # local timezone configured, should be aware + setDatetimeTimezone(True) + movie.reload() + dt_local = movie.updatedAt + assert dt_local.tzinfo is not None + + # explicit IANA zones. Check that the offset is correct too + setDatetimeTimezone("UTC") + movie.reload() + dt = movie.updatedAt + assert dt.tzinfo is not None + assert dt.tzinfo.utcoffset(dt) == timedelta(0) + + setDatetimeTimezone("Asia/Dubai") + movie.reload() + dt = movie.updatedAt + assert dt.tzinfo is not None + assert dt.tzinfo.utcoffset(dt) == timedelta(hours=4) + finally: # Restore for other tests + plexutils.DATETIME_TIMEZONE = original_tz + + def test_video_ne(movies): assert ( len(