882 lines
38 KiB
Python
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()
|