Repository: Diaoul/subliminal (python)

Issue:
Subtitles fron NapiProjekt are not in .srt format
Hi,

So all subtitles downloaded from napiprojekt, are in mDVD format, not srt. I noticed though that Subliminal saves them with .srt extension, but if player supports only .srt, they will not work.

Is there a chance to use eg. subconv to convert such subtitles from mDVD to srt format on fly ?

Thanks
## <bountysource-plugin>

Want to back this issue? **[Post a bounty on it!](https://www.bountysource.com/issues/28560659-subtitles-fron-napiprojekt-are-not-in-srt-format?utm_campaign=plugin&utm_content=tracker%2F321252&utm_medium=issues&utm_source=github)** We accept bounties via [Bountysource](https://www.bountysource.com/?utm_campaign=plugin&utm_content=tracker%2F321252&utm_medium=issues&utm_source=github).
</bountysource-plugin>


Pull request description:
Convert subtitles to another format
closes #536 

Add a `--subtitle-format` CLI option to force the conversion of the subtitles to another format, for instance srt.
By default `--subtitle-format=''`, so the subtitles are not converted.

@ptrcnull  it would be good to test this on Napiprojekt subtitles that were in the MicroDVD format.

Patch:
diff --git a/changelog.d/536.change.rst b/changelog.d/536.change.rst
new file mode 100644
index 0000000..ed59b52
--- /dev/null
+++ b/changelog.d/536.change.rst
@@ -0,0 +1,1 @@
+Add a --subtitle-format CLI option to force converting subtitles to another format
diff --git a/src/subliminal/cli.py b/src/subliminal/cli.py
index 55c6351..7c28f8d 100644
--- a/src/subliminal/cli.py
+++ b/src/subliminal/cli.py
@@ -451,6 +451,14 @@ def cache(ctx: click.Context, clear_subliminal: bool) -> None:
     default='utf-8',
     help='Force subtitle file encoding, set to an empty string to preserve the original encoding. Default is utf-8.',
 )
+@click.option(
+    '-F',
+    '--subtitle-format',
+    type=click.STRING,
+    metavar='FORMAT',
+    default='',
+    help="Force subtitle format, set to an empty string to preserve the original format. Default is ''.",
+)
 @click.option(
     '-s',
     '--single',
@@ -551,6 +559,7 @@ def download(
     use_ctime: bool,
     directory: str | None,
     encoding: str | None,
+    subtitle_format: str | None,
     single: bool,
     force: bool,
     hearing_impaired: tuple[bool | None, ...],
@@ -762,6 +771,7 @@ def download(
             single=single,
             directory=directory,
             encoding=encoding,
+            subtitle_format=subtitle_format,
             language_type_suffix=language_type_suffix,
             language_format=language_format,
         )
diff --git a/src/subliminal/core.py b/src/subliminal/core.py
index f60cc00..395efaa 100644
--- a/src/subliminal/core.py
+++ b/src/subliminal/core.py
@@ -791,6 +791,7 @@ def save_subtitles(
     single: bool = False,
     directory: str | os.PathLike | None = None,
     encoding: str | None = None,
+    subtitle_format: str | None = None,
     extension: str | None = None,
     language_type_suffix: bool = False,
     language_format: str = 'alpha2',
@@ -810,6 +811,7 @@ def save_subtitles(
     :param bool single: save a single subtitle, default is to save one subtitle per language.
     :param str directory: path to directory where to save the subtitles, default is next to the video.
     :param str encoding: encoding in which to save the subtitles, default is to keep original encoding.
+    :param str subtitle_format: format in which to save the subtitles, default is to keep original format.
     :param (str | None) extension: the subtitle extension, default is to match to the subtitle format.
     :param bool language_type_suffix: add a suffix 'hi' or 'fo' if needed. Default to False.
     :param str language_format: format of the language suffix. Default to 'alpha2'.
@@ -829,6 +831,12 @@ def save_subtitles(
             logger.debug('Skipping subtitle %r: language already saved', subtitle)
             continue
 
+        # convert subtitle to a new format
+        if subtitle_format:
+            # Use the video FPS if the FPS of the subtitle is not defined
+            fps = video.frame_rate if subtitle.fps is None else None
+            subtitle.convert(subtitle_format, output_encoding=encoding, fps=fps)
+
         # create subtitle path
         subtitle_path = subtitle.get_path(
             video,
diff --git a/src/subliminal/subtitle.py b/src/subliminal/subtitle.py
index d29bee3..6d6b600 100644
--- a/src/subliminal/subtitle.py
+++ b/src/subliminal/subtitle.py
@@ -242,7 +242,7 @@ class Subtitle:
         # Decode
         return self.content.decode(self.encoding, errors='replace')
 
-    def reencode(self, encoding: str = 'utf-8') -> bool:
+    def reencode(self, text: str | None = None, encoding: str = 'utf-8') -> bool:
         """Re-encode the subtitle raw content using the specified encoding.
 
         :param str encoding: the new encoding of the raw content (default to 'utf-8').
@@ -251,7 +251,8 @@ class Subtitle:
 
         """
         # Compute self._text by calling the property
-        text = self.text
+        if text is None:
+            text = self.text
 
         # Text is empty, maybe because the content was not decoded.
         # Reencoding would erase the content, so return.
@@ -266,10 +267,99 @@ class Subtitle:
             return False
 
         # Save the new encoding and new raw content
+        self.clear_content()
         self.encoding = encoding
         self._content = new_content
         return True
 
+    def convert(
+        self,
+        output_format: str = 'srt',
+        output_encoding: str | None = 'utf-8',
+        fps: float | None = None,
+    ) -> bool:
+        """Convert the subtitle to a given format.
+
+        :param str output_format: the new subtitle format (default to 'srt').
+        :param (str | None) output_encoding: specify the encoding, do not change if None (default to None).
+        :param (float | None) fps: the frame rate used to convert from/to a frame rate based subtitle (default to None).
+        :return: False if the conversion raised an error.
+        :rtype: bool
+
+        """
+        # Compute self._text by calling the property
+        text = self.text
+
+        # Text is empty, maybe because the content was not decoded.
+        # Reencoding would erase the content, so return.
+        if not text:  # pragma: no cover
+            return False
+
+        # Current encoding is not defined, cannot convert
+        if self.encoding is None:  # pragma: no cover
+            logger.error('the current encoding is not defined')
+            return False
+
+        # Use the current encoding by default, otherwise normalize the encoding name
+        output_encoding = self.encoding if output_encoding is None else codecs.lookup(output_encoding).name
+
+        # Pick the subtitle fps if it's not specified as an argument
+        fps = self.fps if fps is None or fps <= 0 else fps
+
+        # Try parsing the subtitle
+        try:
+            obj = SSAFile.from_string(text, format_=self.subtitle_format, fps=fps)
+        except UnknownFPSError:
+            logger.exception('need to specify the FPS to convert this subtitle')
+            return False
+        except Exception:  # pragma: no cover
+            logger.exception('not a valid subtitle')
+            return False
+
+        # Check subtitle format
+        self.subtitle_format = str(obj.format)
+        convert_format = True
+        if self.subtitle_format == output_format:
+            logger.debug('the subtitle is already in the correct format: %s', output_format)
+            convert_format = False
+            if self.encoding == output_encoding:
+                if output_encoding is not None:  # pragma: no branch
+                    logger.debug('the subtitle is already in the correct encoding: %s', output_encoding)
+                return True
+
+        if convert_format:
+            # Try converting
+            try:
+                new_text = obj.to_string(format_=output_format, fps=fps)
+            except Exception:  # pragma: no cover
+                logger.exception('cannot convert subtitle to %s format', output_format)
+                return False
+
+        else:
+            # Do not convert to a new format
+            new_text = text
+
+        # Validate srt
+        if output_format == 'srt':
+            try:
+                parsed = self.parse_srt(new_text)
+            except Exception:  # pragma: no cover
+                msg = 'srt parsing failed, converted subtitle is invalid'
+                logger.exception(msg)
+                return False
+            new_text = parsed
+
+        # Save the new content
+        ret = self.reencode(new_text, encoding=output_encoding)
+
+        # Conversion success
+        if ret:  # pragma: no branch
+            self._is_valid = True
+            self.encoding = output_encoding
+            self.subtitle_format = output_format
+
+        return ret
+
     def is_valid(self, *, auto_fix_srt: bool = False) -> bool:
         """Check if a :attr:`text` is a valid SubRip format.
 
@@ -305,7 +395,7 @@ class Subtitle:
         # Valid srt
         if self.subtitle_format == 'srt':
             try:
-                parsed = self.parse_srt()
+                parsed = self.parse_srt(self.text)
             except Exception:  # pragma: no cover
                 msg = 'srt parsing failed, subtitle is invalid'
                 logger.exception(msg)
@@ -318,9 +408,10 @@ class Subtitle:
         # TODO: check other formats
         return True
 
-    def parse_srt(self) -> str:
+    @staticmethod
+    def parse_srt(text: str) -> str:
         """Text content parsed to a valid srt subtitle."""
-        return str(srt.compose(srt.parse(self.text)))
+        return str(srt.compose(srt.parse(text)))
 
     def guess_encoding(self) -> str | None:
         """Guess encoding using the language, falling back on chardet.


Test patch:
diff --git a/tests/test_subtitle.py b/tests/test_subtitle.py
index 5918996..f83b3f9 100644
--- a/tests/test_subtitle.py
+++ b/tests/test_subtitle.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import os
+from textwrap import dedent
 
 import pytest
 from babelfish import Language  # type: ignore[import-untyped]
@@ -283,6 +284,236 @@ def test_subtitle_reencode() -> None:
     assert subtitle.content == b'Uma palavra longa \xc3\xa9 melhor do que um p\xc3\xa3o curto'
 
 
+def test_subtitle_reencode_text() -> None:
+    text = 'Uma palavra longa é melhor do que um pão curto'
+    subtitle = Subtitle(
+        language=Language('por'),
+        encoding='latin1',
+    )
+
+    assert not subtitle.text
+
+    success = subtitle.reencode(text)
+    assert success
+    assert subtitle.content == b'Uma palavra longa \xc3\xa9 melhor do que um p\xc3\xa3o curto'
+    assert subtitle.text == text
+
+
+def test_subtitle_convert_empty() -> None:
+    subtitle = Subtitle(
+        language=Language('fra'),
+        encoding='latin1',
+    )
+    assert not subtitle.convert()
+
+
+def test_subtitle_convert_same_format_same_encoding() -> None:
+    subtitle = Subtitle(
+        language=Language('eng'),
+        encoding='utf-8',
+    )
+    text = "1\n00:00:05,000 --> 00:00:10,000\n«Attention, ce flim n'est pas un flim sur le cyclimse»\n\n"
+    subtitle.content = text.encode('utf-8')
+    assert subtitle.encoding == 'utf-8'
+    assert subtitle.text == text
+
+    assert subtitle.convert(output_format='srt', output_encoding=None)
+
+
+def test_subtitle_convert_same_format_different_encoding() -> None:
+    subtitle = Subtitle(
+        language=Language('fra'),
+        encoding='latin1',
+    )
+    text = "1\n00:00:05,000 --> 00:00:10,000\n«Attention, ce flim n'est pas un flim sur le cyclimse»\n\n"
+    subtitle.content = text.encode('latin1')
+    assert subtitle.encoding == 'iso8859-1'
+    assert subtitle.text == text
+
+    assert subtitle.convert(output_format='srt', output_encoding='utf-8')
+    assert subtitle.encoding == 'utf-8'
+    assert subtitle.text == text
+
+
+def test_subtitle_convert_from_microdvd_no_fps() -> None:
+    subtitle = Subtitle(
+        language=Language('pol'),
+    )
+    text = """\
+    {1189}{1271}Tłumaczenie:|sinu6
+    {3146}{3189}/Nie rozumiecie?
+    {3189}{3244}/Jądro Kryptona się rozpada.
+    {3244}{3299}To kwestia tygodni.
+    {3299}{3390}Ostrzegałem, że eksploatacja jądra|to samobójstwo.
+    """
+    subtitle.content = text.encode('utf-8')
+
+    assert not subtitle.convert(output_format='srt', output_encoding='utf-8')
+
+
+def test_subtitle_convert_from_microdvd_subtitle_fps() -> None:
+    subtitle = Subtitle(
+        language=Language('pol'),
+        fps=24,
+    )
+    text = """\
+    {1189}{1271}Tłumaczenie:|sinu6
+    {3146}{3189}/Nie rozumiecie?
+    {3189}{3244}/Jądro Kryptona się rozpada.
+    {3244}{3299}To kwestia tygodni.
+    {3299}{3390}Ostrzegałem, że eksploatacja jądra|to samobójstwo.
+    """
+    subtitle.content = text.encode('utf-8')
+
+    assert subtitle.convert(output_format='srt', output_encoding='utf-8')
+    assert subtitle.subtitle_format == 'srt'
+    assert subtitle.encoding == 'utf-8'
+
+    new_text = dedent(
+        """\
+        1
+        00:00:49,542 --> 00:00:52,958
+        Tłumaczenie:
+        sinu6
+
+        2
+        00:02:11,083 --> 00:02:12,875
+        /Nie rozumiecie?
+
+        3
+        00:02:12,875 --> 00:02:15,167
+        /Jądro Kryptona się rozpada.
+
+        4
+        00:02:15,167 --> 00:02:17,458
+        To kwestia tygodni.
+
+        5
+        00:02:17,458 --> 00:02:21,250
+        Ostrzegałem, że eksploatacja jądra
+        to samobójstwo.
+
+        """
+    )
+    assert subtitle.text == new_text
+
+
+def test_subtitle_convert_from_microdvd_argument_fps() -> None:
+    subtitle = Subtitle(
+        language=Language('pol'),
+        fps=32,
+    )
+    text = """\
+    {1189}{1271}Tłumaczenie:|sinu6
+    {3146}{3189}/Nie rozumiecie?
+    {3189}{3244}/Jądro Kryptona się rozpada.
+    {3244}{3299}To kwestia tygodni.
+    {3299}{3390}Ostrzegałem, że eksploatacja jądra|to samobójstwo.
+    """
+    subtitle.content = text.encode('utf-8')
+
+    assert subtitle.convert(output_format='srt', output_encoding='utf-8', fps=24)
+    assert subtitle.subtitle_format == 'srt'
+    assert subtitle.encoding == 'utf-8'
+
+    new_text = dedent(
+        """\
+        1
+        00:00:49,542 --> 00:00:52,958
+        Tłumaczenie:
+        sinu6
+
+        2
+        00:02:11,083 --> 00:02:12,875
+        /Nie rozumiecie?
+
+        3
+        00:02:12,875 --> 00:02:15,167
+        /Jądro Kryptona się rozpada.
+
+        4
+        00:02:15,167 --> 00:02:17,458
+        To kwestia tygodni.
+
+        5
+        00:02:17,458 --> 00:02:21,250
+        Ostrzegałem, że eksploatacja jądra
+        to samobójstwo.
+
+        """
+    )
+    assert subtitle.text == new_text
+
+
+def test_subtitle_convert_to_ssa() -> None:
+    subtitle = Subtitle(
+        language=Language('pol'),
+        fps=24,
+    )
+    text = dedent(
+        """\
+        1
+        00:00:49,542 --> 00:00:52,958
+        Tłumaczenie:
+        sinu6
+
+        2
+        00:02:11,083 --> 00:02:12,875
+        /Nie rozumiecie?
+
+        3
+        00:02:12,875 --> 00:02:15,167
+        /Jądro Kryptona się rozpada.
+
+        4
+        00:02:15,167 --> 00:02:17,458
+        To kwestia tygodni.
+
+        5
+        00:02:17,458 --> 00:02:21,250
+        Ostrzegałem, że eksploatacja jądra
+        to samobójstwo.
+
+        """
+    )
+    subtitle.content = text.encode('utf-8')
+
+    assert subtitle.convert(output_format='ass')
+    assert subtitle.subtitle_format == 'ass'
+    assert subtitle.encoding == 'utf-8'
+
+    # Define a variable to be able to wrap the long line
+    styles = (
+        'Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, '
+        'Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, '
+        'Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding'
+    )
+    new_text = dedent(
+        f"""\
+        [Script Info]
+        ; Script generated by pysubs2
+        ; https://pypi.python.org/pypi/pysubs2
+        WrapStyle: 0
+        ScaledBorderAndShadow: yes
+        Collisions: Normal
+        ScriptType: v4.00+
+
+        [V4+ Styles]
+        Format: {styles}
+        Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
+
+        [Events]
+        Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+        Dialogue: 0,0:00:49.54,0:00:52.96,Default,,0,0,0,,Tłumaczenie:\\Nsinu6
+        Dialogue: 0,0:02:11.08,0:02:12.88,Default,,0,0,0,,/Nie rozumiecie?
+        Dialogue: 0,0:02:12.88,0:02:15.17,Default,,0,0,0,,/Jądro Kryptona się rozpada.
+        Dialogue: 0,0:02:15.17,0:02:17.46,Default,,0,0,0,,To kwestia tygodni.
+        Dialogue: 0,0:02:17.46,0:02:21.25,Default,,0,0,0,,Ostrzegałem, że eksploatacja jądra\\Nto samobójstwo.
+        """
+    )
+    assert subtitle.text == new_text
+
+
 def test_subtitle_info(monkeypatch) -> None:
     subtitle = Subtitle(
         Language('eng'),