Sato's Tech Memo

備忘録と情報共有を兼ねたメモです

Google HomeでiTunesの音楽ファイルを再生

pychromecastというPythonのライブラリを利用して、
ラズパイ経由でGoogle HomeからiTunesの音楽ファイルを再生する方式です。

こんな流れです。
f:id:satoko_szk:20180520152037p:plain

事前準備

あらかじめ、以下を実施。

  • ラズパイにWebサーバ構築
  • ラズパイにNASをマウント(NASでなくとも、iTunesの音楽ファイルがラズパイ上から見えればOK)

Google Homeに音楽キャストするPythonアプリを用意

(※Pythonプログラミングも、Chromecastのアプリ作るのも初めてなので、
  作法的に誤っているところはあるかもしれないです)

pychromecastのインストール

Google HomeはChromecastと同じプロトコルでキャスト可能。
pychromecastというライブラリを利用して、音楽キャストします。

ライブラリ詳細は以下。
GitHub - balloob/pychromecast: Library for Python 3 to communicate with the Google Chromecast.

For more options see the BaseController. For an example of a fully implemented controller see the MediaController.

と記載のあるとおり、メディアをキャストする関数仕様については、BaseControllerやMediaControllerのソース内のコメントを参照。

インストールは、以下コマンド。

pip3 install pychromecast

pip3(Python3のライブラリインストーラ)が未インストールの場合は、先にpip3をインストール。
(pychromecastはPython2は非対応)

sudo apt-get install python3-pip
冒頭処理
import time, shutil, os, sys, random, datetime, logging, re, threading
import lxml.etree
import urllib.parse
import pychromecast

# NASのiTunesファイルパス
# -- iTunes Librari.xml内に記載されているパス
NAS_PATH = 'file://localhost//xxxxx'
# -- ラズパイのマウント先
NAS_MOUNT_PATH = '/mnt/music'

# iTunes Library.xmlファイルパス
ITUNES_LIBRARY_PATH = NAS_MOUNT_PATH + '/iTunes/iTunes Library.xml'

# 音楽データ一時格納先
TEMP_DIR = '/home/pi/www/music/'
LOCAL_URL = 'http://192.168.101.1/music/'

GOOGLE_HOME_NAME = 'ぐーぐるほーむ'

LOCAL_URLのIPアドレスはローカルIPでOK。
GOOGLE_HOME_NAMEは、Google Homeアプリから見えるGoogle Homeの名前と同じものを指定すればOK。

起動関数
# 起動
# 引数:キーワード
def play(keyword):
	# xml読み込み
	global _original_doc
	_original_doc = lxml.etree.parse(ITUNES_LIBRARY_PATH)

	# 英大文字、小文字のゆらぎの対応用
	root = _original_doc.getroot()
	xmlStr = lxml.etree.tostring(root, encoding='utf-8')
	_original_doc = lxml.etree.fromstring(xmlStr)
	global _lower_doc
	_lower_doc = lxml.etree.fromstring(xmlStr.lower())

	# Google Home接続
	chromecasts = pychromecast.get_chromecasts()
	global _cast
	_cast = next(cc for cc in chromecasts if cc.device.friendly_name == GOOGLE_HOME_NAME)
	_cast.wait()

	global _md
	_md = _cast.media_controller
	
	if _cast.status.status_text.startswith('Now Casting'):
		_md.block_until_active()

		# 再生中の場合、ストップ
		if _md.status.player_is_playing:
			_logger.debug('再生中の曲を停止')
			_md.stop()
			_md.block_until_active()

	_playKeyword(keyword)

iTunes Library.xmlを読み込む。
Google Homeから渡されてくる英文字がどうなっているかわからないので、以下を用意。

  • _original_doc:オリジナル
  • _lower_doc:XML内の英文字を全て小文字にしたもの

その後、Google Homeに接続。
再生中の音楽があった場合は停止する。

ここで、停止しておかないと、このアプリが動いている最中に、新たに
「○○流して」
で割り込みがあった場合に、先に動いている方が割り込みをうまく検知できない。
(もしかしたら、判断条件が足りないだけかもしれない)

iTunes Library.xmlを検索して目当ての曲を探す

Google Homeへの接続が完了したら、
iTunes Library.xmlXPath検索して、目当ての曲を探す。

def _playKeyword(keyword):

	# 英小文字に揃える
	keyword = keyword.lower()
	
	# 英単語間以外のスペースを除去
	keyword = re.sub('(?<![A-Za-z])( )', '', keyword)
	keyword = re.sub('( )(?![A-Za-z])', '', keyword)

	# ゆらぎ変換
	yuragi = {"こっこ": "cocco", "ガーネットクロウ": "garnet crow"}
	if keyword in yuragi:
		keyword = yuragi[keyword]
	
	dict_list = []
	
	# タイトル検索
	xpath = './/dict/key[text()="name"]/following-sibling::string[position()=1 and text()="' + keyword + '"]/..'
	dict_list.extend(_getDictList(xpath))
	
	# アーティスト名検索
	xpath = './/dict/key[text()="artist"]/following-sibling::string[position()=1 and text()="' + keyword + '"]/..'
	dict_list.extend(_getDictList(xpath))
	
	# シャッフル
	random.shuffle(dict_list)
	
	# 再生
	for dict in dict_list:
		ret = _playDict(dict)
		if ret == 1 :
			#割り込み終了の場合
			return

# 指定のXPathに該当する音楽のlower_dictを返す
def _getDictList(xpath):
	dict_list = []
	for dict in _lower_doc.xpath(xpath):
		dict_list.append(dict)
	
	return dict_list

まず、Google Homeから渡されてきたキーワードをiTunes Library.xmlに記載されているものに合わせて変換。
ゆらぎ辞書は、とりあえずベタ書きで書いたけど、
そのうち、スプレッドシートに定義して、それを読みにいく方式にしたら使い勝手良さそう。

次に、キーワードでXML内を検索。英小文字で探すので、_lower_docの方を検索。
とりあえず、タイトルとアーティスト名の検索に対応。
iTunes Library.xmlの構造が以下のようになっていて、キー、値、キー、値、の羅列になっているから、XPathが少しめんどい。
まぁでも、iTunes Library.xmlに記載されている情報なら、何でも検索できる。

  • dict
    • key
    • dict
      • key : 'Track ID'
      • integer : トラックIDの値
      • key : 'Name'
      • string : タイトル
      • key : 'Artist'
      • string : アーティスト名
      • etc..

そうして、音楽リストをシャッフルして、再生処理に再生する音楽のdictノードの情報を渡す。
割り込み終了だったら、繰り返さないようにreturnを組み入れておく。

音楽情報の取得
# dictの音楽を再生する
def _playDict(dict):
	info = _getDictInfo(dict)
	if info is None:
		return 0
	
	return _playMusic(info)

# dictノードから、音楽ファイルの情報を返す
# path: ファイルパス
def _getDictInfo(dict):
	track_nodes = dict.xpath('./key[text()="track id"]/following-sibling::integer[1]')
	if len(track_nodes)==0 :
		return None
		
	track_id = track_nodes[0].text
	original_dict = _original_doc.xpath('.//key[text()="Track ID"]/following-sibling::integer[position()=1 and text()="' + track_id + '"]/..')[0]
	path = original_dict.xpath('./key[text()="Location"]/following-sibling::string')[0].text
	
	# パス文字列の置換
	# --- URLエンコーディング
	path =  urllib.parse.unquote(path)
	# --- マウント先のパスに変換
	path = path.replace(NAS_PATH, NAS_MOUNT_PATH)
	
	# 楽曲情報取得
	title = original_dict.xpath('./key[text()="Name"]/following-sibling::string')[0].text
	artist = original_dict.xpath('./key[text()="Artist"]/following-sibling::string')[0].text
	
	metadata = {'metadataType': 3, 'title': title, 'artist': artist}
	
	return {'path': path, 'metadata': metadata}

dictノードを解析して、再生する音楽の詳細情報を取得。
こちらは、iTunes Library.xmlに記載されている内容そのままを使うので、_original_docの方を参照。

音楽ファイルパスは、iTunes Library.xmlには以下のように定義されている。

<key>Location</key><string>file://localhost//LS-WSGL149/MyMusic/iTunes/iTunes%20Music/Every%20Little%20Thing/Time%20to%20Destination/All%20along.mp3</string>

これをファイル参照に使用できるように、URLデコーディングして、マウント先のパスに変換。

以下の仕様に合わせて、タイトルやアーティスト名を含んだメタデータを作成。
https://developers.google.com/cast/docs/reference/messages#MediaData

このメタデータを音楽再生するときにGoogle Homeに一緒にキャストすると、
Google Homeと連携しているAndroidバイスとかから、こんな風にタイトルやアーティスト名が見れる。
f:id:satoko_szk:20180520193518p:plain:w300

音楽再生
# 音楽再生
# 戻り値:通常終了->0, 割り込み終了->1
def _playMusic(info):
	# 再生する曲をWebサーバにコピー
	# 拡張子取得
	path = info['path']
	ext=path[path.rfind('.')+1:]
	
	if ext == 'm4p':
		# DRMファイルは再生できない
		return 0
	
	now = datetime.datetime.now()
	tempFileName = '%04d%02d%02d%02d%02d%02d.%s' % (now.year, now.month, now.day, now.hour, now.minute, now.second, ext)
	tempPath = TEMP_DIR + tempFileName
	shutil.copyfile(path, tempPath)
	
	_md.play_media(LOCAL_URL + tempFileName, 'audio/' + ext, metadata=info['metadata'], stream_type="BUFFERED")
	_md.block_until_active()

	# 曲の再生を待つ
	time.sleep(5)
	
	event = threading.Event()
	_md.register_status_listener(StatusListener(event))
	event.wait()
	
	if _md.status.idle_reason == 'FINISHED':
		# 再生終了
		os.remove(tempPath)
		return 0
	else:
		# 割り込み終了
		os.remove(tempPath)
		return 1

# 再生が終わるのを待つ
# MediaPlayerのステータス変更検知
class StatusListener:
	def __init__(self, event):
		self._event = event
		
	def new_media_status(self, status):
		# player_is_idle : 再生中・一時停止中=FALSE, 終了=TRUE
		if status.player_is_idle or status.media_session_id is None:
			# 再生終了したら
			self._event.set()

Google HomeにURLを渡して音楽流してもらう形なので、
音楽ファイルをWebサーバ下にコピーしてくる。

temp.mp3とかの固定の名前にすると、Google Homeがキャッシュ参照して、
延々同じ音楽を流してしまうので、日時を使った名前にしておく。

play_mediaしてから少し待たないと、速攻終了判定に行ってしまうので、
とりあえず5秒待っている。

再生終了したら、Webサーバ下にコピーしたファイルを削除。

再生終了判定の

status.media_session_id is None

は、外からキャスト停止された場合用。

コマンドラインからも呼び出せるようにする

最後に、

if __name__ == '__main__':
	args=sys.argv
	play(args[1])

これで、

python3 play_music.py 'キーワード'

で、実行できる。
(pythonCGIから呼ぶなら不要だけど)

Webサーバ処理の用意

PHPでもPythonでも何でもいいのだけど、
POSTなりGETなりでパラメタ受け取って、
音楽キャストアプリを起動するWebサーバ処理を用意。

とりあえず、PHPで用意した。

<?php 
$keyword = $_POST['keyword'];
header("Access-Control-Allow-Origin: *");

$command ='/usr/bin/python3 play_music.py "'.$keyword.'" > /dev/null &';

exec($command);
?>
 /dev/null &

を付けて、突き放し処理。

IFTTT登録

最後に「iTunesで○○流して」って言ったら音楽を流してくれるIFTTTレシピを登録。
iTunesで流しているわけじゃないけど、何か接頭辞をつけないといけないから。
f:id:satoko_szk:20180520170449p:plain:w200
f:id:satoko_szk:20180520170915p:plain:w200

はまったところ

  • キャストしようとしたところでエラー。pychromecastが利用しているzeroconfライブラリでType Errorだか何だか。
    • 「zoroconfに問題があって、新しいバージョン入れれば解決する」という情報が公式に載っていたので、ライブラリのバージョン問題かと思って、アップデートしたり再インストールしたり何やりしたけど、うまくいかず。
    • 結局、pychromecastのページをよく読んだら、Python2は非対応になった、と書いてあるのに気づいて、Python3で動かしたらうまく動作した。
  • Webサーバ経由で動かそうとしたところで、pychromecastがImport Error
    • コンソールからの動作はうまくいったけれど、Webサーバ経由で動かそうとしたところでエラー。
    • Apacheの実行ユーザとpychromecastをインストールしたユーザが異なることが原因だった。Apacheの実行ユーザを変更することで解決。
  • shutil.filecopyで文字コード関連のエラー
    • 音楽ファイルのパスに日本語が含まれているのだけど、これがひっかかって、shutil.filecopyがうまく動かない。PythonのLoggerで出力しているログは問題なく日本語出力されているけれど、Apacheのerror.logはエスケープ文字で出力されている。
    • Python文字コードまわりが複雑、という記事が色々あったので、そちら側の原因の可能性を追って試行錯誤してみたけど、結局は、Apache文字コード設定が原因だった。
    • Python文字コードは、Python2と3で色々変わって、Python3では結構シンプルになったっぽい。

この後の拡張

以下ができると良い。

  • スプレッドシートでゆらぎ管理
  • アートワークの表示
  • Chromecastとも連携して、歌詞表示できるようにしてもいいかも
  • 起動時に曲を検索した結果をGoogle Homeに喋らせる。「見つかりませんでした」とか「何曲見つかりました」とか。

初めてPython使ってみた所感

  • ライブラリ充実していて楽チン
  • インデント方式が意外に良い
    • 最初は「なんかCOBOLみたい・・・」と思えて、とっつきの悪さを感じさせるけど、括弧でくくる必要がないのが、テキストエディタでチャカチャカッと書くときに楽で良い。
  • コマンドラインで会話方式で実行できるのも便利
    • XPathとか正規化処理とかを色々試してみるのがやりやすかった。
  • javadoc的なものがなくて、ライブラリの仕様調べるのに手間取った
    • pychromecastの関数仕様を調べるのにjavadoc的なものをなかなか見つけられず、 ソースコードの中にコメントで記載されている、ということに気づくのに時間がかかった。(ソースコードの中にコメントで記載する、というのがPython界隈の風習、という認識で合っているだろうか?)



workファイル
ダイアグラムデータ