はじめに
サブスクで曲聴いてると、タイトルは疎かアーティスト名さえよく知らんまま「これ好き~~~~」とか言ってて何かと不便です。
かと言ってクライアントの画面を出し続けるのは邪魔ですし、それじゃあ「楽曲情報を取得してDBに投げてPHPで表示できるようにしとけば、余ってるAndroid端末か何かで適当にいつでも見れるんじゃね」ということで、まずは楽曲情報の取得ができないか検証してみました。
結論から言うと、不可能ではないことがわかりましたが百点満点の利便性、確実性は望めない感じです。
今回使うあれそれ
- C#
- UWP
- デスクトップ版AmazonMusic
- Windows10
WPFではなくUWP
デスクトップ用に作るのでWPFでええじゃろと思っていましたが、今回必要な機能が残念ながらUWPの方にしか無いようです。
一応UWPの機能をWPFで使う方法もあるらしいものの、面倒くさそうだし何やら制約もあるとのことで調査と検証の手間を惜しんで今回はUWPでいきます。
まずはタイトルなどの基本的な情報を取得してみる
さて、早速本題に入りますが悲しいことにAmazonMusicには公開APIがありません。[以前は非公式のものがあったそうですがAmazon側の仕様変更により現在は使えないらしいです]("https://qiita.com/yumayamada1029/items/d556e8d227418bcfb8c0#cover-art-archive" 音楽系API(主に音楽配信サービス)まとめ)。
ではどうするのかと言いますと、デスクトップ版のAmazonMusicが出して来る通知を取得してわちゃわちゃします。
事前準備(AmazonMusic側)
再生中の曲が変わったタイミングでAmazonMusicから通知が来るように設定してあげます。
デスクトップ版のAmazonMusicを起動したら、右上のアイコンを右クリックしてメニューを展開、「設定」を選択しましょう。
※デスクトップ版のAmazonMusicはそれなりの頻度でアップデートされているので、今後仕様が変わることもあるかもしれません。2021年11月現在での手順です。
事前準備(Windows側)
設定>システム>通知とアクションからAmazonMusicの通知を許可します。
※まだ一度も通知が来ていない場合は一覧に無いかもです。
事前準備(UWPプロジェクト側)
VisualStudioでUWPのプロジェクトを作成したら、まずソリューションエクスプローラーからPackage.appxmanifestを開きます。
「機能」タブに移動し「ユーザー通知リスナー」のチェックボックスを選択状態にしましょう(これが前述の「WPFには無い機能」です)。
この設定手順を踏まないと通知情報を取得できません。
※初回起動時に通知取得許可の是非を問う画面が出て来るので、許可してあげてください。
実装
検証用コード全文
<Page
x:Class="AmazonMusicObserver.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:AmazonMusicObserver"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Width="1200"
Height="750"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid>
<TextBox x:Name="logTextBox" Margin="10,10,10,10" Text="" TextWrapping="Wrap" IsReadOnly="True" />
</Grid>
</Page>
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading;
using System.Threading.Tasks;
using Windows.Foundation.Metadata;
using Windows.UI.Notifications;
using Windows.UI.Notifications.Management;
using Windows.UI.Xaml.Controls;
namespace AmazonMusicObserver {
public sealed partial class MainPage : Page {
//ターゲットとするAppInfo.AppUserModelId
const string TGTID = "Amazon.Music";
//コントロールアクセス用オブジェクト
static TextBox logTB;
public MainPage() {
this.InitializeComponent();
logTB = this.FindName("logTextBox") as TextBox;
MainAsync();
}
/// <summary>
/// 通知一覧から、対象外であるAppInfo.AppUserModelIdを持つ通知を除去する。
/// また、利便性のため最新通知が先頭へ来るように逆順ソートする。
/// </summary>
/// <param name="origin">加工前の通知一覧</param>
/// <returns>加工後の通知一覧</returns>
static List<UserNotification> removeNonTgtNotification(IReadOnlyList<UserNotification> origin) {
List<UserNotification> temp = new List<UserNotification>();
foreach (var item in origin.ToArray()) {
if (item.AppInfo.AppUserModelId.Equals(TGTID)) {
temp.Add(item);
}
}
temp.Reverse();
return temp;
}
/// <summary>
/// AmazonMusicからの通知であることを前提に、通知のテキスト要素を受け取って解析し画面等の更新処理を走らせる
/// </summary>
/// <param name="temp">通知のテキスト要素</param>
static void updateWork(IReadOnlyList<AdaptiveNotificationText> temp) {
try {
logTB.Text += "\nタイトル:" + temp[0].Text; //2021年11月現在、楽曲のタイトルが格納されている
logTB.Text += "\nアーティスト:" + temp[1].Text; //2021年11月現在、楽曲のアーティスト名が格納されている
logTB.Text += "\nアルバム:" + temp[2].Text; //2021年11月現在、楽曲のアルバム名が格納されている
logTB.Text += "\n-------";
Debug.WriteLine(temp[0].Text);
Debug.WriteLine(temp[1].Text);
Debug.WriteLine(temp[2].Text);
} catch (Exception e) {
logTB.Text += "\n" + e.Message;
}
}
//AsyncなMain
private static async Task<int> MainAsync() {
//////////////////////////////////////////////////////
//通知リスナーにアクセスできるかどうかの諸々のチェック
//////////////////////////////////////////////////////
if (!ApiInformation.IsTypePresent("Windows.UI.Notifications.Management.UserNotificationListener")) {
Debug.WriteLine("IsTypePresent: NG");
return -1;
}
Debug.WriteLine("IsTypePresent: OK");
UserNotificationListener listener = UserNotificationListener.Current;
Debug.WriteLine("listener: ");
Debug.WriteLine(listener);
UserNotificationListenerAccessStatus accessStatus = await listener.RequestAccessAsync();
Debug.WriteLine("accessStatus: ");
Debug.WriteLine(accessStatus);
if (accessStatus != UserNotificationListenerAccessStatus.Allowed) {
Debug.WriteLine("アクセス拒否");
return -1;
}
Debug.WriteLine("アクセス許可");
//////////////////////////////////////////////////////
//通知リスナーにアクセスできるかどうかの諸々のチェックここまで
//////////////////////////////////////////////////////
//初回の比較用に通知の履歴を取得
IReadOnlyList<UserNotification> oldList = await listener.GetNotificationsAsync(NotificationKinds.Toast);
//無関係な要らん通知を除去しつつ逆順ソートで成形
oldList = removeNonTgtNotification(oldList);
//通知の一覧を取得できるだけで、良い感じのイベントも差分を取得するメソッドも存在しないらしい。
//ので、ひたすら通知を取得&比較し続けて自力で差分を見付ける
while (true) {
IReadOnlyList<UserNotification> notifs = await listener.GetNotificationsAsync(NotificationKinds.Toast);
//無関係な要らん通知を除去しつつ逆順ソートで成形
notifs = removeNonTgtNotification(notifs);
//今回取得した通知一覧と前回取得した通知一覧を比較、それぞれの最新同士でタイムスタンプを比較して差分の有無を確認
if (DateTimeOffset.Compare(notifs[0].CreationTime, oldList[0].CreationTime) > 0) {
//差分あり。最新通知のオブジェクトを取得
NotificationBinding toastBinding = notifs[0].Notification.Visual.GetBinding(KnownNotificationBindings.ToastGeneric);
if (toastBinding != null) {
//テキスト要素を取得して更新処理に渡す
IReadOnlyList<AdaptiveNotificationText> textElements = toastBinding.GetTextElements();
updateWork(textElements);
}
} else {
Debug.WriteLine("更新無し");
}
//比較用リストを更新
oldList = notifs;
Thread.Sleep(1000);
}
}
}
}
動作確認
こんな感じで、通知と同じ内容でテキストボックスが更新されます(著作権的なあれでジャケット部分はぼかしました)。
問題点
AmazonMusicからの通知に依存しているので、
- 通知内容の構成に変更があれば対応する必要がある
- 通知が表示されないと何もできない
- AmazonMusicのウィンドウが非アクティブでないと通知は出ない(2021年11月現在)
- 単純に再生が終わって次の曲が始まったケース、またはキーボードによるメディアコントロールで次の曲の再生を始めたケースでのみ通知を表示できる
- 必然的に、クリックで開始した曲では絶対に通知は表示されない
- 通知を出したくないケースに対応できない
- なんかたまに通知出ないことあるし、通知が出るべきときに100%確実に通知が出る保証は無いよね……?
- AmazonMusicのウィンドウが非アクティブでないと通知は出ない(2021年11月現在)
ジャケットを取得してみる
とまあ色々と難ありですが、基本的な楽曲情報を取得できなくはないことがわかりました。
次はジャケットだとかカバーアートだとかアルバムアートなどと呼ばれる画像を取得します。
通知からテキスト要素をさっくり取得できたので画像も簡単に取れるだろうと思っていましたが、なんか無理でした。
どなたか情報持っていればコメントしていただければ幸いです。
なんか無理でしたで終わってはあれなのでプランB、「AmazonMusicで再生してるんだからAmazonのストアページに行けば画像あるよな作戦」でいきます。
いい感じに無理矢理度が上がってきましたね。
まずはAmazonのストア検索結果ページURLを生成する
とりあえず適当に検索してみましょう。検索の精度を上げたいので、「デジタルミュージック」で絞り込みます。
さて、気になるURLは……
でした。
&__mk_ja_JP=カタカナ&ref=nb_sb_noss
は無くても検索結果に変化が無いっぽいです(たぶん)。
手入力した検索ワードはそのままURLに入る(実際にはURLエンコードを経ていますが)ので、ここを通知から取得した楽曲情報にしてしまえば検索結果ページのURLになると考えられます。
通知のテキスト要素をURLエンコードし、ちゃんと表示できるURLを生成するメソッドが下記です(最終的なコード全文を後述するので、何をしてるか興味無い人は読み飛ばしちゃって大丈夫です)。
static Uri createStoreSearchResultPageURI(IReadOnlyList<AdaptiveNotificationText> temp) {
//2021年11月現在、取得した楽曲情報からデジタルミュージックに限定したAmazonMusic検索結果ページURLを下記コードで生成可能
string paramStr = temp[0].Text + " " + temp[1].Text + " " + temp[2].Text;
return new Uri("https://www.amazon.co.jp/s?k=" + System.Web.HttpUtility.UrlEncode(paramStr) + "&i=digital-music");
}
ストア検索結果ページURLからページソースを取得して画像URLを取り出す
画像の表示を内部でどうこうしてたら詰みなので、まずは本当に外側から画像URLが取れるのか確認します。
みんな大好きデベロッパーツールで表示すると……
やったぜ!
外部からでも画像URLを取得できることがわかりました。
2021年11月現在、下記の構成になっているようです。
<img
alt=""
class="s-prefetch-image"
src="https://m.media-amazon.com/images/I/71g2PZQJQJL._AC_UY218_.jpg"
srcset="https://m.media-amazon.com/images/I/71g2PZQJQJL._AC_UY218_.jpg 1x, https://m.media-amazon.com/images/I/71g2PZQJQJL._AC_UY327_FMwebp_QL65_.jpg 1.5x, https://m.media-amazon.com/images/I/71g2PZQJQJL._AC_UY436_FMwebp_QL65_.jpg 2x, https://m.media-amazon.com/images/I/71g2PZQJQJL._AC_UY545_FMwebp_QL65_.jpg 2.5x, https://m.media-amazon.com/images/I/71g2PZQJQJL._AC_UY654_FMwebp_QL65_.jpg 3x"
/>
srcset
で3倍までのサイズ別で画像URLを持っていますね。
ここまでわかればコード上からどうにでもなります。
//URLからページソースを取得
string temp = new WebClient().DownloadString(対象ページのURL)
temp = temp.Substring(temp.IndexOf("2.5x,"),temp.IndexOf("3x\""));
temp = temp.Split(" 3x\"",StringSplitOptions.None)[0];
temp = temp.Split(", ", StringSplitOptions.None)[1];
Uri imgURI = new Uri(temp);
今回は検証が主目的なので、解析部分はガバガバです。
2021年11月現在、上記で問題無く取得できています。
動作確認
前述の検証用コードに画像表示機能も追加して動作確認しました(コード全文は後述)。
通知から取得した楽曲情報で画像を取得できていることがわかります。
問題点
検索結果に依存しているので、
- 再生中楽曲のジャケット(通知の画像)と一致しないケースもある
- 複数のアルバムに収録されている同名曲である場合
- 検索結果が正確でない場合
- 検索結果ページのURLやページソースの構成に変更があれば対応する必要がある
などの問題があり、特に画像の不一致については極端な例ではこんなケースも。
ジャケットですらねえ!!!!
検索ワードが長かったり記号含んでたりするのが良くないんだと思います。
最終的なコード全文
<Page
x:Class="AmazonMusicObserver.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:AmazonMusicObserver"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Width="1200"
Height="750"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="287*"/>
<ColumnDefinition Width="913*"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="logTextBox" Margin="10,10,10,10" Text="" TextWrapping="Wrap" />
<WebView Grid.Column="1" x:Name="webview" Margin="10,10,10,10"/>
</Grid>
</Page>
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading;
using System.Threading.Tasks;
using Windows.Foundation.Metadata;
using Windows.UI.Notifications;
using Windows.UI.Notifications.Management;
using Windows.UI.Xaml.Controls;
namespace AmazonMusicObserver {
public sealed partial class MainPage : Page {
//ターゲットとするAppInfo.AppUserModelId
const string TGTID = "Amazon.Music";
//コントロールアクセス用オブジェクト
static TextBox logTB;
static WebView webView;
public MainPage() {
this.InitializeComponent();
logTB = this.FindName("logTextBox") as TextBox;
webView = this.FindName("webview") as WebView;
MainAsync();
}
/// <summary>
/// 通知一覧から、対象外であるAppInfo.AppUserModelIdを持つ通知を除去する。
/// また、利便性のため最新通知が先頭へ来るように逆順ソートする。
/// </summary>
/// <param name="origin">加工前の通知一覧</param>
/// <returns>加工後の通知一覧</returns>
static List<UserNotification> removeNonTgtNotification(IReadOnlyList<UserNotification> origin) {
List<UserNotification> temp = new List<UserNotification>();
foreach (var item in origin.ToArray()) {
if (item.AppInfo.AppUserModelId.Equals(TGTID)) {
temp.Add(item);
}
}
temp.Reverse();
return temp;
}
/// <summary>
/// AmazonMusicからの通知であることを前提に、通知のテキスト要素を受け取って解析し画面等の更新処理を走らせる
/// </summary>
/// <param name="temp">通知のテキスト要素</param>
static void updateWork(IReadOnlyList<AdaptiveNotificationText> temp) {
try {
logTB.Text += "\nタイトル:" + temp[0].Text; //2021年11月現在、楽曲のタイトルが格納されている
logTB.Text += "\nアーティスト:" + temp[1].Text; //2021年11月現在、楽曲のアーティスト名が格納されている
logTB.Text += "\nアルバム:" + temp[2].Text; //2021年11月現在、楽曲のアルバム名が格納されている
logTB.Text += "\n-------";
Debug.WriteLine(temp[0].Text);
Debug.WriteLine(temp[1].Text);
Debug.WriteLine(temp[2].Text);
//通知情報から画像情報が取得できなかったのでAmazonから無理矢理取得する。
//まずは通知から取得した楽曲情報を使って検索結果ページのリンクを生成。
Uri storeSearchResultPageURI = createStoreSearchResultPageURI(temp);
//検索結果ページのリンクからソースを取得、そこから1件目のサムネイル用リンクを取得
Uri imgURI = createImgURI( new WebClient().DownloadString(storeSearchResultPageURI));
//webビューアでサムネイル用のリンク先を表示
webView.Navigate(imgURI);
} catch (Exception e) {
logTB.Text += "\n" + e.Message;
}
}
static Uri createStoreSearchResultPageURI(IReadOnlyList<AdaptiveNotificationText> temp) {
//2021年11月現在、取得した楽曲情報からデジタルミュージックに限定したAmazonMusic検索結果ページURLを下記コードで生成可能
string paramStr = temp[0].Text + " " + temp[1].Text + " " + temp[2].Text;
return new Uri("https://www.amazon.co.jp/s?k=" + System.Web.HttpUtility.UrlEncode(paramStr) + "&i=digital-music");
}
/// <summary>
/// 受け取ったページソースから画像リンクを生成
/// </summary>
/// <param name="temp">ページソース</param>
/// <returns>画像リンク</returns>
static Uri createImgURI(string temp) {
//2021年11月現在、AmazonMusic検索結果ページから取得したソースより下記コードで検索結果1件目のサムネイル用リンクを最大(3倍)サイズで取得可能
temp = temp.Substring(temp.IndexOf("2.5x,"),temp.IndexOf("3x\""));
temp = temp.Split(" 3x\"",StringSplitOptions.None)[0];
temp = temp.Split(", ", StringSplitOptions.None)[1];
Debug.WriteLine(temp);
return new Uri(temp);
}
//AsyncなMain
private static async Task<int> MainAsync() {
//////////////////////////////////////////////////////
//通知リスナーにアクセスできるかどうかの諸々のチェック
//////////////////////////////////////////////////////
if (!ApiInformation.IsTypePresent("Windows.UI.Notifications.Management.UserNotificationListener")) {
Debug.WriteLine("IsTypePresent: NG");
return -1;
}
Debug.WriteLine("IsTypePresent: OK");
UserNotificationListener listener = UserNotificationListener.Current;
Debug.WriteLine("listener: ");
Debug.WriteLine(listener);
UserNotificationListenerAccessStatus accessStatus = await listener.RequestAccessAsync();
Debug.WriteLine("accessStatus: ");
Debug.WriteLine(accessStatus);
if (accessStatus != UserNotificationListenerAccessStatus.Allowed) {
Debug.WriteLine("アクセス拒否");
return -1;
}
Debug.WriteLine("アクセス許可");
//////////////////////////////////////////////////////
//通知リスナーにアクセスできるかどうかの諸々のチェックここまで
//////////////////////////////////////////////////////
//初回の比較用に通知の履歴を取得
IReadOnlyList<UserNotification> oldList = await listener.GetNotificationsAsync(NotificationKinds.Toast);
//無関係な要らん通知を除去しつつ逆順ソートで成形
oldList = removeNonTgtNotification(oldList);
//通知の一覧を取得できるだけで、良い感じのイベントも差分を取得するメソッドも存在しないらしい。
//ので、ひたすら通知を取得&比較し続けて自力で差分を見付ける
while (true) {
IReadOnlyList<UserNotification> notifs = await listener.GetNotificationsAsync(NotificationKinds.Toast);
//無関係な要らん通知を除去しつつ逆順ソートで成形
notifs = removeNonTgtNotification(notifs);
//今回取得した通知一覧と前回取得した通知一覧を比較、それぞれの最新同士でタイムスタンプを比較して差分の有無を確認
if (DateTimeOffset.Compare(notifs[0].CreationTime, oldList[0].CreationTime) > 0) {
//差分あり。最新通知のオブジェクトを取得
NotificationBinding toastBinding = notifs[0].Notification.Visual.GetBinding(KnownNotificationBindings.ToastGeneric);
if (toastBinding != null) {
//テキスト要素を取得して更新処理に渡す
IReadOnlyList<AdaptiveNotificationText> textElements = toastBinding.GetTextElements();
updateWork(textElements);
}
} else {
Debug.WriteLine("更新無し");
}
//比較用リストを更新
oldList = notifs;
Thread.Sleep(1000);
}
}
}
}
今後の展開
それなりに問題を抱えながらも一応取得自体はできたので、あとはこれらをDBに投げ込めばPHPで良い感じに別端末でもweb表示できるようになります。
根本的に解決したい場合
-
モバイル版のAmazonMusicを使う
- なうぷれ系のアプリを見ると、同じく通知領域を経由して再生中楽曲の情報を画像込みでしっかり取得できています。
- 操作はPCがいい、という場合もエミュなりリモートなりでなんとかなりそうです。
-
APIが公開されているサブスクに乗り換える
- 詳しくは見ていませんが、SpotifyはAPIが充実しているっぽいです。
-
ディスプレイ付きのスマートスピーカーを導入する
- PCの画面を占有しません。
- Echo Show、同アカウント別端末で再生中の楽曲情報を表示する機能あったりしないかな……無いよな……。
おしまい
以上、お疲れ様でした!