diff --git a/README.md b/README.md index fbea826..eb72293 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ FULL Vibecoded App for Proof of Concept - no human code, only human prompts and Interface graphique pour préparer des fichiers vidéo, remuxer sans perte, réencoder avec `ffmpeg`, et fusionner des métadonnées Dolby Vision / HDR10+. -Cette documentation correspond à **Mediarecode v1.4.0**. +Cette documentation correspond à **Mediarecode v1.4.x**. ## Sommaire @@ -149,6 +149,7 @@ Les artefacts sont toujours déposés dans `dist/releases/`. | Cible | Commande | Artefact produit | |-------|----------|-----------------| | AppImage Linux | `python3 package.py --allinc` | `dist/releases/Mediarecode-x86_64.AppImage` + `dist/releases/Mediarecode-x86_64.AppImage.zsync` | +| Package macOS natif | `python3 package.py --dmg` | `Mediarecode.app` + `dist/releases/Mediarecode-.dmg` | | Package Windows (natif + MSIX) | `py package.py --msix` | `dist/releases/Mediarecode.msix` | | Soumission Microsoft Store | `py package.py --msix --msixupload --store-config packaging/msix_store.json` | `dist/releases/Mediarecode.msixupload` | | Installateur Windows cross (depuis Linux) | `python3 package.py --windows` | `dist/releases/Mediarecode-Setup.exe` via Wine + NSIS | @@ -178,6 +179,7 @@ Options utiles de `package.py` : | Option | Effet | |--------|-------| | `--allinc` | intègre toutes les dépendances dans l'AppImage et génère le `.zsync` (Linux) | +| `--dmg` | sur macOS natif, produit un `.dmg` distribuable depuis `Mediarecode.app` | | `--msix` | produit un package MSIX sur Windows natif, signé si les variables `MEDIARECODE_MSIX_*` sont définies | | `--msixupload` | génère un `.msixupload` pour Partner Center à partir du package MSIX | | `--store-config PATH` | charge les métadonnées Store/MSIX depuis un JSON dédié | @@ -206,6 +208,10 @@ Le workflow unifié permet de : - inspecter vidéo, audio, sous-titres, chapitres, pièces jointes et tags MKV - activer, exclure et réordonner les pistes - éditer langue, titre et flags de chaque piste +- créer des variantes audio indépendantes depuis l'onglet Encodage, sans modifier la piste d'origine +- réordonner ou supprimer ces variantes sans perdre leur lien avec le workflow +- visualiser dans le panel remux le codec et le bitrate cibles lorsqu'une piste audio sera réencodée +- extraire une piste de sous-titre depuis le menu contextuel du tableau des pistes - définir le titre du conteneur, les balises globales, les chapitres et les pièces jointes - ouvrir une fenêtre de recherche **TMDB** depuis le panneau balises pour rechercher film/série (préremplissage auto depuis titre/nom de fichier) - détecter automatiquement les motifs de série (`SxxExx`, `x`) pour préremplir saison/épisode et positionner la recherche sur **Séries** si pertinent @@ -232,9 +238,11 @@ Backend remux `ffmpeg` (par défaut) : - sortie limitée à `MKV` - écrit la langue de piste en BCP47 sur `language` (ex. `fr-FR`) et purge le champ legacy `language-ietf` pour éviter les doublons incohérents +- corrige au besoin les tags de langue Matroska en post-action, sans repasser par MKVToolNix - permet la recopie ou l'édition des chapitres - permet d'écrire les tags globaux choisis - permet de recopier les pièces jointes source sélectionnées et d'ajouter des fichiers externes (cover incluse) +- peut générer un fichier `.nfo` MediaInfo à côté du MKV final après un remux ou un encodage réussi - télécharge la cover TMDB différée juste avant l'exécution (dans le dossier temporaire du process), puis nettoie ce dossier en fin de run - purge explicitement les balises techniques source `ENCODER` et `CREATION_TIME` avant écriture des métadonnées de sortie - n'écrit plus le tag libre `MUXING_APPLICATION` via `-metadata` @@ -291,7 +299,7 @@ Le panneau **Paramètres** est un éditeur complet de `config.ini` intégré à - **Remux** : backend `ffmpeg` (nominal) - **Outils externes** : chemins explicites pour chaque outil (`ffmpeg`, `ffprobe`, `mediainfo`, `dovi_tool`, `hdr10plus_tool`, etc.) - **Encodage** : profil DoVi, compat-id, buffer RAM -- **Métadonnées** : auth TMDB via clé API v3 (`tmdb_api_key`) ou token Bearer v4 (`tmdb_bearer_token`) +- **Métadonnées** : auth TMDB via clé API v3 (`tmdb_api_key`) ou token Bearer v4 (`tmdb_bearer_token`), génération optionnelle de `.nfo` (`generate_nfo`) Les changements sont appliqués section par section ou en une seule fois via le bouton **Sauvegarder toute la configuration**. Un rechargement depuis `config.ini` est possible sans redémarrer l'application. @@ -338,6 +346,7 @@ Sous Windows, `setup.py` et le démarrage de l'application peuvent auto-détecte | `backend` (section `[remux]`) | `ffmpeg` | backend de remux (`ffmpeg`) | | `tmdb_api_key` | vide | clé API TMDB v3 utilisée par la recherche IMDb/TMDB | | `tmdb_bearer_token` | vide | token Bearer TMDB v4 (utilisé si `tmdb_api_key` est vide, ou via `MEDIARECODE_TMDB_BEARER_TOKEN`) | +| `generate_nfo` | `true` | génère un fichier `.nfo` MediaInfo à côté du MKV final après workflow réussi | | `ram_buffer_enabled` | `true` | autorise l'usage de `/dev/shm` pour les HEVC intermédiaires si disponible | | `ram_buffer_threshold_pct` | `15` | pourcentage minimal de RAM libre à conserver pour activer ce buffer | @@ -392,6 +401,7 @@ startup_panel = container [metadata] tmdb_api_key = tmdb_bearer_token = +generate_nfo = true ``` ## Workflows @@ -534,11 +544,11 @@ flowchart TD |-------|----------------| | `ffprobe` | analyse des flux, chapitres et métadonnées | | `mediainfo` | frame count et informations HDR fines | -| `ffmpeg` | encodage, remux, copie de flux, écriture metadata/chapitres/tags, patch binaire MuxingApp | +| `ffmpeg` | encodage, remux, copie de flux, extraction de sous-titres, écriture metadata/chapitres/tags, patch binaire MuxingApp | | `dovi_tool` | extraction, injection et vérification Dolby Vision | | `hdr10plus_tool` | extraction et injection HDR10+ | | `nvidia-smi` | fallback de détection NVENC sous Linux | --- -*Mediarecode v1.3* +*Mediarecode v1.4.x* diff --git a/core/extractor.py b/core/extractor.py new file mode 100644 index 0000000..b2b6878 --- /dev/null +++ b/core/extractor.py @@ -0,0 +1,99 @@ +""" +core/extractor.py — Extraction de pistes individuelles vers des fichiers autonomes. + +Centralise la logique d'extraction. Pour l'instant : sous-titres uniquement. +Extensible plus tard à l'audio et la vidéo via des méthodes dédiées. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from core.subtitle_codec import CONVERT_TO_SRT, MKV_COPY_SAFE, UNSUPPORTED + + +@dataclass(frozen=True) +class ExtractPlan: + """Plan d'extraction d'une piste vers un fichier autonome.""" + + codec_arg: str # argument -c:s / -c:a / -c:v + extension: str # extension avec point (".srt") + format_label: str # libellé affiché à l'utilisateur + file_filter: str # filtre QFileDialog + extra_args: tuple[str, ...] = field(default_factory=tuple) + + +class TrackExtractor: + """Construit les plans et commandes ffmpeg d'extraction de pistes.""" + + _SUBTITLE_PLANS: dict[str, ExtractPlan] = { + "subrip": ExtractPlan("copy", ".srt", "SubRip", "SubRip (*.srt)"), + "srt": ExtractPlan("copy", ".srt", "SubRip", "SubRip (*.srt)"), + "ass": ExtractPlan("copy", ".ass", "ASS", "SSA/ASS (*.ass *.ssa)"), + "ssa": ExtractPlan("copy", ".ssa", "SSA", "SSA/ASS (*.ass *.ssa)"), + "webvtt": ExtractPlan("copy", ".vtt", "WebVTT", "WebVTT (*.vtt)"), + "hdmv_pgs_subtitle": ExtractPlan("copy", ".sup", "PGS", "PGS (*.sup)"), + "dvd_subtitle": ExtractPlan("copy", ".idx", "VobSub", "VobSub (*.idx *.sub)"), + "hdmv_text_subtitle": ExtractPlan("srt", ".srt", "SubRip", "SubRip (*.srt)"), + } + + _SRT_PLAN = ExtractPlan("srt", ".srt", "SubRip", "SubRip (*.srt)") + + @classmethod + def plan_subtitle(cls, codec: str) -> ExtractPlan: + """Retourne le plan d'extraction pour un codec de sous-titre. + + Lève ``ValueError`` pour les codecs explicitement non supportés + (DVB, teletext, etc.) qui requièrent un OCR ou un outil externe. + """ + c = (codec or "").lower().strip() + + if c in UNSUPPORTED: + raise ValueError( + f"Codec de sous-titre '{codec}' non extractible nativement " + "(nécessite OCR ou outil externe)." + ) + if c in cls._SUBTITLE_PLANS: + return cls._SUBTITLE_PLANS[c] + if c in CONVERT_TO_SRT: + return cls._SRT_PLAN + if c in MKV_COPY_SAFE: + return ExtractPlan("copy", ".mks", f"{codec} (brut)", "Tous les fichiers (*)") + # Inconnu : on tente copy avec extension générique + return ExtractPlan("copy", ".bin", f"{codec} (brut)", "Tous les fichiers (*)") + + @classmethod + def build_subtitle_command( + cls, + ffmpeg_bin: str, + source: Path, + stream_index: int, + codec: str, + output: Path, + ) -> list[str]: + """Construit la commande ffmpeg pour extraire un sous-titre.""" + plan = cls.plan_subtitle(codec) + return [ + ffmpeg_bin, + "-y", + "-i", str(source), + "-map", f"0:{stream_index}", + "-c:s", plan.codec_arg, + *plan.extra_args, + str(output), + ] + + @staticmethod + def default_output_name( + source_stem: str, + language: str, + stream_index: int, + extension: str, + ) -> str: + """Nom de fichier par défaut pour la boîte de dialogue : stem.lang.tN.ext""" + lang = (language or "und").strip() or "und" + return f"{source_stem}.{lang}.t{stream_index}{extension}" + + +__all__ = ["ExtractPlan", "TrackExtractor"] diff --git a/core/workflows/encode/models.py b/core/workflows/encode/models.py index 2fe8e2a..6d3b9ea 100644 --- a/core/workflows/encode/models.py +++ b/core/workflows/encode/models.py @@ -125,6 +125,7 @@ class AudioTrackSettings: input_channels: int | None = None # nb de canaux de la piste source (ffprobe) input_channel_layout: str | None = None # layout source (ex: "7.1", "5.1(side)") source_path: Path | None = None # None = même fichier que la vidéo (config.source) + track_entry_id: str | None = None # GUID de l'objet TrackEntry synchronisé entre panels @dataclass diff --git a/core/workflows/remux.py b/core/workflows/remux.py index ce413f3..f999e5a 100644 --- a/core/workflows/remux.py +++ b/core/workflows/remux.py @@ -169,10 +169,13 @@ def write_mediainfo_nfo( mediainfo_bin: str = "mediainfo", ) -> None: """Génère un fichier .nfo (même nom que le MKV) avec la sortie brute de mediainfo.""" + output_path = Path(output_path).expanduser() + if not output_path.is_absolute(): + output_path = output_path.resolve() nfo_path = output_path.with_suffix(".nfo") try: result = subprocess.run( - [mediainfo_bin, _cli_path(output_path)], + [mediainfo_bin, str(output_path)], capture_output=True, **subprocess_text_kwargs(), ) @@ -313,18 +316,27 @@ def validate(self, config: RemuxConfig) -> list[str]: if not config.track_order: errors.append("Aucune piste sélectionnée.") - track_map = { - (src.file_index, t.mkv_tid): t + track_map_by_id = { + (src.file_index, t.entry_id): t for src in config.sources for t in src.tracks } + track_map_by_pair: dict[tuple[int, int], list[TrackEntry]] = {} + for src in config.sources: + for track in src.tracks: + track_map_by_pair.setdefault((src.file_index, track.mkv_tid), []).append(track) valid_file_indexes = {src.file_index for src in config.sources} - for file_index, mkv_tid in config.track_order: + for order_item in config.track_order: + file_index, mkv_tid, entry_id = self._track_order_parts(order_item) if file_index not in valid_file_indexes: errors.append(f"track_order référence une source inconnue : file_index={file_index}") continue - track = track_map.get((file_index, mkv_tid)) + track = ( + track_map_by_id.get((file_index, entry_id)) + if entry_id + else next(iter(track_map_by_pair.get((file_index, mkv_tid), [])), None) + ) if track is None: errors.append( "track_order référence une piste introuvable : " @@ -894,21 +906,32 @@ def _resolve_mapped_tracks(self, config: RemuxConfig) -> list[_MappedTrack]: for i, src in enumerate(config.sources) } - track_map = { - (src.file_index, t.mkv_tid): (src.path, t) + track_map_by_id = { + (src.file_index, t.entry_id): (src.path, t) for src in config.sources for t in src.tracks } + track_map_by_pair: dict[tuple[int, int], list[tuple[Path, TrackEntry]]] = {} + for src in config.sources: + for track in src.tracks: + track_map_by_pair.setdefault((src.file_index, track.mkv_tid), []).append( + (src.path, track) + ) type_counters: dict[str, int] = {"video": 0, "audio": 0, "subtitle": 0} mapped: list[_MappedTrack] = [] - for file_index, mkv_tid in config.track_order: + for order_item in config.track_order: + file_index, mkv_tid, entry_id = self._track_order_parts(order_item) input_idx = file_index_to_input_idx.get(file_index) if input_idx is None: raise RemuxError(f"Source inconnue dans track_order : file_index={file_index}") - found = track_map.get((file_index, mkv_tid)) + found = ( + track_map_by_id.get((file_index, entry_id)) + if entry_id + else next(iter(track_map_by_pair.get((file_index, mkv_tid), [])), None) + ) if found is None: raise RemuxError( "Piste introuvable dans track_order : " @@ -934,6 +957,15 @@ def _resolve_mapped_tracks(self, config: RemuxConfig) -> list[_MappedTrack]: return mapped + @staticmethod + def _track_order_parts( + item: tuple[int, int] | tuple[int, int, str], + ) -> tuple[int, int, str | None]: + if len(item) >= 3: + file_index, mkv_tid, entry_id = item[0], item[1], str(item[2] or "").strip() + return int(file_index), int(mkv_tid), entry_id or None + return int(item[0]), int(item[1]), None + @staticmethod def _chapter_map_value(config: RemuxConfig, chapter_input_index: int | None) -> str: if config.chapter_overrides is not None: diff --git a/core/workflows/remux_models.py b/core/workflows/remux_models.py index 147badf..d2a9344 100644 --- a/core/workflows/remux_models.py +++ b/core/workflows/remux_models.py @@ -11,8 +11,9 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path +from uuid import uuid4 from core.inspector import AttachmentInfo, FileInfo, HDRType @@ -41,9 +42,14 @@ class TrackEntry: enabled: bool = True file_id: str = "" # UUID du SourceFile parent (usage UI uniquement) time_shift_ms: int = 0 # décalage signé appliqué à la piste (ms) + entry_id: str = field(default_factory=lambda: uuid4().hex) + source_entry_id: str = field(default="", repr=False) + is_new: bool = field(default=False, repr=False) orig_language: str = field(default="", repr=False) orig_title: str = field(default="", repr=False) + orig_codec: str = field(default="", repr=False) + orig_display_info: str = field(default="", repr=False) # Flags MKV éditables (transmis à FFmpeg si modifiés) flag_enabled: bool = field(default=True, repr=False) # --track-enabled-flag @@ -62,6 +68,12 @@ class TrackEntry: orig_flag_original: bool = field(default=False, repr=False) orig_flag_commentary: bool = field(default=False, repr=False) + def __post_init__(self) -> None: + if not self.orig_codec: + self.orig_codec = self.codec + if not self.orig_display_info: + self.orig_display_info = self.display_info + @property def flags_label(self) -> str: """Résumé court des flags actifs (pour la colonne Info du tableau).""" @@ -85,7 +97,16 @@ def flags_label(self) -> str: @property def full_info_label(self) -> str: """Info technique + flags actifs (affichage colonne Info).""" - parts = [p for p in (self.display_info, self.flags_label, self.time_shift_label) if p] + parts = [ + p + for p in ( + "NEW" if self.is_new else "", + self.display_info, + self.flags_label, + self.time_shift_label, + ) + if p + ] return " · ".join(parts) @property @@ -164,14 +185,16 @@ class RemuxConfig: Configuration complète d'un remuxage multi-source. sources : liste ordonnée des fichiers source (chacun avec ses pistes). - track_order : liste de (file_index, mkv_tid) dans l'ordre désiré en sortie. + track_order : liste de (file_index, mkv_tid[, entry_id]) dans l'ordre désiré + en sortie. ``entry_id`` permet de distinguer deux lignes UI + basées sur la même piste source. Seules les pistes présentes dans track_order sont incluses. extra_attachments : fichiers externes à attacher en plus (--attach-file). """ sources: list[SourceInput] output: Path - track_order: list[tuple[int, int]] # (file_index, mkv_tid) ordonnés + track_order: list[tuple[int, int] | tuple[int, int, str]] keep_chapters: bool = True #: None → FFmpeg recopie les chapitres des sources (comportement par défaut). #: list → un fichier ffmetadata temporaire est généré depuis ces entrées ; @@ -300,3 +323,27 @@ def _flags_from_disp(raw: dict) -> dict: )) return entries + + +def clone_track_entry( + entry: TrackEntry, + *, + entry_id: str | None = None, +) -> TrackEntry: + """Clone une piste pour en faire une entrée UI indépendante.""" + source_entry_id = entry.source_entry_id or entry.entry_id + return replace( + entry, + entry_id=entry_id or uuid4().hex, + source_entry_id=source_entry_id, + is_new=True, + orig_language=entry.language, + orig_title=entry.title, + orig_flag_enabled=entry.flag_enabled, + orig_flag_default=entry.flag_default, + orig_flag_forced=entry.flag_forced, + orig_flag_hearing_impaired=entry.flag_hearing_impaired, + orig_flag_visual_impaired=entry.flag_visual_impaired, + orig_flag_original=entry.flag_original, + orig_flag_commentary=entry.flag_commentary, + ) diff --git a/tests/test_encode_models.py b/tests/test_encode_models.py index 06859da..df2b83a 100644 --- a/tests/test_encode_models.py +++ b/tests/test_encode_models.py @@ -210,6 +210,10 @@ def test_custom_codec(self): assert a.codec == "aac" assert a.bitrate_kbps == 192 + def test_track_entry_id_is_canonical_guid(self): + a = AudioTrackSettings(stream_index=1, track_entry_id="track-guid") + assert a.track_entry_id == "track-guid" + # =========================================================================== # EncodeConfig diff --git a/tests/test_encode_panel_widgets.py b/tests/test_encode_panel_widgets.py index 9f18410..4fac6fd 100644 --- a/tests/test_encode_panel_widgets.py +++ b/tests/test_encode_panel_widgets.py @@ -34,6 +34,16 @@ - COL_TITLE a le flag ItemIsEditable sur la nouvelle ligne - COL_LANG a le flag ItemIsEditable sur la nouvelle ligne - track_meta_changed émis sur modification après add_custom_row + - suppression d'une ligne NEW possible même si la source n'est plus listée + - suppression d'une ligne source ne demande pas sa suppression au RemuxPanel + + _AudioTable — plan d'encodage remonté au RemuxPanel : + - changement de codec émet track_encoding_changed(entry_id, codec, bitrate) + - changement de bitrate émet track_encoding_changed(entry_id, codec, bitrate) + + EncodePanel — sources de nouvelles pistes : + - seules les pistes d'origine peuvent servir de source à add_custom_row + - une piste NEW reste éditable/supprimable mais n'active pas le bouton Ajouter _AudioTable — persistance des réglages audio : - Le codec est conservé après reload avec ordre inversé @@ -50,9 +60,12 @@ import pytest from PySide6.QtCore import Qt -from PySide6.QtWidgets import QApplication, QComboBox, QLineEdit +from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QLineEdit +from core.config import AppConfig from core.inspector import AudioTrack +from core.workflows.remux_models import TrackEntry, clone_track_entry +from ui.panels.encode_panel.panel import EncodePanel from ui.panels.encode_panel.widgets import _AudioTable @@ -132,6 +145,18 @@ def _bitrate_value(table: _AudioTable, row: int) -> int: return _bitrate_editor(table, row).value() +def _remux_entry(entry_id: str = "entry-a") -> TrackEntry: + return TrackEntry( + mkv_tid=1, + track_type="audio", + codec="EAC3", + display_info="5.1 640 kbps", + language="fra", + title="", + entry_id=entry_id, + ) + + # =========================================================================== # Flags éditables # =========================================================================== @@ -249,7 +274,7 @@ def test_signal_carries_correct_stream_index(self, table): emitted: list = [] table.track_meta_changed.connect(lambda *a: emitted.append(a)) table.item(0, _AudioTable.COL_TITLE).setText("X") - stream_index, _, _, _ = emitted[0] + stream_index, _, _, _, _ = emitted[0] assert stream_index == 7 def test_signal_carries_correct_source_path(self, table): @@ -257,7 +282,7 @@ def test_signal_carries_correct_source_path(self, table): emitted: list = [] table.track_meta_changed.connect(lambda *a: emitted.append(a)) table.item(0, _AudioTable.COL_TITLE).setText("X") - _, source_path, _, _ = emitted[0] + _, source_path, _, _, _ = emitted[0] assert source_path == _PATH_B def test_signal_carries_current_lang_when_title_changes(self, table): @@ -265,7 +290,7 @@ def test_signal_carries_current_lang_when_title_changes(self, table): emitted: list = [] table.track_meta_changed.connect(lambda *a: emitted.append(a)) table.item(0, _AudioTable.COL_TITLE).setText("Modifié") - _, _, lang, title = emitted[0] + _, _, lang, title, _ = emitted[0] assert lang == "fra" assert title == "Modifié" @@ -274,7 +299,7 @@ def test_signal_carries_current_title_when_lang_changes(self, table): emitted: list = [] table.track_meta_changed.connect(lambda *a: emitted.append(a)) table.item(0, _AudioTable.COL_LANG).setText("ja") - _, _, lang, title = emitted[0] + _, _, lang, title, _ = emitted[0] assert lang == "ja" assert title == "Mon titre" @@ -300,7 +325,7 @@ def test_signal_independent_per_row(self, table): table.item(1, _AudioTable.COL_TITLE).setText("Modifié") assert len(emitted) == 1 - stream_index, _, _, _ = emitted[0] + stream_index, _, _, _, _ = emitted[0] assert stream_index == at1.index def test_load_tracks_resets_then_no_signal(self, table): @@ -386,3 +411,101 @@ def test_truehd_atmos_transcode_disables_truehd_core_extraction(self, table): assert len(settings) == 1 assert settings[0].codec == "eac3" assert settings[0].extract_truehd_core is False + + +class TestAudioTableTrackEncodingChanged: + + def test_codec_change_emits_track_encoding_plan(self, table): + entry = _remux_entry("entry-codec") + table.load_tracks([(_at(index=1), _COLOR, _PATH_A, entry)]) + emitted: list = [] + table.track_encoding_changed.connect(lambda *a: emitted.append(a)) + + _set_codec(table, 0, "aac") + + assert emitted + assert emitted[-1][0] == "entry-codec" + assert emitted[-1][1] == "aac" + + def test_bitrate_change_emits_track_encoding_plan(self, table): + entry = _remux_entry("entry-bitrate") + table.load_tracks([(_at(index=1), _COLOR, _PATH_A, entry)]) + _set_codec(table, 0, "eac3") + emitted: list = [] + table.track_encoding_changed.connect(lambda *a: emitted.append(a)) + + _set_bitrate(table, 0, 960) + + assert emitted + assert emitted[-1] == ("entry-bitrate", "eac3", 960) + + +class TestAudioTableTrackRemoval: + + def test_new_track_can_be_deleted_when_source_is_not_present(self, table): + source_entry = _remux_entry("source-entry") + new_entry = clone_track_entry(source_entry, entry_id="new-entry") + table.load_tracks([(_at(index=1), _COLOR, _PATH_A, new_entry)]) + emitted: list = [] + table.track_removed.connect(lambda entry_id: emitted.append(entry_id)) + + assert table._can_delete(0) is True + table._delete_row(0) + + assert emitted == ["new-entry"] + assert table.rowCount() == 0 + + def test_deleting_source_row_does_not_request_remux_removal(self, table): + source_entry = _remux_entry("source-entry") + new_entry = clone_track_entry(source_entry, entry_id="new-entry") + track = _at(index=1) + table.load_tracks([ + (track, _COLOR, _PATH_A, source_entry), + (track, _COLOR, _PATH_A, new_entry), + ]) + emitted: list = [] + table.track_removed.connect(lambda entry_id: emitted.append(entry_id)) + + assert table._can_delete(0) is True + table._delete_row(0) + + assert emitted == [] + assert table.rowCount() == 1 + + +class TestEncodePanelNewTrackSources: + + def test_new_track_does_not_enable_add_button_when_it_is_the_only_audio(self, qt_app): + panel = EncodePanel(AppConfig()) + source_entry = _remux_entry("source-entry") + new_entry = clone_track_entry(source_entry, entry_id="new-entry") + + panel.set_audio_tracks([(_at(index=1), _COLOR, _PATH_A, new_entry)]) + + assert panel._add_audio_btn.isEnabled() is False + panel.close() + + def test_add_dialog_receives_only_original_tracks(self, qt_app, monkeypatch): + panel = EncodePanel(AppConfig()) + source_entry = _remux_entry("source-entry") + new_entry = clone_track_entry(source_entry, entry_id="new-entry") + original = (_at(index=1, title="Original"), _COLOR, _PATH_A, source_entry) + new_track = (_at(index=1, title="New"), _COLOR, _PATH_A, new_entry) + panel.set_audio_tracks([original, new_track]) + captured: dict[str, list] = {} + + class FakeDialog: + DialogCode = QDialog.DialogCode + + def __init__(self, tracks, *args, **kwargs): + captured["tracks"] = tracks + + def exec(self): + return QDialog.DialogCode.Rejected + + monkeypatch.setattr("ui.panels.encode_panel.panel._AudioSourceDialog", FakeDialog) + + panel._on_add_audio_track() + + assert captured["tracks"] == [original] + panel.close() diff --git a/tests/test_nfo_generation.py b/tests/test_nfo_generation.py index 32362a5..0b98859 100644 --- a/tests/test_nfo_generation.py +++ b/tests/test_nfo_generation.py @@ -233,8 +233,10 @@ def test_writes_nfo_file_with_mediainfo_output(self, tmp_path): log_cb.assert_called_once_with("OK", "NFO généré : film.nfo") args = mock_run.call_args[0][0] + kwargs = mock_run.call_args[1] if mock_run.call_args[1] else mock_run.call_args.kwargs assert args[0] == "mediainfo" - assert str(mkv) in " ".join(args) + assert args[1] == str(mkv.resolve()) + assert "cwd" not in kwargs def test_uses_custom_mediainfo_bin(self, tmp_path): """mediainfo_bin personnalisé est passé à subprocess.run.""" @@ -266,6 +268,27 @@ def test_nfo_path_is_sibling_of_mkv(self, tmp_path): assert (subdir / "film.nfo").exists() + def test_mediainfo_receives_absolute_output_path(self, tmp_path, monkeypatch): + """Le chemin passé à mediainfo ne dépend pas du cwd ni d'un affichage relatif.""" + from core.workflows.remux import write_mediainfo_nfo + + subdir = tmp_path / "output" + subdir.mkdir() + mkv = subdir / "film.mkv" + mkv.touch() + fake_result = MagicMock() + fake_result.stdout = "data" + + monkeypatch.chdir(tmp_path) + with patch("core.workflows.remux.subprocess.run", return_value=fake_result) as mock_run: + write_mediainfo_nfo(Path("output") / "film.mkv", log_cb=MagicMock()) + + args = mock_run.call_args[0][0] + kwargs = mock_run.call_args.kwargs + assert args[1] == str(mkv.resolve()) + assert "cwd" not in kwargs + assert (subdir / "film.nfo").exists() + def test_exception_logs_warn_and_does_not_raise(self, tmp_path): """Une exception subprocess est attrapée et logguée en WARN sans lever.""" from core.workflows.remux import write_mediainfo_nfo diff --git a/tests/test_remux.py b/tests/test_remux.py index 378d163..16b3595 100644 --- a/tests/test_remux.py +++ b/tests/test_remux.py @@ -98,11 +98,19 @@ _TrackTable.update_audio_meta : - Met à jour language et title dans la cellule et dans l'objet TrackEntry + - Met à jour codec et bitrate affichés quand l'encode panel prévoit un réencodage - N'émet pas de signal itemChanged (blockSignals) - Cible uniquement la ligne correspondant à (file_id, mkv_tid) + - Cible la piste NEW par entry_id sans modifier la source - Laisse les autres lignes intactes - Sans effet si (file_id, mkv_tid) introuvable + RemuxPanel — pistes NEW issues de l'encode panel : + - Si la piste source est désélectionnée, la piste NEW reste disponible + - Si la piste NEW est supprimée, elle quitte le panel remux et le workflow + - Les éditions de nom restent indépendantes entre source et piste NEW + - Un changement d'ordre réémet les pistes audio vers EncodePanel avec les entry_id + _AttachmentItemWidget — balises cochées par défaut : - is_tag=False → case cochée - is_tag=True → case cochée (nouveau comportement) @@ -135,7 +143,7 @@ from core.runner import TaskSignals from core.workflows.remux import RemuxWorkflow from core.workflows.remux_models import ( - RemuxConfig, RemuxError, SourceInput, TrackEntry, tracks_from_file_info, + RemuxConfig, RemuxError, SourceInput, TrackEntry, clone_track_entry, tracks_from_file_info, ) from ui.panels.remux_panel import ( RemuxPanel, SourceFile, _FILE_BAR_H, _FILE_PH_H, _FILE_ROW_H, @@ -362,6 +370,11 @@ def test_enabled_default_true(self): ) assert t.enabled is True + def test_original_codec_and_display_info_default_to_initial_values(self): + t = _track(1, track_type="audio", codec="EAC3") + assert t.orig_codec == "EAC3" + assert t.orig_display_info == "5.1 48 kHz" + def test_time_shift_value_label_empty_when_zero(self): t = _track(1, track_type="audio") t.time_shift_ms = 0 @@ -383,6 +396,11 @@ def test_full_info_label_includes_offset_when_non_zero(self): t.time_shift_ms = 125 assert "Δt +125 ms" in t.full_info_label + def test_full_info_label_prefixes_new_for_cloned_track(self): + t = clone_track_entry(_track(1, track_type="audio")) + assert t.is_new is True + assert t.full_info_label.startswith("NEW") + # =========================================================================== # tracks_from_file_info @@ -1205,6 +1223,117 @@ def test_unknown_mkv_tid_is_noop(self, table): result = table.current_tracks() assert result[0].language == "fra" + def test_entry_id_targets_cloned_track_without_touching_source(self, table): + source = _track(1, "audio", file_id="fid", language="fra", title="Source") + clone = clone_track_entry(source) + table.append_tracks(_COLOR_A, [source, clone]) + + table.update_audio_meta("fid", 1, "jpn", "Clone", entry_id=clone.entry_id) + + result = table.current_tracks() + original = next(t for t in result if t.entry_id == source.entry_id) + updated = next(t for t in result if t.entry_id == clone.entry_id) + assert original.language == "fra" + assert original.title == "Source" + assert updated.language == "jpn" + assert updated.title == "Clone" + + def test_update_audio_encoding_updates_codec_and_info_cells(self, table): + track = _track(1, "audio", file_id="fid", codec="EAC3") + table.append_tracks(_COLOR_A, [track]) + + changed = table.update_audio_encoding(track.entry_id, "AAC", "5.1 384 kbps") + + assert changed is True + assert table.item(0, _TrackTable.COL_CODEC).text() == "AAC" + assert table.item(0, _TrackTable.COL_INFO).text() == "5.1 384 kbps" + assert track.codec == "AAC" + assert track.display_info == "5.1 384 kbps" + + +# =========================================================================== +# RemuxPanel — pistes NEW synchronisées avec EncodePanel +# =========================================================================== + +class TestRemuxPanelNewAudioTracks: + + @staticmethod + def _panel_with_audio_tracks( + qt_app, + tmp_path, + tracks: list[TrackEntry], + ) -> RemuxPanel: + cfg = AppConfig() + panel = RemuxPanel(cfg) + src = tmp_path / "source.mkv" + src.touch() + info = _file_info(path=src, audios=[_audio(index=1, title="Source")]) + sf = SourceFile(id="fid", path=src, color=_COLOR_A, info=info, tracks=tracks) + panel._source_files = [sf] + panel._source_colors = {"fid": _COLOR_A} + panel._source_names = {"fid": "source.mkv"} + panel._track_table.append_tracks(_COLOR_A, tracks) + panel._output_edit.setText(str(tmp_path / "out.mkv")) + return panel + + @staticmethod + def _row_for_entry(panel: RemuxPanel, entry: TrackEntry) -> int: + for row in range(panel._track_table.rowCount()): + item = panel._track_table.item(row, _TrackTable.COL_CHECK) + if item is not None and item.data(Qt.ItemDataRole.UserRole) is entry: + return row + raise AssertionError(f"entry not found: {entry.entry_id}") + + def test_new_track_remains_available_when_source_track_is_unselected(self, qt_app, tmp_path): + source = _track(1, "audio", file_id="fid", language="fra", title="Source") + new_track = clone_track_entry(source) + new_track.title = "Clone" + panel = self._panel_with_audio_tracks(qt_app, tmp_path, [source, new_track]) + emitted: list = [] + panel.audio_tracks_changed.connect(lambda tracks: emitted.append(tracks)) + + source_row = self._row_for_entry(panel, source) + panel._track_table.item(source_row, _TrackTable.COL_CHECK).setCheckState(Qt.CheckState.Unchecked) + panel._emit_audio_tracks() + + assert emitted + entries = [item[3] for item in emitted[-1]] + assert entries == [new_track] + panel.close() + + def test_removed_new_track_leaves_remux_and_workflow_config(self, qt_app, tmp_path): + source = _track(1, "audio", file_id="fid", language="fra", title="Source") + new_track = clone_track_entry(source) + panel = self._panel_with_audio_tracks(qt_app, tmp_path, [source, new_track]) + + panel.remove_audio_track_variant(new_track.entry_id) + config = panel.collect_config() + + assert all(track.entry_id != new_track.entry_id for track in panel._source_files[0].tracks) + assert all( + item[2] != new_track.entry_id + for item in (config.track_order if config is not None else []) + if len(item) > 2 + ) + assert panel._track_table.has_entry_id(new_track.entry_id) is False + panel.close() + + def test_track_order_change_reemits_audio_tracks_with_entry_ids(self, qt_app, tmp_path): + source = _track(1, "audio", file_id="fid", language="fra", title="Source") + new_track = clone_track_entry(source) + panel = self._panel_with_audio_tracks(qt_app, tmp_path, [source, new_track]) + emitted: list = [] + panel.audio_tracks_changed.connect(lambda tracks: emitted.append(tracks)) + + panel._track_table.order_changed.emit() + + assert emitted + assert [item[3].entry_id for item in emitted[-1]] == [ + source.entry_id, + new_track.entry_id, + ] + panel.close() + # =========================================================================== # _AttachmentItemWidget — case cochée par défaut (tags et attachements) diff --git a/ui/main_window.py b/ui/main_window.py index a92e93e..1f71666 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1388,6 +1388,9 @@ def _connect_signals(self) -> None: self._remux_panel.video_tracks_changed.connect(self._encode_panel.set_video_tracks) self._remux_panel.audio_tracks_changed.connect(self._encode_panel.set_audio_tracks) self._encode_panel.audio_track_meta_changed.connect(self._remux_panel.update_audio_track_meta) + self._encode_panel.audio_track_encoding_changed.connect(self._remux_panel.update_audio_track_encoding) + self._encode_panel.audio_track_add_requested.connect(self._remux_panel.add_audio_track_variant) + self._encode_panel.audio_track_remove_requested.connect(self._remux_panel.remove_audio_track_variant) self._encode_panel.set_output_provider(self._remux_panel.current_output_path) self._encode_panel.set_file_title_provider(self._remux_panel.current_file_title) self._encode_panel.set_extra_attachments_provider(self._remux_panel.current_extra_attachments) @@ -1519,21 +1522,26 @@ def _merge_remux_extras( source_by_index = {src.file_index: src for src in remux_cfg.sources} remux_track_map: dict[tuple[Path, int], TrackEntry] = {} + remux_track_map_by_id: dict[str, TrackEntry] = {} for src in remux_cfg.sources: for track in src.tracks: remux_track_map[(src.path, track.mkv_tid)] = track + remux_track_map_by_id[track.entry_id] = track for att in src.selected_attachments: attachment_streams.append((src.path, att.index)) if src.copy_tags: tag_sources.append(src.path) ordered_tracks: list[tuple[Path, TrackEntry]] = [] - for file_index, mkv_tid in remux_cfg.track_order: + for item in remux_cfg.track_order: + file_index = int(item[0]) + mkv_tid = int(item[1]) + entry_id = str(item[2]).strip() if len(item) > 2 else "" src = source_by_index.get(file_index) if src is None: continue - track = remux_track_map.get((src.path, mkv_tid)) + track = remux_track_map_by_id.get(entry_id) if entry_id else remux_track_map.get((src.path, mkv_tid)) if track is None: continue ordered_tracks.append((src.path, track)) @@ -1642,7 +1650,10 @@ def _find_track(src_path: Path, stream_index: int, track_type: str) -> TrackEntr audio_offset = 2 for audio_order, ats in enumerate(encode_cfg.audio_tracks): src_path = ats.source_path or encode_cfg.source - t = _find_track(src_path, ats.stream_index, "audio") + if ats.track_entry_id: + t = remux_track_map_by_id.get(ats.track_entry_id) + else: + t = _find_track(src_path, ats.stream_index, "audio") if t is None: continue edit = _make_edit(audio_offset + audio_order, t) diff --git a/ui/panels/encode_panel/panel.py b/ui/panels/encode_panel/panel.py index fbda4b0..577a59e 100644 --- a/ui/panels/encode_panel/panel.py +++ b/ui/panels/encode_panel/panel.py @@ -10,6 +10,7 @@ from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from uuid import uuid4 from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QBrush, QColor, QFont @@ -54,7 +55,10 @@ class EncodePanel(QWidget): log_message = Signal(str, str) ready_changed = Signal(bool) # émis quand la source vidéo change - audio_track_meta_changed = Signal(int, object, str, str) # (stream_index, source_path, lang, title) + audio_track_meta_changed = Signal(int, object, str, str, object) # (stream_index, source_path, lang, title, entry_id) + audio_track_encoding_changed = Signal(object, str, int) # (entry_id, codec, bitrate_kbps) + audio_track_add_requested = Signal(object, str, str, int) # (template TrackEntry, entry_id, codec, bitrate_kbps) + audio_track_remove_requested = Signal(object) # (entry_id) _hw_detected = Signal(object, object, object) # (hw: set[str], sw: set[str], hw_ffmpeg: str) def __init__( @@ -82,7 +86,7 @@ def __init__( self._executor = ThreadPoolExecutor(max_workers=1) self._file_info: FileInfo | None = None self._video_tracks: list[tuple[FileInfo, TrackEntry, str]] = [] - self._audio_tracks_data: list[tuple] = [] # list[tuple[AudioTrack, str, Path]] pour le popup + self._audio_tracks_data: list[tuple] = [] # list[tuple[AudioTrack, str, Path, TrackEntry]] self._duration_s: float | None = None self._hw_encoders: set[str] = set() # Callable fourni par MainWindow pour récupérer le chemin de sortie depuis RemuxPanel. @@ -162,6 +166,8 @@ def _build_ui(self) -> None: self._audio_table = _AudioTable(self._config) self._audio_table.set_changed_callback(self._rebuild_preview) self._audio_table.track_meta_changed.connect(self.audio_track_meta_changed) + self._audio_table.track_encoding_changed.connect(self.audio_track_encoding_changed) + self._audio_table.track_removed.connect(self.audio_track_remove_requested) cl.addWidget(self._audio_table) add_track_row = QHBoxLayout() @@ -342,10 +348,10 @@ def _apply_file_info(self, info: FileInfo) -> None: def set_audio_tracks(self, tracks: list[tuple]) -> None: """Met à jour les pistes audio depuis les pistes activées dans l'onglet Conteneur. - tracks : list[tuple[AudioTrack, str, Path]] — (piste, couleur, chemin_source) + tracks : list[tuple[AudioTrack, str, Path, TrackEntry]] """ self._audio_tracks_data = tracks - self._add_audio_btn.setEnabled(bool(tracks)) + self._add_audio_btn.setEnabled(any(self._is_original_audio_source(t) for t in tracks)) default_codec = "copy" default_bitrate = None @@ -358,6 +364,7 @@ def set_audio_tracks(self, tracks: list[tuple]) -> None: break self._audio_table.load_tracks(tracks, default_codec, default_bitrate) + self._audio_table.emit_encoding_plans() self._rebuild_preview() # ------------------------------------------------------------------ @@ -1069,20 +1076,32 @@ def _current_config(self) -> EncodeConfig | None: def _on_add_audio_track(self) -> None: """Ouvre le popup de sélection pour ajouter une piste audio custom.""" - if not self._audio_tracks_data: + source_tracks = [t for t in self._audio_tracks_data if self._is_original_audio_source(t)] + if not source_tracks: return - dlg = _AudioSourceDialog(self._audio_tracks_data, config=self._config, parent=self) + dlg = _AudioSourceDialog(source_tracks, config=self._config, parent=self) if dlg.exec() != QDialog.DialogCode.Accepted: return track = dlg.selected_track() if track is None: return + template_entry = dlg.selected_track_entry() + if template_entry is None: + return + new_entry_id = uuid4().hex self._audio_table.add_custom_row( track, dlg.selected_color(), dlg.selected_codec(), dlg.selected_bitrate(), dlg.selected_source_path(), + track_entry_id=new_entry_id, + ) + self.audio_track_add_requested.emit( + template_entry, + new_entry_id, + dlg.selected_codec(), + dlg.selected_bitrate(), ) self.log_message.emit( "INFO", @@ -1095,6 +1114,11 @@ def _on_add_audio_track(self) -> None: ), ) + @staticmethod + def _is_original_audio_source(track_tuple: tuple) -> bool: + entry = track_tuple[3] if len(track_tuple) > 3 else None + return not bool(getattr(entry, "is_new", False)) + def refresh_runtime_settings(self) -> None: self._audio_table.refresh_runtime_settings() self._workflow.set_ffmpeg_threads(self._config.ffmpeg_threads) diff --git a/ui/panels/encode_panel/widgets.py b/ui/panels/encode_panel/widgets.py index ee27137..1d5db08 100644 --- a/ui/panels/encode_panel/widgets.py +++ b/ui/panels/encode_panel/widgets.py @@ -28,6 +28,7 @@ from core.i18n import apply_translations, translate_text from core.lang_tags import Rfc5646LanguageTags from core.workflows.encode.models import AUDIO_CODECS, AudioTrackSettings +from core.workflows.remux_models import TrackEntry from ui.panels.encode_panel.theme import ( _C, _combo_style, _input_style, _primary_button, _secondary_button, _separator, ) @@ -344,7 +345,8 @@ class _AudioSourceDialog(QDialog): Fenêtre popup pour ajouter une piste audio custom. Permet de choisir la piste source, l'encodage et le débit cible. - tracks : list[tuple[AudioTrack, str]] ou list[tuple[AudioTrack, str, Path]] + tracks : pistes d'origine uniquement, sous forme + list[tuple[AudioTrack, str]] ou list[tuple[AudioTrack, str, Path, TrackEntry]] """ def __init__( @@ -359,6 +361,7 @@ def __init__( self._result_track: AudioTrack | None = None self._result_color: str = "#ffffff" self._result_source_path = None # Path | None + self._result_track_entry: TrackEntry | None = None self._setup_ui() def _setup_ui(self) -> None: @@ -402,6 +405,7 @@ def _setup_ui(self) -> None: for entry in self._tracks: track, color = entry[0], entry[1] source_path = entry[2] if len(entry) > 2 else None + track_entry = entry[3] if len(entry) > 3 else None ch = track.channels_label lang = track.language or "—" title_part = f" {track.title}" if track.title else "" @@ -414,7 +418,7 @@ def _setup_ui(self) -> None: text = f"█ #{track.index} {track.codec.upper()} {ch}{fmt_tag} [{lang}]{title_part}" item = QListWidgetItem(text) item.setForeground(QBrush(QColor(color))) - item.setData(Qt.ItemDataRole.UserRole, (track, color, source_path)) + item.setData(Qt.ItemDataRole.UserRole, (track, color, source_path, track_entry)) self._track_list.addItem(item) if self._track_list.count(): self._track_list.setCurrentRow(0) @@ -494,11 +498,15 @@ def _on_accept(self) -> None: self._result_track = data[0] self._result_color = data[1] self._result_source_path = data[2] if len(data) > 2 else None + self._result_track_entry = data[3] if len(data) > 3 and isinstance(data[3], TrackEntry) else None self.accept() def selected_track(self) -> AudioTrack | None: return self._result_track + def selected_track_entry(self) -> TrackEntry | None: + return self._result_track_entry + def selected_color(self) -> str: return self._result_color @@ -525,8 +533,11 @@ class _AudioTable(QTableWidget): Colonnes : src | # | Format | Bitrate src | Lang | Nom | Encodage | Débit | Del """ - # Émis quand l'utilisateur modifie lang ou titre : (stream_index, source_path, lang, title) - track_meta_changed = Signal(int, object, str, str) + # Émis quand l'utilisateur modifie lang ou titre : + # (stream_index, source_path, lang, title, track_entry_id) + track_meta_changed = Signal(int, object, str, str, object) + track_encoding_changed = Signal(object, str, int) + track_removed = Signal(object) COL_SOURCE = 0 COL_IDX = 1 @@ -597,13 +608,13 @@ def _setup_table(self) -> None: def load_tracks( self, - tracks: list[tuple], # list[tuple[AudioTrack, str]] ou [AudioTrack, str, Path] + tracks: list[tuple], # list[tuple[AudioTrack, str]] ou [AudioTrack, str, Path, TrackEntry] default_codec: str = "copy", default_bitrate: int | None = None, ) -> None: - previous_settings: dict[tuple[object, int], list[tuple[str, int]]] = {} + previous_settings: dict[object, list[tuple[str, int]]] = {} for data in self._row_data: - key = (data.get("source_path"), data["track"].index) + key = data.get("track_entry_id") or (data.get("source_path"), data["track"].index) codec = data["combo"].currentData() or default_codec bitrate = data["bitrate"].value() previous_settings.setdefault(key, []).append((codec, bitrate)) @@ -615,13 +626,24 @@ def load_tracks( for entry in tracks: track, color = entry[0], entry[1] source_path = entry[2] if len(entry) > 2 else None - key = (source_path, track.index) + track_entry = entry[3] if len(entry) > 3 else None + track_entry_id = track_entry.entry_id if isinstance(track_entry, TrackEntry) else None + is_new = bool(getattr(track_entry, "is_new", False)) + key = track_entry_id or (source_path, track.index) codec = default_codec bitrate = default_bitrate saved_settings = previous_settings.get(key) if saved_settings: codec, bitrate = saved_settings.pop(0) - self._append_row(track, color, codec, bitrate, source_path) + self._append_row( + track, + color, + codec, + bitrate, + source_path, + track_entry_id=track_entry_id, + is_new=is_new, + ) self.blockSignals(False) self._refresh_delete_buttons() self._adjust_height() @@ -629,8 +651,17 @@ def load_tracks( def add_custom_row( self, track: AudioTrack, color: str, codec: str = "copy", bitrate: int | None = None, source_path=None, # Path | None + track_entry_id: str | None = None, ) -> None: - self._append_row(track, color, codec, bitrate, source_path) + self._append_row( + track, + color, + codec, + bitrate, + source_path, + track_entry_id=track_entry_id, + is_new=bool(track_entry_id), + ) self._refresh_delete_buttons() self._adjust_height() if self._changed_cb: @@ -649,6 +680,7 @@ def current_audio_settings(self) -> list[AudioTrackSettings]: input_channels=d["track"].channels, input_channel_layout=d["track"].channel_layout, source_path=d.get("source_path"), + track_entry_id=d.get("track_entry_id"), )) return result @@ -668,6 +700,9 @@ def _adjust_height(self) -> None: def _append_row( self, track: AudioTrack, color: str, codec: str, bitrate: int | None, source_path=None, # Path | None + *, + track_entry_id: str | None = None, + is_new: bool = False, ) -> None: row = self.rowCount() self.insertRow(row) @@ -720,6 +755,9 @@ def _item_rw(text: str) -> QTableWidgetItem: bitrate_edit = _AudioBitrateEditor(track, self._config, codec, bitrate) if self._changed_cb: bitrate_edit.value_changed.connect(self._changed_cb) + bitrate_edit.value_changed.connect( + lambda editor=bitrate_edit: self._emit_encoding_changed_for_bitrate_editor(editor) + ) self.setCellWidget(row, self.COL_BITRATE, bitrate_edit) # Bouton suppression @@ -750,6 +788,8 @@ def _item_rw(text: str) -> QTableWidgetItem: "track": track, "color": color, "source_path": source_path, + "track_entry_id": track_entry_id, + "is_new": is_new, "del_btn": del_btn, }) @@ -784,7 +824,13 @@ def _on_item_changed(self, item: QTableWidgetItem) -> None: title_item = self.item(row, self.COL_TITLE) lang = lang_item.text() if lang_item else "" title = title_item.text() if title_item else "" - self.track_meta_changed.emit(d["track"].index, d.get("source_path"), lang, title) + self.track_meta_changed.emit( + d["track"].index, + d.get("source_path"), + lang, + title, + d.get("track_entry_id"), + ) if self._changed_cb: self._changed_cb() @@ -796,9 +842,27 @@ def _handler(_idx: int = 0) -> None: previous_codec = getattr(d["bitrate"], "_codec", "copy") preferred = None if codec == "flac" or previous_codec == "copy" else d["bitrate"].value() d["bitrate"].set_codec(codec or "copy", preferred) + self._emit_encoding_changed(d) break return _handler + def _emit_encoding_changed_for_bitrate_editor(self, editor: _AudioBitrateEditor) -> None: + for data in self._row_data: + if data["bitrate"] is editor: + self._emit_encoding_changed(data) + return + + def _emit_encoding_changed(self, data: dict) -> None: + track_entry_id = data.get("track_entry_id") + if not track_entry_id: + return + codec = data["combo"].currentData() or "copy" + self.track_encoding_changed.emit(track_entry_id, codec, int(data["bitrate"].value())) + + def emit_encoding_plans(self) -> None: + for data in self._row_data: + self._emit_encoding_changed(data) + def _make_delete_handler(self, del_btn: QPushButton): def _handler() -> None: for row, d in enumerate(self._row_data): @@ -810,16 +874,30 @@ def _handler() -> None: def _delete_row(self, row: int) -> None: if not self._can_delete(row): return + track_entry_id = self._row_data[row].get("track_entry_id") + is_new = bool(self._row_data[row].get("is_new")) self.removeRow(row) self._row_data.pop(row) self._refresh_delete_buttons() self._adjust_height() + if is_new and track_entry_id: + self.track_removed.emit(track_entry_id) if self._changed_cb: self._changed_cb() def _can_delete(self, row: int) -> bool: + if bool(self._row_data[row].get("is_new")): + return True track_idx = self._row_data[row]["track"].index - return sum(1 for d in self._row_data if d["track"].index == track_idx) > 1 + source_path = self._row_data[row].get("source_path") + return ( + sum( + 1 + for d in self._row_data + if d["track"].index == track_idx and d.get("source_path") == source_path + ) + > 1 + ) def _refresh_delete_buttons(self) -> None: for row, d in enumerate(self._row_data): diff --git a/ui/panels/remux_panel/functions/config_builder.py b/ui/panels/remux_panel/functions/config_builder.py index 7abdcbd..8659d16 100644 --- a/ui/panels/remux_panel/functions/config_builder.py +++ b/ui/panels/remux_panel/functions/config_builder.py @@ -50,7 +50,7 @@ def current_config(panel: "RemuxPanel") -> RemuxConfig | None: return None track_order = [ - (id_to_index[t.file_id], t.mkv_tid) + (id_to_index[t.file_id], t.mkv_tid, t.entry_id) for t in all_tracks if t.enabled and t.file_id in id_to_index ] diff --git a/ui/panels/remux_panel/functions/signals.py b/ui/panels/remux_panel/functions/signals.py index 44be02c..5e8f2ac 100644 --- a/ui/panels/remux_panel/functions/signals.py +++ b/ui/panels/remux_panel/functions/signals.py @@ -57,11 +57,14 @@ def emit_audio_tracks(panel: "RemuxPanel") -> None: if audio_data is None: continue audio_track, color, source_path = audio_data - audio_tuples.append(( - dc_replace(audio_track, language=entry.language, title=entry.title), - color, - source_path, - )) + audio_tuples.append( + ( + dc_replace(audio_track, language=entry.language, title=entry.title), + color, + source_path, + entry, + ) + ) panel.audio_tracks_changed.emit(audio_tuples) diff --git a/ui/panels/remux_panel/panel.py b/ui/panels/remux_panel/panel.py index f1bfc2b..9bc6ec5 100644 --- a/ui/panels/remux_panel/panel.py +++ b/ui/panels/remux_panel/panel.py @@ -14,6 +14,7 @@ QLabel, QLayout, QLineEdit, + QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, @@ -23,13 +24,19 @@ ) from core.config import AppConfig +from core.extractor import TrackExtractor from core.file_types import is_accepted from core.i18n import apply_translations, translate_text from core.matroska_attachment_extractor import extract_matroska_attachment_bytes from core.inspector import AttachmentInfo, ChapterEntry, FileInfo -from core.runner import TaskSignals +from core.runner import TaskSignals, ToolRunner from core.workflows.remux import RemuxWorkflow -from core.workflows.remux_models import RemuxConfig, SourceInput, TrackEntry +from core.workflows.remux_models import ( + RemuxConfig, + SourceInput, + TrackEntry, + clone_track_entry, +) from ui.panels.remux_panel.functions import chapters as chapter_functions from ui.panels.remux_panel.functions import config_builder, inspection, signals, tmdb from ui.panels.remux_panel.models import SourceFile @@ -222,7 +229,8 @@ def _build_ui(self) -> None: self._track_table = _TrackTable() self._track_table.itemChanged.connect(self._on_table_changed) - self._track_table.order_changed.connect(self._rebuild_preview) + self._track_table.order_changed.connect(self._on_track_order_changed) + self._track_table.extract_requested.connect(self._on_extract_track) content_layout.addWidget(self._track_table) content_layout.addWidget(_separator()) @@ -466,6 +474,10 @@ def _on_table_changed(self, _item: QTableWidgetItem | None = None) -> None: self._rebuild_preview() self._emit_audio_tracks() + def _on_track_order_changed(self) -> None: + self._rebuild_preview() + self._emit_signals() + def _emit_signals(self) -> None: signals.emit_signals(self) @@ -489,15 +501,137 @@ def refresh_runtime_settings(self) -> None: self._workflow.set_mediainfo_bin(self._config.tool_mediainfo) self._rebuild_preview() - def update_audio_track_meta(self, stream_index: int, source_path, lang: str, title: str) -> None: + def update_audio_track_meta( + self, + stream_index: int, + source_path, + lang: str, + title: str, + entry_id, + ) -> None: file_id = next( (sf.id for sf in self._source_files if sf.info and sf.info.path == source_path), None, ) if file_id is None: return - self._track_table.update_audio_meta(file_id, stream_index, lang, title) + self._track_table.update_audio_meta( + file_id, + stream_index, + lang, + title, + entry_id=str(entry_id or "").strip() or None, + ) + self._rebuild_preview() + + @staticmethod + def _audio_encode_codec_label(codec: str) -> str: + normalized = (codec or "copy").strip().lower() + return { + "aac": "AAC", + "ac3": "AC3", + "eac3": "EAC3", + "flac": "FLAC", + }.get(normalized, normalized.upper() if normalized else "COPY") + + @staticmethod + def _audio_encode_display_info(source_display_info: str, codec: str, bitrate_kbps: int) -> str: + normalized = (codec or "copy").strip().lower() + if normalized == "copy": + return source_display_info + parts: list[str] = [] + for raw_part in str(source_display_info or "").replace("·", " ").split(" "): + part = raw_part.strip() + if not part or "kbps" in part.lower(): + continue + parts.append(part) + if bitrate_kbps > 0: + parts.append(f"{int(bitrate_kbps)} kbps") + return " ".join(parts) + + def _source_track_for_variant(self, entry: TrackEntry) -> TrackEntry: + source = self._find_source(entry.file_id) + if source is None: + return entry + source_entry_id = entry.source_entry_id or entry.entry_id + return next((track for track in source.tracks if track.entry_id == source_entry_id), entry) + + def _apply_audio_encoding_to_entry( + self, + entry: TrackEntry, + codec: str, + bitrate_kbps: int, + ) -> None: + source_entry = self._source_track_for_variant(entry) + normalized = (codec or "copy").strip().lower() + if normalized == "copy": + entry.codec = source_entry.orig_codec or source_entry.codec + entry.display_info = source_entry.orig_display_info or source_entry.display_info + return + entry.codec = self._audio_encode_codec_label(normalized) + entry.display_info = self._audio_encode_display_info( + source_entry.orig_display_info or source_entry.display_info, + normalized, + bitrate_kbps, + ) + + def add_audio_track_variant( + self, + template_entry: TrackEntry, + entry_id: str = "", + codec: str = "copy", + bitrate_kbps: int = 0, + ) -> None: + if template_entry.track_type != "audio": + return + if self._track_table.has_entry_id(entry_id or template_entry.entry_id): + return + + source = self._find_source(template_entry.file_id) + if source is None or source.info is None: + self.log_message.emit("WARN", translate_text("Source introuvable pour cette piste.")) + return + + new_entry = clone_track_entry(template_entry, entry_id=entry_id or None) + self._apply_audio_encoding_to_entry(new_entry, codec, bitrate_kbps) + source.tracks.append(new_entry) + source_color = self._source_colors.get(template_entry.file_id, _C.BORDER) + self._track_table.append_tracks(source_color, [new_entry]) + self._track_table.refresh_filter() self._rebuild_preview() + self._emit_audio_tracks() + + def remove_audio_track_variant(self, entry_id) -> None: + entry_id_str = str(entry_id or "").strip() + if not entry_id_str: + return + for source in self._source_files: + source.tracks = [track for track in source.tracks if track.entry_id != entry_id_str] + if self._track_table.remove_track_by_entry_id(entry_id_str): + self._track_table.refresh_filter() + self._rebuild_preview() + self._emit_audio_tracks() + + def update_audio_track_encoding(self, entry_id, codec: str, bitrate_kbps: int) -> None: + entry_id_str = str(entry_id or "").strip() + if not entry_id_str: + return + for source in self._source_files: + for entry in source.tracks: + if entry.entry_id != entry_id_str or entry.track_type != "audio": + continue + previous = (entry.codec, entry.display_info) + self._apply_audio_encoding_to_entry(entry, codec, bitrate_kbps) + if (entry.codec, entry.display_info) == previous: + return + if self._track_table.update_audio_encoding( + entry.entry_id, + entry.codec, + entry.display_info, + ): + self._rebuild_preview() + self._emit_audio_tracks() + return def current_output_path(self) -> Path | None: text = self._output_edit.text().strip() @@ -565,6 +699,73 @@ def _browse_output(self) -> None: if path: self._output_edit.setText(path) + def _on_extract_track(self, entry: TrackEntry) -> None: + source = self._find_source(entry.file_id) + if source is None or source.info is None: + self.log_message.emit("WARN", translate_text("Source introuvable pour cette piste.")) + return + + codec = (entry.codec or "").lower() + try: + plan = TrackExtractor.plan_subtitle(codec) + except ValueError as exc: + QMessageBox.warning( + self, + translate_text("Extraction impossible"), + str(exc), + ) + return + + default_name = TrackExtractor.default_output_name( + source.path.stem, entry.language, entry.mkv_tid, plan.extension, + ) + default_path = source.path.parent / default_name + out_str, _ = QFileDialog.getSaveFileName( + self, + translate_text("Extraire le sous-titre"), + str(default_path), + plan.file_filter, + ) + if not out_str: + return + + out_path = Path(out_str) + cmd = TrackExtractor.build_subtitle_command( + self._config.tool_ffmpeg, + source.path, + entry.mkv_tid, + codec, + out_path, + ) + + self.log_message.emit( + "INFO", + translate_text( + "Extraction du sous-titre #{idx} ({codec}) vers {name}…", + idx=entry.mkv_tid, codec=plan.format_label, name=out_path.name, + ), + ) + + runner = ToolRunner() + self._extract_runner = runner # conserve la référence pendant l'exécution + signals = runner.run(cmd, label=f"extract-sub-{entry.mkv_tid}") + signals.progress.connect( + lambda line: self.log_message.emit("INFO", line), + Qt.ConnectionType.QueuedConnection, + ) + signals.finished.connect( + lambda _=None, p=out_path: self.log_message.emit( + "OK", translate_text("Sous-titre extrait : {path}", path=str(p)), + ), + Qt.ConnectionType.QueuedConnection, + ) + signals.failed.connect( + lambda msg, _exc: self.log_message.emit( + "ERROR", translate_text("Extraction échouée : {msg}", msg=msg), + ), + Qt.ConnectionType.QueuedConnection, + ) + def _copy_command(self) -> None: from PySide6.QtWidgets import QApplication diff --git a/ui/panels/remux_panel/widgets/track_table.py b/ui/panels/remux_panel/widgets/track_table.py index 63ab68a..0d241fd 100644 --- a/ui/panels/remux_panel/widgets/track_table.py +++ b/ui/panels/remux_panel/widgets/track_table.py @@ -7,6 +7,7 @@ from PySide6.QtWidgets import ( QAbstractItemView, QHeaderView, + QMenu, QMessageBox, QPushButton, QStyle, @@ -96,10 +97,12 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index) -> None: class _TrackTable(QTableWidget): order_changed = Signal() + extract_requested = Signal(object) # TrackEntry _TYPE_ORDER: dict[str, int] = {"video": 0, "audio": 1, "subtitle": 2} _MAX_VISIBLE_ROWS = 15 _ROW_H_DEFAULT = 28 + _NEW_TRACK_COLOR = QColor(_C.ERROR) COL_SOURCE = 0 COL_CHECK = 1 @@ -126,6 +129,8 @@ def __init__(self, parent: QWidget | None = None) -> None: self._setup_ui() self._adjust_height() self.itemChanged.connect(self._on_item_changed) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_context_menu) def _setup_ui(self) -> None: self.setHorizontalHeaderLabels(self._HEADERS) @@ -210,6 +215,8 @@ def _setup_ui(self) -> None: def append_tracks(self, source_color: str, tracks: list[TrackEntry]) -> None: self.blockSignals(True) for entry in tracks: + if self.has_entry_id(entry.entry_id): + continue order = {"video": 0, "audio": 1, "subtitle": 2}.get(entry.track_type, 2) pos = self._find_insert_position(order) self.insertRow(pos) @@ -217,6 +224,16 @@ def append_tracks(self, source_color: str, tracks: list[TrackEntry]) -> None: self.blockSignals(False) self._adjust_height() + def has_entry_id(self, entry_id: str) -> bool: + for row in range(self.rowCount()): + item = self.item(row, self.COL_CHECK) + if item is None: + continue + entry = item.data(Qt.ItemDataRole.UserRole) + if isinstance(entry, TrackEntry) and entry.entry_id == entry_id: + return True + return False + @staticmethod def _row_type_order(data) -> int: if isinstance(data, TrackEntry): @@ -257,6 +274,25 @@ def remove_tracks_by_file_id(self, file_id: str) -> None: self._rebuild_prev_lang() self._adjust_height() + def remove_track_by_entry_id(self, entry_id: str) -> bool: + if not entry_id: + return False + self.blockSignals(True) + try: + for row in range(self.rowCount() - 1, -1, -1): + item = self.item(row, self.COL_CHECK) + if item is None: + continue + entry = item.data(Qt.ItemDataRole.UserRole) + if isinstance(entry, TrackEntry) and entry.entry_id == entry_id: + self.removeRow(row) + return True + finally: + self.blockSignals(False) + self._rebuild_prev_lang() + self._adjust_height() + return False + def clear_all(self) -> None: self.setRowCount(0) self._prev_lang.clear() @@ -321,6 +357,9 @@ def _fill_row(self, row: int, entry: TrackEntry, source_color: str) -> None: title_item.setFlags(self._FLAG_RW) self.setItem(row, self.COL_TITLE, title_item) + if entry.is_new: + self._apply_new_track_style(row) + edit_btn = QPushButton() from PySide6.QtCore import QSize @@ -347,6 +386,16 @@ def _fill_row(self, row: int, entry: TrackEntry, source_color: str) -> None: edit_btn.clicked.connect(lambda _=None, e=entry: self._open_edit_dialog(e)) self.setCellWidget(row, self.COL_EDIT, edit_btn) + def _apply_new_track_style(self, row: int) -> None: + for col in (self.COL_CODEC, self.COL_LANG, self.COL_TITLE, self.COL_INFO): + item = self.item(row, col) + if item is None: + continue + item.setForeground(self._NEW_TRACK_COLOR) + font = item.font() + font.setBold(True) + item.setFont(font) + def current_tracks(self) -> list[TrackEntry]: tracks: list[TrackEntry] = [] for row in range(self.rowCount()): @@ -387,7 +436,15 @@ def _open_edit_dialog(self, entry: TrackEntry) -> None: if lang_item is not None: self.itemChanged.emit(lang_item) - def update_audio_meta(self, file_id: str, mkv_tid: int, lang: str, title: str) -> None: + def update_audio_meta( + self, + file_id: str, + mkv_tid: int, + lang: str, + title: str, + *, + entry_id: str | None = None, + ) -> None: self.blockSignals(True) try: for row in range(self.rowCount()): @@ -397,6 +454,11 @@ def update_audio_meta(self, file_id: str, mkv_tid: int, lang: str, title: str) - entry = item0.data(Qt.ItemDataRole.UserRole) if not isinstance(entry, TrackEntry): continue + if entry_id: + if entry.entry_id != entry_id: + continue + elif entry.file_id != file_id or entry.mkv_tid != mkv_tid: + continue if entry.file_id == file_id and entry.mkv_tid == mkv_tid: lang_item = self.item(row, self.COL_LANG) if lang_item: @@ -411,6 +473,40 @@ def update_audio_meta(self, file_id: str, mkv_tid: int, lang: str, title: str) - finally: self.blockSignals(False) + def update_audio_encoding( + self, + entry_id: str, + codec: str, + display_info: str, + ) -> bool: + if not entry_id: + return False + self.blockSignals(True) + try: + for row in range(self.rowCount()): + item0 = self.item(row, self.COL_CHECK) + if item0 is None: + continue + entry = item0.data(Qt.ItemDataRole.UserRole) + if not isinstance(entry, TrackEntry) or entry.entry_id != entry_id: + continue + + entry.codec = codec + entry.display_info = display_info + codec_item = self.item(row, self.COL_CODEC) + if codec_item: + codec_item.setText(codec) + info_item = self.item(row, self.COL_INFO) + if info_item: + info_item.setText(entry.full_info_label) + info_item.setData(_TRACK_INFO_OFFSET_VALUE_ROLE, entry.time_shift_value_label) + if entry.is_new: + self._apply_new_track_style(row) + return True + finally: + self.blockSignals(False) + return False + def _on_item_changed(self, item: QTableWidgetItem) -> None: if item.column() != self.COL_LANG: return @@ -428,6 +524,23 @@ def _on_item_changed(self, item: QTableWidgetItem) -> None: ), ) + def _on_context_menu(self, pos) -> None: + index = self.indexAt(pos) + if not index.isValid(): + return + chk = self.item(index.row(), self.COL_CHECK) + if chk is None: + return + entry = chk.data(Qt.ItemDataRole.UserRole) + if not isinstance(entry, TrackEntry) or entry.track_type != "subtitle": + return + + menu = QMenu(self) + action = menu.addAction(translate_text("Extraire…")) + chosen = menu.exec(self.viewport().mapToGlobal(pos)) + if chosen is action: + self.extract_requested.emit(entry) + def _find_row_for_entry(self, entry: TrackEntry) -> int | None: for row in range(self.rowCount()): item = self.item(row, self.COL_CHECK)