はじめに
我が家の録画機は、長年のうちに少しずつ増えて気づいたら 4 台になっていました。
- PC TV Plus(Windows PC、Sony 純正)
- nasne ×3(Sony 旧モデル + バッファロー新モデル ×2)
単発で見る分には困らないのですが、「あの番組って録ってあったっけ?」「どの機械に入ってる?」が分からなくなり、結局録り直す、ということが頻発していました。番組表アプリ越しでは横断検索ができないんですよね。
そこで、家にある録画機の中身(タイトル一覧)を 1 本のリストにまとめる仕組みを作ってみました。やってみたら全部で 2,388 件ありました。多すぎ。
この記事では、その実装と仕組みを共有します。録画ファイル本体(DTCP-IP 暗号化されたストリーム)には一切触れません。あくまでメタデータ(タイトル・チャンネル・放送日時)の話です。
結論先取り:何ができるようになったか
[*] PC TV Plus (dedup id): 1,612 件
[*] nasne 3 台合計: 831 件
[*] 統合後ユニーク: 2,388 件 (cross-source 重複 55 件をマージ)
- 4 台の録画リストが 1 本の CSV / JSON に
- タイトル正規化+放送日時で「同じ番組を複数機で録ってる」状態も検出
- ここから「みんなの鉄道」「ゲームセンターCX」などのシリーズを抽出して、欠番や予約状況を可視化(後続のダッシュボード化につながる)
なぜ DLNA だけで完結するか
家庭用録画機は DLNA / DTCP-IP の規格に従っていて、
- 録画ファイル本体は DTCP-IP で暗号化されている(=自由に持ち出せない)
- でも タイトル・チャンネル名・放送日時・ジャンル・あらすじ等のメタデータは UPnP の DIDL-Lite XML で平文取得できる
これは規格上の標準動作で、再生機(テレビ、PS5、PC TV Plus、torne mobile 等)が「録画一覧」を表示するのに必要だからです。要するに、各録画機が日常的に喋っている内容を、こちらも urllib で同じプロトコルで聞きにいくだけ。
全体の流れ
[Mac/Python]
│ 1. SSDP M-SEARCH (UDP マルチキャスト)
▼
[LAN 上の MediaServer 各種] ← PC TV Plus / nasne ×3 が応答
│ 2. LOCATION = device description XML を HTTP GET
▼
[ContentDirectory:control の URL を取得]
│ 3. SOAP で Browse(ObjectID="0", BrowseDirectChildren) を再帰
▼
[DIDL-Lite XML]
│ 4. <item> から dc:title / upnp:channelName / upnp:scheduledStartTime をパース
▼
[titles.json / titles.csv]
シンプルですが、「家庭内の録画機がこんなに饒舌に喋ってる」のを実感できる小さな発見でした。
1. SSDP で LAN 上の MediaServer を見つける
SSDP は「LAN にどんな UPnP 機器いますかー?」をマルチキャストで尋ねるプロトコル。M-SEARCH を投げると、対応機器が LOCATION ヘッダ(device description XML の URL)を返してくれます。
# discover.py(抜粋)
import socket
SSDP_ADDR = "239.255.255.250"
SSDP_PORT = 1900
def msearch(st: str, timeout: float = 4.0) -> list[dict]:
msg = (
"M-SEARCH * HTTP/1.1\r\n"
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
'MAN: "ssdp:discover"\r\n'
f"MX: {int(timeout)}\r\n"
f"ST: {st}\r\n"
"USER-AGENT: macOS/UPnP-discover\r\n\r\n"
)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 4)
s.settimeout(timeout)
s.sendto(msg.encode("ascii"), (SSDP_ADDR, SSDP_PORT))
found = {}
while True:
try:
data, addr = s.recvfrom(65535)
except socket.timeout:
break
# ヘッダを辞書にパース
...
return list(found.values())
# 検索対象 (Search Target)
TARGETS = [
"urn:schemas-upnp-org:service:ContentDirectory:1",
"urn:schemas-upnp-org:device:MediaServer:1",
"ssdp:all",
]
実行すると、こんな感じで応答が返ってきます(IP は伏せ字)。
IP: 192.168.x.10
LOCATION: http://192.168.x.10:64321/nasne_dms.xml
SERVER: ... nasne/2.50 UPnP/1.0 DLNADOC/1.50 ...
IP: 192.168.x.77
LOCATION: http://192.168.x.77:49152/MediaServerDevDesc.xml
SERVER: Microsoft-Windows/10.0 UPnP/1.0 ... ← PC TV Plus
PC TV Plus は Windows の UPnP 経由で名乗っていて、SERVER に "PC TV" の文字列が出ないので、ぱっと見では区別が付かないことがあります。LOCATION を urlopen で叩いて XML を見ると friendlyName に "PC TV Plus" と書いてあるので、それで判定できます。
2. ContentDirectory:control の URL を取得
LOCATION の XML(device description)を取ると、<service> 一覧が入っていて、その中に ContentDirectory サービスがあれば録画一覧が取れます。
<service>
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
<SCPDURL>/ContentDirectory/scpd.xml</SCPDURL>
<controlURL>/ContentDirectory/control</controlURL>
<eventSubURL>/ContentDirectory/event</eventSubURL>
</service>
この controlURL に SOAP を投げると Browse できます。
3. SOAP で再帰 Browse
ContentDirectory の Browse アクションは、フォルダ階層を返してくれる。ObjectID="0" がルート。
# browse.py(抜粋)
SOAP_ENVELOPE = """<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ...>
<s:Body>
<u:Browse xmlns:u="{stype}">
<ObjectID>{oid}</ObjectID>
<BrowseFlag>{flag}</BrowseFlag>
<Filter>*</Filter>
<StartingIndex>{idx}</StartingIndex>
<RequestedCount>{cnt}</RequestedCount>
<SortCriteria></SortCriteria>
</u:Browse>
</s:Body>
</s:Envelope>"""
def browse(object_id: str, flag="BrowseDirectChildren",
start=0, count=200) -> tuple[str, int, int]:
body = SOAP_ENVELOPE.format(
stype="urn:schemas-upnp-org:service:ContentDirectory:1",
oid=object_id, flag=flag, idx=start, cnt=count
).encode("utf-8")
req = urllib.request.Request(
CONTROL_URL,
data=body,
headers={
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"',
},
)
with urllib.request.urlopen(req, timeout=20) as r:
raw = r.read().decode("utf-8")
# <Result> の中に DIDL-Lite XML が文字列として入っている
...
レスポンスは「DIDL-Lite XML が <Result> に文字列として埋め込まれている」という二重 XML 構造です。最初これに気づかず「なんで title が取れないんだ」と 30 分くらい悩みました。
4. DIDL-Lite から欲しいものを抜く
DIDL-Lite はメディアメタデータの標準形式。<item> がコンテンツ、<container> がフォルダ。録画番組は item で、こんな感じで属性が並びます。
<item id="..." parentID="..." restricted="1">
<dc:title>みんなの鉄道 #85 大井川鐵道</dc:title>
<upnp:class>object.item.videoItem.videoBroadcast</upnp:class>
<dc:date>2025-04-15</dc:date>
<upnp:channelName>フジテレビONE</upnp:channelName>
<upnp:scheduledStartTime>2025-04-15T22:00:00</upnp:scheduledStartTime>
<upnp:scheduledDuration>0:30:00</upnp:scheduledDuration>
<upnp:genre>...</upnp:genre>
<res protocolInfo="http-get:*:video/mpeg:DLNA.ORG_PN=...DTCP_..." size="..." duration="...">
http://192.168.x.x:.../...
</res>
</item>
ここで欲しいのは dc:title / upnp:channelName / upnp:scheduledStartTime / dc:date / upnp:genre。<res> の URL は DTCP-IP 暗号化されたストリームの取得 URL で、そこには手を出さない方針です(メタデータだけで十分用が足りる)。
ContentDirectory はフォルダ階層を持つので、Browse を再帰します。広く取りすぎないように max_depth で深さ制限。
def crawl(start_id="0", max_depth=6):
queue = [(start_id, 0, "/")]
visited = set()
all_items = []
while queue:
oid, depth, path = queue.pop(0)
if oid in visited or depth > max_depth:
continue
visited.add(oid)
# ページング
start = 0
while True:
didl, nret, ntot = browse(oid, "BrowseDirectChildren", start, 100)
cs, its = parse_didl(didl)
for c in cs:
queue.append((c["id"], depth+1, path + c["title"] + "/"))
for it in its:
it["_path"] = path
all_items.append(it)
if nret == 0 or start + nret >= ntot:
break
start += nret
つまずきポイント 1: DIDL-Lite に不正文字が混じる
<dc:description> に番組のあらすじが入っているのですが、ごく稀に XML 1.0 で許されない制御文字(U+0000〜U+001F の一部)が混じることがあります。ET.fromstring がパースエラーで落ちる。
対処は 2 段構え。
- 制御文字を空白に置換してから再パース
- それでも壊れる場合は
<item>単位で正規表現抽出 → 個別に小さな DIDL でラップして再パース → 壊れた item だけ捨てる
これで 2,388 件中、ロスは数件程度に抑えられました。
つまずきポイント 2: PC TV Plus は同じ番組が複数のフォルダから見える
PC TV Plus の DLNA ツリーは、
- 日付別フォルダ(
/録画/日付別/2025-04-15/...) - ジャンル別フォルダ(
/録画/ジャンル別/ドキュメンタリー/...) - チャンネル別フォルダ(
/録画/チャンネル別/...)
と複数の "見え方" を提供してくれていて、同じ番組が 3 重にも 4 重にも出てきます。再帰すると数千件になって「録ってる量おかしくない?」と一瞬慌てる。
対処は単純で、item.id でユニーク化すれば済みます。同じ番組はどのフォルダ経由でも同じ id なので。
seen_ids = set()
for it in items:
if it["id"] in seen_ids:
continue
seen_ids.add(it["id"])
...
5. nasne の場合(同じインタフェースで動く)
nasne も DLNA ContentDirectory を実装しているので、全く同じ browse.py が使えます。CONTROL_URL を nasne のものに差し替えるだけ。
# PC TV Plus
CDS_CONTROL_URL=http://192.168.x.77:49152/ContentDirectory/control \
OUT_PREFIX=titles_pctv python3 browse.py
# nasne 1
CDS_CONTROL_URL=http://192.168.x.10:64321/dms/control_0 \
OUT_PREFIX=titles_nasne1 python3 browse.py
DLNA は規格なので、対応機器ならこのコードで読めるはずです。
(nasne は DLNA 以外に独自の JSON API も持っていて、こちらは「予約一覧」「録画予約の追加」など書き込み系もできるのですが、それはまた別の話。今回はあくまで「読み取り専用のタイトル取得」までで完結します。)
6. 4 台ぶんを統合する
各機からの取得結果を 1 つの配列にまとめて、cross-source 重複(同じ放送を別の機械でも録画していた、など)をマージします。
キーは「正規化タイトル + 放送開始時刻(分単位)」。タイトルは全角→半角、空白圧縮、小文字化、で表記揺れを吸収。
def _norm_title(t: str) -> str:
s = t.translate(str.maketrans(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz ",
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "
))
s = re.sub(r"\s+", " ", s).strip().lower()
return s
bucket = defaultdict(list)
for it in all_items:
key = (_norm_title(it["title"]), it["startDateTime"][:16])
bucket[key].append(it)
我が家では cross-source の重複が 55 件。「録ったの忘れて別の機械でも録ってた」あるあるが可視化されました。
注意点・前提
- 対象は自分が所有する LAN 内の録画機 / 自分が視聴する範囲のコンテンツのみ。家の中で完結する話です
- DTCP-IP で暗号化された録画ファイル本体には一切触れていません。
<res>の URL は無視。あくまで番組メタデータ(タイトル・時刻・チャンネル)のみ - 取得するメタデータは、対応機器が DLNA 規格に従って公開している標準項目です。アクセス制御を破ったり認証を回避したりはしていません
- nasne / PC TV Plus を DLNA で読みに行く実装は、Mirakurun・EPGStation・foltia など先行 OSS の延長線上にあるアプローチです
おわりに
「家中の録画機の中身を 1 本のリストに」というのは、やる前は大ごとに見えたんですが、DLNA という標準規格の上に乗ってしまえば、
- SSDP で見つける
- ContentDirectory に SOAP を投げる
- DIDL-Lite から
dc:titleを読む
の 3 ステップで、Python 200 行ちょっとで実現できました。家中の録画機がこんなに 饒舌にメタデータを喋ってくれているのは、やってみるまで気づかなかった発見でした。
このリストを起点に、
- シリーズ別の欠番表示(「みんなの鉄道」全 85 話のうち持ってないのは #12, #34, #58…)
- 録画予約の自動化(欠番が EPG に出てきたら自動で予約を入れる)
- ダッシュボード化
…と展開していけて、家の録画運用がだいぶ静かで穏やかになりました。
同じように家に録画機が増えすぎて困っている方がいたら、まず手元の機器が DLNA で何を喋っているか、一度覗いてみると面白いかもしれません。
続編
参考
- UPnP ContentDirectory:1 Service Template — UPnP Forum の規格書
- DIDL-Lite spec
- Mirakurun — 録画関連 OSS の代表例