diff --git a/tests/core/test_export_name.py b/tests/core/test_export_name.py new file mode 100644 index 00000000..8a677bf1 --- /dev/null +++ b/tests/core/test_export_name.py @@ -0,0 +1,240 @@ +"""Tests for the ``--export`` file name and the messages a run with ``--export`` logs.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from uuid import UUID + +import mediaexport +import pytest + +from unshackle.commands.dl import dl +from unshackle.core.export_name import ExportNamer, export_name, move_export, season_episodes +from unshackle.core.titles import Episode, Movie, Song +from unshackle.core.tracks import Audio, Subtitle, Video + +KID = UUID(hex="00000000000000000000000000000001") + + +class StubService: + """Stands in for the service class slot on a title; never instantiated.""" + + +class StubDRM: + def __init__(self) -> None: + self.content_keys = {KID: "aa" * 16} + + def to_dict(self) -> dict: + return {"system": "Widevine", "pssh_b64": "AAAA"} + + +def video( + id_: str = "v1", + height: int = 1080, + width: int = 1920, + codec: Video.Codec = Video.Codec.AVC, + range_: Video.Range = Video.Range.SDR, + descriptor: Video.Descriptor = Video.Descriptor.DASH, +) -> Video: + return Video( + id_=id_, + url=f"https://example.test/{id_}.mp4", + language="en", + codec=codec, + range_=range_, + width=width, + height=height, + descriptor=descriptor, + ) + + +def audio( + id_: str = "a1", + codec: Audio.Codec = Audio.Codec.AAC, + channels: str = "2.0", + descriptor: Audio.Descriptor = Audio.Descriptor.DASH, + joc: int = 0, +) -> Audio: + return Audio( + id_=id_, + url=f"https://example.test/{id_}.mp4", + language="en", + codec=codec, + channels=channels, + descriptor=descriptor, + joc=joc, + ) + + +def episode(season: int, number: int, name: str = "Pilot") -> Episode: + return Episode( + id_=f"ep-{season}-{number}", service=StubService, title="Example Show", season=season, number=number, name=name + ) + + +def movie(name: str = "Example Movie", year: int | None = 2024) -> Movie: + return Movie(id_="movie-1", service=StubService, name=name, year=year) + + +def seasons_of(*shape: tuple[int, int]) -> dict[int, set[int]]: + return season_episodes([episode(s, n) for s, count in shape for n in range(1, count + 1)]) + + +def test_movie_name_has_year_and_no_scope() -> None: + name = export_name([movie()], [video(), audio(descriptor=Audio.Descriptor.HLS)], "EXAMPLE") + assert name == "Example Movie 2024_1080p_h264_sdr_dash_hls_aac2.0-EXAMPLE" + + +def test_movie_without_year() -> None: + assert export_name([movie(year=None)], [video()], "EXAMPLE") == "Example Movie_1080p_h264_sdr_dash-EXAMPLE" + + +def test_full_season_is_the_season_number() -> None: + titles = [episode(1, n) for n in range(1, 4)] + name = export_name(titles, [video(), audio(codec=Audio.Codec.AC3, channels="5.1")], "EXAMPLE", seasons_of((1, 3))) + assert name == "Example Show S01_1080p_h264_sdr_dash_dd5.1-EXAMPLE" + + +def test_contiguous_subset_is_one_range() -> None: + titles = [episode(1, n) for n in (5, 6, 7, 8, 9)] + assert export_name(titles, [], "EXAMPLE", seasons_of((1, 10))) == "Example Show S01E05-E09-EXAMPLE" + + +def test_non_contiguous_subset_lists_each_run() -> None: + titles = [episode(1, n) for n in (1, 2, 5, 9)] + assert export_name(titles, [], "EXAMPLE", seasons_of((1, 10))) == "Example Show S01E01-E02_S01E05_S01E09-EXAMPLE" + + +def test_many_runs_are_cut_short() -> None: + titles = [episode(1, n) for n in (1, 3, 5, 7, 9, 11)] + name = export_name(titles, [], "EXAMPLE", seasons_of((1, 12))) + assert name == "Example Show S01E01_S01E03_S01E05_+3-EXAMPLE" + + +def test_unknown_season_list_uses_ranges() -> None: + titles = [episode(1, n) for n in (1, 2, 3)] + assert export_name(titles, [], "EXAMPLE") == "Example Show S01E01-E03-EXAMPLE" + + +def test_whole_seasons_in_a_row_collapse() -> None: + titles = [episode(s, n) for s in (1, 2, 3) for n in (1, 2)] + assert export_name(titles, [], "EXAMPLE", seasons_of((1, 2), (2, 2), (3, 2))) == "Example Show S01-S03-EXAMPLE" + + +def test_whole_season_and_a_partial_season() -> None: + titles = [episode(1, 1), episode(1, 2), episode(2, 1)] + assert export_name(titles, [], "EXAMPLE", seasons_of((1, 2), (2, 4))) == "Example Show S01_S02E01-EXAMPLE" + + +def test_quality_parts_are_deduplicated_and_ordered() -> None: + tracks = [ + video("v1", 1080, codec=Video.Codec.HEVC, range_=Video.Range.HDR10), + video("v2", 2160, 3840, codec=Video.Codec.HEVC, range_=Video.Range.SDR, descriptor=Video.Descriptor.ISM), + video("v3", 1080, codec=Video.Codec.AVC, range_=Video.Range.DV), + video("v4", 800, 1920, codec=Video.Codec.HEVC, range_=Video.Range.HDR10), + audio("a1", Audio.Codec.EC3, "5.1", joc=16), + audio("a2", Audio.Codec.AAC, "2.0"), + audio("a3", Audio.Codec.EC3, "5.1", joc=16), + audio("a4", Audio.Codec.EC3, "2.0"), + Subtitle(id_="s1", url="https://example.test/s1.vtt", language="en", codec=Subtitle.Codec.WebVTT), + ] + name = export_name([movie()], tracks, "EXAMPLE") + assert name == "Example Movie 2024_2160p_1080p_h264_h265_sdr_hdr10_dv_dash_ism_aac2.0_ddp5.1atmos_ddp2.0-EXAMPLE" + + +def test_title_is_filename_safe_and_keeps_letters() -> None: + name = export_name([movie('Café: "Ünïcode"/Kanji 映画?', year=None)], [], "EXAMPLE") + assert name == "Café Ünïcode & Kanji 映画-EXAMPLE" + + +def test_song_uses_artist_and_album() -> None: + song = Song( + id_="track-1", service=StubService, name="Track", artist="Example Artist", album="Example Album", track=1 + ) + assert export_name([song], [audio(codec=Audio.Codec.FLAC)], "EXAMPLE") == ( + "Example Artist - Example Album_dash_flac2.0-EXAMPLE" + ) + + +def test_collision_gets_a_suffix(tmp_path: Path) -> None: + other = tmp_path / "Example Movie 2024-EXAMPLE.json" + other.write_text("{}") + current = tmp_path / "export_EXAMPLE_1.json" + current.write_text("mine") + + moved = move_export(current, "Example Movie 2024-EXAMPLE") + + assert moved.name == "Example Movie 2024-EXAMPLE-2.json" + assert other.read_text() == "{}" + assert moved.read_text() == "mine" + assert not current.exists() + # the run's own file keeps its suffix instead of moving on to -3 + assert move_export(moved, "Example Movie 2024-EXAMPLE") == moved + + +def make_dl(tmp_path: Path, seasons: dict[int, set[int]] | None = None) -> dl: + instance = dl.__new__(dl) + instance.service = "EXAMPLE" + instance.log = logging.getLogger("download") + instance.export_namer = ExportNamer("EXAMPLE", seasons or {}) + return instance + + +def test_export_is_renamed_as_titles_arrive_and_stays_valid(tmp_path: Path) -> None: + instance = make_dl(tmp_path, seasons_of((1, 2))) + work = tmp_path / "export_EXAMPLE_1.json" + first, second = episode(1, 1), episode(1, 2) + for title in (first, second): + title.tracks.add(video(f"v{title.number}")) + + instance.write_export(work, first, first.tracks.videos[0], StubDRM()) + partial = tmp_path / "Example Show S01E01_1080p_h264_sdr_dash-EXAMPLE.json" + assert [p.name for p in tmp_path.iterdir()] == [partial.name] + assert [e.id for e in mediaexport.read(partial).titles] == ["ep-1-1"] + + instance.write_export(work, second, second.tracks.videos[0]) + final = tmp_path / "Example Show S01_1080p_h264_sdr_dash-EXAMPLE.json" + assert [p.name for p in tmp_path.iterdir()] == [final.name] + assert [e.id for e in mediaexport.read(final).titles] == ["ep-1-1", "ep-1-2"] + + +def test_export_messages(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + instance = make_dl(tmp_path) + title = movie() + title.tracks.add(video()) + title.tracks.add(audio()) + title.tracks.add(audio("a2")) + work = tmp_path / "export_EXAMPLE_1.json" + + with caplog.at_level(logging.INFO, logger="download"): + for track in title.tracks: + instance.write_export(work, title, track, StubDRM() if isinstance(track, Video) else None) + instance.log_export_title(title) + instance.log_export_summary() + + lines = [r.getMessage() for r in caplog.records] + assert "Exporting to Example Movie 2024_1080p_h264_sdr_dash-EXAMPLE.json" in lines + assert "Exported 'Example Movie' (2024): 1 video, 2 audio, 0 subtitles, 1 key" in lines + final = tmp_path / "Example Movie 2024_1080p_h264_sdr_dash_aac2.0-EXAMPLE.json" + assert f"Saved the export to {final}: 1 title, 1 key" in lines + assert any("content keys" in line for line in lines) + + +def test_episode_message_names_the_episode(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + instance = make_dl(tmp_path) + title = episode(1, 5, "Example Episode") + title.tracks.add(video()) + instance.write_export(tmp_path / "export_EXAMPLE_1.json", title, title.tracks.videos[0]) + + with caplog.at_level(logging.INFO, logger="download"): + instance.log_export_title(title) + + assert "Exported S01E05 'Example Episode': 1 video, 0 audio, 0 subtitles, 0 keys" in caplog.messages + + +def test_summary_when_nothing_was_exported(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + instance = make_dl(tmp_path) + with caplog.at_level(logging.INFO, logger="download"): + instance.log_export_summary() + assert "Nothing was exported" in caplog.messages diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 9b22d34d..8edf2c09 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -50,6 +50,7 @@ from unshackle.core.credential import Credential from unshackle.core.downloaders import default_max_workers, format_speed, parse_speed_limit, set_speed_limit from unshackle.core.drm import DRM_T, ClearKeyCENC, MonaLisa, PlayReady, Widevine, own_kids, verify from unshackle.core.events import events +from unshackle.core.export_name import ExportNamer, move_export, plural, season_episodes, title_label from unshackle.core.providers.anilist import parse_anilist_ref from unshackle.core.providers.tvdb import SEASON_TYPES, parse_int from unshackle.core.proxies import ( @@ -1661,8 +1662,10 @@ class dl: if export: config.directories.exports.mkdir(parents=True, exist_ok=True) + # a working name until the first write, which renames it after what it holds export_path = config.directories.exports / f"export_{self.service}_{int(time.time())}.json" self.export_service = service + self.export_namer = ExportNamer(self.service) else: export_path = None @@ -1875,6 +1878,10 @@ class dl: if list_titles: return + if export_path and isinstance(titles, Series): + # before --select-titles and -w drop titles, so a whole season can be told from part of one + self.export_namer.seasons = season_episodes(titles) + if select_titles and isinstance(titles, Movies) and len(titles) > 1: console.print(Padding(Rule("[rule.text]Select Titles"), (1, 2))) @@ -3029,6 +3036,8 @@ class dl: context={"title": str(title)}, ) self.wait_vault_writes() + if export_path: + self.log_export_summary() return except Exception as e: # noqa # Reported and swallowed (no re-raise) so the CLI exits cleanly; flag it so the @@ -3063,9 +3072,13 @@ class dl: postscript, ) self.wait_vault_writes() + if export_path: + self.log_export_summary() return self.wait_vault_writes() + if export_path: + self.log_export_title(title) if skip_dl: console.log("Skipped downloads as --skip-dl was used...") @@ -3711,6 +3724,8 @@ class dl: dispatch("success", "run", run_context, postscript) self.wait_vault_writes() + if export_path: + self.log_export_summary() dl_time = time_elapsed_since(start_time) console.print(Padding(f"Processed all titles in [progress.elapsed]{dl_time}", (0, 5, 1, 5))) @@ -3951,7 +3966,10 @@ class dl: records the track, manifest, chapter and attachment info. unshackle's full track dicts and title meta go under ``x-unshackle`` for its own importer. """ + namer: Optional[ExportNamer] = getattr(self, "export_namer", None) with self.EXPORT_LOCK: + if namer and namer.path: + export = namer.path try: doc = mediaexport.read(export) if export.is_file() else mediaexport.Document(service_tag=self.service) except mediaexport.ExportError as e: @@ -4009,6 +4027,62 @@ class dl: mediaexport.write(export, doc) except (mediaexport.ExportError, OSError) as e: self.log.warning(f"Could not write export {export}: {e}") + return + if namer: + self.rename_export(namer, export, title, track) + + def rename_export(self, namer: ExportNamer, export: Path, title: Title_T, track: AnyTrack) -> None: + """Give the run's export a name that tells what it holds, after each write to it. + + The file on disk is complete after each write, so a run that stops early still leaves + a usable file with a name that matches what it holds. + """ + first = namer.path is None + namer.add(title, track) + try: + namer.path = move_export(export, namer.name()) + except OSError as e: + self.log.warning(f"Could not rename export {export}: {e}") + namer.path = export + if first: + self.log.info(f"Exporting to {namer.path.name}") + elif namer.path != export: + self.log.debug(f"Renamed the export to {namer.path.name}") + + def log_export_title(self, title: Title_T) -> None: + """Log what the export holds for one title: its track counts and its keys.""" + namer: Optional[ExportNamer] = getattr(self, "export_namer", None) + entry = None + if namer and namer.path: + try: + entry = mediaexport.read(namer.path).get(str(title.id)) + except (mediaexport.ExportError, OSError) as e: + self.log.warning(f"Could not read export {namer.path}: {e}") + return + if entry is None: + self.log.warning(f"Exported nothing for {title_label(title)}") + return + selected = [r.get("type") for r in entry.tracks if r.get("selected")] + self.log.info( + f"Exported {title_label(title)}: {selected.count('video')} video, {selected.count('audio')} audio, " + f"{plural(selected.count('subtitle'), 'subtitle')}, {plural(len(entry.keys), 'key')}" + ) + + def log_export_summary(self) -> None: + """Log where the run's export is and what it holds, and that it must stay private.""" + namer: Optional[ExportNamer] = getattr(self, "export_namer", None) + if not namer or not namer.path: + self.log.warning("Nothing was exported") + return + try: + doc = mediaexport.read(namer.path) + except (mediaexport.ExportError, OSError) as e: + self.log.warning(f"Could not read export {namer.path}: {e}") + return + keys = len(doc.key_pool()) + self.log.info(f"Saved the export to {namer.path}: {plural(len(doc.titles), 'title')}, {plural(keys, 'key')}") + if keys: + self.log.warning("The export holds content keys. Keep it private.") def export_entry(self, title: Title_T) -> mediaexport.Entry: """A mediaexport entry for ``title``: manifests, chapters, a small track list, no keys yet.""" diff --git a/unshackle/core/export_name.py b/unshackle/core/export_name.py new file mode 100644 index 00000000..87a6d551 --- /dev/null +++ b/unshackle/core/export_name.py @@ -0,0 +1,206 @@ +"""The file name of a ``dl --export`` file, built from the titles and tracks it holds. + +The name has the form ``{title} {scope}_{resolutions}_{codecs}_{ranges}_{manifests}_{audio}-{TAG}``, +for example ``Example Show S01_2160p_h265_sdr_hdr10_dash_ddp5.1-EXAMPLE``. An empty part is left out. +""" + +from __future__ import annotations + +import os +from collections.abc import Collection, Iterable, Mapping, Sequence +from pathlib import Path +from typing import Any, Optional + +from unshackle.core.constants import AnyTrack +from unshackle.core.titles import Episode, Movie, Song, Title_T +from unshackle.core.tracks import Audio, Video +from unshackle.core.utilities import sanitize_filename + +STANDARD_HEIGHTS = (4320, 2160, 1440, 1080, 720, 576, 480, 360) +MANIFEST_ORDER = (Video.Descriptor.DASH, Video.Descriptor.HLS, Video.Descriptor.ISM, Video.Descriptor.URL) +MAX_SCOPE_PARTS = 3 +MAX_TITLE_LENGTH = 100 + + +def season_episodes(titles: Iterable[Any]) -> dict[int, set[int]]: + """The episode numbers of each season in ``titles``, to tell a whole season from part of one.""" + seasons: dict[int, set[int]] = {} + for t in titles: + if isinstance(t, Episode) and t.number is not None: + seasons.setdefault(t.season or 0, set()).add(t.number) + return seasons + + +def plural(count: int, word: str) -> str: + return f"{count} {word}" if count == 1 else f"{count} {word}s" + + +def title_label(title: Any) -> str: + """A short label for one title in a message, e.g. ``S01E05 'Name'`` or ``'Name' (2024)``.""" + if isinstance(title, Episode): + label = f"S{title.season or 0:02}E{title.number or 0:02}" + return f"{label} '{title.name}'" if title.name else label + if isinstance(title, Song): + return f"'{title.name}' by {title.artist}" + if isinstance(title, Movie): + return f"'{title.name}' ({title.year})" if title.year else f"'{title.name}'" + return f"'{title}'" + + +def title_part(titles: Sequence[Title_T]) -> str: + first = titles[0] + if isinstance(first, Episode): + text = first.title + elif isinstance(first, Song): + text = f"{first.artist} - {first.album or first.name}" + elif isinstance(first, Movie): + text = f"{first.name} {first.year}" if first.year else first.name + others = len({t.id for t in titles}) - 1 + if others: + text += f" +{others}" + else: + text = str(first) + return sanitize_filename(text, spacer=" ", unicode=True)[:MAX_TITLE_LENGTH].strip(" .") + + +def scope_part(titles: Sequence[Title_T], seasons: Mapping[int, Collection[int]]) -> str: + """``S01`` for a whole season, ``S01E05-E09`` for a run of episodes, runs joined with ``_``. + + A season counts as whole only when every episode the service listed for it is in ``titles``. + Whole seasons in a row collapse to ``S01-S03``. After ``MAX_SCOPE_PARTS`` parts, ``+N`` + counts the parts left out. + """ + exported = season_episodes(titles) + parts: list[str] = [] + whole_run: list[int] = [] + + def close_whole_run() -> None: + if whole_run: + first, last = whole_run[0], whole_run[-1] + parts.append(f"S{first:02}" if first == last else f"S{first:02}-S{last:02}") + whole_run.clear() + + for season in sorted(exported): + numbers = sorted(exported[season]) + listed = seasons.get(season) + if listed and set(listed) <= exported[season]: + if whole_run and whole_run[-1] != season - 1: + close_whole_run() + whole_run.append(season) + continue + close_whole_run() + start = prev = numbers[0] + for n in [*numbers[1:], None]: + if n is not None and n == prev + 1: + prev = n + continue + run = f"S{season:02}E{start:02}" + parts.append(run if start == prev else f"{run}-E{prev:02}") + if n is not None: + start = prev = n + close_whole_run() + + if len(parts) > MAX_SCOPE_PARTS: + parts = [*parts[:MAX_SCOPE_PARTS], f"+{len(parts) - MAX_SCOPE_PARTS}"] + return "_".join(parts) + + +def resolution(track: Video) -> Optional[int]: + """The resolution a release name uses: a letterboxed 1920x800 is 1080p, from its width.""" + if not track.height: + return None + height = track.height + if track.width and track.width > height: + height = max(height, track.width * 9 // 16) + return next((std for std in STANDARD_HEIGHTS if abs(height - std) <= 16), height) + + +def audio_token(track: Audio) -> str: + """``dd5.1``, ``ddp5.1atmos``, ``aac2.0``: the codec and channels of one audio track.""" + codec = track.codec.value.lower().replace("+", "p").replace(" ", "") if track.codec else "audio" + channels = track.channels + if isinstance(channels, float): + channels = f"{channels:.1f}" + return f"{codec}{channels or ''}{'atmos' if track.joc else ''}" + + +def quality_parts(tracks: Iterable[AnyTrack]) -> list[str]: + tracks = list(tracks) + videos = [t for t in tracks if isinstance(t, Video)] + audios = [t for t in tracks if isinstance(t, Audio)] + codec_order = list(Video.Codec) + range_order = list(Video.Range) + audio_order = list(Audio.Codec) + + heights = sorted({h for v in videos if (h := resolution(v))}, reverse=True) + codecs = sorted({v.codec for v in videos if v.codec}, key=codec_order.index) + ranges = sorted({v.range for v in videos if v.range}, key=range_order.index) + media = videos + audios or tracks + manifests = [d for d in MANIFEST_ORDER if any(t.descriptor == d for t in media)] + audio_sorted = sorted( + audios, + key=lambda a: ( + audio_order.index(a.codec) if a.codec else len(audio_order), + -Audio.channel_total(a.channels) if a.channels else 0, + not a.joc, + ), + ) + + groups = [ + [f"{h}p" for h in heights], + [c.value.lower().replace(".", "") for c in codecs], + [r.value.lower() for r in ranges], + [d.name.lower() for d in manifests], + list(dict.fromkeys(audio_token(a) for a in audio_sorted)), + ] + return [token for group in groups for token in group] + + +def export_name( + titles: Sequence[Title_T], + tracks: Iterable[AnyTrack], + service_tag: str, + seasons: Optional[Mapping[int, Collection[int]]] = None, +) -> str: + """The export file name without ``.json``, from the exported ``titles`` and ``tracks``. + + ``seasons`` holds the episode numbers of each season the service listed. Without it, + a season is never whole and its episodes show as ranges. + """ + head = title_part(titles) if titles else "" + scope = scope_part(titles, seasons or {}) if titles and isinstance(titles[0], Episode) else "" + head = f"{head} {scope}".strip() + return "_".join(p for p in (head, *quality_parts(tracks)) if p) + f"-{service_tag}" + + +def move_export(current: Path, name: str) -> Path: + """Move the run's export file to ``name`` in its folder, and return the new path. + + Another file at that name is never overwritten: the name gets ``-2``, ``-3``, and so on. + """ + target = current.with_name(f"{name}.json") + n = 2 + while target != current and target.exists(): + target = current.with_name(f"{name}-{n}.json") + n += 1 + if target != current: + os.replace(current, target) + return target + + +class ExportNamer: + """The state of one run's export: its titles, tracks, and the current file path.""" + + def __init__(self, service_tag: str, seasons: Optional[Mapping[int, Collection[int]]] = None) -> None: + self.service_tag = service_tag + self.seasons: dict[int, set[int]] = {season: set(numbers) for season, numbers in (seasons or {}).items()} + self.titles: dict[str, Title_T] = {} + self.tracks: dict[str, AnyTrack] = {} + self.path: Optional[Path] = None + + def add(self, title: Title_T, track: AnyTrack) -> None: + self.titles.setdefault(str(title.id), title) + self.tracks.setdefault(f"{title.id}/{track.id}", track) + + def name(self) -> str: + return export_name(list(self.titles.values()), self.tracks.values(), self.service_tag, self.seasons) diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 2e316bed..bf4fbb03 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -118,7 +118,7 @@ def import_module_by_path(path: Path) -> ModuleType: return module -def sanitize_filename(filename: str, spacer: str = ".") -> str: +def sanitize_filename(filename: str, spacer: str = ".", unicode: Optional[bool] = None) -> str: """ Sanitise a string to be filename safe. @@ -127,9 +127,10 @@ def sanitize_filename(filename: str, spacer: str = ".") -> str: Set `unicode_filenames: true` in config to preserve the characters of the original language (for example Korean, Japanese, or Chinese) instead of - transliterating them to ASCII equivalents. + transliterating them to ASCII equivalents. Pass ``unicode`` to decide that + for one call instead of the config value. """ - if not config.unicode_filenames: + if not (config.unicode_filenames if unicode is None else unicode): filename = unidecode(filename) filename = re.sub(r"\[\(+", "[", filename) filename = re.sub(r"\)+\]", "]", filename)