科技 · 實用指南
將 Plex 觀看記錄匯出為 CSV,別把它與觀看狀態混為一談
使用經過測試的唯讀指令碼,分頁讀取本機 Plex Media Server 歷史端點,保留各使用者的事件記錄,並標示可能不完整的匯出結果。

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 狀態。
