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
|
# track file with the same name
|
||||||
write_lrc_file = false
|
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]
|
[metadata]
|
||||||
# embed metadata in files
|
# embed metadata in files
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ def test_valid_config_file(tmp_path: Path):
|
|||||||
assert cfg.download.threads_count == 8
|
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):
|
def test_invalid_type_raises(tmp_path: Path):
|
||||||
cfg_file = write_config(
|
cfg_file = write_config(
|
||||||
tmp_path,
|
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,
|
skip_existing=not SKIP_EXISTING,
|
||||||
download_path=DOWNLOAD_PATH,
|
download_path=DOWNLOAD_PATH,
|
||||||
scan_path=SCAN_PATH,
|
scan_path=SCAN_PATH,
|
||||||
|
match_existing_path_case=CONFIG.download.match_existing_path_case,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Metadata:
|
class Metadata:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import aiohttp
|
|||||||
|
|
||||||
from tiddl.cli.config import VIDEOS_FILTER_LITERAL
|
from tiddl.cli.config import VIDEOS_FILTER_LITERAL
|
||||||
from tiddl.cli.utils.download import get_existing_track_filename
|
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 import ApiError, TidalAPI
|
||||||
from tiddl.core.api.models import StreamVideoQuality, Track, TrackQuality, Video
|
from tiddl.core.api.models import StreamVideoQuality, Track, TrackQuality, Video
|
||||||
from tiddl.core.utils import parse_track_stream, parse_video_stream
|
from tiddl.core.utils import parse_track_stream, parse_video_stream
|
||||||
@@ -50,6 +51,7 @@ class Downloader:
|
|||||||
skip_existing: bool
|
skip_existing: bool
|
||||||
download_path: Path
|
download_path: Path
|
||||||
scan_path: Path
|
scan_path: Path
|
||||||
|
match_existing_path_case: bool
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -62,6 +64,7 @@ class Downloader:
|
|||||||
skip_existing: bool,
|
skip_existing: bool,
|
||||||
download_path: Path,
|
download_path: Path,
|
||||||
scan_path: Path,
|
scan_path: Path,
|
||||||
|
match_existing_path_case: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.api = tidal_api
|
self.api = tidal_api
|
||||||
self.rich_output = rich_output
|
self.rich_output = rich_output
|
||||||
@@ -72,6 +75,13 @@ class Downloader:
|
|||||||
self.skip_existing = skip_existing
|
self.skip_existing = skip_existing
|
||||||
self.download_path = download_path
|
self.download_path = download_path
|
||||||
self.scan_path = scan_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(
|
async def download(
|
||||||
self, item: Track | Video, file_path: Path
|
self, item: Track | Video, file_path: Path
|
||||||
@@ -92,16 +102,16 @@ class Downloader:
|
|||||||
filename = get_existing_track_filename(
|
filename = get_existing_track_filename(
|
||||||
item.audioQuality, self.track_quality, file_path
|
item.audioQuality, self.track_quality, file_path
|
||||||
)
|
)
|
||||||
|
existing_file_path = self.get_path(self.scan_path, filename)
|
||||||
vibrant_color = item.album.vibrantColor
|
vibrant_color = item.album.vibrantColor
|
||||||
|
|
||||||
elif isinstance(item, Video):
|
elif isinstance(item, Video):
|
||||||
filename = file_path.with_suffix(".mp4")
|
filename = file_path.with_suffix(".mp4")
|
||||||
|
existing_file_path = self.get_path(self.scan_path, filename)
|
||||||
vibrant_color = item.vibrantColor
|
vibrant_color = item.vibrantColor
|
||||||
|
|
||||||
vibrant_color = vibrant_color or "gray"
|
vibrant_color = vibrant_color or "gray"
|
||||||
|
|
||||||
existing_file_path = self.scan_path / filename
|
|
||||||
|
|
||||||
log.debug(f"{file_path=}, {filename=}, {existing_file_path=}")
|
log.debug(f"{file_path=}, {filename=}, {existing_file_path=}")
|
||||||
|
|
||||||
result_message = "[green]Downloaded"
|
result_message = "[green]Downloaded"
|
||||||
@@ -144,7 +154,7 @@ class Downloader:
|
|||||||
urls, actual_ext = parse_track_stream(stream)
|
urls, actual_ext = parse_track_stream(stream)
|
||||||
if filename.suffix.lower() != actual_ext:
|
if filename.suffix.lower() != actual_ext:
|
||||||
filename = filename.with_suffix(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]
|
quality = track_qualities_color[stream.audioQuality]
|
||||||
|
|
||||||
@@ -160,7 +170,9 @@ class Downloader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
urls, ext = parse_video_stream(stream), ".ts"
|
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]
|
quality = video_qualities_color[stream.videoQuality]
|
||||||
|
|
||||||
task_id = self.rich_output.download_start(
|
task_id = self.rich_output.download_start(
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class Config(BaseModel):
|
|||||||
update_mtime: bool = False
|
update_mtime: bool = False
|
||||||
rewrite_metadata: bool = False
|
rewrite_metadata: bool = False
|
||||||
write_lrc_file: bool = False
|
write_lrc_file: bool = False
|
||||||
|
match_existing_path_case: bool = False
|
||||||
|
|
||||||
def model_post_init(self, __context):
|
def model_post_init(self, __context):
|
||||||
# set scan path to download path when download path is non default
|
# 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