mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 04:05:08 +03:00
✨ 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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user