はじめに
前回の記事 で、家中の録画機(PC TV Plus + nasne ×3)を DLNA 経由で総ざらいして 2,388 タイトルを統合する話を書きました。
統合できたら次に欲しくなるのは「録り逃しの自動補完」です。たとえば「みんなの鉄道」シリーズで #12, #34, #58 が抜けている状態で EPG に再放送が出てきたら、自動で予約を入れたい。
ということで、自宅 LAN 内の自分の nasne 3 台を相手に、録画予約 bot を組みました。やってみたら順調に予約が入ったあと、突然 errorcode 830 が連発して全予約が刺さる、という壁にぶつかります。原因を突き止めて、最終的に eventId のユニーク化 + 3 台分散 で抜けたので、その過程を記事にします。
本記事の前提
- 対象は自宅 LAN 内の自分の所有する nasne 3 台のみ
- 暗号化された録画ストリーム本体には一切触れません。扱うのはメタデータ(タイトル・チャンネル・時刻)と予約 API のみ
- nasne の API は LAN 内で認証なしに公開されているもの。アクセス制御の回避はしていません
- Mirakurun や nasne 連携 OSS 等、先行 OSS の延長線上のアプローチです
やりたかったこと
EPG (各局公式 API)
│ 番組表を取得
▼
[bot] 補完対象シリーズの未保有エピソードを EPG から探す
│ 候補が見つかった
▼
[nasne #1 / #2 / #3] ← 3 台の中で空いてる nasne を選んで予約 POST
入力:
- 各局公式 API から取得した EPG(NHK・日テレ・TBS・フジ・テレビ朝日・テレ東・スカパー有料 BS/CS)
- 自宅の nasne 3 台の現状(録画済み + 予約済み)
- シリーズ定義(
series.json)
処理:
- 各シリーズの保有エピソードを集計、欠番を特定
- EPG から欠番に該当する放送を探す
- まだ予約されていなければ、空いている nasne に予約 POST
出力:
- nasne への予約登録
- bot 自身が作った予約の記録(重複防止 + ダッシュボード表示用)
API は何を観察したか
nasne の予約 API はメーカー公式に公開されているわけではありませんが、torne mobile / nasne home などのコンパニオンアプリが LAN 内で叩いているのを観察すれば、どんなフォーマットなのか分かります。
私は iOS の torne mobile を使って、Mac の rvictl でリモート仮想インタフェースを作り、tcpdump でパケットを取って中身を見ました。
# iPhone を USB 接続して RVI を起動
rvictl -s <iPhone-UDID>
sudo tcpdump -i rvi0 -A -s 0 'host 192.168.x.10 and port 64220'
これで torne mobile が予約ボタンを押した瞬間の HTTP リクエストが流れます。
POST /schedule/reservedInfoCreate HTTP/1.1
Host: 192.168.x.10:64220
Content-Type: application/x-www-form-urlencoded
title=...&startDateTime=...&duration=1800&quality=100
&broadcastingType=2&serviceId=...&eventId=...
&forceFlag=1&storageId=1&creatorId=2000&userData=%7B%7D
ポート 64220 に POST、認証なし、x-www-form-urlencoded。LAN 内だけで完結する素直な設計です。
同じ手法を取りたい方へ: 認証回避や暗号解除をする必要はなく、自分の iPhone にインストールした自分のアプリを、自分の LAN 内で観察するだけです。それでも違和感があれば、nasne 連携 OSS など既存 OSS のソースを読むだけでもエンドポイントとフィールド構造は分かります。
観察できたエンドポイント(読み取り系)
予約系の前に、参考になる読み取り系をまとめておきます。
| Endpoint | 用途 |
|---|---|
GET /recorded/titleListGet |
録画済み一覧(DLNA より速い) |
GET /schedule/reservedListGet |
予約済み一覧 |
GET /schedule/conflictListGet |
チューナー競合の一覧 |
GET /status/boxNameGet |
nasne 個体名 |
GET /status/HDDInfoGet |
HDD 残量 |
ポートは全て 64220(一部 64210 のものもあり)。レスポンスは JSON で errorcode: 0 なら成功。Python だと urllib 一発です。
def title_list(ip: str, page: int = 100) -> list[dict]:
items = []
start = 0
while True:
q = urllib.parse.urlencode({
"searchCriteria": 0, "filter": 0,
"startingIndex": start, "requestedCount": page,
"sortCriteria": 0,
})
url = f"http://{ip}:64220/recorded/titleListGet?{q}"
with urllib.request.urlopen(url, timeout=15) as r:
data = json.loads(r.read().decode("utf-8"))
chunk = data.get("item", []) or []
if not chunk:
break
items.extend(chunk)
if len(chunk) < page:
break
start += len(chunk)
return items
DLNA 経由でも同じデータは取れますが、JSON API のほうが高速で、serviceId や eventId といった内部 ID も生で取れるので、予約処理では JSON API のほうが扱いやすいです。
予約 API: reservedInfoCreate
POST /schedule/reservedInfoCreate のフィールドはこんな感じ。
| フィールド | 内容 |
|---|---|
title |
番組タイトル |
startDateTime |
開始時刻(ISO 8601 + JST オフセット) |
duration |
秒 |
quality |
録画モード(100=DR) |
broadcastingType |
放送波の種別を示す内部値(筆者環境では 地デジ=2 / BS=3 / CS=4。公式仕様は非公開、torne mobile が送る値を観察した結果) |
serviceId |
チャンネル ID(ARIB の service_id と同等) |
eventId |
番組固有 ID(後述、ここがハマりどころ) |
forceFlag |
1=他予約と被ってもユーザに警告しない |
storageId |
通常 1(内蔵 HDD) |
creatorId |
2000 など。クライアント識別 |
userData |
"{}" で OK |
レスポンス(成功例):
{
"errorcode": 0,
"id": "...",
"title": "...",
"startDateTime": "..."
}
def reserve_create(target_ip: str, payload: dict) -> dict:
url = f"http://{target_ip}:64220/schedule/reservedInfoCreate"
body = urllib.parse.urlencode(payload).encode("utf-8")
req = urllib.request.Request(
url, data=body, method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())
ここまでは普通に動きました。
壁: 同じ番組を 2 件目で予約しようとすると errorcode 830
bot を回し始めた最初の数件はちゃんと予約が入ります。ところが「みんなの鉄道」を立て続けに予約していくと、3 件目あたりから突然全部 errorcode 830 で弾かれるようになりました。
✅ 予約成功: 5/2 みんなの鉄道 #34 大井川鐵道
✅ 予約成功: 5/3 みんなの鉄道 #58 江ノ島電鉄
❌ errorcode=830 5/4 みんなの鉄道 #67 ...
❌ errorcode=830 5/5 みんなの鉄道 #71 ...
❌ errorcode=830 5/6 みんなの鉄道 #82 ...
最初は「同シリーズ連続予約に上限がある?」と疑ったのですが、別のシリーズ(漫道コバヤシ)を挟むと普通に通る。同じ serviceId × 同じ eventId の組み合わせを 2 度送ると 830 になっているのが見えてきました。
この時の eventId は 65535(0xFFFF)。これは ARIB 仕様で「event_id 不明」を表す予約値で、torne mobile が「番組指定でなく時刻指定の手動予約」を入れる時に使う値のようです。nasne 側は (serviceId, eventId=65535) のタプルで重複検知をしているらしく、1 nasne 1 serviceId に対して eventId=65535 の予約は 1 件しか入らない仕様でした。
EPG ベースで予約を入れるなら、本来は EPG の event_id を使えば衝突しません。ただし各局公式 API の番組表には ARIB の event_id がそのまま載っているとは限らず、ARIB の生 event_id を取るなら結局 nasne 側の programInfoGet 系を併用する必要があります。
ここで方針転換しました。
eventId は nasne 側の重複検知ハッシュとしてしか使われていないなら、こちらでユニーク値を作って渡せば 1:1 にできるはず。
解決: eventId をハッシュでユニーク化
「番組の開始時刻 + serviceId + タイトル」をシードにして、0〜65534(65535 は ARIB の予約値なので除外)に収まる値に丸めます。
def _unique_event_id(cand: dict) -> int:
"""nasne の重複検知 (errorcode 830) を回避するため、eventId をユニーク値にする。
eventId=65535 (manual time-based) は 1 nasne 1 serviceId 1 件しか入らない仕様。
番組の start + serviceId をハッシュして 0..65534 にマップ。"""
seed = f"{cand.get('start','')}|{cand.get('channelName','')}|{cand.get('title','')}"
h = sum(ord(c) * (i + 1) for i, c in enumerate(seed)) & 0xFFFF
return h if h != 0xFFFF else 0xFFFE
hash() ではなく自前の単純な重み付け和にしているのは、Python の hash() がプロセスごとに乱数化される (PYTHONHASHSEED) からです。bot を再実行しても同じ番組には同じ eventId が当たるよう、決定的に計算したい。
衝突確率は 1/65535 ≒ 0.0015%。家庭の予約規模(多くて数百件)なら無視できます。本当に衝突したらその時だけ +1 でリトライすれば良いですが、まだ起きたことはありません。
これで 830 は完全に消えて、同シリーズの連続予約も問題なく通るようになりました。
さらなる壁: 同 nasne 同 serviceId 同時間帯の制約
eventId をユニーク化しても、同じ nasne で同じ serviceId に対して同時間帯の予約が連続すると、たまに 500 が返る現象が残りました。これは API 側のレート制御か内部キューの問題のようで、簡単には乗り越えられない。
そこで、家にある nasne 3 台に分散させる作戦に切り替えました。元々シリーズ補完で予約したい数は多くないので、3 台を順繰りに使えば各台の負荷は少なくて済む。
def select_target_nasne(cand, existing_by_nasne, channels, margin_sec=60):
"""候補を投入できる nasne を選ぶ。
eventId をユニーク化したことで同 serviceId の重複制約は外れたため、
主に「時間帯のチューナー競合」だけ判定。"""
target = _resolve_channel_params(cand["channelName"], channels)
if not target:
return None, "channel unknown"
cand_start = dt.datetime.fromisoformat(cand["start"])
cand_end = cand_start + dt.timedelta(seconds=int(cand["duration"]))
options = []
for name in NASNE_TARGETS:
existing = existing_by_nasne.get(name, [])
# 同 nasne 内の時間帯重複はチューナー競合(nasne は 1 台 1 チューナー)
conflict = any(
_overlap(r, cand_start, cand_end, margin_sec)
for r in existing
)
if conflict:
continue
options.append((name, len(existing)))
if not options:
return None, "全 nasne 時間競合"
# 負荷の少ない nasne を優先
options.sort(key=lambda x: x[1])
return options[0][0], f"{options[0][0]} (load={options[0][1]})"
ポイントは 2 つ。
- 同 nasne 内の時間帯重複をチューナー競合として弾く: nasne は 1 台あたりシングルチューナー(地デジ/BS/CS の 3 波対応だが、1 台で同時録画できるのは 1 番組)。3 台あれば最大 3 番組同時録画できる
- 負荷が少ない(既存予約数が少ない)nasne を優先: 録画ファイルの偏りを防ぐ
500 が返ったときは別の nasne にフォールバックするロジックも入れています。
target_nasne, reason = select_target_nasne(c, existing_by_nasne, channels)
if not target_nasne:
print(f" skip: {reason}")
continue
resp = reserve_create(NASNE_IPS[target_nasne], payload)
if resp.get("errorcode") == 0:
print(f" ✅ {target_nasne}: {c['title'][:40]}")
existing_by_nasne[target_nasne].append(...)
elif http_500_or_retryable(resp):
# 別の nasne で再試行
fb_pick, _ = select_target_nasne(
c, existing_by_nasne, channels,
exclude={target_nasne}
)
...
結果
| 試行段階 | 成功率 |
|---|---|
| eventId=65535 のまま 1 台 | シリーズあたり 1 件で 830 |
| eventId ユニーク化、1 台に集中 | 連続で数件入るが時々 500 |
| eventId ユニーク化、3 台分散 | 安定運用 |
シリーズ補完の自動予約が止まらなくなりました。「みんなの鉄道」「ゲームセンターCX」「漫道コバヤシ」「世にも奇妙な物語」「グレートレース」「まんが日本史」あたりを自動補完対象に登録して、夜間に EPG を取り直しては差分を予約に流す運用に。
教訓
-
eventId=65535は ARIB の "event_id 不明" 予約値で、nasne の重複検知に使われている。EPG ベースの自動予約では、ここをユニーク値で埋めるとトラブルが減る - API のエラーコードは仕様書がないので観察するしかない。同じ番組で連続失敗するパターン、別シリーズを挟むと通るパターン、を分けて検証すると当たりがつく
- nasne を複数台持っているなら台数分散が効く。nasne は 1 台シングルチューナー(地デジ/BS/CS の 3 波対応だが同時録画は 1 番組)なので、3 台あれば最大 3 番組まで並列に予約・録画できる計算になる
-
hash()を再現性のあるキーに使うのは避ける。プロセスごとに乱数化されるので、自前の決定的な計算(重み付け和やhashlib)にする
おわりに
家にある nasne を「家庭内のオープンな録画 API サーバ」として扱えるようになると、テレビとの付き合い方がだいぶ変わります。
- 「○○のシリーズが #12 抜けている」と気づく前に、bot が勝手に再放送を取ってくれる
- 録ったまま忘れていたタイトルが、ダッシュボードの欠番リストから自然に発掘される
- 録画機 4 台が、ようやく "1 つの録画庫" として機能し始める
DLNA タイトル取得 → 自動予約 → ダッシュボード可視化、で家の録画運用は静かで穏やかになりました。
このアプローチを参考に、自宅の録画機をもう少し親密に扱ってみたい方の助けになれば幸いです。
関連記事
参考
- Mirakurun
- EPGStation
- ARIB STD-B10(PSI/SI 仕様、event_id=0xFFFF の意味)