mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 12:15:13 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 459d5a50b9 | |||
| ee160fc5bc | |||
| 1a78d875fa | |||
| b0ed7bd208 | |||
| e45628e15f | |||
| d9c8984dfa |
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tiddl"
|
||||
version = "3.4.0"
|
||||
version = "3.4.1"
|
||||
description = "Download Tidal tracks with CLI downloader."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
+1
-1
@@ -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.1"
|
||||
|
||||
|
||||
@app.callback()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user