Compare commits

...

12 Commits

Author SHA1 Message Date
Oskar Dudziński 4b6b23225a 🚀 Bump version from 3.2.3 to 3.3.0 2026-04-23 23:03:02 +02:00
Mijael Viricochea ed9a05c666 Added option to write LRC file (#308) 2026-04-23 22:59:38 +02:00
xiliourt 8a2c30feaf 🐬 Remove hidden directories in Dockerfile (#326)
* Update Dockerfile

* Clears entire directory after install

Hidden folders such as .git, .vscode, etc were remaining in the docker build unintentionally. This update clears all files once tiddl is installed.
2026-04-13 22:35:38 +02:00
Oskar Dudziński cda1dc6a7a 🐛 Fixed missing metadata when downloading a single track 2026-04-11 17:04:17 +02:00
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
11 changed files with 78 additions and 32 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
@@ -17,4 +17,4 @@ RUN python -c "import tomllib; f=open('pyproject.toml','rb'); print('\n'.join(to
# -- Layer 3 - Uncached layer (regenerates anytime a new build is released) --
COPY . .
RUN pip install --no-deps .
RUN rm -rf *
RUN rm -rf -- ..?* .[!.]* *
+4
View File
@@ -80,6 +80,10 @@ update_mtime = false
# could be useful when data on Tidal has changed.
rewrite_metadata = false
# if this option is set to true, an .lrc file will be created alongside the
# track file with the same name
write_lrc_file = false
[metadata]
# embed metadata in files
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "3.2.2"
version = "3.3.0"
description = "Download Tidal tracks with CLI downloader."
readme = "README.md"
requires-python = ">=3.13"
+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
+43 -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,
):
@@ -135,6 +135,18 @@ def download_callback(
log.debug(f"{ctx.params=}")
def write_lrc_file(track: Track, lyrics: str, file_path: Path):
if not CONFIG.download.write_lrc_file or not lyrics.strip():
return
lrc_file_path = file_path.with_suffix(".lrc")
try:
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}")
def save_m3u(
resource_type: VALID_M3U_RESOURCE_LITERAL,
filename: str,
@@ -237,7 +249,7 @@ def download_callback(
if isinstance(item, Track):
lyrics_subtitles = ""
if CONFIG.metadata.lyrics:
if CONFIG.metadata.lyrics or CONFIG.download.write_lrc_file:
try:
lyrics_subtitles = ctx.obj.api.get_track_lyrics(
item.id
@@ -255,6 +267,8 @@ def download_callback(
if track_metadata.cover and track_metadata.cover.data is None:
track_metadata.cover.fetch_data()
write_lrc_file(item, lyrics_subtitles, download_path)
add_track_metadata(
path=download_path,
track=item,
@@ -343,13 +357,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
@@ -386,6 +400,12 @@ def download_callback(
track = ctx.obj.api.get_track(resource.id)
album = ctx.obj.api.get_album(track.album.id)
cover: Cover | None = None
save_cover = ("track" in CONFIG.cover.allowed) and CONFIG.cover.save
if album.cover and (CONFIG.metadata.cover or save_cover):
cover = Cover(album.cover, size=CONFIG.cover.size)
await handle_item(
item=track,
file_path=format_template(
@@ -394,6 +414,12 @@ def download_callback(
album=album,
quality=get_item_quality(track),
),
track_metadata=Metadata(
cover=cover,
date=str(album.releaseDate),
artist=album.artist.name if album.artist else "",
# credits are missing
),
)
if (
@@ -463,13 +489,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 +526,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 +580,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 +647,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 +699,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
View File
@@ -55,6 +55,7 @@ class Config(BaseModel):
videos_filter: VIDEOS_FILTER_LITERAL = "none"
update_mtime: bool = False
rewrite_metadata: bool = False
write_lrc_file: bool = False
def model_post_init(self, __context):
# set scan path to download path when download path is non default
+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]),
}
)
+4 -4
View File
@@ -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:
@@ -149,9 +149,9 @@ 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,
)