Version 1 OneShot

This commit is contained in:
2026-10-09 18:58:50 +00:00
parent 514c13d184
commit bf0d661eed
27 changed files with 3039 additions and 0 deletions

0
ffwrap/gui/__init__.py Normal file
View File

881
ffwrap/gui/main_window.py Normal file
View File

@@ -0,0 +1,881 @@
"""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()

160
ffwrap/gui/queue_model.py Normal file
View File

@@ -0,0 +1,160 @@
"""The file queue: Job objects plus a Qt table model / progress delegate."""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from pathlib import Path
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtWidgets import QApplication, QStyle, QStyledItemDelegate, QStyleOptionProgressBar
from ..probe import MediaInfo
class Status(enum.Enum):
PROBING = "Reading..."
READY = "Ready"
INVALID = "Unreadable"
QUEUED = "Queued"
RUNNING = "Converting"
DONE = "Done"
FAILED = "Failed"
SKIPPED = "Skipped"
CANCELLED = "Cancelled"
# Jobs in these states are (re)processed when Start is pressed.
RUNNABLE = {Status.READY, Status.FAILED, Status.CANCELLED, Status.SKIPPED}
@dataclass(eq=False)
class Job:
path: Path
info: MediaInfo | None = None
status: Status = Status.PROBING
progress: float = 0.0
message: str = ""
audio_order: list[int] = field(default_factory=list)
audio_checked: set[int] = field(default_factory=set)
output: Path | None = None
@property
def selected_audio(self) -> list[int]:
return [i for i in self.audio_order if i in self.audio_checked]
def set_audio_selection(self, indices: list[int]) -> None:
"""Check ``indices`` and move them (in order) to the top of the list."""
rest = [i for i in self.audio_order if i not in indices]
self.audio_order = list(indices) + rest
self.audio_checked = set(indices)
def fmt_duration(seconds: float | None) -> str:
if not seconds:
return ""
s = int(round(seconds))
return f"{s // 3600}:{s % 3600 // 60:02d}:{s % 60:02d}"
COLUMNS = ["File", "Duration", "Video", "Audio", "Subs", "Status", "Progress"]
COL_STATUS, COL_PROGRESS = 5, 6
class QueueModel(QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
self.jobs: list[Job] = []
# -- Qt model API -------------------------------------------------------
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.jobs)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(COLUMNS)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return COLUMNS[section]
return None
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
job = self.jobs[index.row()]
col = index.column()
info = job.info
if role == Qt.DisplayRole:
if col == 0:
return job.path.name
if col == 1:
return fmt_duration(info.duration) if info else ""
if col == 2:
return info.video.describe() if info and info.video else ("none" if info else "")
if col == 3:
if not info:
return ""
return f"{len(job.audio_checked)} of {len(info.audio)}" if info.audio else "none"
if col == 4:
return str(len(info.subtitles)) if info else ""
if col == COL_STATUS:
return job.status.value + (f": {job.message}" if job.message else "")
if col == COL_PROGRESS:
return None if job.status in (Status.PROBING, Status.INVALID) else job.progress
elif role == Qt.ToolTipRole:
if col == 0:
return str(job.path)
if col == COL_STATUS:
tip = job.status.value + (f"\n{job.message}" if job.message else "")
if job.output:
tip += f"\nOutput: {job.output}"
return tip
elif role == Qt.UserRole:
return job
return None
# -- helpers ------------------------------------------------------------
def add(self, jobs: list[Job]) -> None:
if not jobs:
return
start = len(self.jobs)
self.beginInsertRows(QModelIndex(), start, start + len(jobs) - 1)
self.jobs.extend(jobs)
self.endInsertRows()
def remove_rows(self, rows: list[int]) -> None:
for row in sorted(set(rows), reverse=True):
self.beginRemoveRows(QModelIndex(), row, row)
del self.jobs[row]
self.endRemoveRows()
def row_of(self, job: Job) -> int:
for i, j in enumerate(self.jobs):
if j is job:
return i
return -1
def refresh(self, job: Job) -> None:
row = self.row_of(job)
if row >= 0:
self.dataChanged.emit(self.index(row, 0), self.index(row, len(COLUMNS) - 1))
def job_at(self, row: int) -> Job | None:
return self.jobs[row] if 0 <= row < len(self.jobs) else None
class ProgressDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
value = index.data(Qt.DisplayRole)
if value is None:
super().paint(painter, option, index)
return
bar = QStyleOptionProgressBar()
bar.rect = option.rect.adjusted(2, 3, -2, -3)
bar.minimum, bar.maximum = 0, 1000
bar.progress = int(value * 1000)
bar.text = f"{value * 100:.0f}%"
bar.textVisible = True
bar.state = option.state
style = option.widget.style() if option.widget else QApplication.style()
style.drawControl(QStyle.CE_ProgressBar, bar, painter, option.widget)

219
ffwrap/gui/runner.py Normal file
View File

@@ -0,0 +1,219 @@
"""Runs ffmpeg for each queued job, one at a time, via QProcess."""
from __future__ import annotations
import os
from pathlib import Path
from PySide6.QtCore import QObject, QProcess, Signal
from ..command import Options, build_command, format_command, plan_output_path, temp_output_path
from ..tools import FFmpegTools
from .queue_model import Job, Status
def _remove_quietly(path: Path | None) -> None:
if path is None:
return
try:
path.unlink()
except OSError:
pass
class BatchRunner(QObject):
job_changed = Signal(object) # Job
log = Signal(str)
progress = Signal(float) # overall 0..1
finished = Signal(dict) # counts per status
def __init__(self, parent=None):
super().__init__(parent)
self._proc: QProcess | None = None
self._jobs: list[Job] = []
self._pos = 0
self._opts = Options()
self._tools: FFmpegTools | None = None
self._taken: set[str] = set()
self._current: Job | None = None
self._temp: Path | None = None
self._stdout_buf = b""
self._stderr_buf = b""
self._stderr_tail: list[str] = []
self._cancelled = False
@property
def running(self) -> bool:
return self._current is not None
# -- public API ---------------------------------------------------------
def start(self, jobs: list[Job], opts: Options, tools: FFmpegTools) -> None:
self._jobs, self._opts, self._tools = jobs, opts, tools
self._pos, self._taken, self._cancelled = 0, set(), False
for job in jobs:
job.status, job.progress, job.message, job.output = Status.QUEUED, 0.0, "", None
self.job_changed.emit(job)
self._next()
def cancel(self) -> None:
self._cancelled = True
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
self.log.emit("Stopping...")
self._proc.kill()
elif self._current is None:
self._finish_batch()
def kill_now(self) -> None:
"""Used on application exit: stop ffmpeg and remove the partial file."""
self._cancelled = True
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
self._proc.kill()
self._proc.waitForFinished(3000)
_remove_quietly(self._temp)
# -- internals ----------------------------------------------------------
def _emit_overall(self, current_fraction: float = 0.0) -> None:
if self._jobs:
self.progress.emit(min(1.0, (self._pos + current_fraction) / len(self._jobs)))
def _next(self) -> None:
while self._pos < len(self._jobs):
if self._cancelled:
break
job = self._jobs[self._pos]
if self._launch(job):
return
self._pos += 1
self._emit_overall()
self._finish_batch()
def _launch(self, job: Job) -> bool:
"""Start ffmpeg for ``job``. Returns False if the job finished immediately."""
opts = self._opts
out = plan_output_path(job.path, opts, self._taken)
if out is None:
job.status, job.message = Status.SKIPPED, "output already exists"
self.log.emit(f"Skipping {job.path.name}: output already exists")
self.job_changed.emit(job)
return False
try:
out.parent.mkdir(parents=True, exist_ok=True)
except OSError as e:
job.status, job.message = Status.FAILED, f"cannot create output folder: {e}"
self.log.emit(f"ERROR {job.path.name}: {job.message}")
self.job_changed.emit(job)
return False
self._temp = temp_output_path(out)
built = build_command(self._tools.ffmpeg, job.info, opts, job.selected_audio, self._temp)
job.output = out
self._taken.add(os.path.normcase(os.path.abspath(out)))
self.log.emit("")
self.log.emit(f"=== [{self._pos + 1}/{len(self._jobs)}] {job.path}")
self.log.emit(f" -> {out}")
for note in built.notes:
self.log.emit(f" {note}")
self.log.emit(format_command(built.args))
self._current = job
self._stdout_buf = self._stderr_buf = b""
self._stderr_tail = []
job.status, job.progress, job.message = Status.RUNNING, 0.0, ""
self.job_changed.emit(job)
proc = QProcess(self)
proc.readyReadStandardOutput.connect(self._on_stdout)
proc.readyReadStandardError.connect(self._on_stderr)
proc.finished.connect(self._on_finished)
proc.errorOccurred.connect(self._on_error)
self._proc = proc
proc.start(built.args[0], built.args[1:])
return True
def _on_stdout(self) -> None:
self._stdout_buf += bytes(self._proc.readAllStandardOutput())
*lines, self._stdout_buf = self._stdout_buf.split(b"\n")
job = self._current
if job is None:
return
duration = job.info.duration if job.info else None
speed = None
updated = False
for raw in lines:
key, _, value = raw.decode(errors="replace").strip().partition("=")
if key == "out_time_us" and duration:
try:
job.progress = max(0.0, min(1.0, int(value) / 1e6 / duration))
updated = True
except ValueError:
pass
elif key == "speed" and value not in ("N/A", ""):
speed = value
if updated:
job.message = f"{job.progress * 100:.0f}%" + (f" @ {speed}" if speed else "")
self.job_changed.emit(job)
self._emit_overall(job.progress)
def _on_stderr(self) -> None:
self._stderr_buf += bytes(self._proc.readAllStandardError())
*lines, self._stderr_buf = self._stderr_buf.split(b"\n")
for raw in lines:
line = raw.decode(errors="replace").rstrip()
if line:
self._stderr_tail = (self._stderr_tail + [line])[-5:]
self.log.emit(f" ffmpeg: {line}")
def _on_error(self, error) -> None:
if error == QProcess.FailedToStart:
# finished() is not emitted in this case.
self._complete(ok=False, reason=f"could not start ffmpeg ({self._proc.errorString()})")
def _on_finished(self, exit_code: int, exit_status) -> None:
self._on_stderr()
ok = exit_status == QProcess.NormalExit and exit_code == 0
reason = "" if ok else (self._stderr_tail[-1] if self._stderr_tail else f"ffmpeg exited with code {exit_code}")
self._complete(ok, reason)
def _complete(self, ok: bool, reason: str) -> None:
job = self._current
if job is None:
return
self._current = None
if self._proc is not None:
self._proc.deleteLater()
self._proc = None
if self._cancelled:
job.status, job.message, job.progress = Status.CANCELLED, "", 0.0
_remove_quietly(self._temp)
self.log.emit(f"Cancelled {job.path.name}")
elif ok:
try:
os.replace(self._temp, job.output)
job.status, job.progress, job.message = Status.DONE, 1.0, ""
self.log.emit(f"Done: {job.output}")
except OSError as e:
job.status, job.message = Status.FAILED, f"could not rename output: {e}"
_remove_quietly(self._temp)
self.log.emit(f"ERROR {job.path.name}: {job.message}")
else:
job.status, job.message = Status.FAILED, reason
_remove_quietly(self._temp)
self.log.emit(f"FAILED {job.path.name}: {reason}")
self._temp = None
self.job_changed.emit(job)
self._pos += 1
self._emit_overall()
self._next()
def _finish_batch(self) -> None:
counts: dict[str, int] = {}
for job in self._jobs:
if job.status == Status.QUEUED:
job.status = Status.READY
self.job_changed.emit(job)
counts[job.status.value] = counts.get(job.status.value, 0) + 1
self._jobs = []
self.finished.emit(counts)