Icecastとliquidsoapを用いたメディアストリーミングサーバーの簡単な作り方

# 概要
最近、ラジオ系のWebアプリを作成したのですが、そのときに使用した音声ストリーミングサーバーIcecastと、そのSourclientと呼ばれる音源供給用のコントロールツールであるliquidsoapの日本語の情報が極端に少なかったため、公式ドキュメントにある簡単な構成例を日本語でまとめたいと思います。今回はUbuntu 16.04での例となりますが、MacでもIcecastとLiquidsoapが導入できれば利用可能です。(Windowsでもいけるらしい)

[Icecast公式ドキュメント](https://icecast.org/)
[liquidsoap公式ドキュメント](https://www.liquidsoap.info/doc-1.3.6/)
[ソースコード](https://github.com/75-akimas/myIcecast)
## Icecastのセットアップ
####Icecastとは

ストリーミングメディアサーバーを容易に建てることができるフリーソフトウェアで、インターネットラジオや個人的なジュークボックスなどを運営するのに使用できます。主な機能としては、Mount pointと呼ばれるラジオで言うところのチャンネルのような、複数のエンドポイントを動的に作成できたり、クライアントが再生する際の認証や、管理画面からリスナーを閲覧、追い出しなど様々な機能が搭載されているため、複雑なサービスにも使用できるのではないでしょうか！

####インストール
```
$ sudo apt update
$ sudo apt install icecast2
```

aptでインストールできるIcecastは少しバージョンが低く、HTTPS等の設定が必要な場合は別の方法でインストールする必要があるので、以下を参照してください。
https://wiki.xiph.org/Icecast_Server/Installing_latest_version_(official_Xiph_repositories)

####設定ファイル
```xml:icecast.xml
<?xml version="1.0"?>
 <!--
 icecast の設定で、各環境ごとに直す必要があるかもしれない箇所を以下に示す。
 ・authentication => admin-user, authentication => admin-password
 管理画面へのログインユーザー名と管理画面へのログインパスワード
 
 ・paths => basedir
 basedir は security => chroot を 1 に設定したときだけ有効になる。
 このとき paths の webroot や adminroot は basedir からの相対パスになる。
 logdir は別扱いで icecast を起動したカレントディレクトリからの相対パスになっているようだ。
 
 ・paths => webroot, paths => adminroot, paths => logdir
 security => chroot が 0 のとき、以上のディレクティブは絶対パスで記述する。
 
 ・security => changeowner
 icecast を立ち上げたときにユーザーやグループを指定したものに変更できる。
 
 ・listen-socket => ssl
 SSL を有効にした環境ならば 1 に設定する必要がある
 
 ・paths => ssl-certificate
 SSL を有効にした環境ならば ssl の certificate のフルパスを記述する必要がある。
 
 ・relay
 この設定には書いていないが、relay を設定するのなら記述する必要がある
 
 ・mount
 以上の設定でデフォルトのマウントポイントの振る舞いは記述されていることになる。
 特定のマウントポイントで特別な振る舞いをする必要があるのなら mount タグで記述する。
 
 詳しくは公式のドキュメントを読むこと。
 http://icecast.org/docs/icecast-2.4.1/config-file.html
 -->
 <icecast>
     <!-- Misc Server -->
     <hostname>localhost</hostname>
     <location>Japan</location>
     <admin>icemaster@localhost</admin>
     <fileserve>1</fileserve>
 
     <!-- Limits -->
     <limits>
         <clients>10000</clients>
         <sources>10000</sources>
         <queue-size>524288</queue-size>
         <client-timeout>30</client-timeout>
         <header-timeout>15</header-timeout>
         <source-timeout>10</source-timeout>
         <burst-size>65535</burst-size>
     </limits>
 
     <!-- Authentication -->
     <authentication>
         <!-- <source-user>source</source-user> -->
         <source-password>hackme</source-password>
         <!-- <relay-user>relay</relay-user> -->
         <relay-password>hackme</relay-password>
         <admin-user>mr.icecast.admin</admin-user>
         <admin-password>hackme</admin-password>
     </authentication>
 
     <!-- クライアントとSourceclientが接続するポート -->
     <listen-socket>
         <port>8001</port>
     </listen-socket>
 
     <!-- Global HTTP headers -->
     <http-headers>
         <header name="Access-Control-Allow-Origin" value="*"/>
     </http-headers>
 
     <!-- Path -->
     <paths>
 	<!-- <basedir>任意のパス</basedir> -->
         <logdir>任意のパス</logdir>
         <webroot>任意のパス</webroot>
         <adminroot>任意のパス</adminroot>
         <ssl-certificate>任意のパス</ssl-certificate>
     </paths>
 
     <!-- Logging -->
     <logging>
         <accesslog>access.log</accesslog>
         <errorlog>error.log</errorlog>
         <playlistlog>playlist.log</playlistlog>
         <loglevel>3</loglevel>
         <logsize>10000</logsize>
     </logging>
 
     <!-- Security -->
     <security>
         <chroot>0</chroot>
 	<!--
         <changeowner>
             <user>任意のユーザー</user>
             <group>任意のグループ</group>
         </changeowner>
 	-->
     </security>
 </icecast>
```

httpsで公開したい場合は以下のようにクライアントが接続するポートはSSLを有効化し、source clientから操作を受ける内部通信のみのポートはSSLを無効のままで2つのポートを設定すれば良いでしょう。この場合は<paths>下の<ssl-certificate>にSSL証明書の秘密鍵と公開鍵の両方を含むファイルへのパスを記述する必要があります。

```xml:icecast.xml
     <!-- クライアントが接続するTCPポート -->
     <listen-socket>
         <port>8000</port>
         <ssl>1</ssl>
     </listen-socket>
     
     <!-- Sourceclientからの操作を受けるポート Port -->
     <listen-socket>
         <port>8001</port>
     </listen-socket>
```

また、リスナーの追加に認証を行いたい場合(ログインユーザーのみに再生を許したいなど)は、以下の設定を追加し、それぞれのエンドポイントでBool値を返すようにすることで実装が可能です。

```xml:icecast.xml
<!-- Mount -->
     <mount type="default">
         <authentication type="url">
             <option name="listener_add" value="リスナー追加の認証のためのURL"/>
             <option name="listener_remove" value="リスナー削除の認証のためのURL"/>
             <option name="timelimit_header" value="session-timelimit:"/>
         </authentication>
     </mount>
```

これでIcecastはSourceclientから受けた命令どおりにストリーミングを開始することが可能になりました。実行は以下のコマンドを実行してください。

```
$ icecast2 -c icecast.xml
```
## liquidsoapのセットアップ

####Sourceclientとは
冒頭でも簡単に説明しましたが、[liquidsoap](https://github.com/savonet/liquidsoap)はIcecastに対し、音声および映像メディアを供給するソフトウェア及びそのスクリプト言語です。Icecastではこれをsource clientと呼び、Icecastが開発しているものとして、IceSがあります。Icesはとても単純な機能しかもっておらず、冒頭で述べた機能である動的なMount pointを作成する場合には別のsource clientを使用する必要があります。
一方liquidsoapはサードパーティ製のsource clientで、動的なMount pointや、曲のフェードイン・アウト、Telnetを用いた対話的な操作、曲再生時・終了時の通知など、様々な機能を有しています。あまり活発的とはいえないIcecastのコミュニティなのですが、liquidsoapは公式ドキュメントのページが一新されたり、現在も更新が続いているリポジトリです。

####インストール
```
$ sudo apt install liquidsoap
```
Icecastと同様にaptがサポートしているliquidsoapはバージョンが低いです。最新版を使用したい場合は公式ドキュメントのInstallation guideに従ってください。Opamを使用したことがあれば、容易に導入できると思います。

###liquidsoapの使い方
liquidsoapを使ってできることはたくさんあるのですが、説明しきれないのでこの記事はドキュメントにある簡単な例を述べるのみにします。

#####めっちゃ簡単なやつ
```ocaml:jingle_and_songs.liq
set("log.file.path","/tmp/<script>.log")
set("log.stdout", true)
set("server.telnet.port", 8002)
set("server.telnet", true)
set("server.timeout", -1.)

# The file source
songs = playlist("/path/to/some/files/")

# The jingle source
jingles = playlist("/path/to/some/jingles")

# We combine the sources and play 
# one single every 3 songs:
s = rotate(weights=[1,3], [jingles, songs])

# We output the stream to an icecast
# server, in ogg/vorbis format.
output.icecast(%vorbis,id="icecast",
               fallible=true,mount="my_radio.ogg", 
               host="localhost", port=8001, password="hackme",
               )
```
ここではsongsには/path/to/some/files/以下の複数の曲、jinglesには/path/to/some/jinglesという1つの曲が割り当てられており、songsの曲が三曲再生されるたびにjinglesが1回再生されるようなプレイリストを作り、Icecastにoggフォーマットで送信しています。また音楽のフォーマットは一般的な物であれば使用可能だと思います。

#####実行
```
$ icecast2 -c icecast.xml
$ liquidsoap jingle_and_songs.liq
```
このコマンドによりスクリプトが実行され、Mount Pointとしてmy_radio.oggと指定しているので、http://localhost:8001/my_radio.ogg にこのプレイリストが再生されるエンドポイントが作成され、音楽聞けるようになります。

#####動的にMount pointを作る例
```ocaml:dynamic_playlist.liq
set("log.file.path","/tmp/<script>.log")
set("log.stdout", true)
set("server.telnet.port", 8002) # Telnet通信に使用するポート
set("server.telnet", true)
set("server.timeout", -1.)

# First, we create a list referencing the dynamic sources:
dyn_sources = ref []

# This is our icecast output.
# It is a partial application: the source needs to be given!
out = output.icecast(%mp3,
                     host="localhost",
                     port=8001,
                     password="hackme",
                     fallible=true)

# Now we write a function to create
# a playlist source and output it.
def create_playlist(uri) =
  # The playlist source
  blank = audio_to_stereo(single("blank.mp3")) #Playlistが空なときに流す無音の音楽
  playlist = request.equeue(id="#{uri}_sec")
  playlist = fallback([playlist,blank])
  output = out(mount=uri, playlist)
  dyn_sources := list.append( [(uri,playlist),(uri,output)], !dyn_sources )
  "Done!"
end

# And a function to destroy a dynamic source
def destroy_playlist(uri) =
  # We need to find the source in the list,
  # remove it and destroy it. Currently, the language
  # lacks some nice operators for that so we do it
  # the functional way

  # This function is executed on every item in the list
  # of dynamic sources
  def parse_list(ret, current_element) =
    # ret is of the form: (matching_sources, remaining_sources)
    # We extract those two:
    matching_sources = fst(ret)
    remaining_sources = snd(ret)

    # current_element is of the form: ("uri", source) so
    # we check the first element
    current_uri = fst(current_element)
    if current_uri == uri then
      # In this case, we add the source to the list of
      # matched sources
      (list.append( [snd(current_element)],
                     matching_sources),
       remaining_sources)
    else
      # In this case, we put the element in the list of remaining
      # sources
      (matching_sources,
       list.append([current_element],
                    remaining_sources))
    end
  end

  # Now we execute the function:
  result = list.fold(parse_list, ([], []), !dyn_sources)
  matching_sources = fst(result)
  remaining_sources = snd(result)

  # We store the remaining sources in dyn_sources
  dyn_sources := remaining_sources

  # If no source matched, we return an error
  if list.length(matching_sources) == 0 then
    "Error: no matching sources!"
  else
    # We stop all sources
    list.iter(source.shutdown, matching_sources)
    # And return
    "Done!"
  end
end


# Now we register the telnet commands:
server.register(namespace="dynamic_playlist",
                description="Start a new dynamic playlist.",
                usage="start <uri>",
                "start",
                create_playlist)
server.register(namespace="dynamic_playlist",
                description="Stop a dynamic playlist.",
                usage="stop <uri>",
                "stop",
                destroy_playlist)
output.dummy(blank())
```

上記のコードはスクリプトの実行中に動的にPlaylist(Mount point)を作成、削除を行うことができるような例となっています。複雑な動作をさせようとしているわけではありませんが、削除の部分に関数型言語っぽさが現れて、読みづらいかもしれません。

まずはPlaylistの作成をしているcreate_playlist関数を見ていきます。
ここではMount pointを作成していると同時に、動的にPlaylistに曲を追加することを可能にしています。先程の簡単な例ではスクリプトを実行したときにフォルダから曲を読み込んでいたため、動的な追加や、好きな順番に曲を追加することはできませんでした。

```ocaml
def create_playlist(uri) =
  # The playlist source 
  blank = audio_to_stereo(single("blank.mp3")) #Playlistがからな場合に流す無音の音楽
  playlist = request.equeue(id="#{uri}_sec")
  playlist = fallback([playlist,blank])
  output = out(mount=uri, playlist)
  dyn_sources := list.append( [(uri,playlist),(uri,output)], !dyn_sources )
  "Done!"
end
```
まず、Playlistの入力列(キュー)に曲がない場合にコケてしまわないよう無音の音楽をblankとしています。
次に引数の文字列をidとしたキューを作成しています。実行時はここに音楽を追加するとFIFOで曲が順番にIcecastに送信されます。
次にfallback関数を使ってplaylistが空のときにblankを再生するようにします。
そして、Icecastにマウントポイントの名前とプレイリストを指定して出力するように設定します。
最後にplaylistとoutputをそれぞれをplaylistsというリストで保持します。

削除の関数destory_playlistは詳しくは説明しませんが、playlistsから指定された名前のplaylistとoutputを削除しています。

```ocaml
server.register(namespace="dynamic_playlist",
                description="Start a new dynamic playlist.",
                usage="start <uri>",
                "start",
                create_playlist)
server.register(namespace="dynamic_playlist",
                description="Stop a dynamic playlist.",
                usage="stop <uri>",
                "stop",
                destroy_playlist)
```
最終的にcreate_playlistとdestory_playlistをTelnetで外部から実行できるコマンドとして登録しています。

#####実行
まずスクリプトを実行します。

```
$ icecast2 -c icecast.xml
$ liquidsoap dynamic_playlist.liq
```
次に、別のターミナルからTelnetでliquidsoapへ接続します。

```
$ telnet localhost 8002
```
正常にliquidsoapが起動できていれば、接続が確認できると思います。実際にどんなコマンドがあるかみてみましょう。

```:Telnet
help
Available commands:
| dummy.autostart
| dummy.metadata
| dummy.remaining
| dummy.skip
| dummy.start
| dummy.status
| dummy.stop
| dynamic_playlist.start <uri>
| dynamic_playlist.stop <uri>
| exit
| help [<command>]
| list
| quit
| request.alive
| request.all
| request.metadata <rid>
| request.on_air
| request.resolving
| request.trace <rid>
| uptime
| var.get <variable>
| var.list
| var.set <variable> = <value>
| version

Type "help <command>" for more information.
END
```
このように使用可能なコマンドが列挙され、その中に先程作成したものが確認できます。

```
| dynamic_playlist.start <uri>
| dynamic_playlist.stop <uri>
```

試しに実行してみましょう。

```:Telnet
dynamic_playlist.start HELLO
Done!
END
```
この状態でhttp://localhost:8001/HELLO を開くと無音が再生されている（？）ことが確認できると思います。では、次に曲を流してみましょう！
曲をHELLOのプレイリストに追加したいのですが、どうしたら良いでしょうか？もう一度helpを実行してみましょう。

```:Telnet
help
Available commands:
| HELLO.autostart
| HELLO.metadata
| HELLO.remaining
| HELLO.skip
| HELLO.start
| HELLO.status
| HELLO.stop
| HELLO_sec.insert <pos> <uri>
| HELLO_sec.move <rid> <pos>
| HELLO_sec.pending_length
| HELLO_sec.primary_queue
| HELLO_sec.push <uri>
| HELLO_sec.queue
| HELLO_sec.remove <rid>
| HELLO_sec.secondary_queue
以下略
```
先ほどと違い、キューHELLOにまつわるコマンドが追加されている事がわかります。今回はキューに曲を追加したいので、pushを実行しましょう。

```:Telnet
HELLO_sec.push /path/to/mp3
1
END
```
曲ファイルを指定するとすぐにhttp://localhost:8001/HELLO で再生されました！
他にも様々なコマンドが用意されているので試してみてください。

## まとめ
ここまでこの記事ではIcecastとそのsource clientであるliquidsoapの簡単な使用法について解説してきました。詳しい内容や応用についてはぜひ公式のドキュメントを参照して下さい。メディアストリームサーバーの需要がどれほどあるかはわかりませんが、興味を持っていただけたとしたらうれしいです。
また、こういった記事を書くのは初めてなのですが、簡単な解説のみを行うつもりが思いの外テキストの量が多くなってしまい、読者さんからしても読みづらかったかもしれません。訂正、意見等ありましたらコメントにて宜しくおねがいします。