Tecnología · GUÍA PRÁCTICA
Exporta el historial de visualización de Plex a CSV sin confundirlo con el estado de reproducción
Usa un script de solo lectura probado para paginar el historial de un Plex Media Server local, conservar eventos por usuario y señalar una exportación que podría estar incompleta.

Plex utiliza términos parecidos para tres cosas distintas: el estado actual visto o no visto de un elemento, el historial de eventos que conserva un Plex Media Server concreto y la actividad asociada a una cuenta o a Discover. Exportar una de ellas no captura automáticamente las otras dos. Esta guía crea un CSV local desde el endpoint de historial del servidor comentado por la comunidad de Plex, verifica la paginación visible y etiqueta el resultado con un alcance preciso.
El proceso es de solo lectura en el servidor Plex. No marca elementos como vistos, no importa datos, no modifica una biblioteca ni promete reconstruir eventos que el servidor nunca conservó. Ejecútalo en un equipo de confianza con acceso a tu propio servidor, mantén oculto el token y pausa la reproducción al crear la instantánea final.
Elige el registro que realmente necesitas
Si solo quieres conservar si una película o un episodio está marcado actualmente como visto, la función oficial de Plex Sincronizar estado de reproducción y valoraciones cubre ese estado para los elementos compatibles. Plex distingue expresamente ese estado actual del historial de visualización, que es el registro continuo de cambios y reproducciones. La sincronización puede restaurar el estado actual, no un libro mayor evento por evento, y excluye elementos en curso y determinados casos de contenido o edición.
El script siguiente trabaja con otra fuente: el historial expuesto por un Plex Media Server local. Una respuesta del foro de Plex identifica /status/sessions/history/all y muestra el filtrado por cuenta y la paginación del contenedor. Por tanto, el CSV resultante exporta historial del servidor. No exporta el historial de una cuenta Plex o de Discover ni sustituye la sincronización del estado actual.
Antes de ejecutarlo, decide si necesitas una cuenta o todas las cuentas representadas en la respuesta del servidor. Al proporcionar --account-id se solicita un accountID concreto. Si se omite, se pide el ámbito sin filtrar del endpoint. El acceso a bibliotecas compartidas, la identidad de cuenta y la retención dependen del servidor; el CSV solo puede contener las filas que devuelve el endpoint.
Qué registra el exportador probado
Cada fila del CSV representa un evento de historial devuelto. Las columnas fijas son event_id, viewed_at_unix, viewed_at_utc, account_id, media_type, title, show, season, year, rating_key y library_section_id. viewed_at_unix conserva el valor original de Plex, mientras que viewed_at_utc convierte una marca Unix válida en una cadena UTC ISO 8601. event_id combina la cuenta, rating key, la hora de visualización original y la clave de Plex para contar identificadores duplicados sin eliminarlos silenciosamente.
El token se envía en la cabecera X-Plex-Token, no en la URL ni en el CSV. Primero, el script comprueba que la URL base sea un origen http o https sin credenciales incrustadas, ruta, consulta ni fragmento. Rechaza redirecciones HTTP para que la cabecera de autenticación no se reenvíe a otro origen. Si PLEX_TOKEN no está en el entorno, lo solicita mediante entrada oculta. Ni el resumen JSON ni el CSV imprimen el token.
El script escribe primero en un archivo temporal del directorio de salida y solo reemplaza el CSV solicitado después de serializar todas las filas obtenidas. No elimina un CSV anterior antes de completar la nueva escritura. Aun así, escribe localmente: si el historial es sensible, elige una ruta fuera de carpetas compartidas automáticamente.
Ejecuta el exportador de solo lectura
Guarda el siguiente script exacto de Python 3 como plex_history_export.py en el equipo de confianza. No requiere paquetes de terceros. Usa la dirección local que normalmente consideras segura para tu servidor; no pegues el token en --base-url. Para una cuenta, ejecuta python3 plex_history_export.py --base-url http://127.0.0.1:32400 --account-id 42 --page-size 100 --output plex-history.csv e introduce el token solo en la solicitud oculta. Omite --account-id únicamente si quieres deliberadamente el resultado sin filtrar del endpoint.
#!/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())
El comando termina con código 0 cuando el primer total comunicado, el número de filas escritas y los identificadores únicos son internamente coherentes y el total no cambia entre páginas. Termina con 1 después de escribir el CSV si existen advertencias que requieren revisión, y con 2 ante problemas de argumentos o entrada de autenticación. Un código 0 comprueba coherencia interna; no demuestra que Plex conservara todos los eventos históricos.
Verifica el CSV en lugar de confiar en que el archivo exista
Lee primero el resumen JSON. rows es el número escrito, unique_event_ids el número de identificadores compuestos únicos, first_total_size el totalSize de la primera página y page_total_sizes recoge lo indicado por cada página. warnings debe estar vacío antes de considerar la ejecución una instantánea estable del servidor.
Abre el CSV con una aplicación que preserve los campos de texto. El módulo csv de Python cita correctamente comas, comillas y saltos de línea, por lo que un título como A, B permanece en un único campo. Comprueba varias reproducciones recientes conocidas, un episodio con serie y temporada y al menos un evento de la cuenta seleccionada. Compara el ID de cuenta y el intervalo temporal con el servidor de origen; no supongas que el título por sí solo identifica una fila.
Conserva el resumen JSON junto al CSV y calcula un SHA-256 del archivo final si se utilizará como registro de auditoría. Anota por separado el servidor, el filtro de cuenta, la hora de ejecución y el SHA del script. No anexes ejecuciones posteriores al mismo CSV sin un método de fusión explícito: las páginas de historial solapadas pueden producir eventos duplicados.
Trata las advertencias de paginación como condición de parada
El script avanza según el número de filas realmente devueltas y rechaza una huella de página repetida. También avisa si totalSize cambia entre páginas, si el número escrito difiere del primer total o si se repiten identificadores compuestos. Estas comprobaciones detectan varias formas habituales en que una exportación paginada parece completa cuando no lo está.
La reproducción durante la exportación puede añadir eventos al principio de una lista cambiante y desplazar los offsets. Pausa la reproducción y las pruebas automatizadas, vuelve a ejecutar y compara dos resultados obtenidos en periodos tranquilos. Si los totales siguen variando, reduce el tamaño de página, conserva ambos resúmenes y no etiquetes ningún archivo como completo hasta entender la causa.
Una página vacía antes de alcanzar el total esperado también implica una exportación incompleta, aunque el script pueda escribir las filas ya recibidas. El ámbito de autenticación, la versión del servidor, la retención y el acceso a bibliotecas afectan a la visibilidad. Corrige el problema de acceso o alcance y repite; no rellenes filas desde el estado actual porque ese estado no es un registro de eventos.
Conoce lo que Tautulli puede y no puede aportar
Tautulli resulta útil si ya estaba funcionando y registrando actividad. Su documentación advierte que el historial detallado se crea mientras Tautulli se ejecuta y, por lo general, no puede importarse retroactivamente desde un historial antiguo de Plex que nunca registró. Su guía separada Exporter cubre metadatos de biblioteca e información multimedia en CSV, JSON, XML o M3U; no es la exportación de historial descrita aquí.
Si Tautulli tiene su propio historial, expórtalo y consérvalo por separado siguiendo sus instrucciones actuales. No lo fusiones con el CSV del servidor Plex solo porque coincidan títulos. Ambos sistemas pueden usar identificadores, usuarios, semánticas temporales y retenciones diferentes. Una fusión defendible exige una clave documentada, normalización de zona horaria y una política de duplicados.
Del mismo modo, una solicitud de historial de cuenta Plex o Discover necesita una ruta de exportación compatible propia. La petición sin respuesta del foro de Plex sobre un historial portátil de Discover demuestra que existe una carencia, no que el endpoint local proporcione esos datos. Etiqueta cada archivo con la fuente que representa realmente.
Qué se probó y qué sigue sin demostrarse
El script exacto anterior tiene SHA-256 c56a76c85d49417cbebdec117f8c5ee40f6dc53e273aafa76f76ec4ab7f4c594. El archivo unittest exacto tiene SHA-256 5db0a7155da9102608d9541ea44dce71c2874f532d25ccb8eb8f4333b2840d75. Se superaron tres pruebas: un fixture local devolvió tres eventos en dos páginas y verificó el filtro de cuenta, la cabecera del token, la conversión Unix a UTC, el entrecomillado CSV, tres identificadores únicos y la ausencia del token de prueba en stdout, stderr y CSV; otra prueba rechazó credenciales incrustadas en la URL base; la tercera rechazó una redirección antes de que un segundo host pudiera recibir la cabecera de autenticación. Ambos archivos también superaron la compilación de bytecode de Python.
Son pruebas sintéticas de transporte y serialización, no una ejecución contra un servidor o una cuenta privada de Plex. No demuestran que todas las versiones de Plex expongan los mismos atributos XML, que el historial de cuenta o Discover esté disponible, que el historial retenido esté completo ni que una exportación siga estable mientras llegan nuevas reproducciones.
El punto seguro de finalización es un CSV etiquetado junto con su resumen, sin advertencias, comprobaciones manuales y una segunda ejecución en periodo tranquilo que coincida. Conserva los archivos originales como solo lectura. Esta guía termina deliberadamente en la exportación y verificación; no ofrece un importador ni modifica el estado de Plex.
