Compare commits

..

3 Commits

Author SHA1 Message Date
grayhook 28efe130f9 Добавить необязательные субтитры при склейке серий.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 12:44:06 +07:00
grayhook bf11c57ac8 Подставлять имя для Plex из выходного каталога, если оно ещё не задано.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 18:37:02 +07:00
grayhook 97f8b72251 Пропускать серии, которые уже лежат в каталоге вывода.
Повторный запуск после докачки сезона не должен заново склеивать старые файлы. VIEW помечает такие серии как пропущенные.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 18:09:50 +07:00
4 changed files with 199 additions and 22 deletions
+87 -10
View File
@@ -9,12 +9,39 @@ action_merge_mkv() {
local video_file="$1" local video_file="$1"
local audio_file="$2" local audio_file="$2"
local final_output_file="$3" local final_output_file="$3"
local subtitle_file="$4"
mkvmerge \ # Файл 0 — аудио, 1 — видео, 2 — субтитры (если заданы).
-o "$TEMP_MERGE_FILE" \ # Порядок дорожек: видео, новая аудиодорожка, затем субтитры.
--track-order 1:0,0:0 \ local -a merge_cmd=()
--language 0:rus "$audio_file" \
"$video_file" \ if [[ -n "$subtitle_file" ]]; then
merge_cmd=(
mkvmerge
-o "$TEMP_MERGE_FILE"
--track-order 1:0,0:0,2:0
--language 0:rus "$audio_file"
"$video_file"
--language 0:rus
)
local subtitle_lower="${subtitle_file,,}"
case "$subtitle_lower" in
*.srt|*.ass|*.ssa|*.sub|*.txt)
merge_cmd+=(--sub-charset 0:UTF-8)
;;
esac
merge_cmd+=("$subtitle_file")
else
merge_cmd=(
mkvmerge
-o "$TEMP_MERGE_FILE"
--track-order 1:0,0:0
--language 0:rus "$audio_file"
"$video_file"
)
fi
"${merge_cmd[@]}" \
&& \ && \
mv "$TEMP_MERGE_FILE" "$final_output_file" mv "$TEMP_MERGE_FILE" "$final_output_file"
} }
@@ -35,7 +62,31 @@ run_processing() {
return return
fi fi
local confirmation_text="Вы уверены, что хотите выполнить '${CURRENT_ACTION}' для ${#FILE_TRIPLETS[@]} файлов?\n\n" local skip_count=0
local pending_count=0
local triplet video audio output subtitle
# Снимок на момент старта. Файл, появившийся уже в этом запуске,
# нельзя считать «и так был» — иначе отчёт завысит число обработанных.
local -A ALREADY_PRESENT=()
for triplet in "${FILE_TRIPLETS[@]}"; do
IFS=$'\t' read -r video audio output subtitle <<< "$triplet"
if output_file_exists "$output"; then
ALREADY_PRESENT["$output"]=1
skip_count=$((skip_count + 1))
else
pending_count=$((pending_count + 1))
fi
done
if [ "$pending_count" -eq 0 ]; then
ui_show_message "Запуск" "Все файлы уже есть в каталоге вывода ($skip_count шт.). Обработка не требуется."
return
fi
local confirmation_text="Вы уверены, что хотите выполнить '${CURRENT_ACTION}' для ${pending_count} файлов?\n\n"
if [ "$skip_count" -gt 0 ]; then
confirmation_text+="Будет пропущено (уже есть): $skip_count\n\n"
fi
confirmation_text+="Результаты будут сохранены в:\n$OUTPUT_BASE_DIR" confirmation_text+="Результаты будут сохранены в:\n$OUTPUT_BASE_DIR"
if ! ui_confirm "Финальное подтверждение" "$confirmation_text"; then if ! ui_confirm "Финальное подтверждение" "$confirmation_text"; then
ui_show_message "Отмена" "Операция отменена пользователем." ui_show_message "Отмена" "Операция отменена пользователем."
@@ -52,20 +103,35 @@ run_processing() {
fi fi
# Шаг 2: Выполнение с прогресс-баром (без изменений) # Шаг 2: Выполнение с прогресс-баром (без изменений)
local conflict_file
conflict_file=$(mktemp)
{ {
local total_files=${#FILE_TRIPLETS[@]} local total_files=${#FILE_TRIPLETS[@]}
local current_file_num=0 local current_file_num=0
while IFS=$'\t' read -r video audio output; do while IFS=$'\t' read -r video audio output subtitle; do
((current_file_num++)) ((current_file_num++))
local progress_before=$(( ((current_file_num - 1) * 100) / total_files )) local progress_before=$(( ((current_file_num - 1) * 100) / total_files ))
echo "$progress_before" echo "$progress_before"
if [[ -n ${ALREADY_PRESENT["$output"]+x} ]]; then
echo -e "XXX\n($current_file_num/$total_files) Пропуск (уже есть):\n$(basename "$output")\nXXX"
continue
fi
if output_file_exists "$output"; then
printf '%s' "$output" > "$conflict_file"
echo -e "XXX\nОшибка: имя занято во время обработки:\n$(basename "$output")\nXXX"
sleep 1
exit 0
fi
echo -e "XXX\n($current_file_num/$total_files) Обработка:\n$(basename "$video")\nXXX" echo -e "XXX\n($current_file_num/$total_files) Обработка:\n$(basename "$video")\nXXX"
case "$CURRENT_ACTION" in case "$CURRENT_ACTION" in
"MERGE") "MERGE")
action_merge_mkv "$video" "$audio" "$output" >/dev/null 2>&1 ;; action_merge_mkv "$video" "$audio" "$output" "$subtitle" >/dev/null 2>&1 ;;
"SYMLINK") "SYMLINK")
action_create_symlink "$video" "$output" >/dev/null 2>&1 ;; action_create_symlink "$video" "$output" >/dev/null 2>&1 ;;
esac esac
@@ -78,10 +144,18 @@ run_processing() {
} | dialog "${DIALOG_OPTS[@]}" --title "Выполнение..." --gauge "Подготовка..." 10 70 0 } | dialog "${DIALOG_OPTS[@]}" --title "Выполнение..." --gauge "Подготовка..." 10 70 0
if [[ -s "$conflict_file" ]]; then
local conflict_path
conflict_path=$(cat "$conflict_file")
rm -f "$conflict_file"
ui_show_message "Ошибка" "Файл появился во время обработки и не входил в список уже существующих:\n$conflict_path\n\nОбработка остановлена."
return 1
fi
rm -f "$conflict_file"
# --- НОВЫЙ БЛОК: ФИНАЛЬНЫЙ ОТЧЕТ --- # --- НОВЫЙ БЛОК: ФИНАЛЬНЫЙ ОТЧЕТ ---
# Шаг 3: Показываем отчет о проделанной работе # Шаг 3: Показываем отчет о проделанной работе
local action_text local action_text
local total_processed=${#FILE_TRIPLETS[@]}
case "$CURRENT_ACTION" in case "$CURRENT_ACTION" in
"MERGE") "MERGE")
@@ -93,7 +167,10 @@ run_processing() {
esac esac
local report_message="Статус: $action_text\n\n" local report_message="Статус: $action_text\n\n"
report_message+="Обработано файлов: $total_processed\n" report_message+="Обработано файлов: $pending_count\n"
if [ "$skip_count" -gt 0 ]; then
report_message+="Пропущено (уже есть): $skip_count\n"
fi
report_message+="Результаты сохранены в:\n$OUTPUT_BASE_DIR" report_message+="Результаты сохранены в:\n$OUTPUT_BASE_DIR"
ui_show_message "Отчёт о выполнении" "$report_message" ui_show_message "Отчёт о выполнении" "$report_message"
+98 -11
View File
@@ -41,12 +41,26 @@ logic_prepare_file_lists() {
return 1 return 1
fi fi
local -a sub_list_raw=()
if [[ -n "$SUB_SRC_DIR" ]]; then
mapfile -d '' sub_list_raw < <(find "$SUB_SRC_DIR" -name "$SUB_FILE_PATTERN" -print0 | sort -z)
if [ ${#sub_list_raw[@]} -eq 0 ]; then
ui_show_message "Ошибка" "Субтитры по шаблону '$SUB_FILE_PATTERN' не найдены."
return 1
fi
if [ ${#video_list_raw[@]} -ne ${#sub_list_raw[@]} ]; then
ui_show_message "Ошибка" "Количество видео (${#video_list_raw[@]}), аудио (${#audio_list_raw[@]}) и субтитров (${#sub_list_raw[@]}) не совпадает."
return 1
fi
fi
for i in "${!video_list_raw[@]}"; do for i in "${!video_list_raw[@]}"; do
local video_file="${video_list_raw[$i]}" local video_file="${video_list_raw[$i]}"
local audio_file="${audio_list_raw[$i]}" local audio_file="${audio_list_raw[$i]}"
local subtitle_file="${sub_list_raw[$i]}"
local output_path local output_path
output_path=$(logic_generate_output_name "$video_file") || return 1 output_path=$(logic_generate_output_name "$video_file") || return 1
FILE_TRIPLETS+=("$video_file"$'\t'"$audio_file"$'\t'"$output_path") FILE_TRIPLETS+=("$video_file"$'\t'"$audio_file"$'\t'"$output_path"$'\t'"$subtitle_file")
done done
# РЕЖИМ: СИМЛИНКИ # РЕЖИМ: СИМЛИНКИ
@@ -71,6 +85,58 @@ logic_prepare_file_lists() {
done done
fi fi
logic_check_output_paths || return 1
return 0
}
# Уже готовый результат: обычный файл или симлинк (в том числе битый).
# Каталог сюда не входит: это не серия, а ошибка пути.
output_file_exists() {
local path="$1"
[[ -f "$path" || -L "$path" ]]
}
# Одинаковые имена и путь-каталог ломают пропуск: вторая серия увидела бы
# файл, созданный первой, и отчёт засчитал бы её как уже готовую.
logic_check_output_paths() {
local -A seen=()
local triplet video audio output
local -a dir_errors=()
local -a dup_errors=()
for triplet in "${FILE_TRIPLETS[@]}"; do
IFS=$'\t' read -r video audio output <<< "$triplet"
if [[ -d "$output" && ! -L "$output" ]]; then
dir_errors+=("$(basename "$video") → $output")
continue
fi
if [[ -n ${seen["$output"]+x} ]]; then
dup_errors+=("$(basename "${seen[$output]}") и $(basename "$video") → $(basename "$output")")
else
seen["$output"]="$video"
fi
done
if [ ${#dir_errors[@]} -gt 0 ]; then
local text="Путь вывода указывает на каталог, а не на файл:\n\n"
local line
for line in "${dir_errors[@]}"; do
text+="$line\n"
done
ui_show_message "Ошибка" "$text"
return 1
fi
if [ ${#dup_errors[@]} -gt 0 ]; then
local text="Несколько серий получают одно и то же имя:\n\n"
local line
for line in "${dup_errors[@]}"; do
text+="$line\n"
done
ui_show_message "Ошибка" "$text"
return 1
fi
return 0 return 0
} }
@@ -105,22 +171,43 @@ logic_show_preview() {
return return
fi fi
local preview_text="РЕЖИМ: $CURRENT_ACTION\nБудут обработаны следующие файлы (${#FILE_TRIPLETS[@]} шт.):\n\n" local body=""
local count=1 local count=1
local skip_count=0
for triplet in "${FILE_TRIPLETS[@]}"; do for triplet in "${FILE_TRIPLETS[@]}"; do
IFS=$'\t' read -r video audio output <<< "$triplet" IFS=$'\t' read -r video audio output subtitle <<< "$triplet"
preview_text+="$(printf "%02d" $count). \n" body+="$(printf "%02d" $count). \n"
local exists=false
if output_file_exists "$output"; then
exists=true
skip_count=$((skip_count + 1))
fi
if [ "$CURRENT_ACTION" == "MERGE" ]; then if [ "$CURRENT_ACTION" == "MERGE" ]; then
preview_text+=" \ZbВИДЕО:\Zn $(basename "$video")\n" body+=" \ZbВИДЕО:\Zn $(basename "$video")\n"
preview_text+=" \ZbАУДИО:\Zn $(basename "$audio")\n" body+=" \ZbАУДИО:\Zn $(basename "$audio")\n"
preview_text+=" \Zb-> ВЫВОД:\Zn $(basename "$output")\n\n" if [[ -n "$subtitle" ]]; then
else # SYMLINK body+=" \ZbСУБТИТРЫ:\Zn $(basename "$subtitle")\n"
preview_text+=" \ZbИСТОЧНИК:\Zn $(basename "$video")\n"
preview_text+=" \Zb-> ССЫЛКА:\Zn $(basename "$output")\n\n"
fi fi
((count++)) body+=" \Zb-> ВЫВОД:\Zn $(basename "$output")\n"
else # SYMLINK
body+=" \ZbИСТОЧНИК:\Zn $(basename "$video")\n"
body+=" \Zb-> ССЫЛКА:\Zn $(basename "$output")\n"
fi
if $exists; then
body+=" \Z3ПРОПУСК:\Zn операция пропущена — файл уже есть\n\n"
else
body+="\n"
fi
count=$((count + 1))
done done
local pending=$((${#FILE_TRIPLETS[@]} - skip_count))
local preview_text="РЕЖИМ: $CURRENT_ACTION\n"
preview_text+="К обработке: $pending, пропуск (уже есть): $skip_count\n\n"
preview_text+="$body"
ui_show_message "Предпросмотр" "$preview_text" ui_show_message "Предпросмотр" "$preview_text"
} }
+1 -1
View File
@@ -32,7 +32,7 @@ ui_select_initial_action() {
dialog "${DIALOG_OPTS[@]}" --title "Выбор действия" \ dialog "${DIALOG_OPTS[@]}" --title "Выбор действия" \
--cancel-label "Выход" \ --cancel-label "Выход" \
--menu "Выберите, что вы хотите сделать:" 15 70 2 \ --menu "Выберите, что вы хотите сделать:" 15 70 2 \
"MERGE" "Склеить видео и аудио (mkvmerge)" \ "MERGE" "Склеить видео и аудио, субтитры по желанию" \
"SYMLINK" "Создать симлинки с новыми именами" "SYMLINK" "Создать симлинки с новыми именами"
} }
+14 -1
View File
@@ -23,11 +23,13 @@ TEMP_MERGE_FILE="/tmp/tmp.mkv"
# ... (все переменные состояния остаются без изменений) # ... (все переменные состояния остаются без изменений)
VIDEO_SRC_DIR="" VIDEO_SRC_DIR=""
AUDIO_SRC_DIR="" AUDIO_SRC_DIR=""
SUB_SRC_DIR=""
OUTPUT_BASE_DIR="" OUTPUT_BASE_DIR=""
OUTPUT_SERIES_NAME="" OUTPUT_SERIES_NAME=""
SEASON_NUMBER="01" SEASON_NUMBER="01"
VIDEO_FILE_PATTERN="*.mkv" VIDEO_FILE_PATTERN="*.mkv"
AUDIO_FILE_PATTERN="*.mka" AUDIO_FILE_PATTERN="*.mka"
SUB_FILE_PATTERN="*.ass"
EPISODE_NUMBER_REGEX='.* ([0-9]{2}) .*' EPISODE_NUMBER_REGEX='.* ([0-9]{2}) .*'
OUTPUT_FILENAME_TEMPLATE="{SERIES_NAME} s{SEASON}e{EPISODE}.mkv" OUTPUT_FILENAME_TEMPLATE="{SERIES_NAME} s{SEASON}e{EPISODE}.mkv"
CURRENT_ACTION="" CURRENT_ACTION=""
@@ -76,6 +78,7 @@ main() {
local max_display_len=45 # Можете поменять это значение под свой экран local max_display_len=45 # Можете поменять это значение под свой экран
local display_video_path=$(truncate_path_for_display "$VIDEO_SRC_DIR" "$max_display_len" "_не задан_") local display_video_path=$(truncate_path_for_display "$VIDEO_SRC_DIR" "$max_display_len" "_не задан_")
local display_audio_path=$(truncate_path_for_display "$AUDIO_SRC_DIR" "$max_display_len" "_не задан_") local display_audio_path=$(truncate_path_for_display "$AUDIO_SRC_DIR" "$max_display_len" "_не задан_")
local display_sub_path=$(truncate_path_for_display "$SUB_SRC_DIR" "$max_display_len" "_не задан_")
local display_output_path=$(truncate_path_for_display "$OUTPUT_BASE_DIR" "$max_display_len" "_не задан_") local display_output_path=$(truncate_path_for_display "$OUTPUT_BASE_DIR" "$max_display_len" "_не задан_")
# Формируем пункты меню ДИНАМИЧЕСКИ # Формируем пункты меню ДИНАМИЧЕСКИ
@@ -84,6 +87,7 @@ main() {
if [ "$CURRENT_ACTION" == "MERGE" ]; then if [ "$CURRENT_ACTION" == "MERGE" ]; then
menu_items+=("A" "Аудио каталог : $display_audio_path") menu_items+=("A" "Аудио каталог : $display_audio_path")
menu_items+=("B" "Субтитры каталог : $display_sub_path")
fi fi
menu_items+=( menu_items+=(
@@ -96,6 +100,7 @@ main() {
if [ "$CURRENT_ACTION" == "MERGE" ]; then if [ "$CURRENT_ACTION" == "MERGE" ]; then
menu_items+=("U" "Шаблон аудиофайлов : $AUDIO_FILE_PATTERN") menu_items+=("U" "Шаблон аудиофайлов : $AUDIO_FILE_PATTERN")
menu_items+=("W" "Шаблон субтитров : $SUB_FILE_PATTERN")
fi fi
menu_items+=( menu_items+=(
@@ -118,11 +123,19 @@ main() {
case "$choice" in case "$choice" in
V) VIDEO_SRC_DIR=$(ui_get_directory "Выберите каталог с видеофайлами" "$VIDEO_SRC_DIR" "$DEFAULT_TORRENTS_DIR") ;; V) VIDEO_SRC_DIR=$(ui_get_directory "Выберите каталог с видеофайлами" "$VIDEO_SRC_DIR" "$DEFAULT_TORRENTS_DIR") ;;
A) AUDIO_SRC_DIR=$(ui_get_directory "Выберите каталог с аудиофайлами" "$AUDIO_SRC_DIR" "$VIDEO_SRC_DIR") ;; A) AUDIO_SRC_DIR=$(ui_get_directory "Выберите каталог с аудиофайлами" "$AUDIO_SRC_DIR" "$VIDEO_SRC_DIR") ;;
O) OUTPUT_BASE_DIR=$(ui_get_directory "Выберите БАЗОВЫЙ каталог для результата" "$OUTPUT_BASE_DIR" "$DEFAULT_ARCHIVE_DIR") ;; B) SUB_SRC_DIR=$(ui_get_directory "Выберите каталог с субтитрами" "$SUB_SRC_DIR" "$VIDEO_SRC_DIR") ;;
O)
OUTPUT_BASE_DIR=$(ui_get_directory "Выберите БАЗОВЫЙ каталог для результата" "$OUTPUT_BASE_DIR" "$DEFAULT_ARCHIVE_DIR")
OUTPUT_BASE_DIR="${OUTPUT_BASE_DIR%/}"
if [[ -z "$OUTPUT_SERIES_NAME" && -n "$OUTPUT_BASE_DIR" ]]; then
OUTPUT_SERIES_NAME=$(basename -- "$OUTPUT_BASE_DIR")
fi
;;
N) OUTPUT_SERIES_NAME=$(ui_get_input "Введите имя сериала для Plex" "$OUTPUT_SERIES_NAME") ;; N) OUTPUT_SERIES_NAME=$(ui_get_input "Введите имя сериала для Plex" "$OUTPUT_SERIES_NAME") ;;
S) SEASON_NUMBER=$(ui_get_input "Введите номер сезона (например, 01, 02)" "$SEASON_NUMBER") ;; S) SEASON_NUMBER=$(ui_get_input "Введите номер сезона (например, 01, 02)" "$SEASON_NUMBER") ;;
P) VIDEO_FILE_PATTERN=$(ui_get_input "Введите шаблон для поиска видео (*.mkv, *ep*.mkv)" "$VIDEO_FILE_PATTERN") ;; P) VIDEO_FILE_PATTERN=$(ui_get_input "Введите шаблон для поиска видео (*.mkv, *ep*.mkv)" "$VIDEO_FILE_PATTERN") ;;
U) AUDIO_FILE_PATTERN=$(ui_get_input "Введите шаблон для поиска аудио (*.mka, *.ac3)" "$AUDIO_FILE_PATTERN") ;; U) AUDIO_FILE_PATTERN=$(ui_get_input "Введите шаблон для поиска аудио (*.mka, *.ac3)" "$AUDIO_FILE_PATTERN") ;;
W) SUB_FILE_PATTERN=$(ui_get_input "Введите шаблон для поиска субтитров (*.ass, *.srt)" "$SUB_FILE_PATTERN") ;;
R) EPISODE_NUMBER_REGEX=$(ui_get_input "Введите ERE-regex для номера серии" "$EPISODE_NUMBER_REGEX") ;; R) EPISODE_NUMBER_REGEX=$(ui_get_input "Введите ERE-regex для номера серии" "$EPISODE_NUMBER_REGEX") ;;
T) OUTPUT_FILENAME_TEMPLATE=$(ui_get_input "Введите шаблон имени выходного файла" "$OUTPUT_FILENAME_TEMPLATE") ;; T) OUTPUT_FILENAME_TEMPLATE=$(ui_get_input "Введите шаблон имени выходного файла" "$OUTPUT_FILENAME_TEMPLATE") ;;
VIEW) logic_show_preview ;; VIEW) logic_show_preview ;;