Compare commits

...

10 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
10 changed files with 107 additions and 40 deletions
+2
View File
@@ -14,6 +14,7 @@ body:
attributes:
label: What command was used?
placeholder: tiddl
render: shell
- type: textarea
id: what-happened
@@ -31,6 +32,7 @@ body:
options:
- 3.13
- 3.14
- 3.15
default: 0
validations:
required: true
+6 -12
View File
@@ -24,6 +24,12 @@ We recommend using [uv](https://docs.astral.sh/uv/)
uv tool install tiddl
```
To install exact version e.g. 3.4.1
```bash
uv tool install tiddl==3.4.1
```
## pip
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.
### 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 | File extension | Details |
+7
View File
@@ -91,6 +91,13 @@ write_lrc_file = false
# downloads will continue under "FooBar".
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]
# embed metadata in files
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "3.4.0"
version = "3.4.4a1"
description = "Download Tidal tracks with CLI downloader."
readme = "README.md"
requires-python = ">=3.13"
+1 -1
View File
@@ -13,7 +13,7 @@ log = logging.getLogger("tiddl")
app = typer.Typer(name="tiddl", no_args_is_help=True, rich_markup_mode="rich")
register_commands(app)
VERSION = "v3.4.0a6"
VERSION = "v3.4.4a1"
@app.callback()
+53 -15
View File
@@ -20,6 +20,7 @@ from tiddl.cli.config import (
ARTIST_SINGLES_FILTER_LITERAL,
VALID_M3U_RESOURCE_LITERAL,
VIDEOS_FILTER_LITERAL,
ATMOS_FILTER_LITERAL,
)
from tiddl.cli.utils.resource import TidalResource
from tiddl.cli.ctx import Context
@@ -126,6 +127,14 @@ def download_callback(
help="Raise an error on resource download failure. Use for debugging",
),
] = 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.
@@ -145,7 +154,9 @@ def download_callback(
with open(lrc_file_path, "w", encoding="utf-8") as f:
f.write(lyrics)
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(
resource_type: VALID_M3U_RESOURCE_LITERAL,
@@ -206,6 +217,7 @@ def download_callback(
download_path=DOWNLOAD_PATH,
scan_path=SCAN_PATH,
match_existing_path_case=CONFIG.download.match_existing_path_case,
dolby_atmos_filter=DOLBY_ATMOS_FILTER,
)
class Metadata:
@@ -346,7 +358,9 @@ def download_callback(
track_metadata=Metadata(
cover=cover,
date=str(album.releaseDate),
artist=album.artist.name if album.artist else "",
artist=(
album.artist.name if album.artist else ""
),
credits=album_item.credits,
album_review=album_review,
),
@@ -355,9 +369,11 @@ def download_callback(
except ApiError as e:
item = album_item.item
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}"
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:
raise
except Exception as e:
@@ -441,7 +457,11 @@ def download_callback(
video = ctx.obj.api.get_video(resource.id)
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)
else:
album = None
@@ -489,13 +509,17 @@ def download_callback(
except ApiError as e:
item = mix_item.item
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:
raise
except Exception as e:
item = mix_item.item
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:
raise
@@ -526,11 +550,15 @@ def download_callback(
try:
await download_album(album)
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:
raise
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:
raise
@@ -580,11 +608,15 @@ def download_callback(
)
)
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:
raise
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:
raise
@@ -637,7 +669,9 @@ def download_callback(
album=album,
playlist=playlist,
playlist_index=playlist_index,
quality=get_item_quality(playlist_item.item),
quality=get_item_quality(
playlist_item.item
),
),
track_metadata=Metadata(),
)
@@ -645,15 +679,19 @@ def download_callback(
except ApiError as e:
item = playlist_item.item
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}"
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:
raise
except Exception as e:
item = playlist_item.item
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:
raise
+29 -7
View File
@@ -7,7 +7,7 @@ from tempfile import NamedTemporaryFile
import aiofiles
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.path import resolve_existing_path_case
from tiddl.core.api import ApiError, TidalAPI
@@ -52,6 +52,7 @@ class Downloader:
download_path: Path
scan_path: Path
match_existing_path_case: bool
dolby_atmos_filter: ATMOS_FILTER_LITERAL
def __init__(
self,
@@ -65,6 +66,7 @@ class Downloader:
download_path: Path,
scan_path: Path,
match_existing_path_case: bool = False,
dolby_atmos_filter: ATMOS_FILTER_LITERAL = "none",
) -> None:
self.api = tidal_api
self.rich_output = rich_output
@@ -76,6 +78,7 @@ class Downloader:
self.download_path = download_path
self.scan_path = scan_path
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:
if self.match_existing_path_case:
@@ -144,7 +147,23 @@ class Downloader:
stream = self.api.get_track_stream(
track_id=item.id, quality=self.track_quality
)
log.debug(f"{stream.trackId=}, {stream.audioQuality}, {stream.audioMode}")
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:
log.error(f"{item.id=} {e=}")
self.rich_output.console.print(
@@ -157,12 +176,15 @@ class Downloader:
quality_string = track_qualities_color[stream.audioQuality]
if stream.audioQuality in ["HI_RES_LOSSLESS", "LOSSLESS"] and stream.audioMode == "STEREO":
if (
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
else:
download_path = download_path.with_suffix(".m4a")
if stream.audioMode == "DOLBY_ATMOS":
quality_string = "[blue]Dolby Atmos[/]"
@@ -172,9 +194,9 @@ class Downloader:
)
urls, ext = parse_video_stream(stream), ".ts"
download_path = self.get_path(
self.download_path, filename
).with_suffix(ext)
download_path = self.get_path(self.download_path, filename).with_suffix(
ext
)
quality_string = video_qualities_color[stream.videoQuality]
task_id = self.rich_output.download_start(
+2
View File
@@ -14,6 +14,7 @@ ARTIST_SINGLES_FILTER_LITERAL = Literal["none", "only", "include"]
VALID_M3U_RESOURCE_LITERAL = Literal["album", "playlist", "mix"]
VALID_RESOURCE_COVER_SAVE_LITERAL = Literal["track", "album", "playlist"]
VIDEOS_FILTER_LITERAL = Literal["none", "only", "allow"]
ATMOS_FILTER_LITERAL = Literal["none", "only", "allow"]
log = getLogger(__name__)
@@ -57,6 +58,7 @@ class Config(BaseModel):
rewrite_metadata: bool = False
write_lrc_file: bool = False
match_existing_path_case: bool = False
atmos_filter: ATMOS_FILTER_LITERAL = "none"
def model_post_init(self, __context):
# set scan path to download path when download path is non default
+3 -3
View File
@@ -23,9 +23,9 @@ class AuthResponse(BaseModel):
acceptedEULA: bool
created: int | str
updated: int | str
facebookUid: int
appleUid: Optional[str]
googleUid: Optional[str]
facebookUid: Optional[int] = None
appleUid: Optional[str] = None
googleUid: Optional[str] = None
accountLinkCreated: bool
emailVerified: bool
newUser: bool
+3 -1
View File
@@ -6,6 +6,8 @@ from xml.etree.ElementTree import fromstring
from tiddl.core.api.models import TrackStream, VideoStream
DOLBY_CODECS = ["eac3", "ac4"]
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"
if track_stream.audioQuality == "HI_RES_LOSSLESS":
file_extension = ".m4a"
elif codecs.startswith("mp4") or codecs == "eac3":
elif codecs.startswith("mp4") or codecs in DOLBY_CODECS:
file_extension = ".m4a"
else:
raise ValueError(f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}")