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는 계정, rating key, 원래 시청 시각, Plex key를 결합해 중복 식별자를 조용히 삭제하지 않고 셀 수 있게 합니다.

토큰은 URL이나 CSV가 아니라 X-Plex-Token 요청 헤더로 전송됩니다. 스크립트는 먼저 기본 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입니다. 검토가 필요한 경고와 함께 CSV를 쓴 경우 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 기록 요청에는 자체적으로 지원되는 내보내기 경로가 필요합니다. 이동 가능한 Discover 기록을 요구한 Plex 포럼의 미답변 글은 답변 공백의 증거이지 로컬 서버 엔드포인트가 해당 데이터를 준다는 증거가 아닙니다. 모든 파일에 실제로 나타내는 출처를 표시하십시오.

검증한 내용과 아직 입증하지 못한 내용

위의 정확한 내보내기 스크립트 SHA-256은 c56a76c85d49417cbebdec117f8c5ee40f6dc53e273aafa76f76ec4ab7f4c594이고 정확한 unittest 파일 SHA-256은 5db0a7155da9102608d9541ea44dce71c2874f532d25ccb8eb8f4333b2840d75입니다. 세 테스트를 통과했습니다. 로컬 fixture는 두 페이지에 세 이벤트를 반환해 계정 필터, 토큰 헤더, Unix에서 UTC 변환, CSV 인용, 고유 식별자 세 개, stdout·stderr·CSV에 fixture 토큰이 없음을 검증했습니다. 다른 테스트는 기본 URL에 포함된 자격 증명을 거부했고, 세 번째는 두 번째 호스트가 인증 헤더를 받기 전에 리디렉션을 거부했습니다. 두 파일 모두 Python 바이트코드 컴파일을 통과했습니다.

이는 합성된 전송 및 직렬화 테스트이지 비공개 Plex 서버나 계정에서 실행한 결과가 아닙니다. 모든 Plex 버전이 같은 XML 속성을 제공하는지, 계정 또는 Discover 기록을 쓸 수 있는지, 보존 기록이 완전한지, 새 재생이 들어오는 동안에도 내보내기가 안정적인지는 입증하지 않습니다.

안전한 완료 지점은 범위를 표시한 CSV와 요약, 빈 경고, 수동 표본 검사, 서로 일치하는 두 번째 조용한 기간 실행입니다. 원본 파일은 읽기 전용으로 보관하십시오. 이 가이드는 의도적으로 내보내기와 검증에서 끝나며 가져오기 도구를 제공하거나 Plex 상태를 변경하지 않습니다.

현재 재생 상태 하나와 여러 이벤트의 시간순 보관 기록을 분리한 원본 개념 일러스트.
원본 개념 일러스트: 현재 시청 상태는 하나의 최신 값이고 서버 기록은 별도의 재생 이벤트 순서입니다. Watch at Ease