Watch at Ease看见世界,读懂生活。

科技 · 实用指南

导出 Plex 观看历史为 CSV,别把它与观看状态混为一谈

用经过测试的只读脚本分页读取本地 Plex Media Server 历史端点,保留按用户区分的事件记录,并标出可能不完整的导出。

通用家庭媒体服务器把受保护的事件记录导出到 CSV 表格的原创插画。
原创插画:只读服务器历史导出应保留事件行、保护令牌,并生成可单独核验的 CSV。 Watch at Ease

Plex 用相近的词指代三类不同数据:某个条目当前的已观看或未观看状态、某台 Plex Media Server 保存的事件历史,以及与账号或 Discover 相关的活动。导出其中一种,并不等于另外两种也被保留。本指南从 Plex 社区讨论过的服务器历史端点生成本地 CSV,核验可见分页,并严格限定结果的名称和范围。

此流程对 Plex 服务器只读:它不会把条目标为已观看、不会导入数据、不会改动资料库,也不会承诺重建服务器从未保留的事件。请在能访问自己服务器的可信电脑上运行,隐藏令牌,并在生成最终快照时暂停播放。

先确定你真正需要哪一种记录

如果目标只是保留电影或剧集当前是否已观看,Plex 官方的“同步观看状态与评分”功能针对的是受支持条目的当前状态。Plex 明确区分当前状态与观看历史;后者是持续累积的变化和播放事件日志。同步功能可以恢复当前状态,却不是逐事件账本;它还不包含播放中的条目,并对部分媒体或版本存在限制。

下面的脚本处理另一数据源:一台本地 Plex Media Server 暴露的历史。Plex 论坛中的答案给出了 /status/sessions/history/all,并展示了账号过滤和容器分页。因此生成的 CSV 是服务器历史导出,不是 Plex 账号或 Discover 历史,也不能替代当前状态同步。

运行前决定你需要单个账号,还是端点未过滤的全部范围。提供 --account-id 会请求一个 accountID;省略它则请求该端点的未过滤范围。共享资料库访问、账号身份和保留期限均受服务器影响;CSV 只能包含端点实际返回的行。

经过测试的导出器会记录什么

每一行 CSV 对应一个返回的历史事件。固定列为 event_id、viewed_at_unix、viewed_at_utc、account_id、media_type、title、show、season、year、rating_key 和 library_section_id。viewed_at_unix 保留 Plex 原始值,viewed_at_utc 则把有效 Unix 时间转换为 ISO 8601 UTC 字符串。event_id 组合账号、ratingKey、原始观看时间和 Plex key,因此报告可以统计重复标识,而不是悄悄删掉。

令牌放在 X-Plex-Token 请求头中,不会写入 URL 或 CSV。脚本首先确认基础 URL 是不含嵌入凭据、路径、查询或片段的 http/https 源;它拒绝 HTTP 重定向,避免认证头被再次发送到其他源。如果环境中没有 PLEX_TOKEN,脚本会用隐藏输入提示。JSON 摘要和 CSV 都不会打印令牌。

脚本先在输出目录写临时文件,所有行序列化完成后才替换指定 CSV。新写入完成前不会删除旧 CSV。不过它仍会在本地写文件;如果观看记录敏感,请选择不被自动共享的输出路径。

运行只读导出器

把下面这份准确的 Python 3 脚本保存为可信电脑上的 plex_history_export.py,不需要第三方软件包。使用你平时信任的本地服务器地址;不要把令牌放进 --base-url。导出单个账号时,运行 python3 plex_history_export.py --base-url http://127.0.0.1:32400 --account-id 42 --page-size 100 --output plex-history.csv,并只在隐藏提示中输入令牌。只有在确实需要端点未过滤结果时才省略 --account-id。

#!/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())

当首次报告总数、写入行数、唯一事件标识和各页总数内部一致时,命令以 0 退出。文件已写入但出现需复核的警告时以 1 退出;参数或认证输入有问题时以 2 退出。退出码 0 只表示内部一致,不能证明 Plex 保留了每一个历史事件。

核验 CSV,不能只看文件是否存在

先读 JSON 摘要。rows 是写入行数,unique_event_ids 是唯一复合标识数,first_total_size 是第一页的 totalSize,page_total_sizes 记录每一页报告的总数。只有 warnings 为空,才能把本次结果视作稳定的服务器快照候选。

用能保留文本字段的程序打开 CSV。Python csv 模块会正确引用逗号、引号和换行,所以 A, B 这样的标题仍是一个字段。抽查几个已知的最近播放、一个带剧名和季号的剧集,以及目标账号的一项事件。把账号 ID 和时间范围与源服务器比对,不要只凭标题识别一行。

把 JSON 摘要与 CSV 放在一起;若导出将作为审计记录,再计算成品文件的 SHA-256。另行记录服务器、账号过滤条件、运行时间和脚本 SHA。不要在没有明确合并方法时把后续运行直接追加到同一个 CSV,因为重叠历史页会产生重复事件。

把分页警告视为停止条件

脚本按实际返回行数推进起点,并拒绝重复的页面指纹。如果各页的 totalSize 发生变化、写入数与第一页总数不同,或复合事件标识重复,它也会发出警告。这些检查能发现若干常见的“看起来完整、实际并不完整”的分页失败。

导出过程中发生播放,会在不断变化的历史列表前端增加事件并移动偏移量。暂停播放和自动化测试后重新运行,再比较两次静默期结果。如果总数仍漂移,请减小页大小、保留两份摘要,并在查明原因前不要把任何一份标为完整。

在达到预期总数前出现空页也表示不完整,即使脚本仍能把已经收到的行写入文件。认证范围、服务器版本、保留期限和资料库访问都会影响可见性。修复访问或范围问题后重跑;不要用当前观看状态补缺失行,因为当前状态不是事件日志。

明确 Tautulli 能补什么、不能补什么

如果 Tautulli 当时已经运行并记录活动,它可能很有用。其文档提醒,详细历史是在 Tautulli 运行期间形成的,通常无法从 Tautulli 从未记录的旧 Plex 历史中追溯导入。它另有一个 Exporter 指南,可把资料库元数据和媒体信息导出为 CSV、JSON、XML 或 M3U;那不是本文讨论的观看历史导出。

若 Tautulli 拥有自己的历史,请按其当前说明单独导出并保存。不要因为标题相同就把它直接并入 Plex 服务器 CSV;两套系统可能拥有不同的事件标识、用户、时间语义和保留策略。可辩护的合并需要明确记录键、时区归一化和重复处理规则。

同理,Plex 账号或 Discover 历史请求需要自身受支持的导出路径。Plex 论坛中未得到答复的 Discover 可移植历史请求说明了答案缺口,却不能证明本地服务器端点会提供那份数据。每个文件都应标明它实际代表的数据源。

实际测试了什么,仍未证明什么

上面准确脚本的 SHA-256 为 c56a76c85d49417cbebdec117f8c5ee40f6dc53e273aafa76f76ec4ab7f4c594;准确 unittest 文件的 SHA-256 为 5db0a7155da9102608d9541ea44dce71c2874f532d25ccb8eb8f4333b2840d75。三项测试通过:本地夹具用两页返回三项事件,验证账号过滤、令牌请求头、Unix 到 UTC 转换、CSV 引用、三个唯一标识,以及标准输出、错误输出和 CSV 均不含夹具令牌;第二项测试拒绝基础 URL 内嵌凭据;第三项在任何第二主机可能收到认证头之前拒绝重定向。两个文件也通过 Python 字节码编译。

这些是合成的传输和序列化测试,不是针对私人 Plex 服务器或账号的运行。它们不能证明所有 Plex 版本都暴露相同 XML 属性、账号或 Discover 历史可用、已保留历史绝对完整,或新增播放期间导出仍稳定。

安全的完成点是一份标明范围的 CSV 及其摘要:警告为空、人工抽查通过、第二次静默期运行结果一致。保留原始文件为只读。本指南有意止于导出和核验,不提供导入器,也不修改 Plex 状态。

将单一当前播放状态标记与多个事件组成的时间顺序档案分开的原创插画。
原创插画:当前观看状态是一个最新值,服务器历史则是另一组按时间排列的播放事件。 Watch at Ease