Compare commits

...

13 Commits

Author SHA1 Message Date
Oskar Dudziński 7de23cee1b Bump version from 3.2.3a1 to 3.2.3 2026-04-11 11:27:38 +02:00
Oskar Dudziński 89e4d5c08e ♻️ Skipping errors is now a default behaviour 2026-04-09 20:57:04 +02:00
Oskar Dudziński a1deba92cc Add tiddl version field to bug report template
Added a field for specifying the installed tiddl version.
2026-04-08 12:55:49 +02:00
Oskar Dudziński 0b11c63eba 📝 Added link fallbacks in main cli call 2026-04-06 19:04:33 +02:00
Oskar Dudziński fc074543d1 Bump version to 3.2.3a1 2026-04-06 09:44:21 +02:00
Magnetkopf d9e2314447 Added no browser opening option for authenticating command (#317)
* feat(auth): add no browser mode

* chore: merge print statements
2026-04-06 09:42:51 +02:00
Magnetkopf c3dd2d0606 Artist tag is now a list (#316) 2026-04-05 10:54:46 +02:00
xoconoch 401313cd27 Changed artist separator to ";"
* chore: change separator to "; "

* chore: finishing changin separators

* chore: continue changing separators

---------

Co-authored-by: Ohjne <er@le.com>
2026-04-05 10:48:16 +02:00
Oskar Dudziński b6ddd6b64e Bump version from 3.2.1 to 3.2.2 2026-03-08 07:53:39 +01:00
nikudaorg 3948c79412 🐛 Fixed auth errors (#300)
Resolves issue #299.

int | str is used instead of updating the type to only "str" because the API change appears to have been introduced quietly and may revert in the future. Since the exact type of these fields is not critical for the library, supporting both types provides a safer and more resilient approach.
2026-03-08 07:53:05 +01:00
Oskar Dudziński ee7e079a27 🚀 Bump version from 3.2.0 to 3.2.1 2026-02-26 14:42:48 +01:00
TooYoungTooSimp 3d1314e198 Enable trust_env in aiohttp.ClientSession (#294) 2026-02-26 14:41:56 +01:00
Filip Voska cf0d1cd362 fix: handle null/missing fields in Video API responses (#295)
* fix: handle null/missing fields in Video API responses

Tidal's API returns some Video objects (lyric/visualiser videos on
artist pages) with fields that don't match the current strict models:

- `imageId` can be null instead of a string
- The nested `album` object can be present but missing `id`, `title`,
  and `cover`

These validation failures cause the entire `ArtistVideosItems` page
to be rejected by Pydantic before any video can be parsed, resulting
in 0 downloads when targeting an artist with `--videos`.

A second independent bug causes an `AttributeError` on every video:
the default template `{album.artist}/{album.title}/{item.title}` is
shared with videos, but many videos have no album. When `album=None`
is passed to `format_template`, Python's `str.format()` evaluates
`None.artist` and raises `AttributeError: 'NoneType' object has no
attribute 'artist'`, which is caught and printed as an error for
every single video.

Fix:
- `resources.py`: make `Video.imageId` and `Video.Album.{id,title,
  cover}` optional so incomplete API responses pass validation
- `format.py`: give `AlbumTemplate` field defaults so it can be
  instantiated empty; use `AlbumTemplate()` as fallback instead of
  `None` when no album is present, so `{album.*}` tokens render as
  empty strings rather than raising AttributeError
- `download/__init__.py`: guard `video.album.id` accesses against
  `None` (now possible after the model fix) in both video code paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add tests for Video model null fields and AlbumTemplate fallback

Covers the two bugs fixed in the previous commit:

- Video model accepts null/missing imageId and partial album objects
- format_template does not raise AttributeError when album is None
  and the template references {album.*} tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:35:33 +01:00
13 changed files with 253 additions and 49 deletions
+6
View File
@@ -43,6 +43,12 @@ body:
- Windows
- Linux
- MacOS
- type: textarea
id: tiddl
attributes:
label: tiddl version
description: tiddl version you have installed
render: shell
- type: textarea
id: logs
attributes:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "3.2.0"
version = "3.2.3"
description = "Download Tidal tracks with CLI downloader."
readme = "README.md"
requires-python = ">=3.13"
@@ -0,0 +1,86 @@
import pytest
from pydantic import ValidationError
from tiddl.core.api.models.resources import Video
# Minimal valid payload shared across tests
BASE_VIDEO = {
"id": 123,
"title": "Test Video",
"volumeNumber": 1,
"trackNumber": 1,
"duration": 180,
"quality": "MP4_1080P",
"streamReady": True,
"adSupportedStreamReady": False,
"djReady": False,
"stemReady": False,
"allowStreaming": True,
"explicit": False,
"popularity": 50,
"type": "Music Video",
"adsPrePaywallOnly": False,
"artists": [],
}
def test_video_null_image_id():
"""imageId=null should be accepted (Tidal returns this for some videos)."""
video = Video.model_validate({**BASE_VIDEO, "imageId": None})
assert video.imageId is None
def test_video_missing_image_id():
"""imageId absent entirely should default to None."""
video = Video.model_validate(BASE_VIDEO)
assert video.imageId is None
def test_video_valid_image_id():
"""A normal imageId string should still be accepted."""
video = Video.model_validate({**BASE_VIDEO, "imageId": "abc123"})
assert video.imageId == "abc123"
def test_video_album_missing_required_fields():
"""album object present but missing id/title/cover should be accepted."""
payload = {
**BASE_VIDEO,
"album": {"vibrantColor": None},
}
video = Video.model_validate(payload)
assert video.album is not None
assert video.album.id is None
assert video.album.title is None
assert video.album.cover is None
def test_video_album_none():
"""album=null should still be accepted (existing behaviour)."""
video = Video.model_validate({**BASE_VIDEO, "album": None})
assert video.album is None
def test_video_album_fully_populated():
"""A fully populated album object should still parse correctly."""
payload = {
**BASE_VIDEO,
"album": {
"id": 42,
"title": "Greatest Hits",
"cover": "cover-uuid",
},
}
video = Video.model_validate(payload)
assert video.album is not None
assert video.album.id == 42
assert video.album.title == "Greatest Hits"
assert video.album.cover == "cover-uuid"
def test_video_still_requires_core_fields():
"""Removing a genuinely required field (title) should still raise."""
payload = {k: v for k, v in BASE_VIDEO.items() if k != "title"}
with pytest.raises(ValidationError):
Video.model_validate(payload)
+103
View File
@@ -0,0 +1,103 @@
from datetime import datetime
import pytest
from tiddl.core.utils.format import AlbumTemplate, format_template, generate_template_data
from tiddl.core.api.models.resources import Video
# Minimal Video instance used across format tests
BASE_VIDEO = Video.model_validate(
{
"id": 1,
"title": "My Video",
"volumeNumber": 1,
"trackNumber": 1,
"duration": 200,
"quality": "MP4_1080P",
"streamReady": True,
"adSupportedStreamReady": False,
"djReady": False,
"stemReady": False,
"allowStreaming": True,
"explicit": False,
"popularity": 10,
"type": "Music Video",
"adsPrePaywallOnly": False,
"artists": [{"id": 1, "name": "Gorillaz", "type": "MAIN"}],
"artist": {"id": 1, "name": "Gorillaz", "type": "MAIN"},
}
)
class TestAlbumTemplateDefaults:
def test_can_be_instantiated_with_no_args(self):
t = AlbumTemplate()
assert t.id == 0
assert t.title == ""
assert t.artist == ""
assert t.artists == ""
assert t.release == ""
def test_date_defaults_to_datetime_min(self):
assert AlbumTemplate().date == datetime.min
def test_explicit_formats_to_empty_string(self):
assert f"{AlbumTemplate().explicit}" == ""
def test_master_formats_to_empty_string(self):
assert f"{AlbumTemplate().master:MASTER}" == ""
class TestFormatTemplateNoAlbum:
def test_album_artist_token_does_not_raise(self):
"""Default template must not raise AttributeError when album is None."""
result = format_template(
template="{album.artist}/{album.title}/{item.title}",
item=BASE_VIDEO,
album=None,
with_asterisk_ext=False,
)
# album tokens render as "_" (empty string → sanitised fallback)
assert result == "_/_/My Video"
def test_album_title_token_does_not_raise(self):
result = format_template(
template="{album.title}",
item=BASE_VIDEO,
album=None,
with_asterisk_ext=False,
)
assert result == "_"
def test_item_title_still_rendered(self):
result = format_template(
template="{item.title}",
item=BASE_VIDEO,
album=None,
with_asterisk_ext=False,
)
assert result == "My Video"
def test_item_artist_still_rendered(self):
result = format_template(
template="{item.artist}",
item=BASE_VIDEO,
album=None,
with_asterisk_ext=False,
)
assert result == "Gorillaz"
class TestGenerateTemplateDataAlbumFallback:
def test_album_template_is_never_none(self):
"""generate_template_data should always return an AlbumTemplate, never None."""
data = generate_template_data(item=BASE_VIDEO, album=None)
assert data["album"] is not None
assert isinstance(data["album"], AlbumTemplate)
def test_album_template_has_empty_fields_when_no_album(self):
data = generate_template_data(item=BASE_VIDEO, album=None)
album = data["album"]
assert album.title == ""
assert album.artist == ""
+3 -3
View File
@@ -33,8 +33,8 @@ def callback(
"""
tiddl - download tidal tracks \u266b
[link=https://github.com/oskvr37/tiddl]github[/link]
[link=https://buymeacoffee.com/oskvr][yellow]buy me a coffee[/link] \u2764
[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]
"""
log.debug(f"{ctx.params=}")
@@ -54,5 +54,5 @@ def callback(
if not is_ffmpeg_installed:
ctx.obj.console.print(
"[yellow]WARNING ffmpeg is not installed, tiddl might not work properly, "
+ "[link=https://github.com/oskvr37/tiddl/blob/main/README.md#installation]read README.md[/]"
+ "[link=https://github.com/oskvr37/tiddl/blob/main/README.md#installation]read README.md (https://github.com/oskvr37/tiddl/blob/main/README.md#installation)[/]"
)
+11 -2
View File
@@ -17,7 +17,14 @@ auth_command = typer.Typer(
# TODO add context and load auth data from ctx
@auth_command.command(help="Login with your Tidal account.")
def login():
def login(
NO_BROWSER: Annotated[
bool,
typer.Option(
"--no-browser", "-n", help="Do not open browser."
),
] = False,
):
loaded_auth_data = load_auth_data()
if loaded_auth_data.token:
@@ -28,8 +35,10 @@ def login():
device_auth = auth_api.get_device_auth()
uri = f"https://{device_auth.verificationUriComplete}"
typer.launch(uri)
if not NO_BROWSER:
typer.launch(uri)
console.print(f"Go to '{uri}' and complete authentication!")
auth_end_at = time() + device_auth.expiresIn
+17 -17
View File
@@ -118,12 +118,12 @@ def download_callback(
help="Videos handling: 'none' to exclude, 'allow' to include, 'only' to download videos only.",
),
] = CONFIG.download.videos_filter,
SKIP_ERRORS: Annotated[
RAISE_ERRORS: Annotated[
bool,
typer.Option(
"--skip-errors",
"-se",
help="Skip unavailable items and continue downloading the rest.",
"--raise-errors",
"-err",
help="Raise an error on resource download failure. Use for debugging",
),
] = False,
):
@@ -343,13 +343,13 @@ def download_callback(
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})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
except Exception as e:
item = album_item.item
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
ctx.obj.console.print(f"[red]Error:[/] {e} ({track_info})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
offset += album_items.limit
@@ -414,7 +414,7 @@ def download_callback(
video = ctx.obj.api.get_video(resource.id)
template = TEMPLATE or CONFIG.templates.video
if "{album" in template and video.album:
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
@@ -463,13 +463,13 @@ def download_callback(
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})")
if not SKIP_ERRORS:
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})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
offset += mix_items.limit
@@ -500,11 +500,11 @@ def download_callback(
await download_album(album)
except ApiError as e:
ctx.obj.console.print(f"[red]API Error:[/] {e} (Album: {album.title}, ID: {album.id})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
except Exception as e:
ctx.obj.console.print(f"[red]Error:[/] {e} (Album: {album.title}, ID: {album.id})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
def get_all_albums(singles: bool):
@@ -554,11 +554,11 @@ def download_callback(
)
except ApiError as e:
ctx.obj.console.print(f"[red]API Error:[/] {e} (Video: {video.title}, ID: {video.id})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
except Exception as e:
ctx.obj.console.print(f"[red]Error:[/] {e} (Video: {video.title}, ID: {video.id})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
if offset > artist_videos.totalNumberOfItems:
@@ -621,13 +621,13 @@ def download_callback(
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})")
if not SKIP_ERRORS:
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})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
offset += playlist_items.limit
@@ -673,11 +673,11 @@ def download_callback(
await handle_resource(r)
except ApiError as e:
ctx.obj.console.print(f"[red]API Error:[/] {e} ({r})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
except Exception as e:
ctx.obj.console.print(f"[red]Error:[/] {e} ({r})")
if not SKIP_ERRORS:
if RAISE_ERRORS:
raise
await asyncio.gather(*(wrapper(r) for r in ctx.obj.resources))
+1 -1
View File
@@ -173,7 +173,7 @@ class Downloader:
with NamedTemporaryFile(
"wb", delete=False, dir=download_path.parent
) as tmp:
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiofiles.open(tmp.name, "wb") as f:
for url in urls:
async with session.get(url) as resp:
+4 -4
View File
@@ -69,9 +69,9 @@ class Video(BaseModel):
picture: Optional[str] = None
class Album(BaseModel):
id: int
title: str
cover: str
id: Optional[int] = None
title: Optional[str] = None
cover: Optional[str] = None
vibrantColor: Optional[str] = None
videoCover: Optional[str] = None
@@ -81,7 +81,7 @@ class Video(BaseModel):
trackNumber: int
streamStartDate: Optional[datetime] = None
imagePath: Optional[str] = None
imageId: str
imageId: Optional[str] = None
vibrantColor: Optional[str] = None
duration: int
quality: Literal["MP4_1080P"] | str
+3 -3
View File
@@ -17,12 +17,12 @@ class AuthResponse(BaseModel):
postalcode: Optional[str]
usState: Optional[str]
phoneNumber: Optional[str]
birthday: Optional[int]
birthday: Optional[int | str]
channelId: int
parentId: int
acceptedEULA: bool
created: int
updated: int
created: int | str
updated: int | str
facebookUid: int
appleUid: Optional[str]
googleUid: Optional[str]
+3 -3
View File
@@ -16,7 +16,7 @@ class Metadata:
disc_number: str
copyright: str | None
album_artist: str
artists: str
artists: list[str]
album_title: str
date: str
isrc: str
@@ -93,7 +93,7 @@ def add_m4a_metadata(track_path: Path, metadata: Metadata) -> None:
"discnumber": metadata.disc_number,
"album": metadata.album_title,
"albumartist": metadata.album_artist,
"artist": metadata.artists,
"artist": ["; ".join(metadata.artists)],
"date": metadata.date,
"copyright": metadata.copyright or "",
"comment": metadata.comment,
@@ -150,7 +150,7 @@ def add_track_metadata(
disc_number=str(track.volumeNumber),
copyright=track.copyright,
album_artist=album_artist,
artists=", ".join(sorted(a.name.strip() for a in track.artists)),
artists=sorted(a.name.strip() for a in track.artists),
album_title=track.album.title,
date=date,
isrc=track.isrc,
+1 -1
View File
@@ -9,7 +9,7 @@ def add_video_metadata(path: Path, video: Video):
mutagen.update(
{
"title": video.title,
"artist": ";".join([artist.name.strip() for artist in video.artists]),
"artist": "; ".join([artist.name.strip() for artist in video.artists]),
}
)
+14 -14
View File
@@ -1,5 +1,5 @@
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from tiddl.core.api.models import Track, Video, Album, Playlist
@@ -35,7 +35,7 @@ class Explicit:
if self.value is None:
return ""
features = format_spec.split(",")
features = format_spec.split("; ")
def get_base():
for feature in features:
@@ -67,14 +67,14 @@ class UserFormat:
@dataclass(slots=True)
class AlbumTemplate:
id: int
title: str
artist: str
artists: str
date: datetime
explicit: Explicit
master: UserFormat
release: str
id: int = 0
title: str = ""
artist: str = ""
artists: str = ""
date: datetime = datetime.min
explicit: Explicit = field(default_factory=lambda: Explicit(None))
master: UserFormat = field(default_factory=lambda: UserFormat(False))
release: str = ""
@dataclass(slots=True)
@@ -149,14 +149,14 @@ def generate_template_data(
isrc=isrc,
quality=quality,
artist=item.artist.name if item.artist else "",
artists=", ".join(main_artists),
features=", ".join(featured_artists),
artists_with_features=", ".join(main_artists + featured_artists),
artists="; ".join(main_artists),
features="; ".join(featured_artists),
artists_with_features="; ".join(main_artists + featured_artists),
explicit=Explicit(getattr(item, "explicit", None)),
dolby=dolby,
)
album_template = None
album_template = AlbumTemplate()
if album:
album_template = AlbumTemplate(
id=album.id,