Автоматичний конвеєр для записів онлайн-пар: записав лекцію в OBS — і більше нічого не робиш.
Відео їде на власний VPS, транскрибується локально через faster-whisper, локальна LLM сама
визначає дисципліну й тему (жодного ручного іменування файлів), запис лягає на YouTube як
unlisted у плейліст свого предмета, а сирий транскрипт повертається у сховище нотаток.
Жодних платних API: класифікацією займається Ollama на тому ж сервері, на голому CPU.
OBS → watch-тека на ПК
│
│ sync-lectures.ps1 -Mode Push (планувальник: 2 рази на день + при вході)
│ scp -p через VPN, звірка SHA256, тоді видалення локальної копії
▼
VPS ~/lectures/incoming/
│
│ run.sh → process.py
├─ ffmpeg → wav 16 кГц моно
├─ faster-whisper → транскрипт з таймкодами (окремий процес, див. нижче)
├─ Ollama + LLM → дисципліна із закритого списку + тема
├─ YouTube API → unlisted, звірка через videos.list
├─ плейліст → відео в плейліст своєї дисципліни
└─ відео → в архів на 7 днів, а не одразу в кошик
▼
VPS ~/lectures/outgoing/<предмет>/<назва>.md
│
│ sync-lectures.ps1 -Mode Pull, звірка SHA256, тоді видалення з VPS
▼
Сховище нотаток: raw/lectures/<предмет>/<назва>.md
Назва формується як <Дисципліна> — <Тема> (ДД.ММ.РРРР) і використовується одночасно
як заголовок на YouTube, ім'я файлу транскрипту й назву підпапки.
| VPS | Linux, 4 ядра, 6 ГБ RAM, ~50 ГБ вільно. GPU не потрібен |
| ПК | Windows з OBS, PowerShell 5.1+, OpenSSH-клієнт |
| Мережа | бажано VPN-тунель між ПК і VPS (WireGuard тощо) |
| акаунт із верифікованим YouTube-каналом | |
| root на VPS | не потрібен — усе ставиться в домашню теку користувача |
Про пам'ять варто сказати чесно: 6 ГБ — це не «з запасом», а «рівно впритул».
whisper large-v3 в int8 займає ~2.9 ГБ, 7B-модель у Ollama — ще ~4.7 ГБ. Разом вони
не влазять, і саме тому транскрипція винесена в окремий процес (див. «Чому саме так»).
# робочі теки
mkdir -p ~/lectures/{incoming,outgoing,archive,work,_needs-review,logs,bin,secrets}
chmod 700 ~/lectures/secrets
# код
git clone https://github.com/<user>/lecture-pipeline.git /tmp/lp
cp /tmp/lp/vps/bin/* ~/lectures/bin/
chmod +x ~/lectures/bin/*.py ~/lectures/bin/*.sh
# оточення
python3 -m venv ~/lectures/venv
~/lectures/venv/bin/pip install -r /tmp/lp/requirements.txtОфіційний інсталятор просить sudo. Якщо його немає — розпакуй тарбол у домашню теку:
TAG=$(curl -fsSL https://api.github.com/repos/ollama/ollama/releases/latest \
| grep -m1 '"tag_name"' | cut -d'"' -f4)
curl -fsSL -o /tmp/ollama.tar.zst \
"https://github.com/ollama/ollama/releases/download/$TAG/ollama-linux-amd64.tar.zst"
# звір контрольну суму зі sha256sum.txt того ж релізу — не пропускай цей крок
tar --zstd -xf /tmp/ollama.tar.zst -C ~/.local
~/lectures/bin/ollama-serve.sh & # слухає лише 127.0.0.1
~/.local/bin/ollama pull qwen2.5:7b-instruct-q4_K_MАвтозапуск — через @reboot у crontab (див. vps/crontab.example).
systemd --user тут не годиться: без loginctl enable-linger, а це root, користувацькі
юніти гинуть при виході з сесії.
- Google Cloud Console → новий проєкт → увімкнути YouTube Data API v3.
- Google Auth Platform → налаштувати consent screen (тип External).
- Clients → Create client → тип Desktop app → зберегти
client_secret.json. Секрет показується один раз і завантажити його потім уже не можна. - Скласти
secrets/client_secret.jsonу форматі:{"installed": {"client_id": "...", "client_secret": "...", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "redirect_uris": ["http://localhost"]}} - На машині з браузером:
python windows/auth_bootstrap.py→ пройти згоду → отриманийsecrets/token.jsonперекласти на VPS із правами600.
Обов'язково переведи застосунок у Production (Audience → Publish app). Поки він у статусі Testing, Google відкликає refresh token кожні 7 днів, і вся автоматика перетворюється на щотижневий ручний ритуал. Щоб кнопка розблокувалась, треба заповнити home page / privacy policy / terms і додати домен у Authorized domains — підійде будь-який безкоштовний динамічний піддомен. Сторінки при цьому Google не завантажує, перевіряє лише формат URL і наявність домену в списку.
git clone https://github.com/<user>/lecture-pipeline.git
cd lecture-pipeline\windows
Copy-Item config.example.ps1 config.ps1 # і підстав свої значення
.\register-task.ps1Далі — увімкнути wake timers з-під адміністратора, інакше ПК не прокинеться:
powercfg /SETACVALUEINDEX SCHEME_CURRENT SUB_SLEEP RTCWAKE 1
powercfg /SETDCVALUEINDEX SCHEME_CURRENT SUB_SLEEP RTCWAKE 1
powercfg /SETACTIVE SCHEME_CURRENT
powercfg /waketimers # має показати задачу Lectures SyncВідредагуй словник SUBJECTS у vps/bin/classify.py. Це і є той закритий список,
з якого модель обирає. Тримай у ньому лише ті предмети, записи яких справді
потраплятимуть у конвеєр — кожен зайвий пункт це ще один шанс на промах.
Модель не вигадує назву дисципліни, а вибирає код зі списку або повертає НЕВІДОМО.
Прив'язка «день тижня + номер пари → предмет» свідомо не використовується: вона ламається
на першому ж перенесенні, святі чи зміні розкладу.
Граматика структурованого виводу змушує модель генерувати поля саме в тому порядку, в якому вони описані у схемі. Якщо код дисципліни стоїть першим, мала модель мусить вгадати його нульовим токеном — ще до того, як «побачила» зміст.
Заміри на однакових фікстурах, qwen2.5-3b:
| Схема | Влучань |
|---|---|
subject першим |
3 / 7 |
keywords → topic → kind → subject |
5 / 7 |
| те саме + коди рядками замість чисел | 6 / 7 |
| те саме на 7B | 7 / 7 |
Тобто «спершу подумай вголос, потім відповідай» працює і на рівні JSON-схеми.
Заміна числових ID на семантичні коди (МАТАНАЛІЗ замість 4) додала ще: модель
правильно виписувала ключові слова «числові ряди, ознака збіжності», але не могла
згадати, що математичний аналіз — це «4».
Ще одна дрібниця з великими наслідками: обмежуй довжину рядкових полів у схемі
(maxLength) і став num_predict. Без цього мала модель одного разу згенерувала
10 600 токенів замість 40 і намертво зациклилась.
whisper large-v3 в int8 тримає ~2.9 ГБ, Ollama слідом просить ~4.7 ГБ, а на сервері
всього 6 ГБ. del model + gc.collect() не повертають пам'ять ОС — вона лишається
в malloc-аренах. Гарантію дає лише вихід процесу, тому transcribe_worker.py
запускається окремо і одразу вмирає.
- Локальне відео — лише після збігу SHA256 з обох боків.
- Файл заливається як
<name>.uploadingі перейменовується вже на сервері, тож процесор ніколи не побачить недокачаний файл. - Відео на сервері — не одразу після заливки, а через тиждень, і лише якщо YouTube
підтвердив
uploadStatus=processed.
Останнє — не перестраховка, а наслідок реального інциденту. YouTube приймає файл
асинхронно: uploaded означає тільки «прийнято». Відхилення (задовге відео, Content ID,
збій обробки) приходить згодом — коли видаляти вже пізно. Так була втрачена
72-хвилинна лекція. Тепер відео лежить в archive/, і перед видаленням стан
перепитується в YouTube; якщо той не підтвердив — файл лишається, а в лог падає ERROR.
Той самий запис, перезалитий під іншим іменем, розпізнається як дублікат і не
обробляється вдруге. Паралельні запуски виключені через flock.
4 ядра CPU, без GPU, на реальній 72-хвилинній лекції:
| Крок | Час |
|---|---|
| whisper large-v3, int8 | 51 хв (0.71× реального часу) |
| класифікація 7B | ~2.5 хв разом із завантаженням моделі |
| заливка 50 МБ | секунди |
| разом на півторагодинну пару | приблизно година |
Короткі кліпи дають оманливо кращі цифри (на 54-секундному виходило 0.3×) — орієнтуйся на замір з повної пари.
Квота YouTube API: 10 000 одиниць на добу, заливка ≈ 1600 → не більше ~6 відео на день. Додавання в плейліст коштує 50, перевірка стану — 1.
longUploadsStatus: eligible — це НЕ дозвіл. Означає «канал може увімкнути довгі
завантаження, але ще не увімкнув». Дозвіл — рівно allowed. Без нього YouTube мовчки
відхилить усе довше за 15 хвилин, причому вже після того, як прийме файл.
Конвеєр перевіряє цей прапорець перед заливкою і відмовляється працювати, якщо там не
allowed.
unlisted — це не «приватно». Хто має пряме посилання, той дивиться без авторизації.
Якщо ділитися ні з ким не плануєш — став private у yt_upload.py.
Дата береться з mtime файлу. Для записів OBS це і є час запису. Але у файлу,
завантаженого звідкись, mtime — дата скачування. Тому scp викликається з -p: без
нього mtime став би часом копіювання, і запис, залитий за logon-тригером наступного
ранку, отримав би дату наступного дня.
Плейлісти коштують ширшого дозволу. playlistItems.insert вимагає скоупа youtube
(повне керування акаунтом), який дозволяє й видаляти відео. Вужчого скоупа саме під
плейлісти в API немає. Якщо це неприйнятно — прибери крок із плейлістом і лишайся на
youtube.upload + youtube.readonly.
Не пиши в каталог сховища, синхронізований E2E-плагіном. Якщо нотатки їдуть на той самий сервер через Remotely Save чи подібне, там усе зашифроване на боці клієнта — запис повз клієнт зіпсує сховище. Конвеєр використовує окремий канал і того каталогу не торкається.
# класифікатор на фікстурах: 5 дисциплін + 2 негативні кейси
LECTURE_KEEP_ALIVE=5m venv/bin/python tests/test_classifier.py tests/fixturesНегативні кейси (кулінарія, урок англійської) — не формальність. Саме вони ловлять найнеприємнішу поведінку: коли модель за будь-яку ціну намагається підігнати зміст під щось зі списку. Обов'язково перевіряй і їх, а не лише happy path.
Якщо класифікатор не впевнений, файл лягає в _needs-review/ разом із транскриптом,
відео не заливається і не видаляється, і чекає ручного розбору.
Це інструмент для особистого архіву власних записів. Перш ніж ним користуватись:
- Запис чужої лекції — це чужий об'єкт авторського права. У багатьох закладів є прямі правила щодо запису занять; спитай викладача.
- У записі можуть звучати голоси та імена інших студентів, які на це не погоджувались.
unlistedобмежує поширення, але не робить відео приватним.- Ніколи не став таким записам
public.
Автори не несуть відповідальності за те, як ви скористаєтесь цим кодом.
MIT — див. LICENSE.