06.mpv player作成

ブラウザとmpdコマンドを利用した音楽プレーヤー(mpd player)を作ったが、 今度は、mpvとsocket通信を利用した音楽プレーヤーを作ってみた。

    機能:
  1. nas上のファイル再生
  2. youtubeの再生
  3. net radioの再生
  4. DLNAの再生

●mpv下調べ

先に作成したmpd playerのユーザー・インターフェースに合わせるようにmpv内部コマンドを調べた。

まずは、mpv最新版をサイト記載の手順で導入する。

CGI Pythonの有効化

pythonをCGIとして利用する方法が分かったのでApacheの設定をする。これでPHPを使用しなくても良くなった。

※CGI有効化と拡張子.pyのCGIが動作出来るように設定 $ sudo nano /etc/apache2/mods-available/mime.conf 219行目の #を削除し、最後に .pyを追加して上書き保存 変更前: #AddHandler cgi-script .cgi 変更後: AddHandler cgi-script .cgi .py ※CGIを動かすディレクトリの設定 $ sudo nano /etc/apache2/conf-available/serve-cgi-bin.conf 11行目の /usr/lib/cgi-bin/ を /home/pi/public_html/cgi-bin に変更 12行目の /usr/lib/cgi-bin を /home/pi/public_html/cgi-bin に変更 上書き保存 変更前: ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ <Directory "/usr/lib/cgi-bin"> 変更後: ScriptAlias /cgi-bin/ /home/pi/public_html/ <Directory "/home/pi/public_html"> ※CGI モジュールを有効にする $ sudo a2enmod cgid *無効にする場合 $ sudo a2dismod cgid $ sudo systemctl restart apache2

python プログラム側のCGI対応

※pythonプログラム(mpv_player.py)側のCGI対応抜粋 : import re import cgi : if __name__ == '__main__': form = cgi.FieldStorage() recv = form.getvalue('cmd','') # cmdの値が無い場合は2nd パラムで''(null)になるようにしているが、無くても良さそう re_quotation = re.compile('".*?"|\'.*?\'') # duble quotかsingle quotで囲まれている argv_pre = re_quotation.sub('', recv).split() + re_quotation.findall(recv) # quot無しと有りを分ける re_quot_esc = re.compile(r'"|\\') argv = [] for a in argv_pre: argv.append(eval(re_quot_esc.sub('',repr(a)))) # duble quotとesc(\)を削除する argv.insert(0,'cmd') # 内部処理用に追加しているが本来無くても良い # argc = len(argv) st = main(argv) print('Content-Type:application/json\n\n') # browserへの返信用 print(json.dumps(st)) # browserへの返信データ #※上記2つのprint()以外にコーディング内で使用するとブラウザからのアクセスでサーバエラーになるので注意! ※ コマンドプロンプトより単体テストするには $ ./mpv_player.py cmd=play%200 # play listの0番を再生させる例で%20はスペースを意味する $ ./mpv_player-test.py cmd=lsinfo%20'"/mnt/music/2L%20sample"' # '"..."'の様にsingle/duble quotで二重にしてparamを渡す ※ ブラウザからのCGIアクセスURL 上記CGI設定で行ったディレクトリ(/home/pi//public_html)配下のアドレスで指定する 例: /home/pi/public_html/test/test.pyの場合 http://server_name/cgi-bin/test/test.py

●mpv起動コマンド

mpvを起動してアイドル状態でIPC待機させるpython作成(参考:1番参照)

※python プログラム import subprocess as proc ipc_server = "/home/pi/.config/mpv/mpvsocket" # プロセス間通信用フォルダInter Process Communication(IPC) mpv_argv = [ "mpv", "--idle=yes", "--no-video", "--cache=no", "--really-quiet", "--no-input-default-bindings", "--no-input-terminal", f"--input-ipc-server={ipc_server}", ] openmpv = '' openmpv = proc.Popen(mpv_argv) # mpvを起動してidle状態にする

●mpvとコマンドやデータの受け渡し

mpvへコマンドの送信やプロパティの読み込みや書き込みはIPC通信で行う。
コマンドの種類は参考:2番,3番参照のこと。

※リストの一部 def _compose_message(message): """辞書型メッセージをjson形式に変換して返す。 """ data = json.dumps(message, separators=",:") return data.encode("utf8", "strict") + b"\n" def _parse_message(data): """json形式を辞書型メッセージに変換して返す。 """ data = data.decode("utf8", "ignore") # (結果となる Unicode から単に文字を除く) return json.loads(data) def _send_command(command, timeout = 10): """mpvと通信を行う。 """ cmd = _compose_message(command) sock = socket.socket(socket.AF_UNIX) sock.settimeout(timeout) try: sock.connect(ipc_server) except FileNotFoundError as e: rcv = e except Exception as e: t = traceback.format_exception_only(type(e), e) t1 ='{"error":"' + t[0].replace('\n','') + '"}\n' rcv = t1.encode() else: try: sock.send(cmd) rcv = sock.recv(512*10) except Exception as e: rcv = e sock.close() return rcv def command(*args): global TIMEOUT """mpvプロセスに対して1つのコマンドを実行し、その結果を返す。 """ return _send_command({"command": list(args)}, TIMEOUT) def get_property(name): """プロパティ 'name' の値を返す。 """ return _parse_message(command("get_property", name)) def set_property(name, value): """プロパティ 'name' の値を設定する。 """ return command("set_property", name, value)

●DLNA操作

netの情報を参考にDLNAのファイルをアクセスできるようにした。
詳細は参考:4番参照のこと。

※DLNA通信特有の部分 # ブロードキャストでDLNA Media Serverを探す def searchServer(): global timeout msearch_request_lines = ( 'M-SEARCH * HTTP/1.1', 'HOST: 239.255.255.250:1900', 'MAN: "ssdp:discover"', 'MX: 1', # seconds to delay response 'ST:ssdp:all', # search target '', '' ) svr_list = [] sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(1) msearch_request_body = '\r\n'.join(msearch_request_lines) # cr/lfでないとだめだった sock.sendto(msearch_request_body.encode('utf-8'), ('239.255.255.250', 1900)) while True: try: res, device = sock.recvfrom(512) response = res.decode('utf-8') except socket.timeout: break if 'ContentDirectory' in response: # DLNAサーバの判別 for line in response.split('\n'): if 'LOCATION:' in line: line = line.replace('LOCATION: http://','').replace('\r','') srv = {'server':'', 'port':'', 'desc':''} srv['server'] = line[:line.find(':')] srv['port'] = line[line.find(':')+1:line.find('/')] srv['desc'] = line[line.find('/'):] svr_list.append(srv) sock.close() return svr_list # DLNA Media Serverと通信 def sockStream(svr, msg): global timeout s = socket.socket( socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect( ( svr['server'], int(svr['port'])) ) s.send(bytes(msg, 'utf-8')) chunk = '' msg_length = 512 try: while len(chunk) < msg_length: tmp = s.recv(512*10) if b'content-length:' in tmp.lower(): # message lengthを得る leng = re.search('content-length: (.+)\\r\\n', tmp.decode(), re.IGNORECASE).group(1) msg_length = int(leng) + len(tmp.decode()) if not tmp: break # 最後まで受け取ったら受信終了 chunk += tmp.decode() except socket.timeout as e: if chunk == '': print(e) s.close() return chunk # DLNA Media Serverの情報を得る def getServerInf(): svr_list = searchServer() if len(svr_list) == 0: return None # server_listに情報追加 for svr in svr_list: msg_base = [ f'GET {svr["desc"]} HTTP/1.1', 'Content-Type: text/html; charset="UTF-8"', 'Content-Length: 0', f'Host: {svr["server"]}:{svr["port"]}', 'Connection: close', '', '' ] # 最後にダミー2つは必須(EOFを意味する?) msg = '\r\n'.join(msg_base) # cr/lfでないとだめだった ret = sockStream(svr,msg) svr['friendlyName'] = xmlExtraction('friendlyName', ret) # 全てのserviceを抽出する ret = ''.join(ret.splitlines()) # WG2200HPの改行コードを削除 services = re.findall(r'<service>(.+?)</service>', ret) for s in services: if 'ContentDirectory' in s: svr['serviceType'] = xmlExtraction('serviceType', s) svr['controlURL'] = xmlExtraction('controlURL', s) break # 'ContentDirectory'以外のサービスは不使用 svr['id'] = 0 # default 大親フォルダID return svr_list # DLNA Media Serverからフォルダやファイル情報を得る def soapRequest(svr, flag='BrowseDirectChildren'): # *** flag: BrowseDirectChildren(default)/BrowseMetadata *** global timeout req_base = [ '<?xml version="1.0" Encoding="UTF-8" ?>', '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">', '<s:Body>', f'<u:Browse xmlns:u="{svr["serviceType"]}">', f'<ObjectID>{svr["id"]}</ObjectID>', f'<BrowseFlag>{flag}</BrowseFlag>', '<Filter>*</Filter>', '<StartingIndex>0</StartingIndex>', '<RequestedCount>0</RequestedCount>', '<SortCriteria></SortCriteria>', '</u:Browse>', '</s:Body>', '</s:Envelope>', '', '' ] # 最後にダミー2つは必須(EOFを意味する?) req = '\r\n'.join(req_base) # cr/lfでないとだめだった req_len = len(req) message = [ f'POST {svr["controlURL"]} HTTP/1.1', 'Content-Type: text/xml; charset="UTF-8"', f'Content-Length: {req_len}', f'Host: {svr["server"]}:{svr["port"]}', f'SOAPAction: \"{svr["serviceType"]}#Browse\"' 'Connection: close', '', f'{req}' ] msg = '\r\n'.join(message) # cr/lfでないとだめだった return sockStream(svr, msg)

●ブラウザの操作

ブラウザ側はjavascriptでサーバ側とやり取りする。主な画面操作は以下の通り。

§
mpv playerのブラウザ画面
§


01.音楽プレーヤーのインデックス画面

02.mpv-playerのtop画面

03.mpv-playerのnasファイル再生画面

04.mpv-playerのyoutube再生画面

05.mpv-playerのradio再生画面

06.mpv-playerのDLNA再生画面

●所感

CGIでpythonが使えてPHPを介さず直にできるのでスッキリした。DLNAはMedia Serverの種類により応答データの形式が異なるので注意が必要だった。 音に関しては、やはりMPDの方が若干明瞭な感じで良いように思う。
また、今回の結果を踏まえて、CGIとDLNAの機能、及びブラウザ画面は先に作成したMPD Playerにも適用し統一させた。

【参考】
  1. JSON IPC
  2. List of Input Commands
  3. Properties
  4. How to browse a Digital Living Network Alliance (DLNA) media server

作成:2022/12/27