У каждого разработчика есть папка, в которую страшно заглядывать. У меня таких две: node_modules и Music_Old_2007_BACKUP_FINAL_2.
Вторая - это страшная археологическая свалка из MP3-файлов, которые я собирал с 2004 года. Тогда я был студентом, интернет - dial-up, и музыку мы таскали друг к другу на жестких дисках. Приходишь к другу с винтом на 40 гигов, уходишь с новой коллекцией. Рипы с кассет, записи с радио, треки непонятного происхождения с файлопомоек. Половина без тегов, четверть с тегами типа «Íåèçâåñòíûé èñïîëíèòåëü», остальное — Track01.mp3, New Folder (2)/asdf.mp3.
В какой-то момент я понял: либо я это разберу, либо оно так и умрет нераспознанным. Shazam на телефоне? Страшно представить, сколько времени уйдёт на 12 000 файлов. Значит, автоматизируем.
Проблема глубже, чем кажется
Казалось бы - возьми API Shazam, прогони файлы, получи результат. Но нет.
Проблема №1: Shazam не даёт публичный API
Официального API для разработчиков нет. Есть Apple Music API, но он про другое. Пришлось использовать реверс-инженерию - библиотеку shazamio, которая эмулирует запросы мобильного приложения.
Проблема №2: Качество исходников
Мои файлы — это не FLAC с винила. Это 128 kbps рипы, записи с радио с обрезанными началами, треки с артефактами от древних кодеков. Shazam такое не всегда переваривает.
Проблема №3: Скорость
12 000 файлов × 5 секунд на распознавание = 16+ часов. А если делать последовательно и сеть моргнёт — начинай сначала.
Проблема №4: Что делать с результатом?
Получить название трека - полдела. Надо ещё записать теги, переименовать файл, разложить по папкам. И не сломать то, что уже было размечено.
Архитектура решения
Я разбил задачу на три слоя:
┌─────────────────────────────────────────────────────┐ │ recognize.py │ │ CLI-интерфейс, точка входа │ ├─────────────────────────────────────────────────────┤ │ music.py │ │ Бизнес-логика: конвертация, теги, организация │ ├─────────────────────────────────────────────────────┤ │ async_music.py │ │ Асинхронное распознавание через Shazam │ └─────────────────────────────────────────────────────┘
Асинхронность — наше всё
Главное узкое место — сеть. Пока ждём ответ от Shazam, можно отправить следующий запрос. Поэтому ядро системы — асинхронное:
import asyncio from shazamio import Shazam class AsyncMusicRecognizer: def __init__(self, max_concurrent: int = 5): self.shazam = Shazam() self.semaphore = asyncio.Semaphore(max_concurrent) async def recognize_file(self, file_path: str) -> dict | None: async with self.semaphore: # Не больше 5 одновременных запросов try: result = await self.shazam.recognize(file_path) if result and 'track' in result: track = result['track'] return { 'title': track.get('title'), 'artist': track.get('subtitle'), 'album': track.get('sections', [{}])[0].get('metadata', [{}])[0].get('text'), 'year': self._extract_year(track), 'genre': track.get('genres', {}).get('primary'), } except Exception as e: logging.warning(f"Failed to recognize {file_path}: {e}") return None async def recognize_batch(self, files: list[str]) -> dict[str, dict]: tasks = [self.recognize_file(f) for f in files] results = await asyncio.gather(*tasks, return_exceptions=True) return {f: r for f, r in zip(files) if r and not isinstance(r, Exception)}
Семафор — ключевой момент. Без него можно легко получить бан от Shazam за слишком агрессивные запросы. 5 параллельных запросов — эмпирически подобранный баланс между скоростью и стабильностью.
Конвертация: не все форматы одинаково полезны
Shazam принимает не всё подряд. Лучше всего работает с WAV и MP3. А у меня в коллекции — WMA, OGG, FLAC, M4A и даже пара APE-файлов (привет, 2005 год).
from pydub import AudioSegment import os class MusicService: SUPPORTED_FORMATS = {'.mp3', '.wav', '.ogg', '.flac', '.m4a', '.wma', '.aac'} def convert_to_mp3(self, input_path: str, output_dir: str = None) -> str: """Конвертирует аудио в MP3 для распознавания.""" ext = os.path.splitext(input_path)[1].lower() if ext == '.mp3': return input_path if ext not in self.SUPPORTED_FORMATS: raise ValueError(f"Unsupported format: {ext}") # Определяем выходной путь output_dir = output_dir or os.path.dirname(input_path) base_name = os.path.splitext(os.path.basename(input_path))[0] output_path = os.path.join(output_dir, f"{base_name}_converted.mp3") # Конвертируем audio = AudioSegment.from_file(input_path) audio.export(output_path, format='mp3', bitrate='192k') return output_path def convert_files_to_mp3(self, directory: str) -> list[str]: """Массовая конвертация директории.""" converted = [] for root, _, files in os.walk(directory): for file in files: if os.path.splitext(file)[1].lower() in self.SUPPORTED_FORMATS: try: result = self.convert_to_mp3(os.path.join(root, file)) converted.append(result) except Exception as e: logging.error(f"Conversion failed for {file}: {e}") return converted
Тегирование: уважай чужой труд
Когда Shazam вернул результат, надо записать теги. Но есть нюанс: файл мог быть уже частично размечен. Может, я вручную подписал исполнителя, но не знал названия альбома. Не хочется затирать эту информацию.
from mutagen.easyid3 import EasyID3 from mutagen.id3 import ID3, ID3NoHeaderError class TagService: def apply_tags(self, file_path: str, metadata: dict, overwrite: bool = False): """Применяет теги к файлу.""" try: audio = EasyID3(file_path) except ID3NoHeaderError: audio = EasyID3() audio.save(file_path) audio = EasyID3(file_path) tag_mapping = { 'title': 'title', 'artist': 'artist', 'album': 'album', 'year': 'date', 'genre': 'genre', } for meta_key, tag_key in tag_mapping.items(): if meta_key in metadata and metadata[meta_key]: # Записываем только если тег пустой или разрешена перезапись existing = audio.get(tag_key, [''])[0] if overwrite or not existing or existing == 'Unknown': audio[tag_key] = str(metadata[meta_key]) audio.save()
Флаг overwrite=False по умолчанию — уважение к моим прошлым усилиям по разметке.
CLI: для тех, кто любит терминал
#!/usr/bin/env python3 """ Music Recognition CLI Распознавание музыки через Shazam с автоматическим тегированием. Использование: python recognize.py /path/to/music python recognize.py /path/to/music --organize --output ./sorted """ import argparse import asyncio from pathlib import Path def main(): parser = argparse.ArgumentParser(description='Bulk music recognition via Shazam') parser.add_argument('path', help='Path to audio file or directory') parser.add_argument('--organize', action='store_true', help='Organize files into Artist/Album structure') parser.add_argument('--output', '-o', help='Output directory for organized files') parser.add_argument('--dry-run', action='store_true', help='Show what would be done without making changes') parser.add_argument('--concurrent', '-c', type=int, default=5, help='Max concurrent requests') args = parser.parse_args() path = Path(args.path) if not path.exists(): print(f"Error: {path} does not exist") return 1 # Собираем файлы if path.is_file(): files = [str(path)] else: files = [str(f) for f in path.rglob('*') if f.suffix.lower() in AUDIO_EXTENSIONS] print(f"Found {len(files)} audio files") # Запускаем распознавание recognizer = AsyncMusicRecognizer(max_concurrent=args.concurrent) results = asyncio.run(recognizer.recognize_batch(files)) recognized = sum(1 for r in results.values() if r) print(f"Recognized: {recognized}/{len(files)} ({recognized/len(files)*100:.1f}%)") # Применяем теги if not args.dry_run: tag_service = TagService() for file_path, metadata in results.items(): if metadata: tag_service.apply_tags(file_path, metadata) print(f"Tagged: {metadata['artist']} — {metadata['title']}") return 0 if __name__ == '__main__': exit(main())
Грабли, на которые я наступил
1. Rate limiting
Первая версия работала быстро. Слишком быстро. После ~200 запросов Shazam начинал возвращать ошибки. Решение — семафор + случайные задер��ки:
async def recognize_file(self, file_path: str) -> dict | None: async with self.semaphore: await asyncio.sleep(random.uniform(0.5, 1.5)) # Антибан # ... остальной код
2. Кодировки в тегах
Старые MP3 — это кладезь кривых кодировок. Íåèçâåñòíûé — это «Неизвестный» в CP1251, прочитанный как Latin-1. Пришлось добавить эвристику:
def fix_encoding(self, text: str) -> str: """Пытается исправить кривую кодировку.""" if not text: return text # Типичные паттерны кривой кодировки try: # CP1251 → Latin-1 → UTF-8 fixed = text.encode('latin-1').decode('cp1251') if self._is_readable(fixed): return fixed except (UnicodeDecodeError, UnicodeEncodeError): pass return text def _is_readable(self, text: str) -> bool: """Проверяет, что текст содержит читаемые символы.""" cyrillic = sum(1 for c in text if '\u0400' <= c <= '\u04FF') return cyrillic > len(text) * 0.3 # Хотя бы 30% кириллицы
3. Дубликаты
В моей коллекции один и тот же трек мог лежать в 5 разных папках с разными именами. После распознавания получаем 5 файлов с одинаковыми тегами. Решение — хеширование аудио:
import hashlib from pydub import AudioSegment def audio_hash(file_path: str, duration_ms: int = 30000) -> str: """Хеш первых 30 секунд аудио.""" audio = AudioSegment.from_file(file_path) sample = audio[:duration_ms] return hashlib.md5(sample.raw_data).hexdigest()
4. Память
12 000 файлов × метаданные = потенциально много памяти. Решение — обработка батчами и запись промежуточных результатов:
BATCH_SIZE = 100 async def process_directory(self, directory: str, state_file: str = '.recognition_state.json'): """Обработка с сохранением состояния.""" state = self._load_state(state_file) files = self._get_unprocessed_files(directory, state) for i in range(0, len(files), BATCH_SIZE): batch = files[i:i + BATCH_SIZE] results = await self.recognize_batch(batch) # Сохраняем промежуточное состояние state['processed'].extend(batch) state['results'].update(results) self._save_state(state_file, state) print(f"Progress: {i + len(batch)}/{len(files)}")
Теперь можно прервать процесс и продолжить с того же места.
Результаты
После прогона по моей коллекции:
12 847 файлов обработано
9 623 (74.9%) успешно распознано
2 891 дубликат найден и удалён
~6 часов общего времени работы
Оставшиеся 25% — это в основном:
Записи с радио с обрезанными началами
Ремиксы и бутлеги
Совсем уж экзотика, которую Shazam не знает
Что дальше
Этот проект — часть большей экосистемы для работы с аудио:
music_recognition (этот проект) — распознавание
audiobook-cleaner — очистка аудиокниг от шума через MDX-Net
vinyl_pipeline — полный пайплайн оцифровки винила
У меня есть небольшая коллекция пластинок — в основном электроника 80-х уж не знаю как он оказался у родителей, но сейчас он мой. Идея в том, чтобы записать пластинку целиком, автоматически убрать щелчки и шум, нарезать на треки, распознать каждый и получить готовую цифровую коллекцию с правильными тегами.
А ещё я слушаю много аудиокниг — в основном sci-fi, пока пишу код. Лем, Стругацкие, иногда что-то современное. И качество записей бывает... разным. Отсюда и audiobook-cleaner — MDX-Net отлично справляется с фоновым шумом и артефактами сжатия.
Заключение
Иногда лучший проект — тот, который решает твою личную боль. Я потратил пару выходных на код, который сэкономил мне недели ручной работы. И попутно попрактиковался с реверс-инженерингом API и археологией кодировок.
Код открыт: github.com/formeo/music_recognition
P.S. Если у вас тоже есть папка «Music_OLD_FINAL_BACKUP_2» — вы знаете, что делать.
P.P.S. Да, в той папке нашёлся трек, который я искал лет 15. Это был какой-то немецкий транс из начала нулевых, записанный с «Радио Рекорд». Без названия, 128 kbps, но с правильными воспоминаниями. Теперь знаю — Cosmic Gate, «Exploration of Space». И с правильными тегами.
