Files
tiddl/tiddl/core/utils/format.py
T
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

236 lines
6.3 KiB
Python

import re
from dataclasses import dataclass, field
from datetime import datetime
from tiddl.core.api.models import Track, Video, Album, Playlist
from tiddl.core.utils.sanitize import sanitize_string
def _clean_segment(text: str) -> str:
"""
Clean a single path segment using sanitize_string plus extra rules
to keep it safe for Windows / NAS filesystems.
- Uses sanitize_string for base cleanup.
- Collapses multiple dots ("..", "...") into a single dot.
- Removes trailing dots and spaces (Windows forbids them).
- Collapses multiple spaces into one.
- Ensures the segment is never empty (uses "_" as fallback).
"""
text = sanitize_string(text)
text = re.sub(r"\.{2,}", ".", text)
text = text.rstrip(" .")
text = re.sub(r"\s{2,}", " ", text)
text = text.strip()
return text or "_"
class Explicit:
def __init__(self, value: bool | None):
self.value = value
def __format__(self, format_spec: str):
if self.value is None:
return ""
features = format_spec.split(",")
def get_base():
for feature in features:
match feature:
case "long":
return "explicit" if self.value else ""
case "full":
return "explicit" if self.value else "clean"
return "E" if self.value else ""
base = get_base()
for feature in features:
match feature:
case "upper":
return base.upper()
return base
class UserFormat:
def __init__(self, value: bool) -> None:
self.value = value
def __format__(self, format_spec: str) -> str:
return format_spec if self.value is True else ""
@dataclass(slots=True)
class AlbumTemplate:
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)
class ItemTemplate:
id: int
title: str
title_version: str
number: int
volume: int
version: str
copyright: str
bpm: int
isrc: str
quality: str
artist: str
artists: str
features: str
artists_with_features: str
explicit: Explicit
dolby: UserFormat
@dataclass(slots=True)
class PlaylistTemplate:
uuid: str
title: str
index: int
created: datetime
updated: datetime
def generate_template_data(
item: Track | Video | None = None,
album: Album | None = None,
playlist: Playlist | None = None,
playlist_index: int = 0,
quality: str = "",
) -> dict[str, ItemTemplate | AlbumTemplate | PlaylistTemplate | None]:
"""Normalize Tidal API Track/Video + Album data into safe templates."""
item_template = None
if item:
main_artists = sorted(
[a.name for a in (item.artists or []) if a.type == "MAIN"]
)
featured_artists = sorted(
[a.name for a in (item.artists or []) if a.type == "FEATURED"]
)
if isinstance(item, Track):
version = item.version or ""
copyright_ = item.copyright or ""
bpm = item.bpm or 0
isrc = item.isrc or ""
dolby = UserFormat("DOLBY_ATMOS" in item.mediaMetadata.tags)
else: # Video
version = ""
copyright_ = ""
bpm = 0
isrc = ""
dolby = UserFormat(False)
item_template = ItemTemplate(
id=item.id,
title=item.title,
title_version=f"{item.title} ({version})" if version else item.title,
number=item.trackNumber,
volume=item.volumeNumber,
version=version,
copyright=copyright_,
bpm=bpm,
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),
explicit=Explicit(getattr(item, "explicit", None)),
dolby=dolby,
)
album_template = AlbumTemplate()
if album:
album_template = AlbumTemplate(
id=album.id,
title=album.title,
artist=album.artist.name if album.artist else "",
artists=", ".join(
a.name for a in (album.artists or []) if a.type == "MAIN"
),
date=album.releaseDate,
explicit=Explicit(getattr(album, "explicit", None)),
master=UserFormat(
"HIRES_LOSSLESS" in album.mediaMetadata.tags and quality == "MAX"
),
release=album.type,
)
playlist_template = None
if playlist:
playlist_template = PlaylistTemplate(
uuid=playlist.uuid,
title=playlist.title,
index=playlist_index,
created=datetime.fromisoformat(playlist.created),
updated=datetime.fromisoformat(playlist.lastUpdated),
)
templates: dict[str, ItemTemplate | AlbumTemplate | PlaylistTemplate | None] = {
"item": item_template,
"album": album_template,
"playlist": playlist_template,
}
return templates
def format_template(
template: str,
item: Track | Video | None = None,
album: Album | None = None,
playlist: Playlist | None = None,
playlist_index: int = 0,
quality: str = "",
with_asterisk_ext: bool = True,
**extra,
) -> str:
"""
Raises `AttributeError` on invalid template.
"""
custom_fields = {"now": datetime.now()}
data = (
generate_template_data(
item=item,
album=album,
playlist=playlist,
playlist_index=playlist_index,
quality=quality,
)
| extra
| custom_fields
)
segments: list[str] = []
for raw_segment in template.split("/"):
formatted = raw_segment.format(**data)
cleaned = _clean_segment(formatted)
segments.append(cleaned)
path = "/".join(segments)
if with_asterisk_ext:
path += ".*"
return path