Amazon Echo で Google Drive 上の mp3 を 再生する (その2)

# はじめに

[前回の記事](https://qiita.com/hokonin/items/7c497a9537032dbb7a46) のコード部分を説明します。
AudioPlayer のリファレンスは[こちら](https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/audioplayer-interface-reference.html)。

# AudioPlayer の使い方（考え方）

プレイリストの曲をすべて再生することだけを考えるなら、最初に全ての曲をキューに登録してしまうという使い方もあります。しかし後に実装する次の曲、前の曲、シャッフル再生などは任意のタイミングで指示され、登録していたキューが無駄になることもあるため、以下の考え方で使用します。

* キューには 1 曲しか登録しない
* 次の曲、前の曲、曲の終了、などのリクエストにより、次に再生する曲をキューに入れ替え登録する
  （再生中の曲をそのままに登録する方法と、再生中の曲を含めて入れ替える方法があり使い分ける）
* 機能に必要な情報はトークンに埋め込み、次回リクエスト時にトークンから必要な情報を取得する

## トークンについて

トークンについては[リファレンス](https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/audioplayer-interface-reference.html#play-params)の `audioItem.stream.token` に記述がありますが、再生中の曲（ストリーム）を識別するための 1024 文字以内の任意の文字列です。このトークンに情報を埋め込むことで、DB 等に再生中の曲の情報を保存したりせずに必要な機能を実現します。
今回、トークンはコロン ( : ) で区切った以下の形式で組み立てることにします。

`プレイリストファイルID` : `乱数seed` : `再生曲番号` : `ループ再生フラグ` : `リピートフラグ` : `曲情報テキスト`

|項目|説明|
|---|---|
|プレイリストファイルID|再生中のプレイリスト(json) の Google Drive 上のファイルID です。これにより再生開始後は `root.json` を見る必要がなくなります。|
|乱数seed|シャッフル再生用の項目です。0 が順次再生、0 以外がシャッフル再生です。**未実装部分**|
|再生曲番号|現在何曲目を再生しているかの番号です。|
|ループ再生フラグ|`loop` なら全体ループ再生、空文字列なら一度限り再生を示します。**未実装部分**|
|リピートフラグ|`repeat` なら次回も同じ曲を再生(1回のみ)、空文字列なら次の曲を再生します。**未実装部分**|
|曲情報テキスト|曲名を訪ねた際に読み上げるテキストです。コロンを含めないよう注意が必要です。**未実装部分**|

# コード概説

## getRequestTypeOrIntentName() (index.js 11行目～）

今回のスキルでは、RequestType か IntentName で処理を分岐しますが、その分岐をしやすくするため適切な方の値を返却するだけの関数です。

```js
const getRequestTypeOrIntentName = handlerInput => {
  return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
    ? Alexa.getIntentName(handlerInput.requestEnvelope)
    : Alexa.getRequestType(handlerInput.requestEnvelope);
};
```

## LaunchRequestHandler() (index.js 17行目～)

「Alexa、プレイミュージックを開いて」などとスキルの起動のみが行われた場合の処理です。
プレイリスト名の発話を促す返答を行い発話待ちにします。

```js
const LaunchRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
    },
    handle(handlerInput) {
        const speakOutput = 'プレイリスト名を教えてください。';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};
```

## PlayMusicHandler() (index.js 30行目～)

個別に見ていきます。

### canHandle(), async handle()

canHandel() で `PlayMusicIntent` か `AudioPlayer.PlaybackNearlyFinished` の場合に `true` を返却することで、それらのリクエストが発生した場合に handle() が呼び出されます。
未実装のリクエストは 94行目の `CancelAndStopIntentHandler()` に記述してあるので、実装が完了したものは随時こちらの配列に移していく形になります。
handle() の基本的な処理の流れは以下になります。

* 再生用パラメータを初期化 (38行目～44行目)
* リクエストに応じて再生用パラメータを調整 (45行目～)
* 再生する曲の url とトークンを作成 (85行目～87行目)
* `addAudioPlayerPlayDirective` で再生を指示 (88行目)

#### PlayMusicIntent (index.js 46行目～)

`PlayMusicIntent` は [2.3.4. インテント](https://qiita.com/hokonin/items/7c497a9537032dbb7a46#234-%E3%82%A4%E3%83%B3%E3%83%86%E3%83%B3%E3%83%88) で設定したサンプル発話に合致する発話がなされた場合にリクエストされます。
再生パラメータの初期値は初回再生時と同じであるため変更しませんが、プレイリストのファイル ID が未取得のため、`{playlist}` スロットに格納されたプレイリストの `id` をキーに、`root.json` からプレイリストファイルのファイル ID だけ取得します。

```js
    case('PlayMusicIntent'): {
        // intent.slots.{スロット}.resolutions 配下にユーザーが選択したスロットが格納される
        // ユーザーが発話したプレイリスト名に対応する Google Drive 上の json ファイルを取得するが、
        // スロット値の ID を利用することで、発話の揺らぎに対応しつつ対象ファイルを一意にしている
        const resolvedSlot = handlerInput.requestEnvelope.request.intent.slots.playlist.resolutions.resolutionsPerAuthority[0].values;
        if (resolvedSlot === undefined) {
            const speakOutput = 'プレイリストを認識できませんでした。もう一度お願いします。';
            return handlerInput.responseBuilder
                .speak(speakOutput)
                .reprompt(speakOutput)
                .getResponse();
        }
        const rootjson = await getJson(makeDriveUrl(rootFileId));
        playlistId = rootjson.playlists[resolvedSlot[0].value.id];
        break;
    }
```

#### AudioPlayer.PlaybackNearlyFinished (index.js 62行目～)

`AudioPlayer.PlaybackNearlyFinished` は曲の終わりが近づいてキューに次の曲を追加できるようになった時に自動的にリクエストされてきます。
behavior の初期値の `REPLACE_ALL` は再生中の曲が終了してしまうため、再生中の曲に影響を与えずにキューを置き換える `REPLACE_ENQUEUED` に変更します。
また、再生中の曲のトークンから再生パラメータを切り出し、次の曲にするため再生曲番号を `+1` します。この段階ではプレイリストに何曲あるか分からないため `+1` した結果が最後の曲を超えているかどうかは 75 行目で考慮しています。

```js
    case('AudioPlayer.PlaybackNearlyFinished'): {
        // 曲の終了間際の場合は再生中の曲をそのままにするため REPLACE_ENQUEUED でキューを置き換える
        behavior = 'REPLACE_ENQUEUED';
        const audioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        [playlistId, seed, track, loop, repeat, ] = audioPlayer.token.split(':');
        track = (+track) + 1;
        break;
    }
```

#### プレイリスト取得 (index.js 75行目～)

`root.json` またはトークンから取得したプレイリストのファイル ID からプレイリストデータを読み込みます。
ここで全曲数がわかるため、次の再生曲番号が最後の曲を超えていたらキューをクリアして終了します。「再生を終了します」などのアナウンスをしたいところですが、`AudioPlayer.Playback～` では `.speak` による発言ができない仕様になっています。

```js
        // 全て再生したら終了する
        const playlist = await getJson(makeDriveUrl(playlistId));
        if (track >= playlist.length) {
            return handlerInput.responseBuilder
                .addAudioPlayerClearQueueDirective('CLEAR_ALL')
                .withShouldEndSession(true)
                .getResponse();
        }
```

#### 再生指示 (index.js 84行目～)

情報が揃ったため、再生指示の応答を返却します。
`idx` はシャッフル再生時に `track` とずれるため用意しています。

```js
    // addAudioPlayerPlayDirective を利用して AudioPlayer に音楽再生の指示を応答する
    const idx = track;
    const url = makeDriveUrl(playlist[idx].id);
    const token = `${playlistId}:${seed}:${track}:${loop}:${repeat}:${playlist[idx].info}`;
    return handlerInput.responseBuilder
        .addAudioPlayerPlayDirective(behavior, url, token, offset, null)
        .getResponse();
```

## getJson (index.js 180行目～)

指定した (Google Drive の) URL の json データを読み込む関数です。
`uc?export=download&id=` を使うと直接ファイルにアクセスできているように見えますが、一度リダイレクトされています。
`https` モジュールは、リダイレクトを追跡するようにはなっておらず、追跡できる `request` モジュールは標準では使えませんので、`https` モジュールを使って簡易的にリダイレクトを追跡する処理を組みます。

```js
const getJson = (url) => new Promise((resolve, reject) => {
    Https.get(url, (res) => {
        res.setEncoding('utf8');
        let json = '';

        if(res.statusCode === 301 || res.statusCode === 302) {
            resolve(getJson(res.headers.location));
        } else {
            let bufs = [];
            res.on('data', (chunk) => json += chunk);
            res.on("end", () => {
                try {
                    resolve(JSON.parse(json));
                } catch (err) {
                    console.log(err);
                    reject(err);
                }
            });
            res.on('error', (e) => {
                console.log(e.message);
                reject(e);
            });
        }
    });
});
```

# おわりに

無駄に長くなっていまいましたが以上になります。つまづく箇所はリダイレクトの追跡くらいでしょうか。
[その3](https://qiita.com/hokonin/items/e55b68f26da2f6100545) では未実装機能を実装します。

# 参考

* Stack Overflow ： [How do you follow an HTTP Redirect in Node.js?](https://stackoverflow.com/questions/7323932/how-do-you-follow-an-http-redirect-in-node-js/13390241)