mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 12:15:13 +03:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de23cee1b | |||
| 89e4d5c08e | |||
| a1deba92cc | |||
| 0b11c63eba | |||
| fc074543d1 | |||
| d9e2314447 | |||
| c3dd2d0606 | |||
| 401313cd27 | |||
| b6ddd6b64e | |||
| 3948c79412 | |||
| ee7e079a27 | |||
| 3d1314e198 | |||
| cf0d1cd362 |
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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)[/]"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user