Chienomi

LWMP (localwebmediaplayer) 2.0リリース!!

開発::util

LWMPの2.0.0をリリースした。

追加した機能としてはオーディオファイルのフォールバック(トランスコード)だが、コード構成的にはかなり大規模に手が入っており、設定の互換性もない。

CGIであるが故の苦しさを抱えた改修になったが、ローカル向けCGIだからこその方法で解決に持っていった。

設定ファイル

もともとのLWMPは設定の余地が非常に少ないアプリケーションだった。 このため基本的に値はLighttpdの設定ファイル上で環境変数として定義して渡すという方式を取っていた。

だが、開発が進むに従って設定項目が増えていき、これをカバーするためにstart-lwmp.rbが設定ファイルを扱うようになり、さらにstart-lwmp.rb内で環境変数に変換するようになった。

$config = {
  !["false", "no", "disable"].include?(ENV["USE_METADATA"]),
  !["false", "no", "disable"].include?(ENV["USE_THUMBNAIL"]),
  "ffprobe",
  ENV["VIDEOPLAYER"] || "default", # vidstack, vlitejs, plyr, fluid, default
  ENV["AUDIOPLAYER"] || "default", # vidstack, vlitejs, plyr, default

  # Generally, these settings should not be changed.
  ENV["MEDIA_ROOT"].sub(%r:/$:, ""),
  ENV["THUMB_ROOT"]&.sub(%r:/$:, "") || ENV["MEDIA_ROOT"].sub(%r:media/?$:, "thumb"),
  ENV["METADATA_ROOT"]&.sub(%r:/$:, "") || ENV["MEDIA_ROOT"].sub(%r:media/?$:, "meta"),
  ENV["LWMP_INSTANCE_NAME"]
}

ここでネックになるのがLighttpdは自分に渡ってきていない環境変数を参照するとエラーになるということだ。 このため、optionalな項目であっても埋めておく必要があり、start-lwmp.rbはこれを埋めるために設定ファイルを読んだあとに変換して環境変数を埋めるようになっていた。

ENV["REPO_DIR"] = config["repo"]
ENV["MEDIA_ROOT"] = spec["media"] or abort "Key 'media' is not found."
ENV["MEDIA_ROOT"] = ENV["MEDIA_ROOT"].sub(%r:/$:, "") + "/"
ENV["THUMB_ROOT"] = spec["thumb"]&.sub(%r:/$:, "") + "/"
ENV["METADATA_ROOT"] = spec["metadata"]&.sub(%r:/$:, "") + "/"
ENV["USE_METADATA"] = spec["use_metadata"] || ""
ENV["USE_THUMBNAIL"] = spec["use_thumbnail"] || ""
ENV["EXCLUDE_EXTS"] = spec["exclude"] || ""
ENV["SERVER_PORT"] = (spec["port"] or abort "Key 'port' is not found.").to_s
ENV["LWMP_INSTANCE_NAME"] = spec["name"] || profile
ENV["FFPROBE_CMD"] = config["ffprobe"] || "ffprobe"
ENV["VIDEOPLAYER"] = spec["videoplayer"] || config["videoplayer"] || "default"
ENV["AUDIOPLAYER"] = spec["audioplayer"] || config["audioplayer"] || "default"

exec((config["lighttpd_cmd"] || "lighttpd"), "-D", "-f", [config["repo"], "tools", "lighttpd", "lwmp.lighttpd.conf"].join("/"))

ここに至って問題を列挙すると

  • 設定項目ひとつに対して、似たような記述が3箇所あり、それとは別に設定本体がある
  • 場合によってはconfig.rbを触る必要があるが、これがGit管理されているのでpullするときに困る
  • 設定ファイルであるlwmp.lighttpd.confがソフトウェアの更新に従って更新をマージする必要がある

さらにいえば、もともとはlwmp.lighttpd.confを更新してLighttpdを起動するという想定があり、複数プロファイルを設定としてまとめるためにlwmp-start.rbがあるという関係だったはずなのだけど、既にlwmp-start.rbが必須な感じになってしまっている。

その状態でcache_rootdata_root、さらにはreport_decode_errorを追加する必要があり、この方式での管理に限界を感じたことから、lwmp-start.rbを前提にすることで設定ファイルで管理する方式にした。 結果的にはthumb_rootmeta_rootが削除されたためあまり変わらなかったのだが、整理されるべき問題ではあったため、正しいことだと思う。

ただし、直接的に設定ファイルを扱えるxxwmpと違い、LWMPの場合はhackが必要になる。 直接的に設定ファイルを扱えないのは、LWMPがCGIであるという理由もあるが、それ以上にLighttpdと設定を二重化しないという制約を課しているからだ。 サーバーのセットアップ(特にNginxが想定になっていてシステムグローバルにセットアップ)という重い準備作業があるxxwmpと、ポータブルな形式を持っているLWMPではターゲットが違う。 今回解消したいポイントに直結しているものでもあるため、Lighttpdが解釈できる環境変数を使う方式は変えたくなかった。

このため、設定ファイル(従来から存在した${XDG_CONFIG_HOME:-$HOME/.config}/reasonset/lwmp.yaml)はlwmp-start.rbがロードして整える。 これを7つの環境変数に入れてLighttpdに渡す。

ENV["REPO_DIR"] = config["repo"]
ENV["MEDIA_ROOT"] = spec["media_root"]
ENV["DATA_ROOT"] = spec["data_root"]
ENV["CACHE_ROOT"] = spec["cache_root"]
ENV["SERVER_PORT"] = (spec["port"] or abort "Key 'port' is not found.").to_s
ENV["RUBY_BIN_PATH"] = config["ruby"] || "/usr/bin/ruby"
ENV["CONFIG_PROFILE"] = YAML.dump(spec)

ここのポイントが$CONFIG_PROFILE。 CGIスクリプトから見える必要がある設定の値をlwmp-start.rbで再アッセンブルしてYAMLにして詰めている。

環境変数は〜Windows 8では2kiB未満という厳しい制限があるが、Linuxでは128kBが標準の単一の環境変数のサイズ制限。コマンドライン全体のサイズ制限は通常2MB。 FreeBSD/DragonflyBSDは全体で256kBまたは512kB。OpenBSD/NetBSDは256kB。単一変数のサイズ制限はない。 Illumosも単一制限はなく、全体制限は1MB。 (LWMPの動作環境ではないが、Windows 10/11は32kB。)

つまり、特殊なものを除けば設定ファイルくらい余裕で入る。 プロファイルの数にもよるけど、私が使っているものは3プロファイルで1kB程度。

さらに環境変数の値として含められない値はNULL文字。よってMarshal.dumpした値は入れられないが、YAMLであれば入る。

これにより、Lighttpdが参照する必要がある設定値は個別の環境変数に入れ、アプリが参照する必要がある設定値はまとめてYAMLにして環境変数に入れておくことで、アプリ側が必要とする設定値が変わった場合の影響範囲を抑制できる。 この手法は実際は問題ないのだが、非常に避けられがち。 まぁ、クラウドプラットフォームでは使えなかったりする手法だとか、そもそもほとんどの場合はファイルでいいという事情があるので、非常にニッチな状況でのhackの域を出ないのだけど。

なお環境変数は実行情報として露出するものなので、シークレットを含むことについては考慮すべき事項である。 古典的なセキュリティ一般教養としては、「やってはいけない」とされていることだ。 ただし、最近はシークレットを環境変数で渡すのが当たり前になっているということと、そもそもファイルにシークレットが書いてあるのならどっちが安全かは相当あやしいという事情から、そこまでめちゃくちゃ問題というわけではないけれども。

クライアントスクリプトの分割

従来、LWMPのクライアントスクリプトは別モジュールであるfetchrapper以外は全部main.jsに入れる形だった。 v1.3ではオプショナルなビデオプレイヤーライブラリ関連がわけられたが、これはあくまでオプショナルだからだ。

v2では新たにaudio-error-handler.js, current_state.js, msgwindow.jsにわけられた。

まず大前提として、クライアントサイドJavaScriptは「モジュール化を必要としない限り単一ファイルであるほうが良い」である。 そして、今回分割されたのも「モジュール化が必要となったから」だ。

今回の目玉機能である「デコードエラーの報告」はaudio-error-handler.jsによって実装されている。この機能をmain.jsから分割する必要があったのは、「オプショナルであるためにモジュール化された」ビデオプレイヤーライブラリも同じコードを必要としたからである。

こうすると連鎖的に問題が発生する。 audio-error-handler.jsはエラーを報告すべきかどうかを知るためにcurrentState.SystemInfoにアクセスする必要がある。 ところが、currentStateオブジェクトはmain.jsに閉じているためにaudio-error-handler.jsからは見えない。 これを共有するために分割する必要がある。

加えて、エラーが起きたこと、報告したことをユーザーに通知するため、msg_show()を使いたいが、これもmain.jsに閉じているために使えない。 このため、msg_show()も共有されるモジュールとして切り離す必要ができた。

さて、ここであまり知られていない知識である「モジュールのオブジェクトの共有」について話そう。

まずJavaScriptのimportによってロードされるモジュールは単一の実体である。 複数の場所から呼ばれた場合でも、単一のソースは一度しか読み込まれず、単一の実体を共有する。 この「ソース」の特定は正規化されたURLによって判定される。このため、

import { ex } from "./shared.js"

import { ex } from "././shared.js"

というコードがあったとしても2度ロードされることはない。

current_state.jscurrentStateオブジェクトをエクスポートするが、これはオブジェクトリテラルで定義されている。 つまり、

const currentState = {}
export {currentState}

という構造である。 ここで「実体が共有されている」ということが非常に重要になる。 これをロードするあるスクリプトが

currentState.x = 1

という操作を行った場合、同じくロードしている別のファイルからでもこの破壊的変更は見える。 単一のオブジェクトをモジュールに切り出すというのは、複数モジュールでオブジェクトを共有したい場合の汎用的な解であることを覚えておくと色々役に立つ。

なお、クライアントサイドJavaScriptにおいては常に値の共有方法としてwindowオブジェクトに入れるという方法も存在していることも覚えておくといいだろう。 モジュールに切り出す方法は、それよりはモジュールのルールに親しく、好ましく捉えられやすいだろう。

再生できない音楽問題

LWMPのそもそも出発点としては、「PC上の音楽をスマホで聴きたい」だ。 今やマルチなメディアプレイヤーになっているLWMPだが、もともとは音楽が第一、動画が第二だった。 ブックリーダーやテキストビューワは後付けだ。

にも関わらず、「メディアプレイヤー、オーディオプレイヤーでは再生できるのにブラウザだと再生できない」という音源が一定数あり、ちゃんと代替できずにいた。まさに画竜点睛を欠く。

これをLWMPの本来のコンセプトを保ったまま解決することはできなかった。 配信データはあくまでPC上のライブラリであり、メディアファイル配信にアプリケーションは介入しない、というものだ。

コンセプトの維持を断念するなら、最も安いのは「再生できないファイルだけバッチで再生できる形式にしたファイルに変換したものを配信する」である。 だが問題は「再生できないファイル」を事前に特定するのがかなり難しいということだ。ウェブブラウザによる差異もある。

なので実体験を良くするためにはクライアントが再生に失敗したことを通知し、それをバッチが処理する形式にしたい。 「クライアントはサーバーへの書き込みを行わない」というコンセプトからも外れてしまうが。

クライアント側ではaudio要素のハンドラとしてはこれである。

if (error_code === 3 || error_code === 4) {
  /* ... */
}

audio.addEventListener("error", audio_error_handler)

audio-error-handler.jsaudio要素のエラーをそのまま渡さないVidstackのためにちょっと複雑になっているが、それを除けばaudio要素のerrorイベントを拾ってe.target.error.codeを判定すれば良い。

これをサーバーがリストに保存し、バッチがこのリストを読んでffmpegで処理する。 ちょっとしたポイントとして、「リクエストパス」と「正規化されたファイルシステム上のパス」の2つの概念で管理されている。 リクエストパスはクライアント側で代替ファイルを要求するかどうかの判定を容易にするため、ファイルシステム上のパスを正規化するのは多重にエンコーディングするのを避けるため。

ffmpegでの処理はflacの場合は再エンコードのみだが、lossy音源の場合は「メタデータのみ削除」「Opusで再エンコード」の2段階になっている。 これは、ほとんどの場合は再生できない原因はメタデータにあり、メタデータを抜くだけなら劣化なしにできるためこれを試す。それでもエラーが出るならエンコードが必要で、エンコードする場合はflacにすれば劣化しないが、ファイルサイズが大きくなってしまうため256kb/sのOpusにすることで劣化控えめでサイズも抑制できるというのを狙っている。 まあ、128kbps/chのOpusで劣化を感じるのはかなり難しいしね。

バッチはこのマッピングをJSONファイルに保存する。 このJSONファイルを直接クライアントが読み込むことで代替ファイルをロードすべきかを判断する。

const mediaURI = function (path) {
  const origin = "/media/" + path.split("/").map(encodeURIComponent).join("/")
  if (currentState.transcode[origin]) {
    return currentState.transcode[origin]
  } else {
    return origin
  }
}

モバイルには問題が残る

実はモバイルの音楽再生には他にも問題がある。 それは、バックグラウンド再生を有効にしていてもスリープになるとブラウザがタスクキルされてしまい、再生が止まる、ということだ。

スリープまでの時間を伸ばせばある程度実用的に使えるが、オーディオプレイヤーのようにポケットに入れっぱなしというわけにはいかない。 OPPO端末であればGame Spaceのtoolsに放置ゲー向けのディスプレイを維持する機能があったりするのでこれを使えばなんかなったりするのだけど、前提としてブラウザをゲームとして登録する必要があるし、かなり苦しい。

ただこれをアプリ側で解決する方法はなく、せいぜいPWA化を考える程度の話だ。 それによって改善する可能性はほとんどないし、そもそもXXWMPはともかくLWMPはセキュアコンテキストを提供できないので成立しない。

一応、play(), pause()インターフェイスを追加するという案はあるのだが、この場合vLiteJSでは動かないいう割り切りが必要になる。 (実際にv2.1でこれが実装された。)

この問題はモバイル側の制御によるところなので、完全に解決することはできないだろう。どうしてもというのならモバイルアプリとして実装するしかないが、それは明らかにLWMPとしてやるべきことではない。

Faviconの追加

v2.1.0にてFaviconが追加された。

私はあまりローカルツールがウェブベースであったとしてもFaviconを入れることはしないのだけど、LWMPはショートカットを作りたいケースが多く、Faviconがないことが実用的にも多少不便な感じだったので、遅ればせながらという感じ。

今回も私がサクッと作ったものになる。

XXWMPへの移植

まだ作業中だが、LWMP2の機能はXXWMPにも移植予定。

ただ、LWMP2での更新の目玉が設定ファイルや構成まわりだったのだけど、ここはそもそもXXWMPは作りが違うのでそのまま移植できない。 基本的にアプリケーションサーバーとして起動するXXWMPは設定を持たせるのが容易なので、ずっと素直な作りになるが、別途作業が必要なのでちょっとかかる。

またベタ移植できない要素として、マッピングにファイルパスが書かれているという問題がある。 ファイルパスは他ユーザーに露出させるべきではないものなので、ユーザー別に空間を分けるようにする変更が必要だ。

まぁ、LWMPと比べXXWMPにとっては需要の低い機能なので、おいおいといったところ。