Началось все с того, что мне захотелось написать музыкального бота для своего discord сервера.
При проектировании проекта, я решил разделить его на две части. Первая — получение музыки из ВК. Вторая — сам бот. И начать я решил с первой части.
Поиск какой-либо информации на этот счет или уже возможно готового куска кода не принес никаких результатов из-за чего очевидным решением данной проблемы было то, что придется разбираться с этим самому.
Я решил посмотреть что сейчас отдает ВКонтакте при воспроизведении записи и полез во вкладку network, вот что я там увидел:
Фото
Теперь передо мной стояла новая задача, как получить с определенного аудио нужную ссылку на m3u8 файл и уже потом думать как его разбирать и собирать в дальнейшем в цельным mp3 файл.
В ходе раздумий был найден довольно простой вариант в виде библиотеки для питона vk_api и реализация получения такой ссылки через эту библиотеку выглядит так:
from vk_api import VkApi
from vk_api.audio import VkAudio
login = "+7XXXXXXXXXX"
password = "your_password"
vk_session = VKApi(
login=login,
password=password,
api_version='5.81'
)
vk_session.auth()
vk_audio = VKAudio(vk_session)
# Делаем поиск аудио по названию
# Так же можно получать аудио со страницы функцией .get_iter(owner_id)
# где owner_id это айди страницы
# или же можно получить аудио с альбома, где мы сначала получаем айди альбомов
# функцией .get_albums_iter()
# и после снова вызываем .get_iter(owner_id, album_id), где album_id полученный
# айди альбома
q = "audio name"
audio = next(vk_audio.search_iter(q=q))
url = audio['url'] # получаем ту длиннющую ссылку на m3u8 файл
Вот мы и получили ссылку на этот файл и встал вопрос, а что делать дальше. Я попробовал запихнуть эту ссылку в ffmpeg и уже было обрадовался, ведь он скачал мой заветный аудиофайл и сразу же сделал конвертацию в mp3, однако, счастье мое длилось не долго, ведь ffmpeg хоть и скачал все сегменты, самостоятельно склеив их, но зашифрованные сегменты он не расшифровал, поэтому давайте еще раз взглянем на внутренности m3u8 файла
#EXTM3U
#EXT-X-TARGETDURATION:25
#EXT-X-ALLOW-CACHE:YES
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=AES-128,URI="https://cs1-66v4.vkuseraudio.net/s/v1/ac/wYaompMqHNQpBIH183wK68QVW45tvaJLaznkPiqES66JM-xzffiiM4KQx5WPS0Vg99U9ggCDronPKO8bzit3v_j8fH6LymN2pngBXYTv5uaDnFiAfc2aXv848bhRJEyFVB1gaJw1VR4BS9WnSb8jIMd0haPgfvJMcWC7FW7wpFkGU14/key.pub"
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:1
#EXTINF:2.000,
seg-1-a1.ts
#EXT-X-KEY:METHOD=NONE
#EXTINF:4.000,
seg-2-a1.ts
#EXTINF:20.000,
seg-3-a1.ts
#EXT-X-KEY:METHOD=AES-128,URI="https://cs1-66v4.vkuseraudio.net/s/v1/ac/wYaompMqHNQpBIH183wK68QVW45tvaJLaznkPiqES66JM-xzffiiM4KQx5WPS0Vg99U9ggCDronPKO8bzit3v_j8fH6LymN2pngBXYTv5uaDnFiAfc2aXv848bhRJEyFVB1gaJw1VR4BS9WnSb8jIMd0haPgfvJMcWC7FW7wpFkGU14/key.pub"
#EXTINF:20.000,
seg-4-a1.ts
#EXT-X-KEY:METHOD=NONE
#EXTINF:25.444,
seg-5-a1.ts
#EXT-X-ENDLIST
Мы видим, что перед зашифрованными сегментами в EXT-X-KEY указан метод шифровки AES-128 и ссылка на скачку ключа для расшифровки.
Для решения уже этой проблемы была найдена прекрасная библиотека m3u8 и pycryptodome:
import m3u8
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
# Получаем этот самый m3u8 файл
m3u8_data = m3u8.load(
url="" # Вставляем наш полученный ранее url
)
segments = m3u8.data.get("segments")
# Парсим файл в более удобный формат
segments_data = {}
for segment in segments:
segment_uri = segment.get("uri")
extended_segment = {
"segment_method": None,
"method_uri": None
}
if segment.get("key").get("method") == "AES-128":
extended_segment["segment_method"] = True
extended_segment["method_uri"] = segment.get("key").get("uri")
segments_data[segment_uri] = extended_segment
# И наконец качаем все сегменты с расшифровкой
uris = segments_data.keys()
downloaded_segments = []
for uri in uris:
# Используем начальный url где мы подменяем index.m3u8 на наш сегмент
audio = requests.get(url=index_url.replace("index.m3u8", uri))
# Сохраняем .ts файл
downloaded_segments.append(audio.content)
# Если у сегмента есть метод, то расшифровываем его
if segments_data.get(uri).get("segment_method") is not None:
# Качаем ключ
key_uri = segments_data.get(uri).get("method_uri")
key = requests.get(url=key_uri)
iv = downloaded_segments[-1][0:16]
ciphered_data = downloaded_segments[-1][16:]
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
data = unpad(cipher.decrypt(ciphered_data), AES.block_size)
downloaded_segments[-1] = data
complete_segments = b''.join(downloaded_segments)
И наконец конвертируем все в mp3 формат, для чего нам понадобится установленный ffmpeg на ПК.
import os
with open('../m3u8_downloader/segments/temp.ts', 'w+b') as f:
f.write(complete_segments)
os.system(f'ffmpeg -i "media/music/segments/temp.ts" -vcodec copy '
f'-acodec copy -vbsf h264_mp4toannexb "media/music/mp3/temp.wav"')
os.remove("../m3u8_downloader/segments/temp.ts")
Для меня это был довольно интересный опыт, поскольку я никогда до этого в своей жизни не работал с зашифрованными файлами и HLS протоколом, надеюсь Вам тоже было интересно читать это. Так же надеюсь я смог помочь другим людям, ведь никаких решений по скачиванию аудио с ВКонтакте на питоне в 2022 году я не нашел.
Так же выложу весь код:
Hidden text
import os
import m3u8
import requests
from vk_api import VkApi
from vk_api.audio import VkAudio
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
class M3U8Downloader:
def __init__(self, login: str, password: str):
self._vk_session = VkApi(
login=login,
password=password,
api_version='5.81'
)
self._vk_session.auth()
self._vk_audio = VkAudio(self._vk_session)
def download_audio(self, q: str):
url = self._get_audio_url(q=q)
segments = self._get_audio_segments(url=url)
segments_data = self._parse_segments(segments=segments)
segments = self._download_segments(segments_data=segments_data, index_url=url)
self._convert_ts_to_mp3(segments=segments)
@staticmethod
def _convert_ts_to_mp3(segments: bytes):
with open(f'media/music/segments/temp.ts', 'w+b') as f:
f.write(segments)
os.system(f'ffmpeg -i "media/music/segments/temp.ts" -vcodec copy '
f'-acodec copy -vbsf h264_mp4toannexb "media/music/mp3/temp.wav"')
os.remove("../m3u8_downloader/segments/temp.ts")
def _get_audio_url(self, q: str):
self._vk_audio.get_albums_iter()
audio = next(self._vk_audio.search_iter(q=q))
url = audio['url']
return url
@staticmethod
def _get_audio_segments(url: str):
m3u8_data = m3u8.load(
uri=url
)
return m3u8_data.data.get("segments")
@staticmethod
def _parse_segments(segments: list):
segments_data = {}
for segment in segments:
segment_uri = segment.get("uri")
extended_segment = {
"segment_method": None,
"method_uri": None
}
if segment.get("key").get("method") == "AES-128":
extended_segment["segment_method"] = True
extended_segment["method_uri"] = segment.get("key").get("uri")
segments_data[segment_uri] = extended_segment
return segments_data
@staticmethod
def _download_segments(segments_data: dict, index_url: str) -> bin:
downloaded_segments = []
for uri in segments_data.keys():
audio = requests.get(url=index_url.replace("index.m3u8", uri))
downloaded_segments.append(audio.content)
if segments_data.get(uri).get("segment_method") is not None:
key_uri = segments_data.get(uri).get("method_uri")
key = download_key(key_uri=key_uri)
iv = downloaded_segments[-1][0:16]
ciphered_data = downloaded_segments[-1][16:]
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
data = unpad(cipher.decrypt(ciphered_data), AES.block_size)
downloaded_segments[-1] = data
return b''.join(downloaded_segments)
@staticmethod
def download_key(key_uri: str) -> bin:
return requests.get(url=key_uri).content
login = "" # phone
password = "" # password
md = M3U8Downloader(login=login, password=password)
q = "Воллны Волны" # Запрос музыки по названию
md.download_audio()
Комментарии (17)
zederthast
16.07.2022 20:30Зачем получать музыку из вконтакте, если есть рутрекер?
tachycardiazxc Автор
16.07.2022 20:32В целом как я и написал, изначальная идея — это написание музыкального бота для дискорда. Посмотрев что уже есть в интернете я пришел к выводу, что подобную реализацию еще никто не осуществлял, а похожие реализации уже устарели. Поэтому я решил попробовать сделать ее самостоятельно и заодно подарить другим людям, которые возможно столкнуться с подобной проблемой.
qw1
16.07.2022 21:15Придётся пользователей бота просить создать vk-аккаунт и вписать его логин/пароль.
Если вся толпа юзеров будет входить через один акк, вероятно, этот акк скоро забанят.tachycardiazxc Автор
16.07.2022 21:32+1Ну моя идея скорее заключалась в том, чтобы дать людям такого бота на открытом коде и вообще изначально она возникла потому что меня раздражал уже написанный кем-то вариант на javascript, люди требовали премиум подписку за банальный шафл треков, который делается случайным перемешиванием списка, бред же?
Поэтому я написал своего бота, все исходники выложил на гитхаб, и те, кто заинтересован в таком боте могут спокойно найти его, поставить к себе на ПК или возможно даже на хост и пользоваться им в кругу друзей, для огромных серверов это конечно наверное и будет проблемой(я про бан аккаунта), однако, для простеньких серверов для своих это классное решение наверное.
Кстати сам бот:
https://github.com/tachycardiazxc/DiscordBot
bratuha
16.07.2022 20:43+2Материал хороший, за анализ и подготовку - только уважение.
В целом, складывается ощущение, что не очень то эту музыку vk и хотели прятать. Защита от скачивания это требование "правообладателей" прочих мутных контор (в т.ч. международных). Сам vk и рад бы с этим не связываться, как не связывался долгое время. Компромисс: музыка лицензирована для некоторых регионов к нахождению на сайте, но скачивать ее нельзя. Плюс подписочная модель, и в конторы что-то идет, и сам vk на этом может подзаработать.
Формально требования соблюдены, по факту же фича скачивания музыки остается на поверхности, программы и скрипты для скачивания продолжают работать.
Но как уязвимость расходится, например на хабре, поставят таск, багофичу поправят. Такое уже практиковалось.
tachycardiazxc Автор
16.07.2022 20:44Пожалуй да, но скорее всего снова найдется человек, который поймет как обойти это.
in_heb
17.07.2022 03:03Ну widevine l1 вроде как до сих пор не обошли, по крайне мере публично ничего не выложено чтобы подампить медиаконтент им защищенный
Xokare228
16.07.2022 20:48Кому надо знают (а узнать из не проблема вообще) секреты доверенных приложений через которые api получения музыки прекрасно работает и без парсинга html
kalaider
16.07.2022 20:44+2os.system('ffmpeg -i "../m3u8_downloader/mp3/temp.ts" "../m3u8_downloader/mp3/temp.mp3"')
Скачал такой сегмент, внутри mp3. Не лучше будет минимизировать реенкод, добавив
-c copy
к флагам ffmpeg? Неизвестно, сколько уже раз файл перекодировался на стороне самого vk (при загрузке, при нарезке в HLS). Конечно, если среди сегментов не только mp3 попадаются, нужно будет перекодировать, но это легко проверить тем же ffmpeg/ffprobe.
LivEEvil
18.07.2022 10:25зачем использовать ffmpeg, по сути стороннюю прогу? если можно не использовать.
На golang для Demux юзал go-astits, думаю, для других языков аналоги тоже есть
lgorSL
Большое спасибо за код, хорошо когда есть пример. Но он требует доработок. Я попробовал его запустить и мне пришлось добавить несколько исправлений:
в compile_audio сегменты могут склеиваться в неправильном порядке. Надо их как-то сортировать и не надеяться, что os.listdir() вернёт файлы в том же порядке, в котором их создали
в нескольких местах стоит добавить os.makedirs(..., exist_ok=True), так как при первом запуске никаких этих папок нет
download_segments кладёт их не туда, где они ожидаются в compile_audio и в delete_segments
Замечания по стилю кода:
Лучше вместо open("").write(...) ипользовать конструкцию with open("") as f: f.write(...), чтобы файл автоматически закрывался при выходе из блока.
Вместе с типами аргументов можно ещё указывать типы возвращаемых значений
вместо list и dict лучше из typing указывать что-то вроде List[Dict[str, Any]]
lgorSL
Можно вообще не сохранять ключи и сегменты во временные файлы, код сильно упростится.
и потом при необходимости сохранить в файл:
lgorSL
А ещё по-умолчанию ffmpeg перекодирует с битрейтом 128 кбит/с. С опцией "-acodec copy" вроде получше - ничего не перекодируется, данные просто копируются.
tachycardiazxc Автор
Спасибо большое за замечания! для меня это очень важный опыт и очень важные замечания. Я учту их, так же подправлю код!
Helltraitor
3 совет дурной, смотри документацию