Files
ffmpeg_wrapper/ffwrap/gui/main_window.py
2026-10-09 18:58:50 +00:00

882 lines
38 KiB
Python

"""Main application window."""
from __future__ import annotations
import json
import os
from pathlib import Path
from PySide6.QtCore import QObject, QRunnable, QSettings, Qt, QThreadPool, QUrl, Signal
from PySide6.QtGui import QAction, QDesktopServices, QFontDatabase, QKeySequence
from PySide6.QtWidgets import (
QAbstractItemView, QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton,
QRadioButton, QSpinBox, QSplitter, QTableView, QToolBar, QVBoxLayout, QWidget,
)
from .. import APP_NAME, __version__
from ..command import (
AUDIO_MODES, EXISTS_POLICIES, VIDEO_MODES, Options, build_command, format_command,
match_audio_selection, plan_output_path,
)
from ..formats import (
AUDIO_BITRATES, AUDIO_CHANNELS, AUDIO_ENCODERS, AUDIO_ENCODERS_BY_NAME, AUDIO_SAMPLE_RATES, CONTAINERS,
CUSTOM_RESOLUTION, FRAME_RATES, RESOLUTIONS, SPEED_LEVELS, TEXT_SUBTITLE_CODECS,
VIDEO_ENCODERS, VIDEO_ENCODERS_BY_NAME,
)
from ..probe import MediaInfo, ProbeError, probe
from ..tools import FFmpegTools, locate_tools
from .queue_model import COL_PROGRESS, RUNNABLE, Job, ProgressDelegate, QueueModel, Status
from .runner import BatchRunner
MEDIA_EXTENSIONS = {
".mkv", ".mp4", ".m4v", ".mov", ".avi", ".wmv", ".flv", ".webm", ".ts", ".m2ts", ".mts",
".mpg", ".mpeg", ".vob", ".3gp", ".ogv", ".divx", ".rmvb", ".asf", ".mxf", ".dv",
".mka", ".mp3", ".flac", ".m4a", ".wav", ".ogg", ".opus", ".ac3", ".dts", ".wma",
}
# --------------------------------------------------------------------------
# Background probing
# --------------------------------------------------------------------------
class _ProbeSignals(QObject):
done = Signal(object, object, str) # job, MediaInfo | None, error
class _ProbeTask(QRunnable):
def __init__(self, ffprobe: str, job: Job, signals: _ProbeSignals):
super().__init__()
self.ffprobe, self.job, self.signals = ffprobe, job, signals
def run(self):
try:
info = probe(self.ffprobe, self.job.path)
self.signals.done.emit(self.job, info, "")
except ProbeError as e:
self.signals.done.emit(self.job, None, str(e))
except Exception as e: # never let a worker thread die silently
self.signals.done.emit(self.job, None, f"{type(e).__name__}: {e}")
class TrackList(QListWidget):
"""Checkable, drag-reorderable list of audio tracks."""
order_changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setDragDropMode(QAbstractItemView.InternalMove)
self.setDefaultDropAction(Qt.MoveAction)
self.setSelectionMode(QAbstractItemView.SingleSelection)
def dropEvent(self, event):
super().dropEvent(event)
self.order_changed.emit()
def _combo(items: list[tuple[str, object]]) -> QComboBox:
cb = QComboBox()
for label, data in items:
cb.addItem(label, data)
return cb
def _select_data(cb: QComboBox, value) -> bool:
i = cb.findData(value)
if i >= 0:
cb.setCurrentIndex(i)
return True
return False
# --------------------------------------------------------------------------
# Main window
# --------------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(APP_NAME)
self.resize(1280, 880)
self.setAcceptDrops(True)
self.settings = QSettings("ffwrap", "FFmpegWrapper")
self.tools: FFmpegTools | None = None
self.model = QueueModel(self)
self.runner = BatchRunner(self)
self.pool = QThreadPool(self)
self.pool.setMaxThreadCount(max(2, min(6, os.cpu_count() or 2)))
self.probe_signals = _ProbeSignals()
self.probe_signals.done.connect(self._on_probed)
self._track_job: Job | None = None
self._loading = False
self._build_ui()
self._connect()
self._load_settings()
self._init_tools()
# ------------------------------------------------------------------ UI
def _build_ui(self) -> None:
# Toolbar / menus
tb = QToolBar("Main")
tb.setMovable(False)
tb.setToolButtonStyle(Qt.ToolButtonTextOnly)
self.addToolBar(tb)
self.act_add = QAction("Add Files...", self, shortcut=QKeySequence.Open)
self.act_add_dir = QAction("Add Folder...", self)
self.act_remove = QAction("Remove Selected", self, shortcut=QKeySequence.Delete)
self.act_clear = QAction("Clear Queue", self)
for a in (self.act_add, self.act_add_dir, self.act_remove, self.act_clear):
tb.addAction(a)
def action(text, slot, shortcut=None):
a = QAction(text, self)
a.triggered.connect(slot)
if shortcut is not None:
a.setShortcut(shortcut)
return a
m_file = self.menuBar().addMenu("&File")
m_file.addAction(self.act_add)
m_file.addAction(self.act_add_dir)
m_file.addSeparator()
m_file.addAction(action("Quit", self.close, QKeySequence.Quit))
m_settings = self.menuBar().addMenu("&Settings")
m_settings.addAction(action("Set FFmpeg Location...", self._choose_ffmpeg_dir))
m_settings.addAction(action("Use Automatic FFmpeg Detection", self._reset_ffmpeg_dir))
m_settings.addSeparator()
m_settings.addAction(action("Reset Conversion Options", self._reset_options))
self.menuBar().addMenu("&Help").addAction(action("About", self._about))
# Queue table
self.table = QTableView()
self.table.setModel(self.model)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.table.setAlternatingRowColors(True)
self.table.verticalHeader().setVisible(False)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.setItemDelegateForColumn(COL_PROGRESS, ProgressDelegate(self.table))
hh = self.table.horizontalHeader()
hh.setSectionResizeMode(QHeaderView.Interactive)
hh.setSectionResizeMode(0, QHeaderView.Stretch)
for col, w in ((1, 70), (2, 190), (3, 70), (4, 45), (5, 190), (6, 110)):
self.table.setColumnWidth(col, w)
self.drop_hint = QLabel("Drop video files or folders here, or use Add Files...")
self.drop_hint.setAlignment(Qt.AlignCenter)
self.drop_hint.setStyleSheet("color: gray;")
queue_box = QWidget()
ql = QVBoxLayout(queue_box)
ql.setContentsMargins(0, 0, 0, 0)
ql.addWidget(self.table)
ql.addWidget(self.drop_hint)
# Audio track panel
tracks_box = QGroupBox("Audio tracks")
tl = QVBoxLayout(tracks_box)
self.tracks_title = QLabel("Select a file in the queue")
self.tracks_title.setWordWrap(True)
self.track_list = TrackList()
hint = QLabel("Tick the tracks to keep. Drag to reorder - the first ticked track becomes the default.")
hint.setWordWrap(True)
hint.setStyleSheet("color: gray;")
self.stream_info = QLabel("")
self.stream_info.setWordWrap(True)
row = QHBoxLayout()
self.btn_all = QPushButton("All")
self.btn_none = QPushButton("None")
self.btn_apply_all = QPushButton("Apply to All Files")
self.btn_apply_all.setToolTip(
"Copy this track selection to every other file in the queue.\n"
"Tracks are matched by language, codec and channel layout.")
row.addWidget(self.btn_all)
row.addWidget(self.btn_none)
row.addStretch(1)
row.addWidget(self.btn_apply_all)
tl.addWidget(self.tracks_title)
tl.addWidget(self.track_list, 1)
tl.addWidget(hint)
tl.addLayout(row)
tl.addWidget(self.stream_info)
top = QSplitter(Qt.Horizontal)
top.addWidget(queue_box)
top.addWidget(tracks_box)
top.setStretchFactor(0, 3)
top.setStretchFactor(1, 2)
top.setSizes([800, 460])
# Settings
self.settings_box = QWidget()
sl = QHBoxLayout(self.settings_box)
sl.setContentsMargins(0, 0, 0, 0)
sl.addWidget(self._build_output_group(), 1)
sl.addWidget(self._build_video_group(), 1)
sl.addWidget(self._build_audio_group(), 1)
# Log
self.log_view = QPlainTextEdit()
self.log_view.setReadOnly(True)
self.log_view.setMaximumBlockCount(10000)
self.log_view.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
splitter = QSplitter(Qt.Vertical)
splitter.addWidget(top)
splitter.addWidget(self.settings_box)
splitter.addWidget(self.log_view)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(2, 1)
splitter.setCollapsible(1, False)
splitter.setSizes([380, 300, 160])
# Bottom bar
self.overall = QProgressBar()
self.overall.setRange(0, 1000)
self.overall.setFormat("%p%")
self.btn_preview = QPushButton("Show Command")
self.btn_preview.setToolTip("Print the ffmpeg command for the selected file to the log")
self.btn_start = QPushButton("Start")
self.btn_start.setDefault(True)
self.btn_start.setMinimumWidth(110)
self.btn_stop = QPushButton("Stop")
self.btn_stop.setEnabled(False)
bottom = QHBoxLayout()
bottom.addWidget(self.overall, 1)
bottom.addWidget(self.btn_preview)
bottom.addWidget(self.btn_start)
bottom.addWidget(self.btn_stop)
central = QWidget()
cl = QVBoxLayout(central)
cl.addWidget(splitter, 1)
cl.addLayout(bottom)
self.setCentralWidget(central)
self.ffmpeg_label = QLabel()
self.statusBar().addPermanentWidget(self.ffmpeg_label)
def _build_output_group(self) -> QGroupBox:
box = QGroupBox("Output")
f = QFormLayout(box)
self.cb_container = _combo([(c.label, c.key) for c in CONTAINERS.values()])
f.addRow("Container:", self.cb_container)
self.rb_same_dir = QRadioButton("Same folder as source")
self.rb_custom_dir = QRadioButton("Custom folder:")
self.ed_out_dir = QLineEdit()
self.ed_out_dir.setPlaceholderText("Choose an output folder")
self.btn_out_dir = QPushButton("Browse...")
dir_row = QHBoxLayout()
dir_row.addWidget(self.ed_out_dir, 1)
dir_row.addWidget(self.btn_out_dir)
f.addRow("Save to:", self.rb_same_dir)
f.addRow("", self.rb_custom_dir)
f.addRow("", dir_row)
self.ed_suffix = QLineEdit()
self.ed_suffix.setPlaceholderText("e.g. _jellyfin (optional)")
f.addRow("Name suffix:", self.ed_suffix)
self.cb_exists = _combo([(v, k) for k, v in EXISTS_POLICIES.items()])
f.addRow("If file exists:", self.cb_exists)
self.chk_subs = QCheckBox("Keep subtitles (converted where needed)")
self.chk_subs.setToolTip(
"MP4/MOV: text subtitles become mov_text; image subtitles (PGS/VobSub) are dropped.\n"
"MKV: all subtitles are kept as-is. WebM: text becomes WebVTT. AVI: none.")
f.addRow("", self.chk_subs)
return box
def _build_video_group(self) -> QGroupBox:
self.video_box = box = QGroupBox("Video")
f = QFormLayout(box)
self.cb_video_mode = _combo([(v, k) for k, v in VIDEO_MODES.items()])
f.addRow("Mode:", self.cb_video_mode)
self.cb_vcodec = QComboBox()
f.addRow("Encoder:", self.cb_vcodec)
self.cb_resolution = _combo([(label, h) for label, h in RESOLUTIONS] + [("Custom...", CUSTOM_RESOLUTION)])
self.cb_resolution.setToolTip("Target size of the shorter side; aspect ratio is kept.")
f.addRow("Resolution:", self.cb_resolution)
self.sp_width = QSpinBox()
self.sp_height = QSpinBox()
for sp in (self.sp_width, self.sp_height):
sp.setRange(0, 16384)
sp.setSingleStep(2)
sp.setSpecialValueText("auto")
self.custom_row = QWidget()
cr = QHBoxLayout(self.custom_row)
cr.setContentsMargins(0, 0, 0, 0)
cr.addWidget(self.sp_width, 1)
cr.addWidget(QLabel("x"))
cr.addWidget(self.sp_height, 1)
f.addRow("Width x Height:", self.custom_row)
self.custom_row_label = f.labelForField(self.custom_row)
self.chk_no_upscale = QCheckBox("Never upscale smaller videos")
f.addRow("", self.chk_no_upscale)
self.cb_fps = _combo([(v, v) for v in FRAME_RATES])
f.addRow("Frame rate:", self.cb_fps)
self.sp_quality = QSpinBox()
self.sp_quality.setToolTip("CRF / CQ value. Lower = better quality and bigger files.")
f.addRow("Quality (lower=better):", self.sp_quality)
self.cb_speed = _combo([(s, i) for i, s in enumerate(SPEED_LEVELS)])
self.cb_speed.setToolTip("Encoder preset. Slower presets give smaller files at the same quality.")
f.addRow("Speed:", self.cb_speed)
return box
def _build_audio_group(self) -> QGroupBox:
self.audio_box = box = QGroupBox("Audio")
f = QFormLayout(box)
self.cb_audio_mode = _combo([(v, k) for k, v in AUDIO_MODES.items()])
f.addRow("Mode:", self.cb_audio_mode)
self.cb_acodec = QComboBox()
f.addRow("Encoder:", self.cb_acodec)
self.cb_abitrate = _combo([("Auto", 0)] + [(f"{b} kb/s", b) for b in AUDIO_BITRATES if b])
f.addRow("Bitrate:", self.cb_abitrate)
self.cb_channels = _combo(AUDIO_CHANNELS)
self.cb_channels.setToolTip("Up/down-mix the channel layout (e.g. 5.1 to stereo).")
f.addRow("Channels:", self.cb_channels)
self.cb_samplerate = _combo([("Original", 0)] + [(f"{r / 1000:g} kHz", r) for r in AUDIO_SAMPLE_RATES if r])
self.cb_samplerate.setToolTip("Resample audio to a different sample rate.")
f.addRow("Sample rate:", self.cb_samplerate)
note = QLabel("Auto copies tracks the container supports and re-encodes the rest "
"(e.g. DTS/TrueHD -> AAC for MP4).")
note.setWordWrap(True)
note.setStyleSheet("color: gray;")
f.addRow(note)
return box
def _connect(self) -> None:
self.act_add.triggered.connect(self._add_files_dialog)
self.act_add_dir.triggered.connect(self._add_folder_dialog)
self.act_remove.triggered.connect(self._remove_selected)
self.act_clear.triggered.connect(self._clear_queue)
self.table.selectionModel().currentRowChanged.connect(lambda *_: self._show_tracks())
self.table.customContextMenuRequested.connect(self._table_menu)
self.model.rowsInserted.connect(self._update_hint)
self.model.rowsRemoved.connect(self._update_hint)
self.track_list.itemChanged.connect(self._track_item_changed)
self.track_list.order_changed.connect(self._track_order_changed)
self.btn_all.clicked.connect(lambda: self._check_all_tracks(True))
self.btn_none.clicked.connect(lambda: self._check_all_tracks(False))
self.btn_apply_all.clicked.connect(self._apply_tracks_to_all)
self.cb_container.currentIndexChanged.connect(self._container_changed)
self.rb_same_dir.toggled.connect(self._update_enabled)
self.btn_out_dir.clicked.connect(self._browse_out_dir)
self.cb_video_mode.currentIndexChanged.connect(self._update_enabled)
self.cb_audio_mode.currentIndexChanged.connect(self._update_enabled)
self.cb_resolution.currentIndexChanged.connect(self._update_enabled)
self.cb_vcodec.currentIndexChanged.connect(self._video_encoder_changed)
self.cb_acodec.currentIndexChanged.connect(self._update_enabled)
self.btn_start.clicked.connect(self._start)
self.btn_stop.clicked.connect(self.runner.cancel)
self.btn_preview.clicked.connect(self._preview_command)
self.runner.job_changed.connect(self.model.refresh)
self.runner.log.connect(self._log)
self.runner.progress.connect(lambda v: self.overall.setValue(int(v * 1000)))
self.runner.finished.connect(self._batch_finished)
# ------------------------------------------------------------ settings
def _load_settings(self) -> None:
geo = self.settings.value("geometry")
if geo is not None:
self.restoreGeometry(geo)
try:
opts = Options.from_dict(json.loads(self.settings.value("options", "{}")))
except (TypeError, ValueError):
opts = Options()
self._apply_options(opts)
def _save_settings(self) -> None:
self.settings.setValue("geometry", self.saveGeometry())
self.settings.setValue("options", json.dumps(self.current_options().to_dict()))
def _apply_options(self, o: Options) -> None:
self._loading = True
try:
_select_data(self.cb_container, o.container)
self._populate_encoders()
self.rb_custom_dir.setChecked(bool(o.output_dir))
self.rb_same_dir.setChecked(not o.output_dir)
self.ed_out_dir.setText(o.output_dir)
self.ed_suffix.setText(o.suffix)
_select_data(self.cb_exists, o.if_exists)
self.chk_subs.setChecked(o.subtitles)
_select_data(self.cb_video_mode, o.video_mode)
_select_data(self.cb_vcodec, o.video_encoder)
_select_data(self.cb_resolution, o.resolution)
self.sp_width.setValue(o.custom_width)
self.sp_height.setValue(o.custom_height)
self.chk_no_upscale.setChecked(o.no_upscale)
_select_data(self.cb_fps, o.fps)
self._update_quality_range(reset=False)
self.sp_quality.setValue(o.quality)
_select_data(self.cb_speed, o.speed)
_select_data(self.cb_audio_mode, o.audio_mode)
_select_data(self.cb_acodec, o.audio_encoder)
_select_data(self.cb_abitrate, o.audio_bitrate)
_select_data(self.cb_channels, o.audio_channels)
_select_data(self.cb_samplerate, o.audio_sample_rate)
finally:
self._loading = False
self._update_enabled()
def current_options(self) -> Options:
return Options(
container=self.cb_container.currentData(),
output_dir=self.ed_out_dir.text().strip() if self.rb_custom_dir.isChecked() else "",
suffix=self.ed_suffix.text(),
if_exists=self.cb_exists.currentData(),
video_mode=self.cb_video_mode.currentData(),
video_encoder=self.cb_vcodec.currentData() or "libx264",
resolution=self.cb_resolution.currentData(),
custom_width=self.sp_width.value(),
custom_height=self.sp_height.value(),
no_upscale=self.chk_no_upscale.isChecked(),
fps=self.cb_fps.currentData(),
quality=self.sp_quality.value(),
speed=self.cb_speed.currentData(),
audio_mode=self.cb_audio_mode.currentData(),
audio_encoder=self.cb_acodec.currentData() or "aac",
audio_bitrate=self.cb_abitrate.currentData(),
audio_channels=self.cb_channels.currentData(),
audio_sample_rate=self.cb_samplerate.currentData(),
subtitles=self.chk_subs.isChecked(),
)
def _reset_options(self) -> None:
self._apply_options(Options())
# ------------------------------------------------------------- ffmpeg
def _init_tools(self) -> None:
self.tools = locate_tools(self.settings.value("ffmpeg_dir", "") or None)
if self.tools:
self.ffmpeg_label.setText(f"FFmpeg {self.tools.version}")
self.ffmpeg_label.setToolTip(f"{self.tools.ffmpeg}\n{self.tools.ffprobe}")
self._log(f"Using ffmpeg {self.tools.version}: {self.tools.ffmpeg}")
else:
self.ffmpeg_label.setText("FFmpeg not found - Settings > Set FFmpeg Location")
self.ffmpeg_label.setToolTip("")
self._log("FFmpeg was not found. Put ffmpeg and ffprobe next to this program, install them "
"on your PATH, or use Settings > Set FFmpeg Location.")
self._populate_encoders()
# Re-probe anything that was waiting for ffprobe.
for job in self.model.jobs:
if job.status in (Status.PROBING, Status.INVALID) and job.info is None:
self._probe(job)
def _choose_ffmpeg_dir(self) -> None:
d = QFileDialog.getExistingDirectory(self, "Folder containing ffmpeg and ffprobe",
self.settings.value("ffmpeg_dir", ""))
if not d:
return
found = locate_tools(d)
if found is None or Path(found.ffmpeg).parent != Path(d):
QMessageBox.warning(self, APP_NAME, "ffmpeg and ffprobe were not found in that folder.")
return
self.settings.setValue("ffmpeg_dir", d)
self._init_tools()
def _reset_ffmpeg_dir(self) -> None:
self.settings.remove("ffmpeg_dir")
self._init_tools()
# ---------------------------------------------------- dynamic controls
def _populate_encoders(self) -> None:
"""Fill encoder combos with encoders that are available and fit the container."""
container = CONTAINERS[self.cb_container.currentData()]
available = self.tools.encoders if self.tools else None
vcodec_changed = False
def fits(enc, accepted):
return (accepted is None or enc.codec in accepted) and (available is None or enc.name in available)
for cb, encoders, accepted in (
(self.cb_vcodec, VIDEO_ENCODERS, container.video_codecs),
(self.cb_acodec, AUDIO_ENCODERS, container.audio_codecs),
):
current = cb.currentData()
cb.blockSignals(True)
cb.clear()
for enc in encoders:
if fits(enc, accepted):
cb.addItem(enc.label, enc.name)
if not _select_data(cb, current):
cb.setCurrentIndex(0)
cb.blockSignals(False)
if cb is self.cb_vcodec:
vcodec_changed = cb.currentData() != current
self._update_quality_range(reset=vcodec_changed and not self._loading)
def _container_changed(self) -> None:
if self._loading:
return
self._populate_encoders()
self._update_enabled()
def _video_encoder_changed(self) -> None:
if not self._loading:
self._update_quality_range(reset=True)
self._update_enabled()
def _update_quality_range(self, reset: bool) -> None:
enc = VIDEO_ENCODERS_BY_NAME.get(self.cb_vcodec.currentData())
if enc is None:
return
self.sp_quality.setRange(*enc.quality_range)
if reset:
self.sp_quality.setValue(enc.quality_default)
def _update_enabled(self) -> None:
if self._loading:
return
running = self.runner.running
self.settings_box.setEnabled(not running)
self.ed_out_dir.setEnabled(self.rb_custom_dir.isChecked())
self.btn_out_dir.setEnabled(self.rb_custom_dir.isChecked())
container = CONTAINERS[self.cb_container.currentData()]
subs_possible = container.text_subtitles is not None or container.image_subtitles != frozenset()
self.chk_subs.setEnabled(subs_possible)
vencode = self.cb_video_mode.currentData() != "copy"
for w in (self.cb_vcodec, self.cb_resolution, self.chk_no_upscale, self.cb_fps, self.sp_quality):
w.setEnabled(vencode)
enc = VIDEO_ENCODERS_BY_NAME.get(self.cb_vcodec.currentData())
self.cb_speed.setEnabled(vencode and enc is not None and enc.speed_option is not None)
custom = self.cb_resolution.currentData() == CUSTOM_RESOLUTION
self.custom_row.setVisible(custom)
if self.custom_row_label:
self.custom_row_label.setVisible(custom)
self.custom_row.setEnabled(vencode)
amode = self.cb_audio_mode.currentData()
aencode = amode in ("auto", "encode")
for w in (self.cb_acodec, self.cb_channels, self.cb_samplerate):
w.setEnabled(aencode)
aenc = AUDIO_ENCODERS_BY_NAME.get(self.cb_acodec.currentData())
self.cb_abitrate.setEnabled(aencode and aenc is not None and not aenc.lossless)
self.track_list.setEnabled(amode != "strip")
for b in (self.btn_all, self.btn_none, self.btn_apply_all):
b.setEnabled(amode != "strip")
def _browse_out_dir(self) -> None:
d = QFileDialog.getExistingDirectory(self, "Output folder", self.ed_out_dir.text() or str(Path.home()))
if d:
self.ed_out_dir.setText(d)
# --------------------------------------------------------- adding files
def _add_files_dialog(self) -> None:
exts = " ".join(f"*{e}" for e in sorted(MEDIA_EXTENSIONS))
files, _ = QFileDialog.getOpenFileNames(
self, "Add media files", self.settings.value("last_dir", str(Path.home())),
f"Media files ({exts});;All files (*)")
if files:
self.settings.setValue("last_dir", str(Path(files[0]).parent))
self.add_paths([Path(f) for f in files])
def _add_folder_dialog(self) -> None:
d = QFileDialog.getExistingDirectory(self, "Add all media files in folder (including sub-folders)",
self.settings.value("last_dir", str(Path.home())))
if d:
self.settings.setValue("last_dir", d)
self.add_paths([Path(d)])
def add_paths(self, paths: list[Path]) -> None:
files: list[Path] = []
for p in paths:
if p.is_dir():
files += sorted((f for f in p.rglob("*") if f.is_file() and f.suffix.lower() in MEDIA_EXTENSIONS),
key=lambda f: str(f).lower())
elif p.is_file():
files.append(p)
existing = {os.path.normcase(str(j.path.resolve())) for j in self.model.jobs}
new_jobs = []
for f in files:
key = os.path.normcase(str(f.resolve()))
if key in existing or ".part." in f.name:
continue
existing.add(key)
new_jobs.append(Job(path=f.resolve()))
self.model.add(new_jobs)
for job in new_jobs:
self._probe(job)
if new_jobs and not self.table.currentIndex().isValid():
self.table.selectRow(0)
if paths and not new_jobs:
self.statusBar().showMessage("No new media files found", 4000)
def _probe(self, job: Job) -> None:
if not self.tools:
job.status, job.message = Status.INVALID, "ffprobe not found"
self.model.refresh(job)
return
job.status, job.message = Status.PROBING, ""
self.model.refresh(job)
self.pool.start(_ProbeTask(self.tools.ffprobe, job, self.probe_signals))
def _on_probed(self, job: Job, info: MediaInfo | None, error: str) -> None:
if self.model.row_of(job) < 0:
return # removed while probing
if info is None:
job.status, job.message = Status.INVALID, error
self._log(f"Cannot read {job.path.name}: {error}")
else:
job.info = info
job.audio_order = [s.index for s in info.audio]
job.audio_checked = set(job.audio_order)
job.status, job.message = Status.READY, ""
self.model.refresh(job)
if job is self._current_job():
self._show_tracks()
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event):
paths = [Path(u.toLocalFile()) for u in event.mimeData().urls() if u.isLocalFile()]
self.add_paths(paths)
def _update_hint(self, *_):
self.drop_hint.setVisible(not self.model.jobs)
# ------------------------------------------------------- queue actions
def _selected_rows(self) -> list[int]:
return sorted({i.row() for i in self.table.selectionModel().selectedRows()})
def _current_job(self) -> Job | None:
idx = self.table.currentIndex()
return self.model.job_at(idx.row()) if idx.isValid() else None
def _remove_selected(self) -> None:
rows = [r for r in self._selected_rows() if self.model.jobs[r].status != Status.RUNNING
and self.model.jobs[r].status != Status.QUEUED]
self.model.remove_rows(rows)
self._show_tracks()
def _clear_queue(self) -> None:
if self.runner.running:
QMessageBox.information(self, APP_NAME, "Stop the current batch before clearing the queue.")
return
self.model.remove_rows(list(range(len(self.model.jobs))))
self.overall.setValue(0)
self._show_tracks()
def _table_menu(self, pos) -> None:
job = self.model.job_at(self.table.indexAt(pos).row())
if job is None:
return
menu = QMenu(self)
target = job.output if job.output and job.output.exists() else job.path
menu.addAction("Open Containing Folder").triggered.connect(
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(target.parent))))
if job.status == Status.DONE:
menu.addAction("Convert Again").triggered.connect(lambda: self._requeue(job))
if job.status == Status.INVALID:
menu.addAction("Retry Reading File").triggered.connect(lambda: self._probe(job))
menu.addSeparator()
menu.addAction(self.act_remove)
menu.exec(self.table.viewport().mapToGlobal(pos))
def _requeue(self, job: Job) -> None:
job.status, job.progress, job.message = Status.READY, 0.0, ""
self.model.refresh(job)
# ---------------------------------------------------------- track panel
def _show_tracks(self) -> None:
job = self._current_job()
self._track_job = job
self.track_list.blockSignals(True)
self.track_list.clear()
if job is None or job.info is None:
self.tracks_title.setText("Select a file in the queue" if job is None
else f"{job.path.name}\n({job.status.value})")
self.stream_info.setText("")
self.track_list.blockSignals(False)
return
info = job.info
self.tracks_title.setText(f"<b>{job.path.name}</b>")
by_index = {s.index: s for s in info.audio}
for n, idx in enumerate(job.audio_order):
s = by_index[idx]
pos = info.audio.index(s) + 1
text = f"Track {pos}: {(s.language or 'und').upper():4} {s.describe()}"
if s.title:
text += f' "{s.title}"'
if s.default:
text += " (default)"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, idx)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsDragEnabled)
item.setCheckState(Qt.Checked if idx in job.audio_checked else Qt.Unchecked)
self.track_list.addItem(item)
if not info.audio:
self.track_list.addItem(QListWidgetItem("(no audio tracks)"))
v = info.video
text_subs = sum(1 for s in info.subtitles if s.codec in TEXT_SUBTITLE_CODECS)
image_subs = len(info.subtitles) - text_subs
parts = [f"Video: {v.describe()}" if v else "Video: none"]
if info.subtitles:
parts.append(f"Subtitles: {text_subs} text, {image_subs} image")
self.stream_info.setText(" | ".join(parts))
self.track_list.blockSignals(False)
def _sync_tracks_from_list(self) -> None:
job = self._track_job
if job is None or job.info is None:
return
order, checked = [], set()
for i in range(self.track_list.count()):
item = self.track_list.item(i)
idx = item.data(Qt.UserRole)
if idx is None:
continue
order.append(idx)
if item.checkState() == Qt.Checked:
checked.add(idx)
job.audio_order, job.audio_checked = order, checked
self.model.refresh(job)
def _track_item_changed(self, _item) -> None:
self._sync_tracks_from_list()
def _track_order_changed(self) -> None:
self._sync_tracks_from_list()
def _check_all_tracks(self, checked: bool) -> None:
self.track_list.blockSignals(True)
for i in range(self.track_list.count()):
item = self.track_list.item(i)
if item.data(Qt.UserRole) is not None:
item.setCheckState(Qt.Checked if checked else Qt.Unchecked)
self.track_list.blockSignals(False)
self._sync_tracks_from_list()
def _apply_tracks_to_all(self) -> None:
ref = self._track_job
if ref is None or ref.info is None:
return
changed = 0
for job in self.model.jobs:
if job is ref or job.info is None or job.status in (Status.RUNNING, Status.QUEUED):
continue
if not ref.selected_audio:
job.audio_checked = set()
else:
job.set_audio_selection(match_audio_selection(ref.info.audio, ref.selected_audio, job.info.audio))
self.model.refresh(job)
changed += 1
self.statusBar().showMessage(f"Track selection applied to {changed} other file(s)", 5000)
# ------------------------------------------------------------- running
def _check_ready(self, opts: Options) -> bool:
if not self.tools:
QMessageBox.warning(self, APP_NAME, "FFmpeg was not found. Use Settings > Set FFmpeg Location.")
return False
if self.rb_custom_dir.isChecked():
if not opts.output_dir:
QMessageBox.warning(self, APP_NAME, "Choose an output folder, or select 'Same folder as source'.")
return False
try:
Path(opts.output_dir).mkdir(parents=True, exist_ok=True)
except OSError as e:
QMessageBox.warning(self, APP_NAME, f"Cannot use output folder:\n{e}")
return False
if opts.video_mode != "copy" and opts.resolution == CUSTOM_RESOLUTION \
and not (opts.custom_width or opts.custom_height):
QMessageBox.warning(self, APP_NAME, "Enter a custom width and/or height.")
return False
return True
def _start(self) -> None:
if self.runner.running:
return
opts = self.current_options()
if not self._check_ready(opts):
return
jobs = [j for j in self.model.jobs if j.status in RUNNABLE and j.info is not None]
if not jobs:
busy = any(j.status == Status.PROBING for j in self.model.jobs)
QMessageBox.information(self, APP_NAME, "Files are still being read - try again in a moment."
if busy else "Nothing to convert. Add some files first.")
return
no_audio = [j for j in jobs if j.info.audio and not j.selected_audio and opts.audio_mode != "strip"]
if no_audio:
names = "\n".join(j.path.name for j in no_audio[:10])
if QMessageBox.question(self, APP_NAME, f"These files have no audio track ticked and will be "
f"converted without audio:\n\n{names}\n\nContinue?") != QMessageBox.Yes:
return
self._save_settings()
self.overall.setValue(0)
self._set_running(True)
self.runner.start(jobs, opts, self.tools)
def _set_running(self, running: bool) -> None:
self.btn_start.setEnabled(not running)
self.btn_stop.setEnabled(running)
self.act_clear.setEnabled(not running)
self.settings_box.setEnabled(not running)
if not running:
self._update_enabled()
def _batch_finished(self, counts: dict) -> None:
self._set_running(False)
summary = ", ".join(f"{n} {k.lower()}" for k, n in counts.items())
self._log(f"Batch finished: {summary or 'nothing to do'}")
self.statusBar().showMessage(f"Finished: {summary}", 10000)
if counts.get(Status.FAILED.value) or counts.get(Status.CANCELLED.value):
return
self.overall.setValue(1000)
def _preview_command(self) -> None:
job = self._current_job()
if job is None or job.info is None:
self._log("Select a file that has been read successfully to preview its command.")
return
if not self.tools:
self._log("FFmpeg not found.")
return
opts = self.current_options()
out = plan_output_path(job.path, opts, set())
if out is None:
self._log(f"{job.path.name}: output already exists and would be skipped.")
return
built = build_command(self.tools.ffmpeg, job.info, opts, job.selected_audio, out)
# Drop the progress plumbing so the command can be pasted into a terminal.
args = list(built.args)
for flag in ("-progress", "-nostats", "-nostdin"):
if flag in args:
i = args.index(flag)
del args[i:i + (2 if flag == "-progress" else 1)]
self._log("")
self._log(f"--- Command preview for {job.path.name}")
for note in built.notes:
self._log(f" {note}")
self._log(format_command(args))
def _log(self, text: str) -> None:
self.log_view.appendPlainText(text)
def _about(self) -> None:
ff = f"FFmpeg {self.tools.version}\n{self.tools.ffmpeg}" if self.tools else "FFmpeg not found"
QMessageBox.about(self, APP_NAME, f"{APP_NAME} {__version__}\n\nA simple front-end for ffmpeg.\n\n{ff}")
def closeEvent(self, event):
if self.runner.running:
if QMessageBox.question(self, APP_NAME, "A conversion is running. Stop it and quit?") != QMessageBox.Yes:
event.ignore()
return
self.runner.kill_now()
self._save_settings()
self.pool.clear()
event.accept()