mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
feat(export): separate export file name parts with dots
Titles, scope and quality parts use dots and never follow output_template.
This commit is contained in:
@@ -789,14 +789,17 @@ unshackle dl --skip-dl --export EXAMPLE 81234567
|
|||||||
```
|
```
|
||||||
|
|
||||||
The file name tells what the file holds, in the form
|
The file name tells what the file holds, in the form
|
||||||
`{title} {scope}_{resolutions}_{codecs}_{ranges}_{manifests}_{audio}-{SERVICE}.json`:
|
`{title}.{scope}.{resolutions}.{codecs}.{ranges}.{manifests}.{audio}-{SERVICE}.json`:
|
||||||
|
|
||||||
| Export | File name |
|
| Export | File name |
|
||||||
|---|---|
|
|---|---|
|
||||||
| A movie | `Example Movie 2024_1080p_h264_sdr_hls_aac2.0-EXAMPLE.json` |
|
| A movie | `Example.Movie.2024.1080p.h264.sdr.hls.aac2.0-EXAMPLE.json` |
|
||||||
| A whole season | `Example Show S01_2160p_h265_sdr_hdr10_dash_dd5.1-EXAMPLE.json` |
|
| A whole season | `Example.Show.S01.2160p.h265.sdr.hdr10.dash.dd5.1-EXAMPLE.json` |
|
||||||
| Episodes 5 to 9 and 12 | `Example Show S01E05-E09_S01E12_1080p_h264_sdr_dash_aac2.0-EXAMPLE.json` |
|
| Episodes 5 to 9 and 12 | `Example.Show.S01E05-E09.S01E12.1080p.h264.sdr.dash.aac2.0-EXAMPLE.json` |
|
||||||
|
|
||||||
|
- A dot separates each part and each word of the title. The name has no spaces or
|
||||||
|
underscores. It does not follow your `output_template`: a template with spaces does not
|
||||||
|
change the export name.
|
||||||
- The scope is `S01` only when the file holds every episode the service lists for that season.
|
- The scope is `S01` only when the file holds every episode the service lists for that season.
|
||||||
Otherwise it names each run of episodes. Whole seasons in a row become `S01-S03`. After three
|
Otherwise it names each run of episodes. Whole seasons in a row become `S01-S03`. After three
|
||||||
parts, `+N` counts the parts left out. A movie has no scope.
|
parts, `+N` counts the parts left out. A movie has no scope.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import mediaexport
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from unshackle.commands.dl import dl
|
from unshackle.commands.dl import dl
|
||||||
|
from unshackle.core.config import config
|
||||||
from unshackle.core.export_name import ExportNamer, export_name, move_export, season_episodes
|
from unshackle.core.export_name import ExportNamer, export_name, move_export, season_episodes
|
||||||
from unshackle.core.titles import Episode, Movie, Song
|
from unshackle.core.titles import Episode, Movie, Song
|
||||||
from unshackle.core.tracks import Audio, Subtitle, Video
|
from unshackle.core.tracks import Audio, Subtitle, Video
|
||||||
@@ -83,48 +84,48 @@ def seasons_of(*shape: tuple[int, int]) -> dict[int, set[int]]:
|
|||||||
|
|
||||||
def test_movie_name_has_year_and_no_scope() -> None:
|
def test_movie_name_has_year_and_no_scope() -> None:
|
||||||
name = export_name([movie()], [video(), audio(descriptor=Audio.Descriptor.HLS)], "EXAMPLE")
|
name = export_name([movie()], [video(), audio(descriptor=Audio.Descriptor.HLS)], "EXAMPLE")
|
||||||
assert name == "Example Movie 2024_1080p_h264_sdr_dash_hls_aac2.0-EXAMPLE"
|
assert name == "Example.Movie.2024.1080p.h264.sdr.dash.hls.aac2.0-EXAMPLE"
|
||||||
|
|
||||||
|
|
||||||
def test_movie_without_year() -> None:
|
def test_movie_without_year() -> None:
|
||||||
assert export_name([movie(year=None)], [video()], "EXAMPLE") == "Example Movie_1080p_h264_sdr_dash-EXAMPLE"
|
assert export_name([movie(year=None)], [video()], "EXAMPLE") == "Example.Movie.1080p.h264.sdr.dash-EXAMPLE"
|
||||||
|
|
||||||
|
|
||||||
def test_full_season_is_the_season_number() -> None:
|
def test_full_season_is_the_season_number() -> None:
|
||||||
titles = [episode(1, n) for n in range(1, 4)]
|
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)))
|
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"
|
assert name == "Example.Show.S01.1080p.h264.sdr.dash.dd5.1-EXAMPLE"
|
||||||
|
|
||||||
|
|
||||||
def test_contiguous_subset_is_one_range() -> None:
|
def test_contiguous_subset_is_one_range() -> None:
|
||||||
titles = [episode(1, n) for n in (5, 6, 7, 8, 9)]
|
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"
|
assert export_name(titles, [], "EXAMPLE", seasons_of((1, 10))) == "Example.Show.S01E05-E09-EXAMPLE"
|
||||||
|
|
||||||
|
|
||||||
def test_non_contiguous_subset_lists_each_run() -> None:
|
def test_non_contiguous_subset_lists_each_run() -> None:
|
||||||
titles = [episode(1, n) for n in (1, 2, 5, 9)]
|
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"
|
assert export_name(titles, [], "EXAMPLE", seasons_of((1, 10))) == "Example.Show.S01E01-E02.S01E05.S01E09-EXAMPLE"
|
||||||
|
|
||||||
|
|
||||||
def test_many_runs_are_cut_short() -> None:
|
def test_many_runs_are_cut_short() -> None:
|
||||||
titles = [episode(1, n) for n in (1, 3, 5, 7, 9, 11)]
|
titles = [episode(1, n) for n in (1, 3, 5, 7, 9, 11)]
|
||||||
name = export_name(titles, [], "EXAMPLE", seasons_of((1, 12)))
|
name = export_name(titles, [], "EXAMPLE", seasons_of((1, 12)))
|
||||||
assert name == "Example Show S01E01_S01E03_S01E05_+3-EXAMPLE"
|
assert name == "Example.Show.S01E01.S01E03.S01E05.+3-EXAMPLE"
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_season_list_uses_ranges() -> None:
|
def test_unknown_season_list_uses_ranges() -> None:
|
||||||
titles = [episode(1, n) for n in (1, 2, 3)]
|
titles = [episode(1, n) for n in (1, 2, 3)]
|
||||||
assert export_name(titles, [], "EXAMPLE") == "Example Show S01E01-E03-EXAMPLE"
|
assert export_name(titles, [], "EXAMPLE") == "Example.Show.S01E01-E03-EXAMPLE"
|
||||||
|
|
||||||
|
|
||||||
def test_whole_seasons_in_a_row_collapse() -> None:
|
def test_whole_seasons_in_a_row_collapse() -> None:
|
||||||
titles = [episode(s, n) for s in (1, 2, 3) for n in (1, 2)]
|
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"
|
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:
|
def test_whole_season_and_a_partial_season() -> None:
|
||||||
titles = [episode(1, 1), episode(1, 2), episode(2, 1)]
|
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"
|
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:
|
def test_quality_parts_are_deduplicated_and_ordered() -> None:
|
||||||
@@ -140,12 +141,24 @@ def test_quality_parts_are_deduplicated_and_ordered() -> None:
|
|||||||
Subtitle(id_="s1", url="https://example.test/s1.vtt", language="en", codec=Subtitle.Codec.WebVTT),
|
Subtitle(id_="s1", url="https://example.test/s1.vtt", language="en", codec=Subtitle.Codec.WebVTT),
|
||||||
]
|
]
|
||||||
name = export_name([movie()], tracks, "EXAMPLE")
|
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"
|
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:
|
def test_title_is_filename_safe_and_keeps_letters() -> None:
|
||||||
name = export_name([movie('Café: "Ünïcode"/Kanji 映画?', year=None)], [], "EXAMPLE")
|
name = export_name([movie('Café: "Ünïcode"/Kanji 映画?', year=None)], [], "EXAMPLE")
|
||||||
assert name == "Café Ünïcode & Kanji 映画-EXAMPLE"
|
assert name == "Café.Ünïcode.&.Kanji.映画-EXAMPLE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_template_does_not_change_the_name(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
config,
|
||||||
|
"output_template",
|
||||||
|
{"series": "{title} {season_episode} {quality}", "movies": "{title} ({year}) {quality}"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(config, "unicode_filenames", False)
|
||||||
|
titles = [episode(1, n) for n in (1, 2, 3)]
|
||||||
|
tracks = [video(descriptor=Video.Descriptor.HLS), audio("a1", Audio.Codec.EC3, "5.1", Audio.Descriptor.HLS, 16)]
|
||||||
|
assert export_name(titles, tracks, "EX") == "Example.Show.S01E01-E03.1080p.h264.sdr.hls.ddp5.1atmos-EX"
|
||||||
|
|
||||||
|
|
||||||
def test_song_uses_artist_and_album() -> None:
|
def test_song_uses_artist_and_album() -> None:
|
||||||
@@ -153,24 +166,24 @@ def test_song_uses_artist_and_album() -> None:
|
|||||||
id_="track-1", service=StubService, name="Track", artist="Example Artist", album="Example Album", track=1
|
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") == (
|
assert export_name([song], [audio(codec=Audio.Codec.FLAC)], "EXAMPLE") == (
|
||||||
"Example Artist - Example Album_dash_flac2.0-EXAMPLE"
|
"Example.Artist.Example.Album.dash.flac2.0-EXAMPLE"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_collision_gets_a_suffix(tmp_path: Path) -> None:
|
def test_collision_gets_a_suffix(tmp_path: Path) -> None:
|
||||||
other = tmp_path / "Example Movie 2024-EXAMPLE.json"
|
other = tmp_path / "Example.Movie.2024-EXAMPLE.json"
|
||||||
other.write_text("{}")
|
other.write_text("{}")
|
||||||
current = tmp_path / "export_EXAMPLE_1.json"
|
current = tmp_path / "export_EXAMPLE_1.json"
|
||||||
current.write_text("mine")
|
current.write_text("mine")
|
||||||
|
|
||||||
moved = move_export(current, "Example Movie 2024-EXAMPLE")
|
moved = move_export(current, "Example.Movie.2024-EXAMPLE")
|
||||||
|
|
||||||
assert moved.name == "Example Movie 2024-EXAMPLE-2.json"
|
assert moved.name == "Example.Movie.2024-EXAMPLE-2.json"
|
||||||
assert other.read_text() == "{}"
|
assert other.read_text() == "{}"
|
||||||
assert moved.read_text() == "mine"
|
assert moved.read_text() == "mine"
|
||||||
assert not current.exists()
|
assert not current.exists()
|
||||||
# the run's own file keeps its suffix instead of moving on to -3
|
# the run's own file keeps its suffix instead of moving on to -3
|
||||||
assert move_export(moved, "Example Movie 2024-EXAMPLE") == moved
|
assert move_export(moved, "Example.Movie.2024-EXAMPLE") == moved
|
||||||
|
|
||||||
|
|
||||||
def make_dl(tmp_path: Path, seasons: dict[int, set[int]] | None = None) -> dl:
|
def make_dl(tmp_path: Path, seasons: dict[int, set[int]] | None = None) -> dl:
|
||||||
@@ -189,12 +202,12 @@ def test_export_is_renamed_as_titles_arrive_and_stays_valid(tmp_path: Path) -> N
|
|||||||
title.tracks.add(video(f"v{title.number}"))
|
title.tracks.add(video(f"v{title.number}"))
|
||||||
|
|
||||||
instance.write_export(work, first, first.tracks.videos[0], StubDRM())
|
instance.write_export(work, first, first.tracks.videos[0], StubDRM())
|
||||||
partial = tmp_path / "Example Show S01E01_1080p_h264_sdr_dash-EXAMPLE.json"
|
partial = tmp_path / "Example.Show.S01E01.1080p.h264.sdr.dash-EXAMPLE.json"
|
||||||
assert [p.name for p in tmp_path.iterdir()] == [partial.name]
|
assert [p.name for p in tmp_path.iterdir()] == [partial.name]
|
||||||
assert [e.id for e in mediaexport.read(partial).titles] == ["ep-1-1"]
|
assert [e.id for e in mediaexport.read(partial).titles] == ["ep-1-1"]
|
||||||
|
|
||||||
instance.write_export(work, second, second.tracks.videos[0])
|
instance.write_export(work, second, second.tracks.videos[0])
|
||||||
final = tmp_path / "Example Show S01_1080p_h264_sdr_dash-EXAMPLE.json"
|
final = tmp_path / "Example.Show.S01.1080p.h264.sdr.dash-EXAMPLE.json"
|
||||||
assert [p.name for p in tmp_path.iterdir()] == [final.name]
|
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"]
|
assert [e.id for e in mediaexport.read(final).titles] == ["ep-1-1", "ep-1-2"]
|
||||||
|
|
||||||
@@ -214,9 +227,9 @@ def test_export_messages(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> No
|
|||||||
instance.log_export_summary()
|
instance.log_export_summary()
|
||||||
|
|
||||||
lines = [r.getMessage() for r in caplog.records]
|
lines = [r.getMessage() for r in caplog.records]
|
||||||
assert "Exporting to Example Movie 2024_1080p_h264_sdr_dash_aac2.0-EXAMPLE.json" in lines
|
assert "Exporting to Example.Movie.2024.1080p.h264.sdr.dash.aac2.0-EXAMPLE.json" in lines
|
||||||
assert "Exported 'Example Movie' (2024): 1 video, 2 audio, 0 subtitles, 1 key" 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"
|
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 f"Saved the export to {final}: 1 title, 1 key" in lines
|
||||||
assert any("content keys" in line for line in lines)
|
assert any("content keys" in line for line in lines)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""The file name of a ``dl --export`` file, built from the titles and tracks it holds.
|
"""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}``,
|
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.
|
for example ``Example.Show.S01.2160p.h265.sdr.hdr10.dash.ddp5.1-EXAMPLE``. An empty part is left out.
|
||||||
|
The name always uses dots and never reads the output templates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -60,11 +61,11 @@ def title_part(titles: Sequence[Title_T]) -> str:
|
|||||||
text += f" +{others}"
|
text += f" +{others}"
|
||||||
else:
|
else:
|
||||||
text = str(first)
|
text = str(first)
|
||||||
return sanitize_filename(text, spacer=" ", unicode=True)[:MAX_TITLE_LENGTH].strip(" .")
|
return sanitize_filename(text, spacer=".", unicode=True)[:MAX_TITLE_LENGTH].strip(" .")
|
||||||
|
|
||||||
|
|
||||||
def scope_part(titles: Sequence[Title_T], seasons: Mapping[int, Collection[int]]) -> str:
|
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 ``_``.
|
"""``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``.
|
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``
|
Whole seasons in a row collapse to ``S01-S03``. After ``MAX_SCOPE_PARTS`` parts, ``+N``
|
||||||
@@ -102,7 +103,7 @@ def scope_part(titles: Sequence[Title_T], seasons: Mapping[int, Collection[int]]
|
|||||||
|
|
||||||
if len(parts) > MAX_SCOPE_PARTS:
|
if len(parts) > MAX_SCOPE_PARTS:
|
||||||
parts = [*parts[:MAX_SCOPE_PARTS], f"+{len(parts) - MAX_SCOPE_PARTS}"]
|
parts = [*parts[:MAX_SCOPE_PARTS], f"+{len(parts) - MAX_SCOPE_PARTS}"]
|
||||||
return "_".join(parts)
|
return ".".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def resolution(track: Video) -> Optional[int]:
|
def resolution(track: Video) -> Optional[int]:
|
||||||
@@ -169,8 +170,7 @@ def export_name(
|
|||||||
"""
|
"""
|
||||||
head = title_part(titles) if titles else ""
|
head = title_part(titles) if titles else ""
|
||||||
scope = scope_part(titles, seasons or {}) if titles and isinstance(titles[0], Episode) 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, scope, *quality_parts(tracks)) if p) + f"-{service_tag}"
|
||||||
return "_".join(p for p in (head, *quality_parts(tracks)) if p) + f"-{service_tag}"
|
|
||||||
|
|
||||||
|
|
||||||
def move_export(current: Path, name: str) -> Path:
|
def move_export(current: Path, name: str) -> Path:
|
||||||
|
|||||||
Reference in New Issue
Block a user