0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LAN 上の DLNA メディアサーバを総ざらいして、家中の録画タイトル 2,400 本を一元化した話(PC TV Plus + nasne ×3)

0
Last updated at Posted at 2026-05-01

はじめに

我が家の録画機は、長年のうちに少しずつ増えて気づいたら 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 段構え。

  1. 制御文字を空白に置換してから再パース
  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 で何を喋っているか、一度覗いてみると面白いかもしれません。


続編

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?