Compare commits

...

25 Commits

Author SHA1 Message Date
Oskar Dudziński 05d63d153e Added ac4 codecs support 2026-06-09 20:39:43 +02:00
Oskar Dudziński 04de8e677c 🐛 Fixed Auth errors (#374)
* 🐛 Fix core Auth API

* 🔖 Bump version to 3.4.3
2026-05-10 13:22:24 +02:00
Oskar Dudziński d0d146b87f 🔖 Bump version to 3.4.2 2026-05-10 13:18:00 +02:00
mvpetrico 77e488ff30 🐛 Fix core API errors (#367) 2026-05-10 13:16:48 +02:00
Oskar Dudziński 459d5a50b9 Added audio mode filter (Dolby Atmos or Stereo) (#363)
* prepare dolby atmos config

* add audio mode filter logic
2026-05-06 00:57:46 +02:00
Oskar Dudziński ee160fc5bc 📝 Added update instruction, removed depracated section 2026-05-05 21:22:52 +02:00
Oskar Dudziński 1a78d875fa 📝 Added Python 3.15 2026-05-05 21:13:48 +02:00
Oskar Dudziński b0ed7bd208 ♻️ Formatting 2026-05-05 21:13:29 +02:00
Oskar Dudziński e45628e15f 🚀 Bump version to 3.4.1 2026-05-05 20:57:05 +02:00
Oskar Dudziński d9c8984dfa 🚀 Bump version to 3.4.1 2026-05-05 20:56:47 +02:00
Oskar Dudziński c285be6ed2 Add missing auth command tests 2026-05-04 22:02:00 +02:00
Oskar Dudziński 5e4f9bdb6a 🚀 Bump to 3.4.0 2026-05-04 21:42:34 +02:00
Oskar Dudziński a282c1a4af Show audio type in search command 2026-05-04 21:35:26 +02:00
Oskar Dudziński 46a6e748da 🐛 Fix #360 2026-05-04 21:34:36 +02:00
Oskar Dudziński bf8ded5f60 Show audio mode in CLI while downloading (Dolby Atmos) 2026-05-04 01:46:26 +02:00
Oskar Dudziński 7e0fb9fb37 🚀 Bump to 3.4.0a8 2026-05-03 20:45:00 +02:00
Oskar Dudziński 880f6008b0 🐛 Fixed playlist cover saving (#358)
* add proper template for playlist covers

* cover size is now properly limited

* dont save cover if cover data is empty
2026-05-03 20:43:36 +02:00
Oskar Dudziński 0f9a4006f1 Bump to 3.4.0a7 2026-05-03 00:38:57 +02:00
Oskar Dudziński 3cfadd7795 🐛 Fixed album date format 2026-05-03 00:38:12 +02:00
Oskar Dudziński 47975e12bc 📢 Log tiddl version 2026-05-02 18:19:23 +02:00
Oskar Dudziński fbb32e735d 📝 Added version info to CLI 2026-05-02 18:09:15 +02:00
Oskar Dudziński 3cba05910b 📢 Log client id and if it was loaded from env 2026-05-02 17:35:52 +02:00
Oskar Dudziński c22cb2941d 📢 Log stream data 2026-05-02 17:31:15 +02:00
Oskar Dudziński 6b82c40fae Added Dolby Atmos support (needs testing) (#348)
* add dolby atmos support

* 🚀 Bump version to 3.4.0a5
2026-04-30 01:14:32 +02:00
Oskar Dudziński 9abf141411 🐛 Fixed missing releaseDate in albums (fix #260) 2026-04-29 14:00:43 +02:00
19 changed files with 212 additions and 69 deletions
+2
View File
@@ -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
+6 -12
View File
@@ -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 |
+8 -1
View File
@@ -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
View File
@@ -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"
+52 -7
View File
@@ -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
View File
@@ -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()
+12 -6
View File
@@ -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.")
+54 -16
View File
@@ -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(
+37 -9
View File
@@ -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)
+7 -7
View File
@@ -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:
+2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+5
View File
@@ -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
+3 -3
View File
@@ -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
+4
View File
@@ -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 -1
View File
@@ -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()}"
+1 -1
View File
@@ -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"
+3 -1
View File
@@ -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}")