- Python 55.5%
- TypeScript 31.4%
- CSS 10%
- JavaScript 2.3%
- Dockerfile 0.5%
- Other 0.3%
| backend | ||
| frontend | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| promt.md | ||
| pyproject.toml | ||
| pytest.ini | ||
| README.md | ||
Jellyfin Projector Sync
Самостоятельно размещаемое приложение для экспорта фильмов и отдельных эпизодов Jellyfin на старые USB-проекторы. React-интерфейс на русском, FastAPI API, SQLite/Alembic и отдельный FFmpeg-воркер. Создание коротких фрагментов отключено в интерфейсе и API; доступен полный экспорт. История, настройки и пользовательские профили сохраняются после перезапуска.
У разных ревизий YG300 разные декодеры. Ни один профиль не гарантирует совместимость. Проверьте воспроизведение экспортированного видео на своём устройстве.
Требования и запуск Docker Compose
Linux x86_64 или ARM64, Docker Engine с Compose v2; свободное место под исходник (при удалённом доступе), выходное видео и временные файлы. Образы используют многоархитектурные Python/Node/Debian base images; ARM64 отдельно не проверен.
cp .env.example .env
# В .env задайте уникальный ADMIN_PASSWORD (от 12 символов)
# и APP_SECRET (от 32 символов). Сгенерировать секрет:
openssl rand -hex 32
docker compose up -d --build
Откройте http://localhost:8787 (или порт PORT из .env) и войдите с ADMIN_PASSWORD. Для доступа из домашней сети задайте BIND_ADDRESS=0.0.0.0 или конкретный LAN IP в .env. Ключи Jellyfin не задаются в frontend и не попадают в его bundle.
По умолчанию наружу опубликован только localhost. Для HTTPS через доверенный reverse proxy задайте COOKIE_SECURE=true. Не открывайте сервис напрямую в интернет. .env, резервные копии и Docker-доступ должны быть доступны только администратору.
docker compose logs -f app worker
docker compose restart app worker
docker compose down # данные сохраняются
# НЕ используйте down -v, если нужно сохранить БД и видео
migrate применяет Alembic-миграции перед запуском API и воркера. app отдаёт frontend и API с одного origin. worker забирает задания из общей SQLite БД. Общий volume app-data содержит app.db, exports/<job-id>/ и cache/. По умолчанию одновременно обрабатывается одно задание; WORKER_CONCURRENCY=1..4 управляет потоками единственного воркера. Не масштабируйте число worker-контейнеров: файловая блокировка допускает один supervisor на volume.
Подключение Jellyfin и User ID
- Jellyfin Dashboard → API Keys → создайте отдельный ключ приложения.
- В приложении откройте Настройки, укажите URL, например
http://192.168.1.10:8096(можно с базовым/jellyfin), и ключ. - Нажмите Проверить и найти пользователей. Проверяются
/System/Info,/Users, а при заданном ID —/Users/{id}. - Выберите пользователя библиотеки из появившегося списка. Нажмите Сохранить. Если сервер запрещает список пользователей, скопируйте ID из URL страницы пользователя в Jellyfin Dashboard.
- Укажите предпочитаемый язык аудио: обычно
rusилиeng, как он обозначен в метаданных. При отсутствии языка выбирается default-дорожка, затем первая; выбранная дорожка видна перед отправкой задания.
Ключ зашифрован Fernet в SQLite с ключом, производным от APP_SECRET. Не меняйте APP_SECRET без повторной настройки Jellyfin: это также завершает существующие сессии. API не возвращает сохранённый ключ; пустое поле при сохранении оставляет прежний ключ.
Интеграция изолирована в backend/app/jellyfin.py. Используются реальные маршруты Jellyfin: /Items с фильтрами includeItemTypes, parentId, userId; /Users/{userId}/Items/{itemId}; /Items/{itemId}/PlaybackInfo; /Videos/{itemId}/stream?static=true&mediaSourceId=...; /Videos/{itemId}/{sourceId}/Subtitles/{index}/Stream.srt. Авторизация — заголовок Authorization: MediaBrowser … Token="…"; устаревшие заголовки X-Emby-* не используются. Контракты сверены с официальными контроллерами Jellyfin и официальным SDK. Редиректы исходников отключены, чтобы не передавать ключ третьей стороне.
Прямой доступ к медиатеке
Без настроенных сопоставлений исходник скачивается с Jellyfin во временный каталог целиком. Затем FFprobe и FFmpeg работают локально; в FFmpeg не передаются ключи или авторизованные URL. Временный исходник удаляется после задания. Подготовка большого файла может занять время и требует места, прогресс транскодирования начинается после скачивания.
Для быстрого доступа добавьте в общий раздел volumes в docker-compose.yml:
- /srv/media:/media:ro
В .env:
SOURCE_MAPPINGS={"/jellyfin/library":"/media"}
Первый путь — как его видит Jellyfin, второй — внутри контейнеров приложения, а /srv/media — путь на хосте. Для нескольких корней добавьте сопоставления и mounts. Приложение использует прямой файл только если он находится внутри явно разрешённого корня, существует и читается. Иначе пытается получить оригинал через Jellyfin. Не монтируйте целый / хоста. Симлинки за пределы разрешённых корней и .. запрещены.
Контейнеры работают от UID/GID 10001:10001: обеспечьте чтение media и запись в отдельные экспортные каталоги этому пользователю, например через ACL. Live TV, ISO/дисковые структуры и источники, требующие открытия live stream, не являются поддерживаемыми обычными файлами; выбирайте файловую медиаверсию. Ошибки доступа отображаются в заданиях.
Экспорт видео
Откройте фильм или отдельный эпизод, выберите медиаверсию, профиль, аудиодорожку и субтитры, затем нажмите Экспортировать целиком. Прогресс доступен в Заданиях, готовое видео — в Экспортах. Ранее созданные фрагменты остаются доступны.
Как экспортировать один эпизод
Сериалы → нужный сериал → сезон → конкретный эпизод. Страница показывает название сериала, номер сезона и эпизода. Выберите медиаверсию и дорожки именно этого эпизода, нажмите Экспортировать целиком. Для нескольких эпизодов отметьте их флажками в списке сезона и нажмите Настроить экспорт. Откроется тот же экран подготовки, что и для одного эпизода: выберите медиаверсию, профиль, аудиодорожку, режим и дорожку субтитров, затем нажмите В задачи. Настройки первого эпизода применяются ко всем выбранным; номера дорожек сопоставляются по языку, названию и формату. Если медиаверсия или дорожка не найдена однозначно, настройте такой эпизод отдельно. Каждый эпизод создаёт отдельный полный экспорт. При частичной ошибке повторная отправка пропускает уже добавленные эпизоды. Для фильма полный экспорт запускается той же кнопкой на странице фильма.
В Заданиях можно отменить ожидающее или выполняющееся задание. Процесс FFmpeg получает SIGTERM, затем SIGKILL при необходимости; частичные выходы удаляются. После сбоя/перезапуска активные задания отмечаются как ошибочные с объяснением и могут быть отправлены повторно. Готовые экспорты и история сохраняются.
Профили и субтитры
- Conservative: MP4, H.264 Constrained Baseline L3.0, yuv420p, до 640×480 с полями, квадратные пиксели, 25 fps, 1200/1500 кбит/с, AAC-LC stereo 128 кбит/с 44100 Hz, faststart, без B-frames/CABAC.
- Low Bitrate: до 640×360 без добавления полей, чётные размеры, 700/1000 кбит/с, аудио 96 кбит/с.
- Alternative AVI: MPEG-4 Part 2 + MP3 stereo, 640×480. Экспериментально, зависит от железа.
Настройки → Профили экспорта: выберите базовый, измените контейнер/кодеки, битрейты, размеры, fps, аудио, H.264 профиль/уровень и режим субтитров. Сохраните как новый. Пользовательский профиль можно обновить. Проверяются разрешённые кодеки, диапазоны, чётные размеры, соответствие H.264 level и MP3 для AVI. Произвольные FFmpeg-аргументы не принимаются. Соотношение сторон сохраняется, полей чёрного цвета можно избежать, отключив padding.
Текстовые встроенные субтитры извлекаются в UTF-8 SRT; внешние получаются через Jellyfin API. При burn-in используются libass и установленные DejaVu/Liberation с кириллицей, время фильтра остаётся привязано к исходнику. SRT sidecar скачивается отдельной кнопкой и копируется вместе с видео. Стили ASS/SSA при преобразовании в SRT теряются. PGS/DVD/DVB bitmap-субтитры отклоняются с понятной ошибкой; автоматического OCR нет. Устройство может не поддерживать внешний SRT — burn-in предпочтительнее.
HDR PQ/HLG определяется FFprobe, затем явно преобразуется в SDR BT.709 через zscale + tonemap=hable. Без нужных фильтров или обязательных метаданных задача останавливается с ошибкой. Dolby Vision без корректного базового HDR-слоя не гарантируется. Проверьте результат преобразования HDR визуально.
USB и локальные назначения
Накопитель монтирует хост, приложение его не форматирует и не извлекает. Сначала определите устройство через lsblk -f; убедитесь, что это нужный USB, и используйте существующую файловую систему. Пример для существующей FAT32 (подставьте настоящий UUID):
sudo mkdir -p /mnt/projector-usb
sudo mount -t vfat -o uid=10001,gid=10001,umask=0077 /dev/disk/by-uuid/YOUR-UUID /mnt/projector-usb
sudo touch /mnt/projector-usb/.projector-sync-volume
Для ext4 используйте обычный mount и выдайте UID 10001 права записи только в выделенный экспортный каталог. Для постоянного mount можно использовать /etc/fstab с UUID, nofail и подходящими владельцем/правами; проверяйте findmnt /mnt/projector-usb до запуска контейнеров.
Добавьте mount в общий раздел Compose:
- /mnt/projector-usb:/usb
# либо отдельный локальный каталог:
- /srv/projector-exports:/local-exports
В .env:
EXPORT_DESTINATIONS={"usb":{"name":"Projector USB","path":"/usb","usb":true,"fat32":true},"local":{"name":"Local exports","path":"/local-exports"}}
Указывайте fat32:true для FAT32, чтобы отклонять файлы больше 4 GiB минус 1 байт. Тип ФС Docker bind mount не всегда можно надёжно определить внутри контейнера, поэтому этот флаг задаётся явно. Маркер должен находиться на самом USB, а не в пустой точке монтирования. После смены/повторного подключения USB пересоздайте контейнеры (docker compose up -d --force-recreate app worker), чтобы bind mount указывал на актуальный накопитель. Наличие маркера и свободное место повторно проверяются при копировании; ошибка размонтирования/записи останавливает задание.
В Экспортах выберите назначение и нажмите На USB. Это отдельное отменяемое задание копирования без перекодирования. Файл пишется во временное имя, flush/fsync и размер проверяются перед публикацией. Существующие имена не перезаписываются; кнопка С заменой требует явного подтверждения. Для одновременного хранения разных дорожек одного видео используйте разные каталоги/переименуйте существующий файл перед копированием. Внутри приложения результаты разделены по ID задания.
Дождитесь завершения копирования, остановите использующие mount контейнеры и выполните на хосте sync и sudo umount /mnt/projector-usb. Не выдёргивайте диск во время записи. Контейнеру не нужны privileged-режим или доступ к /dev.
Если YG300 не воспроизводит файл
- Проверьте экспортированный Conservative MP4 на FAT32 и простое имя файла (при необходимости латиницей).
- Проверьте отдельную аудиодорожку; для старого декодера оставляйте stereo и AAC-LC.
- Попробуйте Low Bitrate: меньший поток помогает слабым декодерам/медленному USB.
- Попробуйте Alternative AVI. Поддержка H.264, AAC и MPEG-4 зависит от ревизии.
- Отключите sidecar или встройте текстовые субтитры в картинку.
- Если USB не виден — проверьте mount, маркер, права UID 10001 и
EXPORT_DESTINATIONS. Диски автоматически не форматируются. - Ошибки FFmpeg доступны в Заданиях → Диагностика FFmpeg. Неудачные исходники, выбранные несуществующие дорожки и нехватка места не замалчиваются.
Оценка размера основана на битрейте и длительности и не является точным обещанием. Длинное видео на FAT32 может потребовать меньшего битрейта или exFAT, если конкретный проектор его поддерживает. Автоматического разбиения видео нет.
Разработка и проверки
Python 3.12+, Node 22+, FFmpeg/FFprobe с libx264, AAC, MP3, libass и zscale.
python3 -m venv .venv
.venv/bin/pip install -r backend/requirements.txt pytest
.venv/bin/python -m pytest -q
npm ci --prefix frontend
npm run typecheck --prefix frontend
npm run build --prefix frontend
Локальный запуск: экспортируйте ADMIN_PASSWORD, APP_SECRET, DATA_DIR (абсолютный записываемый каталог), затем в трёх терминалах:
# из backend, используйте абсолютный путь к .venv при необходимости
../.venv/bin/alembic upgrade head
../.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
../.venv/bin/python -m app.worker
# из корня, в третьем терминале:
npm run dev --prefix frontend
Vite проксирует /api на 8000. Откройте http://localhost:5173. Для production frontend собирается в Docker и отдаётся FastAPI.
Тесты не требуют Jellyfin: httpx MockTransport проверяет реальные REST-контракты; отдельные тесты проверяют безопасность, отдельный эпизод, cache, состояния/отмену очереди, дорожки, субтитры, имена, пути, FAT32 и копирование. Интеграционные тесты генерируют MKV с двумя аудиодорожками и кириллическими субтитрами, транскодируют фрагмент всеми тремя профилями и проверяют его FFprobe. Полный воркер проверяется с direct source, burn-in и sidecar.
Ограничения и ручная проверка
- Реальный Jellyfin, конкретный YG300, USB hot-unplug, ARM64 и HDR-картинка требуют проверки на вашем оборудовании. Пройдите описанный выше путь для фильма и одного эпизода, проверьте вторую медиаверсию, язык аудио, burn-in/sidecar, отмену длинного экспорта и сохранение истории после
docker compose restart. - Один администратор, cookie-сессия на 24 часа; отдельных пользователей приложения и разграничения доступа нет. Ограничение попыток входа действует в памяти одного API-процесса. Пользовательские каталоги фиксируются администратором через deployment, не через произвольные пути API.
- Удаление вывода требует подтверждения; история остаётся. Автоматической очистки готовых файлов, распределённых воркеров, OCR и извлечения USB нет.
- После аварийного отключения при копировании на внешнем диске может остаться скрытый
.partialфайл; его можно удалить после остановки воркера. Готовое видео публикуется атомарно, video+SRT — два отдельных файла, а не транзакция файловой системы. - Не меняйте настройки Jellyfin, пока идут задания. Они используют сохранённые настройки сервера. Не изменяйте вручную SQLite и каталоги во время работы. Для резервной копии остановите app/worker и сохраните volume вместе с APP_SECRET.
Структура: backend/app/main.py — HTTP/auth, jellyfin.py — REST, models.py/transcode.py — профили и FFmpeg, queue.py/worker.py — очередь и процессы, files.py — ограничение путей и назначения. frontend/src — React/TypeScript, backend/migrations — схема БД, backend/tests — автоматические проверки.