2023-12-19 23:58:57 +01:00
#!/usr/bin/env python
2025-03-03 09:31:37 +01:00
import signal
2025-07-03 11:09:57 +02:00
import sys
2025-01-04 13:58:15 +01:00
from collections.abc import Callable
2024-01-12 10:47:17 +01:00
from pathlib import Path
2025-06-29 14:14:03 +02:00
from typing import Annotated
2025-07-03 11:09:57 +02:00
from urllib.parse import urlparse
2024-01-12 10:47:17 +01:00
2023-12-19 23:58:57 +01:00
import typer
2025-06-29 14:14:03 +02:00
from rich.console import Console , Group
2023-12-19 23:58:57 +01:00
from rich.live import Live
2025-03-01 21:53:46 +01:00
from rich.progress import (
BarColumn ,
Progress ,
SpinnerColumn ,
TaskProgressColumn ,
TextColumn ,
)
2023-12-19 23:58:57 +01:00
from rich.table import Table
2024-04-01 18:58:37 +02:00
from tidal_dl_ng import __version__
2025-03-23 21:07:58 +01:00
from tidal_dl_ng.config import HandlingApp , Settings , Tidal
2024-01-12 08:37:58 +01:00
from tidal_dl_ng.constants import CTX_TIDAL , MediaType
from tidal_dl_ng.download import Download
2024-01-14 23:09:43 +01:00
from tidal_dl_ng.helper.path import get_format_template , path_file_settings
2024-05-22 06:33:40 +02:00
from tidal_dl_ng.helper.tidal import (
all_artist_album_ids ,
get_tidal_media_id ,
get_tidal_media_type ,
instantiate_media ,
)
2024-01-19 08:17:47 +01:00
from tidal_dl_ng.helper.wrapper import LoggerWrapped
2024-01-12 08:37:58 +01:00
from tidal_dl_ng.model.cfg import HelpSettings
2023-12-19 23:58:57 +01:00
2024-01-19 18:00:16 +01:00
app = typer . Typer ( context_settings = { "help_option_names" : [ "-h" , "--help" ]}, add_completion = False )
2025-07-03 11:09:57 +02:00
app_dl_fav = typer . Typer (
2025-01-03 15:23:42 +00:00
context_settings = { "help_option_names" : [ "-h" , "--help" ]},
add_completion = True ,
help = "Download from a favorites collection." ,
)
2025-01-04 13:42:53 +01:00
2025-07-03 11:09:57 +02:00
app . add_typer ( app_dl_fav , name = "dl_fav" )
2023-12-19 23:58:57 +01:00
def version_callback ( value : bool ):
2025-07-01 11:42:07 +02:00
"""Callback to print version and exit if version flag is set.
Args:
value (bool): If True, prints version and exits.
"""
2023-12-19 23:58:57 +01:00
if value :
2024-04-01 18:58:37 +02:00
print ( f " { __version__ } " )
2025-07-03 08:57:46 +02:00
2023-12-19 23:58:57 +01:00
raise typer . Exit ()
2025-07-03 11:09:57 +02:00
@app.callback ()
def callback_app (
ctx : typer . Context ,
version : Annotated [ bool | None , typer . Option ( "--version" , "-v" , callback = version_callback , is_eager = True )] = None ,
):
"""App callback to initialize context and handle version option.
Args:
ctx (typer.Context): Typer context object.
version (bool | None, optional): Version flag. Defaults to None.
"""
ctx . obj = { "tidal" : None }
2025-07-01 11:42:07 +02:00
def _handle_track_or_video (
2025-07-03 08:57:46 +02:00
dl : Download , ctx : typer . Context , item : str , media : object , file_template : str , idx : int , urls_pos_last : int
2025-07-01 11:42:07 +02:00
) -> None :
"""Handle downloading a track or video item.
Args:
dl (Download): The Download instance.
2025-07-03 08:57:46 +02:00
ctx (typer.Context): Typer context object.
2025-07-01 11:42:07 +02:00
item (str): The URL or identifier of the item.
media: The media object to download.
file_template (str): The file template for saving the media.
idx (int): The index of the item in the list.
urls_pos_last (int): The last index in the URLs list.
"""
2025-07-03 08:57:46 +02:00
settings = ctx . obj [ CTX_TIDAL ] . settings
2025-07-01 11:42:07 +02:00
download_delay : bool = bool ( settings . data . download_delay and idx < urls_pos_last )
dl . item (
media = media ,
file_template = file_template ,
download_delay = download_delay ,
quality_audio = settings . data . quality_audio ,
quality_video = settings . data . quality_video ,
)
def _handle_album_playlist_mix_artist (
ctx : typer . Context ,
2025-07-03 08:57:46 +02:00
dl : Download ,
2025-07-01 11:42:07 +02:00
handling_app : HandlingApp ,
media_type : MediaType ,
media : object ,
2025-07-01 13:19:28 +02:00
item_id : str ,
2025-07-01 11:42:07 +02:00
file_template : str ,
) -> bool :
"""Handle downloading albums, playlists, mixes, or artist collections.
Args:
ctx (typer.Context): Typer context object.
2025-07-03 08:57:46 +02:00
dl (Download): The Download instance.
2025-07-01 11:42:07 +02:00
handling_app (HandlingApp): The HandlingApp instance.
media_type (MediaType): The type of media (album, playlist, mix, or artist).
media: The media object to download.
2025-07-01 13:19:28 +02:00
item_id (str): The ID of the media item.
2025-07-01 11:42:07 +02:00
file_template (str): The file template for saving the media.
Returns:
bool: False if aborted, True otherwise.
"""
2025-07-01 13:19:28 +02:00
item_ids : list [ str ] = []
2025-07-03 08:57:46 +02:00
settings = ctx . obj [ CTX_TIDAL ] . settings
2025-07-01 11:42:07 +02:00
if media_type == MediaType . ARTIST :
media_type = MediaType . ALBUM
2025-07-01 13:19:28 +02:00
item_ids += all_artist_album_ids ( media )
2025-07-01 11:42:07 +02:00
else :
item_ids . append ( item_id )
for _item_id in item_ids :
if handling_app . event_abort . is_set ():
return False
dl . items (
media_id = _item_id ,
media_type = media_type ,
file_template = file_template ,
2025-07-03 08:57:46 +02:00
video_download = settings . data . video_download ,
2025-07-01 11:42:07 +02:00
download_delay = settings . data . download_delay ,
2025-07-03 08:57:46 +02:00
quality_audio = settings . data . quality_audio ,
quality_video = settings . data . quality_video ,
2025-07-01 11:42:07 +02:00
)
return True
def _process_url (
dl : Download ,
ctx : typer . Context ,
handling_app : HandlingApp ,
item : str ,
idx : int ,
urls_pos_last : int ,
) -> bool :
"""Process a single URL or ID for download.
Args:
dl (Download): The Download instance.
ctx (typer.Context): Typer context object.
handling_app (HandlingApp): The HandlingApp instance.
item (str): The URL or identifier to process.
idx (int): The index of the item in the list.
urls_pos_last (int): The last index in the URLs list.
Returns:
bool: False if aborted, True otherwise.
"""
2025-07-03 08:57:46 +02:00
settings = ctx . obj [ CTX_TIDAL ] . settings
2025-07-01 11:42:07 +02:00
if handling_app . event_abort . is_set ():
return False
if "http" not in item :
print ( f "It seems like that you have supplied an invalid URL: { item } " )
return True
2025-07-01 13:19:28 +02:00
media_type = get_tidal_media_type ( item )
if not isinstance ( media_type , MediaType ):
print ( f "Could not determine media type for: { item } " )
return True
item_id = get_tidal_media_id ( item )
if not isinstance ( item_id , str ):
print ( f "Could not determine media id for: { item } " )
return True
file_template = get_format_template ( media_type , settings )
if not isinstance ( file_template , str ):
print ( f "Could not determine file template for: { item } " )
return True
2025-07-01 11:42:07 +02:00
try :
2025-07-01 13:19:28 +02:00
media = instantiate_media ( ctx . obj [ CTX_TIDAL ] . session , media_type , item_id )
2025-07-01 11:42:07 +02:00
except Exception :
print ( f "Media not found (ID: { item_id } ). Maybe it is not available anymore." )
return True
if media_type in [ MediaType . TRACK , MediaType . VIDEO ]:
2025-07-03 08:57:46 +02:00
_handle_track_or_video ( dl , ctx , item , media , file_template , idx , urls_pos_last )
2025-07-01 11:42:07 +02:00
elif media_type in [ MediaType . ALBUM , MediaType . PLAYLIST , MediaType . MIX , MediaType . ARTIST ]:
2025-07-03 08:57:46 +02:00
return _handle_album_playlist_mix_artist ( ctx , dl , handling_app , media_type , media , item_id , file_template )
2025-07-01 11:42:07 +02:00
return True
2025-01-04 13:42:53 +01:00
def _download ( ctx : typer . Context , urls : list [ str ], try_login : bool = True ) -> bool :
2025-01-04 13:58:15 +01:00
"""Invokes download function and tracks progress.
2025-01-03 15:20:02 +00:00
2025-07-01 11:42:07 +02:00
Args:
ctx (typer.Context): The typer context object.
urls (list[str]): The list of URLs to download.
try_login (bool, optional): If true, attempts to login to TIDAL. Defaults to True.
Returns:
bool: True if ran successfully.
2025-01-03 15:20:02 +00:00
"""
if try_login :
ctx . invoke ( login , ctx )
2025-07-03 08:57:46 +02:00
settings : Settings = ctx . obj [ CTX_TIDAL ] . settings
2025-03-03 09:31:37 +01:00
handling_app : HandlingApp = HandlingApp ()
2025-07-01 11:42:07 +02:00
2025-01-03 15:20:02 +00:00
progress : Progress = Progress (
2025-03-01 21:53:46 +01:00
TextColumn ( "[progress.description] {task.description} " ),
2025-01-03 15:20:02 +00:00
SpinnerColumn (),
BarColumn (),
2025-03-01 21:53:46 +01:00
TaskProgressColumn (),
refresh_per_second = 20 ,
auto_refresh = True ,
expand = True ,
2025-07-01 11:42:07 +02:00
transient = False ,
2025-03-01 21:53:46 +01:00
)
2025-07-01 11:42:07 +02:00
2025-03-01 21:53:46 +01:00
progress_overall = Progress (
TextColumn ( "[progress.description] {task.description} " ),
SpinnerColumn (),
BarColumn (),
TaskProgressColumn (),
2025-02-28 13:22:38 +01:00
refresh_per_second = 20 ,
auto_refresh = True ,
expand = True ,
2025-07-01 11:42:07 +02:00
transient = False ,
2025-01-03 15:20:02 +00:00
)
2025-07-01 11:42:07 +02:00
2025-01-03 15:20:02 +00:00
fn_logger = LoggerWrapped ( progress . print )
2025-07-01 11:42:07 +02:00
2025-01-03 15:20:02 +00:00
dl = Download (
session = ctx . obj [ CTX_TIDAL ] . session ,
2025-07-03 08:57:46 +02:00
skip_existing = settings . data . skip_existing ,
2025-01-03 15:20:02 +00:00
path_base = settings . data . download_base_path ,
2025-01-04 13:42:53 +01:00
fn_logger = fn_logger ,
2025-01-03 15:20:02 +00:00
progress = progress ,
2025-03-01 21:53:46 +01:00
progress_overall = progress_overall ,
2025-03-03 09:31:37 +01:00
event_abort = handling_app . event_abort ,
event_run = handling_app . event_run ,
2025-01-03 15:20:02 +00:00
)
2025-07-01 11:42:07 +02:00
progress_table = Table . grid ()
2025-03-01 21:53:46 +01:00
progress_table . add_row ( progress )
progress_table . add_row ( progress_overall )
2025-07-01 11:42:07 +02:00
progress_group = Group ( progress_table )
2025-01-03 15:20:02 +00:00
urls_pos_last = len ( urls ) - 1
2025-03-01 21:53:46 +01:00
with Live ( progress_group , refresh_per_second = 20 , vertical_overflow = "visible" ):
try :
2025-07-01 11:42:07 +02:00
for idx , item in enumerate ( urls ):
2025-07-03 08:57:46 +02:00
if _process_url ( dl , ctx , handling_app , item , idx , urls_pos_last ) is False :
2025-03-03 09:31:37 +01:00
return False
2025-03-01 21:53:46 +01:00
finally :
progress . refresh ()
progress . stop ()
2025-01-03 15:20:02 +00:00
return True
2023-12-19 23:58:57 +01:00
@app.command ( name = "cfg" )
def settings_management (
2025-06-29 14:14:03 +02:00
names : Annotated [ list [ str ] | None , typer . Argument ()] = None ,
2023-12-19 23:58:57 +01:00
editor : Annotated [
bool , typer . Option ( "--editor" , "-e" , help = "Open the settings file in your default editor." )
] = False ,
2025-07-01 13:19:28 +02:00
) -> None :
2025-07-01 11:42:07 +02:00
"""Print or set an option, or open the settings file in an editor.
2023-12-19 23:58:57 +01:00
2025-07-01 11:42:07 +02:00
Args:
names (list[str] | None, optional): None (list all options), one (list the value only for this option) or two arguments (set the value for the option). Defaults to None.
editor (bool, optional): If set, your favorite system editor will be opened. Defaults to False.
2023-12-19 23:58:57 +01:00
"""
if editor :
config_path : Path = Path ( path_file_settings ())
if not config_path . is_file ():
config_path . write_text ( '{"version": "1.0.0"}' )
config_file_str = str ( config_path )
typer . launch ( config_file_str )
else :
settings = Settings ()
d_settings = settings . data . to_dict ()
if names :
if names [ 0 ] not in d_settings :
print ( f 'Option " { names [ 0 ] } " is not valid!' )
2025-07-01 13:19:28 +02:00
elif len ( names ) == 1 :
print ( f ' { names [ 0 ] } : " { d_settings [ names [ 0 ]] } "' )
elif len ( names ) > 1 :
settings . set_option ( names [ 0 ], names [ 1 ])
settings . save ()
2023-12-19 23:58:57 +01:00
else :
help_settings : dict = HelpSettings () . to_dict ()
table = Table ( title = f "Config: { path_file_settings () } " )
table . add_column ( "Key" , style = "cyan" , no_wrap = True )
table . add_column ( "Value" , style = "magenta" )
table . add_column ( "Description" , style = "green" )
for key , value in sorted ( d_settings . items ()):
table . add_row ( key , str ( value ), help_settings [ key ])
console = Console ()
console . print ( table )
@app.command ( name = "login" )
def login ( ctx : typer . Context ) -> bool :
2025-07-01 11:42:07 +02:00
"""Login to TIDAL and update context object.
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: True if login was successful, False otherwise.
"""
2023-12-19 23:58:57 +01:00
print ( "Let us check, if you are already logged in... " , end = "" )
settings = Settings ()
tidal = Tidal ( settings )
2024-02-01 07:38:01 +01:00
result = tidal . login ( fn_print = print )
2023-12-19 23:58:57 +01:00
ctx . obj [ CTX_TIDAL ] = tidal
return result
2024-05-03 20:38:29 +02:00
@app.command ( name = "logout" )
def logout () -> bool :
2025-07-01 11:42:07 +02:00
"""Logout from TIDAL.
Returns:
bool: True if logout was successful, False otherwise.
"""
2024-05-03 20:38:29 +02:00
settings = Settings ()
tidal = Tidal ( settings )
result = tidal . logout ()
if result :
print ( "You have been successfully logged out." )
return result
2023-12-19 23:58:57 +01:00
@app.command ( name = "dl" )
def download (
ctx : typer . Context ,
2025-06-29 14:14:03 +02:00
urls : Annotated [ list [ str ] | None , typer . Argument ()] = None ,
2024-02-01 20:04:55 +01:00
file_urls : Annotated [
2025-06-29 14:14:03 +02:00
Path | None ,
2023-12-19 23:58:57 +01:00
typer . Option (
"--list" ,
"-l" ,
exists = True ,
file_okay = True ,
dir_okay = False ,
writable = False ,
readable = True ,
resolve_path = True ,
help = "List with URLs to download. One per line" ,
),
] = None ,
2025-01-04 13:42:53 +01:00
) -> bool :
2025-07-01 11:42:07 +02:00
"""Download media from provided URLs or a file containing URLs.
Args:
ctx (typer.Context): Typer context object.
urls (list[str] | None, optional): List of URLs to download. Defaults to None.
file_urls (Path | None, optional): Path to file containing URLs. Defaults to None.
Returns:
bool: True if download was successful, False otherwise.
"""
2024-01-14 23:09:43 +01:00
if not urls :
# Read the text file provided.
2024-02-01 20:04:55 +01:00
if file_urls :
2025-01-04 13:53:49 +01:00
text : str = file_urls . read_text ()
2024-01-14 23:09:43 +01:00
urls = text . splitlines ()
2023-12-19 23:58:57 +01:00
else :
print ( "Provide either URLs, IDs or a file containing URLs (one per line)." )
raise typer . Abort ()
2025-01-03 15:20:02 +00:00
return _download ( ctx , urls )
2023-12-19 23:58:57 +01:00
2025-07-03 11:09:57 +02:00
@app_dl_fav.command (
2025-01-03 15:28:32 +00:00
name = "tracks" ,
2025-01-04 13:53:49 +01:00
help = "Download your favorite track collection." ,
2025-01-03 15:28:32 +00:00
)
2025-01-04 13:53:49 +01:00
def download_fav_tracks ( ctx : typer . Context ) -> bool :
"""Download your favorite track collection.
2025-07-01 11:42:07 +02:00
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: Download result.
2025-01-04 13:53:49 +01:00
"""
2025-01-04 13:42:53 +01:00
# Method name
func_name_favorites : str = "tracks"
2025-01-03 15:28:32 +00:00
2025-01-04 13:42:53 +01:00
return _download_fav_factory ( ctx , func_name_favorites )
2025-01-03 15:28:32 +00:00
2025-07-03 11:09:57 +02:00
@app_dl_fav.command (
2025-01-03 15:28:32 +00:00
name = "artists" ,
2025-01-04 13:53:49 +01:00
help = "Download your favorite artist collection." ,
2025-01-03 15:28:32 +00:00
)
2025-01-04 13:53:49 +01:00
def download_fav_artists ( ctx : typer . Context ) -> bool :
"""Download your favorite artist collection.
2025-07-01 11:42:07 +02:00
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: Download result.
2025-01-04 13:53:49 +01:00
"""
2025-01-04 13:42:53 +01:00
# Method name
func_name_favorites : str = "artists"
2025-01-03 15:28:32 +00:00
2025-01-04 13:42:53 +01:00
return _download_fav_factory ( ctx , func_name_favorites )
2025-01-03 15:28:32 +00:00
2025-07-03 11:09:57 +02:00
@app_dl_fav.command (
2025-01-03 15:28:32 +00:00
name = "albums" ,
2025-01-04 13:53:49 +01:00
help = "Download your favorite album collection." ,
2025-01-03 15:28:32 +00:00
)
2025-01-04 13:53:49 +01:00
def download_fav_albums ( ctx : typer . Context ) -> bool :
"""Download your favorite album collection.
2025-07-01 11:42:07 +02:00
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: Download result.
2025-01-04 13:53:49 +01:00
"""
2025-01-04 13:42:53 +01:00
# Method name
func_name_favorites : str = "albums"
2025-01-03 15:28:32 +00:00
2025-01-04 13:42:53 +01:00
return _download_fav_factory ( ctx , func_name_favorites )
2025-01-03 15:28:32 +00:00
2025-07-03 11:09:57 +02:00
@app_dl_fav.command (
2025-01-03 15:28:32 +00:00
name = "videos" ,
2025-01-04 13:53:49 +01:00
help = "Download your favorite video collection." ,
2025-01-03 15:28:32 +00:00
)
2025-01-04 13:53:49 +01:00
def download_fav_videos ( ctx : typer . Context ) -> bool :
"""Download your favorite video collection.
2025-07-01 11:42:07 +02:00
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: Download result.
2025-01-04 13:53:49 +01:00
"""
2025-01-04 13:42:53 +01:00
# Method name
func_name_favorites : str = "videos"
return _download_fav_factory ( ctx , func_name_favorites )
2025-01-04 13:53:49 +01:00
def _download_fav_factory ( ctx : typer . Context , func_name_favorites : str ) -> bool :
"""Factory which helps to download items from the favorites collections.
2025-07-01 11:42:07 +02:00
Args:
ctx (typer.Context): Typer context object.
func_name_favorites (str): Method name to call from `tidalapi` favorites object.
Returns:
bool: Download result.
2025-01-04 13:53:49 +01:00
"""
2025-01-03 15:28:32 +00:00
ctx . invoke ( login , ctx )
2025-01-04 13:53:49 +01:00
func_favorites : Callable = getattr ( ctx . obj [ CTX_TIDAL ] . session . user . favorites , func_name_favorites )
2025-07-01 13:19:28 +02:00
media_urls : list [ str ] = [ media . share_url for media in func_favorites ()]
2025-01-04 13:42:53 +01:00
return _download ( ctx , media_urls , try_login = False )
2025-01-03 15:28:32 +00:00
2023-12-19 23:58:57 +01:00
@app.command ()
def gui ( ctx : typer . Context ):
2025-07-01 11:42:07 +02:00
"""Launch the GUI for the application.
Args:
ctx (typer.Context): Typer context object.
"""
2023-12-19 23:58:57 +01:00
from tidal_dl_ng.gui import gui_activate
ctx . invoke ( login , ctx )
gui_activate ( ctx . obj [ CTX_TIDAL ])
2025-03-03 09:31:37 +01:00
def handle_sigint_term ( signum , frame ):
"""Set app abort event, so threads can check it and shutdown.
2025-07-01 11:42:07 +02:00
Args:
signum: Signal number.
frame: Current stack frame.
2025-03-03 09:31:37 +01:00
"""
handling_app : HandlingApp = HandlingApp ()
handling_app . event_abort . set ()
2023-12-19 23:58:57 +01:00
if __name__ == "__main__" :
2025-03-03 09:31:37 +01:00
# Catch CTRL+C
signal . signal ( signal . SIGINT , handle_sigint_term )
signal . signal ( signal . SIGTERM , handle_sigint_term )
2025-07-03 11:09:57 +02:00
# Check if the first argument is a URL. Hacky solution, since Typer does not support positional arguments without options / commands.
if len ( sys . argv ) > 1 :
first_arg = sys . argv [ 1 ]
parsed_url = urlparse ( first_arg )
if parsed_url . scheme in [ "http" , "https" ] and parsed_url . netloc :
# Rewrite sys.argv to simulate `dl <URL>`
sys . argv . insert ( 1 , "dl" )
2023-12-19 23:58:57 +01:00
app ()