mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 12:15:13 +03:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05d63d153e | |||
| 04de8e677c | |||
| d0d146b87f | |||
| 77e488ff30 | |||
| 459d5a50b9 | |||
| ee160fc5bc | |||
| 1a78d875fa | |||
| b0ed7bd208 | |||
| e45628e15f | |||
| d9c8984dfa | |||
| c285be6ed2 | |||
| 5e4f9bdb6a | |||
| a282c1a4af | |||
| 46a6e748da | |||
| bf8ded5f60 | |||
| 7e0fb9fb37 | |||
| 880f6008b0 | |||
| 0f9a4006f1 | |||
| 3cfadd7795 | |||
| 47975e12bc | |||
| fbb32e735d | |||
| 3cba05910b | |||
| c22cb2941d | |||
| 6b82c40fae | |||
| 9abf141411 |
@@ -14,6 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: What command was used?
|
label: What command was used?
|
||||||
placeholder: tiddl
|
placeholder: tiddl
|
||||||
|
render: shell
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
@@ -31,6 +32,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- 3.13
|
- 3.13
|
||||||
- 3.14
|
- 3.14
|
||||||
|
- 3.15
|
||||||
default: 0
|
default: 0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ We recommend using [uv](https://docs.astral.sh/uv/)
|
|||||||
uv tool install tiddl
|
uv tool install tiddl
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To install exact version e.g. 3.4.1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install tiddl==3.4.1
|
||||||
|
```
|
||||||
|
|
||||||
## pip
|
## pip
|
||||||
|
|
||||||
You can also use [pip](https://packaging.python.org/en/latest/tutorials/installing-packages/)
|
You can also use [pip](https://packaging.python.org/en/latest/tutorials/installing-packages/)
|
||||||
@@ -81,18 +87,6 @@ $ tiddl download url <url>
|
|||||||
|
|
||||||
Run `tiddl download` to see available download options.
|
Run `tiddl download` to see available download options.
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
By default, tiddl stops when encountering unavailable items in collections such as playlists, albums, artists, or mixes (e.g., removed or region-locked tracks).
|
|
||||||
|
|
||||||
Use `--skip-errors` to automatically skip these items and continue downloading:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tiddl download url <url> --skip-errors
|
|
||||||
```
|
|
||||||
|
|
||||||
Skipped items are logged with track/album name and IDs for reference.
|
|
||||||
|
|
||||||
### Quality
|
### Quality
|
||||||
|
|
||||||
| Quality | File extension | Details |
|
| Quality | File extension | Details |
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ write_lrc_file = false
|
|||||||
# downloads will continue under "FooBar".
|
# downloads will continue under "FooBar".
|
||||||
match_existing_path_case = false
|
match_existing_path_case = false
|
||||||
|
|
||||||
|
# Dolby Atmos filter
|
||||||
|
# none - download only STEREO tracks
|
||||||
|
# only - download only DOLBY_ATMOS tracks
|
||||||
|
# allow - download both
|
||||||
|
# (both versions won't be downloaded at a time, it depends on what Tidal returns)
|
||||||
|
atmos_filter = "none"
|
||||||
|
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
# embed metadata in files
|
# embed metadata in files
|
||||||
@@ -137,7 +144,7 @@ allowed = [
|
|||||||
# album = "albums/{album.artist} - {album.title}"
|
# album = "albums/{album.artist} - {album.title}"
|
||||||
|
|
||||||
# you can access: {playlist}
|
# you can access: {playlist}
|
||||||
# playlist = "playlists/{title}"
|
# playlist = "playlists/{playlist.title}"
|
||||||
|
|
||||||
|
|
||||||
[m3u]
|
[m3u]
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tiddl"
|
name = "tiddl"
|
||||||
version = "3.4.0a4"
|
version = "3.4.4a1"
|
||||||
description = "Download Tidal tracks with CLI downloader."
|
description = "Download Tidal tracks with CLI downloader."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
@@ -124,28 +124,73 @@ def test_logout_with_token(monkeypatch: pytest.MonkeyPatch):
|
|||||||
mock_api_instance.logout_token.assert_called_once_with("token")
|
mock_api_instance.logout_token.assert_called_once_with("token")
|
||||||
mock_save.assert_called_once_with(AuthData())
|
mock_save.assert_called_once_with(AuthData())
|
||||||
|
|
||||||
assert "Logged out!" in result.stdout
|
assert "Logged out successfully!\n" in result.stdout
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
def test_logout_no_token(monkeypatch: pytest.MonkeyPatch):
|
def test_logout_no_token(monkeypatch: pytest.MonkeyPatch):
|
||||||
"""Should only clear auth data."""
|
"""Should do nothing."""
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token=None)
|
"tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token=None)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with (patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI,):
|
||||||
|
result = runner.invoke(auth_command, ["logout"])
|
||||||
|
|
||||||
|
MockAuthAPI.assert_not_called()
|
||||||
|
|
||||||
|
assert "No active session found." in result.stdout
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_force(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""Should remove local token even when the API request raises an error."""
|
||||||
|
|
||||||
|
# 1. Mock existing session
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token="fake-token")
|
||||||
|
)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI,
|
patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI,
|
||||||
patch("tiddl.cli.commands.auth.save_auth_data") as mock_save,
|
patch("tiddl.cli.commands.auth.save_auth_data") as mock_save,
|
||||||
):
|
):
|
||||||
|
# 2. Configure the mock to RAISE an exception
|
||||||
|
mock_api_instance = MockAuthAPI.return_value
|
||||||
|
mock_api_instance.logout_token.side_effect = Exception("Server Down")
|
||||||
|
|
||||||
|
# 3. Invoke with --force
|
||||||
|
result = runner.invoke(auth_command, ["logout", "--force"])
|
||||||
|
|
||||||
|
# 4. Assertions
|
||||||
|
# API was still called
|
||||||
|
mock_api_instance.logout_token.assert_called_once_with("fake-token")
|
||||||
|
|
||||||
|
# Local data was still wiped (this is the core of --force)
|
||||||
|
mock_save.assert_called_once_with(AuthData())
|
||||||
|
|
||||||
|
# Check for your specific "force" success message
|
||||||
|
assert "Token removed!" in result.stdout
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_fails_without_force(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token="token")
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI,
|
||||||
|
patch("tiddl.cli.commands.auth.save_auth_data") as mock_save,
|
||||||
|
):
|
||||||
|
|
||||||
|
MockAuthAPI.return_value.logout_token.side_effect = Exception("Error")
|
||||||
|
|
||||||
result = runner.invoke(auth_command, ["logout"])
|
result = runner.invoke(auth_command, ["logout"])
|
||||||
|
|
||||||
mock_save.assert_called_once_with(AuthData())
|
assert "Local session retained" in result.stdout
|
||||||
MockAuthAPI.assert_not_called()
|
mock_save.assert_not_called() # Ensure data wasn't wiped
|
||||||
|
|
||||||
assert "Logged out!" in result.stdout
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_not_logged_in(monkeypatch: pytest.MonkeyPatch):
|
def test_refresh_not_logged_in(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
|||||||
+5
-2
@@ -13,6 +13,8 @@ log = logging.getLogger("tiddl")
|
|||||||
app = typer.Typer(name="tiddl", no_args_is_help=True, rich_markup_mode="rich")
|
app = typer.Typer(name="tiddl", no_args_is_help=True, rich_markup_mode="rich")
|
||||||
register_commands(app)
|
register_commands(app)
|
||||||
|
|
||||||
|
VERSION = "v3.4.4a1"
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
@app.callback()
|
||||||
def callback(
|
def callback(
|
||||||
@@ -30,13 +32,14 @@ def callback(
|
|||||||
),
|
),
|
||||||
] = CONFIG.debug,
|
] = CONFIG.debug,
|
||||||
):
|
):
|
||||||
"""
|
f"""
|
||||||
tiddl - download tidal tracks \u266b
|
tiddl {VERSION} - download tidal tracks \u266b
|
||||||
|
|
||||||
[link=https://github.com/oskvr37/tiddl]github (https://github.com/oskvr37/tiddl)[/link]
|
[link=https://github.com/oskvr37/tiddl]github (https://github.com/oskvr37/tiddl)[/link]
|
||||||
[link=https://buymeacoffee.com/oskvr][yellow]buy me a coffee (https://buymeacoffee.com/oskvr)[/link]
|
[link=https://buymeacoffee.com/oskvr][yellow]buy me a coffee (https://buymeacoffee.com/oskvr)[/link]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
log.debug(f"{VERSION=}")
|
||||||
log.debug(f"{ctx.params=}")
|
log.debug(f"{ctx.params=}")
|
||||||
|
|
||||||
is_ffmpeg_installed = ifs()
|
is_ffmpeg_installed = ifs()
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def login(
|
|||||||
|
|
||||||
if not NO_BROWSER:
|
if not NO_BROWSER:
|
||||||
typer.launch(uri)
|
typer.launch(uri)
|
||||||
|
|
||||||
console.print(f"Go to '{uri}' and complete authentication!")
|
console.print(f"Go to '{uri}' and complete authentication!")
|
||||||
|
|
||||||
auth_end_at = time() + device_auth.expiresIn
|
auth_end_at = time() + device_auth.expiresIn
|
||||||
@@ -97,17 +97,23 @@ def logout(
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
AuthAPI().logout_token(auth_data.token)
|
api = AuthAPI()
|
||||||
|
api.logout_token(auth_data.token)
|
||||||
success = True
|
success = True
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
console.print(f"[bold red]Logout request failed: {error}")
|
console.print(f"[bold red]Logout request failed: {error}")
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
if success or force:
|
if not (success or force):
|
||||||
save_auth_data(AuthData())
|
|
||||||
console.print("[bold green]Logged out successfully!")
|
|
||||||
else:
|
|
||||||
console.print("[bold yellow]Local session retained. Use --force to override.")
|
console.print("[bold yellow]Local session retained. Use --force to override.")
|
||||||
|
return
|
||||||
|
|
||||||
|
save_auth_data(AuthData())
|
||||||
|
|
||||||
|
if success:
|
||||||
|
console.print("[bold green]Logged out successfully!")
|
||||||
|
elif force:
|
||||||
|
console.print("[bold green]Token removed!")
|
||||||
|
|
||||||
|
|
||||||
@auth_command.command(help="Refreshes your token in app.")
|
@auth_command.command(help="Refreshes your token in app.")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from tiddl.cli.config import (
|
|||||||
ARTIST_SINGLES_FILTER_LITERAL,
|
ARTIST_SINGLES_FILTER_LITERAL,
|
||||||
VALID_M3U_RESOURCE_LITERAL,
|
VALID_M3U_RESOURCE_LITERAL,
|
||||||
VIDEOS_FILTER_LITERAL,
|
VIDEOS_FILTER_LITERAL,
|
||||||
|
ATMOS_FILTER_LITERAL,
|
||||||
)
|
)
|
||||||
from tiddl.cli.utils.resource import TidalResource
|
from tiddl.cli.utils.resource import TidalResource
|
||||||
from tiddl.cli.ctx import Context
|
from tiddl.cli.ctx import Context
|
||||||
@@ -126,6 +127,14 @@ def download_callback(
|
|||||||
help="Raise an error on resource download failure. Use for debugging",
|
help="Raise an error on resource download failure. Use for debugging",
|
||||||
),
|
),
|
||||||
] = False,
|
] = False,
|
||||||
|
DOLBY_ATMOS_FILTER: Annotated[
|
||||||
|
ATMOS_FILTER_LITERAL,
|
||||||
|
typer.Option(
|
||||||
|
"--dolby-atmos",
|
||||||
|
"-da",
|
||||||
|
help="Dolby Atmos filter, 'none' to exclude, 'allow' to include, 'only' to download only Dolby Atmos, if available.",
|
||||||
|
),
|
||||||
|
] = CONFIG.download.atmos_filter,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Download Tidal resources.
|
Download Tidal resources.
|
||||||
@@ -145,7 +154,9 @@ def download_callback(
|
|||||||
with open(lrc_file_path, "w", encoding="utf-8") as f:
|
with open(lrc_file_path, "w", encoding="utf-8") as f:
|
||||||
f.write(lyrics)
|
f.write(lyrics)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Failed to write LRC file for track {track.title} (ID: {track.id}): {e}")
|
log.error(
|
||||||
|
f"Failed to write LRC file for track {track.title} (ID: {track.id}): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
def save_m3u(
|
def save_m3u(
|
||||||
resource_type: VALID_M3U_RESOURCE_LITERAL,
|
resource_type: VALID_M3U_RESOURCE_LITERAL,
|
||||||
@@ -206,6 +217,7 @@ def download_callback(
|
|||||||
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,
|
match_existing_path_case=CONFIG.download.match_existing_path_case,
|
||||||
|
dolby_atmos_filter=DOLBY_ATMOS_FILTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Metadata:
|
class Metadata:
|
||||||
@@ -346,7 +358,9 @@ def download_callback(
|
|||||||
track_metadata=Metadata(
|
track_metadata=Metadata(
|
||||||
cover=cover,
|
cover=cover,
|
||||||
date=str(album.releaseDate),
|
date=str(album.releaseDate),
|
||||||
artist=album.artist.name if album.artist else "",
|
artist=(
|
||||||
|
album.artist.name if album.artist else ""
|
||||||
|
),
|
||||||
credits=album_item.credits,
|
credits=album_item.credits,
|
||||||
album_review=album_review,
|
album_review=album_review,
|
||||||
),
|
),
|
||||||
@@ -355,9 +369,11 @@ def download_callback(
|
|||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
item = album_item.item
|
item = album_item.item
|
||||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||||
if hasattr(item, 'album') and item.album:
|
if hasattr(item, "album") and item.album:
|
||||||
track_info += f", Album ID: {item.album.id}"
|
track_info += f", Album ID: {item.album.id}"
|
||||||
ctx.obj.console.print(f"[red]API Error:[/] {e} ({track_info})")
|
ctx.obj.console.print(
|
||||||
|
f"[red]API Error:[/] {e} ({track_info})"
|
||||||
|
)
|
||||||
if RAISE_ERRORS:
|
if RAISE_ERRORS:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -441,7 +457,11 @@ def download_callback(
|
|||||||
video = ctx.obj.api.get_video(resource.id)
|
video = ctx.obj.api.get_video(resource.id)
|
||||||
template = TEMPLATE or CONFIG.templates.video
|
template = TEMPLATE or CONFIG.templates.video
|
||||||
|
|
||||||
if "{album" in template and video.album and video.album.id is not None:
|
if (
|
||||||
|
"{album" in template
|
||||||
|
and video.album
|
||||||
|
and video.album.id is not None
|
||||||
|
):
|
||||||
album = ctx.obj.api.get_album(video.album.id)
|
album = ctx.obj.api.get_album(video.album.id)
|
||||||
else:
|
else:
|
||||||
album = None
|
album = None
|
||||||
@@ -489,13 +509,17 @@ def download_callback(
|
|||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
item = mix_item.item
|
item = mix_item.item
|
||||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||||
ctx.obj.console.print(f"[red]API Error:[/] {e} ({track_info})")
|
ctx.obj.console.print(
|
||||||
|
f"[red]API Error:[/] {e} ({track_info})"
|
||||||
|
)
|
||||||
if RAISE_ERRORS:
|
if RAISE_ERRORS:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
item = mix_item.item
|
item = mix_item.item
|
||||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||||
ctx.obj.console.print(f"[red]Error:[/] {e} ({track_info})")
|
ctx.obj.console.print(
|
||||||
|
f"[red]Error:[/] {e} ({track_info})"
|
||||||
|
)
|
||||||
if RAISE_ERRORS:
|
if RAISE_ERRORS:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -526,11 +550,15 @@ def download_callback(
|
|||||||
try:
|
try:
|
||||||
await download_album(album)
|
await download_album(album)
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
ctx.obj.console.print(f"[red]API Error:[/] {e} (Album: {album.title}, ID: {album.id})")
|
ctx.obj.console.print(
|
||||||
|
f"[red]API Error:[/] {e} (Album: {album.title}, ID: {album.id})"
|
||||||
|
)
|
||||||
if RAISE_ERRORS:
|
if RAISE_ERRORS:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ctx.obj.console.print(f"[red]Error:[/] {e} (Album: {album.title}, ID: {album.id})")
|
ctx.obj.console.print(
|
||||||
|
f"[red]Error:[/] {e} (Album: {album.title}, ID: {album.id})"
|
||||||
|
)
|
||||||
if RAISE_ERRORS:
|
if RAISE_ERRORS:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -580,11 +608,15 @@ def download_callback(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
ctx.obj.console.print(f"[red]API Error:[/] {e} (Video: {video.title}, ID: {video.id})")
|
ctx.obj.console.print(
|
||||||
|
f"[red]API Error:[/] {e} (Video: {video.title}, ID: {video.id})"
|
||||||
|
)
|
||||||
if RAISE_ERRORS:
|
if RAISE_ERRORS:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ctx.obj.console.print(f"[red]Error:[/] {e} (Video: {video.title}, ID: {video.id})")
|
ctx.obj.console.print(
|
||||||
|
f"[red]Error:[/] {e} (Video: {video.title}, ID: {video.id})"
|
||||||
|
)
|
||||||
if RAISE_ERRORS:
|
if RAISE_ERRORS:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -637,7 +669,9 @@ def download_callback(
|
|||||||
album=album,
|
album=album,
|
||||||
playlist=playlist,
|
playlist=playlist,
|
||||||
playlist_index=playlist_index,
|
playlist_index=playlist_index,
|
||||||
quality=get_item_quality(playlist_item.item),
|
quality=get_item_quality(
|
||||||
|
playlist_item.item
|
||||||
|
),
|
||||||
),
|
),
|
||||||
track_metadata=Metadata(),
|
track_metadata=Metadata(),
|
||||||
)
|
)
|
||||||
@@ -645,15 +679,19 @@ def download_callback(
|
|||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
item = playlist_item.item
|
item = playlist_item.item
|
||||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||||
if hasattr(item, 'album') and item.album:
|
if hasattr(item, "album") and item.album:
|
||||||
track_info += f", Album ID: {item.album.id}"
|
track_info += f", Album ID: {item.album.id}"
|
||||||
ctx.obj.console.print(f"[red]API Error:[/] {e} ({track_info})")
|
ctx.obj.console.print(
|
||||||
|
f"[red]API Error:[/] {e} ({track_info})"
|
||||||
|
)
|
||||||
if RAISE_ERRORS:
|
if RAISE_ERRORS:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
item = playlist_item.item
|
item = playlist_item.item
|
||||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||||
ctx.obj.console.print(f"[red]Error:[/] {e} ({track_info})")
|
ctx.obj.console.print(
|
||||||
|
f"[red]Error:[/] {e} ({track_info})"
|
||||||
|
)
|
||||||
if RAISE_ERRORS:
|
if RAISE_ERRORS:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -679,7 +717,7 @@ def download_callback(
|
|||||||
and playlist.squareImage
|
and playlist.squareImage
|
||||||
):
|
):
|
||||||
Cover(
|
Cover(
|
||||||
playlist.squareImage, size=max(CONFIG.cover.size, 1080)
|
playlist.squareImage, size=min(CONFIG.cover.size, 1080)
|
||||||
).save_to_directory(
|
).save_to_directory(
|
||||||
path=DOWNLOAD_PATH
|
path=DOWNLOAD_PATH
|
||||||
/ format_template(
|
/ format_template(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from tempfile import NamedTemporaryFile
|
|||||||
import aiofiles
|
import aiofiles
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from tiddl.cli.config import VIDEOS_FILTER_LITERAL
|
from tiddl.cli.config import VIDEOS_FILTER_LITERAL, ATMOS_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.cli.utils.path import resolve_existing_path_case
|
||||||
from tiddl.core.api import ApiError, TidalAPI
|
from tiddl.core.api import ApiError, TidalAPI
|
||||||
@@ -52,6 +52,7 @@ class Downloader:
|
|||||||
download_path: Path
|
download_path: Path
|
||||||
scan_path: Path
|
scan_path: Path
|
||||||
match_existing_path_case: bool
|
match_existing_path_case: bool
|
||||||
|
dolby_atmos_filter: ATMOS_FILTER_LITERAL
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -65,6 +66,7 @@ class Downloader:
|
|||||||
download_path: Path,
|
download_path: Path,
|
||||||
scan_path: Path,
|
scan_path: Path,
|
||||||
match_existing_path_case: bool = False,
|
match_existing_path_case: bool = False,
|
||||||
|
dolby_atmos_filter: ATMOS_FILTER_LITERAL = "none",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.api = tidal_api
|
self.api = tidal_api
|
||||||
self.rich_output = rich_output
|
self.rich_output = rich_output
|
||||||
@@ -76,6 +78,7 @@ class Downloader:
|
|||||||
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
|
self.match_existing_path_case = match_existing_path_case
|
||||||
|
self.dolby_atmos_filter = dolby_atmos_filter
|
||||||
|
|
||||||
def get_path(self, base_path: Path, relative_path: Path) -> Path:
|
def get_path(self, base_path: Path, relative_path: Path) -> Path:
|
||||||
if self.match_existing_path_case:
|
if self.match_existing_path_case:
|
||||||
@@ -144,6 +147,23 @@ class Downloader:
|
|||||||
stream = self.api.get_track_stream(
|
stream = self.api.get_track_stream(
|
||||||
track_id=item.id, quality=self.track_quality
|
track_id=item.id, quality=self.track_quality
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
f"{stream.trackId=}, {stream.audioQuality=}, {stream.audioMode=}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.dolby_atmos_filter == "none"
|
||||||
|
and stream.audioMode == "DOLBY_ATMOS"
|
||||||
|
) or (
|
||||||
|
self.dolby_atmos_filter == "only"
|
||||||
|
and stream.audioMode == "STEREO"
|
||||||
|
):
|
||||||
|
self.rich_output.console.print(
|
||||||
|
f"[blue]Skipping[/] [gray]{item.title}[/] [blue]due to Dolby Atmos filter[/] {self.dolby_atmos_filter}"
|
||||||
|
)
|
||||||
|
return None, False
|
||||||
|
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
log.error(f"{item.id=} {e=}")
|
log.error(f"{item.id=} {e=}")
|
||||||
self.rich_output.console.print(
|
self.rich_output.console.print(
|
||||||
@@ -154,11 +174,19 @@ class Downloader:
|
|||||||
urls, _ = parse_track_stream(stream)
|
urls, _ = parse_track_stream(stream)
|
||||||
download_path = self.get_path(self.download_path, filename)
|
download_path = self.get_path(self.download_path, filename)
|
||||||
|
|
||||||
quality = track_qualities_color[stream.audioQuality]
|
quality_string = track_qualities_color[stream.audioQuality]
|
||||||
|
|
||||||
if stream.audioQuality in ["HI_RES_LOSSLESS", "LOSSLESS"]:
|
if (
|
||||||
quality = f"{quality} {stream.bitDepth}-bit, {(stream.sampleRate or 0) / 1000:.1f} kHz"
|
stream.audioQuality in ["HI_RES_LOSSLESS", "LOSSLESS"]
|
||||||
|
and stream.audioMode == "STEREO"
|
||||||
|
):
|
||||||
|
quality_string = f"{quality_string} {stream.bitDepth}-bit, {(stream.sampleRate or 0) / 1000:.1f} kHz"
|
||||||
should_extract_flac = True
|
should_extract_flac = True
|
||||||
|
else:
|
||||||
|
download_path = download_path.with_suffix(".m4a")
|
||||||
|
|
||||||
|
if stream.audioMode == "DOLBY_ATMOS":
|
||||||
|
quality_string = "[blue]Dolby Atmos[/]"
|
||||||
|
|
||||||
elif isinstance(item, Video):
|
elif isinstance(item, Video):
|
||||||
stream = self.api.get_video_stream(
|
stream = self.api.get_video_stream(
|
||||||
@@ -166,13 +194,13 @@ class Downloader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
urls, ext = parse_video_stream(stream), ".ts"
|
urls, ext = parse_video_stream(stream), ".ts"
|
||||||
download_path = self.get_path(
|
download_path = self.get_path(self.download_path, filename).with_suffix(
|
||||||
self.download_path, filename
|
ext
|
||||||
).with_suffix(ext)
|
)
|
||||||
quality = video_qualities_color[stream.videoQuality]
|
quality_string = video_qualities_color[stream.videoQuality]
|
||||||
|
|
||||||
task_id = self.rich_output.download_start(
|
task_id = self.rich_output.download_start(
|
||||||
f"[{vibrant_color}]{item.title} {quality}"
|
f"[{vibrant_color}]{item.title} {quality_string}"
|
||||||
)
|
)
|
||||||
|
|
||||||
download_path.parent.mkdir(exist_ok=True, parents=True)
|
download_path.parent.mkdir(exist_ok=True, parents=True)
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ from typing_extensions import Annotated
|
|||||||
from tiddl.cli.ctx import Context
|
from tiddl.cli.ctx import Context
|
||||||
from tiddl.cli.utils.resource import TidalResource
|
from tiddl.cli.utils.resource import TidalResource
|
||||||
from tiddl.core.api.models.base import Search, SearchArtist
|
from tiddl.core.api.models.base import Search, SearchArtist
|
||||||
from tiddl.core.api.models.resources import Track, Album, Playlist
|
from tiddl.core.api.models.resources import Track, Album, Playlist, Video
|
||||||
|
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
|
|
||||||
search_subcommand = typer.Typer()
|
search_subcommand = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
@@ -120,15 +119,16 @@ def search(
|
|||||||
|
|
||||||
|
|
||||||
def _display_name(item) -> str:
|
def _display_name(item) -> str:
|
||||||
# if searchArtist, else if track/album, else playlist
|
|
||||||
if isinstance(item, SearchArtist):
|
if isinstance(item, SearchArtist):
|
||||||
return item.name
|
return item.name
|
||||||
|
elif isinstance(item, Video):
|
||||||
|
return f"{item.artist or item.artists[0].name or ""} - {item.title}"
|
||||||
elif isinstance(item, (Track, Album)):
|
elif isinstance(item, (Track, Album)):
|
||||||
# Try to format as "Main Artist - Title"
|
return f"{item.artist or item.artists[0].name or ""} - {item.title} [blue][{', '.join(item.audioModes)}][/]"
|
||||||
main_artist = item.artists[0] if item.artists else None
|
elif isinstance(item, (Playlist)):
|
||||||
return f"{main_artist.name} - {item.title}" if main_artist else f"{item.title}"
|
|
||||||
else: # Playlist
|
|
||||||
return item.title
|
return item.title
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown item type")
|
||||||
|
|
||||||
|
|
||||||
def _display_id(item) -> str:
|
def _display_id(item) -> str:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ ARTIST_SINGLES_FILTER_LITERAL = Literal["none", "only", "include"]
|
|||||||
VALID_M3U_RESOURCE_LITERAL = Literal["album", "playlist", "mix"]
|
VALID_M3U_RESOURCE_LITERAL = Literal["album", "playlist", "mix"]
|
||||||
VALID_RESOURCE_COVER_SAVE_LITERAL = Literal["track", "album", "playlist"]
|
VALID_RESOURCE_COVER_SAVE_LITERAL = Literal["track", "album", "playlist"]
|
||||||
VIDEOS_FILTER_LITERAL = Literal["none", "only", "allow"]
|
VIDEOS_FILTER_LITERAL = Literal["none", "only", "allow"]
|
||||||
|
ATMOS_FILTER_LITERAL = Literal["none", "only", "allow"]
|
||||||
|
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ class Config(BaseModel):
|
|||||||
rewrite_metadata: bool = False
|
rewrite_metadata: bool = False
|
||||||
write_lrc_file: bool = False
|
write_lrc_file: bool = False
|
||||||
match_existing_path_case: bool = False
|
match_existing_path_case: bool = False
|
||||||
|
atmos_filter: ATMOS_FILTER_LITERAL = "none"
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class Favorites(BaseModel):
|
|||||||
class TrackStream(BaseModel):
|
class TrackStream(BaseModel):
|
||||||
trackId: int
|
trackId: int
|
||||||
assetPresentation: Literal["FULL"]
|
assetPresentation: Literal["FULL"]
|
||||||
audioMode: Literal["STEREO"]
|
audioMode: Literal["STEREO", "DOLBY_ATMOS"]
|
||||||
audioQuality: TrackQuality
|
audioQuality: TrackQuality
|
||||||
manifestMimeType: Literal["application/dash+xml", "application/vnd.tidal.bts"]
|
manifestMimeType: Literal["application/dash+xml", "application/vnd.tidal.bts"]
|
||||||
manifestHash: str
|
manifestHash: str
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class Album(BaseModel):
|
|||||||
numberOfTracks: int
|
numberOfTracks: int
|
||||||
numberOfVideos: int
|
numberOfVideos: int
|
||||||
numberOfVolumes: int
|
numberOfVolumes: int
|
||||||
releaseDate: datetime
|
releaseDate: datetime | None = None
|
||||||
copyright: Optional[str] = None
|
copyright: Optional[str] = None
|
||||||
type: Literal["ALBUM", "SINGLE", "EP"]
|
type: Literal["ALBUM", "SINGLE", "EP"]
|
||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
from os import environ
|
from os import environ
|
||||||
from requests import request
|
from requests import request
|
||||||
from typing import Any, TypeAlias
|
from typing import Any, TypeAlias
|
||||||
|
|
||||||
from tiddl.core.auth.exceptions import AuthClientError
|
from tiddl.core.auth.exceptions import AuthClientError
|
||||||
|
|
||||||
|
log = logging.getLogger("tiddl")
|
||||||
|
|
||||||
|
|
||||||
def get_auth_credentials() -> tuple[str, str]:
|
def get_auth_credentials() -> tuple[str, str]:
|
||||||
ENV_KEY = "TIDDL_AUTH"
|
ENV_KEY = "TIDDL_AUTH"
|
||||||
@@ -22,6 +25,8 @@ def get_auth_credentials() -> tuple[str, str]:
|
|||||||
if env_value:
|
if env_value:
|
||||||
client_id, client_secret = env_value.split(";")
|
client_id, client_secret = env_value.split(";")
|
||||||
|
|
||||||
|
log.debug(f"{client_id=}, {bool(env_value)=}")
|
||||||
|
|
||||||
return client_id, client_secret
|
return client_id, client_secret
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ class AuthResponse(BaseModel):
|
|||||||
acceptedEULA: bool
|
acceptedEULA: bool
|
||||||
created: int | str
|
created: int | str
|
||||||
updated: int | str
|
updated: int | str
|
||||||
facebookUid: int
|
facebookUid: Optional[int] = None
|
||||||
appleUid: Optional[str]
|
appleUid: Optional[str] = None
|
||||||
googleUid: Optional[str]
|
googleUid: Optional[str] = None
|
||||||
accountLinkCreated: bool
|
accountLinkCreated: bool
|
||||||
emailVerified: bool
|
emailVerified: bool
|
||||||
newUser: bool
|
newUser: bool
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ class Cover:
|
|||||||
if not self.data:
|
if not self.data:
|
||||||
self.data = self.fetch_data()
|
self.data = self.fetch_data()
|
||||||
|
|
||||||
|
if not self.data:
|
||||||
|
log.debug(f"cover data is empty ({file})")
|
||||||
|
return
|
||||||
|
|
||||||
file.parent.mkdir(parents=True, exist_ok=True)
|
file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ class FFmpegError(RuntimeError):
|
|||||||
|
|
||||||
def run(cmd: list[str]) -> subprocess.CompletedProcess:
|
def run(cmd: list[str]) -> subprocess.CompletedProcess:
|
||||||
"""Run a process; raise `FFmpegError` on non-zero exit with stderr."""
|
"""Run a process; raise `FFmpegError` on non-zero exit with stderr."""
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
# Force UTF-8 encoding to prevent UnicodeDecodeError on Windows
|
||||||
|
r = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace" # Added as a safety net
|
||||||
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise FFmpegError(
|
raise FFmpegError(
|
||||||
f"{cmd[0]} failed (rc={r.returncode}): {r.stderr.strip()}"
|
f"{cmd[0]} failed (rc={r.returncode}): {r.stderr.strip()}"
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ def generate_template_data(
|
|||||||
artists=", ".join(
|
artists=", ".join(
|
||||||
a.name for a in (album.artists or []) if a.type == "MAIN"
|
a.name for a in (album.artists or []) if a.type == "MAIN"
|
||||||
),
|
),
|
||||||
date=album.releaseDate,
|
date=album.releaseDate or datetime.min,
|
||||||
explicit=Explicit(getattr(album, "explicit", None)),
|
explicit=Explicit(getattr(album, "explicit", None)),
|
||||||
master=UserFormat(
|
master=UserFormat(
|
||||||
"HIRES_LOSSLESS" in album.mediaMetadata.tags and quality == "MAX"
|
"HIRES_LOSSLESS" in album.mediaMetadata.tags and quality == "MAX"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from xml.etree.ElementTree import fromstring
|
|||||||
|
|
||||||
from tiddl.core.api.models import TrackStream, VideoStream
|
from tiddl.core.api.models import TrackStream, VideoStream
|
||||||
|
|
||||||
|
DOLBY_CODECS = ["eac3", "ac4"]
|
||||||
|
|
||||||
|
|
||||||
def parse_manifest_XML(xml_content: str):
|
def parse_manifest_XML(xml_content: str):
|
||||||
"""
|
"""
|
||||||
@@ -80,7 +82,7 @@ def parse_track_stream(track_stream: TrackStream) -> tuple[list[str], str]:
|
|||||||
file_extension = ".flac"
|
file_extension = ".flac"
|
||||||
if track_stream.audioQuality == "HI_RES_LOSSLESS":
|
if track_stream.audioQuality == "HI_RES_LOSSLESS":
|
||||||
file_extension = ".m4a"
|
file_extension = ".m4a"
|
||||||
elif codecs.startswith("mp4"):
|
elif codecs.startswith("mp4") or codecs in DOLBY_CODECS:
|
||||||
file_extension = ".m4a"
|
file_extension = ".m4a"
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}")
|
raise ValueError(f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}")
|
||||||
|
|||||||
Reference in New Issue
Block a user