Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bb67f67
feat: Global datetime timezone setter/resolver
eliasbenb Feb 28, 2026
0c4b51e
feat: Normalize to configured timezone in utils.toDatetime
eliasbenb Feb 28, 2026
fb588e7
chore: setDatetimeTimezone tests
eliasbenb Feb 28, 2026
af28a5e
style: Solve flake8 warnings
eliasbenb Feb 28, 2026
4351935
docs: Note possible breaking behavioral change when plexapi.timezone …
eliasbenb Feb 28, 2026
aa8432e
refactor: Solve C901 flake8 error
eliasbenb Feb 28, 2026
424ec9d
chore: Integration test for timezone awareness
eliasbenb Feb 28, 2026
fcff93b
chore: Force a realod to get re-run datetime parsing with configure tz
eliasbenb Feb 28, 2026
fa5cbcd
chore: Migrate tz tests from the server module to a less important vi…
eliasbenb Feb 28, 2026
40ed3f9
Document tzdata requirement on systems that don't have it by default …
eliasbenb Feb 28, 2026
f025972
fix: Handle case where toDatetime is called with format when the dt a…
eliasbenb Feb 28, 2026
32c82bc
refactor: Keep `plexapi.DATETIME_TIMEZONE` and `utils.DATETIME_TIMEZO…
eliasbenb Feb 28, 2026
c71faba
chore: Fix incorrect type assignment on timezone test
eliasbenb Feb 28, 2026
4ee2116
fix: bool-like parsing in setDatetimeTimezone resolution
eliasbenb Feb 28, 2026
080094f
fix: Stale DATETIME_TIMZONE ref
eliasbenb Feb 28, 2026
3be4e76
style: Unused import
eliasbenb Feb 28, 2026
c5addc0
style: Resolve flake8 E501 line too long
eliasbenb Feb 28, 2026
3957df4
refactor: Use `utils.cast()` for partial config parsing
eliasbenb Mar 12, 2026
3f7d22f
refactor: Keep one reference to the configured timezone
eliasbenb Mar 12, 2026
2d1481a
refactor: Re-export DATETIME_TIMEZONE
eliasbenb Mar 12, 2026
84f7ed8
fix: `DATETIME_TIMEZONE` desync when imported from `plexapi`
eliasbenb Mar 13, 2026
b2c8643
chore: Test to confirm plexapi and plexapi.utils `DATETIME_TIMEZONE` …
eliasbenb Mar 13, 2026
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
14 changes: 12 additions & 2 deletions plexapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
from platform import uname
from uuid import getnode

from plexapi.config import PlexConfig, reset_base_headers
import plexapi.const as const
from plexapi.utils import SecretsFilter
import plexapi.utils as utils
from plexapi.config import PlexConfig, reset_base_headers
from plexapi.utils import SecretsFilter, setDatetimeTimezone

# Load User Defined Config
DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini')
Expand All @@ -17,6 +18,8 @@
PROJECT = 'PlexAPI'
VERSION = __version__ = const.__version__
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
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 Expand Up @@ -50,3 +53,10 @@
logfilter = SecretsFilter()
if CONFIG.get('log.show_secrets', '').lower() != 'true':
log.addFilter(logfilter)


def __getattr__(name):
""" Dynamic module attribute access for aliased values. """
if name == 'DATETIME_TIMEZONE':
return utils.DATETIME_TIMEZONE
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
86 changes: 72 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,66 @@ 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 str(value).strip().lower() == 'local':
tzinfo = datetime.now().astimezone().tzinfo
# Attempt to resolve value as a boolean-like string or IANA timezone string
else:
setting = str(value).strip()
# Try to cast as boolean first (normalize to lowercase for case-insensitive matching)
try:
is_enabled = cast(bool, setting.lower())
tzinfo = datetime.now().astimezone().tzinfo if is_enabled else None
except ValueError:
# Not a boolean string, try parsing as IANA timezone
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 +398,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
48 changes: 47 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import time

import plexapi.utils as utils
import pytest

import plexapi
import plexapi.utils as utils
from plexapi.exceptions import NotFound


Expand All @@ -12,6 +14,50 @@ 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_package_datetime_timezone_stays_synced():
original_tz = utils.DATETIME_TIMEZONE
try:
tzinfo = utils.setDatetimeTimezone("UTC")
assert tzinfo is not None
assert plexapi.DATETIME_TIMEZONE is tzinfo

assert plexapi.DATETIME_TIMEZONE is utils.DATETIME_TIMEZONE
utils.setDatetimeTimezone(False)
assert plexapi.DATETIME_TIMEZONE is None
assert plexapi.DATETIME_TIMEZONE is utils.DATETIME_TIMEZONE
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
Loading