Skip to content
Merged
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
13 changes: 13 additions & 0 deletions core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,19 @@ def _sigterm_handler(signum, frame):
# Set debug mode early so all debug messages show
self._set_debug_mode()

# Pin a stable Plex device identity before any Plex connection so the
# server sees one known "PlexCache-D" device instead of re-flagging it
# as new on each run/container recreation (issue #190).
try:
from core.plex_api import ensure_plex_client_identity, PLEXCACHE_CLIENT_ID_KEY
client_id = ensure_plex_client_identity(str(self.config_file))
if client_id:
# Keep the in-memory config in sync so a later config save
# (e.g. new-user discovery) doesn't drop the persisted id.
self.config_manager.settings_data[PLEXCACHE_CLIENT_ID_KEY] = client_id
except Exception as e:
logging.warning(f"Could not set stable Plex client identity (non-fatal): {e}")

# Log startup diagnostics after log level is configured
if self.verbose:
self._log_startup_diagnostics()
Expand Down
83 changes: 83 additions & 0 deletions core/plex_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,89 @@
import requests


# Stable Plex device identity (shared with the OAuth flow in core/setup.py).
PLEXCACHE_CLIENT_ID_KEY = 'plexcache_client_id'
PLEXCACHE_PRODUCT_NAME = 'PlexCache-D'


def apply_plex_client_identity(client_id: str) -> None:
"""Make every plexapi connection present a stable PlexCache-D device identity.

By default plexapi derives ``X-Plex-Client-Identifier`` from the host's MAC
(``hex(uuid.getnode())``) and the device name from the container hostname.
When that value isn't recognized — a recreated container with a new MAC, or
after Plex ages the headless device out — Plex registers PlexCache as a brand
new device and fires a "New Device" security notification (issue #190).

Reusing the already-persisted ``plexcache_client_id`` (the same id minted for
the OAuth login) pins the identifier across container recreations and runs, so
Plex consistently sees one known device. Sets the product/name/version too so
the device reads "PlexCache-D" at the app's own version instead of "PlexAPI"
at the plexapi library version.

Must be called before any ``PlexServer``/``MyPlexAccount`` is constructed.
"""
import plexapi
from core import __version__ as version

plexapi.X_PLEX_IDENTIFIER = client_id
plexapi.X_PLEX_PRODUCT = PLEXCACHE_PRODUCT_NAME
plexapi.X_PLEX_DEVICE_NAME = PLEXCACHE_PRODUCT_NAME
plexapi.X_PLEX_VERSION = version

# reset_base_headers() rebuilds from the X_PLEX_* globals and returns a fresh
# dict. Mutate the existing BASE_HEADERS in place rather than reassigning, so
# modules that bound it via ``from plexapi import BASE_HEADERS`` see the change.
new_headers = plexapi.reset_base_headers()
plexapi.BASE_HEADERS.clear()
plexapi.BASE_HEADERS.update(new_headers)
logging.debug(
f"Plex client identity set: id={client_id} "
f"product={PLEXCACHE_PRODUCT_NAME} version={version}"
)


def ensure_plex_client_identity(settings_file: str) -> Optional[str]:
"""Load (or create and persist) the stable client id, then apply it to plexapi.

Reads ``plexcache_client_id`` directly from the settings file so the on-disk
value is preserved verbatim and only the missing key is added (a one-time
write for installs configured by manual token rather than OAuth). The identity
is applied to plexapi even if persistence fails, so a run is never blocked.

Returns the client id that was applied, or ``None`` when there are no usable
settings to pin against (no Plex connections happen in that state anyway):
- the settings file does not exist yet (pre-setup), or
- the settings file exists but cannot be parsed — in which case the file is
left untouched rather than clobbered with a stub.
"""
if not os.path.exists(settings_file):
return None

try:
with open(settings_file, 'r', encoding='utf-8') as f:
raw = json.load(f)
except (json.JSONDecodeError, IOError, OSError) as e:
logging.warning(f"Could not read settings for Plex client identity: {e}")
return None
if not isinstance(raw, dict):
return None

client_id = raw.get(PLEXCACHE_CLIENT_ID_KEY)
if not client_id:
import uuid
client_id = str(uuid.uuid4())
raw[PLEXCACHE_CLIENT_ID_KEY] = client_id
try:
from core.file_operations import save_json_atomically
save_json_atomically(settings_file, raw, label="settings")
except Exception as e:
logging.warning(f"Could not persist {PLEXCACHE_CLIENT_ID_KEY}: {e}")

apply_plex_client_identity(client_id)
return client_id


@dataclass
class OnDeckItem:
"""Represents an OnDeck item with metadata.
Expand Down
146 changes: 146 additions & 0 deletions tests/test_plex_client_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Tests for the stable Plex device identity (issue #190).

PlexCache must present one persistent ``X-Plex-Client-Identifier`` so Plex sees a
single known "PlexCache-D" device instead of re-flagging it as new on every run or
container recreation. These cover ``apply_plex_client_identity`` (header mutation)
and ``ensure_plex_client_identity`` (load/create/persist + apply).
"""

import importlib
import json
import os
import sys
from unittest.mock import MagicMock

import pytest

from core.plex_api import (
apply_plex_client_identity,
ensure_plex_client_identity,
PLEXCACHE_CLIENT_ID_KEY,
PLEXCACHE_PRODUCT_NAME,
)

# Module-global rebound by the autouse fixture to the real plexapi module.
plexapi = None


@pytest.fixture(autouse=True)
def restore_plexapi_globals():
"""Guarantee the real plexapi module, then restore everything we touched.

Other test modules replace ``sys.modules['plexapi']`` with a MagicMock and
don't restore it, and our functions do a lazy ``import plexapi`` — so without
this we'd mutate a mock. Force the real package in, snapshot the header
globals, and on teardown restore both the globals and the original
sys.modules entries (so the mock other modules rely on is put back).
"""
global plexapi
saved_modules = {
k: v for k, v in list(sys.modules.items())
if k == "plexapi" or k.startswith("plexapi.")
}

if isinstance(sys.modules.get("plexapi"), MagicMock):
for k in list(saved_modules):
del sys.modules[k]
try:
plexapi = importlib.import_module("plexapi")
except ImportError:
sys.modules.update(saved_modules)
pytest.skip("plexapi not installed")
else:
try:
plexapi = importlib.import_module("plexapi")
except ImportError:
pytest.skip("plexapi not installed")

saved_globals = {
"X_PLEX_IDENTIFIER": plexapi.X_PLEX_IDENTIFIER,
"X_PLEX_PRODUCT": plexapi.X_PLEX_PRODUCT,
"X_PLEX_DEVICE_NAME": plexapi.X_PLEX_DEVICE_NAME,
"X_PLEX_VERSION": plexapi.X_PLEX_VERSION,
"BASE_HEADERS": dict(plexapi.BASE_HEADERS),
}
try:
yield
finally:
plexapi.X_PLEX_IDENTIFIER = saved_globals["X_PLEX_IDENTIFIER"]
plexapi.X_PLEX_PRODUCT = saved_globals["X_PLEX_PRODUCT"]
plexapi.X_PLEX_DEVICE_NAME = saved_globals["X_PLEX_DEVICE_NAME"]
plexapi.X_PLEX_VERSION = saved_globals["X_PLEX_VERSION"]
plexapi.BASE_HEADERS.clear()
plexapi.BASE_HEADERS.update(saved_globals["BASE_HEADERS"])
# Put back whatever sys.modules['plexapi'] was (possibly a mock).
for k in list(sys.modules):
if k == "plexapi" or k.startswith("plexapi."):
del sys.modules[k]
sys.modules.update(saved_modules)


def test_apply_sets_stable_identity_on_base_headers(restore_plexapi_globals):
apply_plex_client_identity("fixed-client-id-123")

assert plexapi.X_PLEX_IDENTIFIER == "fixed-client-id-123"
assert plexapi.BASE_HEADERS["X-Plex-Client-Identifier"] == "fixed-client-id-123"
assert plexapi.BASE_HEADERS["X-Plex-Product"] == PLEXCACHE_PRODUCT_NAME
assert plexapi.BASE_HEADERS["X-Plex-Device-Name"] == PLEXCACHE_PRODUCT_NAME


def test_apply_mutates_base_headers_in_place(restore_plexapi_globals):
# Modules bind BASE_HEADERS via `from plexapi import BASE_HEADERS`, so the
# change must land on the same dict object, not a replacement.
same_ref = plexapi.BASE_HEADERS
apply_plex_client_identity("ref-check-id")
assert plexapi.BASE_HEADERS is same_ref
assert same_ref["X-Plex-Client-Identifier"] == "ref-check-id"


def test_ensure_returns_none_when_file_missing(tmp_path, restore_plexapi_globals):
missing = tmp_path / "nope.json"
assert ensure_plex_client_identity(str(missing)) is None


def test_ensure_reuses_existing_client_id(tmp_path, restore_plexapi_globals):
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps({"PLEX_TOKEN": "tok", PLEXCACHE_CLIENT_ID_KEY: "existing-id"}),
encoding="utf-8",
)

result = ensure_plex_client_identity(str(settings))

assert result == "existing-id"
assert plexapi.BASE_HEADERS["X-Plex-Client-Identifier"] == "existing-id"
# Existing value reused, not regenerated.
on_disk = json.loads(settings.read_text(encoding="utf-8"))
assert on_disk[PLEXCACHE_CLIENT_ID_KEY] == "existing-id"


def test_ensure_creates_and_persists_when_missing(tmp_path, restore_plexapi_globals):
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps({"PLEX_TOKEN": "tok", "PLEX_URL": "http://x"}),
encoding="utf-8",
)

result = ensure_plex_client_identity(str(settings))

assert result # a new id was minted
on_disk = json.loads(settings.read_text(encoding="utf-8"))
# New id persisted, and existing keys preserved.
assert on_disk[PLEXCACHE_CLIENT_ID_KEY] == result
assert on_disk["PLEX_TOKEN"] == "tok"
assert on_disk["PLEX_URL"] == "http://x"
assert plexapi.BASE_HEADERS["X-Plex-Client-Identifier"] == result


def test_ensure_does_not_clobber_unparseable_file(tmp_path, restore_plexapi_globals):
settings = tmp_path / "settings.json"
settings.write_text("{ this is not valid json", encoding="utf-8")

result = ensure_plex_client_identity(str(settings))

assert result is None
# File left untouched rather than overwritten with a stub.
assert settings.read_text(encoding="utf-8") == "{ this is not valid json"
9 changes: 9 additions & 0 deletions web/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ async def lifespan(app: FastAPI):
(STATIC_DIR / "css").mkdir(exist_ok=True)
(STATIC_DIR / "js").mkdir(exist_ok=True)

# Pin a stable Plex device identity before any Plex connection so the server
# sees one known "PlexCache-D" device instead of re-flagging it as new on
# each run/container recreation (issue #190).
try:
from core.plex_api import ensure_plex_client_identity
ensure_plex_client_identity(str(SETTINGS_FILE))
except Exception as e:
logging.warning("Could not set stable Plex client identity (non-fatal): %s", e)

# Prefetch Plex data in background (libraries, users)
# This prevents lag on first Settings page load
settings_service = get_settings_service()
Expand Down
Loading