Для разработчиков
Внутреннее устройство url-media-probe: архитектура, потоки данных, как добавлять новые форматы.
Структура проекта
url_media_probe/
├── __init__.py # Публичный API: MediaProbe, MediaInfo, readers
├── media_probe.py # Главный класс — цикл инспекции (_probe)
├── media_info.py # Frozen dataclass MediaInfo — результат
├── range_readers.py # Абстракция чтения: RangeDownloader / RangeFileReader / RangeBytesReader
├── py.typed # PEP 561 маркер
└── parsers/
├── __init__.py # parse_media_dimensions() — точка входа в парсеры
├── _types.py # ParseResult — контракт парсер → MediaProbe
├── _constants.py # Лимиты чтения (MAX_HEAD, MAX_TAIL и т.д.)
├── image.py # ImageParser: PNG, JPEG, GIF, WEBP
├── video.py # VideoParser: MP4/M4A, AVI, MKV, WebM, OGV, OGG, HLS, M3U
└── audio.py # AudioParser: MP3, WAV, FLAC, AAC, WMA (+ делегирование в video для OGG/M4A)
Архитектура: поток от вызова до результата
Пользователь
│
▼
MediaProbe.from_url() / from_file() / from_bytes()
│
│ 1. Создаёт RangeReader (Downloader / File / Bytes)
│
▼
MediaProbe._probe(reader)
│
│ 2. Цикл: reader отдаёт head (и опционально tail)
│ reader.__aiter__ → reader.head, reader.tail, reader.meta
│
│ 3. parse_media_dimensions(head, tail, file_size)
│ ├── ImageParser.parse() → ParseResult | None
│ ├── VideoParser.parse() → ParseResult | None
│ └── AudioParser.parse() → ParseResult | None
│
│ 4. _split_parse_result(raw) → dims dict, needs dict, status
│ Если status == "ok" → _build_media_info() → MediaInfo
│ Если status == "partial" → reader.pending_needs = needs → goto 2
│
▼
MediaInfo (frozen dataclass)
ParseResult — контракт парсер → MediaProbe
url_media_probe/parsers/_types.py
Типизированный dataclass, который каждый парсер возвращает вместо dict[str, Any]:
@dataclass
class ParseResult:
format: str | None = None # "PNG", "MP4", "AAC" ...
width: int | None = None
height: int | None = None
duration: float | None = None # секунды
fps: float | None = None
sample_rate: int | None = None # Гц
bitrate_nominal: int | None = None # кбит/с (из метаданных)
bitrate_avg: int | None = None # кбит/с (вычисляется из size/duration)
video_codec: str | None = None # "h264", "vp8", "av1" ...
video_codec_long: str | None = None
audio_codec: str | None = None # "aac", "mp3", "opus" ...
audio_codec_long: str | None = None
status: Literal["ok", "partial"] = "partial"
parse_note: str = ""
need_head: int = 0 # -1 = нужен head, >0 = нужен head такого размера
need_tail: int = 0 # 0 = не нужен, >0 = нужен tail (от конца файла, назад)
need_slice: tuple[int, int] | None = None # (offset, size): слайс от известной позиции, вперёд
need_url: str = "" # non-empty = нужна докачка по другому URL
Свойство ok: True если status == "ok".
Парсер возвращает ParseResult(status="ok") когда данных достаточно, или ParseResult(status="partial", need_head=N) когда нужно больше данных.
Поля need_head / need_tail / need_slice — взаимодополняющие запросы на докачку:
- need_tail=N — конец файла неизвестен (например moov у потокового MP4 с mdat size=0): reader заякоривает tail на EOF и растёт назад, докачивая только недостающий префикс.
- need_slice=(offset, size) — позиция вычислена из head (например начало moov = конец mdat): reader заякоривает tail на offset и растёт вперёд, при смене якоря буфер сбрасывается.
RangeReader — абстракция источника данных
url_media_probe/range_readers.py
Три реализации, единый интерфейс __aiter__. Лимиты чтения max_total (максимум байт) и max_ratio объявлены один раз в базовом классе RangeReader и наследуются всеми реализациями:
| Класс | Источник | Лимиты по умолчанию |
|---|---|---|
RangeDownloader |
HTTP URL | max_total=256 000, max_ratio=0.1 |
RangeFileReader |
Локальный файл | max_total=20 971 520, max_ratio=1.0 (чтение целиком) |
RangeBytesReader |
bytes / BytesIO | max_total=20 971 520, max_ratio=1.0 (чтение целиком) |
Каждый reader при итерации заполняет:
- reader.head — начало файла (bytes)
- reader.tail — единый буфер хвоста (bytes): либо конец файла (якорь на EOF), либо слайс от вычисленной парсером позиции (moov у MP4)
- reader.tail_start — абсолютное смещение tail[0] в файле (для EOF-хвоста file_size - len(tail))
- reader.meta — ReaderMeta (content_type, file_name, file_size)
- reader.pending_needs — dict с запросами парсера ("_need_head", "_need_tail", "_need_slice")
Общий бюджет. Все reader-ы считают бюджет по одному правилу (RangeReader._budget()): file_size неизвестен → max_total; иначе min(max_total, round(file_size · max_ratio)), но не меньше INITIAL_HEAD. Head, tail и слайс суммарно не превышают бюджет, каждая порция ограничена остатком бюджета.
Начальный head:
RangeDownloader._initial_head_size()— минимум:INITIAL_HEAD(4 КБ) или размер файла, если он меньше (в пределах бюджета). head заранее почти не скачивается: парсер после первой итерации сам решает, что нужно, и reader качает ровно запрошенное.RangeReader._initial_head_size()(файлы/bytes) — минимумINITIAL_HEADили размер файла, если он меньше.
tail = RangeReader._tail_limit() — верхняя граница объёма tail (не «заказ» на всю порцию сразу: парсеры сообщают need_tail, а reader растёт от уже скачанного хвоста до запрошенного):
min(MAX_TAIL, _budget())
Для HTTP-дефолта (256 КБ, 0.1) бюджет для больших файлов — round(256 000 · 0.1) ≈ 25.6 КБ < MAX_TAIL, поэтому tail по умолчанию ~25.6 КБ. Для локального чтения (max_ratio=1.0, max_total=20 МБ) бюджет равен размеру файла, и tail упирается в лимит MAX_TAIL = 64 КБ.
Резервирования места под tail в бюджете нет: head минимален, поэтому бюджет тратится только на то, что реально запросил парсер (head/tail/слайс).
Если размер файла неизвестен (нет Content-Length) — бюджет равен max_total.
max_ratio=0 или max_total=0 (HTTP) → режим «только заголовки»: тело не скачивается, MediaProbe.from_url() строит MediaInfo(status="ok") по reader.meta.
Цикл итерации:
1. Reader читает начальный блок по _initial_head_size()
2. Парсер анализирует, возвращает ParseResult
3. Если status == "partial", reader выполняет запросы из pending_needs: _need_head (докачка head), _need_tail (рост tail назад от EOF), _need_slice (слайс от известной позиции вперёд)
4. Повторяет до status == "ok" или исчерпания лимитов (MAX_HEAD для head, _tail_limit() для tail, бюджет — общий лимит скачивания)
Парсеры: общая архитектура
Точка входа
parsers/__init__.py → parse_media_dimensions(head, tail, file_size, tail_start=0):
ImageParser.parse() → None? → VideoParser.parse() → None? → AudioParser.parse() → None?
Порядок важен: изображения проверяются первыми (самые быстрые), затем видео, затем аудио.
ImageParser (parsers/image.py)
Статический парсер — анализирует только head (не требует tail).
Методы-детекторы сигнатур → методы-парсеры:
| Сигнатура | Метод | Что извлекает |
|---|---|---|
\x89PNG + IHDR |
_png_parse |
width, height |
RIFF + WEBP |
_webp_parse |
width, height, duration, fps (для анимации) |
\xff\xd8 (JPEG) |
_jpeg_parse |
width, height (по маркеру SOF) |
GIF87a/GIF89a |
_gif_parse_info |
width, height, duration, fps (по GCE) |
Анимированные форматы (GIF, WebP) могут запросить need_head для докачки, если текущего head недостаточно для определения длительности.
VideoParser (parsers/video.py)
Крупнейший парсер (~1226 строк). Обрабатывает контейнерные форматы.
| Формат | Метод | Ключевые техники |
|---|---|---|
| MP4/M4A | _mp4_m4a_parse_info |
Парсинг атомов: ftyp → moov → mvhd (duration) → trak → tkhd (dims) + mdia → stbl → stsd (codec) |
| AVI | _avi_parse_info |
RIFF: hdrl → avih (duration) + strl → strh (stream type, fps, sample_rate) + strf (codec, dims) |
| MKV/WebM | _webm_mkv_parse_info |
EBML: Segment → Info (duration) + Tracks (codec, dims, sample_rate) |
| OGV/OGG | _ogg_parse_info |
OggS pages → Vorbis/Theora/Opus headers → granule positions для duration |
| HLS | _hls_parse_info |
Парсит m3u8 плейлист, определяет duration из EXT-X-PLAYLIST-TYPE и EXT-X-ENDLIST |
| M3U | _m3u_parse_info |
Парсит простые m3u плейлисты (без HLS), определяет duration по сумме песен |
Детектирование формата в parse(): проверяет сигнатуры в порядке:
1. MP4 (ftyp или moov/mdat)
2. RIFF (AVI или WAV)
3. OggS
4. EBML (MKV/WebM)
5. #EXTM3U (HLS/M3U)
Внутренние хелперы MP4:
- _mp4_parse_atoms — рекурсивный парсер атомов (box format)
- _mp4_moov_parse — обходит moov → trak → mdia, собирает dims dict
- _mp4_parse_tkhd — извлекает width/height из Track Header Box
- _mp4_parse_stsd_codec — извлекает codec из Sample Description Box (avc1→h264, hev1→h265, mp4a→aac, opus→opus)
- _mp4_parse_mvhd_duration — из Media Header Box
- _mp4_parse_tkhd_duration — из Track Header Box v1
Внутренние хелперы AVI:
- _parse_avih — main AVI header (duration, total_frames, width, height)
- _parse_strl — обходит stream list (vids/auds), вызывает _parse_strh + _parse_strf
- _parse_strh — stream header (fccHandler → video codec, sample_rate)
- _parse_strf — stream format (wFormatTag → audio codec, dims)
Кодек-мапы:
- _MP4_CODEC_MAP — "avc1" → "h264", "mp4a" → "aac", "opus" → "opus" и т.д.
- _AVI_VIDEO_CODEC_MAP — "mp4v" → "mpeg4", "cvid" → "cinepak", "av01" → "av1" и т.д.
- _AVI_AUDIO_FMT_MAP — 1 → "pcm_s16le", 0x55 → "mp3", 0x0001 → "pcm" и т.д.
AudioParser (parsers/audio.py)
| Формат | Метод | Ключевые техники |
|---|---|---|
| MP3 | _mp3_parse_info |
Поиск frame sync (0xFFE0+), Xing/VBRI заголовки для duration, ID3 теги |
| WAV | _wav_parse_info |
RIFF: fmt chunk (sample_rate, byte_rate) + data chunk (audio_bytes → duration) |
| FLAC | _flac_parse_info |
fLaC: STREAMINFO block (sample_rate, total_samples → duration) |
| AAC | _aac_parse_info |
ADTS frame sync (0xFFF0+), подсчёт кадров, экстраполяция duration |
| WMA | _wma_parse_info |
ASF: FilePropertiesObject (duration) + StreamPropertiesObject (sample_rate, bitrate) |
Делегирование: _ogg_ogv_check → VideoParser._ogg_parse_info, _m4a_check/_mp4_check → VideoParser._mp4_m4a_parse_info.
Цикл инспекции: детали
MediaProbe._probe(reader) в media_probe.py:
for chunk in reader: # reader отдаёт head/tail порциями
if not reader.head → error
if looks_like_html(head) → error
raw = parse_media_dimensions(head, tail, file_size, tail_start) # ParseResult | None
if raw is None → partial (неизвестный формат)
dims, needs, status = _split_parse_result(raw) # разделяет data + service fields
if status == "ok" → _build_media_info(dims) → return MediaInfo
reader.pending_needs = needs # передаёт запросы reader'у для докачки
# После цикла: если есть need_url → докачка по другому URL (HLS master playlist)
# Финальный fallback → _build_media_info(dims, status) → MediaInfo
_split_parse_result(raw): преобразует ParseResult в tuple (dims_dict, needs_dict, status):
- dims_dict — только пользовательские поля (format, width, height, duration, fps, sample_rate, bitrate_nominal, video_codec, audio_codec, parse_note)
- needs_dict — {"_need_head": N} и/или {"_need_tail": N} и/или {"_need_slice": (offset, size)}
- Обратная совместимость: если raw — dict (старый формат), парсит ключи _need_head, _need_tail, _need_slice, _status
_build_media_info(dims, ...): создаёт MediaInfo из dims dict:
- bitrate_avg вычисляется как file_size / duration * 8 / 1024 (если не задан)
- duration округляется: до 1 decimal если < 10 сек, иначе до целого
- MIME-тип определяется из _FORMAT_TO_MIME (формат → mime)
Как добавить новый формат
Изображение
- Добавить метод-детектор
_xxx_check(data: bytes) -> boolвImageParser - Добавить метод-парсер
_xxx_parse(data: bytes) -> ParseResult | None - Вызвать детектор и парсер в
ImageParser.parse()(порядок важен!) - Добавить fixture в
tests/fixtures/fixtures.json - Добавить тест в
tests/test_parsers.py
Видео/аудио (контейнер)
- Добавить сигнатуру в
VideoParser.parse()(метод-детектор + порядок) - Добавить метод
_xxx_parse_info(head, tail, file_size) -> ParseResult | None - Реализовать парсинг заголовков контейнера
- Заполнить
ParseResult:format,width/height,duration,fps,sample_rate,bitrate_nominal,video_codec,audio_codec - Добавить формат в
_FORMAT_TO_MIME(media_probe.py) и Literal-типformatв MediaInfo - Добавить fixture + тесты
Аудио-кодек (внутри контейнера)
- Добавить кодек-мап (аналог
_MP4_CODEC_MAPили_AVI_AUDIO_FMT_MAP) - Парсить кодек изSample Description Box / Stream Format chunk
- Заполнять
audio_codec(краткий) иaudio_codec_long(полное название)
Константы
url_media_probe/parsers/_constants.py:
| Константа | Значение | Описание |
|---|---|---|
INITIAL_HEAD |
4096 | Минимальный head (и значение при неизвестном размере) |
MAX_HEAD |
256 000 | Максимальный объём head (лимит докачки) |
MAX_TAIL |
65 536 | Верхний предел tail (лимит _tail_limit()) |
MOOV_TAIL_START |
4096 | Первый чанк хвоста потокового MP4 (moov в конце) |
OGG_TAIL_START |
4096 | Первый чанк хвоста OGG (хватит на последнюю страницу) |
MP3_TAIL_FRAME |
8192 | Докачка MP3, когда в head не нашёлся фрейм |
MID_REUSE_MIN |
65 536 | Мин. объём tail-слайса для переиспользования при смене якоря |
EXPAND_CHUNK |
4096 | Размер шага докачки head |
NEED_HEAD |
"_need_head" |
Ключ в pending_needs (для reader) |
NEED_TAIL |
"_need_tail" |
Ключ в pending_needs (для reader) |
NEED_SLICE |
"_need_slice" |
Ключ для запроса слайса от известной позиции |
NEED_URL |
"_need_url" |
Ключ для запроса докачки по URL |
PARSE_STATUS |
"_status" |
Ключ статуса (legacy, используется в _split_parse_result) |
Дополнительно лимиты max_total и max_ratio задаются в RangeReader.__init__ (см. выше). Бюджет — min(max_total, file_size · max_ratio) (не меньше INITIAL_HEAD): после его исчерпания reader не читает больше данных. Резервирования нет: head минимален, и бюджет тратится только на запрошенное парсером (каждая порция ограничена остатком budget − downloaded).
Фикстуры и тесты
Структура
tests/
├── fixtures/
│ ├── fixtures.json # Все фикстуры: base64 head/tail + expected поля
│ └── prepare_fixtures.py # Скрипт для скачивания и создания фикстур
├── conftest.py # Pytest-фикстуры: all_fixtures, fixture_bytes_io
├── helpers.py # make_mock_session, make_fixture_named_bytes_io и т.д.
├── test_parsers.py # Unit-тесты парсеров (прямой вызов методов)
├── test_media_probe.py # Интеграционные тесты MediaProbe
├── test_media_info.py # Тесты MediaInfo
└── test_range_readers.py # Тесты reader'ов
Фикстуры
Физических бинарных файлов нет. Все данные хранятся в fixtures.json как base64:
{
"mp4_h264": {
"head_b64": "AAAAG3ZpZ...", // начало файла (base64)
"tail_b64": "AAAADHZpZ...", // конец файла (base64, опционально)
"url": "https://example.com/v.mp4",
"mime_type": "video/mp4",
"file_size": 1048576,
"format": "MP4",
"width": 1920,
"height": 1080,
"duration": 15.0,
"video_codec": "h264",
"audio_codec": "aac"
}
}
Конвенция: только video_codec и audio_codec (короткие имена), без _long вариантов.
Как добавить новую фикстуру
Используется tests/fixtures/prepare_fixtures.py.
Автоматический режим (рекомендуется):
- Добавить запись в
SAMPLE_URLSвprepare_fixtures.py:
SAMPLE_URLS = {
...
"my_new_format": {
"url": "https://example.com/sample.webm",
},
}
- Запустить:
cd tests/fixtures
python prepare_fixtures.py my_new_format
Скрипт скачает файл, запустит MediaProbe, получит все expected-поля автоматически.
- Проверить полученные значения в
fixtures.jsonвручную перед коммитом.
Ручной режим (USE_AUTOMATIC_EXPECTED = False):
Указать expected-поля явно:
"my_new_format": {
"url": "https://example.com/sample.webm",
"expected": {"format": "WEBM", "width": 1280, "height": 720},
"head_size": 8192,
}
Тесты
Unit-тесты парсеров (test_parsers.py):
- Прямой вызов методов парсеров (ImageParser._jpeg_parse, VideoParser._mp4_m4a_parse_info и т.д.)
- Тесты edge cases и покрытие веток
- Используют hand-crafted байты или данные из fixtures.json
Интеграционные тесты MediaProbe (test_media_probe.py):
Три подхода к тестированию:
from_bytes_range— самый простой для fixture-тестов:
@pytest.mark.parametrize("name", FIXTURES_ID)
async def test_format_detected(self, name, all_fixtures):
head, tail, exp = all_fixtures[name]
probe = MediaProbe()
info = await probe.from_bytes_range(head, tail, exp["file_size"])
assert info.format == exp["format"]
Не требует mock session, тестирует полный pipeline: reader → _probe → parsers → MediaInfo.
from_url+ mock session — для тестирования HTTP-специфичного поведения:
session = make_mock_session(head, tail, mime, file_size)
info = await probe.from_url("https://example.com/test", session=session)
Используется для: retry, ошибки сети, HTML-страницы, Range-запросы.
from_bytes/from_file— для тестирования других reader'ов.
Тесты reader'ов (test_range_readers.py):
- Тесты RangeDownloader, RangeFileReader, RangeBytesReader
- Моки HTTP-ответов, edge cases чтения
Соглашения по коду
- Все парсеры возвращают
ParseResult bitrate_nominal— номинальный битрейт из метаданных (не вычисляется)bitrate_avg— вычисляется в_build_media_infoкакfile_size / duration- Все docstrings и сообщения на русском языке
formatвParseResult— строковый идентификатор ("PNG","MP4"), не MIMEstatus:"ok"— данные полные,"partial"— нужны дополнительные данные