2026-10-19 13:24:04 +02:00
|
|
|
|
#!/home/lucas/scripts/.venv/bin/python3
|
2026-04-14 23:56:03 +02:00
|
|
|
|
"""
|
|
|
|
|
|
FFmpeg Python CLI Tool
|
|
|
|
|
|
|
|
|
|
|
|
A wrapper for ffmpeg-python to perform common media tasks.
|
|
|
|
|
|
Parameters are documented with their equivalent FFmpeg command-line flags.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import json
|
2026-10-19 13:24:04 +02:00
|
|
|
|
import logging
|
2026-10-26 09:44:05 +01:00
|
|
|
|
import os
|
|
|
|
|
|
import re
|
2026-05-02 15:38:16 +02:00
|
|
|
|
import subprocess
|
2026-10-26 09:44:05 +01:00
|
|
|
|
import sys
|
2026-05-02 15:38:16 +02:00
|
|
|
|
import tempfile
|
2026-04-20 12:57:30 +02:00
|
|
|
|
from pathlib import Path
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
|
|
|
|
|
import ffmpeg
|
2026-05-02 15:38:16 +02:00
|
|
|
|
from mutagen.easyid3 import EasyID3
|
2026-10-25 18:32:51 +01:00
|
|
|
|
from serverlibrary import *
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
logging.setLoggerClass(CustomLogger)
|
|
|
|
|
|
logger: CustomLogger = logging.getLogger(__name__) # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
VIDEO_EXTS = {".mp4", ".mkv", ".avi", ".mov", ".webm"}
|
|
|
|
|
|
AUDIO_EXTS = {".mp3", ".m4a", ".m4b", ".opus"}
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
# OUTPUT ENFORCEMENT (clean + centralized)
|
|
|
|
|
|
# ----------------------------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
commands = {
|
2026-10-26 09:44:05 +01:00
|
|
|
|
"test": {"name": "test", "output": False},
|
|
|
|
|
|
"info": {"name": "info", "output": False},
|
|
|
|
|
|
"convert-av": {"name": "convert-av", "output": True},
|
|
|
|
|
|
"convert-audio": {"name": "convert-audio", "output": True},
|
|
|
|
|
|
"convert-video": {"name": "convert-video", "output": True},
|
|
|
|
|
|
"remove-subs": {"name": "remove-subs", "output": True},
|
|
|
|
|
|
"remove-audio": {"name": "remove-audio", "output": True},
|
|
|
|
|
|
"extract-audio": {"name": "extract-audio", "output": True},
|
|
|
|
|
|
"downsize": {"name": "downsize", "output": True},
|
|
|
|
|
|
"filter-audio": {"name": "filter-audio", "output": True},
|
|
|
|
|
|
"cut": {"name": "cut", "output": True},
|
|
|
|
|
|
"set-primary-audio": {"name": "set-primary-audio", "output": True},
|
|
|
|
|
|
"chapters": {"name": "chapters", "output": True},
|
|
|
|
|
|
"metadata-print": {"name": "metadata-print", "output": False},
|
|
|
|
|
|
"metadata-set": {"name": "metadata-set", "output": True},
|
|
|
|
|
|
"remove-russian-from-db": {"name": "remove-russian-from-db", "output": True},
|
2026-04-14 23:56:03 +02:00
|
|
|
|
}
|
2026-10-19 13:24:04 +02:00
|
|
|
|
# ------ Russian Dictonary --------------#
|
|
|
|
|
|
AUDIO_TRANSLATIONS = {
|
2026-10-26 09:44:05 +01:00
|
|
|
|
# Additional descriptors
|
|
|
|
|
|
"Закадровый": "Voice-over",
|
|
|
|
|
|
"Закадровое озвучивание": "Voice-over",
|
|
|
|
|
|
"Многоголосый закадровый": "Multi-voice voice-over",
|
|
|
|
|
|
"Многоголосное закадровое озвучивание": "Multi-voice voice-over",
|
|
|
|
|
|
# Variants found in metadata
|
|
|
|
|
|
"Оригинален език": "Original language",
|
|
|
|
|
|
"Многоголосное озвучивание": "Multi-voice dub",
|
|
|
|
|
|
"Многоголосый": "Multi-voice",
|
|
|
|
|
|
"Любительский": "Amateur voice-over",
|
|
|
|
|
|
"многоголосый": "Multi-voice",
|
|
|
|
|
|
"Профессиональный": "Professional",
|
|
|
|
|
|
"Одноголосый": "Single voice",
|
|
|
|
|
|
"Двухголосый": "Dual voice",
|
|
|
|
|
|
"Авторский": "Author voice-over",
|
|
|
|
|
|
"закадровый": "Voice-over",
|
|
|
|
|
|
"Оригинал": "Original",
|
|
|
|
|
|
"Дубляж": "Dubbed",
|
|
|
|
|
|
# Russian groups
|
|
|
|
|
|
"Кубик в кубе": "Kubik v Kube",
|
|
|
|
|
|
"LostFilm": "LostFilm",
|
|
|
|
|
|
"AlexFilm": "AlexFilm",
|
|
|
|
|
|
"Baibako": "Baibako",
|
|
|
|
|
|
"NewStudio": "NewStudio",
|
|
|
|
|
|
"Jaskier": "Jaskier",
|
|
|
|
|
|
"Kerob": "Kerob",
|
|
|
|
|
|
"TVShows": "TVShows",
|
|
|
|
|
|
"HDRezka": "HDRezka",
|
|
|
|
|
|
# Groups / studios
|
|
|
|
|
|
"Синема УС": "Cinema US",
|
|
|
|
|
|
"KinoPub": "KinoPub",
|
|
|
|
|
|
"Озвучка Rumble": "Rumble",
|
|
|
|
|
|
"Rumble": "Rumble",
|
|
|
|
|
|
# Voice actor
|
|
|
|
|
|
"Юрий Сербин": "Yuriy Serbin",
|
|
|
|
|
|
"Ю. Сербин": "Y. Serbin",
|
|
|
|
|
|
# Languages
|
|
|
|
|
|
"Английски": "English",
|
|
|
|
|
|
"Русский": "Russian",
|
|
|
|
|
|
"Русская": "Russian",
|
|
|
|
|
|
"Русское": "Russian",
|
|
|
|
|
|
# ISO codes
|
|
|
|
|
|
"rus": "Russian",
|
|
|
|
|
|
"ru": "Russian",
|
|
|
|
|
|
"ger": "German",
|
|
|
|
|
|
"deu": "German",
|
|
|
|
|
|
"eng": "English",
|
|
|
|
|
|
"und": "Unknown",
|
2026-10-19 13:24:04 +02:00
|
|
|
|
}
|
|
|
|
|
|
RUSSIAN_RE = re.compile(
|
2026-10-26 09:44:05 +01:00
|
|
|
|
r"\brus\b|"
|
|
|
|
|
|
r"\bru\b|"
|
|
|
|
|
|
r"russian|"
|
|
|
|
|
|
r"рус|"
|
|
|
|
|
|
r"русский|"
|
|
|
|
|
|
r"русская|"
|
|
|
|
|
|
r"русское|"
|
|
|
|
|
|
r"дубляж|"
|
|
|
|
|
|
r"оригинал|"
|
|
|
|
|
|
r"многоголос|"
|
|
|
|
|
|
r"двухголос|"
|
|
|
|
|
|
r"одноголос|"
|
|
|
|
|
|
r"авторский|"
|
|
|
|
|
|
r"любительский|"
|
|
|
|
|
|
r"профессиональн|"
|
|
|
|
|
|
r"закадров|"
|
|
|
|
|
|
r"юрий\s+сербин|"
|
|
|
|
|
|
r"ю\.\s*сербин|"
|
|
|
|
|
|
r"сербин|"
|
|
|
|
|
|
r"синема\s+ус|"
|
|
|
|
|
|
r"kinopub|"
|
|
|
|
|
|
r"rumble|"
|
|
|
|
|
|
r"озвучка\s+rumble|"
|
|
|
|
|
|
r"кубик\s+в\s+кубе|"
|
|
|
|
|
|
r"lostfilm|"
|
|
|
|
|
|
r"alexfilm|"
|
|
|
|
|
|
r"baibako|"
|
|
|
|
|
|
r"newstudio|"
|
|
|
|
|
|
r"jaskier|"
|
|
|
|
|
|
r"kerob|"
|
|
|
|
|
|
r"tvshows|"
|
|
|
|
|
|
r"hdrezka",
|
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
def is_russian_track(track: dict) -> bool:
|
|
|
|
|
|
descr = bool(RUSSIAN_RE.search(track["description"]))
|
|
|
|
|
|
language = bool(RUSSIAN_RE.search(track["language"]))
|
|
|
|
|
|
lang = bool(RUSSIAN_RE.search(track["lang"]))
|
|
|
|
|
|
return descr or lang or language
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
class ArgsBuffer:
|
|
|
|
|
|
input: Path | None = None
|
|
|
|
|
|
output: Path | None = None
|
2026-10-25 18:32:51 +01:00
|
|
|
|
kidflag: bool | None = False
|
|
|
|
|
|
loud: bool | None = False
|
2026-10-19 13:24:04 +02:00
|
|
|
|
key: str | None = None
|
|
|
|
|
|
value: str | None = None
|
2026-10-26 13:00:58 +01:00
|
|
|
|
audio_index: list | None = None
|
2026-10-25 14:31:20 +01:00
|
|
|
|
dry_run: bool | None = None
|
2026-10-25 18:32:51 +01:00
|
|
|
|
audio_codec: str | None = None
|
|
|
|
|
|
video_codec: str | None = None
|
|
|
|
|
|
preset: str | None = None
|
|
|
|
|
|
quality: int | None = None
|
2026-10-26 09:44:05 +01:00
|
|
|
|
width: int | None = None
|
|
|
|
|
|
height: int | None = None
|
2026-10-25 18:32:51 +01:00
|
|
|
|
crf: int | None = None
|
|
|
|
|
|
num: int | None = None
|
|
|
|
|
|
start: str | None = None
|
|
|
|
|
|
duration: str | None = None
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
CYRILLIC_RE = re.compile(r"[А-Яа-яЁё]")
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def translate_audio_tracks(tracks: list[dict]) -> list[dict]:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
translated = []
|
|
|
|
|
|
|
|
|
|
|
|
for track in tracks:
|
2026-10-26 13:00:58 +01:00
|
|
|
|
new_track = track.copy()
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
for key, value in new_track.items():
|
|
|
|
|
|
if not isinstance(value, str):
|
|
|
|
|
|
continue
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
for src, dst in AUDIO_TRANSLATIONS.items():
|
|
|
|
|
|
value = re.sub(
|
|
|
|
|
|
rf"\b{re.escape(src)}\b",
|
|
|
|
|
|
dst,
|
|
|
|
|
|
value,
|
|
|
|
|
|
flags=re.IGNORECASE,
|
|
|
|
|
|
)
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if CYRILLIC_RE.search(value):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Contains unidentified Cyrillic characters in {key}: {value}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
new_track[key] = value
|
|
|
|
|
|
|
|
|
|
|
|
translated.append(new_track)
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
|
|
|
|
|
return translated
|
|
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
# ---------- Utilities ---------- #
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# ----------------------------
|
|
|
|
|
|
# HELPERS
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
def ask(prompt, default=None):
|
|
|
|
|
|
value = input(f"{prompt}" + (f" [{default}]" if default else "") + ": ").strip()
|
|
|
|
|
|
return value if value else default
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-20 12:57:30 +02:00
|
|
|
|
def ensure(value, prompt):
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return ask(prompt)
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
def move_overwrite_file(files_move_from, files_move_to, args):
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
backup_files = args.backup
|
|
|
|
|
|
silent = not args.loud
|
|
|
|
|
|
for file_to, file_from in zip(files_move_to, files_move_from):
|
|
|
|
|
|
if not file_to.is_file():
|
2026-10-26 13:00:58 +01:00
|
|
|
|
logger.error(f"overwrite Target File does not exist: {file_to}")
|
2026-10-26 09:44:05 +01:00
|
|
|
|
return False
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
if not file_from.is_file():
|
2026-10-26 13:00:58 +01:00
|
|
|
|
logger.trace(f"overwrite File does not exist: {file_from}")
|
2026-07-16 10:54:33 +02:00
|
|
|
|
continue
|
2026-10-26 09:44:05 +01:00
|
|
|
|
backup = file_to.parent / f"{file_to.stem}_backup{file_from.suffix}"
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
|
|
|
|
|
if backup_files:
|
|
|
|
|
|
# move original -> backup
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.trace(f"Move original to backup: {backup}")
|
|
|
|
|
|
safe_rename(file_to, backup, silent=silent)
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
# move overwrite -> original name
|
2026-10-26 09:44:05 +01:00
|
|
|
|
retstr, ret = safe_rename(file_from, file_to, silent=silent, overwrite=True)
|
|
|
|
|
|
logger.trace(retstr)
|
|
|
|
|
|
if not ret:
|
|
|
|
|
|
logger.error(f"Safe rename failed for: {file_from} -> {file_to}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
LOG_FILE = Path.home() / "ffmpeg_log.log"
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
|
|
|
|
|
def run_ffmpeg(stream, overwrite=True, loud=False, dry_run=False):
|
2026-07-16 10:54:33 +02:00
|
|
|
|
kwargs = {
|
|
|
|
|
|
"overwrite_output": overwrite,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
kwargs.update(
|
|
|
|
|
|
capture_stdout=True,
|
|
|
|
|
|
capture_stderr=True,
|
|
|
|
|
|
quiet=True,
|
|
|
|
|
|
)
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
if dry_run:
|
|
|
|
|
|
logger.debug(f"Dry run: {stream}")
|
|
|
|
|
|
return
|
2026-07-16 10:54:33 +02:00
|
|
|
|
try:
|
|
|
|
|
|
out, err = ffmpeg.run(stream, **kwargs)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.trace(out)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
logger.error(err)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
except ffmpeg.Error as e:
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.error(e.stderr)
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error("FFmpeg failed.\n")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def probe_audio_streams(file):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Probes metadata to identify audio tracks.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg Equivalent:
|
|
|
|
|
|
ffprobe -v error -select_streams a -show_entries stream=index:tags=language -of json
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
probe = ffmpeg.probe(file)
|
|
|
|
|
|
except ffmpeg.Error as e:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(f"ffprobe failed:{e}")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
(s.get("index"), s.get("tags", {}).get("language", "unknown"))
|
|
|
|
|
|
for s in probe.get("streams", [])
|
|
|
|
|
|
if s.get("codec_type") == "audio"
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def select_audio_stream(file, provided=None):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Logical handler for stream selection. Ensures we target the correct
|
|
|
|
|
|
absolute stream index when building filter graphs.
|
|
|
|
|
|
"""
|
|
|
|
|
|
streams = probe_audio_streams(file)
|
|
|
|
|
|
|
|
|
|
|
|
if not streams:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.info("No audio streams found.")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
2026-04-20 12:57:30 +02:00
|
|
|
|
provided = int(provided) if provided else None
|
2026-04-14 23:56:03 +02:00
|
|
|
|
if provided is not None:
|
|
|
|
|
|
if any(s[0] == provided for s in streams):
|
|
|
|
|
|
return provided
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.info(f"Provided index {provided} is not a valid audio stream.")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
if len(streams) == 1:
|
|
|
|
|
|
return streams[0][0]
|
|
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.info(f"Found {len(streams)} audio streams:")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
for i, (idx, lang) in enumerate(streams):
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.info(f"[{i}] Index: {idx}, Language: {lang}")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
choice = int(input("Select stream number (the list index [x]): "))
|
|
|
|
|
|
return streams[choice][0]
|
|
|
|
|
|
except (ValueError, IndexError):
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error("Invalid selection.")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def parse_time_to_seconds(value: str) -> float:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Convert human-readable time formats into seconds.
|
|
|
|
|
|
|
|
|
|
|
|
Supported formats:
|
|
|
|
|
|
- "100s" → 100
|
|
|
|
|
|
- "1m20s" → 80
|
|
|
|
|
|
- "2h10m5s" → 7805
|
|
|
|
|
|
- "0h1m5s" → 65
|
|
|
|
|
|
- "0:1:5" → 65 (hh:mm:ss or mm:ss)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
value = str(value).strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Case 1: pure seconds (e.g. 100s)
|
|
|
|
|
|
if value.endswith("s") and value[:-1].isdigit():
|
|
|
|
|
|
return float(value[:-1])
|
|
|
|
|
|
|
|
|
|
|
|
# Case 2: h/m/s format (e.g. 1h2m3s)
|
2026-10-26 09:44:05 +01:00
|
|
|
|
pattern = re.compile(r"(?:(?P<h>\d+)h)?(?:(?P<m>\d+)m)?(?:(?P<s>\d+)s)?")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
match = pattern.fullmatch(value)
|
|
|
|
|
|
if match and any(match.groups()):
|
|
|
|
|
|
h = int(match.group("h") or 0)
|
|
|
|
|
|
m = int(match.group("m") or 0)
|
|
|
|
|
|
s = int(match.group("s") or 0)
|
|
|
|
|
|
return h * 3600 + m * 60 + s
|
|
|
|
|
|
|
|
|
|
|
|
# Case 3: colon format (hh:mm:ss or mm:ss)
|
|
|
|
|
|
if ":" in value:
|
|
|
|
|
|
parts = list(map(int, value.split(":")))
|
|
|
|
|
|
if len(parts) == 2:
|
|
|
|
|
|
m, s = parts
|
|
|
|
|
|
return m * 60 + s
|
|
|
|
|
|
elif len(parts) == 3:
|
|
|
|
|
|
h, m, s = parts
|
|
|
|
|
|
return h * 3600 + m * 60 + s
|
|
|
|
|
|
|
|
|
|
|
|
# fallback: raw number
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(value)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise argparse.ArgumentTypeError(f"Invalid time format: {value}")
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-10-25 18:32:51 +01:00
|
|
|
|
def get_scodec(output_path: Path):
|
|
|
|
|
|
ext = output_path.suffix
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
if ext == "mp4":
|
|
|
|
|
|
return "mov_text"
|
|
|
|
|
|
|
|
|
|
|
|
if ext == "mkv":
|
|
|
|
|
|
return "srt"
|
|
|
|
|
|
|
|
|
|
|
|
return "copy"
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-10-25 18:32:51 +01:00
|
|
|
|
def get_vcodec(output_path: Path):
|
|
|
|
|
|
ext = output_path.suffix
|
2026-04-14 23:56:03 +02:00
|
|
|
|
if ext == "mp4":
|
|
|
|
|
|
return "libx264"
|
|
|
|
|
|
|
|
|
|
|
|
return "copy"
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-05-02 15:38:16 +02:00
|
|
|
|
def get_chapter_lengths(file_path):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Returns an array of chapter durations (in seconds).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
cmd = [
|
|
|
|
|
|
"ffprobe",
|
2026-10-26 09:44:05 +01:00
|
|
|
|
"-v",
|
|
|
|
|
|
"error",
|
|
|
|
|
|
"-print_format",
|
|
|
|
|
|
"json",
|
2026-05-02 15:38:16 +02:00
|
|
|
|
"-show_chapters",
|
2026-10-26 09:44:05 +01:00
|
|
|
|
file_path,
|
2026-05-02 15:38:16 +02:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
cmd,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
check=True,
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.trace(result.stdout)
|
2026-05-02 15:38:16 +02:00
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
|
raise RuntimeError(result.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
data = json.loads(result.stdout)
|
|
|
|
|
|
|
|
|
|
|
|
chapters = []
|
|
|
|
|
|
for ch in data.get("chapters", []):
|
2026-10-26 09:44:05 +01:00
|
|
|
|
chapters.append(
|
|
|
|
|
|
{"start": float(ch["start_time"]), "end": float(ch["end_time"])}
|
|
|
|
|
|
)
|
2026-05-02 15:38:16 +02:00
|
|
|
|
|
|
|
|
|
|
return chapters
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-05-02 15:38:16 +02:00
|
|
|
|
def natural_sort_key(path: Path):
|
2026-10-26 09:44:05 +01:00
|
|
|
|
s = str(path.name) # force string conversion
|
2026-05-02 15:38:16 +02:00
|
|
|
|
return [
|
2026-10-26 09:44:05 +01:00
|
|
|
|
int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", s)
|
2026-05-02 15:38:16 +02:00
|
|
|
|
]
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
# ---------- Commands ---------- #
|
|
|
|
|
|
def cmd_info(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Retrieves and prints metadata.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg Equivalent:
|
|
|
|
|
|
ffprobe -show_format -show_streams -of json [input]
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-10-25 19:04:22 +01:00
|
|
|
|
info = ffmpeg.probe(str(args.input))
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.info(json.dumps(info, indent=2))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
except ffmpeg.Error as e:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error("Error probing file:", e)
|
|
|
|
|
|
|
2026-10-27 08:23:09 +01:00
|
|
|
|
return False, "nooutputneeded"
|
|
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
def cmd_convert_av(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Re-encodes both video and audio.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg Equivalent:
|
|
|
|
|
|
ffmpeg -i [input] -c:v [video_codec] -c:a [audio_codec] -preset [preset] -crf [quality] [output]
|
|
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
|
- vcodec/c:v: Sets the video encoder (e.g., libx264).
|
|
|
|
|
|
- acodec/c:a: Sets the audio encoder (e.g., aac).
|
|
|
|
|
|
- preset: Trade-off between compression speed and efficiency (ultrafast to veryslow).
|
|
|
|
|
|
- qp/crf: Constant Rate Factor. Lower is higher quality (18-28 is standard).
|
|
|
|
|
|
"""
|
|
|
|
|
|
inp = ffmpeg.input(args.input)
|
|
|
|
|
|
out = ffmpeg.output(
|
|
|
|
|
|
inp,
|
2026-10-25 18:32:51 +01:00
|
|
|
|
str(args.output),
|
2026-04-14 23:56:03 +02:00
|
|
|
|
vcodec=args.video_codec,
|
|
|
|
|
|
acodec=args.audio_codec,
|
|
|
|
|
|
scodec=get_scodec(args.output),
|
|
|
|
|
|
preset=args.preset,
|
|
|
|
|
|
qp=args.quality,
|
|
|
|
|
|
# This maps all streams from the first input once
|
2026-10-26 09:44:05 +01:00
|
|
|
|
map="0",
|
2026-04-14 23:56:03 +02:00
|
|
|
|
# Prevents FFmpeg from trying to guess/add extra default streams
|
2026-10-26 09:44:05 +01:00
|
|
|
|
map_metadata=0,
|
2026-04-14 23:56:03 +02:00
|
|
|
|
)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, out
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def cmd_convert_audio(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Transcodes audio while keeping video intact.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg Equivalent:
|
|
|
|
|
|
ffmpeg -i [input] -map 0:v -map 0:a -c:v copy -c:a [audio_codec] [output]
|
|
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
|
- vcodec='copy': Enables 'stream copy' mode, bypassing the video decoder/encoder.
|
|
|
|
|
|
"""
|
|
|
|
|
|
inp = ffmpeg.input(args.input)
|
|
|
|
|
|
out = ffmpeg.output(
|
|
|
|
|
|
inp,
|
2026-10-25 18:32:51 +01:00
|
|
|
|
str(args.output),
|
2026-04-14 23:56:03 +02:00
|
|
|
|
vcodec="copy",
|
|
|
|
|
|
acodec=args.audio_codec,
|
|
|
|
|
|
scodec=get_scodec(args.output),
|
2026-10-26 09:44:05 +01:00
|
|
|
|
map="0",
|
|
|
|
|
|
map_metadata=0,
|
2026-04-14 23:56:03 +02:00
|
|
|
|
)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, out
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def cmd_convert_video(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Transcodes video while keeping audio intact.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg Equivalent:
|
|
|
|
|
|
ffmpeg -i [input] -map 0:v -map 0:a -c:v [video_codec] -c:a copy [output]
|
|
|
|
|
|
"""
|
|
|
|
|
|
inp = ffmpeg.input(args.input)
|
|
|
|
|
|
out = ffmpeg.output(
|
|
|
|
|
|
inp,
|
2026-10-25 18:32:51 +01:00
|
|
|
|
str(args.output),
|
2026-04-14 23:56:03 +02:00
|
|
|
|
vcodec=args.video_codec,
|
|
|
|
|
|
acodec="copy",
|
|
|
|
|
|
scodec=get_scodec(args.output),
|
2026-10-26 09:44:05 +01:00
|
|
|
|
map="0",
|
|
|
|
|
|
map_metadata=0,
|
2026-04-14 23:56:03 +02:00
|
|
|
|
)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, out
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def cmd_remove_subs(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Strips all subtitle data.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg Equivalent:
|
|
|
|
|
|
ffmpeg -i [input] -sn -c copy [output]
|
|
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
|
- sn: Disables subtitle recording.
|
|
|
|
|
|
"""
|
2026-10-25 19:04:22 +01:00
|
|
|
|
probe = ffmpeg.probe(str(args.input))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = [
|
|
|
|
|
|
"ffmpeg",
|
|
|
|
|
|
"-y",
|
2026-10-26 09:44:05 +01:00
|
|
|
|
"-i",
|
|
|
|
|
|
str(args.input),
|
2026-10-19 13:24:04 +02:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
if args.num is None:
|
|
|
|
|
|
# remove all subtitle streams
|
|
|
|
|
|
for stream in probe["streams"]:
|
|
|
|
|
|
if stream["codec_type"] != "subtitle":
|
|
|
|
|
|
cmd.extend(["-map", f"0:{stream['index']}"])
|
|
|
|
|
|
else:
|
|
|
|
|
|
# remove only specified subtitle streams
|
|
|
|
|
|
for stream in probe["streams"]:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
if stream["codec_type"] == "subtitle" and stream["index"] in args.num:
|
2026-10-19 13:24:04 +02:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
cmd.extend(["-map", f"0:{stream['index']}"])
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
cmd.extend(
|
|
|
|
|
|
[
|
|
|
|
|
|
"-c",
|
|
|
|
|
|
"copy",
|
|
|
|
|
|
"-map_metadata",
|
|
|
|
|
|
"0",
|
|
|
|
|
|
str(args.output),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
logger.debug("%s", " ".join(cmd))
|
2026-10-25 19:04:22 +01:00
|
|
|
|
if args.dry_run:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.debug(f"Dry run: {' '.join(cmd)}")
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, "nooutputneeded"
|
2026-10-19 13:24:04 +02:00
|
|
|
|
try:
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
cmd,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
check=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
|
|
|
logger.error("Command: %s", " ".join(e.cmd))
|
|
|
|
|
|
logger.error("Return code: %s", e.returncode)
|
|
|
|
|
|
logger.error("STDOUT:\n%s", e.stdout)
|
|
|
|
|
|
logger.error("STDERR:\n%s", e.stderr)
|
|
|
|
|
|
raise
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, "nooutputneeded"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def cmd_remove_audio_track(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
1. Removes a specific audio track from the video (Main Output).
|
|
|
|
|
|
"""
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
probe = ffmpeg.probe(str(args.input))
|
2026-10-26 13:00:58 +01:00
|
|
|
|
remove_indices = []
|
|
|
|
|
|
for audio_index in args.audio_index:
|
|
|
|
|
|
remove_indices.append(select_audio_stream(args.input, audio_index))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
remaining_audio = [
|
2026-10-26 09:44:05 +01:00
|
|
|
|
s
|
2026-10-19 13:24:04 +02:00
|
|
|
|
for s in probe["streams"]
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if s["codec_type"] == "audio" and s["index"] not in remove_indices
|
2026-10-19 13:24:04 +02:00
|
|
|
|
]
|
2026-10-26 09:44:05 +01:00
|
|
|
|
if not remaining_audio:
|
|
|
|
|
|
logger.error("No audiotracks remaining")
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return False, "nooutputneeded"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
remaining_audio = translate_audio_tracks(remaining_audio)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
removed_was_default = any(
|
2026-10-26 13:00:58 +01:00
|
|
|
|
s["index"] in remove_indices and s.get("disposition", {}).get("default", 0) == 1
|
2026-10-19 13:24:04 +02:00
|
|
|
|
for s in probe["streams"]
|
2026-04-14 23:56:03 +02:00
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
cmd = [
|
|
|
|
|
|
"ffmpeg",
|
|
|
|
|
|
"-y",
|
2026-10-26 09:44:05 +01:00
|
|
|
|
"-i",
|
|
|
|
|
|
str(args.input),
|
2026-10-19 13:24:04 +02:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
for stream in probe["streams"]:
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if stream["index"] not in remove_indices:
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd.extend(["-map", f"0:{stream['index']}"])
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
cmd.extend(
|
|
|
|
|
|
[
|
|
|
|
|
|
"-c:v",
|
|
|
|
|
|
"copy",
|
|
|
|
|
|
"-c:a",
|
|
|
|
|
|
"copy",
|
|
|
|
|
|
"-c:s",
|
|
|
|
|
|
get_scodec(args.output),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
if removed_was_default and remaining_audio:
|
|
|
|
|
|
# First remaining audio stream in the output becomes default
|
2026-10-26 09:44:05 +01:00
|
|
|
|
cmd.extend(
|
|
|
|
|
|
[
|
|
|
|
|
|
"-disposition:a",
|
|
|
|
|
|
"0",
|
|
|
|
|
|
"-disposition:a:0",
|
|
|
|
|
|
"default",
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
cmd.append(str(args.output))
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.trace(" ".join(cmd))
|
2026-10-25 14:31:20 +01:00
|
|
|
|
if args.dry_run:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.debug(f"Dry run: {' '.join(cmd)}")
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, "nooutputneeded"
|
2026-10-19 13:24:04 +02:00
|
|
|
|
try:
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
cmd,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
check=True,
|
|
|
|
|
|
)
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.debug("FFmpeg subprocess successful")
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.trace(result.stdout)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, "nooutputneeded"
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
except subprocess.CalledProcessError as e:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
2026-10-19 13:24:04 +02:00
|
|
|
|
"Command failed (%s):\n%s\n\nstderr:\n%s",
|
|
|
|
|
|
e.returncode,
|
|
|
|
|
|
" ".join(map(str, e.cmd)),
|
|
|
|
|
|
e.stderr,
|
|
|
|
|
|
)
|
|
|
|
|
|
raise RuntimeError(f"Command failed: {' '.join(e.cmd)}") from e
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-23 13:07:32 +02:00
|
|
|
|
def cmd_extract_audio_track(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
1. Saves that specific audio track as a standalone MP3 (Extra Output).
|
|
|
|
|
|
"""
|
|
|
|
|
|
inp = ffmpeg.input(args.input)
|
2026-10-25 19:04:22 +01:00
|
|
|
|
probe = ffmpeg.probe(str(args.input))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-04-23 13:07:32 +02:00
|
|
|
|
output_audio = Path(args.output)
|
|
|
|
|
|
output_audio = str(output_audio.with_name(f"{output_audio.stem}.mp3"))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
outputs = []
|
|
|
|
|
|
for index in args.audio_index:
|
|
|
|
|
|
remove_idx = select_audio_stream(args.input, index)
|
2026-04-23 13:07:32 +02:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
extracted_audio = None
|
2026-04-23 13:07:32 +02:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
for s in probe["streams"]:
|
|
|
|
|
|
idx = s["index"]
|
|
|
|
|
|
if idx == remove_idx:
|
|
|
|
|
|
extracted_audio = inp[str(idx)]
|
2026-04-23 13:07:32 +02:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
outputs.append(
|
|
|
|
|
|
ffmpeg.output(
|
|
|
|
|
|
extracted_audio, output_audio, acodec="libmp3lame", audio_bitrate="192k"
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return True, outputs
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def cmd_downsize(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Scales resolution and compresses.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg Equivalent:
|
|
|
|
|
|
ffmpeg -i [input] -vf scale=[w]:[h] -c:v libx264 -crf [crf] -c:a copy [output]
|
|
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
|
- filter('scale'): Invokes the video filter (vf) for resizing.
|
|
|
|
|
|
- crf: Set to 23 (default) for balanced quality.
|
|
|
|
|
|
"""
|
|
|
|
|
|
inp = ffmpeg.input(args.input)
|
|
|
|
|
|
video = inp.video.filter("scale", args.width, args.height)
|
2026-10-25 19:04:22 +01:00
|
|
|
|
info = ffmpeg.probe(str(args.input))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
streams = info.get("streams", [])
|
|
|
|
|
|
subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
|
|
|
|
|
|
|
|
|
|
|
|
subs = {}
|
|
|
|
|
|
if len(subtitle_streams) > 0:
|
|
|
|
|
|
subs = {inp["s"]}
|
|
|
|
|
|
|
|
|
|
|
|
out = ffmpeg.output(
|
|
|
|
|
|
video,
|
|
|
|
|
|
inp.audio,
|
|
|
|
|
|
*subs,
|
2026-10-25 18:32:51 +01:00
|
|
|
|
str(args.output),
|
2026-04-14 23:56:03 +02:00
|
|
|
|
vcodec="libx264",
|
|
|
|
|
|
crf=args.crf,
|
|
|
|
|
|
acodec="copy",
|
2026-10-26 09:44:05 +01:00
|
|
|
|
scodec=get_scodec(args.output),
|
2026-04-14 23:56:03 +02:00
|
|
|
|
)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, out
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def cmd_filter_audio(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Multi-stage audio mastering chain.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg Equivalent:
|
2026-04-23 13:07:32 +02:00
|
|
|
|
ffmpeg -i [input] -filter_complex "[0:a:X]highpass...,acompressor...,equalizer...out" \
|
|
|
|
|
|
-map 0:v -map out -c:v copy -c:a libvorbis [output]
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
Filter Descriptions:
|
|
|
|
|
|
1. highpass: Cuts frequencies below 100Hz (removes low-end rumble).
|
|
|
|
|
|
2. acompressor: Reduces volume of loud peaks to increase overall clarity.
|
|
|
|
|
|
3. equalizer: Adjusts specific frequency bands (2.5kHz and 4kHz for presence).
|
|
|
|
|
|
4. deesser: Dampens harsh 'S' sounds.
|
|
|
|
|
|
5. alimiter: Hard limit at 0.95 to prevent digital clipping (distortion).
|
|
|
|
|
|
6. dynaudnorm: Smooths out loudness levels over time.
|
|
|
|
|
|
"""
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if len(args.audio_index) > 1:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"Filter Audio only filters the first element of list audio_index: {args.audio_index}"
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
index = args.audio_index[0]
|
|
|
|
|
|
|
|
|
|
|
|
idx_filt = select_audio_stream(args.input, index)
|
2026-04-26 10:20:29 +02:00
|
|
|
|
|
|
|
|
|
|
inp = ffmpeg.input(args.input)
|
2026-10-25 19:04:22 +01:00
|
|
|
|
probe = ffmpeg.probe(str(args.input))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-04-26 10:20:29 +02:00
|
|
|
|
audio_streams = []
|
|
|
|
|
|
audio_titles = {}
|
|
|
|
|
|
audio_languages = {}
|
|
|
|
|
|
|
|
|
|
|
|
for s in probe["streams"]:
|
|
|
|
|
|
if s.get("codec_type") == "audio":
|
|
|
|
|
|
idx = s["index"]
|
|
|
|
|
|
audio_streams.append(idx)
|
|
|
|
|
|
|
|
|
|
|
|
tags = s.get("tags", {})
|
|
|
|
|
|
audio_titles[idx] = tags.get("title", f"Audio {idx}")
|
|
|
|
|
|
audio_languages[idx] = tags.get("language", "und")
|
2026-04-23 13:07:32 +02:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
filtered_audios = []
|
2026-04-26 10:20:29 +02:00
|
|
|
|
metadata_args = []
|
|
|
|
|
|
|
|
|
|
|
|
out_audio_idx = 0
|
|
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
for idx in audio_streams:
|
|
|
|
|
|
audio = inp[str(idx)]
|
2026-04-26 10:20:29 +02:00
|
|
|
|
|
|
|
|
|
|
# -------------------
|
|
|
|
|
|
# ORIGINAL STREAM
|
|
|
|
|
|
# -------------------
|
|
|
|
|
|
filtered_audios.append(audio)
|
|
|
|
|
|
|
|
|
|
|
|
metadata_args += [
|
|
|
|
|
|
f"-metadata:s:a:{out_audio_idx}",
|
|
|
|
|
|
f"title={audio_titles[idx]}",
|
|
|
|
|
|
f"-metadata:s:a:{out_audio_idx}",
|
|
|
|
|
|
f"language={audio_languages[idx]}",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
out_audio_idx += 1
|
|
|
|
|
|
|
|
|
|
|
|
# -------------------
|
|
|
|
|
|
# FILTERED STREAM
|
|
|
|
|
|
# -------------------
|
2026-04-14 23:56:03 +02:00
|
|
|
|
if idx == idx_filt:
|
|
|
|
|
|
audio_filtered = (
|
2026-10-26 09:44:05 +01:00
|
|
|
|
audio.filter("highpass", f=100)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
"acompressor", threshold="-22dB", ratio=4, attack=10, release=150
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
.filter("equalizer", f=2500, t="q", w=1, g=5)
|
|
|
|
|
|
.filter("equalizer", f=4000, t="q", w=1, g=4)
|
|
|
|
|
|
.filter("deesser", i=0.4, f=0.25)
|
|
|
|
|
|
.filter("alimiter", limit=0.95)
|
|
|
|
|
|
.filter("dynaudnorm", f=200, g=15)
|
|
|
|
|
|
)
|
2026-04-26 10:20:29 +02:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
filtered_audios.append(audio_filtered)
|
2026-04-23 13:07:32 +02:00
|
|
|
|
|
2026-04-26 10:20:29 +02:00
|
|
|
|
base_title = audio_titles[idx]
|
|
|
|
|
|
filtered_title = base_title + " + filtered"
|
|
|
|
|
|
lang = audio_languages[idx]
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-04-26 10:20:29 +02:00
|
|
|
|
metadata_args += [
|
|
|
|
|
|
f"-metadata:s:a:{out_audio_idx}",
|
|
|
|
|
|
f"title={filtered_title}",
|
|
|
|
|
|
f"-metadata:s:a:{out_audio_idx}",
|
|
|
|
|
|
f"language={lang}",
|
|
|
|
|
|
f"-metadata:s:a:{out_audio_idx}",
|
|
|
|
|
|
f"handler_name={filtered_title}",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
out_audio_idx += 1
|
|
|
|
|
|
|
|
|
|
|
|
# -------------------
|
|
|
|
|
|
# SUBTITLES
|
|
|
|
|
|
# -------------------
|
2026-04-23 13:07:32 +02:00
|
|
|
|
subs = []
|
2026-04-26 10:20:29 +02:00
|
|
|
|
if any(s.get("codec_type") == "subtitle" for s in probe["streams"]):
|
2026-04-23 13:07:32 +02:00
|
|
|
|
subs.append(inp["s"])
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-04-26 10:20:29 +02:00
|
|
|
|
# -------------------
|
|
|
|
|
|
# OUTPUT (IMPORTANT FIX HERE)
|
|
|
|
|
|
# -------------------
|
2026-10-26 09:44:05 +01:00
|
|
|
|
input_args = [
|
|
|
|
|
|
inp.video,
|
2026-04-14 23:56:03 +02:00
|
|
|
|
*filtered_audios,
|
2026-10-26 09:44:05 +01:00
|
|
|
|
*subs,
|
|
|
|
|
|
]
|
2026-04-26 10:20:29 +02:00
|
|
|
|
|
|
|
|
|
|
out = ffmpeg.output(
|
|
|
|
|
|
*input_args,
|
2026-10-25 18:32:51 +01:00
|
|
|
|
str(args.output),
|
2026-04-14 23:56:03 +02:00
|
|
|
|
vcodec="copy",
|
|
|
|
|
|
acodec="libvorbis",
|
2026-10-26 09:44:05 +01:00
|
|
|
|
scodec=get_scodec(args.output),
|
2026-04-26 10:20:29 +02:00
|
|
|
|
).global_args(*metadata_args)
|
|
|
|
|
|
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, out
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def cmd_cut_segment(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Cut a segment from a video or audio file.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg parameters:
|
|
|
|
|
|
ss (start time): where to begin cutting (seconds or timestamp)
|
|
|
|
|
|
t (duration): length of segment to extract
|
|
|
|
|
|
c=copy: stream copy (fast, no re-encoding)
|
|
|
|
|
|
|
|
|
|
|
|
Notes:
|
|
|
|
|
|
- Using ss before input enables fast seek
|
|
|
|
|
|
- Copy mode is fast but may be less frame-accurate
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
start = parse_time_to_seconds(args.start)
|
|
|
|
|
|
duration = parse_time_to_seconds(args.duration)
|
|
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
inp = ffmpeg.input(args.input, ss=start)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
out = ffmpeg.output(
|
|
|
|
|
|
inp,
|
2026-10-25 18:32:51 +01:00
|
|
|
|
str(args.output),
|
2026-04-14 23:56:03 +02:00
|
|
|
|
t=duration,
|
2026-10-19 13:24:04 +02:00
|
|
|
|
map="0",
|
2026-04-14 23:56:03 +02:00
|
|
|
|
vcodec=get_vcodec(args.output),
|
|
|
|
|
|
acodec="copy",
|
|
|
|
|
|
scodec=get_scodec(args.output),
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.trace(f"Command: {out}")
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, out
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def cmd_set_primary_audio(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Set a chosen audio stream as the PRIMARY (first/default) audio track.
|
|
|
|
|
|
|
|
|
|
|
|
Uses select_audio_stream() to ensure correct stream selection.
|
|
|
|
|
|
|
|
|
|
|
|
FFmpeg behavior:
|
|
|
|
|
|
- We explicitly reorder audio streams
|
|
|
|
|
|
- Selected stream becomes first audio track
|
|
|
|
|
|
- All other audio streams are preserved
|
|
|
|
|
|
- Video and subtitles are kept
|
|
|
|
|
|
|
|
|
|
|
|
Important:
|
|
|
|
|
|
FFmpeg does NOT support "set default audio track" directly in MP4.
|
|
|
|
|
|
So we simulate it by stream reordering.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
inp = ffmpeg.input(args.input)
|
|
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if len(args.audio_index) > 1:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"Set primary can only set the first element of list audio_index: {args.audio_index}"
|
|
|
|
|
|
)
|
|
|
|
|
|
index = args.audio_index[0]
|
|
|
|
|
|
|
|
|
|
|
|
primary_audio = select_audio_stream(args.input, index)
|
2026-10-25 19:04:22 +01:00
|
|
|
|
probe = ffmpeg.probe(str(args.input))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
audio_streams = [
|
2026-10-26 09:44:05 +01:00
|
|
|
|
s["index"] for s in probe["streams"] if s.get("codec_type") == "audio"
|
2026-04-14 23:56:03 +02:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
reordered_audio = [primary_audio] + [a for a in audio_streams if a != primary_audio]
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
audio_inputs = [inp[f"a:{i - 1}"] for i in reordered_audio]
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
out_kwargs = {}
|
2026-04-14 23:56:03 +02:00
|
|
|
|
for i, stream in enumerate(reordered_audio):
|
|
|
|
|
|
if stream == primary_audio:
|
|
|
|
|
|
out_kwargs[f"disposition:a:{i}"] = "default"
|
|
|
|
|
|
else:
|
|
|
|
|
|
out_kwargs[f"disposition:a:{i}"] = "0"
|
|
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
info = ffmpeg.probe(str(args.input))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
streams = info.get("streams", [])
|
|
|
|
|
|
subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
|
|
|
|
|
|
|
|
|
|
|
|
subs = {}
|
|
|
|
|
|
if len(subtitle_streams) > 0:
|
|
|
|
|
|
subs = {inp["s"]}
|
|
|
|
|
|
|
|
|
|
|
|
out = ffmpeg.output(
|
|
|
|
|
|
inp.video,
|
|
|
|
|
|
*audio_inputs,
|
|
|
|
|
|
*subs,
|
2026-10-25 18:32:51 +01:00
|
|
|
|
str(args.output),
|
2026-04-14 23:56:03 +02:00
|
|
|
|
vcodec="copy",
|
|
|
|
|
|
acodec="copy",
|
|
|
|
|
|
scodec=get_scodec(args.output),
|
2026-10-26 09:44:05 +01:00
|
|
|
|
**out_kwargs,
|
2026-04-14 23:56:03 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, out
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
|
|
|
|
|
def cmd_show_metadata(args, show_metadata=False):
|
2026-07-16 10:54:33 +02:00
|
|
|
|
"""
|
|
|
|
|
|
Prints metadata directly from ffprobe output.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
probe = ffmpeg.probe(str(args.input))
|
2026-07-16 10:54:33 +02:00
|
|
|
|
format_tags = probe.get("format", {}).get("tags", {})
|
2026-10-25 18:32:51 +01:00
|
|
|
|
|
|
|
|
|
|
if show_metadata:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.info("=== FORMAT METADATA ===")
|
2026-10-25 18:32:51 +01:00
|
|
|
|
for k, v in format_tags.items():
|
|
|
|
|
|
logger.info(f"{k}: {v}")
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
# logger.info(f"\n=== STREAM METADATA ===")
|
|
|
|
|
|
# for i, stream in enumerate(probe.get("streams", [])):
|
|
|
|
|
|
# logger.info(f"\n--- Stream {i} ({stream.get('codec_type')}) ---")
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
# tags = stream.get("tags", {})
|
|
|
|
|
|
# if not tags:
|
|
|
|
|
|
# logger.info(f"No tags")
|
|
|
|
|
|
# else:
|
|
|
|
|
|
# for k, v in tags.items():
|
|
|
|
|
|
# logger.info(f"{k}: {v}")
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return False, format_tags
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_set_metadata(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Set metadata for media files.
|
|
|
|
|
|
|
|
|
|
|
|
- Default: uses ffmpeg (stream copy)
|
|
|
|
|
|
- If file is .opus: uses kid3-cli (Vorbis tags)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-10-25 18:32:51 +01:00
|
|
|
|
ext = args.input.suffix.lower()
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
# OPUS → use Kid3 CLI (Vorbis comments)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
if ext == ".opus" or args.kidflag:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
cmd = ["kid3-cli", "-c", f"set {args.key} '{args.value}'", args.input]
|
2026-10-25 19:04:22 +01:00
|
|
|
|
if args.dry_run:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.debug(f"Dry run: {' '.join(cmd)}")
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, "nooutputneeded"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
cmd,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
check=True,
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.trace(result.stdout)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return False, "nooutputneeded" # Kid3 writes in-place
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
# Default → ffmpeg (copy stream)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
|
|
|
|
inp = ffmpeg.input(args.input)
|
|
|
|
|
|
|
|
|
|
|
|
out = ffmpeg.output(
|
2026-10-26 09:44:05 +01:00
|
|
|
|
inp, str(args.output), c="copy", metadata=f"{args.key}={args.value}"
|
2026-07-16 10:54:33 +02:00
|
|
|
|
)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, out
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-05-02 15:38:16 +02:00
|
|
|
|
def cmd_apply_chapters(args):
|
|
|
|
|
|
"""
|
2026-10-19 13:24:04 +02:00
|
|
|
|
Apply chapter titles to an M4B using a simple title-only text file.
|
2026-05-02 15:38:16 +02:00
|
|
|
|
|
|
|
|
|
|
Chapters file format:
|
|
|
|
|
|
One title per line:
|
|
|
|
|
|
Introduction
|
|
|
|
|
|
Acknowledgment
|
|
|
|
|
|
The War Begins
|
|
|
|
|
|
|
|
|
|
|
|
Behavior:
|
|
|
|
|
|
- Evenly distributes chapters across media duration
|
|
|
|
|
|
- Generates ffmetadata internally
|
|
|
|
|
|
- Applies chapters using ffmpeg-python
|
|
|
|
|
|
"""
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
# read chapter titles
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
with open(args.chapter_file, "r", encoding="utf-8") as f:
|
|
|
|
|
|
titles = [line.strip() for line in f if line.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
if not titles:
|
|
|
|
|
|
raise ValueError("Chapter file is empty")
|
|
|
|
|
|
|
|
|
|
|
|
if Path(args.input).suffix == ".m4b":
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
# build ffmetadata chapters
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
chapters = get_chapter_lengths(args.input)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
if len(chapters) != len(titles):
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
|
|
|
|
|
f"Chapter lengths do not match: {len(chapters)}, {len(titles)}"
|
|
|
|
|
|
)
|
2026-05-02 15:38:16 +02:00
|
|
|
|
for idx, title in enumerate(titles):
|
2026-10-26 09:44:05 +01:00
|
|
|
|
chapters[idx].update({"title": title})
|
2026-05-02 15:38:16 +02:00
|
|
|
|
|
|
|
|
|
|
metadata_text = ";FFMETADATA1\n\n"
|
|
|
|
|
|
for ch in chapters:
|
|
|
|
|
|
start_ms = int(float(ch["start"]) * 1000)
|
|
|
|
|
|
end_ms = int(float(ch["end"]) * 1000)
|
|
|
|
|
|
metadata_text += (
|
|
|
|
|
|
"[CHAPTER]\n"
|
|
|
|
|
|
"TIMEBASE=1/1000\n"
|
|
|
|
|
|
f"START={start_ms}\n"
|
|
|
|
|
|
f"END={end_ms}\n"
|
|
|
|
|
|
f"title={ch['title']}\n\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
# run ffmpeg
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
with tempfile.NamedTemporaryFile("w+", suffix=".txt", delete=False) as f:
|
|
|
|
|
|
f.write(metadata_text)
|
|
|
|
|
|
meta_path = f.name
|
|
|
|
|
|
|
|
|
|
|
|
cmd = [
|
|
|
|
|
|
"ffmpeg",
|
|
|
|
|
|
"-y",
|
2026-10-26 09:44:05 +01:00
|
|
|
|
"-i",
|
|
|
|
|
|
args.input,
|
|
|
|
|
|
"-f",
|
|
|
|
|
|
"ffmetadata",
|
|
|
|
|
|
"-i",
|
|
|
|
|
|
meta_path,
|
|
|
|
|
|
"-map_chapters",
|
|
|
|
|
|
"1",
|
|
|
|
|
|
"-map_metadata",
|
|
|
|
|
|
"0",
|
|
|
|
|
|
"-c",
|
|
|
|
|
|
"copy",
|
|
|
|
|
|
args.output,
|
2026-05-02 15:38:16 +02:00
|
|
|
|
]
|
2026-10-25 19:04:22 +01:00
|
|
|
|
if args.dry_run:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.debug(f"Dry run: {' '.join(cmd)}")
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, "nooutputneeded"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
cmd,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
check=True,
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.trace(result.stdout)
|
|
|
|
|
|
|
2026-05-02 15:38:16 +02:00
|
|
|
|
elif Path(args.input).suffix == ".mp3":
|
2026-10-26 09:44:05 +01:00
|
|
|
|
filename = Path(args.input)
|
|
|
|
|
|
logger.info(f"{args.file_counter}:{filename}")
|
|
|
|
|
|
title = titles[args.file_counter]
|
|
|
|
|
|
safe_title = title.replace("/", "-").replace(":", "-")
|
|
|
|
|
|
new_name = f"{args.file_counter + 1:03d} - {safe_title}.mp3"
|
2026-05-02 15:38:16 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
new_path = filename.with_name(new_name)
|
2026-05-02 15:38:16 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.info(f"{filename.name} -> {new_name}")
|
|
|
|
|
|
audio = EasyID3(filename)
|
|
|
|
|
|
audio["title"] = title
|
|
|
|
|
|
audio["tracknumber"] = str(args.file_counter + 1)
|
|
|
|
|
|
if args.dry_run:
|
|
|
|
|
|
logger.debug("Dry run")
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, "nooutputneeded"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
audio.save()
|
|
|
|
|
|
safe_rename(filename, new_path)
|
2026-05-02 15:38:16 +02:00
|
|
|
|
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, "nooutputneeded"
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
def cmd_remove_russian_from_db(args_in):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Find all media with russian audio
|
|
|
|
|
|
remove russian audio tracks
|
|
|
|
|
|
"""
|
2026-10-25 18:32:51 +01:00
|
|
|
|
file = args_in.input
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.trace("Run remove russian cmd")
|
|
|
|
|
|
try:
|
|
|
|
|
|
audio_tracks = translate_audio_tracks(get_audio_tracks(file))
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
logger.warning("%s", e)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return False, "nooutputneeded"
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
2026-10-26 13:00:58 +01:00
|
|
|
|
logger.trace(f"Remove Russian from File: {file.name}")
|
|
|
|
|
|
logger.trace(f"With Audio tracks: {audio_tracks}")
|
2026-10-25 18:32:51 +01:00
|
|
|
|
remove_tracks = []
|
|
|
|
|
|
for track in audio_tracks:
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if is_russian_track(track):
|
2026-10-25 18:32:51 +01:00
|
|
|
|
remove_tracks.append(track["number"])
|
|
|
|
|
|
logger.debug(f"Removing russian track {track['number']} from {file}")
|
2026-10-26 13:00:58 +01:00
|
|
|
|
args = args_in
|
|
|
|
|
|
args.audio_index = remove_tracks
|
2026-10-26 10:22:06 +01:00
|
|
|
|
if remove_tracks:
|
2026-10-26 13:00:58 +01:00
|
|
|
|
cmd_remove_audio_track(args)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return True, "nooutputneeded"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return False, "nooutputneeded"
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
2026-05-02 15:38:16 +02:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
def cmd_test(args):
|
|
|
|
|
|
"""
|
|
|
|
|
|
TEST MODE:
|
|
|
|
|
|
Runs each command individually on test.mp4 and probes outputs.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
test_file_dir = Path("/home/lucas/scripts/media_editing/testfiles")
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_files = [
|
|
|
|
|
|
"test_auto.mkv",
|
|
|
|
|
|
"test_auto.mp4",
|
2026-10-27 08:23:09 +01:00
|
|
|
|
"test_auto_only_russian.mkv",
|
2026-10-26 09:44:05 +01:00
|
|
|
|
"test_auto_no_subs.mkv",
|
|
|
|
|
|
"test_auto_no_subs.mp4",
|
|
|
|
|
|
]
|
|
|
|
|
|
test_files = [test_file_dir / file for file in test_files]
|
2026-10-19 13:24:04 +02:00
|
|
|
|
test_dir = Path(tempfile.mkdtemp(prefix="ffmpeg_test_"))
|
|
|
|
|
|
logger.info(f"Using test dir: {test_dir}")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
def red_tag(tag: str) -> str:
|
2026-10-25 18:32:51 +01:00
|
|
|
|
return f"{red}[{tag}]{reset}"
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
def green_tag(tag: str) -> str:
|
2026-10-25 18:32:51 +01:00
|
|
|
|
return f"{green}[{tag}]{reset}"
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 18:32:51 +01:00
|
|
|
|
def run_and_probe(name, cmd_func, args_probe, _test_dict=None):
|
|
|
|
|
|
if _test_dict is None:
|
|
|
|
|
|
_test_dict = []
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.info(f"TEST: {name}")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
# Run FFmpeg for this single command only
|
2026-10-27 08:23:09 +01:00
|
|
|
|
return_value ,outputs = cmd_func(args_probe)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
if not outputs:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error("No outputs returned.")
|
2026-10-19 13:24:04 +02:00
|
|
|
|
return False
|
2026-10-25 19:04:22 +01:00
|
|
|
|
if outputs != "nooutputneeded":
|
2026-10-19 13:24:04 +02:00
|
|
|
|
try:
|
|
|
|
|
|
ffmpeg.run(
|
2026-10-26 09:44:05 +01:00
|
|
|
|
outputs, overwrite_output=True, quiet=True, capture_stderr=True
|
2026-10-19 13:24:04 +02:00
|
|
|
|
)
|
|
|
|
|
|
except ffmpeg.Error as e:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error("FFmpeg failed:")
|
2026-10-19 13:24:04 +02:00
|
|
|
|
if e.stderr:
|
|
|
|
|
|
logger.info(e.stderr.decode(errors="ignore"))
|
|
|
|
|
|
return False
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.info("FFmpeg succeeded")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
# Probe result file(s)
|
|
|
|
|
|
out_files = []
|
|
|
|
|
|
|
2026-10-27 08:23:09 +01:00
|
|
|
|
if hasattr(args_probe, "output") and args_probe.output and return_value:
|
2026-10-25 18:32:51 +01:00
|
|
|
|
out_files.append(args_probe.output)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 18:32:51 +01:00
|
|
|
|
for idx, file in enumerate(out_files):
|
|
|
|
|
|
if not file.exists():
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.info(f"{red_tag('FAIL')} Missing output: {file}")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
continue
|
|
|
|
|
|
try:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
ret_va = validate_ffprobe(file, _test_dict[idx])
|
2026-10-25 18:32:51 +01:00
|
|
|
|
return ret_va
|
2026-04-14 23:56:03 +02:00
|
|
|
|
except ffmpeg.Error as e:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(f"{red_tag('FAIL')} Probe error: {file}")
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.error(e)
|
|
|
|
|
|
return False
|
2026-10-27 08:23:09 +01:00
|
|
|
|
return True
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
2026-10-25 18:32:51 +01:00
|
|
|
|
def is_cover_art(stream):
|
|
|
|
|
|
if stream["codec_type"] != "video":
|
|
|
|
|
|
return False
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 18:32:51 +01:00
|
|
|
|
if stream.get("disposition", {}).get("attached_pic") == 1:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
if stream.get("nb_frames") and int(stream.get("nb_frames")) <= 1:
|
|
|
|
|
|
return True
|
|
|
|
|
|
tags = stream.get("tags", {})
|
|
|
|
|
|
if "DURATION" in tags:
|
|
|
|
|
|
h, m, s = tags["DURATION"].split(":")
|
|
|
|
|
|
if int(h) * 3600 + int(m) * 60 + float(s) < 1:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if "MIMETYPE" in tags and tags["MIMETYPE"] == "image/jpg":
|
|
|
|
|
|
return True
|
|
|
|
|
|
if "FILENAME" in tags and tags["FILENAME"] == "cover.jpg":
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def get_streams(_test_file):
|
|
|
|
|
|
info = ffmpeg.probe(_test_file)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
streams = info.get("streams", [])
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
_video_streams = [
|
|
|
|
|
|
s for s in streams if s.get("codec_type") == "video" and not is_cover_art(s)
|
|
|
|
|
|
]
|
2026-10-25 18:32:51 +01:00
|
|
|
|
_audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
|
|
|
|
|
|
_subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
|
2026-10-26 09:44:05 +01:00
|
|
|
|
_cover_image = [
|
|
|
|
|
|
s for s in streams if s.get("codec_type") == "video" and is_cover_art(s)
|
|
|
|
|
|
]
|
2026-10-25 18:32:51 +01:00
|
|
|
|
if not os.path.exists(_test_file):
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.info("test file not found in script directory.")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
return _video_streams, _audio_streams, _subtitle_streams, _cover_image
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
def validate_ffprobe(file_path, expected: dict):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Validate ffprobe output using a dictionary-based expectation.
|
|
|
|
|
|
|
|
|
|
|
|
Expected dict format:
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": int,
|
|
|
|
|
|
"audio_count": int,
|
|
|
|
|
|
"subtitle_count": int,
|
|
|
|
|
|
"audio_codecs": list[str],
|
|
|
|
|
|
"video_codecs": list[str]
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
2026-10-25 18:32:51 +01:00
|
|
|
|
if not file_path.is_file():
|
|
|
|
|
|
logger.error(f"Test file {file_path} is not a file")
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
[
|
|
|
|
|
|
"ffprobe",
|
2026-10-26 09:44:05 +01:00
|
|
|
|
"-v",
|
|
|
|
|
|
"quiet",
|
|
|
|
|
|
"-print_format",
|
|
|
|
|
|
"json",
|
2026-10-25 18:32:51 +01:00
|
|
|
|
"-show_chapters",
|
|
|
|
|
|
"-show_format",
|
|
|
|
|
|
file_path,
|
|
|
|
|
|
],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
check=True,
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 18:32:51 +01:00
|
|
|
|
info = json.loads(result.stdout)
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
file_path
|
|
|
|
|
|
)
|
2026-10-25 18:32:51 +01:00
|
|
|
|
args = ArgsBuffer()
|
|
|
|
|
|
args.input = file_path
|
|
|
|
|
|
tags = cmd_show_metadata(args)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
return_value = True
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- COUNT CHECKS ----------
|
|
|
|
|
|
if "video_count" in expected:
|
|
|
|
|
|
if len(video_streams) != expected["video_count"]:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
|
|
|
|
|
f"{red_tag('VIDEO COUNT FAIL')} expected {expected['video_count']}, got {len(video_streams)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return_value = return_value and False
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
if "audio_count" in expected:
|
|
|
|
|
|
if len(audio_streams) != expected["audio_count"]:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
|
|
|
|
|
f"{red_tag('AUDIO COUNT FAIL')} expected {expected['audio_count']}, got {len(audio_streams)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return_value = return_value and False
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
if "subtitle_count" in expected:
|
|
|
|
|
|
if len(subtitle_streams) != expected["subtitle_count"]:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
|
|
|
|
|
f"{red_tag('SUBTITLE COUNT FAIL')} expected {expected['subtitle_count']}, got {len(subtitle_streams)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return_value = return_value and False
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- CODEC CHECKS ----------
|
|
|
|
|
|
if "audio_codecs" in expected:
|
|
|
|
|
|
actual = sorted([s.get("codec_name") for s in audio_streams])
|
|
|
|
|
|
expected_codecs = sorted(expected["audio_codecs"])
|
|
|
|
|
|
if len(expected_codecs) < len(actual):
|
|
|
|
|
|
last = expected_codecs[-1] if expected_codecs else None
|
2026-10-26 09:44:05 +01:00
|
|
|
|
expected_codecs = expected_codecs + [last] * (
|
|
|
|
|
|
len(actual) - len(expected_codecs)
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
if actual != expected_codecs:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
|
|
|
|
|
f"{red_tag('AUDIO CODEC FAIL')}\nExpected: {expected_codecs}\nGot: {actual}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return_value = return_value and False
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
if "video_codecs" in expected:
|
|
|
|
|
|
actual = sorted([s.get("codec_name") for s in video_streams])
|
|
|
|
|
|
expected_codecs = sorted(expected["video_codecs"])
|
|
|
|
|
|
if len(expected_codecs) < len(actual):
|
|
|
|
|
|
last = expected_codecs[-1] if expected_codecs else None
|
2026-10-26 09:44:05 +01:00
|
|
|
|
expected_codecs = expected_codecs + [last] * (
|
|
|
|
|
|
len(actual) - len(expected_codecs)
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
if actual != expected_codecs:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
|
|
|
|
|
f"{red_tag('VIDEO CODEC FAIL')}\nExpected: {expected_codecs}\nGot: {actual}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return_value = return_value and False
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
# ---------- DURATION CHECKS ----------
|
|
|
|
|
|
if "duration" in expected:
|
|
|
|
|
|
format_info = info.get("format", {})
|
|
|
|
|
|
actual_duration = float(format_info.get("duration", 0))
|
2026-10-26 09:44:05 +01:00
|
|
|
|
expected_duration = parse_time_to_seconds(expected["duration"])
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# allow small ffmpeg timing variance (important!)
|
|
|
|
|
|
tolerance = expected.get("duration_tolerance", 0.2)
|
|
|
|
|
|
|
|
|
|
|
|
if abs(actual_duration - expected_duration) <= tolerance:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
|
|
|
|
|
f"{red_tag('DURATION FAIL')} expected ~{expected_duration} (±{tolerance}), got {actual_duration}s"
|
|
|
|
|
|
)
|
|
|
|
|
|
return_value = return_value and False
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
# ---------- CHAPTER CHECKS ----------
|
|
|
|
|
|
if "chapter_name" in expected:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
chapter_name = [
|
|
|
|
|
|
chapter.get("tags", {}).get("title", "")
|
|
|
|
|
|
for chapter in info.get("chapters", [])
|
|
|
|
|
|
]
|
2026-10-19 13:24:04 +02:00
|
|
|
|
expected_chapter_name = expected["chapter_name"]
|
|
|
|
|
|
if expected_chapter_name != chapter_name:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
|
|
|
|
|
f"Expected chapter_name: {expected_chapter_name}, got {chapter_name}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return_value = return_value and False
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
if "chapter_count" in expected:
|
|
|
|
|
|
chapter_num = len(info.get("chapters", []))
|
|
|
|
|
|
expected_chapter_count = expected["chapter_count"]
|
|
|
|
|
|
if expected_chapter_count != chapter_num:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error(
|
|
|
|
|
|
f"Expected chapters: {expected_chapter_count}, got {chapter_num}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return_value = return_value and False
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
# ---------- METADATA CHECKS ----------
|
|
|
|
|
|
|
|
|
|
|
|
if "metadata" in expected:
|
2026-10-25 18:32:51 +01:00
|
|
|
|
keys = expected["metadata"].keys()
|
|
|
|
|
|
values = expected["metadata"].values()
|
2026-10-26 09:44:05 +01:00
|
|
|
|
for key, value in zip(keys, values):
|
2026-10-25 18:32:51 +01:00
|
|
|
|
if key in tags.keys() and tags[key] == value:
|
|
|
|
|
|
logger.debug(f"{key}: {value}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Key {key}: {value} not in expected tags")
|
|
|
|
|
|
return_value = return_value and False
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
if "no_russian_dir" in expected:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
audio_tracks = translate_audio_tracks(get_audio_tracks(file_path))
|
|
|
|
|
|
logger.debug(f"Audio tracks: {audio_tracks}")
|
|
|
|
|
|
for audio_track in audio_tracks:
|
|
|
|
|
|
if is_russian_track(audio_track["description"]):
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
f"Found russian track: {audio_track} in file {file_path}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return return_value and False
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
# logger.debug(f"Subtitle tracks: {subtitle_tracks}")
|
|
|
|
|
|
# for subtitle_track in subtitle_tracks:
|
|
|
|
|
|
# if is_russian_track(audio_track["description"]):
|
|
|
|
|
|
# logger.error(f"Found russian track: {subtitle_track} in file {file}")
|
|
|
|
|
|
# return return_value and False
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.info(f"{green_tag('PASS')} {file_path}")
|
2026-10-19 13:24:04 +02:00
|
|
|
|
return return_value
|
|
|
|
|
|
|
|
|
|
|
|
ret = True
|
2026-04-14 23:56:03 +02:00
|
|
|
|
# ---------- INFO ----------
|
2026-10-26 09:44:05 +01:00
|
|
|
|
# args = ArgsBuffer()
|
|
|
|
|
|
# args.input = test_file_mp4
|
|
|
|
|
|
# test_dict = {}
|
|
|
|
|
|
# run_and_probe(cmd["name"], cmd_info, args, test_dict)
|
|
|
|
|
|
# args.input = test_file_mkv
|
|
|
|
|
|
# ret = ret and run_and_probe(cmd["name"], cmd_info, args, test_dict)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- DOWNSIZE ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
|
|
|
|
|
|
cmd = commands["downsize"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
for test_file in test_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.width = 640
|
|
|
|
|
|
args_test.height = 360
|
|
|
|
|
|
args_test.crf = 28
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams),
|
|
|
|
|
|
"subtitle_count": len(subtitle_streams),
|
|
|
|
|
|
"video_codecs": ["h264"],
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
ret = ret and run_and_probe(cmd["name"], cmd_downsize, args_test, test_dict)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- REMOVE AUDIO TRACK ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = commands["remove-audio"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
2026-10-26 13:00:58 +01:00
|
|
|
|
args_test.audio_index = [1]
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
for test_file in test_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": 1,
|
|
|
|
|
|
"subtitle_count": len(subtitle_streams),
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-27 08:23:09 +01:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_remove_audio_track, args_test, test_dict
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-04-23 13:07:32 +02:00
|
|
|
|
# ---------- EXTRACT AUDIO TRACK ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = commands["extract-audio"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
2026-10-26 13:00:58 +01:00
|
|
|
|
args_test.audio_index = [1]
|
2026-04-23 13:07:32 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
for test_file in test_files:
|
|
|
|
|
|
args_test.input = test_file
|
|
|
|
|
|
test_dict = [{"video_count": 0, "audio_count": 1, "subtitle_count": 0}]
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir) / f"extracted_audio_{Path(test_file).suffix[1:]}.mp3"
|
|
|
|
|
|
)
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_extract_audio_track, args_test, test_dict
|
|
|
|
|
|
)
|
2026-04-23 13:07:32 +02:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
# ---------- CONVERT AV ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = commands["convert-av"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
for test_file in test_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.video_codec = "libx264"
|
|
|
|
|
|
args_test.audio_codec = "aac"
|
|
|
|
|
|
args_test.preset = "fast"
|
|
|
|
|
|
args_test.quality = 23
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams),
|
|
|
|
|
|
"subtitle_count": len(subtitle_streams),
|
|
|
|
|
|
"video_codecs": ["h264"],
|
|
|
|
|
|
"audio_codecs": ["aac"],
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_convert_av, args_test, test_dict
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- CONVERT AUDIO ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = commands["convert-audio"]
|
|
|
|
|
|
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
for test_file in test_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.audio_codec = "aac"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams),
|
|
|
|
|
|
"subtitle_count": len(subtitle_streams),
|
|
|
|
|
|
"audio_codecs": ["aac"],
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_convert_audio, args_test, test_dict
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- CONVERT VIDEO ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = commands["convert-video"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
for test_file in test_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.video_codec = "libx264"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams),
|
|
|
|
|
|
"subtitle_count": len(subtitle_streams),
|
|
|
|
|
|
"video_codecs": ["h264"],
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_convert_video, args_test, test_dict
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- REMOVE SUBS ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = commands["remove-subs"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
for test_file in test_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-25 18:32:51 +01:00
|
|
|
|
args_test.num = None
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams),
|
|
|
|
|
|
"subtitle_count": 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_remove_subs, args_test, test_dict
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- FILTER AUDIO ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = commands["filter-audio"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
for test_file in test_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-26 13:00:58 +01:00
|
|
|
|
args_test.audio_index = [1]
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams) + 1,
|
|
|
|
|
|
"subtitle_count": len(subtitle_streams),
|
|
|
|
|
|
"audio_codecs": ["vorbis"],
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_filter_audio, args_test, test_dict
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- CUT ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = commands["cut"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
for test_file in test_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.start = "0:0:5"
|
|
|
|
|
|
args_test.duration = "10s"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams),
|
|
|
|
|
|
"subtitle_count": len(subtitle_streams),
|
|
|
|
|
|
"duration": "10s",
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_cut_segment, args_test, test_dict
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- SET PRIMARY AUDIO ----------
|
2026-10-19 13:24:04 +02:00
|
|
|
|
cmd = commands["set-primary-audio"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
for test_file in test_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-26 13:00:58 +01:00
|
|
|
|
args_test.audio_index = [2]
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams),
|
|
|
|
|
|
"subtitle_count": len(subtitle_streams),
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_set_primary_audio, args_test, test_dict
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
# ---------- APPLY CHAPTERS-----
|
|
|
|
|
|
cmd = commands["chapters"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
test_file = test_file_dir / "test_auto.m4b"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.chapter_file = test_file_dir / "test_auto_chapters.txt"
|
|
|
|
|
|
lines = Path(args_test.chapter_file).read_text(encoding="utf-8").splitlines()
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams),
|
|
|
|
|
|
"chapter_name": lines,
|
|
|
|
|
|
"chapter_count": len(lines),
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_apply_chapters, args_test, test_dict
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- WRITE METADATA-----
|
|
|
|
|
|
cmd = commands["metadata-set"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
test_file = test_file_dir / "test_auto.m4b"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
video_streams, audio_streams, subtitle_streams, cover_image = get_streams(
|
|
|
|
|
|
test_file
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
args_test.key = "artist"
|
|
|
|
|
|
args_test.value = "TEST"
|
|
|
|
|
|
args_test.kidflag = False
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"video_count": len(video_streams),
|
|
|
|
|
|
"audio_count": len(audio_streams),
|
|
|
|
|
|
"metadata": {"artist": "TEST"},
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
ret = ret and run_and_probe(cmd["name"], cmd_set_metadata, args_test, test_dict)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------- REMOVE RUSSIAN AUDIO -----
|
|
|
|
|
|
cmd = commands["remove-russian-from-db"]
|
|
|
|
|
|
if cmd["name"] in args.choices:
|
2026-10-25 18:32:51 +01:00
|
|
|
|
for test_file in test_files:
|
|
|
|
|
|
args_test = ArgsBuffer()
|
|
|
|
|
|
args_test.input = test_file
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args_test.output = (
|
|
|
|
|
|
Path(test_dir)
|
|
|
|
|
|
/ f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
)
|
2026-10-25 18:32:51 +01:00
|
|
|
|
args_test.loud = False
|
2026-10-26 09:44:05 +01:00
|
|
|
|
test_dict = [{"no_russian_dir": test_dir}]
|
|
|
|
|
|
ret = ret and run_and_probe(
|
|
|
|
|
|
cmd["name"], cmd_remove_russian_from_db, args_test, test_dict
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
## -----------FOLDER TEST---------------
|
|
|
|
|
|
## ---------- REMOVE RUSSIAN AUDIO -----
|
|
|
|
|
|
# cmd = commands["remove-russian-from-db"]
|
|
|
|
|
|
# if cmd["name"] in args.choices:
|
|
|
|
|
|
# args_test = ArgsBuffer()
|
|
|
|
|
|
# args_test.input = test_file_dir
|
|
|
|
|
|
# args_test.output = (
|
|
|
|
|
|
# Path(test_dir)
|
|
|
|
|
|
# / f"out_{str(cmd['name']).replace('-', '_')}_{Path(test_file).stem}{Path(test_file).suffix}"
|
|
|
|
|
|
# )
|
|
|
|
|
|
# args_test.loud = False
|
|
|
|
|
|
# test_dict = [{"no_russian_dir": test_dir}]
|
|
|
|
|
|
# ret = ret and run_and_probe(
|
|
|
|
|
|
# cmd["name"], cmd_remove_russian_from_db, args_test, test_dict
|
|
|
|
|
|
# )
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
|
|
|
|
|
if ret:
|
|
|
|
|
|
logger.info(green_tag("ALL TESTS SUCCEEDED"))
|
2026-04-20 12:57:30 +02:00
|
|
|
|
else:
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.info(red_tag("SOME TESTS FAILED"))
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
# ---------- CLI Setup ---------- #
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
def add_command(
|
|
|
|
|
|
subparsers,
|
|
|
|
|
|
name,
|
|
|
|
|
|
func,
|
|
|
|
|
|
help_text,
|
|
|
|
|
|
aliases=None,
|
|
|
|
|
|
parents=None,
|
|
|
|
|
|
):
|
|
|
|
|
|
parser = subparsers.add_parser(
|
|
|
|
|
|
name,
|
|
|
|
|
|
aliases=aliases or [],
|
|
|
|
|
|
parents=parents or [],
|
|
|
|
|
|
help=help_text,
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.set_defaults(
|
|
|
|
|
|
func=func,
|
|
|
|
|
|
command=name,
|
|
|
|
|
|
)
|
|
|
|
|
|
return parser
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_audio_index(parser):
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"-a",
|
|
|
|
|
|
"--audio-index",
|
|
|
|
|
|
type=int,
|
2026-10-26 13:00:58 +01:00
|
|
|
|
nargs="+",
|
2026-10-25 19:04:22 +01:00
|
|
|
|
help="Audio track index",
|
2026-04-14 23:56:03 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-25 18:32:51 +01:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
def main():
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
description="Media processing tool using ffmpeg-python",
|
|
|
|
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
|
|
|
|
|
# SHARED ARGS
|
|
|
|
|
|
# =========================================================
|
|
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
base_p = argparse.ArgumentParser(add_help=False)
|
2026-10-25 19:04:22 +01:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
base_p.add_argument("input", nargs="?", type=Path, help="Input file or directory")
|
|
|
|
|
|
base_p.add_argument("-o", "--output", type=Path, help="Output file")
|
2026-10-26 13:00:58 +01:00
|
|
|
|
base_p.add_argument("--overwrite", action="store_true", help="Overwrite input")
|
2026-10-25 19:04:22 +01:00
|
|
|
|
base_p.add_argument("--backup", action="store_true", help="Backup original")
|
|
|
|
|
|
base_p.add_argument("--loud", action="store_true", help="Show ffmpeg output")
|
2026-10-26 09:44:05 +01:00
|
|
|
|
base_p.add_argument(
|
|
|
|
|
|
"--kidflag", action="store_true", help="Use kid3 instead of ffmpeg"
|
|
|
|
|
|
)
|
|
|
|
|
|
base_p.add_argument(
|
|
|
|
|
|
"-r", "--recursive", action="store_true", help="Process recursively"
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
base_p.add_argument(
|
|
|
|
|
|
"-ll",
|
|
|
|
|
|
"--log-level",
|
|
|
|
|
|
type=str.upper,
|
|
|
|
|
|
default="INFO",
|
|
|
|
|
|
choices=["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
base_p.add_argument(
|
|
|
|
|
|
"--dry-run",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Do not execute actions",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
sub = parser.add_subparsers(
|
|
|
|
|
|
dest="command",
|
|
|
|
|
|
required=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# INFO
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"info",
|
|
|
|
|
|
cmd_info,
|
|
|
|
|
|
"Inspect media metadata",
|
|
|
|
|
|
aliases=["i"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# TEST
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"test",
|
|
|
|
|
|
cmd_test,
|
|
|
|
|
|
"Run tests",
|
|
|
|
|
|
aliases=["t"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.add_argument(
|
|
|
|
|
|
"--choices",
|
|
|
|
|
|
nargs="+",
|
|
|
|
|
|
choices=list(commands.keys()),
|
|
|
|
|
|
default=list(commands.keys()),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# CONVERT AV
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"convert-av",
|
|
|
|
|
|
cmd_convert_av,
|
|
|
|
|
|
"Full video/audio re-encode",
|
|
|
|
|
|
aliases=["cav"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.add_argument("--video-codec", default="libx264")
|
|
|
|
|
|
p.add_argument("--audio-codec", default="aac")
|
|
|
|
|
|
p.add_argument("--preset", default="medium")
|
|
|
|
|
|
p.add_argument("--quality", type=int)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# CONVERT AUDIO
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"convert-audio",
|
|
|
|
|
|
cmd_convert_audio,
|
|
|
|
|
|
"Convert audio only",
|
|
|
|
|
|
aliases=["ca"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.add_argument("--audio-codec", default="aac")
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# CONVERT VIDEO
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"convert-video",
|
|
|
|
|
|
cmd_convert_video,
|
|
|
|
|
|
"Convert video only",
|
|
|
|
|
|
aliases=["cv"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.add_argument("--video-codec", default="libx264")
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# REMOVE SUBS
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"remove-subs",
|
|
|
|
|
|
cmd_remove_subs,
|
|
|
|
|
|
"Strip subtitle streams",
|
|
|
|
|
|
aliases=["rs"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
|
|
|
|
|
# REMOVE AUDIO
|
|
|
|
|
|
# =========================================================
|
2026-04-23 13:07:32 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"remove-audio",
|
|
|
|
|
|
cmd_remove_audio_track,
|
|
|
|
|
|
"Remove audio track",
|
|
|
|
|
|
aliases=["ra"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
add_audio_index(p)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
|
|
|
|
|
# EXTRACT AUDIO
|
|
|
|
|
|
# =========================================================
|
|
|
|
|
|
|
|
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"extract-audio",
|
|
|
|
|
|
cmd_extract_audio_track,
|
|
|
|
|
|
"Extract audio track",
|
|
|
|
|
|
aliases=["ea"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
add_audio_index(p)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# DOWNSIZE
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"downsize",
|
|
|
|
|
|
cmd_downsize,
|
|
|
|
|
|
"Scale video",
|
|
|
|
|
|
aliases=["d"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.add_argument("-w", "--width", type=int)
|
|
|
|
|
|
p.add_argument("-he", "--height", type=int)
|
|
|
|
|
|
p.add_argument("-c", "--crf", type=int)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# FILTER AUDIO
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"filter-audio",
|
|
|
|
|
|
cmd_filter_audio,
|
|
|
|
|
|
"Apply audio filters",
|
|
|
|
|
|
aliases=["fa"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
add_audio_index(p)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# CUT
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"cut",
|
|
|
|
|
|
cmd_cut_segment,
|
|
|
|
|
|
"Cut media segment",
|
|
|
|
|
|
aliases=["c"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.add_argument("--start")
|
|
|
|
|
|
p.add_argument("--duration")
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# SET PRIMARY AUDIO
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"set-primary-audio",
|
|
|
|
|
|
cmd_set_primary_audio,
|
|
|
|
|
|
"Set default audio track",
|
|
|
|
|
|
aliases=["spa"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
add_audio_index(p)
|
2026-10-19 13:24:04 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
|
|
|
|
|
# REMOVE RUSSIAN AUDIO
|
|
|
|
|
|
# =========================================================
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"remove-russian-from-db",
|
|
|
|
|
|
cmd_remove_russian_from_db,
|
|
|
|
|
|
"Remove russian audio tracks",
|
|
|
|
|
|
aliases=["rrfd"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
# =========================================================
|
|
|
|
|
|
# CHAPTERS
|
|
|
|
|
|
# =========================================================
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-25 19:04:22 +01:00
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"chapters",
|
|
|
|
|
|
cmd_apply_chapters,
|
|
|
|
|
|
"Apply chapter metadata",
|
|
|
|
|
|
aliases=["ch"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.add_argument(
|
|
|
|
|
|
"-cf",
|
|
|
|
|
|
"--chapter-file",
|
|
|
|
|
|
help="ffmetadata chapter file",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
|
|
|
|
|
# METADATA PRINT
|
|
|
|
|
|
# =========================================================
|
|
|
|
|
|
|
|
|
|
|
|
add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"metadata-print",
|
|
|
|
|
|
cmd_show_metadata,
|
|
|
|
|
|
"Show metadata",
|
|
|
|
|
|
aliases=["mp"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================
|
|
|
|
|
|
# METADATA SET
|
|
|
|
|
|
# =========================================================
|
|
|
|
|
|
|
|
|
|
|
|
p = add_command(
|
|
|
|
|
|
sub,
|
|
|
|
|
|
"metadata-set",
|
|
|
|
|
|
cmd_set_metadata,
|
|
|
|
|
|
"Set metadata value",
|
|
|
|
|
|
aliases=["ms"],
|
|
|
|
|
|
parents=[base_p],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.add_argument("-k", "--key")
|
|
|
|
|
|
p.add_argument("-v", "--value")
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# =========================================================
|
|
|
|
|
|
# PARSE
|
|
|
|
|
|
# =========================================================
|
2026-10-25 19:04:22 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
args = parser.parse_args()
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
setup_logging(args.log_level, "ffmpeg_handling")
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# ----------------------------
|
|
|
|
|
|
# COMMAND VALIDATION
|
|
|
|
|
|
# ----------------------------
|
2026-04-14 23:56:03 +02:00
|
|
|
|
if not hasattr(args, "func"):
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.info("\nAvailable commands:")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-04-20 12:57:30 +02:00
|
|
|
|
seen = set()
|
|
|
|
|
|
for name, parser in sub.choices.items():
|
|
|
|
|
|
if id(parser) in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(id(parser))
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.info(f" - {name}")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-04-20 12:57:30 +02:00
|
|
|
|
args.command = ensure(None, "Command")
|
2026-04-14 23:56:03 +02:00
|
|
|
|
|
2026-04-20 12:57:30 +02:00
|
|
|
|
if args.command not in sub.choices:
|
|
|
|
|
|
parser.error("Unknown command")
|
|
|
|
|
|
|
|
|
|
|
|
# re-parse subcommand properly
|
|
|
|
|
|
sub_args = sub.choices[args.command].parse_args([])
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args = argparse.Namespace(**vars(sub_args))
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
# INTERACTIVE FALLBACK (ensure ALL missing values)
|
|
|
|
|
|
# ----------------------------
|
2026-04-14 23:56:03 +02:00
|
|
|
|
if args.command != "test":
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args.input = Path(
|
|
|
|
|
|
ensure(getattr(args, "input", None), f"Input file ({Path.cwd()})")
|
|
|
|
|
|
)
|
2026-10-25 14:31:20 +01:00
|
|
|
|
args.input = Path(args.input).expanduser().resolve()
|
2026-04-26 10:20:29 +02:00
|
|
|
|
if not Path(args.input).exists():
|
2026-10-25 14:31:20 +01:00
|
|
|
|
parser.error(f"Input file {args.input} does not exist!")
|
2026-10-19 13:24:04 +02:00
|
|
|
|
if commands[args.command]["output"] and not getattr(args, "output", None):
|
2026-07-16 10:54:33 +02:00
|
|
|
|
input_path = Path(args.input)
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if args.overwrite:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args.output = input_path.with_name(
|
2026-10-26 13:00:58 +01:00
|
|
|
|
f"{input_path.stem}_overwrite{input_path.suffix}"
|
2026-10-26 09:44:05 +01:00
|
|
|
|
)
|
2026-07-16 10:54:33 +02:00
|
|
|
|
else:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args.output = ensure(
|
|
|
|
|
|
getattr(args, "output", None),
|
|
|
|
|
|
"Output file (enter for default file)",
|
|
|
|
|
|
)
|
2026-04-23 13:07:32 +02:00
|
|
|
|
if args.output == "" or args.output is None:
|
|
|
|
|
|
input_path = Path(args.input)
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args.output = input_path.with_name(
|
|
|
|
|
|
f"{input_path.stem}_{args.command}{input_path.suffix}"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
args.output = Path(args.output)
|
2026-04-20 12:57:30 +02:00
|
|
|
|
# ----------------------------
|
|
|
|
|
|
# COMMAND-SPECIFIC REQUIRED FIELDS
|
|
|
|
|
|
# ----------------------------
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-04-20 12:57:30 +02:00
|
|
|
|
if args.command == "convert-av":
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args.preset = ensure(
|
|
|
|
|
|
getattr(args, "preset", None),
|
|
|
|
|
|
"Encoding speed/quality preset (default: medium, e.g. ultrafast, fast, slow, veryslow)",
|
|
|
|
|
|
)
|
|
|
|
|
|
args.quality = ensure(
|
|
|
|
|
|
getattr(args, "quality", None),
|
|
|
|
|
|
"Output quality level (e.g. CRF value or bitrate depending on encoder)",
|
|
|
|
|
|
)
|
2026-04-20 12:57:30 +02:00
|
|
|
|
if args.command == "convert-av" or args.command == "convert-audio":
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args.audio_codec = ensure(
|
|
|
|
|
|
getattr(args, "audio_codec", None),
|
|
|
|
|
|
"Audio codec to use (default: aac, e.g. mp3, opus)",
|
|
|
|
|
|
)
|
2026-04-20 12:57:30 +02:00
|
|
|
|
if args.command == "convert-av" or args.command == "convert-video":
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args.video_codec = ensure(
|
|
|
|
|
|
getattr(args, "video_codec", None),
|
|
|
|
|
|
"Video codec to use (default: libx264, e.g. libx265, vp9)",
|
|
|
|
|
|
)
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
|
|
|
|
|
if args.command == "cut":
|
|
|
|
|
|
args.start = ensure(getattr(args, "start", None), "Start time (e.g. 10s, 1:00)")
|
|
|
|
|
|
args.duration = ensure(getattr(args, "duration", None), "Duration (e.g. 30s)")
|
|
|
|
|
|
|
|
|
|
|
|
if args.command == "downsize":
|
|
|
|
|
|
args.width = ensure(getattr(args, "width", None), "width: eg 1080")
|
|
|
|
|
|
args.height = ensure(getattr(args, "height", None), "height: eg 720")
|
|
|
|
|
|
args.crf = ensure(getattr(args, "crf", None), "crf: eg 23")
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
if args.command in [
|
|
|
|
|
|
"filter-audio",
|
|
|
|
|
|
"remove-audio",
|
|
|
|
|
|
"extract-audio",
|
|
|
|
|
|
"set-primary-audio",
|
|
|
|
|
|
]:
|
2026-10-26 13:00:58 +01:00
|
|
|
|
index = str(ensure(getattr(args, "audio_index", None), "Audio index"))
|
|
|
|
|
|
args.audio_index = [int(x) for x in re.split(r"[,\s]+", index.strip()) if x]
|
2026-04-23 13:07:32 +02:00
|
|
|
|
if args.audio_index is None:
|
2026-10-26 13:00:58 +01:00
|
|
|
|
args.audio_index = [1]
|
2026-04-23 13:07:32 +02:00
|
|
|
|
|
2026-07-16 10:54:33 +02:00
|
|
|
|
if args.command == "chapters":
|
2026-10-26 09:44:05 +01:00
|
|
|
|
args.chapter_file = ensure(
|
|
|
|
|
|
getattr(args, "chapter_file", None), "File with chapternames"
|
|
|
|
|
|
)
|
2026-07-16 10:54:33 +02:00
|
|
|
|
|
2026-10-19 13:24:04 +02:00
|
|
|
|
if args.command == "metadata-set":
|
2026-07-16 10:54:33 +02:00
|
|
|
|
args.chapter_file = ensure(getattr(args, "key", None), "Key to change")
|
|
|
|
|
|
args.chapter_file = ensure(getattr(args, "value", None), "Key value")
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
# EXECUTE COMMAND
|
|
|
|
|
|
# ----------------------------
|
|
|
|
|
|
try:
|
2026-04-23 13:07:32 +02:00
|
|
|
|
if args.command == "test":
|
|
|
|
|
|
args.func(args)
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
outputs = []
|
|
|
|
|
|
input_path = Path(args.input)
|
2026-10-26 09:44:05 +01:00
|
|
|
|
files_move_from = []
|
|
|
|
|
|
files_move_to = []
|
2026-04-23 13:07:32 +02:00
|
|
|
|
# ---- DIRECTORY ----
|
|
|
|
|
|
if input_path.is_dir():
|
2026-10-26 09:44:05 +01:00
|
|
|
|
counter = 0
|
|
|
|
|
|
logger.info(f"Processing dir: {input_path}")
|
2026-10-25 18:32:51 +01:00
|
|
|
|
if args.recursive:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
sorted_files = sorted(
|
|
|
|
|
|
(
|
|
|
|
|
|
p
|
|
|
|
|
|
for p in input_path.rglob("*")
|
|
|
|
|
|
if p.is_file() and p.suffix.lower() in VIDEO_EXTS
|
|
|
|
|
|
),
|
|
|
|
|
|
key=natural_sort_key,
|
|
|
|
|
|
)
|
2026-10-25 18:32:51 +01:00
|
|
|
|
else:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
sorted_files = sorted(
|
|
|
|
|
|
(
|
|
|
|
|
|
p
|
|
|
|
|
|
for p in input_path.iterdir()
|
|
|
|
|
|
if p.is_file() and p.suffix.lower() in VIDEO_EXTS
|
|
|
|
|
|
),
|
|
|
|
|
|
key=natural_sort_key,
|
|
|
|
|
|
)
|
2026-05-02 15:38:16 +02:00
|
|
|
|
for file in sorted_files:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
if (
|
|
|
|
|
|
file.suffix.lower() not in VIDEO_EXTS
|
|
|
|
|
|
and file.suffix.lower() not in AUDIO_EXTS
|
|
|
|
|
|
):
|
2026-04-23 13:07:32 +02:00
|
|
|
|
continue
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.trace(f"Processing File: {file}")
|
2026-10-25 18:32:51 +01:00
|
|
|
|
args.input = Path(file)
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if args.overwrite:
|
|
|
|
|
|
out_path = file.with_name(f"{file.stem}_overwrite{file.suffix}")
|
2026-07-16 10:54:33 +02:00
|
|
|
|
else:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
out_path = file.with_name(
|
|
|
|
|
|
f"{file.stem}_{args.command}{file.suffix}"
|
|
|
|
|
|
)
|
2026-10-25 19:04:22 +01:00
|
|
|
|
args.output = out_path
|
2026-05-02 15:38:16 +02:00
|
|
|
|
args.file_counter = counter
|
2026-10-26 10:22:06 +01:00
|
|
|
|
return_value, output_str = args.func(args)
|
|
|
|
|
|
if output_str != "nooutputneeded":
|
|
|
|
|
|
outputs.append(output_str)
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if args.overwrite and return_value:
|
2026-10-26 10:22:06 +01:00
|
|
|
|
files_move_from.append(out_path)
|
|
|
|
|
|
files_move_to.append(file)
|
2026-10-26 09:44:05 +01:00
|
|
|
|
counter += 1
|
2026-04-23 13:07:32 +02:00
|
|
|
|
# ---- SINGLE FILE ----
|
|
|
|
|
|
elif input_path.is_file():
|
2026-10-26 13:00:58 +01:00
|
|
|
|
return_value, output_str = args.func(args)
|
2026-10-26 10:22:06 +01:00
|
|
|
|
if output_str != "nooutputneeded":
|
|
|
|
|
|
outputs.append(output_str)
|
2026-04-23 13:07:32 +02:00
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(f"Invalid input path: {args.input}")
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
2026-05-02 15:38:16 +02:00
|
|
|
|
if any(item is None for item in outputs) or len(outputs) == 0:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.info("Output is NONE")
|
2026-05-02 15:38:16 +02:00
|
|
|
|
else:
|
|
|
|
|
|
if not isinstance(outputs, list):
|
|
|
|
|
|
outputs = [outputs]
|
2026-04-20 12:57:30 +02:00
|
|
|
|
|
2026-05-02 15:38:16 +02:00
|
|
|
|
if args.command != "test" and outputs is not None:
|
2026-07-16 10:54:33 +02:00
|
|
|
|
run_ffmpeg(
|
2026-10-26 09:44:05 +01:00
|
|
|
|
ffmpeg.merge_outputs(*outputs), overwrite=True, loud=args.loud
|
2026-05-02 15:38:16 +02:00
|
|
|
|
)
|
2026-10-26 13:00:58 +01:00
|
|
|
|
if args.overwrite and not move_overwrite_file(
|
2026-10-26 09:44:05 +01:00
|
|
|
|
files_move_from, files_move_to, args
|
|
|
|
|
|
):
|
2026-10-26 13:00:58 +01:00
|
|
|
|
logger.error("move_overwrite failed")
|
2026-10-26 09:44:05 +01:00
|
|
|
|
sys.exit(1)
|
2026-04-20 12:57:30 +02:00
|
|
|
|
except ffmpeg.Error as e:
|
2026-10-26 09:44:05 +01:00
|
|
|
|
logger.error("FFmpeg failed.\n")
|
2026-04-20 12:57:30 +02:00
|
|
|
|
if e.stderr:
|
2026-10-19 13:24:04 +02:00
|
|
|
|
logger.error(e.stderr.decode(errors="ignore"))
|
2026-04-20 12:57:30 +02:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
2026-10-26 09:44:05 +01:00
|
|
|
|
|
2026-04-14 23:56:03 +02:00
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|