Как я восстанавливал подкасты «Эхо Москвы»

После того как 5 Марта 2022 года радиостанция «Эхо Москвы» была легко и непринужденно закрыта, мне пришла в голову оригинальная мысль — а может мне её… приоткрыть. В стиле Савы Игнатича — «он ломает, я чиню». Конечно, не саму радиостанцию, а её подкасты.

TLDR

Все получилось, хотя и не без приключений. Вот результаты:

История вопроса

Мысль эта возникла не на пустом месте. Дело в том, что давно, больше 10 лет назад, я собрал из разрозненных RSS фидов этой радиостанции нечто удобоваримое для потребления в виде комбинированного подкаста, то что я назвал «Правильный, комбинированный фид избранных передач». Проблем с оригинальными фидами была масса, и они были, порой, весьма нетривиальны. Из того, что я помню, там были сложности с наименованием эпизодов, некорректными последовательностями эпизодов, дубликатами и прочее разное в этом духе. Кроме того, подписываться в те годы на множество подкастов было занятие не для слабонервных. В результате этого всего, мой фид стал самым популярным местом, где обычные люди находили подкаст-версии передач радиостанции. Не обходилось и без курьезов: не раз и не два, мне приходили возмущенные жалобы на содержание различных передач, на качество звучания, на профессионализм ведущих, короче на всё то, к чему я отношения никогда не имел. Вначале я пытался пояснить, как оно на самом деле, но для «обычных людей» мои рассказы были либо слишком сложны, либо неубедительны.

Если идти совсем глубоко в прошлое, то лет 16 назад я написал программку UPG, которая слушала интернет онлайн-эфир, и нарезала из него подкасты. Я понимаю, что в это трудно сейчас поверить, но в те годы подкасты всё ещё были экзотикой, и радиостанции игнорировали эту модную новинку. Несколько лет именно так и строились мои подкасты «Эха», но, насколько я помню, эта штука всегда была уделом только самых гиковских пользователей и не была предназначена для нормальных людей.

Подход к техническому решению

Закрытие «Эха» я воспринял, как техническую проблему, которую надо попытаться решить техническими методами. За ту пару недель, что я раздумывал, многие передачи появились на Ютубе в видео формате. Формат этот, надо сказать, довольно странный, он на 90 процентов «говорящие головы», и если оттуда взять только звук, то качество восприятия, на мой вкус, совсем не пострадает. Таким образом, задача стала вырисовываться: надо как-то достать список обновлений каналов/листов с Ютуба, вынуть из него ссылки на видео, достать видео, вытащить из него звук, и всё это оформить в виде подкаста.

Недолгое гугление навело меня на проект podsync. Судя по описанию, он очень близок к тому, что надо, но после дня экспериментов оказалось, что его использовать не получится. Там были немалые проблемы, как внутренние, так и внешние (под капотом он использует youtube-dl). Кое-как оно работало, но очень медленно (видео загружалось часами), требовало доступа к API Ютуба с обязательной регистрацией (этого я хотел избежать) и, как мне показалось, этот podsync не очень готов к «промышленному использованию». В неожиданных для него ситуациях появлялись странные ошибки, работа останавливалась, и понять, что пошло не так, было очень непросто. Был вариант, засучив рукава, починить всё что можно, но и тут было, всё не так чтобы прямо. Изучив дискуссии вокруг некоторых актуальных для меня тикетов, мне показалось, что по ряду вопросов у нас с автором есть концептуальное расхождение, и шанс того, что мои изменения будут приняты, не особо велик. Конечно, можно было его форкнуть и ваять в своей песочнице всё, что душе угодно, но если браться за клавиши, то лучше это делать со знакомой кодовой базой.

И такой код нашелся. Это был feed-master, современная инкарнация моего сервиса UPG. Первая версия feed-master была на питоне, и её даже можно отыскать в первых комитах. Несмотря на то, что этот комит был «всего» 8 лет назад, этому проекту, как минимум, на 3 года больше, просто он тогда ещё не жил на Гитхабе.

Текущая версия feed-master (конечно на Го) занималась простым делом — собирала корректный фид для обобщённого подкаста из множества разрозненных, по пути решая проблемы оригинальных фидов. Именно она и производила тот самый «Правильный, комбинированный фид избранных передач». Естественно, идея научить feed-master магии Ютуба, превратить каналы в отдельные подкасты, и потом слить их вместе, мне показалась весьма разумной и, по ощущениям, не очень сложной технически. Забегая вперед, могу себя похвалить — я почти правильно оценил сложность программистской части, хотя и люто недооценил операционные аспекты, но об этом ниже.

Детали реализации

После изучения вопроса, оказалось что Ютуб любезно предоставляет список видео для всех каналов и плей-листов в виде Atom фида. Берутся они так GET https://www.youtube.com/feeds/videos.xml?channel_id=<ID> для каналов, и https://www.youtube.com/feeds/videos.xml?playlist_id=<ID> для playlist. В этом фиде есть все, что надо для извлечения видео, а именно yt:videoId, плюс дополнительная информация, что понадобится для формирования подкаста: заголовки, описания, автор, ссылки и прочее…

Идею писать самому загрузку видео я даже не рассматривал. Весь этот проект был изначально «что-то на одни выходные», и мне было необходимо использовать готовые решения по максимуму. Так нашелся совершенно замечательный yt-dlp, который является форком знаменитого youtube-dl. Он умный, хитрый, умеет загружать видео и, при помощи всем известного ffmpeg, доставать из него звук.

То есть мы берем фид Ютуба, извлекаем из него последние эн записей, проверяем, если мы эти записи ещё не обработали, и вызываем для новых записей yt-dlp с параметрами для вытаскивания audio. То, что мы запускаем, определено в конфиге, и сейчас там используется нечто типа yt-dlp --extract-audio --audio-format=mp3 --audio-quality=0 -f m4a/bestaudio "https://www.youtube.com/watch?v={{.ID}}" --no-progress -o {{.FileName}}.

С этим {{.FileName}} я допустил первую промашку. Вместо того, чтоб сделать имена, соответствующие уникальному VideoID, я генерировал их случайно как UUID. Видимо, мысль моя была в том, что метаданные об обработанных эпизодах делают консистентность именования файлов неважной, но, как показала практика, это было не так. То есть, когда всё работало правильно, то да, неважно. Получалась «типа-транзакция», когда сначала доставался файл, и потом добавлялась запись в хранилище. В виде store я и до этого использовал boltdb (KV store), и для всех Ютуб дел просто добавил часть для новых метаданных. К сожалению, предполагать, что всё пойдет всегда так, это обычно плохая идея, и мои «типа-транзакции» показали своё злобное лицо, когда я перезапустил процесс после того, как метаданные были обновлены, но до того, как запись файла закончилась. После этого я поменял имена файлов на хеш SHA1 от channelID+videoID.

Любители криптографии, умоляю, не бранитесь на SHA1, но поймите, что ничего связанного с криптографией эти хеши и близко не делают. Я, скорее всего, мог тут использовать и богомерзкий MD5 и даже совсем простой CRC64. Но т.к. в feed-master и до этого хеши считались SHA1, я его и продолжил использовать

После того как файл извлечен и сохранен, в boltdb записывается 2 пары ключ:значение. Первая эта pubdate_:videoID со значением feed.Entry (то, что из Ютуб Atom) записывается в bucket «channelID», а вторая записывается в общий для всех каналов bucket «processed», и в виде значения там pubdate (pubdate это время публикации эпизода). Первая запись нужна для того, чтоб доставать обратно сортированный список эпизодов для построения RSS канала, а вторая для того, чтоб понимать, был ли конкретный эпизод уже обработан.

Оптимизации, типа понимать обработанные эпизоды, сравнивая их pubdate время с временем новых эпизодов, обдумывались, но были отвергнуты из-за того, что этот исходный Atom фид не рассматривает время, как линейное и неизменяемое, и иногда оно меняется, а иногда идет назад. Ну и, кроме того, одного общего времени не хватит, надо на каждый канал. А раз так, то проще и правильнее не оптимизировать без причины, а просто хранить идентификаторы всех обработанных эпизодов.

В тот момент, когда у нас есть mp3 файлы на диске, и есть метаданные в bolt, построить RSS элементарно. Чтоб сделать процесс ещё более прямым, я добавил в метаданные и путь к файлу, т.е. всё, что надо для построения элемента channel>item у меня там есть, и из набора этих записей построить RSS подкаст фид одного канала — не проблема. Ну, а когда, все эти RSS фиды доступны, их надо скормить основной части feed-master (той, что последние годы комбинировала), и дело в шляпе. Для отдачи сформированных RSS каналов я добавил новый endpoint GET /yt/rss/{channelID}, который строит по запросу и даже без кэширования. Наверное кэш можно и добавить, но и без этого на запрос уходит 10-20ms (включая сетевой уровень), так-что пока это не проблема.

И последнее, что я сделал — ограничил «глубину» разбора Atom (последние эн записей) и максимальное число эпизодов в полученном RSS подкаста (на каждый канал). Понимать, какие эпизоды устарели и нуждаются в удалении, очень просто — взять из бакета channelID в обратном порядке (от новых к старым) и удалить всё то старое (и файл, в том числе), что за пределами разрешенного максимума. Поначалу, у меня было полное равенство и общий на все каналы параметр MaxItems, но пришлось сделать возможность изменения его для конкретных каналов из-за того, что в одном, с низкой активностью, и 5 хватит, а в другом и 50 будет мало. По сути, я этим пытался балансировать «наполненность» по времени, и, наверное, использовать временной параметр тут было бы логичнее (типа хранить 2 недели), но я pubDate тут не очень доверяю, ну, и так проще и прямее, на мой взгляд.

На этом, в целом, программная часть проекта была завершена, и можно было запускать.

Операционная катастрофа

Напомню, что фид «Эхо Москвы» у меня раздавался долгие годы, так что я решил ничего не менять, чтобы упростить жизнь тех, кто подписан на этот подкаст. О количестве подписчиков я имел весьма слабое представление — те цифры, что показывал FeedBurner, были всегда диким бредом, а после того, как они его перестали поддерживать, этот бред перестал даже обновляться.

Но у меня была машинка в DigitalOcean и целая куча неиспользованного трафика, так что я решил поднять там и сервер для раздачи статики, и натравить его на каталог с извлечёнными файлами. Очевидно, фиды теперь тоже поставлял мой feed-master. Ну, что может пойти не так, всё ведь прямо и просто, проверено и работает.

В момент включения нового feed-master с первыми несколькими каналами (их сейчас 13, а в начале было всего 2) сразу и много что пошло не так:

  • Процесс извлечения audio оказался не под силу этой машинке. То есть он работал, но долго и настолько тяжело, что http запросы отваливались по timeout.
  • Раздача статики через мой reproxy тормозила неимоверно и нагружала CPU.

По поводу первого пункта стало понятно, что так жить нельзя и надо унести то, что нагружает, подальше от того, что раздает. Для этого я быстро поднял ещё одну машинку в DO, раза в 3 мощнее той, что раздает. А по второму пункту я себя успокоил тем, что reproxy не для того писался, чтоб статику раздавать в подобном режиме, и поменял на безотказный nginx. Запустил на отдельной машинке и, в принципе, это был первый рабочий вариант. То есть оно как-то работало. Но очень туго. Вы видели как nginx съедает все ресурсы? А как всё IO заканчивается? Вот и я тоже увидел. Не в reproxy было дело, зря я на него плохое подумал.

Но раздача пошла, обновления выкачиваются, фиды строятся. Ну, а что тяжело этой машинке — дело её такое, работать. Но меня не покидали смутные сомнения в том, что при такой сумасшедшей отдаче мои неизрасходованные 9ТB трафика в месяц может и не хватить. Благо vnstat в пальцы и посмотрим, как оно на самом деле. Да ещё и DO дает мониторинг сетевой активности, тоже полезно. Посмотрев на результаты через 8 часов раздачи, я прослезился. Оно отдало за это время в районе 2ТB, и DO показывала постоянно 1.5Gb сетевой нагрузки.

Дело было ночью, и успокаивая себя тем, что «это начальный наплыв, вот выкачают все новые эпизоды и успокоятся», я пошел спать. На утро выяснилось, что мой оптимизм был слишком… оптимистичным, и из 9ТB уже потрачено 5. Очевидно, на месяц не хватит, и есть шанс, что 9ТB даже не хватит на сутки. Надо было что-то делать. Вариантов не так много, но они были:

  • Забить на лишний трафик и, когда придет счет, оплатить. Ну, может, кинуть клич в народ, чтоб помогли с оплатой, но из моего опыта на подобную помощь особенно рассчитывать не приходится. Вариант, конечно, простой, но довольно затратный. Лишний терабайт на DO стоит $10, а нам, видимо, понадобится не менее 4*30=120Т, что значит $1,200 в месяц. И это по самым оптимистичным прогнозам. За ту неделю, что подкаст раздается, были дни и с 15ТB, ну и, кроме того, DO как-то… неточно считает лишний трафик, и мы с ними сильно расходимся в цифрах. По тому, что я считал как 4Т, они как-то насчитали 7T с гаком. То есть ежемесячная цена вопроса в реальности, скорее всего, будет в районе 3-5к денег. И нет, я не настолько хотел восстановить фид подкастов «Эха».
  • Написать DO и попросить не считать трафик с этого инстанса, потому что на хорошее дело. Я это сделал и даже получил ответ. Там, если в двух словах, у них есть программа, на которую можно подать, если мой проект за равенство и против разных дискриминаций (не уверен, что я бы попал под эти критерии), ну, а самое главное, всё, что там светит, — это кредит на $1000 единоразово. А, как мы посчитали выше, этого хватит только на неделю, если повезет.
  • Закрыть доступ и пользоваться самому, ну и дать по блату десятку-другому проверенных товарищей. Можете назвать меня эгоистом-единоличником, но это был самый вероятный вариант развития событий. Ну, в конце концов, я это запрограммировал, я и буду потреблять!
  • Найти какой-то мистически дешевый или бесплатный путь раздачи такого количества. На ум приходят Cloudflare или CloudFront и прочие CDN. Но тут тоже проблема — все они, во всяком случае, те, что я поизучал, не предназначены для отдачи массивных mp3 файлов и совсем плохо подходят для раздачи подкастов, в частности. Я читал отчёты о том, как Cloudflare блокировал адрес с которого приходит то, что собирает для Apple Podcasts. Кроме того, мне не кажется, что эти провайдеры обрадуются нашему нетипичному трафику. Я задал этот вопрос поддержке Cloudflare почти неделю назад, и ответа не получил.
  • Пожаловаться на эту проблему и надеяться на добрых людей. Несмотря на то, что этот способ, на первый взгляд, кажется каким-то наивным, он и сработал. На мой призыв нашлись добровольцы с достаточными мощностями и большим (нелимитированным) трафиком.

Решение операционной проблемы

Теперь, когда у нас начал вытанцовываться настоящий CDN, надо было придумать, как всё это организовать. С доставкой mp3 файлов всё просто — rsync наше все, а с --delete то, что мы удаляем, локально удалится и на раздающих серверах, не переполняя их диски. RSS всех каналов, в принципе, можно раздавать и с нашего мастера, но раз у нас такой почти что CDN, то почему бы его (RSS) не сохранить локально и тем же rsync раскидать по всем воркерам (узлам раздачи).

Что касается прокси/LB, то тут ничего нового изобретать не надо. Я в свое время написал RLB, который занимается подобным для нашего подкаста уже много лет. Он не совсем традиционный LB, и всё, что он умеет делать, это поддерживать список живых узлов и делать на них http редирект. Для раздачи подкастов этого вполне достаточно. Ещё он умеет задавать разный вес разным узлам, позволяя, при необходимости, регулировать нагрузку.

Тут пытливый читатель может спросить — «а как быть с архивами»? Когда feed-master начнет удалять файлы, может быть неприятно. Я не сразу понял, что это проблема, пока не заметил, как файл, который был удален и из фида, и на диске, появился в запросах от подкаст-клиентов. Очевидно, на него кроме 404 нам было нечего сказать. Как выяснилось, многие подкаст клиенты не готовы к тому, что эпизод подкаста, который они видели раньше, может пропасть. И это, несмотря на то, что эпизода больше нет в RSS фиде.

Решилось это весьма немудрено. Один из наших узлов оказался с большим HDD, и его 2T хватит на многие и многие годы архива. Всё, что я сделал, это добавил ещё один rsync на /archive в этом узле, но без --delete. Ну да, этот узел будет набирать архив в дополнение к свежим, и дубликаты файлов имеют место быть. Но кого это волнует? Лишние 20G свежих файлов — это крохи по сравнению с доступными 2Т. Ну, а все воркеры получили try_files $uri @rewrite;, где @rewrite редиректит на узел с архивом, меняя при этом url.

Добавляем то, что надо для спокойствия

Конечно, для душевного спокойствия нам надо знать, если что-то пошло не так. Например, один из узлов перестал отвечать, или отвечает медленно. Или если вдруг сертификат скоро закончится, но почему-то не обновился. Или если на мастере вдруг упал процесс, и всё в этом роде. Для простого и быстрого решения я взял gatus, возможности которого немного расширены моим sys-agent. Всё это заработало почти сразу, единственная проблема была, как взять ID у приватного канала Телеграмма, туда идут сообщения о падениях.

В качестве сетевой статистики на всех воркерах стоит vnstat, который каждые 5 минут рисует свои простые и полезные графики. Ну, а для того, чтоб наблюдать за статистикой раздачи на более высоком уровне (файлы и узлы), рядом с RLB запущен компаньон — rlb-stats.

Для параноидального спокойствия, все места, что могут знать IP адрес клиента, этот адрес старательно портят. Я не думаю, что ко мне придут в гости с паяльником, чтобы получить эту информацию, но так спокойней и правильней.

Что в результате

В результате, у нас есть фид подкаста, простая страница для ручного потребления и раздача на apple podcasts. Ещё есть телеграмм канал, куда feed-master загружает аудио файлы. Наверное, можно найти и в других местах, например, на castbox.

За первые 5 дней мы раздали аудио-файлы этого возрожденного подкаста больше миллиона раз (1.4М на момент написания этой заметки) и потратили (на доброе дело) более 50Т трафика.

Надеюсь, что сервис вышел на уровень полной автономности, и его можно оставить без присмотра. Если что пойдет не так, то наш мониторинг будет жаловаться, и пользователи тоже не промолчат.

Чем помочь?

  • Если у вас есть мощности подходящие для узла раздачи (20Г диск, 1Gb коннект и неограниченный трафик), то свяжитесь со мной в Телеграмме. Прямо сейчас нам хватает на раздачу, но никто не знает, как оно дальше будет.
  • Если хочется и можется помочь в деле программирования, то в репо feed-master время от времени появляются тикеты с пометкой «help wanted».
  • Если хотите помочь рабочей копейкой, то на Github есть такое место либо на patreon.
  • Если хотите помочь с хостингом в DO, и вам нужны там дроплеты, то вот реферальная ссылка.
  • Починить эту статью. Тут наверняка есть очепятки, и запятые стоят на слух. Починку можно представать в виде PR.

Заключительное спасибо

Огромное спасибо добрым людям, что поддержали эту инициативу своими серверами, временем и участием в разработке. Без вас ничего бы не вышло, вы реально молодцы!