Как мы интегрировали Яндекс.Музыку с Siri. Доклад Яндекса
Чтобы вам было понятно, что это и как работает, пример первый. Говорим: «Включи «Сектор Газа» в Яндекс.Музыке» — и бум, музыка пошла. Второй пример: можно сказать «Мне нравится этот трек в Яндекс.Музыке». Вы идете, слушаете, не хотите доставать телефон, whatever. Все полайкано, все хорошо.
Начну с плана. Расскажу про то, зачем мы это делали, кто такая Siri и зачем и когда она появилась для сторонних приложений, а также про типы музыкальных запросов. Они называются интенты — дальше я буду использовать это слово. Расскажу, как и где писать код, что для этого нужно. Покажу реализацию интента, включение музыки и интента, лайка и дизлайка. В самом конце расскажу про нюансы.
Пасхалка от Apple. На страничке документации класса INPlayMediaIntent есть много примеров того, как это работает на разных языках. На русском написано: «Играй Qeen на Яндекс Музыке». Это было сделано еще до того, как мы реализовали поддержку Siri, так что Apple, спасибо вам большое. Это очень лестно.
Зачем мы это делали? Во-первых, почему бы и нет, крутая фича. Во-вторых, это была часть большой задачи по реализации Яндекс.Музыки под Apple CarPlay, но мы сейчас не об этом.
Давайте теперь про Siri. Siri появилась в iPhone 4S, начиная с iOS 5, если я все правильно гуглил. Она выглядела вот так, была совсем неуклюжей. Только к iOS13, на WWDC 2019 показали, что теперь вы можете реализовывать в своих музыкальных приложениях поддержку Siri. Здорово.
Как это работает? Я не придумал ничего лучше, чем просто взять этот слайд из презентации WWDC. Пользователь говорит что-то Siri. Siri это обрабатывает и отдает вам данные в какой-то extension. Вы с этими данными идете в ваши сервисы, бэкенды, app-группы, общие контейнеры. Это работает с вашим приложением, но не всегда. Дальше объясню, почему, и расскажу всю обратную сторону: чтобы вам на экране показалось то, что надо, Siri сказала то, что надо, и так далее.
Типы интентов. Первый — INPlayMediaIntent, интент из серии «включи что-нибудь». INAddMediaIntent — это «добавь что-нибудь». Добавь этот трек в плейлист, когда грустно. INUpdateMediaAffinityIntent — это интент лайк/дизлайк. Последний — INSearchMediaIntent, «найди». То есть вы говорите: «Найди «Сектор Газа» в Яндекс.Музыке». Открывается приложение Яндекс.Музыка, в котором сразу открыт «Сектор Газа».
Я сегодня расскажу про эти два интента — «включи» и «лайк/дизлайк», потому что именно мы их и реализовали. Давайте посмотрим на код.
Как я говорил, это extension. Называется IntentsExtension. Его нужно создать. Вы его создаете, у вас появляется таргет, в котором вы должны написать строками названия классов, этих интентов, которые вы поддерживаете. Как видите, у нас их два: INPlayMediaIntent и INUpdateMediaAffinityIntent.
Далее снизу вылезает менюшка, где вы должны указать, какие типы данных вы поддерживаете. Эти типы данных будут настраиваться в зависимости от того, какие классы вы указали.
Если это не медиазапрос, а что-нибудь другое — например, у Siri есть еще поддержка заметок, — то там будут другие типы.
Также у вас появляется класс IntentHandler. Он появляется за вас, вам ничего делать не надо. Он выглядит так, он есть у вас в таргете. Все здорово.
Про реализацию протокола INMediaIntentHandling. Это протокол, как очевидно из названия, обрабатывает INPlayMediaIntent. У него больше двух методов, чуть ли не семь. Но я расскажу про эти два, потому что они нам как раз понадобились. Мы их реализовали. Это resolveMediaItems, такой метод нужен для того, чтобы вы собрали данные, с которыми Siri за вас что-то сделала. Вы пошли в ваш поиск, помапили нужные данные для Apple и вернули их в коллбэк. handle — это первая часть обработки этих данных. Дальше объясню, почему первая.
У этих двух методов есть общий параметр: INPlayMediaIntent. Давайте посмотрим, что это такое. Здесь много букв. Запомните MediaItems — мы потом о нем еще поговорим. Здесь есть куча всего. Например, playback speed для подкастов. Играть с шафлом, без шафла. Repeat mode. Но сейчас нам нужен mediaSearch.
Объясню и покажу, что это. Это класс, у которого есть очень много значений от mediaType до mediaIdentifier. Некоторые вещи заполняет Siri, некоторые заполняете вы. Сейчас объясню на примерах, как это все работает.
Пример 1: «Включи трек Skyfall от Adele в Яндекс.Музыке». Вы можете это сказать Siri прямо сейчас, если у вас есть подписка Яндекс.Музыки. Слово «включи» определяет тип интента. INPlayMediaIntent. Соответственно, будут вызываться те методы, которые я показывал ранее. Слово «трек» определяет поле mediaType, значение — song. Его говорить необязательно, дальше объясню, почему. Когда вы произносите такие дополнительные штуки для Siri, вы улучшаете качество вашего поиска. Вы все еще можете сказать «Включи Skyfall в Яндекс.Музыке». Если наш поиск посчитает, что Skyfall — это трек, который вам нужен, Siri именно его и включит.
Слово «Skyfall» определяет mediaName. «От Adele» определяет artistName. Как вы можете заметить, предлог «от» просто игнорируется, потому что Siri сама за вас поняла: «от» значит, что следующим будет название артиста. И последняя часть: «в Яндекс.Музыке», в каком приложении это должно работать. К сожалению, мы не можем назначить музыкальное приложение по умолчанию. Поэтому нужно всегда в конце добавлять: «в Яндекс.Музыке».
Пример 2: «Включи грустную акустическую музыку в Яндекс.Музыке». «Включи» — понятно. Слово «грустную» определяет moodNames == [«sad»]. Обратите внимание, что тут написано на английском, а не на русском. Есть список констант, который матчит ваши слова в moodNames, вот этот массив, но в документации его нет. Готовьтесь.
Слово «акустическую» определяет genreNames, которое тоже написано на английском. Но эти константы уже есть в документации. Зайдя в документацию по INMediaSearch.genreNames, вы увидите, что она там есть. Огромная таблица, в которой написано, какие жанры понимает Siri. Главное, если вы будете реализовывать это у себя, приготовьтесь к тому, что ваш поиск должен понимать английский язык. Наш, к счастью, понимает.
Слово музыка определяет mediaType==.music. Это считается типом сущности, который можно воспроизводить.
Пример 3: «Скажи Яндекс.Музыке включить рок». То есть мы полностью поменяли слова во фразе местами. И это все равно работает. Еще есть вот такая штука: «Включи музыку, чтобы уснуть, в Яндекс.Музыке». Казалось бы, что здесь такого? То ли в genreNames, то ли в moodNames будет слово meditation. Почему здесь слово meditation, решает только Siri. Ваше дело — реализовать то, что сказала Siri, а дальше надо разбираться самим. И еще куча всего, чего мы не знаем. Возможно, есть и другие фразы, но не в документации. Надо готовиться к тому, что Siri сделает кучу всего за вас. Это прикольно, но одновременно очень странно.
Дальше расскажу прикольную штуку. Siri в прошлом году обучили на библиотеке Apple Music. Когда вы начинаете разговаривать с Siri в музыкальном контексте, например, «Включи «Сектор Газа» в Яндекс.Музыке», она поймет, что «Сектор Газа» — это исполнитель, и сама подставит значение, сделает все за вас.
Вы можете даже сказать на английском: «Play Sector Gaza in Yandex.Music». «Сектор Газа» нормально распарсится. Это очень здорово. До этого был вообще кошмар. Во-первых, как видите, имя «Децл» она не смогла спокойно распознать. А вот тут она еще почему-то взяла название проекта Xcode, которого ни в каких константах нет. Очень странно. Если ваш проект называется в Xcode «Суперпуперприкольное приложение», то здесь будет написано то же самое, хотя название самого приложения другое. Очень странно. Видите, внизу написано «Я.Музыка» и все окей.
Поговорим конкретно про реализацию этих методов. Это resolveMediaItems. Первое, что вы должны сделать — по крайней мере, если у вас так же, как у нас, — это проверить, что пользователь залогинен и у него есть подписка. Как вы видите, существует куча стандартных ответов результатов для Siri. Вы с ними ничего не можете сделать. Вы можете только сказать ей, что нужно сделать. Она скажет проверить данные подписки пользователя, вашего аккаунта. Вы в этих фразах никак не участвуете. Их знает сама Siri, локализует сама Siri. Все делает сама Siri.
Далее вы должны взять этот mediaSearch и склеить эти данные. Мы берем практически все, что есть, помещаем в один массив, делаем из него строку, где каждый элемент просто разбит через пробел, и отправляем в наш поиск, потому что наш поиск такое может съесть. Это здорово. Дальше вы мапите эти данные и отдаете в коллбэки с результатом success. Но важно, как мапить эти данные и во что.
Помните, я вам говорил запомнить INMediaItem? Это они и есть. Вы должны помапить ваши сущности в INMediaItem. Это пример того, как у нас мапятся треки. Для всех остальных сущностей типа плейлиста, артиста, альбома, whatever, все идет таким же образом. Поле mediaItems в интенте будет заполнено данными, которые вы запомнили. Давайте разберем, что куда летит. Оно иногда может показываться на экране — дальше покажу, как. identifier вы заполняете, скорее, для себя. Это id сущности, который хранится у вас на стораджах и на бэкенде. Title, тип, обложка, артист — вот они. Все здорово.
Дальше — реализация handle. mediaItems, которую вы напарсили и вернули в том коллбэке, теперь появляется в поле mediaItems у интента. Вы проверяете, что они есть? возвращаете вот такой response, в котором передаете ей код handleInApp. Помните, я говорил, что у handle есть две части.
Так вот, это оно и есть. В AppDelegate, где же еще, вы должны реализовать еще один метод, который называется application handle with completionHandler, в котором появляются базовые классы интента. Поскольку у нас музыкальное приложение, то мы проверяем только на музыкальные интенты — на то, что это INPlayMediaIntent. Дальше отдаем это в класс, который умеет ходить на бэкенды и качать треки, помещаем все это в плеер и получается вот так. Все, что нужно. Самое прикольное: если вы вернете больше одного успешного результата, то Siri — это видно на виджете плеера на первом скриншоте — покажет кнопку Maybe you wanted. При тапе на эту кнопку открывается второй экран, который находится справа. Там как раз будут сущности, которые вы еще не искали. Максимум четыре. Вы можете сложить туда хоть миллион, но система покажет только четыре. В целом здорово, ничего страшного.
Дальше давайте поговорим про INUpdateMediaAffinityIntentHandling. Из названия протокола очевидно, что он умеет обрабатывать интент INUpdateMediaAffinity. Это как раз лайки и дизлайки. Тут намного интереснее. У самого протокола, по-моему, четыре-пять методов. Я расскажу про три из них, которыми мы воспользовались.
Они вызываются в таком порядке: resolveMediaItems, resolveAffinityType, IntentHandler.
resolveMediaItems работает так же, как и с предыдущим интентом. Вы берете эти данные, идете в ваш поиск, мапите в INMediaItem и возвращаете в коллбэки.
Все то же самое. resolveAffinityType. Нужно проверить, что вы можете с этой конкретной сущностью, которую вы нашли в поиске, совершить это конкретное действие. Например, лайк или дизлайк. Дальше покажу подробнее, зачем это нужно. Handle уже одинарный, не двойной, в котором мы должны совершить это действие — лайк/дизлайк. У них есть общий параметр. Это INUpdateMediaAffinityIntent. Давайте разберем, что это такое. Он гораздо меньше.
У него есть три поля. С mediaItems и mediaSearch мы уже знакомы. Что такое affinityType? Это enum, у которого есть три значения: unknown, like и dislike. В целом понятно, это как раз тип действия, которое вы должны совершить.
С mediaSearch вы уже знакомы, но у него есть одно поле, которым мы не пользовались: reference.
Что это такое? Это значение INMediaReference, тоже enum. У него есть два значения: unknown и currentlyPlaying.
Если еще кто-то не догадался, что это такое, то давайте я вам покажу на примерах.
Пример 1. «Мне нравится трек Skyfall от Adele в Яндекс.Музыке». Фраза «мне нравится» определяет тип интента, INUpdateMediaAffinityIntent. То есть по этому протоколу будет вызываться именно ваш код, INUpdateMediaAffinityIntentHandling. Также это определяет поле affinityType как like, потому что «Мне нравится». Cлово «трек» определяет mediaType==.song так же, как раньше.
«Skyfall» точно так же определяет поле mediaName. «Adele» — artistName. В целом понятно.
Пример 2. «Мне не нравится этот трек в Яндекс.Музыке». Тут по-другому. «Мне не нравится» определяет тип интента и affinityType==dislike, так как «Мне не нравится».
Слово «этот» определяет слово reference как currentlyPlaying. То есть как раз то самое значение, то, что сейчас играет.
Слово «трек» определяет mediaType==.song, которое также необязательно, потому что можно сказать: «Мне нравится это в Яндекс.Музыке». Этого будет достаточно. Но «трек» улучшит поиск.
Реализация resolveMediaItems. В начале вы точно так же проверяете логин, подписку. Дальше идет небольшой паттерн-матчинг, примерно похожий на тот, который есть у нас в коде. Пример resolveNotCurrent я рассматривать не буду, потому что он точно такой же. Вы берете все данные, которые есть у вас в интенте, в mediaSearch, идете в ваш поиск, мапите и возвращаете в коллбэке. Все здорово. Но я расскажу про вот эту штуку, потому что она интереснее. resolveCurrent. Во-первых, как вы можете заметить, этот enum работает не совсем правильно. CurrentlyPlaying — это хорошо, если сказать, что мне нравится этот трек. Но если сказать, мне нравится «это», значение будет unknown, а query будет пустым. Почему так? Понятия не имею. Но это так. Мы это поняли в момент испытания Siri. Это очень странно, но работает именно так. Давайте теперь подумаем. currentlyPlaying, что сейчас сыграет. Extension — это другая часть приложения. У нас нет доступа.
Что делать? Для начала расскажу, кто не знает, что такое NowPlayingInfo. Это большой словарь с кучей стандартных ключей, которые есть в Media Player framework, если я ничего не путаю. Вы его заполняете данными. На виджете плеера, на локскрине и в Control Center появляются как раз те данные, которыми вы заполнили этот словарь.
Apple нам обещала, что если положить в NowPlayingInfo по тому ключу, который вы видите на экране, любое строковое значение, то в intent.mediaSearch?.mediaIdentifier будет как раз то значение, которое лежит в NowPlayingInfo. Но это вообще не работает. Я пытался, не сработало. К счастью, на помощь пришли божественные App Groups, которые работают уже тысячу лет, и никаких проблем с ними нет.
Как они помогли? Вы создаете appGroupUserDefaults, указываете в suitName id вашей app.group. В основном приложении вы вставляете значение по ключу. Из extension достаете по этому ключу. Все работает классно. Я на всякий случай решил воспользоваться ключом, который как раз не работает, чтобы как минимум оставить напоминание самому себе, что это не работает.
Есть еще вот такая штука. Один из результатов, которые нужно запомнить, — это disambiguation. Например, пользователь сказал, что ему нравится этот артист в Яндекс.Музыке.
Но играет трек, у которого несколько исполнителей. Что делать? Этот результат как раз для этого и нужен.
Siri отобразит вот такое меню.
Вы можете голосом или тапом выбрать то, что нужно. INMediaItem, один из них уйдет дальше в метод handle. Точнее, сначала в resolve AffinityType. Зачем он нужен? Например, в Музыке так повелось, что мы дизлайкать можем только треки. Артиста или альбом вы дизлайкать не можете. Этот метод нужен как раз для таких случаев. Вы проверяете тип значения, и если это трек, то его можно лайкать и дизлайкать. Если это что-то другое, вы можете только лайкать. Дальше проверяете: если они совпадают, возвращаете константу unsupported. Тут забавно. Siri мне говорит, что это работает для какого-то определенного типа. Поэтому она скажет, что просто не поддерживаются дизлайки. Хотя они поддерживаются, но только для треков. Спасибо!
Метод handle. Вы точно так же проверяете MediaItems, который у нас есть, берете его id и дальше должны сходить в API и полайкать. То есть в целом все просто.
По ответу от сервера вы можете вернуть два значения: success или fail. Если пришла ошибка, то все плохо. Siri обязательно об скажет, что произошла какая-то ошибка, либо как я показывал в примере: «Я сказала Яндекс.Музыке, что вам это нравится».
Вы вроде бы все сделали. Но это, естественно, не все. Возникают нюансы. Нюанс номер один: пользователи Яндекс.Музыки привыкли к тому, что дизлайк трека приводит к автоматическому скипу, включается следующий. Очевидно, если человеку не нравится этот трек, зачем его слушать дальше. А как, находясь в другом таргете, заставить плеер переключиться на другой трек? На помощь нам пришли старые-добрые сишные дарвиновские нотификации.
Как они нам помогли и что это такое, сейчас объясню. Дарвиновские нотификации — это core-штука системы. Ими можно обмениваться между таргетами, между приложениями. Отправка выглядит так, обработка — так. В целом понятно. Мы из extension отправляем нотификацию, что мы дизлайкнули текущий трек. Это ловит основное приложение, делает скип, все довольны.
Нюанс номер два — русский язык. Сейчас объясню, почему. Я тестировал на английском, потому что система у меня стоит на английском. Наше приложение называется Yandex Music. Никаких проблем нет, для Siri тем более. Но на русском языке наше приложение называется Я.Музыка. Когда я попробовал что-то типа «Включи «Сектор Газа» в Я.Музыке», Siri посчитала, что «Я» сказано случайно и надо включить исполнителя в Apple Music. Вот так это и работало. К счастью, есть решение.
Вы в вашем info.plist заполняете один ключ, массив словарей, где указываете альтернативное название для вашего приложения и подсказку по произношению.
Именно поэтому у нас как альтернативное название приложения указана Яндекс.Музыка. Подсказка для произношения описана в Яндекс.Музыке, потому что пользователь скажет, что ему «нравится что-нибудь в Яндекс.Музыке». Это работает без проблем, спасибо, Apple. Очень элегантное, хорошее решение.
Нюанс номер три. С ним поинтереснее. Наши пользователи в курсе, что это такое. Это два умных плейлиста из списка скольки-то наших умных плейлистов. С ними возникли нюансы. Начнем с плейлиста дня.
«Включи плейлист дня в Яндекс.Музыке». Казалось бы, мы хотим, чтобы слово «Включи» определило тип интента, а слова «плейлист дня» определили mediaName. Но это работает по-другому. Слово «плейлист» определяется как mediaType==.playlist, потому что Siri поняла: нужно включить какой-то плейлист. А слово «дня» распознается как mediaName.
Есть workaround, но он для пользователя. «Включи плейлист плейлист дня в Яндекс.Музыке», где слово плейлист определится как mediaType. Второе слово «плейлист» определится как mediaName, и все счастливы.
Кажется, можно это закостылить — сразу объясню, почему мы не стали этого делать. На разных версиях iOS и на разных языках это работает по-разному. Например, если я скажу на английском: «Play playlist playlist of the day in Yandex Music», Siri решит, что вы случайно сказали слово «playlist» два раза подряд, а «of» the ничего не значит, она его выкинет. У вас будет mediaName== «day». Как вы можете догадаться, включится Green Day (00:26:05). Это аботает не так хорошо, как хотелось бы.
«Включи плейлист с Алисой в Яндекс.Музыке». Тут еще интереснее. «Включи» по-прежнему определяет интент. Слово «плейлист» определяет playlist. А «с Алисой» определяется как artistName.
Знаете, почему? Потому что есть такая рок-группа — «Алиса». Русская Siri посчитала, что «Алиса» — это та самая рок-группа. Причем если сказать ту же самую фразу на английском, то включится исполнитель, которого зовут A-List.
К этому можно было бы найти решение. Есть класс INVocabulary, который может задавать для Siri кастомный вокабуляр ваших сущностей в приложении. Слишком умно сказано, в чем соль? Вы можете передать туда название ваших сущностей, как у нас плейлист дня и плейлист с Алисой. Передаете по специальному типу mediaPlaylistTitle, чтобы Siri поняла, что это такие плейлисты. И все должно заработать. Это первая фишка из моего опыта, которая кидает exception при обращении к ней, если не выставлен entitlement для этой API. Я проверял, оно не помогает. Они это как-то асинхронно делают.
Вторая проблема. Все это, к сожалению, не сработало. Слово «плейлист» все-таки важнее для Siri как тип сущности, а не как название этой сущности.
Нюанс номер пять. Когда мы закинули сборку в App Store Connect, нам пришло письмо счастья с перечислением проблем приложения. К счастью, это был просто warning, не автоматический reject о том, что Siri реализована неправильно. В письме было сказано, что мы не представили примеры фраз по каждому из языков, со ссылкой на документацию.
В итоге, покопавшись в документации, мы поняли, что нужно создать plist именно с таким названием. Вы не указываете его ни в build settings, нигде. Это просто название. И локализуется оно вот таким образом. То есть сам файл локализован, а не локализованы строки внутри него. Как вы можете догадаться, это неудобно, потому что большинство сервисов, сторонних, которые мы используем, не поддерживают такой тип локализации. Поэтому все переведенные строчки я решил поместить в нее руками, а не писать какие-то скрипты. Вы знаете, что лучше сделать за пять минут руками, чем автоматизировать пять часов.
В этих строчках нужно писать для каждого интента примеры того, как пользователю пользоваться Siri. Например: «Включи рок в Яндекс.Музыке», «Мне не нравится этот трек». Я это сделал. Потом у меня возник вопрос: где это показывается? В документации этого нет. Никто ничего не пишет.
В какой-то момент до меня дошло. Помните бородатые времена, когда была iOS 13 и Siri была полноэкранной? У нее, если совершить определенное количество действий, появлялись подсказки. Там есть сторонние приложения. Вы видите Яндекс.Музыку и Telegram. Почему это здесь написано, мне неизвестно, но Apple, очевидно, это чинить не будут, потому что Siri в iOS 14 уже неполноэкранная. Там просто маленький красивый кругляшок снизу, и все.
Итого:
- Siri — это круто. Можно идти в плохую погоду, например по ужасному морозу, и говорить, что нужно включить, что лайкнуть, что дизлайкнуть.
- Siri неплохо задокументирована, почти без багов. Я никаких серьезных багов сегодня не приводил.
- Если у вас тип сущности содержится в названии этой сущности, то вы страдаете вместе с нами.
А помимо того, что всем это нравится и все довольны, вы получаете заветную маленькую иконку в App Store для вашего приложения, на которой написано, что Siri его поддерживает. Очень здорово и мило. На этом у меня всё, всем спасибо!