Треки без прямой ссылки на файл
September 21, 2026 · View on GitHub
Отчёт о том, как Arsound достаёт аудио, когда цельного файла нет, где проходит предел возможного и какие ловушки встретились по дороге. Всё проверено живьём на SoundCloud 2026.09.02-release, Android 16 (SM-S938B), 20.09.2026.
Код: HlsDownloader,
ClientProfiles,
TrackSource,
DownloadTrackPatch.
Коротко
SoundCloud отдаёт трек одним из двух способов: цельным файлом (progressive) или плейлистом HLS из
коротких сегментов. Плеер играет оба, а скачивание раньше умело только первый — отсюда жалоба «трек
слушается, но не скачивается».
Сейчас скачивается всё, что сервер отдаёт без защиты:
- авторская ссылка «Download file», если артист её включил;
- любой поток из
media.transcodings, с перебором по порядку; - если приложению не дали ничего — тот же трек запрашивается как веб-плеером;
- HLS собирается самим приложением: сегменты, ключ, расшифровка, упаковка в один файл.
Не скачивается: отрывки (SNIP), треки по подписке (SUB, GO_PLUS) и треки под DRM
Предел: DRM
Часть треков живёт только на потоках cbc-encrypted-hls и ctr-encrypted-hls. Их плейлист
выглядит так:
#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://dcbfe1e388a55718a192063b2058e91f",KEYFORMAT="com.apple.streamingkeydelivery"
Это FairPlay (у cenc-варианта — Widevine/PlayReady). Ключ выдаёт лицензионный сервер модулю
защиты внутри плеера, а не HTTPS-ссылка. Достать его можно только взломом защиты контента
Что вместо этого: плейлист проверяется до скачивания (HlsDownloader.isDrmProtected), DRM-потоки
пропускаются, и если других не осталось — трек получает статус DRM_PROTECTED. В меню трека: «этот
трек защищён DRM: его можно слушать, но не сохранить». В проверке плейлиста:
✕ <название> (защищён DRM).
Признаки DRM в плейлисте: METHOD начинается с SAMPLE-AES, либо URI ключа не https
(например skd://), либо KEYFORMAT отличается от identity. Обычный HLS с METHOD=AES-128 и
ключом по HTTPS — не DRM, он скачивается.
Живой пример: трек 2284441712 «Тело похудело» — у него все восемь транскодингов либо отвечают
404, либо под DRM.
Этап 1. Сборка трека из HLS
1.1. Выбор потока
Перебираются все транскодинги по порядку: цельный файл, затем незашифрованный hls, затем
cbc-encrypted-hls и ctr-encrypted-hls; внутри каждого вида низкое качество (lq) идёт
последним. Транскодинг, который этому клиенту не положен, отвечает 404, поэтому перебор не
останавливается на первом отказе — раньше останавливался, и трек считался недоступным.
Ссылка на транскодинг обменивается на ссылку .m3u8. Плейлист сначала проверяется на DRM, и только
потом разбирается.
1.2. Разбор плейлиста
Собственный разбор достаёт:
- список URI сегментов (относительные ссылки разворачиваются от адреса плейлиста);
#EXT-X-KEY—METHOD,URIключа,KEYFORMATи необязательныйIV;#EXT-X-MAP— заголовки фрагментированного MP4, отдельный первый сегмент;#EXT-X-MEDIA-SEQUENCE— начальный номер сегмента.
Ловушка 1. SoundCloud отдаёт HLS фрагментированным MP4 (styp/sidx), а не транспортным
потоком MPEG-TS, как предполагал план. Без сегмента #EXT-X-MAP (там ftyp и moov) файл не
читается вообще: MediaExtractor падает с Failed to instantiate extractor, и в первом прогоне
сохранялся мёртвый файл на 1,8 МБ.
Ловушка 2. Сегмент #EXT-X-MAP не шифруется: если прогнать его через расшифровку вместе с
остальными, заголовки превращаются в мусор. Он скачивается как есть (служебный номер
последовательности -1).
1.3. Ключ
Ключ скачивается по URI из #EXT-X-KEY, проверяется на длину 16 байт и кэшируется: у плейлиста он
один на все сегменты.
Если IV в манифесте нет, используется порядковый номер сегмента — стандартное поведение HLS.
1.4. Скачивание и расшифровка
Сегменты качаются по четыре параллельно, но записываются строго в порядке плейлиста. Режим берётся
из METHOD: AES/CBC/NoPadding или AES/CTR/NoPadding.
Нюанс. Дополнение PKCS#7 у CBC снимается вручную, а не режимом PKCS5Padding: часть сегментов
приходит вообще без дополнения, и расшифровка таких падала бы с BadPaddingException.
1.5. Упаковка
Контейнер определяется по первым байтам собранного файла:
ftyp/styp— фрагментированный MP4 (так отдаёт SoundCloud). Вместе с сегментом#EXT-X-MAPэто уже готовый.m4a, переупаковка не нужна;- иначе транспортный поток: аудиодорожка переносится через
MediaExtractor→MediaMuxer(MUXER_OUTPUT_MPEG_4) в.m4a. Если поток повторяет свои часы, метки времени сдвигаются вперёд, иначе получился бы битый файл; - если дорожки нет и упаковка не удалась — сохраняются расшифрованные байты как есть, с расширением
по
mime_type.
Ловушка 3. Тип для MediaStore обязан совпадать с расширением: .aac с типом audio/mp4 дало
файл soundcloud-<id>.aac.m4a — система дописала своё расширение, и имя разошлось с тем, что
записано в настройках приложения. Сейчас .m4a → audio/mp4, .aac → audio/aac,
.opus → audio/ogg, остальное → audio/mpeg.
Готовый файл кладётся в Музыка/Arsound рядом с файлами системного загрузчика. С Android 10
приложение не пишет туда напрямую, поэтому файл создаётся через MediaStore с IS_PENDING; на
более старых версиях — обычной записью в файл.
Этап 2. Запрос от имени веб-плеера
Корень жалобы «всё так же недоступно». Приложению SoundCloud отдаёт меньше, чем сайту: тот же
трек мобильному клиенту приходит с policy=SNIP и 404 на все потоки, а веб-плееру — целиком
(MONETIZE, AD_SUPPORTED, snipped=false, полная длительность). Поэтому веб-профиль спрашивается
всегда, когда обычный ответ не дал качаемого источника, в том числе когда мобильный ответ назвал
трек отрывком. Раньше этап 2 запускался только если потоков не было вовсе — и такие треки молча
считались отрывками.
Запрос: GET /tracks/<id>?client_id=<id> с десктопным User-Agent (пробуются Chrome и Safari).
client_id не зашит в код — он меняется. Он читается со страницы soundcloud.com: из подключённых
скриптов плеера, начиная с последних (id лежит в одном из последних бандлов), и хранится сутки.
Нюанс. Потоки веб-ответа нельзя резолвить по OAuth-токену приложения: нужен
client_id + track_authorization из самого ответа. Без track_authorization endpoint отвечает
{}, с чужим OAuth — 404.
Этап ищет поток только для треков, которые пользователю и так доступны целиком: отрывки и подписочные треки веб-ответа отсеиваются той же проверкой, DRM-потоки пропускаются.
Этап 3. Статусы и интерфейс
Вместо «успех / недоступно» источник трека описывается TrackSource.Status:
| Статус | Что значит |
|---|---|
READY | есть цельный файл, качает системный загрузчик |
REQUIRES_HLS_PROCESSING | есть только HLS, трек собирается приложением |
REQUIRES_PROFILE_SEARCH | потоков нет, запускается этап 2 |
AUDIO_ONLY_PREVIEW | отрывок (SNIP) |
SUBSCRIPTION_REQUIRED | нужна подписка (SUB, GO_PLUS) |
DRM_PROTECTED | остались только потоки под FairPlay или Widevine |
UNAVAILABLE | блокировка, приватный или удалённый трек |
В меню трека причина отказа называется прямо: «это только отрывок трека», «этот трек доступен по подписке», «этот трек защищён DRM: его можно слушать, но не сохранить». При сборке из HLS показывается «собираю трек из потока», по окончании — «трек сохранён».
В проверке плейлиста треки с REQUIRES_HLS_PROCESSING попадают в «можно скачать» с пометкой
«фоновая обработка», а у недоступных в списке указывается причина. Пока сегменты качаются и
упаковываются, DownloadProgress держит трек в «скачивается»: системный загрузчик о нём не знает,
поэтому такие треки добавляются к его опросу отдельно, и повторно скачать их нельзя.
Что проверено живьём
- HLS-сборка: трек
1428151939, 11 сегментов (10 +#EXT-X-MAP), файлsoundcloud-1428151939.m4a, 1 851 897 Б.ffprobe: aac, 44 100 Гц, stereo, 91,641 с — ровно длительность трека по API;ffmpeg -f null -декодирует без ошибок. - Обычный путь на том же треке:
f7tllzyMDkVb.128.mp3, 1 466 617 Б. - Этап 2: в логе
ClientProfiles: Web client id found, затемDRM protected stream for 2284441712: cbc-encrypted-hls— веб-профиль опрошен, потоки найдены и отвергнуты как DRM. - Проверка плейлиста на 113 треках: «можно скачать 111, недоступно 2»; на плейлисте из 38 треков —
✕ Тело похудело (защищён DRM). - Удаление: кнопка в меню трека удалила файл; диалог плейлиста посчитал 37 файлов (отменён, файлы на месте).
Нюанс проверки. Ветку HLS почти невозможно поймать в бою: у живых треков прогрессивный поток
есть почти всегда (проверено 60 треков через веб-API и 113 через проверку плейлиста). Ветка гонялась
временной сборкой с флагом FORCE_HLS = true, где авторская ссылка и progressive выключены. Флаг
в репозиторий не попал.
Что не проверено
- Этап 2 с успешным исходом: трека, которому веб-плеер даёт поток, а приложение — нет, среди
проверенных не попалось. Подтверждено только то, что
client_idсо страницы читается и запрос от имени веб-плеера уходит. - Ветка с транспортным потоком MPEG-TS (
MediaExtractor→MediaMuxer): SoundCloud отдаёт фрагментированный MP4, TS не встретился ни разу. Код оставлен как запасной путь. - Запись через
MediaStoreна Android ниже 10: под рукой только Android 16.
Прочие нюансы
- Запросы к
/tracks/<id>/downloadчасто отвечают404(у автора не включена ссылка) или429(слишком часто при проверке большого плейлиста) — это норма, перебор идёт дальше. - Ссылки на потоки живут недолго, поэтому источник из диалога проверки плейлиста считается протухшим через 5 минут и резолвится заново.
- Проверка плейлиста на 113 треках занимает около двух минут: на каждый трек уходит отдельный запрос ссылки, иначе заблокированные в регионе треки попадали бы в «можно скачать».
Logger.printInfoпишется в logcat всегда, без включения отладки:adb logcat | grep "revanced: ".