"""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"{job.path.name}") 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()