diff --git a/pymkv/MKVFile.py b/pymkv/MKVFile.py index 5404e0f..503e49d 100644 --- a/pymkv/MKVFile.py +++ b/pymkv/MKVFile.py @@ -55,6 +55,7 @@ checking_file_path, get_file_info, verify_mkvmerge, + verify_mkvpropedit, ) T = TypeVar("T") @@ -88,6 +89,8 @@ class MKVFile: The path where pymkv looks for the mkvmerge executable. pymkv relies on the mkvmerge executable to parse files. By default, it is assumed mkvmerge is in your shell's $PATH variable. If it is not, you need to set *mkvmerge_path* to the executable location. + mkvpropedit_path : str, optional + Same as mkvmerge_path but for mkvpropedit. Raises ------ @@ -100,9 +103,17 @@ def __init__( file_path: str | os.PathLike | None = None, title: str | None = None, mkvmerge_path: str | os.PathLike | Iterable[str] = "mkvmerge", + mkvpropedit_path: str | os.PathLike | Iterable[str] = "mkvpropedit", ) -> None: + # gather changes to MKV properties for mkvpropedit + self.__property_changes: list[str] = [] + # … but not yet + self.__record_changes: bool = False + self.mkvmerge_path: tuple[str, ...] = prepare_mkvtoolnix_path(mkvmerge_path) + self.mkvpropedit_path: tuple[str, ...] = prepare_mkvtoolnix_path(mkvpropedit_path) self.title = title + self._file_path: str | None = None self._chapters_file: str | None = None self._chapter_language: str | None = None self._global_tags_file: str | None = None @@ -121,6 +132,10 @@ def __init__( msg = "mkvmerge is not at the specified path, add it there or changed mkvmerge_path property" raise FileNotFoundError(msg) + if not verify_mkvpropedit(mkvpropedit_path=self.mkvpropedit_path): + msg = "mkvpropedit is not at the specified path, add it there or changed mkvpropedit_path property" + raise FileNotFoundError(msg) + if file_path is not None: file_path = checking_file_path(file_path) try: @@ -130,6 +145,7 @@ def __init__( check_path=False, ) self._info_json = info_json + self._file_path = file_path except sp.CalledProcessError as e: error_output = e.output.decode() raise sp.CalledProcessError( @@ -178,6 +194,8 @@ def __init__( new_track.flag_hearing_impaired = track["properties"]["flag_hearing_impaired"] if "flag_visual_impaired" in track["properties"]: new_track.flag_visual_impaired = track["properties"]["flag_visual_impaired"] + if "flag_text_descriptions" in track["properties"]: + new_track.flag_text_descriptions = track["properties"]["flag_text_descriptions"] if "flag_original" in track["properties"]: new_track.flag_original = track["properties"]["flag_original"] @@ -186,6 +204,10 @@ def __init__( # split options self._split_options: list[str] = [] + # On __init__ end switch on recording of changes done by object + # consumer + self.__record_changes = True + def __repr__(self) -> str: """ Return a string representation of the MKVFile object. @@ -195,6 +217,27 @@ def __repr__(self) -> str: """ return repr(self.__dict__) + @property + def record_changes(self) -> bool: + return self.__record_changes + + @record_changes.setter + def record_changes(self, state: bool) -> bool: + self.__record_changes = state + + @property + def property_changes( self, include_tracks = False ) -> list[str]: + property_changes = [] + if len( self.__property_changes ) > 0: + property_changes.extend( [ "--edit", "info" ] ) + property_changes += self.__property_changes + if include_tracks: + for track in self.tracks: + if len(track.property_changes) > 0: + property_changes.extend( [ "--edit", f"track:{(track.track_id + 1)!s}" ] ) + property_changes += track.property_changes + return self.property_changes + @property def chapter_language(self) -> str | None: """ @@ -234,7 +277,38 @@ def global_tag_entries(self) -> int: """ return self._global_tag_entries - def command( # noqa: PLR0912,PLR0915 + def set_title( + self, + title: str | None, + ) -> None: + """ + Changes or removes the title in the segment info section of the + MKVFile. + + Parameters + ---------- + title : str | None + The title to use for the MKVFile. If set to None or an empty string, the value will be deleted. + """ + if title == None or title == "": + self.title = None + self.__property_changes.extend( [ "--delete", "title" ] ) + else: + self.title = title + self._propery_changes.extend( [ "--set", f"title='{title}'" ] ) + + def command( + self, + output_path: str | None = None, + subprocess: bool = False, + mkvtool: str = 'merge' + ) -> str | list: + if 'propedit' == mkvtool: + return self.command_propedit( subprocess = subprocess ) + else: # if mkvtool == 'merge' or anything else currently undefined + return self.command_merge( output_path = output_path, subprocess = subprocess ) + + def command_merge( # noqa: PLR0912,PLR0915 self, output_path: str, subprocess: bool = False, @@ -297,6 +371,10 @@ def command( # noqa: PLR0912,PLR0915 command.extend(["--visual-impaired-flag", f"{track.track_id!s}:1"]) else: command.extend(["--visual-impaired-flag", f"{track.track_id!s}:0"]) + if track.flag_text_descriptions: + command.extend(["--text-descriptions-flag", f"{track.track_id!s}:1"]) + else: + command.extend(["--text-descriptions-flag", f"{track.track_id!s}:0"]) if track.flag_original: command.extend(["--original-flag", f"{track.track_id!s}:1"]) else: @@ -373,7 +451,52 @@ def command( # noqa: PLR0912,PLR0915 return command if subprocess else " ".join(command) - def mux(self, output_path: str | os.PathLike, silent: bool = False, ignore_warning: bool = False) -> int: + def command_propedit( + self, + subprocess: bool = False, + ) -> str | list: + """ + Generates an mkvpropedit command based on the configured :class:`~pymkv.MKVFile`. + + Parameters + ---------- + subprocess : bool + Will return the command as a list so it can be used easily with the :mod:`subprocess` module. + + Returns + ------- + str, list of str + The full command to mux the :class:`~pymkv.MKVFile` as a string containing spaces. Will be returned as a + list of strings with no spaces if `subprocess` is True. + """ + command = [ *self.mkvpropedit_path, self._file_path ] + if len( self.__property_changes ) > 0: + command.extend( [ "--edit", "info" ] ) + command += self.__property_changes + for track in self.tracks: + if len(track.property_changes) > 0: + command.extend( [ "--edit", f"track:{(track.track_id + 1)!s}" ] ) + command += track.property_changes + return command if subprocess else " ".join(command) + + def mux( + self, + output_path: str | os.PathLike, + silent: bool = False, + ignore_warning: bool = False + ) -> int: + """ + Compatibility stub to just forward arguments to run_command() + """ + return self.command( output_path = output_path, silent = silent, ignore_warning = ignore_warning, mkvtool = 'merge' ) + + def run_command( + self, + output_path: str | os.PathLike | None = None, + silent: bool = False, + ignore_warning: bool = False, + mkvtool: str = 'merge' + ) -> int: """ Mixes the specified :class:`~pymkv.MKVFile`. @@ -399,8 +522,11 @@ def mux(self, output_path: str | os.PathLike, silent: bool = False, ignore_warni or issues with output file writing. The error message provides details about the failure based on the output of the command. """ - output_path = str(Path(output_path).expanduser()) - args = self.command(output_path, subprocess=True) + if 'propedit' == mkvtool: + args = self.command( subprocess = True, mkvtool = 'propedit' ) + else: # if mkvtool == 'merge' or anything else currently undefined + output_path = str(Path(output_path).expanduser()) + args = self.command( output_path = output_path, subprocess = True, mkvtool = 'merge' ) stdout = sp.DEVNULL if silent else None stderr = sp.PIPE @@ -1011,6 +1137,7 @@ def link_to_previous(self, file_path: str) -> None: Raised if file at `file_path` cannot be verified as an MKV. """ self._link_to_previous_file = checking_file_path(file_path) + self.__property_changes.extend( [ "--set", f"prev-filename='{ quote( self._link_to_previous_file ) }'" ] ) def link_to_next(self, file_path: str) -> None: """ @@ -1029,6 +1156,7 @@ def link_to_next(self, file_path: str) -> None: Raised if file at `file_path` cannot be verified as an MKV. """ self._link_to_next_file = checking_file_path(file_path) + self.__property_changes.extend( [ "--set", f"next-filename='{ quote( self._link_to_next_file ) }'" ] ) def link_to_none(self) -> None: """ @@ -1039,6 +1167,8 @@ def link_to_none(self) -> None: """ self._link_to_previous_file = None self._link_to_next_file = None + self.__property_changes.extend( [ "--delete", " prev-filename" ] ) + self.__property_changes.extend( [ "--delete", " next-filename" ] ) def chapters(self, file_path: str, language: str | None = None) -> None: """ diff --git a/pymkv/MKVTrack.py b/pymkv/MKVTrack.py index d0fd12b..db98deb 100644 --- a/pymkv/MKVTrack.py +++ b/pymkv/MKVTrack.py @@ -43,6 +43,7 @@ import subprocess as sp from pathlib import Path from typing import TYPE_CHECKING, Any +from shlex import quote, split from pymkv.ISO639_2 import is_iso639_2 from pymkv.utils import prepare_mkvtoolnix_path @@ -134,6 +135,7 @@ def __init__( # noqa: PLR0913 flag_commentary: bool | None = False, flag_hearing_impaired: bool | None = False, flag_visual_impaired: bool | None = False, + flag_text_descriptions: bool | None = False, flag_original: bool | None = False, mkvmerge_path: str | os.PathLike | Iterable[str] = "mkvmerge", mkvextract_path: str | os.PathLike | Iterable[str] = "mkvextract", @@ -144,6 +146,11 @@ def __init__( # noqa: PLR0913 ) -> None: from pymkv.TypeTrack import get_track_extension # noqa: PLC0415 + # gather changes to MKV properties for mkvpropedit + self.__property_changes: list[str] = [] + # … but not yet + self.__record_changes: bool = False + # track info self._track_codec: str | None = None self._track_type: str | None = None @@ -159,6 +166,7 @@ def __init__( # noqa: PLR0913 self._pts = 0 # flags + self._track_name: str | None = None self.track_name = track_name self._language: str | None = None self.language = language @@ -167,14 +175,17 @@ def __init__( # noqa: PLR0913 self._language_ietf: str | None = None self.language_ietf = language_ietf self._tags: str | None = None - self.default_track = default_track - self.forced_track = forced_track - self.flag_commentary = flag_commentary - self.flag_hearing_impaired = flag_hearing_impaired - self.flag_visual_impaired = flag_visual_impaired - self.flag_original = flag_original self.compression = compression self._tag_entries = tag_entries + self.__flags = { + "flag-commentary": flag_commentary, + "flag-default": default_track, + "flag-forced": forced_track, + "flag-hearing-impaired": flag_hearing_impaired, + "flag-original": flag_original, + "flag-text-descriptions": flag_text_descriptions, + "flag-visual-impaired": flag_visual_impaired + } # exclusions self.no_chapters = False @@ -186,6 +197,216 @@ def __init__( # noqa: PLR0913 self.mkvextract_path = prepare_mkvtoolnix_path(mkvextract_path) self.extension = get_track_extension(self) + # On __init__ end switch on recording of changes done by object + # consumer + self.__record_changes = True + + @property + def record_changes(self) -> bool: + return self.__record_changes + + @record_changes.setter + def record_changes(self, state: bool) -> bool: + self.__record_changes = state + + @property + def property_changes(self) -> list[str]: + property_changes = [] + if len(self.__property_changes) > 0: + property_changes.extend( [ "--edit", f"track:{(self.track_id + 1)!s}" ] ) + property_changes += self.__property_changes + return property_changes + + @property + def track_name(self) -> str: + return self._track_name + + @track_name.setter + def track_name(self, new_name: str | None) -> None: +# print( f"({self.track_id}) Current name = { self._track_name }" ) +# print( f"({self.track_id}) New name = { new_name }" ) + if self._track_name != new_name: + self._track_name = new_name + if self.__record_changes: + if new_name and new_name != "": + self.__property_changes.extend( [ "--set", f"name={ quote( new_name ) }" ] ) + else: + self.__property_changes.extend( [ "--delete", "name" ] ) + + def __toggle_flag( self, flag_name: str, flag_value: bool ) -> None: + if self.__flags[ flag_name ] != flag_value: + self.__flags[ flag_name ] = flag_value + if self.__record_changes: + self.__property_changes.extend( [ "--set", f"{flag_name}={'1' if flag_value else '0'}" ] ) + + @property + def default_track(self) -> bool: + """ + Get the current state of the flag-default. + This is a compatibility property for the removal of the + attribute of the same name. + + Returns: + bool: The current state of the flag-default. + """ + return self.__flags["flag-default"] + + @default_track.setter + def default_track(self, flag: bool) -> None: + """ + Set the state of the flag-default. + This is a compatibility property for the removal of the + attribute of the same name. + + Args: + flag (bool): The new value for flag-default + """ + self.__toggle_flag("flag-default", flag) + + @property + def forced_track(self) -> bool: + """ + Get the current state of the flag-default. + This is a compatibility property for the removal of the + attribute of the same name. + + Returns: + bool: The current state of the flag-default. + """ + return self.__flags["flag-forced"] + + @forced_track.setter + def forced_track(self, flag: bool) -> None: + """ + Set the state of the flag-forced. + This is a compatibility property for the removal of the + attribute of the same name. + + Args: + flag (bool): The new value for flag-forced + """ + self.__toggle_flag("flag-forced", flag) + + @property + def flag_commentary(self) -> bool: + """ + Get the current state of the flag-commentary. + This is a compatibility property for the removal of the + attribute of the same name. + + Returns: + bool: The current state of the flag-commentary. + """ + return self.__flags["flag-commentary"] + + @flag_commentary.setter + def flag_commentary(self, flag: bool) -> None: + """ + Set the state of the flag-commentary. + This is a compatibility property for the removal of the + attribute of the same name. + + Args: + flag (bool): The new value for flag-commentary + """ + self.__toggle_flag("flag-commentary", flag) + + @property + def flag_original(self) -> bool: + """ + Get the current state of the flag-original. + This is a compatibility property for the removal of the + attribute of the same name. + + Returns: + bool: The current state of the flag-original. + """ + return self.__flags["flag-original"] + + @flag_original.setter + def flag_original(self, flag: bool) -> None: + """ + Set the state of the flag-original. + This is a compatibility property for the removal of the + attribute of the same name. + + Args: + flag (bool): The new value for flag-original + """ + self.__toggle_flag("flag-original", flag) + + @property + def flag_hearing_impaired(self) -> bool: + """ + Get the current state of the flag-hearing-impaired. + This is a compatibility property for the removal of the + attribute of the same name. + + Returns: + bool: The current state of the flag-hearing-impaired. + """ + return self.__flags["flag-hearing-impaired"] + + @flag_hearing_impaired.setter + def flag_hearing_impaired(self, flag: bool) -> None: + """ + Set the state of the flag-hearing-impaired. + This is a compatibility property for the removal of the + attribute of the same name. + + Args: + flag (bool): The new value for flag-hearing-impaired + """ + self.__toggle_flag("flag-hearing-impaired", flag) + + @property + def flag_visual_impaired(self) -> bool: + """ + Get the current state of the flag-visual-impaired. + This is a compatibility property for the removal of the + attribute of the same name. + + Returns: + bool: The current state of the flag-visual-impaired. + """ + return self.__flags["flag-visual-impaired"] + + @flag_visual_impaired.setter + def flag_visual_impaired(self, flag: bool) -> None: + """ + Set the state of the flag-visual-impaired. + This is a compatibility property for the removal of the + attribute of the same name. + + Args: + flag (bool): The new value for flag-visual-impaired + """ + self.__toggle_flag("flag-visual-impaired", flag) + + @property + def flag_text_descriptions(self) -> bool: + """ + Get the current state of the flag-text-descriptions. + This is a compatibility property for the removal of the + attribute of the same name. + + Returns: + bool: The current state of the flag-text-descriptions. + """ + return self.__flags["flag-text-descriptions"] + + @flag_text_descriptions.setter + def flag_text_descriptions(self, flag: bool) -> None: + """ + Set the state of the flag-text-descriptions. + This is a compatibility property for the removal of the + attribute of the same name. + + Args: + flag (bool): The new value for flag-text-descriptions + """ + self.__toggle_flag("flag-text-descriptions", flag) + def __repr__(self) -> str: """ Return a string representation of the MKVTrack object. @@ -331,8 +552,13 @@ def language(self, language: str | None) -> None: """ if language is None or is_iso639_2(language): self._language = language + if self.__record_changes: + if language and language != "": + self.__property_changes.extend( [ "--set", f"language={language}" ] ) + else: + self.__property_changes.extend( [ "--delete", "language" ] ) else: - msg = "not an ISO639-2 language code" + msg = f"{language} not an ISO639-2 language code" raise ValueError(msg) @property @@ -402,6 +628,8 @@ def language_ietf(self, language_ietf: str | None) -> None: language_ietf (str): The language to set in BCP47 format. """ self._language_ietf = language_ietf + if self.__record_changes: + if language_ietf and language_ietf != "": self.__property_changes.extend( [ "--set", f"language-ietf={language_ietf}" ] ) @property def tags(self) -> str | None: diff --git a/pymkv/Verifications.py b/pymkv/Verifications.py index 94d6ede..0e4a71c 100644 --- a/pymkv/Verifications.py +++ b/pymkv/Verifications.py @@ -156,6 +156,39 @@ def verify_mkvmerge( return _verify_mkvmerge_cached(mkvmerge_command) +@cache +def _verify_mkvpropedit_cached(mkvpropedit_path_tuple: tuple[str, ...]) -> bool: + """Internal cached function to verify mkvpropedit availability.""" + try: + mkvpropedit_command = list(mkvpropedit_path_tuple) + mkvpropedit_command.append("-V") + output = sp.check_output(mkvpropedit_command).decode() # noqa: S603 + except (sp.CalledProcessError, FileNotFoundError): + return False + return bool(match("mkvpropedit.*", output)) + + +def verify_mkvpropedit( + mkvpropedit_path: str | os.PathLike | Iterable[str] = "mkvpropedit", +) -> bool: + """Verify if mkvpropedit is available at the specified path. + + Parameters + ---------- + mkvpropedit_path : str | os.PathLike | Iterable[str], optional + The path to the mkvpropedit executable. Defaults to "mkvpropedit". + + Returns + ------- + bool + True if mkvpropedit is available at the specified path, False otherwise. + """ + mkvpropedit_command = prepare_mkvtoolnix_path(mkvpropedit_path) + if isinstance(mkvpropedit_command, list): + mkvpropedit_command = tuple(mkvpropedit_command) + return _verify_mkvpropedit_cached(mkvpropedit_command) + + def verify_matroska( file_path: str | os.PathLike[Any], mkvmerge_path: str | os.PathLike | Iterable[str] = "mkvmerge",