ABEMA の番組表を Omarchy のバーに出すウィジェットを作った

ABEMA の番組表を Omarchy のバーに出すウィジェットを作った

ABEMA の全チャンネルで「いま何が放送中か」を、バーのアイコンから 1 枚のパネルで見られるようにした。番組をクリックするとそのまま ABEMA で開く。

なぜ作ろうと思ったのか?
自分でもわからないんだ・・・なんとなく作ったら面白そうぐらいにしか・・・

どんなもの

Omarchy のバーウィジェット(Quickshell プラグイン)。中身は QML 1 枚・JS 1 枚・シェルスクリプト 1 本・Python 1 本で、全部で 1,800 行ちょっと。

  • grid(既定)は abema.tv/timetable と同じ形。チャンネルが横に並び、時間が縦に流れて、「いま」の位置に横線が入る
  • listing は新聞のテレビ欄。開始時刻ごとに行をまとめて、各行に進捗バーが付く
  • 番組ブロックを押すとそのチャンネルを再生、チャンネル名(列の頭)を押すとそのチャンネルだけの番組表がおよそ 1 日分、あらすじ付きで出る
  • チャンネル名/番組名でフィルタ、h l j k で移動、Enter で視聴、Esc で戻る
  • 更新はパネルを開いている間だけ 1 分ごと。閉じている間は何も取りにいかない

インストールは 1 行。

omarchy plugin add https://github.com/polidog/omarchy-abematv.git --enable

映像は出せない

先に書いておくと、このウィジェットは自前で再生しないし、パネルの中に映像も出さない。

ABEMA のライブ HLS は abematv-license:// という独自方式で暗号化されていて、復号できるのは本家のプレイヤーだけ。シェル側で描画できるものが無い以上、番組表とランチャーに徹するしかなかった。

そのかわり、プレイヤーのウィンドウは 1 枚に保つことにした。Chromium の --app モードは URL ごとに新しいウィンドウを開くので、チャンネルを 2 つ選ぶとウィンドウが積み上がる。なので開く前に、すでに出ている ABEMA のウィンドウを閉じる。

hyprctl -j clients |
  jq -r '.[] | select(.class | startswith("chrome-abema.tv")) | .address'

chrome-abema.tv* のクラスが付くのは web app のウィンドウだけなので、abema.tv を開いている普通のブラウザのタブは巻き込まない。どうせ差し替わるプレイヤーだけを閉じて、新しいものを同じ場所・同じルールの下に出す、という割り切り。

出る場所を固定したい人は class:^(chrome-abema\.tv) に Hyprland の window rule を書けばいい。

番組表はトークンの向こう側にあった

最初、チャンネル名を押すと「いま流れている 1 番組のカード」が出るだけだった。グリッドがすでに言っていることを、1 クリック奥でもう一度言っているだけ。

理由ははっきりしていて、1 分ごとに叩いている api.abema.io/v1/broadcast/slots日時指定を受け付けないから。あれは「いま」しか答えない。トークンが要らないのはありがたいが、そのぶん現在しか知らない。

全体の番組表は /v1/timetable/dataSet にある。2 週間分。ただしこちらはベアラトークンが要る。ABEMA のクライアントが初回起動時に自分で発行している匿名の端末トークンで、中身は端末 UUID とアプリに埋まっている鍵での HMAC チェーン。アカウントもログインも要らないし、再生や DRM には一切触らない。

bin/abematv-listing はそれと同じものを作って、番組表ごと ~/.cache/omarchy-abematv にキャッシュする。出力は broadcast/slots と同じ形にした。パネル側に 2 つ目のフォーマットを覚えさせたくなかったので、既存の Model.js がそのまま読める形で吐かせている。

$ bin/abematv-listing abema-news
{"slots":[{"id":"...","title":"...","startAt":...}, ...]}

TTL は番組表が 1 時間、トークンが 1 日。番組表は gzip で 1MB あるので、そう何度も取りにいくものではない。ちなみに Accept-Encoding: gzip を付けないとこのエンドポイントは 406 を返す。

パネルの見た目も変えて、チャンネルのカードは 1 行 1 番組のリストになった。放送中の行はライブのチャンネルへ、まだ始まっていない行はその番組のページへ飛ぶ(参加できるストリームがまだ無いので)。出演者の行はここで消えた。番組表にはクレジットのブロックが入っていないので、埋めるものが無い。

ハマったこと

QML の binding とハンドラで、どっちが先に走るか

チャンネルを開くと、どのチャンネルを開いても番組表が 1 行しか出ない、という状態が続いた。

原因は、ヘルパーを起動するプロセスの commanddetailChannel への binding で書いていたこと。

// これがまずかった
Process {
  id: listingProc
  command: [pluginDir + "bin/abematv-listing", detailChannel]
}
onDetailChannelChanged: {
  if (detailChannel !== "") listingProc.running = true
}

binding は プロパティ変更の通知で再評価されるが、onDetailChannelChanged も同じ通知で呼ばれる。どちらが先に走るかによって、まだ 1 つ前のチャンネル名を持ったままの command でプロセスが起動する。 初回は空文字なのでヘルパーが拒否し、それが「このチャンネルには番組表が無い」と読めて、すでに画面にある 1 行にフォールバックしていた。

直し方は、binding をやめてハンドラの中で起動の直前に代入するだけ。

onDetailChannelChanged: {
  if (detailChannel === "") return
  listingProc.command = [pluginDir + "bin/abematv-listing", detailChannel]
  listingProc.running = true
}

順序に依存するものを 2 つ置かない。それだけの話だった。

マーケットプレイスのレビューで言われたこと

Omarchy のプラグインマーケットプレイスに出したら、セキュリティレビューで「ネットワークから返ってきたものを信用しすぎ」と指摘された。もっともだったので全部直した。

  • curl--proto '=https'--max-filesize を付けた。上限はバイトが生まれる場所で効かせる。パネルが全部受け取ってから測るのでは遅い。-L は最初から渡していないので、ABEMA の外へのリダイレクトは追わない
  • ヘルパー側はリダイレクトを明示的に拒否した。 urllib は元のヘッダを新しいリクエストにコピーするので、302 が返ってきたら答えたホストにベアラトークンを渡すことになる。ここで一番盗む価値があるのはそれ。URL は 2 つとも定数なので、そもそも追う先など無い
  • ボディは上限の 1 バイト先まで読んで、そこで拒否する。圧縮されたものも展開後も同じ。小さな gzip が展開するとギガバイトになる、というやつがスワップではなく上限で止まる
  • キャッシュにはそのトークンが入っているので、読み書きの両方で O_NOFOLLOW で開く。差し替えられたシンボリックリンクは「どこか別の場所への道」ではないし、信用する相手でもない

tools/test-listing でこの 4 つを全部見ている。

あと、リポジトリのルートに preview.png が無いとマーケットプレイスが汎用のカードにフォールバックする、というのも指摘で知った。置いたら向こうで勝手に最適化してくれる。

まとめ

やっていることは「番組表を出して、押したら本家のプレイヤーに投げる」だけ。映像を出せない以上それ以上のことはできないのだけど、バーから 1 クリックで「いま何やってるか」が見えるのは思ったより気持ちがいい。

非公式のプラグインなので、ABEMA / サイバーエージェントとは関係ありません。プラグインは omarchy-shell の中で素のまま動くので、入れる前にコードを読んでください。1,800 行しかないので。

MIT。

https://github.com/polidog/omarchy-abematv

カテゴリ