Треки без прямой ссылки на файл

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 из коротких сегментов. Плеер играет оба, а скачивание раньше умело только первый — отсюда жалоба «трек слушается, но не скачивается».

Сейчас скачивается всё, что сервер отдаёт без защиты:

  1. авторская ссылка «Download file», если артист её включил;
  2. любой поток из media.transcodings, с перебором по порядку;
  3. если приложению не дали ничего — тот же трек запрашивается как веб-плеером;
  4. 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: ".