From b1e28a8ae69a2ecb514b881ff64ba9010b86fbf9 Mon Sep 17 00:00:00 2001 From: Piotr Karbowski Date: Sat, 25 Apr 2026 00:55:02 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20`match=5Fexisting=5Fpath=5F?= =?UTF-8?q?case`=20option=20(#335)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When enabled, existing path components are reused even if Tidal returns different casing. This avoids creating separate paths on case-sensitive filesystems that would conflict later when moved to case-insensitive systems. For example, if "FooBar" already exists and the API returns "foobar", downloads will continue under "FooBar". Co-authored-by: Piotr Karbowski --- docs/config.example.toml | 7 +++++ tests/cli/test_config.py | 14 +++++++++ tests/cli/test_path.py | 38 +++++++++++++++++++++++ tiddl/cli/commands/download/__init__.py | 1 + tiddl/cli/commands/download/downloader.py | 20 +++++++++--- tiddl/cli/config.py | 1 + tiddl/cli/utils/path.py | 38 +++++++++++++++++++++++ 7 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 tests/cli/test_path.py create mode 100644 tiddl/cli/utils/path.py diff --git a/docs/config.example.toml b/docs/config.example.toml index 99331c7..7944a6f 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -84,6 +84,13 @@ rewrite_metadata = false # track file with the same name write_lrc_file = false +# when enabled, existing path components are reused even if Tidal returns +# different casing. This avoids creating separate paths on case-sensitive +# filesystems that would conflict later when moved to case-insensitive systems. +# For example, if "FooBar" already exists and the API returns "foobar", +# downloads will continue under "FooBar". +match_existing_path_case = false + [metadata] # embed metadata in files diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 30f27d7..b4e20c4 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -38,6 +38,20 @@ def test_valid_config_file(tmp_path: Path): assert cfg.download.threads_count == 8 +def test_match_existing_path_case_config(tmp_path: Path): + cfg_file = write_config( + tmp_path, + """ + [download] + match_existing_path_case = true + """, + ) + + cfg = load_config_file(cfg_file) + + assert cfg.download.match_existing_path_case is True + + def test_invalid_type_raises(tmp_path: Path): cfg_file = write_config( tmp_path, diff --git a/tests/cli/test_path.py b/tests/cli/test_path.py new file mode 100644 index 0000000..f9d886c --- /dev/null +++ b/tests/cli/test_path.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest + +from tiddl.cli.utils.path import resolve_existing_path_case + + +def test_resolve_existing_path_case_reuses_existing_directories(tmp_path: Path): + existing_album = tmp_path / "FooBar" / "[2024.01.02] Album" + existing_album.mkdir(parents=True) + + path = resolve_existing_path_case( + tmp_path, + Path("foobar") / "[2024.01.02] album" / "01 - Track.flac", + ) + + assert path == existing_album / "01 - Track.flac" + + +def test_resolve_existing_path_case_reuses_existing_file(tmp_path: Path): + existing_file = tmp_path / "FooBar" / "01 - Track.flac" + existing_file.parent.mkdir() + existing_file.touch() + + path = resolve_existing_path_case(tmp_path, Path("foobar") / "01 - track.flac") + + assert path == existing_file + + +def test_resolve_existing_path_case_keeps_new_components(tmp_path: Path): + path = resolve_existing_path_case(tmp_path, Path("FooBar") / "New Album") + + assert path == tmp_path / "FooBar" / "New Album" + + +def test_resolve_existing_path_case_rejects_absolute_path(tmp_path: Path): + with pytest.raises(ValueError, match="relative_path"): + resolve_existing_path_case(tmp_path, tmp_path / "FooBar") diff --git a/tiddl/cli/commands/download/__init__.py b/tiddl/cli/commands/download/__init__.py index 04c8cd5..c30a6d3 100644 --- a/tiddl/cli/commands/download/__init__.py +++ b/tiddl/cli/commands/download/__init__.py @@ -205,6 +205,7 @@ def download_callback( skip_existing=not SKIP_EXISTING, download_path=DOWNLOAD_PATH, scan_path=SCAN_PATH, + match_existing_path_case=CONFIG.download.match_existing_path_case, ) class Metadata: diff --git a/tiddl/cli/commands/download/downloader.py b/tiddl/cli/commands/download/downloader.py index ed342a4..f6f925e 100644 --- a/tiddl/cli/commands/download/downloader.py +++ b/tiddl/cli/commands/download/downloader.py @@ -9,6 +9,7 @@ import aiohttp from tiddl.cli.config import VIDEOS_FILTER_LITERAL from tiddl.cli.utils.download import get_existing_track_filename +from tiddl.cli.utils.path import resolve_existing_path_case from tiddl.core.api import ApiError, TidalAPI from tiddl.core.api.models import StreamVideoQuality, Track, TrackQuality, Video from tiddl.core.utils import parse_track_stream, parse_video_stream @@ -50,6 +51,7 @@ class Downloader: skip_existing: bool download_path: Path scan_path: Path + match_existing_path_case: bool def __init__( self, @@ -62,6 +64,7 @@ class Downloader: skip_existing: bool, download_path: Path, scan_path: Path, + match_existing_path_case: bool = False, ) -> None: self.api = tidal_api self.rich_output = rich_output @@ -72,6 +75,13 @@ class Downloader: self.skip_existing = skip_existing self.download_path = download_path self.scan_path = scan_path + self.match_existing_path_case = match_existing_path_case + + def get_path(self, base_path: Path, relative_path: Path) -> Path: + if self.match_existing_path_case: + return resolve_existing_path_case(base_path, relative_path) + + return base_path / relative_path async def download( self, item: Track | Video, file_path: Path @@ -92,16 +102,16 @@ class Downloader: filename = get_existing_track_filename( item.audioQuality, self.track_quality, file_path ) + existing_file_path = self.get_path(self.scan_path, filename) vibrant_color = item.album.vibrantColor elif isinstance(item, Video): filename = file_path.with_suffix(".mp4") + existing_file_path = self.get_path(self.scan_path, filename) vibrant_color = item.vibrantColor vibrant_color = vibrant_color or "gray" - existing_file_path = self.scan_path / filename - log.debug(f"{file_path=}, {filename=}, {existing_file_path=}") result_message = "[green]Downloaded" @@ -144,7 +154,7 @@ class Downloader: urls, actual_ext = parse_track_stream(stream) if filename.suffix.lower() != actual_ext: filename = filename.with_suffix(actual_ext) - download_path = self.download_path / filename + download_path = self.get_path(self.download_path, filename) quality = track_qualities_color[stream.audioQuality] @@ -160,7 +170,9 @@ class Downloader: ) urls, ext = parse_video_stream(stream), ".ts" - download_path = (self.download_path / filename).with_suffix(ext) + download_path = self.get_path( + self.download_path, filename + ).with_suffix(ext) quality = video_qualities_color[stream.videoQuality] task_id = self.rich_output.download_start( diff --git a/tiddl/cli/config.py b/tiddl/cli/config.py index ef7159b..0535e24 100644 --- a/tiddl/cli/config.py +++ b/tiddl/cli/config.py @@ -56,6 +56,7 @@ class Config(BaseModel): update_mtime: bool = False rewrite_metadata: bool = False write_lrc_file: bool = False + match_existing_path_case: bool = False def model_post_init(self, __context): # set scan path to download path when download path is non default diff --git a/tiddl/cli/utils/path.py b/tiddl/cli/utils/path.py new file mode 100644 index 0000000..701eb16 --- /dev/null +++ b/tiddl/cli/utils/path.py @@ -0,0 +1,38 @@ +from pathlib import Path + + +def resolve_existing_path_case(base_path: Path, relative_path: Path) -> Path: + """ + Return base_path / relative_path, reusing existing path component casing. + """ + + if relative_path.is_absolute(): + raise ValueError("relative_path must not be absolute") + + resolved_path = base_path + + for part in relative_path.parts: + if part in ("", "."): + continue + + existing_part = find_existing_child_case(resolved_path, part) + resolved_path = resolved_path / (existing_part or part) + + return resolved_path + + +def find_existing_child_case(parent: Path, name: str) -> str | None: + if not parent.is_dir(): + return None + + casefolded_name = name.casefold() + fallback: str | None = None + + for child in parent.iterdir(): + if child.name == name: + return child.name + + if fallback is None and child.name.casefold() == casefolded_name: + fallback = child.name + + return fallback