Watch at EaseStories. Answers. Perspective.

Technology · PRACTICAL GUIDE

Export Plex watch history to CSV without confusing it with watch state

Use a tested read-only script to page a local Plex Media Server history endpoint, preserve per-user event records and flag an export that may be incomplete.

Original editorial illustration of a generic home media server exporting protected event records to a CSV-style sheet.
Original illustration: a read-only server-history export should preserve event rows, protect the token and produce a separately verifiable CSV. Watch at Ease

Plex uses similar words for three different things: the current watched or unwatched state of an item, the event history kept by a particular Plex Media Server, and activity associated with an account or Discover. Exporting one does not automatically capture the other two. This guide creates a local CSV from the server-history endpoint discussed by the Plex community, verifies the pagination it can see and labels the result narrowly.

The workflow is read-only on the Plex server. It does not mark an item watched, import anything, change a library or promise to reconstruct events the server never retained. Run it on a trusted computer that can reach your own server, keep the token hidden, and pause playback while making the final snapshot.

Choose the record you actually need

If the goal is only to preserve whether a movie or episode is currently watched, Plex’s official Sync Watch State and Ratings feature addresses that state for supported items. Plex explicitly distinguishes this current state from watch history, which is the ongoing log of changes and plays. The sync feature can restore the current state, not an event-by-event ledger, and it excludes in-progress items and some media or edition cases.

The script below addresses a different source: the history exposed by one local Plex Media Server. A Plex forum answer identifies /status/sessions/history/all and shows account filtering plus container pagination. The resulting CSV is therefore a server-history export. It is not an export of Plex account or Discover history, and it is not a replacement for current-state sync.

Decide before running whether you need one account or every account represented by the server response. Supplying --account-id asks the server for one accountID. Omitting it requests the endpoint’s unfiltered scope. Shared-library access, account identity and retention depend on the server; a CSV can only contain rows the endpoint returns.

What the tested exporter records

Each CSV row represents one returned history event. The fixed columns are event_id, viewed_at_unix, viewed_at_utc, account_id, media_type, title, show, season, year, rating_key and library_section_id. viewed_at_unix preserves the raw Plex value while viewed_at_utc converts a valid Unix timestamp to an ISO 8601 UTC string. event_id combines the account, rating key, raw viewed timestamp and Plex key so the report can count duplicate identifiers without silently deleting them.

The token is sent in the X-Plex-Token request header, not placed in the URL or CSV. The script first checks that the base URL is an http or https origin without embedded credentials, a path, query text or fragment. It refuses HTTP redirects so the authentication header is not resent to another origin. If PLEX_TOKEN is not already present in the environment, it prompts with hidden input. The JSON summary and CSV never print the token.

The script writes to a temporary file in the output directory and replaces the requested CSV only after all fetched rows have been serialized. It does not delete a prior CSV before the new write completes. It still writes locally, so choose an output path outside any automatically shared folder if the viewing record is sensitive.

Run the read-only exporter

Save the following exact Python 3 script as plex_history_export.py on the trusted computer. No third-party package is required. Use the local address you normally trust for your server; do not paste a token into --base-url. For one account, run python3 plex_history_export.py --base-url http://127.0.0.1:32400 --account-id 42 --page-size 100 --output plex-history.csv and enter the token only at the hidden prompt. Omit --account-id only when you deliberately want the endpoint’s unfiltered result.

#!/usr/bin/env python3 """Export Plex Media Server history to CSV without mutating the server.""" from __future__ import annotations import argparse import csv import getpass import json import os import sys import tempfile import urllib.parse import urllib.request import urllib.error import xml.etree.ElementTree as ET from datetime import datetime, timezone from pathlib import Path FIELDS = ( "event_id", "viewed_at_unix", "viewed_at_utc", "account_id", "media_type", "title", "show", "season", "year", "rating_key", "library_section_id", ) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None def _utc(value: str) -> str: try: return datetime.fromtimestamp(int(value), timezone.utc).isoformat().replace("+00:00", "Z") except (TypeError, ValueError, OverflowError, OSError): return "" def _event(row: ET.Element) -> dict[str, str]: a = row.attrib account = a.get("accountID", "") rating_key = a.get("ratingKey", "") viewed_at = a.get("viewedAt", "") return { "event_id": "|".join((account, rating_key, viewed_at, a.get("key", ""))), "viewed_at_unix": viewed_at, "viewed_at_utc": _utc(viewed_at), "account_id": account, "media_type": a.get("type", row.tag.lower()), "title": a.get("title", ""), "show": a.get("grandparentTitle", ""), "season": a.get("parentIndex", ""), "year": a.get("year", ""), "rating_key": rating_key, "library_section_id": a.get("librarySectionID", ""), } def fetch_page(base_url: str, token: str, start: int, size: int, account_id: str | None): params = { "X-Plex-Container-Start": str(start), "X-Plex-Container-Size": str(size), } if account_id: params["accountID"] = account_id url = base_url.rstrip("/") + "/status/sessions/history/all?" + urllib.parse.urlencode(params) request = urllib.request.Request(url, headers={"Accept": "application/xml", "X-Plex-Token": token}) opener = urllib.request.build_opener(NoRedirect()) with opener.open(request, timeout=30) as response: body = response.read() root = ET.fromstring(body) rows = [_event(child) for child in root if child.tag in {"Video", "Track", "Photo", "Directory"}] total = int(root.attrib.get("totalSize", root.attrib.get("size", len(rows)))) return rows, total def validate_base_url(value: str) -> str: parsed = urllib.parse.urlsplit(value) if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password: raise argparse.ArgumentTypeError("base URL must be an http(s) origin without embedded credentials") if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: raise argparse.ArgumentTypeError("base URL must be an origin without a path, query or fragment") return value.rstrip("/") def export_history(base_url: str, token: str, output: Path, page_size: int, account_id: str | None): rows: list[dict[str, str]] = [] starts_seen: set[tuple[str, ...]] = set() totals: list[int] = [] start = 0 expected_total: int | None = None while expected_total is None or start < expected_total: page, total = fetch_page(base_url, token, start, page_size, account_id) totals.append(total) if expected_total is None: expected_total = total if not page: break fingerprint = tuple(item["event_id"] for item in page) if fingerprint in starts_seen: raise RuntimeError("Plex returned a repeated page; refusing a falsely complete export") starts_seen.add(fingerprint) rows.extend(page) start += len(page) output.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile("w", encoding="utf-8", newline="", dir=output.parent, delete=False) as tmp: writer = csv.DictWriter(tmp, fieldnames=FIELDS, extrasaction="ignore") writer.writeheader() writer.writerows(rows) temporary = Path(tmp.name) temporary.replace(output) unique_events = len({row["event_id"] for row in rows}) warnings = [] if len(set(totals)) > 1: warnings.append("Plex totalSize changed during pagination; pause playback and rerun for a stable snapshot") if expected_total is not None and len(rows) != expected_total: warnings.append(f"expected {expected_total} rows from the first page but wrote {len(rows)}") if unique_events != len(rows): warnings.append(f"detected {len(rows) - unique_events} duplicate event identifiers") return { "output": str(output), "rows": len(rows), "unique_event_ids": unique_events, "first_total_size": expected_total, "page_total_sizes": totals, "warnings": warnings, "scope": "local Plex Media Server history endpoint; not Plex account/Discover history or current watch state", } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--base-url", required=True, type=validate_base_url, help="for example http://127.0.0.1:32400") parser.add_argument("--output", required=True, type=Path) parser.add_argument("--account-id", help="optional Plex accountID filter") parser.add_argument("--page-size", type=int, default=100) args = parser.parse_args() if not 1 <= args.page_size <= 1000: parser.error("--page-size must be between 1 and 1000") token = os.environ.get("PLEX_TOKEN") or getpass.getpass("Plex token (input hidden): ").strip() if not token: print("PLEX_TOKEN is required in the environment; it is never written to CSV or diagnostics", file=sys.stderr) return 2 result = export_history(args.base_url, token, args.output, args.page_size, args.account_id) print(json.dumps(result, ensure_ascii=False, indent=2)) return 1 if result["warnings"] else 0 if __name__ == "__main__": raise SystemExit(main())

The command exits 0 when the first reported total, written row count and unique event identifiers are internally consistent and the page total does not change. It exits 1 after writing the CSV when it has warnings that require review. It exits 2 for argument or authentication-input problems. A zero exit code is an internal consistency check, not proof that Plex retained every historical event.

Verify the CSV instead of trusting a file that merely exists

Read the JSON summary first. rows is the number written, unique_event_ids is the number of unique composite identifiers, first_total_size is the first page’s totalSize and page_total_sizes records what every page reported. warnings must be empty before treating the run as a stable server snapshot.

Open the CSV with an application that preserves the text fields. The Python csv module quotes commas, quotes and line breaks, so a title such as A, B remains one field. Spot-check several known recent plays, one episode with a show and season, and at least one event for the selected account. Compare the account ID and time range with the source server rather than assuming the title alone identifies a row.

Keep the JSON summary next to the CSV and calculate a SHA-256 for the finished file if the export will be used as an audit record. Record the server, account filter, run time and script SHA separately. Do not append later runs to the same CSV without an explicit merge method; overlapping history pages can create duplicate events.

Treat pagination warnings as a stop condition

The script advances by the number of rows actually returned and refuses a repeated page fingerprint. It also warns if totalSize changes between pages, if the written count differs from the first total or if composite event identifiers repeat. These checks catch several common ways a paginated export can look complete when it is not.

Playback during the export can add events at the front of a changing history list and shift offsets. Pause playback and any automated tests, rerun, and compare two quiet-period results. If totals still drift, reduce the page size, retain both summaries and do not label either file complete until the cause is understood.

An empty page before the expected total is also incomplete even though the script can still write the rows already received. Authentication scope, server version, retention and library access can all affect visibility. Fix the underlying access or scope question and rerun; do not fill missing rows from current watch state because current state is not an event log.

Know what Tautulli can and cannot fill

Tautulli is useful when it was already running and recording activity. Its documentation warns that detailed history is created while Tautulli runs and generally cannot be imported retroactively from an old Plex history that Tautulli never recorded. Its separate Exporter guide covers library metadata and media information in CSV, JSON, XML or M3U; it is not the watch-history export described here.

If Tautulli has its own history, export and preserve that dataset separately under Tautulli’s current instructions. Do not merge it into the Plex server CSV merely because titles match. The two systems can have different event identifiers, users, time semantics and retention. A defensible merge needs a documented key, timezone normalization and duplicate policy.

Likewise, a Plex account or Discover-history request needs its own supported export path. The unanswered Plex forum request for a portable Discover history is evidence of the gap, not evidence that the local server endpoint supplies it. Label every file with the source it actually represents.

What was tested and what remains unproved

The exact exporter script above has SHA-256 c56a76c85d49417cbebdec117f8c5ee40f6dc53e273aafa76f76ec4ab7f4c594. The exact unittest file has SHA-256 5db0a7155da9102608d9541ea44dce71c2874f532d25ccb8eb8f4333b2840d75. Three tests passed: a local fixture returned three events over two pages and verified account filtering, a token header, Unix-to-UTC conversion, CSV quoting, three unique identifiers and absence of the fixture token from stdout, stderr and CSV; another test rejected credentials embedded in the base URL; the third refused a redirect before any second host could receive the authentication header. Both files also passed Python bytecode compilation.

Those are synthetic transport and serialization tests, not a run against a private Plex server or account. They do not prove that every Plex version exposes the same XML attributes, that account or Discover history is available, that retained history is complete, or that an export remains stable while new plays arrive.

The safe completion point is a labeled CSV plus its summary, empty warnings, manual spot checks and a second quiet-period run that agrees. Keep the original files read-only. This guide deliberately stops at export and verification; it does not provide an importer or modify Plex state.

Original editorial illustration separating one current playback-state marker from a chronological archive of many events.
Original illustration: current watch state is a single latest value, while server history is a separate sequence of playback events. Watch at Ease