Added match_existing_path_case option (#335)

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 <git.throwaway941@simplelogin.com>
This commit is contained in:
Piotr Karbowski
2026-04-25 00:55:02 +02:00
committed by GitHub
parent 658e4a81ab
commit b1e28a8ae6
7 changed files with 115 additions and 4 deletions
+7
View File
@@ -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
+14
View File
@@ -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,
+38
View File
@@ -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")
+1
View File
@@ -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:
+16 -4
View File
@@ -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(
+1
View File
@@ -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
+38
View File
@@ -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