Python с Yandex music API. Или индекс твоей смерти

Предисловие

Кхм… Некоторые меня возможно помнят по публикациям на сайте StopGame.

Я может и не самый умный программист, но чё-то сварганить могу, так что не принимайте мою работу как эталонную, я просто описал, то, что знаю по опыту. Если вы хотите меня поправить или указать на ошибки, буду рад поучиться у вас!

Ну, а теперь рыба!

Начало | Шиза — плохой компаньон

Что-ж, решил я как-то невзначай сделать discord bot-а для проигрывания музыки, ну и написал Typical бота на python за пару часиков, ещё доработав функцию игры интернет радио добавив туда аналогичный список. И тут я вспомнил: я же не давно смог достать свой токен Yandex Music! А чё бы не сделать тогда функцию проигрывания Yandex музыки?

Пишем | Чая много не бывает

Начинается всё с обозначения клиента: главного класса нашего «плеера»

from yandex_music import ClientAsync
import json
import asyncio

SETTINGS = json.load(open("config.json", "r"))
YCLIENT = asyncio.run(ClientAsync(SETTINGS["YToken"]).init())

1-ые три строчки, объяснять надеюсь не нужно, это просто импорты всего нужного
5-ую я использую для загрузки токена из отдельного файла (Да, я знаю, что есть env, но мне ПО.ФИ.ГУ).
6-ая строчка — самая интересная, это собственно наш клиент, поскольку он асинхронный, запускать его нужно исключительно через asyncio.run (Иначе, при любых операциях он будет выдавать ошибки)

Далее создаём нужное… «окружение» для нашего бота

import discord
from discord import FFmpegOpusAudio
from discord.ext import commands
from yandex_music import ClientAsync
import json
import asyncio

SETTINGS = json.load(open("config.json", "r"))
YCLIENT = asyncio.run(ClientAsync(SETTINGS["YToken"]).init())

intents = discord.Intents.all()
bot = commands.Bot(command_prefix="^", intents = intents)

@bot.command()
async def play(ctx, *desc):
  ctx.send("Hello World!")
  
bot.run(SETTINGS["APIKey"])

Не так уж и много…
Тут мы добавили собственно самого бота и тестовую на данный момент, команду «play».

Далее пишем команду «play»

global YCLIENT

desc = list(desc)

url = None
if len(desc) == 0:
  await ctx.message.add_reaction("❌")
  return
else:
  url = desc[0]

Что за desc? Спросят некоторые, а я отвечу: Это discord-овская благодетель
Discord имеет аху… крутой код для получения аргументов, так, что для получения всех аргументов команды достаточно прописать *desc, а далее преобразовать его в list.
Собственно говоря, здесь мы просто проверяем есть ли хоть один аргумент и если есть, то принимаем его так будто это url

YandexMusicAPI | Начало ШИЗЫ

Теперь мы в добром здравии начинаем писать Yandex

Начнём с 1-го, я ПОКА, ЧТО выделил 4 вида url у яндекс музыки (На самом деле 5, но радио мы проигнорируем).
Это:

  1. track — Одиночная песня

  2. album — Набор песен одного альбома

  3. artist — Набор альбомов одного исполнителя

  4. playlists — Набор песен разных исполнителей и альбомов

И в чём проблема? Спросите вы. А я отвечу: ОНИ, СОБАКА, РАЗНЫЕ ДО АБСОЛЮТА!

Начинаем по разделам:

track

if "track" in command:
  idm = command.split("/")[-1]
  track = (await YCLIENT.tracks(idm))[0]

Самый простой метод определение каким типом является ссылка, это проверить есть ли какой либо «кусок текста» по порядку, от track до playlist (Как написано выше)

idm — это id трека. Получить ID любого из «объектов» можно по последнему элементу списка, который мы получаем если разделить ссылку по знаку »/»

А дальше жесть, вы не можете получить трек по id, вы обязательно получите list треков.
Так, что берите самый первый элемент и не мучайтесь

album

if "album" in command:
  album = (await YCLIENT.albums_with_tracks(int(command.split("/")[-1])))
  volumes = album.volumes[0]
  track = volumes[0]

Тут мы получаем альбом со всеми «целиковыми» треками, а далее выделяем все треки и кидаем в отдельный массив. Спросите:, а почему мы берём, только первый индекс? Всё очень просто!
Когда мы получаем массив треков мы получаем его не так:

[
  track,
  track,
  track,
  track,
  track,
  ...
]

А вот так:

[
  [
    track,
    track,
    track,
    track,
    track,
    ...
  ]
]

Почему? Спросите у создателя!

artist

Начало веселухи!

elif "artist" in command:
  ida = command.split("/")[-1]
  artist = (await YCLIENT.artists(ida))
  artist = artist[0]
  albums = (await artist.get_albums_async())
  albumList = [await YCLIENT.albums_with_tracks(album.id) for album in albums]
  volumes = []
  for x in albumList:
      volumes = volumes + x.volumes[0]
  track = volumes[0]

Вначале получаем id исполнителя, после чего получаем СПИСОК исполнителей, и берём из него первый элемент. Потом получаем все альбомы этого исполнителя, после чего создаём массив с объектами альбомов с треками.

На 7 — 9 строчках мы получаем все треки из всех альбомов и складываем в отдельный массив, что бы получить «единый массив» всех треков

playlists

elif "playlists" in command:
  idu = command.split("/")[-3]
  album = (await YCLIENT.users_playlists(kind=command.split("/")[-1], user_id=idu))
  album = album.tracks
  alubmid = [x.id for x in album]
  album = (await YCLIENT.tracks(alubmid))
  track = album[0]

Тут стоит сказать, что плейлисты, для Я.музыки это тип данных, которые хранят не Tracks, а ShortTracks (Посмотрите объяснение целиковых треков), так, что мы в начале получаем объект плейлиста, а после получаем треки, из треков достаём id, а из id, берём снова треки.

И да, в .tracks как и в остальные команды можно вставлять, а после и получать списки

Загрузка и проигрывание

if ctx.author.voice is not None:
  player = await (ctx.author.voice).channel.connect()
  trackSource = await track.download_bytes_async()
  audio_data = AudioSegment.from_file(BytesIO(trackSource), format='mp3')
  normalized_audio = audio_data.normalize()
  player.play(FFmpegOpusAudio(normalized_audio.export(format='wav'), pipe=True))
else:
  await ctx.send("Пользователь не в голосовом канале. Попробуйте перезайти в него, если это не так")

А здесь мы и будем проигрывать наши песни :)

Но, для начала мы проверяем находится, ли пользователь в голосовом канале и, если, да, то создаём объект player с указанием голосового канала. Скачиваем в байт-коде песню и преобразуем её с помощью AudioSegment, что бы выполнить нормализацию, а после выдать дискорду не в виде .mp3, а в виде .wav файла.

BytesIO же, мы используем для того, что бы не приходилось сохранять файлы на жёстком диске.

Ну, а теперь добавим «шарма~»

embed = discord.Embed(title=f"**{track.title}**", description=f"{track.albums[0]["title"]} • {track.albums[0]["year"]}", color=discord.Color.from_rgb(random.randint(125, 255), random.randint(125, 255), random.randint(125, 255)))
embed.set_footer(text=track.artists[0]["name"], icon_url=track.albums[0].artists[0].cover.get_url())
embed.set_thumbnail(url=track.get_cover_url())
ctx.send(embed=embed)

Тут как раз таки показано получение «дополнительных» данных к треку. Если вы хотите получить ПОЛНЫЙ список, то можно написать просто print(track) . К счастью доброе большинство классов из библиотеки преобразуются в JSON.

Однако! Учтите, что в большинстве случаев для получения url, например обложек альбомов, требуется выполнение функции по типу .get_url()

Ну, а теперь…

Может добавим не много Numpy?

url = track.get_cover_url()
thumbnail_image = np.array(Image.open(BytesIO(requests.get(url).content)))
rl, gl, bl = (thumbnail_image[:,:,0])[:,0].tolist(), (thumbnail_image[:,:,1])[:,0].tolist(), (thumbnail_image[:,:,2])[:,0].tolist()
r, g, b = int(sum(rl) / len(rl)), int(sum(gl) / len(gl)), int(sum(bl) / len(bl))

Выглядит… Адски…

Ну по классике, по порядку:

  1. track.get_cover_url() — получаем url обложки альбома

  2. ...requests.get(url).content))) — получаем изображение

  3. ...BytesIO(requ... — сохраняем изображение в памяти ОЗУ

  4. ...Image.open(Byte... — открываем его как объект Pillow

  5. np.array(Imag... — переводим Pillow в numpy.array

  6. thumbnail_image[:,:,0] — получаем все 0 индексы в каждом из под массивов, под массивов (Numpy имеет данные в виде матрицы где каждый элемент, это матрица с цветами RGB, так, что мы получаем все значения красного разделённого на «строки»). То же самое делаем с 1-ым и 2-ыми индексами

  7. (thumbnail_image[:,:,0])[:,0] — получаем все 0 индексы подмассивов (Как, я говорил цвета разделены ещё и построчно, но поскольку нам это не надо, мы их от туда достаём)

  8. .tolist() — используем, что бы преобразовать numpy.array в классический список Python

  9. А в 4-ой строчке мы высчитываем средне арифметическое для всех цветов

    Хоба! У нас есть «средний цвет» по обложке альбома, который мы со спокойной душой можем вставить в качестве цвета для embed

color=discord.Color.from_rgb(r, g, b))

Конец | НАКОНЕЦ, ТО

И вот, что у нас получилось по итогу:

import discord
from discord import FFmpegOpusAudio
from discord.ext import commands
from yandex_music import ClientAsync
import json
import asyncio
from io import BytesIO
from pydub import AudioSegment
from PIL import Image
import numpy as np
import requests

SETTINGS = json.load(open("config.json", "r"))
YCLIENT = asyncio.run(ClientAsync(SETTINGS["YToken"]).init())

intents = discord.Intents.all()
bot = commands.Bot(command_prefix="^", intents = intents)

@bot.command()
async def play(ctx, *desc):
  global YCLIENT

  desc = list(desc)
  
  command = None # Да, простите, я на пол пути url в command переименовал :3
  if len(desc) == 0:
    await ctx.message.add_reaction("❌")
    return
  else:
    command = desc[0]

  if "track" in command:
    idm = command.split("/")[-1]
    track = (await YCLIENT.tracks(idm))[0]
  if "album" in command:
    album = (await YCLIENT.albums_with_tracks(int(command.split("/")[-1])))
    volumes = album.volumes[0]
    track = volumes[0]
  elif "artist" in command:
    ida = command.split("/")[-1]
    artist = (await YCLIENT.artists(ida))
    artist = artist[0]
    albums = (await artist.get_albums_async())
    albumList = [await YCLIENT.albums_with_tracks(album.id) for album in albums]
    volumes = []
    for x in albumList:
        volumes = volumes + x.volumes[0]
    track = volumes[0]
  elif "playlists" in command:
    idu = command.split("/")[-3]
    album = (await YCLIENT.users_playlists(kind=command.split("/")[-1], user_id=idu))
    album = album.tracks
    alubmid = [x.id for x in album]
    album = (await YCLIENT.tracks(alubmid))
    track = album[0]
  if ctx.author.voice is not None:
    player = await (ctx.author.voice).channel.connect()
    trackSource = await track.download_bytes_async()
    audio_data = AudioSegment.from_file(BytesIO(trackSource), format='mp3')
    normalized_audio = audio_data.normalize()
    player.play(FFmpegOpusAudio(normalized_audio.export(format='wav'), pipe=True))
    
    url = track.get_cover_url()
    thumbnail_image = np.array(Image.open(BytesIO(requests.get(url).content)))
    rl, gl, bl = (thumbnail_image[:,:,0])[:,0].tolist(), (thumbnail_image[:,:,1])[:,0].tolist(), (thumbnail_image[:,:,2])[:,0].tolist()
    r, g, b = int(sum(rl) / len(rl)), int(sum(gl) / len(gl)), int(sum(bl) / len(bl))  
    
    embed = discord.Embed(title=f"**{track.title}**", description=f"{track.albums[0]["title"]} • {track.albums[0]["year"]}", color=discord.Color.from_rgb(r, g, b)))
    embed.set_footer(text=track.artists[0]["name"], icon_url=track.albums[0].artists[0].cover.get_url())
    embed.set_thumbnail(url=url)
    ctx.send(embed=embed)
  else:
    await ctx.send("Пользователь не в голосовом канале. Попробуйте перезайти в него, если это не так")
  
bot.run(SETTINGS["APIKey"])

И вот такой, вот результат у нас вышел…

Тут много чего можно исправить начиная с определения среднего цвета (Это можно ещё сделать если с помощью Pillow уменьшить изображение до 1×1 пиксель, посмотрите вот этот StackOverFlow) и заканчивая добавления плейлистов…

Но это оставим, либо на других, либо на потом~

Потому что всю эту статью я писал по уже готовому коду, где была поддержка и плейлистов и красивые кнопочки, и поддержка более чем одного сервера, но там, столько кода и костылей, что разбирать это, пока не сил не времени, так, что… вот…

Думаю на этом можно прощаться! Пока!

Бери шинель—пошли домой Бери шинель—айда по домам!

Бери шинель—пошли домой 
Бери шинель—айда по домам!

© Habrahabr.ru