【Django】Apple MusicKit JS で Apple Music サインインの問題発生

事象

まず、MusicKit JS でプレイヤーを設置し曲をフル再生しようとすると Apple Music へのサインインが必要となります。

その際の正常なフローとしては:

  1. 自動ポップアップでサインイン画面が表示される
  2. ユーザーID & パスワード、そして6 桁のワンタイムコードを入力してサインイン
    • Mac の Safari では指紋認証のみでサインイン可
  3. アプリケーション(オリジン毎)による Apple Music へのアクセス許可を求められる
  4. 「許可」ボタンのクリックするとポップアップ画面が閉じる
  5. アプリケーションで曲のフルバージョンが再生できるようになる

今回、上記 4 の「許可」ボタンをクリックしてもボタンがグレーアウトされるだけで何も変わらない事象が起きた。

ポップアップウィンドウは閉じず、曲も再生されない。

もう一度曲を再生しようとしても再度別のポップアップが開いてサインインを要求されるという状況でした。

ちなみにサインインされてない状態だとフルバージョンではなく 30 秒バージョンの再生になります。

問題 1:「許可」ボタンクリックから進まない

まず、上記ステップで「許可」ボタンをクリックしてもボタンがグレーアウトするだけでその先に進まなかった問題から。

原因:Referrer-Policy の設定漏れ

諸々調べた結果、原因としてボタンクリック時の HTTP request に Referer 情報が含まれていなかった事が挙げられます。

Django ではデフォルトの Referrer-Policy が same-origin となりますが、same-origin の挙動は「同一オリジンのリクエストではオリジン、パス、クエリー文字列を送信します。オリジン間リクエストでは Referer ヘッダーを送信しません。」となっています。

つまり Apple の認証システムへの request 時に Referer ヘッダーが送信されず、その後の処理が行われなかったと思われます。

Django での Referrer-Policy 変更方法

Django で Referrer-Policy を変更するには settings.py で SECURE_REFERRER_POLICY を設定する必要があります。

おそらく「SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"」で良いと思います。

残る問題

これで「許可」ボタンをクリックすると、ポップアップウィンドウの中でアプリが表示されました。

仕方なくその中で曲を再生してみようとするとまた別のポップアップウィンドウが開きサインインを求められるループに入りました。

一旦グレーアウトで止まる問題は解消されましたが、別の問題が発生しただけです。

問題 2:曲のフル再生ができない

「許可」ボタンクリック後ポップアップウィンドウが閉じない問題はさておき、曲が再生できないのはなぜなのかを調べました。

原因 1/2:musicUserToken が付与されない

デベロッパーツールで request/response の内容を確認した結果、「許可」ボタンをクリックした際の Apple 側からの response で musicUserToken を受け取っているにもかかわらず、それがこちら側の musicKitInstance に渡っていないことが判明。

正常時はブラウザの Local Storage に保存され、無事認証が完了した musicKitInstance をデベロッパーツールで覗くと下記のように musicUserToken が入っているはずですが、今回はこれが「undefined」となっていました。

ちなみにコード上で半ば無理やり下記のようにアサインすると曲の再生ができていました。(music は musicKitInstance)

music.musicUserToken = "AoSKv/b0ED6YZzVuAEIvki4eOgFgeQYrCPaU+KSFV7fFdEozGUawOuKXxrzGyISRlHPfJlOzkclA+Nk4I0SbLI/f0tiZ++a+QYOG3EP+d935PvL+udndhJjfG/xe+ctry69X/rTtqgdr2VRCbqMgt/xzocg7gg2w/QPuTcA7YSpevglys3/2AsC69ofZKl8fHKkp04dyLuhxVZOC2h4PGXc+6chmnSHIxo7tp/VTv+IWr8+fhQ=="

原因 2/2:ポップアップ起動直前の URL が「#」で終わっている

正直かなり特殊な例かもしれませんが、自分のアプリ上、曲のタブを表示するボタンに a タグを使用し href="#" としていました。

つまり、曲を再生する前にこれをクリックするとメインウィンドウの URL が「http://localhost:8000/#」という形になりますが、これが良くなかったようです。

サインイン用のポップアップが開いて「許可」ボタンをクリックした後、ポップアップウィンドウの URL が更新されますが、その際メインウィンドウの URL と musicUserToken が「#」で繋がったものが入ります。

「http://localhost:8000/#ユーザートークン」の形で「#」につづけて musicUserToken が入ってくるんですが、上記で先に「#」が一つ入るため「http://localhost:8000/##ユーザートークン」という風に「#」が重複していました。

これが原因なのではと思い「#」を一つ消してブラウザ更新すると musicKitInstance に musicUserToken がアサインされ、URL からトークンの部分が消えました。(http://localhost:8000/ になる)

解決:URLに「#」を含まない様変更

アプリのコードを変更して「#」が付かないようにしたところ、「許可」ボタンクリックで musicUserToken は付与されるようになりました。

残る問題

ただ、メインウィンドウに戻らずポップアップ上にそのままアプリが表示される状況です。

その状態で曲はフル再生できますが、ポップアップが閉じてくれないのは問題です。

正直これに関しては解決できませんでした。

Django のテンプレートをそのまま Django の外に置いて python の http.server の Web サーバーで表示したところ無事にサインインできました。

なぜ内容が同じなのに Django で render されたものだとポップアップの挙動がおかしくなるのかは謎です。

と言うわけで、結局 html/css/js の部分は Django を通さず、API 部分だけ Django で書くことにしました。

それによってクロスオリジンのエラーを解決する必要がありましたが今のところ大丈夫そうです。

その他メモ書き

  • musicUserToken はオリジン毎の割り当て。
    • 127.0.0.1:8000 で取得した musicUserToken は localhost:8000 には共有されない。
    • 同じ http://127.0.0.1:8000 であれば http.server を使った html/js でも Django の runserver を使った Django テンプレートでも同じ musicUserToken で動く。

Apple MusicKit JS で簡単な音楽プレーヤーを作ってみる

Apple の MusicKit JS を使って、とりあえず簡単な音楽プレーヤーを作ってみます。

その前段階として Developer Token を作成してください。下記が参考になれば幸いです。

というわけで作ったのが下記の html ファイル。「Your Developer Token」となっている部分を自身で作成したものに置換すれば動くと思います。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />

<title>MusicKit JS</title>
</head>
<body>
<center>
    <p><img id="artwork"/></p>
    <p id="media-item-title">Display Media Item Title here</p>
    <p id="album-title">Display Album Title here</p>
    <p id="artist-name">Display Artist Name here</p>
    <p id="playback-time">Display Playback Time here</p>
    <p><button id="play">Play</button><button id="pause">Pause</button></p>
    <p><button id="previous-item">Previous</button><button id="next-item">Next</button></p>
</center>

<script src="https://js-cdn.music.apple.com/musickit/v1/musickit.js"></script>
<script type="text/javascript">
    // MusicKit JS の Promise を作成
    const setupMusicKit = new Promise((resolve) => {
        document.addEventListener('musickitloaded', function () {
            // MusicKit を定義
            MusicKit.configure({
                developerToken: 'Your Developer Token',
                app: {
                    name: 'My MusicKit JS App',
                    build: '2022.11.14'
                }
            })
            // MusicKit のインスタンスで Promise を resolve
            resolve(MusicKit.getInstance())
        })
    });

    // MusicKit.configure() が完了するのを待って残りを実行
    setupMusicKit.then(async (music) => {

        // 再生中の曲の情報を表示する HTML 要素を取得
        let currentSongName = document.getElementById('media-item-title');
        let currentAlbumName = document.getElementById('album-title');
        let currentArtistName = document.getElementById('artist-name');
        let playbackTime = document.getElementById('playback-time');

        // playbackTimeDidChange をトリガーにして再生時間の表示を更新
        music.addEventListener('playbackTimeDidChange', () => {
            playbackTime.textContent = music.player.currentPlaybackTime
        });

        // mediaItemDidChange をトリガーにして再生中の曲名の表示を更新
        music.addEventListener('mediaItemDidChange', () => {
            currentSongName.textContent = music.player.nowPlayingItem.title
        });

        // Play ボタンと player.play() の紐付け
        let playButton = document.getElementById('play');
        playButton.addEventListener('click', async () => {
            await music.player.play();
            // 曲を再生しつつ、紐づく情報の表示を更新
            currentAlbumName.textContent = music.player.nowPlayingItem.albumName
            currentArtistName.textContent = music.player.nowPlayingItem.artistName
        });

        // Pause ボタンと player.pause() の紐付け
        let pauseButton = document.getElementById('pause');
        pauseButton.addEventListener('click', () => {
            music.player.pause();
        });

        // Previous ボタンと player.skipToPreviousItem() の紐付け
        let previousButton = document.getElementById('previous-item');
        previousButton.addEventListener('click', () => {
            music.player.skipToPreviousItem();
        });

        // Next ボタンと player.skipToNextItem() の紐付け
        let nextButton = document.getElementById('next-item');
        nextButton.addEventListener('click', () => {
            music.player.skipToNextItem();
        });

        // アルバムを再生 Queue に登録
        await music.setQueue({
            album: '1542182291' // アルバム ID を渡す
        })

        // アルバム情報取得のプロミス
        let albumInfoPromise = music.api.album(1542182291)
        albumInfoPromise.then((albumData) => {
            let artworkImg = document.getElementById('artwork')
            // アルバムアートの URL を取得
            let artworkURL = MusicKit.formatArtworkURL(albumData.attributes.artwork, 100, 100)
            // アルバムアートを表示
            artworkImg.setAttribute('src', artworkURL)
        })
    })
</script>
</body>
</html>

大事なのは下記の記述で MusicKit JS を読み込んでいるところと。。。

<script src="https://js-cdn.music.apple.com/musickit/v1/musickit.js"></script>

下記の様な感じで MusicKit インスタンスの Promise を作っているところ。

    // MusicKit JS の Promise を作成
    const setupMusicKit = new Promise((resolve) => {
        document.addEventListener('musickitloaded', function () {
            // MusicKit を定義
            MusicKit.configure({
                developerToken: 'Your Developer Token',
                app: {
                    name: 'My MusicKit JS App',
                    build: '2022.11.14'
                }
            })
            // MusicKit のインスタンスで Promise を resolve
            resolve(MusicKit.getInstance())
        })
    });

この辺りは色々やり方があるんだと思いますが、とりあえず「musickitloaded」のイベントを待って MusicKit.configure() を実行する必要があります。

あとは公式ドキュメントを見ながら色々遊べればと。。。

ちなみに Apple Music API という、アルバムや楽曲などの情報の取得を主とした API もありますが、MusicKit API の MusicKit.API クラスがその役割を担っています。