Systemd для непривилегированного пользователя

Уже год я работаю девопсом в банке. Это первая работа за много лет, где у меня нет полных прав в системе, то есть, делать что-либо под учёткой root и пользоваться sudo я не могу. Одновременно с этим на серверах могут быть дополнительные ограничения, например, может не быть доступа к планировщику cron, даже к личному через crontab -e. Также, в число моих обязанностей входит развёртывание собранных приложений (в основном это запуск файлов .jar), соответственно, также под непривилегированными учётками. Часто люди пытаются запускать такие приложения в фоне прямо в консольной сессии:

nohup "java -jar be-app-project-${VERSION}.jar --server.port=9090 &"

А потом, чтобы остановить приложение, приходится искать его идентификатор процесса (PID), используя порой вот такие громоздкие конструкции (видел в одном пайплайне развёртывания):

ps -ef | grep be-app-project-*.jar | grep -v grep | awk '$1=="project" {print $2}' | xargs kill

Можно, конечно, для отлавливания PID пользоваться pgrep, что несколько улучшит вид вышеприведённой строки, но общей кривизны решения это не меняет, к тому же, если пытаться такие практики применять в системах CI/CD типа Gitlab или Teamcity, то раннер может не запустить фоновый процесс и будет бесконечно висеть или выдаст ошибку.

Решением всех этих проблем является systemd, сервисы, таймеры и прочие сущности (юниты) которого будут работать в пространстве пользователя, т. е., для управления ими не требуется никакого повышения привилегий. Более того, отпадёт необходимость ухищряться в фоновом запуске приложения из консольной сессии и последующего отлавливания PID. Да и в целом грех не использовать systemd — это современная, гибкая и удобная система, основными препятствиями к освоению которой являются косность мышления, предубеждение против новых решений и нежелание учиться новому. С этим надо бороться по мере сил. С запуском непривилегированных юнитов есть один небольшой нюанс, о нём ниже.

Начнём с замены крона, опишу самый минимум. Предположим, каждые 3 минуты нужно запускать скрипт, который будет добавлять текущее время в текстовый файл. Создаём сам скрипт, например, по пути /home/user/scripts/add-time.sh, в котором обязательно нужно указать шебанг, чтобы скрипт гарантированно ассоциировался с bash.

#!/bin/bash
date +'%F %T' >> ~/add-time.txt

Затем сделать его исполняемым.

user@k3:~$ chmod u+x /home/user/scripts/add-time.sh

Теперь приступим к созданию службы, или сервиса, или демона. Все команды systemctl нужно будет запускать с параметром --user, что указывает на работу не c системными, а с пользовательскими юнитами. Следующая команда создаёт сервис add-time.service в каталоге ~/.config/systemd/user, где будут храниться все сервисы, таймеры и прочие юниты пользователя. Если этого каталога нет, он будет создан автоматически. Эту же команду можно использовать и для редактирования уже существующего юнита.

user@k3:~$ systemctl edit add-time --user --full --force

# Вставить в открывшийся редактор следующий текст и выйти с сохранением.
# В ExecStart= указывается абсолютный путь к скрипту. Скрипт должен быть исполняемым, иначе не заработает.
[Service]
ExecStart=/home/user/scripts/add-time.sh

Удобство команды systemctl edit в том, что после неё не нужно перечитывать список служб командой systemctl --user daemon-reload. Теперь создаём таймер, запускающий одноимённую службу с заданной периодичностью.

user@k3:~$ systemctl edit add-time.timer --user --full --force

# Вставить в открывшийся редактор следующий текст и выйти с сохранением.
[Timer]
OnCalendar=*:0/3

[Install]
WantedBy=timers.target

Таймер в systemd исключительно гибкий, он более функционален, чем cron. Например, можно запускать пропущенные задания, если сервер в нужный момент не работал, запускать задания через определённое время после запуска системы или в зависимости от активности/неактивности сервиса, настраивать точность срабатывания таймера для распределения нагрузки, когда в одно и то же время задание начинается на многих серверах и т. д. Секция [Install] здесь нужна для того, чтобы таймер можно было прописать в автозагрузку, т. е. он продолжит срабатывать после перезапуска сервера.

Теперь запустим таймер, пропишем его в автозагрузку и проверим его состояние. Видно, что таймер запускает сервис add-time.service и до его запуска осталась 1 мин 48 сек.

# Запуск
user@k3:~$ systemctl --user start add-time.timer
# Автозагрузка
user@k3:~$ systemctl --user enable add-time.timer
Created symlink /home/user/.config/systemd/user/timers.target.wants/add-time.timer → /home/user/.config/systemd/user/add-time.timer.
# Статус
user@k3:~$ systemctl --user status add-time.timer
● add-time.timer
     Loaded: loaded (/home/user/.config/systemd/user/add-time.timer; enabled; vendor preset: enabled)
     Active: active (waiting) since Wed 2025-04-16 15:46:08 MSK; 3s ago
    Trigger: Wed 2025-04-16 15:48:00 MSK; 1min 48s left
   Triggers: ● add-time.service

Apr 16 15:46:08 k3 systemd[952]: Started add-time.timer.

Через некоторое время видно результат в текстовом файле:

user@k3:~$ cat add-time.txt
2025-04-16 15:48:43
2025-04-16 15:51:43
2025-04-16 15:54:43
2025-04-16 15:57:43
2025-04-16 16:00:43
2025-04-16 16:03:43

Теперь про тот небольшой нюанс запуска пользовательских непривилегированных сервисов systemd, о котором я упоминал. Дело в том, что такие сервисы работают только тогда, когда пользователь работает в системе. Если он не зашёл, его сервисы не работают. К счастью, можно включить возможность выполнения сервисов пользователя вне зависимости от его логина. Сделать это нужно один раз, но команда включения при повторном применении ошибок не даёт, поэтому её можно спокойно использовать без предварительной проверки, например, в плейбуках Ansible.

# "Linger: no" - службы работать без входа в систему не будут
user@k3:~$ loginctl user-status
user (1000)
           Since: Sat 2025-04-12 12:24:26 MSK; 4 days ago
           State: active
        Sessions: *2
          Linger: no
    [...]

# Включить для текущего пользователя
user@k3:~$ loginctl enable-linger $USER

# Теперь порядок
user@k3:~$ loginctl user-status
user (1000)
           Since: Sat 2025-04-12 12:24:26 MSK; 4 days ago
           State: active
        Sessions: *2
          Linger: yes
    [...]

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

[Unit]
Description=Project App Backend {{ backend_maven_version }}

[Service]
Restart=always
Environment=REPORT_PATH={{ backend_report_directory }}
ExecStart=/usr/bin/java -Xrs -jar {{ backend_lib_directory }}/be-app-project-{{ backend_maven_version }}.jar

[Install]
WantedBy=multi-user.target

Шаги копирования шаблона и запуска службы в плейбуке.

- name: "Copy unit file"
  template:
    src: "{{ service_name }}.service.j2"
    dest: "~/.config/systemd/user/{{ service_name }}.service"

- name: "Start service"
  systemd:
    scope: user
    daemon_reload: true
    name: "{{ service_name }}"
    state: started
    enabled: true

Теперь можно просто и удобно работать со службами systemd (которые, к слову, могут и перезапускаться сами в случае сбоя, быть зависимыми друг от друга и много чего ещё) и не вспоминать про ловлю процессов и висящие в фоне терминалы, и всё это безо всяких привилегий root.

Перекодировка DVD-Video и метаданных в H.265

Попался мне один DVD-Video с концертом, который надо было перекодировать в более компактный и удобный формат. Сегодня это H.265 (HEVC) для видео и OPUS для аудиодорожки. Также, хотелось бы иметь удобную навигацию, то есть, возможность перематывать сразу на начало следующего номера. Для этого необходимо вытащить с DVD метаданные и переделать их в формат, понятный кодировщику ffmpeg. Сразу скажу, что с метаданными-то и возникли проблемы, которые нужно было решить. Всё действие будет происходить в командной строке Powershell. Поехали.

DVD имел стандартный вид — это папка VIDEO_TS с файлами IFO, где хранится меню, и VOB, где содержится видео в формате MPEG-2, порезанное на части по гигабайту. Полезные файлы VOB имеют индекс VTS_<номер видео>_<номер файла>.VOB, где номера начинаются с единицы. Например, такими файлами будут VTS_01_1.VOB, VTS_01_2.VOB и так далее, или VTS_02_1.VOB, VTS_02_2.VOB и так далее. Их можно легко вычислить по большому размеру (столбец Length):

cd "C:\temp\PETERSON_QUARTET\VIDEO_TS"
dir -Recurse -Include "*.ifo","*.vob"

    Directory: C:\temp\PETERSON_QUARTET\VIDEO_TS

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-ar--          01.04.2025    10:01          16384 VIDEO_TS.IFO
-ar--          01.04.2025    10:01        1198080 VIDEO_TS.VOB
-ar--          01.04.2025    10:01          40960 VTS_01_0.IFO
-ar--          01.04.2025    10:01         149504 VTS_01_0.VOB
-ar--          01.04.2025    11:10     1073664000 VTS_01_1.VOB
-ar--          01.04.2025    11:10     1054126080 VTS_01_2.VOB
-ar--          01.04.2025    10:01          43008 VTS_02_0.IFO
-ar--          01.04.2025    10:01         149504 VTS_02_0.VOB
-ar--          01.04.2025    11:10     1073295360 VTS_02_1.VOB
-ar--          01.04.2025    11:10     1073457152 VTS_02_2.VOB
-ar--          01.04.2025    11:05      228806656 VTS_02_3.VOB

Сразу займёмся метаданными. Нужно сказать, что меню DVD-Video могут иметь самую причудливую и неудобную для обработки структуру. Раньше для вытаскивания пунктов меню из файлов IFO я пользовался программой ChapterXtractor, для которой я сделал шаблон, сразу при сохранении создающий файл метаданных ffmpeg с пересчётом временных меток. Но когда я открыл файлы VTS_01_0.IFO и VTS_02_0.IFO, стало ясно, что здесь так сделать не выйдет.

dvd_chapterXtractor1.png
dvd_chapterXtractor2.png

Как видно из картинок, каждое меню содержит полный набор номеров, но часть из них очень короткие, то есть, это явно заглушки. Само меню на DVD двухстраничное, что, наверное, и обусловило наличие двух меню вместо одного. Концерт на DVD один, поэтому эти два меню нужно как-то совмещать.

dvd_menu1.jpg
dvd_menu2.jpg

Для этого я взял консольную программу vgtmpeg, которая представляет собой видоизменённый ffmpeg, дополненный инструментами по работе с DVD/BluRay и т. п. В частности, эта программа умеет показывать метаданные DVD и даже конвертировать их сразу в формат ffmpeg, но сейчас это нам не подходит, так как нужна предварительная обработка. Вот информация, которую vgtmpeg выдаёт об этом диске:

$vgtmpeg = & C:\scripts\vgtmpeg\vgtmpeg.exe -hide_banner -i 'dvd://.' 2>&1

# Вывести результат
$vgtmpeg

Guessed Channel Layout for Input Stream #0.1 : stereo
Input #0, mpeg, from 'dvd://.?title=1':
  Metadata:
    source_type     : dvd
  Duration: 00:42:15.18, start: 0.000000, bitrate: 6714 kb/s
    Chapter #0:0: start 0.000000, end 513.689289
    Chapter #0:1: start 513.689289, end 989.563244
    Chapter #0:2: start 989.563244, end 1420.233467
    Chapter #0:3: start 1420.233467, end 1768.811800
    Chapter #0:4: start 1768.811800, end 2132.419356
    Chapter #0:5: start 2132.419356, end 2533.942333
    Chapter #0:6: start 2533.942333, end 2534.118556
    Chapter #0:7: start 2534.118556, end 2534.294778
    Chapter #0:8: start 2534.294778, end 2534.471000
    Chapter #0:9: start 2534.471000, end 2534.647222
    Chapter #0:10: start 2534.647222, end 2534.823444
    Chapter #0:11: start 2534.823444, end 2534.999667
    Chapter #0:12: start 2534.999667, end 2535.175889
  Program 1 
    Stream #0:0[0x100e0]: Video: mpeg2video (Main), yuv420p(tv, top first), 720x480 [SAR 8:9 DAR 4:3], 29.97 fps, 29.97 tbr, 90k tbn, 59.94 tbc
    Stream #0:1[0x100a0](und): Audio: pcm_dvd, 48000 Hz, stereo, s16, 1536 kb/s
    Metadata:
      language-iso639_2: und
      language-simple : Unknown
      language-description: Unknown
  No Program
    Stream #0:2[0x100bf]: Data: dvd_nav_packet
Guessed Channel Layout for Input Stream #1.1 : stereo
Input #1, mpeg, from 'dvd://.?title=2':
  Metadata:
    source_type     : dvd
  Duration: 00:45:33.19, start: 0.000000, bitrate: 6953 kb/s
    Chapter #1:0: start 0.000000, end 0.176222
    Chapter #1:1: start 0.176222, end 0.352444
    Chapter #1:2: start 0.352444, end 0.528667
    Chapter #1:3: start 0.528667, end 0.704889
    Chapter #1:4: start 0.704889, end 0.881111
    Chapter #1:5: start 0.881111, end 1.057333
    Chapter #1:6: start 1.057333, end 388.582222
    Chapter #1:7: start 388.582222, end 712.003578
    Chapter #1:8: start 712.003578, end 1017.434489
    Chapter #1:9: start 1017.434489, end 1419.978933
    Chapter #1:10: start 1419.978933, end 1858.760578
    Chapter #1:11: start 1858.760578, end 2302.571778
    Chapter #1:12: start 2302.571778, end 2733.185778
  Program 2 
    Stream #1:0[0x200e0]: Video: mpeg2video (Simple), yuv420p(tv, top first), 720x480 [SAR 8:9 DAR 4:3], 29.97 fps, 29.97 tbr, 90k tbn, 59.94 tbc
    Stream #1:1[0x200a0](und): Audio: pcm_dvd, 48000 Hz, stereo, s16, 1536 kb/s
    Metadata:
      language-iso639_2: und
      language-simple : Unknown
      language-description: Unknown
  No Program
    Stream #1:2[0x200bf]: Data: dvd_nav_packet
At least one output file must be specified

Отлично, всё видно. Нужные строки — со словом chapter. Сделаем из них таблицу и вычислим длительность частей в дополнительной колонке.

$csv = $vgtmpeg -match 'chapter' -replace '.*#(\d+:\d+).*start ([\d\.]+).*end ([\d\.]+)','$1;$2;$3' |
ConvertFrom-Csv -Header Chapter,Start,End -Delimiter ';' |
select *,@{n='Range';e={$_.End - $_.Start}}

# Вывести результат
$csv

Chapter Start       End          Range
------- -----       ---          -----
0:0     0.000000    513.689289  513,69
0:1     513.689289  989.563244  475,87
0:2     989.563244  1420.233467 430,67
0:3     1420.233467 1768.811800 348,58
0:4     1768.811800 2132.419356 363,61
0:5     2132.419356 2533.942333 401,52
0:6     2533.942333 2534.118556   0,18
0:7     2534.118556 2534.294778   0,18
0:8     2534.294778 2534.471000   0,18
0:9     2534.471000 2534.647222   0,18
0:10    2534.647222 2534.823444   0,18
0:11    2534.823444 2534.999667   0,18
0:12    2534.999667 2535.175889   0,18
1:0     0.000000    0.176222      0,18
1:1     0.176222    0.352444      0,18
1:2     0.352444    0.528667      0,18
1:3     0.528667    0.704889      0,18
1:4     0.704889    0.881111      0,18
1:5     0.881111    1.057333      0,18
1:6     1.057333    388.582222  387,52
1:7     388.582222  712.003578  323,42
1:8     712.003578  1017.434489 305,43
1:9     1017.434489 1419.978933 402,54
1:10    1419.978933 1858.760578 438,78
1:11    1858.760578 2302.571778 443,81
1:12    2302.571778 2733.185778 430,61

Вот, стало гораздо лучше. В колонке Range числа хранятся точные, просто при выводе всей таблицы отображаются в сокращённом виде. Например, если вывести второе значение отдельно, то оно будет 475,873955, так что по поводу корректности расчётов можно не беспокоиться. Теперь наглядно видно, что первые 6 частей берутся из первого меню, а последующие — из второго. Из-за того, что начала и окончания частей не идут последовательно по времени, надо их реконструировать. Для этого я взял первую метку начала и дальше прибавлял к ней длину из Range, а потом отсеивал те части, которые меньше 5 секунд.

$timeline = @()
$t = $csv[0].Start
$csv |% {
    $obj = [PSCustomObject]@{
        Start = $t
        End = $t + $_.Range
        Range = $_.Range
    }
    $t = $t + $_.Range
    $timeline += $obj |? Range -gt 5
}

# Вывести результат
$timeline

  Start     End  Range
  -----     ---  -----
   0,00  513,69 513,69
 513,69  989,56 475,87
 989,56 1420,23 430,67
1420,23 1768,81 348,58
1768,81 2132,42 363,61
2132,42 2533,94 401,52
2536,23 2923,76 387,52
2923,76 3247,18 323,42
3247,18 3552,61 305,43
3552,61 3955,15 402,54
3955,15 4393,94 438,78
4393,94 4837,75 443,81
4837,75 5268,36 430,61

# Показать количество строк
$timeline.count
13

Прекрасно! Ровно 13 частей. Можно теперь лепить файл метаданных ffmpeg.

# Названия частей
$titles = ("Cakewalk
Love Ballade
Soft Winds
You Look Good To Me
My One And Only Love
Nigerian Market Place
Cool Walk
I Can't Get Started
Come Sunday
Reunion Blues
If You Only Knew
Sushi Blues
Blues Etude") -split "`n"

# Создание нового файла метаданных file.ffmeta для ffmpeg, заголовок
";FFMETADATA1
title=Recorded 'LIVE' at Kan-i Hoken Hall, Tokyo on February 28, 1987
artist=Oscar Peterson Featuring Joe Pass - The Quartet Live
" > file.ffmeta

# Добавление частей с названиями, переделка времени в миллисекунды
$c = 0
$timeline |% {
"[CHAPTER]
TIMEBASE=1/1000
START=$($_.start.tostring("0.000") -replace '\D')
END=$($_.end.tostring("0.000") -replace '\D')
title=$($titles[$c])
"
$c++
} >> file.ffmeta

Сначала я округлял число до тысячных: [Math]::Round($_.start, 3), и получил вроде бы правдоподобный результат, но выяснилось, что если последней цифрой после запятой оказывался 0, то он, естественно, пропадал и ffmpeg при попытке вшить эти метаданные в видеофайл выдавал ошибку, так как такая метка была на один десятичный разряд меньше и оказывалась по времени раньше предыдущей. Поэтому задействовал .tostring("0.000"), что гарантировало корректный перевод в строку.

Фрагмент полученного файла file.ffmeta:

;FFMETADATA1
title=Recorded 'LIVE' at Kan-i Hoken Hall, Tokyo on February 28, 1987
artist=Oscar Peterson Featuring Joe Pass - The Quartet Live

[CHAPTER]
TIMEBASE=1/1000
START=0000
END=513689
title=Cakewalk

[CHAPTER]
TIMEBASE=1/1000
START=513689
END=989563
title=Love Ballade

[CHAPTER]
TIMEBASE=1/1000
START=989563
END=1420233
title=Soft Winds

С метаданными разобрались, теперь дело за перекодировкой. Параметры -analyzeduration 100M -probesize 100M нужны, чтобы увеличить глубину определения аудиопотоков, потому что без них ffmpeg иногда не видел в DVD аудиодорожку. На вход подаются все файлы VOB, склеенные через concat: по порядку, и файл file.ffmeta как метаданные.

DVD-Video — старый формат, и видео там чересстрочное (interlaced), когда кадр делится на полукадры — «поля» (fields) — которые выводятся на экран последовательно. Раньше я всегда оставлял чересстрочную развёртку, когда кодировал в H.264 (AVC), потому что качество работы фильтров деинтерлейса тогда часто вызывало вопросы. H.265 тоже вроде как-то поддерживает чересстрочное видео, но cмысла заниматься этой экзотикой сегодня я не вижу, потому что найден прекрасный фильтр деинтерлейса bwdif. Оказалось, что наилучшие результаты деинтерлейса достигаются при удвоении частоты кадров — видео получается плавное и никакой «гребёнки» и «теней» в кадре не наблюдается. Фильтр сам умеет определять порядок полей, так что в большинстве случаев никаких настроек не требуется. А то, что видео получается 60 кадров/сек, сегодня уже не проблема.

Мой процессор поддерживает аппаратное кодирование в H.265, поэтому используется кодировщик hevc_qsv, качество 26 (настройка в сторону лучшего качества, чем стандартные 28 в софт-варианте libx265), хотя можно поставить, к примеру, и 24, если источник шумный. Звуковой кодек — libopus, 192 кбит/сек, чего вполне достаточно для любого стереосигнала. Тэг-идентификатор формата видео я ставлю по старой памяти; полагаю, можно обойтись и без него.

ffmpeg -analyzeduration 100M -probesize 100M `
-i "concat:VTS_01_1.VOB|VTS_01_2.VOB|VTS_02_1.VOB|VTS_02_2.VOB|VTS_02_3.VOB" -i file.ffmeta `
-vf bwdif -c:v hevc_qsv -c:a libopus -b:a 192k `
-global_quality:v 26 -tag:v hvc1 "Oscar Peterson - The Quartet Live (1987).mp4"

Результат: конечный файл занимает 655 МБ против 4,2 ГБ у исходного DVD. Качество никак не пострадало, заголовки и навигация на месте.

P. S. Вместо ручного заполнения названий частей и заголовков можно взять их из онлайн-каталога, где есть API. Вот вариант с Discogs.

Cсылка на этот конкретный DVD: https://www.discogs.com/ru/release/19022464-Oscar-Peterson-Featuring-Joe-Pass-The-Quartet-Live, оттуда надо взять идентификатор — это цифры после слова release.

# Получение данных
$release = Invoke-WebRequest https://api.discogs.com/releases/19022464 -UserAgent "FooBarApp/3.0" |ConvertFrom-Json

# Cписок треков для метаданных и заголовки задаются уже из полученной информации с сайта
$titles = $release.tracklist.title

";FFMETADATA1
title=$($release.title)
artist=$($release.artists_sort)
" > file.ffmeta

Справка по Discogs API.

Собрал компьютер сыну

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

Сначала думал о чём-то покомпактнее, типа формата mini-ITX, но в результате не стал связываться с корпусами с нестандартными блоками питания и взял обычный microATX в Регарде, где есть удобный конфигуратор системного блока, чтобы не особенно раздумывать о совместимости того или иного железа. Можно было там же заказать и сборку, но мы хотели собирать сами.

Материнская плата MSI A520M-A PRO

Здесь у меня не было особых критериев, выбирал просто по отсутствию сильно устаревших разъёмов, давно знакомому бренду и компоновке задней панели, как это ни удивительно. Рассматривал также Gigabyte и ASRock.

Процессор AMD Ryzen 5 5600X OEM + кулер ID-COOLING FROZN A410 Black

Тут выбор базировался на показателе best value на сайте cpubenchmark.net, где процессор AMD Ryzen 5 5600, который я и хотел взять изначально, находится на первом месте.

PassMark - Price Performance

Я заказал боксовый вариант, но мне перезвонил менеджер (спасибо ему!) и предложил более высокочастотный 5600X и отдельный кулер-башню за те же деньги. Меня беспокоило, не повышен ли теплопакет у 5600X, но оказалось, что это те же 65 Вт, поэтому я согласился и не пожалел. Кулер отличный и даже, пожалуй, избыточный для моего случая, но это вряд ли можно назвать недостатком. Чтобы поставить его, нужно было открутить от материнской платы штатный крепёж и привинтить идущий в комплекте. Шприц с термопастой также прилагался.

Оперативная память Kingston Fury Beast Black 32GB DDR4 3200MHz 2x16GB kit (KF432C16BB1K2/32)

В принципе, хватило бы и 16 ГБ, но пусть уже будет, чтобы потом не возвращаться к этой теме. Тем более, цены на память пока не космические.

Накопитель SSD Samsung 970 EVO Plus 1TB (MZ-V7S1T0BW)

Те же соображения насчёт объёма, что и по памяти. В моём рабочем неттопе стоит та же модель, но вполовину меньше, а стоила она тогда примерно столько же, и это при том, что было совсем другое время и доллар стоил сильно дешевле.

Видеокарта NVIDIA GeForce RTX 3050 MSI 8Gb (RTX 3050 VENTUS 2X XS 8G)

Видеокарту я сначала хотел взять с 6 ГБ памяти, но потом прочёл, что у такой модификации уменьшена пропускная способность шины и соотношение цены и эффективности хуже, поэтому взял с 8 ГБ, где этого недостатка нет, а цена выше незначительно. А ещё у этих видеокарт есть аппаратный кодировщик NVENC, умеющий работать с AVC/HEVC — это пригодится для стримов и просто для кодирования видео.

Wi-Fi адаптер ASUS PCE-AXE5400

Чтобы не тянуть провод от роутера из коридора, занял единственный разъём PCI-E x1 на материнской плате этим устройством. Работает прекрасно.

Блок питания Zalman MegaMax 700W (ZM700-TXIIv2)

Другой блок питания Zalman замечательно работает у меня уже 5 лет в сетевом хранилище и я был рад, что БП этой фирмы доступны для покупки — они тихие и надёжные. Для видеокарты рекомендован БП в 500 Вт, так что, полагаю, мощность адекватная этой конфигурации.

Корпус AeroCool Cs-111 Black

Корпус удобный, с прозрачной дверцей, которая даже не прикручивается винтами, а просто удерживается магнитиком, и внутрь корпуса можно залезть без помех в любой момент. Есть пара мелких недостатков: внутри установлен 120-мм вентилятор без возможности регулировки оборотов, поэтому я заменил его на Aerocool Frost 12 PWM, у которого такая функция есть. Также, у этого корпуса есть два места под вентиляторы на крыше, а мне как раз это не нужно и я хотел бы эту перфорацию заглушить, чтобы туда не оседала пыль. Пока не придумал, как это сделать приличнее нарезания чёрного картона и его прикручивания к внутренней стороне, а 3d-печать мне сейчас недоступна. Каких-то фирменных заглушек в продаже я не нашёл. Ну, покамест накрыли эти отверстия расписанием уроков.

Монитор MSI 24″ Pro MP242A

«Раз уж и материнская плата, и видеокарта этой фирмы, то пусть уж и монитор будет», — подумал я опять в нерациональной манере. Рациональным было то, что этот монитор один из самых дешёвых FullHD с IPS-матрицей и HDMI-разъёмом. Написано «100 герц», хотя я никогда не понимал, какое отношение эти герцы имеют к ЖК-мониторам, ладно ЭЛТ были, там да. Показывает хорошо, и даже есть встроенные динамики.

Колонки Sven SPS-585 Black

Я выбирал колонки, чтобы у них не было эквалайзера, чтобы звук просто шёл напрямую без регулировок тембра. Таких колонок очень немного в потребительском сегменте. Эти оказались очень достойным вариантом. Из-за небольшого размера низкочастотного динамика пресловутые басы недостаточно глубокие, но колонки играют чисто и цену свою оправдывают полностью.

Микрофон Fifine K669 Black

Про микрофоны Фифайн я впервые услышал на Youtube-канале прекрасного Стива Сегуина, автора бесплатного сервиса видеоконференций VDO.Ninja. Микрофоны эти удивляют высоким качеством за свою достаточно скромную цену. Я купил один из самых дешёвых. Конечно, качество звука стало несопоставимо лучше, чем у микрофона, встроенного в веб-камеру Sven IC-950 HD, валявшуюся раньше у меня в ящике.

Прочее

  • Игровая клавиатура механическая проводная Redragon Fizz тихая, RED SWITCH
  • Игровая мышь проводная TechFurn, черный, серый
  • Коврик для компьютерной мышки и клавиатуры Aksholan, 800х300х3мм, «Карта мира», черный

Резюме

Машина-зверь, конечно, на мой неискушённый взгляд — где-то раза в 2-3 мощнее моего рабочего неттопа, который теперь перестанут мучать играми. BIOS материнской платы я сразу прошил на самую свежую бету, лежащую на сайте производителя, операционка установилась со свистом, грузится она за несколько секунд, всё прямо-таки летает. Тестированием не увлекались, бенчмарки всякие не запускали; пока в ходу Roblox и Minecraft, они работают отлично на максимальных настройках. В общем, хорошо получилось, и владелец доволен. Хватило бы лет на семь-восемь — было бы славно. Главное, чтобы все были живы-здоровы.

Новый домашний сервер

4-го августа я переехал со старого неттопа на новый GMKtec NucBox G3. Внутри — процессор Intel N100 Alder Lake 12-го поколения, 32 ГБ памяти Kingston PC4-25600 DDR4, 1 ТБ SSD Samsung 980 Pro и Wi-fi Realtek RTL8852BE (802.11ax), также имеется дополнительный порт для SSD-накопителя формата M.2 2242 (SATA). Я после установки системы Ubuntu 24.04 стал использовать wi-fi на время настройки, планируя потом подключиться к роутеру по проводу, да так на нём и остался — удобно, работает стабильно и скорость хорошая.

Внешний вид сервера Внешний вид сервера

Из минусов — мелкий вентилятор в нижней части корпуса, но пока минус этот скорее теоретический, потому что сейчас шума от него не слышно вообще после следующих настроек в BIOS, которые я подсмотрел на Reddit:

  1. Выставить TDP на 8 Вт (с 10 Вт), макс. потребление будет 19 Вт (с 24 Вт). В покое 8-9 Вт, выключенный — 1,2 Вт. Power -> Power limit select: 8W
  2. Включить Cstates (по умолчанию выключено). Advance -> CPU - Power management control -> C States: enabled
  3. Выключить турборежим процессора при загрузке. Advance -> CPU - Power management control -> Boot performance mode: Max Non-Turbo performance
  4. Повысить порог срабатывания вентилятора. Advance -> Hardware monitor -> Smart fan function -> Fan off: 40, Fan start: 65

Почему я решил поменять железо? Началось с того, что какой-то малолетний кретин бил мне по входной двери ногой и убегал. Чтобы выяснить, кто это делает, я организовал видеонаблюдение через глазок, на который установил альтернативный веб-сервер, позволивший мне получить с глазка потоки RTSP, которые шли на сервер Frigate NVR, поднятый всё в том же Докере. Для установки глазка пришлось немного рассверлить в двери дырку под него, купив очень красивое ступенчатое сверло.

Frigate Frigate

У Frigate есть возможность использовать различные варианты аппаратного видеоускорения и моделей обнаружения объектов, но на старом неттопе была доступна только чисто процессорная обработка. Это работало, но загрузка была довольно приличная, да и в целом эта конфигурация уже устарела — ей восемь лет, последние три из которых она работала круглосуточно. Так что в конце 2023 года я купил новый неттоп, который провалялся без дела до конца июля, когда, наконец, у меня дошли руки перевезти все сервисы со старого.

Сервисы на сегодняший день такие:

  • Реверс-прокси Træfik — обновил c версии 2.6 на 3.1, немного изменился синтаксис ярлыков, теперь там используются регулярные выражения вместо перечисления нескольких суффиксов или имён хоста, а ещё HTTP/3 сменил экспериментальный статус на стабильный.
  • Вышеупомянутый Frigate, у которого я включил видеоускорение VAAPI и модель OpenVino. Позже, наверное, надо бы попробовать добавить к нему Home Assistant как управляющую оболочку.
  • Блог на прекрасном движке Datenstrom Yellow
  • Вики на DokuWiki
  • Файловый сервис Nextcloud
  • Фотосервис Photoprism
  • Генеалогическое древо Webtrees
  • Страничка мониторинга (node_exporter, docker metrics, glances, smartctl_exporter, Prometheus, Grafana)

С мониторингом была сложная история. Я использовал PhpSysInfo в контейнере, и мне хотелось избавиться от довольно кривого способа сбора информации раз в полчаса на самом хосте и подкладывания файлов в веб-каталог приложения. Оказалось, что PhpSysInfo умеет работать через SSH, поэтому я создал на хосте выделенного пользователя и разрешил ему выполнять команды sensors и docker stats через sudo без запроса пароля.

sudo visudo /etc/sudoers.d/phpsysinfo

Cmnd_Alias PHPSYSINFO=/usr/bin/docker stats*, /usr/sbin/smartctl
phpsysinfo ALL = NOPASSWD: PHPSYSINFO

Пришлось открыть пару тем в репозитории Гитхаба из-за того, что какие-то моменты были непонятны, а какие-то не работали, но автор приложения, как и раньше, невероятно отзывчив и решает проблемы просто молниеносно, так что всё более-менее работало и прочитанные/записанные данные диска теперь показывались в понятных гигабайтах, а не в секторах, как раньше. Тем не менее, теперь вывод страницы после захода на неё стал очень тормозной — PhpSysInfo каждый раз лезет на хост по SSH и собирает данные (на полях отмечу утилиту sshpass, позволяющую автоматически подставлять пароль для входа). Вдобавок, перестало нормально работать отображение скопившихся системных обновлений, так что я начал искать альтернативу.

Glances Glances

Через некоторое время я нашёл Glances, у которого мне очень понравился внешний вид, но проект пока сыроват и документация у него достаточно фрагментарная, поэтому я решил вернуться к давней идее, на которую у старого неттопа не хватало ресурсов — Prometheus + Grafana. В конце концов, популярная вещь и по работе пригодится. Провозившись несколько дней, вспоминая, как там всё устроено, я нарисовал страницу более-менее в соответствии с моими предпочтениями. Что до производительности, то неттоп легко справляется с новым мониторингом. Glances я оставил как сборщик данных о контейнерах: он умеет экспортировать метрики для Prometheus.

Grafana Grafana

Все контейнеры с БД я обновил до MariaDB 11.4 с версии 10.6, завелось без проблем. PHP был обновлён на версию 8.3 ещё на старом неттопе. Docker volumes переделал просто в mount points — мне кажется, мигрировать данные и делать резервные копии так удобнее, каких-то преимуществ docker named volumes в моём случае я не вижу.

Малолетний кретин, кстати, в дверь мне больше не бил, поэтому я так и не знаю, кто это. Заснял, как в дверь плевали в ноябре 2023 года — возможно, это тот самый, но с тех пор ничего плохого с дверью не происходило.

Пока мне нравится, поживём — увидим.

Удаление плавающей заставки из мультсериала

Волею судеб с некоторых пор я последовательно скачиваю серии мультиков про Наруто. После скачивания для экономии места на сетевом хранилище я пережимаю эти серии в H.265, оставляя только само содержимое, удаляя финальные титры и песню-заставку (opening).

В первой порции этого сериала из 220 серий песня звучала в самом начале, поэтому вырезать её было достаточно просто. Теперь же, скачав пачку новых эпизодов, я обнаружил, что заставка не имеет постоянного времени начала и воспользоваться старым способом уже нельзя — нужно как-то обнаруживать её местоположение в видеофайле.

Ffmpeg справляется и с этой задачей. Механизм заключается в том, что видеофайл прогоняется через фильтр, сравнивающий кадры с образцом на скриншоте, выдавая статистику, из которой можно извлечь нужную метку времени t:.

frame=12549 fps=353 q=-0.0 size=N/A time=00:08:24.36 bitrate=N/A speed=14.2x    
frame=12719 fps=353 q=-0.0 size=N/A time=00:08:31.16 bitrate=N/A speed=14.2x    
[Parsed_blackframe_1 @ 0000023f584beec0] frame:12737 pblack:100 pts:12798 t:511.920000 type:I last_keyframe:12737
[Parsed_blackframe_1 @ 0000023f584beec0] frame:12738 pblack:100 pts:12799 t:511.960000 type:P last_keyframe:12737
...
[Parsed_blackframe_1 @ 0000023f584beec0] frame:12846 pblack:88 pts:12907 t:516.280000 type:P last_keyframe:12845
[Parsed_blackframe_1 @ 0000023f584beec0] frame:12847 pblack:89 pts:12908 t:516.320000 type:P last_keyframe:12845
frame=12891 fps=353 q=-0.0 size=N/A time=00:08:38.08 bitrate=N/A speed=14.2x    
frame=13088 fps=353 q=-0.0 size=N/A time=00:08:45.92 bitrate=N/A speed=14.2x

Это нужно сделать 2 раза: для кадра начала заставки и кадра конца, а затем уже на основании полученных меток времени приступать к обработке и перекодированию. Конечно, операция гораздо более трудоёмкая, чем раньше, но положительные эмоции от найденного работающего решения того стоят, да и надо же чем-то себя занимать на этой бессмысленной новогодней неделе.

Ситуация дополнительно усложнялась следующими факторами:

  1. Кадры заставки от серии к серии могут немного меняться и фильтр перестаёт воспринимать кадр как похожий на образец на скриншоте. Ниже примеры, где один образец кадра наложен на другой и видно, что логотип был сдвинут или увеличен.
naruto_opening5_end.jpg
naruto_opening1_end.jpg
  1. Заставка сама по себе меняется примерно каждые 25 серий, так что тут придётся пробежаться по эпизодам сериала, записать их диапазоны с теми или иными заставками и наделать пары скриншотов для каждого диапазона.
  2. Серии (именно их содержательная часть) длятся неодинаковое время. У одних финальные титры начинаются в 20:06, а у других — на минуту позже.
  3. Наличие квадратных скобок в именах файлов и путях. Почему-то многие владельцы раздач на торрент-трекерах питают слабость к подобным символам, поэтому приходится добавлять в скрипт дополнительные ухищрения.

Чтобы фильтр не был таким дотошным и допускал мелкие изменения в кадре по сравнению с образцом на картинке, надо понизить ему порог чувствительности. Но вскоре выяснилось, что если в образце преобладает чёрный цвет, то фильтр с увеличенной погрешностью начинал ошибочно определять обычные чёрные кадры вместо искомого. Вот пример подобной картинки:

naruto_opening3_start.jpg

В целом получилось так: для полноцветных кадров я выставлял blackframe=80 при стандартном значении 98, а для кадров на чёрном фоне оставлял по умолчанию blackframe.

Чтобы определить длительность серий, я сделал быстрый просмотр, выбирая по 10 серий, чтобы индекс массива для удобства совпадал с последней цифрой серии, и нажимая F8 в Powershell ISE на соответствующей строке.

$episodes = 80..89 |% {$_.ToString("000")}
$e = dir *.avi |? name -match "$($episodes -join '|')"

ffplay -i $e[0].Name -ss 20:06 -an
ffplay -i $e[1].Name -ss 20:06 -an
ffplay -i $e[2].Name -ss 20:06 -an
ffplay -i $e[3].Name -ss 20:06 -an
ffplay -i $e[4].Name -ss 20:06 -an
ffplay -i $e[5].Name -ss 20:06 -an
ffplay -i $e[6].Name -ss 20:06 -an
ffplay -i $e[7].Name -ss 20:06 -an
ffplay -i $e[8].Name -ss 20:06 -an
ffplay -i $e[9].Name -ss 20:06 -an

Результаты я заносил в список прямо в скрипте, подвёрстывая их к диапазонам серий с видами заставок, чтобы потом последовательно раскомментировать нужные строки.

Чтобы в случае чего можно было вернуться к перекодированию без возни с повторным отлавливанием заставки, временные метки записываются в файл CSV:

Name;Start;End
Naruto_Shippuuden_TV2_[001_of_XXX]_[Rus_Jap]_[NIKITOS].avi;511.920000;601.760000
Naruto_Shippuuden_TV2_[002_of_XXX]_[Rus_Jap]_[NIKITOS].avi;119.000000;208.800000
Naruto_Shippuuden_TV2_[003_of_XXX]_[Rus_Jap]_[NIKITOS].avi;130.960000;220.800000
...
Naruto_Shippuuden_TV2_[131_of_XXX]_[Rus_Jap]_[NIKITOS].avi;448.489708;536.827964782715
Naruto_Shippuuden_TV2_[132_of_XXX]_[Rus_Jap]_[NIKITOS].avi;383.299583;471.637840270996
Naruto_Shippuuden_TV2_[133_of_XXX]_[Rus_Jap]_[NIKITOS].avi;1.501500;89.8814591169357

В файле CSV первые конечные метки отличаются от тех, что идут позже — у них меньше десятичных знаков. Дело в том, что сначала я указывал ffmpeg искать в первых 10 минутах видеофайла и начальный, и конечный кадр — в функции была одна команда. Потом я догадался, что конечный кадр ведь идёт после начального, поэтому нужно начинать искать конечный кадр с того места, где был начальный, а так как все заставки идут не более полутора минут, то ими надо поиск и ограничить. Длительность заставок выяснилась путём анализа того же CSV-файла.

Import-Csv 'C:\temp\log.txt' -Delimiter ';' |select *,@{n='Duration';e={$_.end - $_.start}}

Name                                                       Start      End        Duration
----                                                       -----      ---        -------
Naruto_Shippuuden_TV2_[001_of_XXX]_[Rus_Jap]_[NIKITOS].avi 511.920000 601.760000 89,84
Naruto_Shippuuden_TV2_[002_of_XXX]_[Rus_Jap]_[NIKITOS].avi 119.000000 208.800000  89,8
Naruto_Shippuuden_TV2_[003_of_XXX]_[Rus_Jap]_[NIKITOS].avi 130.960000 220.800000 89,84
Naruto_Shippuuden_TV2_[004_of_XXX]_[Rus_Jap]_[NIKITOS].avi 170.000000 259.840000 89,84
Naruto_Shippuuden_TV2_[005_of_XXX]_[Rus_Jap]_[NIKITOS].avi 81.040000  170.760000 89,72
Naruto_Shippuuden_TV2_[006_of_XXX]_[Rus_Jap]_[NIKITOS].avi 191.960000 281.760000  89,8
Naruto_Shippuuden_TV2_[007_of_XXX]_[Rus_Jap]_[NIKITOS].avi 0.000000   89.840000  89,84

Тем самым я убил двух зайцев: уменьшил время обработки и убрал дополнительную функцию, вычисляющую время между найденными кадрами и пишущую в лог ошибку и пропускающую перекодирование, если это время превышало 90 секунд. Но так как значение местонахождения конечного кадра было уже относительным к начальному (например, для начального теперь оно могло быть 150, а для конечного — 88), поэтому для команды кодирования нужно эти значения складывать, и это вычисленное значение даёт большее число знаков после запятой.

Для лучшего ориентирования я добавил вывод информации на экран. Здесь видно, что поиск начала занимает 43 секунды, а поиск конца уже 5.

2024.01.02 17:49:45 Поиск начала заставки в Naruto_Shippuuden_TV2_[147_of_XXX]_[Rus_Jap]_[NIKITOS].avi...
2024.01.02 17:50:28 Начало заставки найдено на 384.509125 сек.
2024.01.02 17:50:28 Поиск конца заставки в Naruto_Shippuuden_TV2_[147_of_XXX]_[Rus_Jap]_[NIKITOS].avi...
2024.01.02 17:50:33 Конец заставки найден на 472.847373962402 сек.

Есть и журнал ошибок. Какие-то файлы всё же приходится обрезать самому в Avidemux, пока компьютер перелопачивает остальные, к тому же, среди набора серий попадается неформат — склеенные или разделённые на несколько частей эпизоды. Но это всё равно лучше, чем обрабатывать руками все 235 видеофайлов.

2024.01.02 14:28:50 Конец заставки не найден в Naruto_Shippuuden_TV2_[125_of_XXX]_[Rus_Jap]_[NIKITOS].avi
2024.01.02 14:29:20 Начало заставки не найдено в Naruto_Shippuuden_TV2_[126_of_XXX]_[Rus_Jap]_[NIKITOS].avi
2024.01.03 07:23:11 Начало заставки не найдено в Naruto_Shippuuden_TV2_[183_of_XXX]_[Rus_Jap]_[NIKITOS].avi

Собственно, скрипт.

## Заставка 1 рассвет
#$episodes = 1..30
## Заставка 2 глаз
#$episodes = 31..53
## Заставка 3 птица
#$episodes = 54..77
#$episodesLong = ,55+71
## Заставка 4 облака
$episodes = 78..102                         # Диапазон обрабатываемых эпизодов
$episodesLong = ,79+80+82+85+91+92+100+102  # Эпизоды с увеличенной длиной
## Заставка 5 водопад
#$episodes = 103..128
#$episodesLong = ,104+106+107+111..115+120+123..125
## Заставка 6 мороженое
#$episodes = 129..153
#$episodesLong = ,131+133+135+138..141+143+145+148+149..155
## Заставка 7 зонтик
#$episodes = 154..179
#$episodesLong = 149..155+157..159+162+167+170..172+174..179
## Заставка 8 лицо
#$episodes = 180..196+199..205
#$episodesLong = 180..184+186..208
## Заставка 9 летающие острова
#$episodes = 206..230
#$episodesLong = ,208+210+211+213..235
## Заставка 10 скала
#$episodes = 231..235
#$episodesLong = 213..235

$inFolder = 'C:\Users\User\downloads\Naruto Shippuuden TV2 `[NIKITOS`] HWP' # Каталог с исходниками
$outFolder = 'C:\temp'             # Каталог с обработанными файлами
$logFolder = 'C:\temp'             # Каталог с логами
$csv = "$logFolder\log.txt"        # Журнал/CSV 
$logError = "$logFolder\error.txt" # Журнал ошибок
$ext = '*.avi'                     # Маска и расширение исходных файлов 
$episodes = $episodes |% {$_.ToString("000")}              # Переделать номера в трёхзначные для поиска файлов
$openingStartPic = Get-Item "$inFolder\opening4_start.jpg" # Картинка начального кадра
$openingEndPic = Get-Item "$inFolder\opening4_end.jpg"     # Картинка конечного кадра
$openingMax = 90          # Длина заставки (сек.), глубина поиска конечного кадра
$vidDuration = 1206       # Стандартная длина эпизода (сек.)
$vidDurationLong = 1266   # Увеличенная длина эпизода (сек.)

# Функция поиска кадров
function Find-Frame ($file,$pic,$startPoint) {
    if ($startPoint) {
        # Конечный кадр
        (& ffmpeg.exe -hwaccel_output_format qsv -ss $startPoint -t $openingMax -an -i $file -loop 1 -i $pic -filter_complex "blend=difference:shortest=1,blackframe=80" -f null - 2>&1) -match 'blackframe.*type:I'
    }
    else {
        # Начальный кадр
        (& ffmpeg.exe -hwaccel_output_format qsv -t 10:00 -an -i $file -loop 1 -i $pic -filter_complex "blend=difference:shortest=1,blackframe=80" -f null - 2>&1) -match 'blackframe.*type:I'
    }
}

# Функция логирования/вывода
function log ($text,$file,$color) {
    $t = (get-date).tostring("yyyy.MM.dd HH:mm:ss")
    if ($file) {
        Tee-Object -InputObject "$t $text" -FilePath $file -Append
    }
    else {
        if (-not $color) {$color = "white"}
        Write-Host -fore $color "$t $text"
    }
}

cd $inFolder

dir $ext |? name -match "$($episodes -join '|')" |% {

# Поиск начала заставки
log -text "Поиск начала заставки в $($_.name)..." -color yellow 
$openingStart = Find-Frame -file $_.name -pic $openingStartPic.fullname
if ($openingStart) {
    $openingStartFrame = ($openingStart[0] -split ' ')[-3] -replace "t:"
    log -text "Начало заставки найдено на $openingStartFrame сек." -color green
}
else {
    log -text "Начало заставки не найдено в $($_.name)" -file $logError
    continue
}

# Поиск конца заставки
log -text "Поиск конца заставки в $($_.name)..." -color yellow
$openingEnd = Find-Frame -file $_.name -pic $openingEndPic.fullname -startPoint $openingStartFrame
if ($openingEnd) {
    $openingEndFrame = [single]$openingStartFrame + [single](($openingEnd[-1] -split ' ')[-3] -replace "t:")
    log -text "Конец заставки найден на $openingEndFrame сек." -color green
}
else {
    log -text "Конец заставки не найден в $($_.name)" -file $logError
    continue
}

# CSV
"$($_.name);$openingStartFrame;$openingEndFrame" |Out-File $csv -Encoding default -Append

# Выбор длины серии в целом
if ($_.basename -match "$($episodesLong -join '|')") {$tail = $vidDurationLong}
else {$tail = $vidDuration}

# Обработка/кодирование
& ffmpeg.exe -y -hide_banner -hwaccel_output_format qsv -i $_.name `
-filter_complex `
"[0:0]trim=start=0:end=$($openingStartFrame),setpts=PTS-STARTPTS[av];
 [0:1]atrim=start=0:end=$($openingStartFrame),asetpts=PTS-STARTPTS[aa];
 [0:0]trim=start=$($openingEndFrame):end=$($tail),setpts=PTS-STARTPTS[bv];
 [0:1]atrim=start=$($openingEndFrame):end=$($tail),asetpts=PTS-STARTPTS[ba];
 [av][bv]concat[outv];[aa][ba]concat=v=0:a=1[outa]" `
-map [outv] -map [outa] -c:v hevc_qsv -global_quality:v 28 `
-c:a libopus -ac 1 -b:a 64k `
"$outFolder\$($_.basename).mp4"

Clear-Variable openingStart,openingEnd,openingStartFrame,openingEndFrame
}

После перекодирования всех серий они стали занимать 9,5 ГБ вместо исходных 67.

С новым годом, желаю вам мира и спокойствия.