Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
----------------------
Expand Down
4 changes: 3 additions & 1 deletion plexapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)

Expand Down
87 changes: 73 additions & 14 deletions plexapi/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. """
Expand Down Expand Up @@ -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.

Expand All @@ -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


Expand Down
29 changes: 29 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 34 additions & 1 deletion tests/test_video.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down