mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 12:15:13 +03:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b6b23225a | |||
| ed9a05c666 | |||
| 8a2c30feaf | |||
| cda1dc6a7a | |||
| 7de23cee1b | |||
| 89e4d5c08e | |||
| a1deba92cc | |||
| 0b11c63eba | |||
| fc074543d1 | |||
| d9e2314447 | |||
| c3dd2d0606 | |||
| 401313cd27 |
@@ -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
@@ -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 -- ..?* .[!.]* *
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
):
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user