├── .gitignore ├── README.md ├── backup ├── album.py ├── artist.py ├── common.py ├── cover.py ├── lyrics.py ├── main.py ├── search.py └── song.py ├── interface ├── __init__.py └── interface.py ├── main.py ├── media ├── __init__.py ├── album.py ├── artist.py ├── cover.py ├── downloads.py ├── playlist.py └── track.py ├── requirements.txt ├── test.py └── tools ├── __init__.py ├── order.py └── path.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # How to use the Tidal Music Downloader: 2 | 3 | ### Cloning the repository 4 | ``` 5 | git clone https://github.com/Nem-git/tidal.git 6 | ``` 7 | 8 | ### Installing the dependencies 9 | ``` 10 | cd tidal/ 11 | pip install -r requirements.txt 12 | ``` 13 | 14 | ## Arguments 15 | ``` 16 | search 17 | download 18 | artist 19 | album 20 | track 21 | cover 22 | ``` 23 | 24 | ## Examples 25 | 26 | ### Searching for an artist 27 | ``` 28 | python tidal/main.py search artist "Dua Lipa" 29 | ``` 30 | 31 | ### Searching for a track 32 | ``` 33 | python tidal/main.py search track "Not like us" 34 | ``` 35 | 36 | ### Downloading an artist's entire discography 37 | ``` 38 | python tidal/main.py download artist 9127 39 | ``` 40 | 41 | ### Downloading an entire album 42 | ``` 43 | python tidal/main.py download track 364531268 44 | ``` 45 | 46 | ### Downloading an album cover 47 | ``` 48 | python tidal/main.py download cover "899da3f4-54bb-4b2d-bed3-06da2c503075" 49 | ``` 50 | 51 | ## Dependencies 52 | 53 | **Hifi-Tui Docs** 54 | https://tidal.401658.xyz/tdoc 55 | https://github.com/sachinsenal0x64/HIFI-TUI?tab=readme-ov-file#-api-documentation 56 | 57 | **Hifi-Tui API** 58 | https://tidal.401658.xyz/ 59 | 60 | **Mutagen Docs** 61 | https://mutagen.readthedocs.io/en/latest/api/id3.html 62 | -------------------------------------------------------------------------------- /backup/album.py: -------------------------------------------------------------------------------- 1 | from common import Common 2 | 3 | 4 | class Album: 5 | 6 | def __init__(self) -> None: 7 | self.request_url = "https://tidal.401658.xyz/album/" 8 | self.id = None 9 | self.date = None 10 | self.response = None 11 | self.cover = None 12 | self.artist_cover = self.cover 13 | self.songs = [] 14 | self.path = None 15 | self.name = None 16 | self.artist_name = None 17 | 18 | def Infos(self) -> None: 19 | 20 | self.response = Common.Send_request(self.request_url, {"id": self.id}) 21 | 22 | if self.response[0]["artist"]["picture"] != None: 23 | self.cover = self.response[0]["cover"] 24 | 25 | self.date = self.response[0]["releaseDate"].split("-")[0] 26 | 27 | self.name = Common.Verify_string(self.response[0]["title"]) 28 | 29 | for i in self.response[1]["items"]: 30 | self.songs.append(i["item"]["id"]) 31 | 32 | if self.artist_name == None: 33 | self.artist_name = Common.Verify_string(self.response[0]["artist"]["name"]) 34 | 35 | self.path = f"../{self.artist_name}/{self.name}/" 36 | Common.Verify_path(self.path) 37 | -------------------------------------------------------------------------------- /backup/artist.py: -------------------------------------------------------------------------------- 1 | from common import Common 2 | from cover import Cover 3 | 4 | 5 | class Artist: 6 | 7 | def __init__(self) -> None: 8 | 9 | self.request_url = "https://tidal.401658.xyz/artist/" 10 | self.id = None 11 | self.name = None 12 | self.response = None 13 | self.liste = None 14 | self.albums = [] 15 | self.search_albums = [] 16 | self.query = None 17 | self.cover = None 18 | self.path = None 19 | 20 | def Infos(self) -> None: 21 | 22 | self.response = Common.Send_request(self.request_url, {"f": self.id}) 23 | 24 | self.liste = self.response[0]["rows"][0]["modules"][0]["pagedList"]["items"] 25 | 26 | for i in self.liste: 27 | self.albums.append(i["id"]) 28 | 29 | self.response = Common.Send_request( 30 | f"https://tidal.401658.xyz/artist/", {"id": self.id} 31 | ) 32 | 33 | try: 34 | self.name = Common.Verify_string(self.response[0]["name"]) 35 | except: 36 | self.name = Common.Verify_string(self.response[0]["title"]) 37 | 38 | self.path = f"../{self.name}/" 39 | Common.Verify_path(self.path) 40 | 41 | # Cover().id = self.response[1][0]["750"] 42 | # Cover().path = self.path + "artist.jpg" 43 | Cover().Download(self.response[0]["picture"], self.path, "artist.jpg") 44 | 45 | # try: 46 | # 47 | # print(self.cover) 48 | # with open(f"{self.path}artist.jpg", "wb") as c: 49 | # self.response = requests.get(self.cover) 50 | # while self.response.status_code != 200: 51 | # self.response = requests.get(self.cover) 52 | # c.write(self.response.content) 53 | # except: 54 | # print("No artist cover") 55 | -------------------------------------------------------------------------------- /backup/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | 4 | 5 | class Common: 6 | 7 | def Send_request(request, paramaters) -> dict: 8 | response = requests.get(request, params=paramaters) 9 | while response.status_code != 200: 10 | if response.status_code == 404: 11 | break 12 | response = requests.get(request, params=paramaters) 13 | return response.json() 14 | 15 | def Verify_path(path) -> None: 16 | if not os.path.exists(path): 17 | os.makedirs(path) 18 | 19 | def Verify_string(text) -> str: 20 | if type(text) == str: 21 | for c in '*?<>"|': 22 | text = text.replace(c, "") 23 | for c in "'\\/:": 24 | text = text.replace(c, "-") 25 | return text 26 | 27 | def Clear(self) -> None: 28 | if os.name == "nt": 29 | os.system("cls") 30 | else: 31 | os.system("clear") 32 | -------------------------------------------------------------------------------- /backup/cover.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from common import Common 3 | import os 4 | 5 | 6 | class Cover: 7 | request_url = "https://tidal.401658.xyz/cover/" 8 | url = None 9 | response = None 10 | 11 | def Download(self, id, path, name) -> None: 12 | 13 | try: 14 | if not os.path.exists(path + name): 15 | Common.Verify_path(path) 16 | self.url = ( 17 | "https://resources.tidal.com/images/" 18 | + id.replace("-", "/") 19 | + "/750x750.jpg" 20 | ) 21 | print(self.url) 22 | with open(f"{path + name}", "wb") as f: 23 | self.response = requests.get(self.url) 24 | while self.response.status_code != 200: 25 | if self.response.status_code == 404: 26 | break 27 | self.response = requests.get(self.url) 28 | f.write(self.response.content) 29 | except: 30 | print(f"No image to put in {path + name}") 31 | -------------------------------------------------------------------------------- /backup/lyrics.py: -------------------------------------------------------------------------------- 1 | from common import Common 2 | -------------------------------------------------------------------------------- /backup/main.py: -------------------------------------------------------------------------------- 1 | from song import Song 2 | from cover import Cover 3 | from album import Album 4 | from artist import Artist 5 | from search import Search 6 | from common import Common 7 | import time 8 | 9 | 10 | def Artist_download(ids): 11 | for id in ids: 12 | artist = Artist() 13 | cover = Cover() 14 | artist.id = id 15 | artist.Infos() 16 | cover.Download(artist.response[0]["picture"], artist.path, "artist.jpg") 17 | print(f"Downloading all songs from {artist.name}") 18 | Album_download(artist.albums) 19 | 20 | 21 | def Album_download(ids): 22 | album = Album() 23 | song = Song() 24 | cover = Cover() 25 | for id in ids: 26 | album.id = id 27 | album.Infos() 28 | print(f"{album.name.upper()} [{album.id}]") 29 | Track_download(album.songs) 30 | cover = Cover() 31 | album = Album() 32 | 33 | 34 | def Track_download(ids): 35 | ssss = time.time() 36 | for id in ids: 37 | song = Song() 38 | cover = Cover() 39 | song.quality = "HI_RES_LOSSLESS" 40 | song.id = id 41 | song.Infos() 42 | print(song.name.lower()) 43 | cover.Download(song.album_cover, song.path, "cover.jpg") 44 | song.Download() 45 | cover.Download(song.artist_cover, f"../{song.artist_name}/", "artist.jpg") 46 | song.Tag() 47 | print(time.time() - ssss) 48 | 49 | 50 | def Cover_download(id, path, name): 51 | cover = Cover() 52 | Common.Verify_path(path) 53 | cover.id = id 54 | cover.Download() 55 | 56 | 57 | if __name__ == "__main__": 58 | common = Common() 59 | search = Search() 60 | common.Clear() 61 | 62 | search_type = None 63 | query = None 64 | while type(search_type) != int: 65 | try: 66 | search_type = int( 67 | input( 68 | "What kind of search?\n\n1. Search artist\n2. Search album\n3. Search track\n4. Search cover\n" 69 | ) 70 | ) 71 | except: 72 | print("Please enter a valid number\n") 73 | 74 | common.Clear 75 | query_id = int(input("1. Search query\n2. Search IDs\n")) 76 | ids = [] 77 | 78 | if query_id == 1: 79 | query = input("Query:\n") 80 | search.query = query 81 | 82 | if query_id == 2: 83 | for number in input("IDs ([space] between every ID (ex: 1 4 5)):\n").split(" "): 84 | ids.append(int(number)) 85 | 86 | common.Clear() 87 | 88 | if search_type == 1: 89 | if query_id == 1: 90 | search.liste = search.Artist() 91 | for number in search.Choice(): 92 | ids.append(search.liste[number - 1]) 93 | 94 | search = Search() 95 | Artist_download(ids) 96 | 97 | if search_type == 2: 98 | if query_id == 1: 99 | search.liste = search.Album() 100 | for number in search.Choice(): 101 | ids.append(search.liste[number - 1]) 102 | 103 | search = Search() 104 | Album_download(ids) 105 | 106 | if search_type == 3: 107 | if query_id == 1: 108 | search.liste = search.Track() 109 | for number in search.Choice(): 110 | ids.append(search.liste[number - 1]) 111 | 112 | search = Search() 113 | Track_download(ids) 114 | 115 | if search_type == 4: 116 | if query_id == 1: 117 | search.liste = search.Cover() 118 | for number in search.Choice(): 119 | ids.append(search.liste[number - 1]) 120 | 121 | search = Search() 122 | Cover_download(ids) 123 | -------------------------------------------------------------------------------- /backup/search.py: -------------------------------------------------------------------------------- 1 | from common import Common 2 | 3 | 4 | class Search: 5 | 6 | def __init__(self) -> None: 7 | self.request_url = "https://tidal.401658.xyz/search/" 8 | self.response = None 9 | self.ids = [] 10 | self.query = None 11 | self.liste = None 12 | 13 | def Artist(self) -> list: 14 | self.response = Common.Send_request(self.request_url, {"a": self.query}) 15 | 16 | for artist in self.response[0]["artists"]["items"]: 17 | print(f'{len(self.ids) + 1}. {artist["name"]} [{artist["id"]}]') 18 | self.ids.append(artist["id"]) 19 | 20 | return self.ids 21 | 22 | def Album(self) -> list: 23 | self.response = Common.Send_request(self.request_url, {"al": self.query}) 24 | 25 | for album in self.response["albums"]["items"]: 26 | print( 27 | f'{len(self.ids) + 1}. {album["title"]} - {album["artists"][0]["name"]} [{album["id"]}]' 28 | ) 29 | self.ids.append(album["id"]) 30 | 31 | return self.ids 32 | 33 | def Track(self) -> list: 34 | self.response = Common.Send_request(self.request_url, {"s": self.query}) 35 | 36 | for track in self.response["items"]: 37 | print( 38 | f'{len(self.ids) + 1}. {track["title"]} - {track["artist"]["name"]} [{track["id"]}]' 39 | ) 40 | self.ids.append(track["id"]) 41 | 42 | return self.ids 43 | 44 | def Choice(self) -> list: 45 | self.ids = [] 46 | while len(self.ids) == 0: 47 | try: 48 | for number in input( 49 | "\nTo choose, put a [space] between every number(ex: 1 2 5 6):\n" 50 | ).split(" "): 51 | self.ids.append(int(number)) 52 | 53 | except: 54 | self.ids = [] 55 | 56 | return self.ids 57 | -------------------------------------------------------------------------------- /backup/song.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from mutagen import flac, id3 3 | from common import Common 4 | 5 | 6 | class Song(Common): 7 | 8 | def __init__(self) -> None: 9 | self.download_url = "https://tidal.401658.xyz/track/" 10 | self.search_url = "https://tidal.401658.xyz/search/" 11 | self.artist_name = None 12 | self.artist_cover = None 13 | self.album_name = None 14 | self.album_cover = None 15 | self.name = None 16 | self.number = None 17 | self.date = None 18 | self.url = None 19 | self.path = None 20 | self.id = None 21 | self.quality = None 22 | self.response = None 23 | self.query = None 24 | 25 | def Infos(self) -> None: 26 | 27 | self.response = Common.Send_request( 28 | self.download_url, {"id": self.id, "quality": self.quality} 29 | ) 30 | 31 | self.name = Common.Verify_string(self.response[0]["title"]) 32 | self.artist_cover = self.response[0]["artist"]["picture"] 33 | self.album_cover = self.response[0]["album"]["cover"] 34 | self.number = f"{self.response[0]['trackNumber']:02}" 35 | self.url = self.response[2]["OriginalTrackUrl"] 36 | 37 | if self.artist_name == None: 38 | self.artist_name = Common.Verify_string(self.response[0]["artist"]["name"]) 39 | if self.album_name == None: 40 | self.album_name = Common.Verify_string(self.response[0]["album"]["title"]) 41 | self.path = f"../{self.artist_name}/{self.album_name}/" 42 | 43 | def Download(self) -> None: 44 | 45 | Common.Verify_path(self.path) 46 | 47 | with open( 48 | f'{self.path}{self.number} {self.name}.{self.url.split(".")[4].split("?")[0]}', 49 | "wb", 50 | ) as track: 51 | self.response = requests.get(self.url) 52 | while self.response.status_code != 200: 53 | self.response = requests.get(self.url) 54 | track.write(self.response.content) 55 | 56 | def Tag(self) -> None: 57 | # try: 58 | Common.Verify_path(self.path) 59 | track = flac.FLAC(f"{self.path}{self.number} {self.name}.flac") 60 | track.add_tags() 61 | track.tags["ALBUM"] = self.album_name 62 | track.tags["ARTIST"] = self.artist_name 63 | track.tags["COMMENT"] = f"QUALITY: {self.quality}" 64 | track.tags["TITLE"] = self.name 65 | track.tags["TRACKNUMBER"] = self.number 66 | 67 | # try: 68 | self.album_cover = flac.Picture() 69 | with open(f"{self.path}cover.jpg", "rb") as _: 70 | self.album_cover.data = _.read() 71 | 72 | self.album_cover.type = id3.PictureType.COVER_FRONT 73 | 74 | if self.artist_cover is not None: 75 | self.artist_cover = flac.Picture() 76 | with open(f"../{self.artist_name}/artist.jpg", "rb") as _: 77 | self.artist_cover.data = _.read() 78 | 79 | self.artist_cover.type = id3.PictureType.ARTIST 80 | self.artist_cover.mime = "image/jpeg" 81 | track.add_picture(self.artist_cover) 82 | self.album_cover.mime = "image/jpeg" 83 | self.album_cover.width = 1280 84 | self.album_cover.height = 1280 85 | track.add_picture(self.album_cover) 86 | # except: 87 | # pass 88 | track.save() 89 | 90 | # except: 91 | # print("NOT A FLAC FILE") 92 | -------------------------------------------------------------------------------- /interface/__init__.py: -------------------------------------------------------------------------------- 1 | from interface import Interface 2 | -------------------------------------------------------------------------------- /interface/interface.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from tools import Order 4 | 5 | 6 | class Interface: 7 | 8 | def __init__(self) -> None: 9 | print( 10 | """ 11 | Welcome to my TIDAL downloader!! 12 | This program is only in alpha, but I 13 | hope to improve on it as time comes. 14 | ________________________________________________________ 15 | To select the different choices in the 16 | menu below, you need to enter a number, 17 | then press the ENTER/RETURN key. 18 | ________________________________________________________ 19 | Have fun! 20 | 21 | Credits: A big thanks to 0x64 for making 22 | this incredible reverse proxy and sacrificing 23 | his account for the good of the community :) 24 | https://github.com/sponsors/sachinsenal0x64 25 | ________________________________________________________ 26 | 27 | Here are the download options for now: 28 | 29 | 1. Search and Download a certain track 30 | 2. Search and Download an entire album 31 | 3. Search and Download an artist's whole discography 32 | 4. Search and Download the cover of an album 33 | 5. Search and Download a TIDAL playlist (WIP) 34 | 6. Download a song's lyrics (WIP) 35 | ________________________________________________________ 36 | """ 37 | ) 38 | 39 | c = int(input("Choice: ")) 40 | 41 | match c: 42 | 43 | case 1: 44 | self.Artist() 45 | 46 | def Artist(self) -> None: 47 | pass 48 | 49 | 50 | main = Main() 51 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import tools.order 2 | 3 | # Change this before downloading 4 | download_path = "/Users/Fake/Music/" 5 | 6 | # Qualities: Look at https://github.com/sachinsenal0x64/HIFI-TUI?tab=readme-ov-file#-faq 7 | download_quality = "HI_RES_LOSSLESS" 8 | 9 | tools.order.choices() 10 | -------------------------------------------------------------------------------- /media/__init__.py: -------------------------------------------------------------------------------- 1 | from .downloads import Download 2 | from .album import Album 3 | from .artist import Artist 4 | from .cover import Cover 5 | from .track import Track 6 | -------------------------------------------------------------------------------- /media/album.py: -------------------------------------------------------------------------------- 1 | from . import Download 2 | 3 | 4 | class Album: 5 | 6 | async def download_json(self, item_id: str) -> dict[str, str]: 7 | return await Download().Json(rq_type="album", param={"id": item_id}) 8 | 9 | async def metadata(self, resp: dict[str, str]) -> list[str] | None: 10 | streamable: str | bool = resp.get("allowStreaming", False) 11 | if not streamable: 12 | return 13 | 14 | title: str = resp.get("title", "Unknown Album") 15 | track_number: str | None = resp.get("numberOfTracks") 16 | date: str = resp.get("releaseDate") 17 | year: str = date[:4] 18 | copyrights: str = resp.get("copyright", "Not copyrighted") 19 | explicit: str | bool = resp.get("explicit", False) 20 | artists: str = " & ".join(artist["name"] for artist in resp.get("artists", [])) 21 | volume_number: str = resp.get("numberOfVolumes") 22 | 23 | qualities: dict[str, int] = { 24 | "LOW": 0, 25 | "HIGH": 1, 26 | "LOSSLESS": 2, 27 | "HI_RES": 3, 28 | } 29 | 30 | quality: int = qualities[resp.get("audioQuality")] 31 | 32 | tracks: str | None = resp.get("items") 33 | 34 | if resp["cover"]: 35 | cover_id = resp["cover"] 36 | 37 | return ( 38 | title, 39 | track_number, 40 | date, 41 | year, 42 | copyrights, 43 | cover_id, 44 | explicit, 45 | artists, 46 | volume_number, 47 | tracks, 48 | ) # , quality 49 | 50 | async def search(self, query): 51 | resp = await Download().Search(rq_type="search", param={"al" : query}) 52 | 53 | albums = [] 54 | 55 | for album in resp["albums"]["items"]: 56 | albums.append([ 57 | album["id"], 58 | album["title"], 59 | album["artists"][0]["name"], 60 | album["releaseDate"][:4] 61 | ]) 62 | 63 | 64 | print(albums) 65 | return albums -------------------------------------------------------------------------------- /media/artist.py: -------------------------------------------------------------------------------- 1 | from . import Download 2 | 3 | 4 | class Artist: 5 | 6 | async def download_json(self, item_id: str) -> dict[str, str]: 7 | return await Download().Json(rq_type="artist", param={"f": item_id}) 8 | 9 | async def metadata(self, resp: dict[str, str]) -> tuple[str]: 10 | 11 | name: str = resp[0]["artists"][0]["name"] 12 | album_number: int = len(resp) 13 | cover_id: str = resp[0]["artists"][0]["picture"] 14 | 15 | album_ids = [] 16 | for album in resp: 17 | if album["album"]["id"] not in album_ids: 18 | album_ids.append(int(album["album"]["id"])) 19 | 20 | return ( 21 | name, 22 | album_number, 23 | cover_id, 24 | album_ids, 25 | ) 26 | 27 | async def search(self, query) -> list: 28 | resp = await Download().Search(rq_type="search", param={"a": query}) 29 | 30 | artists = [] 31 | 32 | for artist in resp[0]["artists"]["items"]: 33 | artists.append([artist["id"], artist["name"]]) 34 | 35 | print(artists) 36 | return artists 37 | -------------------------------------------------------------------------------- /media/cover.py: -------------------------------------------------------------------------------- 1 | from . import Download 2 | 3 | class Cover: 4 | 5 | async def download_media(self, path: str, url: str) -> None: 6 | await Download().Media(path=path, url=url, param={}) 7 | 8 | async def search(self, query) -> list: 9 | resp = await Download().Search(rq_type="cover", param={"q" : query}) 10 | 11 | covers = [] 12 | for cover in resp: 13 | covers.append([cover["id"], cover["name"], cover["1280"]]) 14 | 15 | print(covers) 16 | return covers -------------------------------------------------------------------------------- /media/downloads.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import aiofiles 3 | 4 | 5 | class Download: 6 | 7 | async def Media(self, path: str, url: str, param: dict[str, str]) -> None: 8 | """ 9 | This Python async function downloads media content from a specified URL and saves it to a file at a 10 | given path. 11 | 12 | :param path: The `path` parameter in the `Media` function is a string that represents the file path 13 | where the downloaded media will be saved 14 | :type path: str 15 | :param url: The `url` parameter in the `Media` function is a string that represents the URL from 16 | which you want to download media content 17 | :type url: str 18 | :param param: The `param` parameter in the `Media` function is a dictionary that contains key-value 19 | pairs of parameters that will be sent in the HTTP request. These parameters are typically used for 20 | things like filtering, sorting, or specifying additional information for the request. In this case, 21 | the `param` dictionary is 22 | :type param: dict[str, str] 23 | """ 24 | async with aiohttp.ClientSession() as session: 25 | try: 26 | async with session.get(url=url, params=param) as resp: 27 | if resp.ok: 28 | async with aiofiles.open(file=f"{path}", mode="wb") as track: 29 | async for chunk in resp.content.iter_chunked(n=4096000): 30 | await track.write(chunk) 31 | else: 32 | print(f"Server gave back error {resp.status}") 33 | except: 34 | print("Server probably gave back error 403") 35 | 36 | async def Json( 37 | self, rq_type: str, param: dict[str, str] 38 | ) -> dict[str, str] | dict[str, int]: 39 | """ 40 | This Python async function sends a request to a Tidal API endpoint and processes the response based 41 | on the provided parameters. 42 | 43 | :param rq_type: The `rq_type` parameter in the provided code represents the type of request being 44 | made to the Tidal API. It is a string that specifies the category of data being requested, such as 45 | "artists", "albums", "videos", "items", or "playlists" 46 | :type rq_type: str 47 | :param param: The `param` parameter in the `Json` method is a dictionary that contains key-value 48 | pairs of parameters to be included in the request. These parameters are used to customize the 49 | request to the API endpoint specified by `rq_type`. The keys in the `param` dictionary correspond to 50 | different types of data 51 | :type param: dict[str, str] 52 | :return: The `Json` method returns a dictionary with string keys and string values. The specific 53 | content of the dictionary returned depends on the logic within the method and the input parameters 54 | provided. 55 | """ 56 | 57 | types: dict[str, str] = { 58 | "a": "artists", 59 | "al": "albums", 60 | "v": "videos", 61 | "s": "items", 62 | "p": "playlists", 63 | } 64 | 65 | url: str = "/".join(("https://tidal.401658.xyz", rq_type)) 66 | async with aiohttp.ClientSession() as session: 67 | async with session.get(url=url, params=param) as resp: 68 | if resp.status == 200: 69 | 70 | for k in param.keys(): 71 | if k in types.keys(): 72 | s_type: dict[str, str] = await resp.json() 73 | 74 | if k == "a": 75 | s_type = await resp.json() 76 | s_type = s_type[0] 77 | 78 | s_type = s_type[types.get(k)] 79 | return s_type 80 | 81 | iter_dict: dict[str, str] = {} 82 | 83 | if rq_type == "artist": 84 | iter_dict = await resp.json() 85 | iter_dict = iter_dict[1] 86 | 87 | else: 88 | for part in await resp.json(): 89 | if type(part) is list: 90 | for j in part: 91 | iter_dict = iter_dict | j 92 | 93 | else: 94 | iter_dict = iter_dict | part 95 | 96 | return iter_dict 97 | 98 | 99 | async def Search(self, rq_type, param) -> list: 100 | url: str = "/".join(("https://tidal.401658.xyz", rq_type)) 101 | async with aiohttp.ClientSession() as session: 102 | async with session.get(url=url, params=param) as resp: 103 | if resp.status == 200: 104 | return await resp.json() -------------------------------------------------------------------------------- /media/playlist.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nem-git/tidal-dl/47b8bf477bd690a5a92f60ffad49f14299d44773/media/playlist.py -------------------------------------------------------------------------------- /media/track.py: -------------------------------------------------------------------------------- 1 | import aiofiles 2 | from mutagen import flac, mp4 3 | from mutagen.id3 import PictureType 4 | from . import Download 5 | 6 | 7 | class Track: 8 | 9 | async def download_media(self, path: str, url: str) -> None: 10 | await Download().Media(path=path, url=url, param={}) 11 | 12 | async def download_json(self, item_id: str, quality: str) -> dict[str, str]: 13 | return await Download().Json( 14 | rq_type="track", param={"id": item_id, "quality": quality} 15 | ) 16 | 17 | async def metadata( 18 | self, track: dict[str, str], album: dict[str, str] 19 | ) -> tuple[str]: 20 | streamable: str | bool = track.get("allowStreaming", False) 21 | if not streamable: 22 | return 23 | 24 | title: str | None = track.get("title") 25 | track_number: str = f'{track.get("trackNumber", "1"):02}' 26 | volume_number: str = track.get("volumeNumber", "1") 27 | copyrights: str = track.get("copyright", "Not copyrighted") 28 | isrc: str = track.get("isrc", "") 29 | 30 | # If you want all your files in the artist folder and only tag with multiple artists, not different folders 31 | artists: str = track["artist"]["name"] 32 | # If you want to separate the artists in the folders too: 33 | # artists: str = " & ".join(artist["name"] for artist in track.get("artists")) 34 | 35 | version: str = track.get("version", "1") 36 | if track.get("version") is not None: 37 | title = f"{title} ({version})" 38 | 39 | qualities: dict[str, int] = { 40 | "LOW": 0, 41 | "HIGH": 1, 42 | "LOSSLESS": 2, 43 | "HI_RES": 3, 44 | } 45 | 46 | quality: int = qualities[track.get("audioQuality")] 47 | if quality >= 2: 48 | file_extension = ".flac" 49 | else: 50 | file_extension = ".m4a" 51 | 52 | url: str = track.get("OriginalTrackUrl") 53 | 54 | if album == {}: 55 | album = track.get("album") 56 | 57 | return ( 58 | title, 59 | track_number, 60 | volume_number, 61 | copyrights, 62 | isrc, 63 | artists, 64 | version, 65 | quality, 66 | file_extension, 67 | url, 68 | album, 69 | ) 70 | 71 | async def tag( 72 | self, 73 | title: str, 74 | track_number: str, 75 | total_track_number: str, 76 | volume_number: str, 77 | copyrights: str, 78 | isrc: str, 79 | artists: str, 80 | file_extension: str, 81 | album: dict[str, str], 82 | track_path: str, 83 | album_cover_path: str, 84 | artist_cover_path: str, 85 | ) -> None: 86 | 87 | if file_extension == ".m4a": 88 | track = mp4.MP4(track_path) 89 | # track.add_tags() 90 | track.tags["\\xa9nam"] = title 91 | track.tags["\\xa9ART"] = artists 92 | track.tags["\\xa9alb"] = album.get("title") 93 | track.tags["cprt"] = copyrights 94 | # track.tags["----:com.apple.iTunes:ISRC"] = isrc.encode("utf-8") 95 | # #track.tags["trkn"] = str(object=track_number, ) #Need to add total_track_number 96 | # track.tags["disk"] = str(object=volume_number) 97 | 98 | if album_cover_path != "": 99 | async with aiofiles.open(file=album_cover_path, mode="rb") as c: 100 | track["covr"] = mp4.MP4Cover( 101 | data=await c.read(), imageformat="FORMAT_JPEG" 102 | ) 103 | 104 | if file_extension == ".flac": 105 | track = flac.FLAC(track_path) 106 | track.add_tags() 107 | track.tags["TITLE"] = title 108 | track.tags["ARTIST"] = artists 109 | track.tags["ALBUM"] = album.get("title") 110 | track.tags["COPYRIGHT"] = copyrights 111 | # track.tags["ISRC"] = isrc.encode("utf-8") 112 | track.tags["TRACKNUMBER"] = str(object=track_number) 113 | track.tags["DISCNUMBER"] = str(object=volume_number) 114 | 115 | # if artist_cover_path != "": 116 | # async with aiofiles.open(file=artist_cover_path, mode="rb") as c: 117 | # cover = flac.Picture() 118 | # cover.data = await c.read() 119 | # cover.type = PictureType.ARTIST 120 | # cover.mime = "image/jpeg" 121 | # cover.width = 1280 122 | # cover.height = 1280 123 | # track.add_picture(picture=cover) 124 | 125 | if album_cover_path != "": 126 | async with aiofiles.open(file=album_cover_path, mode="rb") as c: 127 | cover = flac.Picture() 128 | cover.data = await c.read() 129 | cover.type = PictureType.COVER_FRONT 130 | cover.mime = "image/jpeg" 131 | cover.width = 1280 132 | cover.height = 1280 133 | track.add_picture(picture=cover) 134 | 135 | track.save() 136 | 137 | async def search(self, query): # -> list[Any]: 138 | resp = await Download().Search(rq_type="search", param={"s": query}) 139 | 140 | tracks = [] 141 | 142 | for track in resp["items"]: 143 | tracks.append( 144 | [ 145 | track["id"], 146 | track["title"], 147 | track["album"]["title"], 148 | track["artist"]["name"], 149 | ] 150 | ) 151 | 152 | print(tracks) 153 | return tracks 154 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiodns==3.2.0 2 | aiofiles==24.1.0 3 | aiohttp==3.9.5 4 | aiosignal==1.3.1 5 | attrs==23.2.0 6 | cffi==1.16.0 7 | frozenlist==1.4.1 8 | idna==3.7 9 | multidict==6.0.5 10 | mutagen==1.47.0 11 | pycares==4.4.0 12 | pycparser==2.22 13 | tqdm==4.66.4 14 | yarl==1.9.4 15 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import Toplevel, ttk 3 | import asyncio 4 | from media.artist import Artist 5 | 6 | 7 | class Interface: 8 | 9 | def Main(self) -> None: 10 | master = tk.Tk() 11 | window = ttk.Frame(master=master).grid(row=0, column=0) 12 | master.title(string="Tidal Music Downloader") 13 | 14 | tk.Label( 15 | master=window, 16 | text="Tidal Music Downloader\nPlease select the type of content you'd like to download", 17 | ).grid(row=1, column=0) 18 | 19 | tk.Button( 20 | master=window, 21 | text="Artist", 22 | command=lambda: self.Artist(master=master), 23 | ).grid(row=2, column=0) 24 | 25 | tk.Button( 26 | master=window, 27 | text="Album", 28 | command=lambda: self.Album(master=master), 29 | ).grid(row=3, column=0) 30 | 31 | tk.Button( 32 | master=window, 33 | text="Track", 34 | command=lambda: self.Track(master=master), 35 | ).grid(row=4, column=0) 36 | 37 | master.mainloop() 38 | 39 | def Artist(self, master: tk.Tk) -> None: 40 | 41 | window = Toplevel(master=master) 42 | window.title(string="Artist") 43 | 44 | tk.Label( 45 | master=window, 46 | text="Tidal Music Downloader\nPlease enter the name of the artist whose entire discography you want to download", 47 | ).grid(row=1, column=0) 48 | 49 | artist_var = tk.StringVar() 50 | 51 | tk.Entry(master=window, textvariable=artist_var).grid(row=2, column=0) 52 | 53 | tk.Button( 54 | master=window, 55 | text="Search", 56 | command=lambda: ArtistUI().Call_Function( 57 | command=Artist().search(query=artist_var.get()), 58 | window=window, 59 | ), 60 | ).grid(row=3, column=0) 61 | 62 | 63 | interface = Interface() 64 | 65 | asyncio.run(main=interface.Main()) 66 | -------------------------------------------------------------------------------- /tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .path import Path 2 | from .order import Order 3 | -------------------------------------------------------------------------------- /tools/order.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import tqdm 4 | import time 5 | from . import Path 6 | from media import Artist, Album, Track, Cover 7 | 8 | 9 | class Order: 10 | async def Artist( 11 | self, item_id : str, quality : str, path:str 12 | ) -> None: 13 | 14 | artist = Artist() 15 | 16 | resp: dict[str, str] = await artist.download_json(item_id=item_id) 17 | 18 | ( 19 | name, 20 | album_number, 21 | cover_id, 22 | album_ids, 23 | ) = await artist.metadata(resp=resp) 24 | 25 | artist_cover_path = "" #Just for now 26 | 27 | if cover_id: 28 | artist_cover_path: str = f"{path}{name}/artist.jpg" 29 | await self.Cover(id=cover_id, resolution=1280, path=artist_cover_path) 30 | 31 | 32 | # Async 33 | # async with asyncio.TaskGroup() as tg: 34 | # for album_id in album_ids: 35 | # tg.create_task( 36 | # coro=Order().Album( 37 | # item_id=album_id, 38 | # quality=quality, 39 | # path=path, 40 | # artist_cover_path=artist_cover_path 41 | # ) 42 | # ) 43 | 44 | # Not async 45 | for album_id in album_ids: 46 | await Order().Album(item_id=album_id,quality=quality,path=path, artist_cover_path=artist_cover_path) 47 | 48 | 49 | async def Album(self, item_id: str, quality: str, path: str, artist_cover_path: str) -> None: 50 | 51 | album = Album() 52 | 53 | resp: dict[str, str] = await album.download_json(item_id=item_id) 54 | 55 | try: 56 | if resp["status"] == 404: 57 | print(resp["userMessage"]) 58 | return 59 | except: 60 | pass 61 | 62 | ( 63 | title, 64 | track_number, 65 | date, 66 | year, 67 | copyrights, 68 | cover_id, 69 | explicit, 70 | artists, 71 | volume_number, 72 | tracks, 73 | ) = await album.metadata(resp=resp) 74 | 75 | album_cover_path: str = "" #Just for now 76 | 77 | if cover_id: 78 | album_cover_path: str = f"{path}/cover.jpg" 79 | await self.Cover(id=cover_id, resolution=1280, path=album_cover_path) 80 | 81 | track_ids: list[str] = [] 82 | for track in tracks: 83 | track_id: str = track["item"]["id"] 84 | track_ids.append(track_id) 85 | 86 | # Async 87 | # async with asyncio.TaskGroup() as tg: 88 | # for track_id in tqdm.tqdm(iterable=track_ids, 89 | # desc=f"{title} ({year})", 90 | # unit=" track", 91 | # ascii=False): 92 | # tg.create_task( 93 | # coro=Order().Track( 94 | # item_id=track_id, 95 | # quality=quality, 96 | # path=path, 97 | # total_track_number=track_number, 98 | # album_cover_path=album_cover_path, 99 | # artist_cover_path=artist_cover_path, 100 | # ) 101 | # ) 102 | 103 | # Not async 104 | for track_id in tqdm.tqdm(iterable=track_ids, 105 | desc=f"{title} ({year})", 106 | unit=" track", 107 | ascii=False): 108 | await Order().Track( 109 | item_id=track_id, 110 | quality=quality, 111 | path=path, 112 | total_track_number=track_number, 113 | album_cover_path=album_cover_path, 114 | artist_cover_path=artist_cover_path, 115 | ) 116 | 117 | 118 | async def Track( 119 | self, 120 | item_id: str, 121 | quality: str, 122 | path: str, 123 | total_track_number: str, 124 | album_cover_path: str, 125 | artist_cover_path: str, 126 | ) -> None: 127 | 128 | track = Track() 129 | 130 | resp: dict[str, str] = await track.download_json( 131 | item_id=item_id, quality=quality 132 | ) 133 | 134 | ( 135 | title, 136 | track_number, 137 | volume_number, 138 | copyrights, 139 | isrc, 140 | artists, 141 | version, 142 | quality, 143 | file_extension, 144 | url, 145 | album, 146 | ) = await track.metadata(track=resp, album={}) 147 | 148 | path = "/".join( 149 | (path, Path().Clean(string=artists), Path().Clean(string=album["title"])) 150 | ) 151 | 152 | Path().Create(path=path) 153 | 154 | track_path: str = ( 155 | f"{path}/{track_number} {Path().Clean(string=title)}{file_extension}" 156 | ) 157 | 158 | await track.download_media(path=track_path, url=url) 159 | 160 | 161 | await track.tag( 162 | title=title, 163 | track_number=track_number, 164 | total_track_number=total_track_number, 165 | volume_number=volume_number, 166 | copyrights=copyrights, 167 | isrc=isrc, 168 | artists=artists, 169 | file_extension=file_extension, 170 | album=album, 171 | track_path=track_path, 172 | album_cover_path=album_cover_path, 173 | artist_cover_path=artist_cover_path, 174 | ) 175 | 176 | async def Cover(self, id: str, resolution: int, path: str) -> None: 177 | 178 | cover = Cover() 179 | 180 | url: str = f"https://resources.tidal.com/images/{id.replace('-', '/')}/{resolution}x{resolution}.jpg" 181 | 182 | await cover.download_media(path=path, url=url) 183 | 184 | 185 | 186 | # Les Cowboys Fringants 4907832 187 | # NWA 9127 188 | # Michael Jackson 606 189 | # Linkin Park 14123 190 | 191 | 192 | def choices(download_path, download_quality) -> None: 193 | timer: float = time.time() 194 | order = Order() 195 | 196 | 197 | 198 | match sys.argv[1]: 199 | case "download": 200 | match sys.argv[2]: 201 | case "artist": 202 | # Download Artist 203 | asyncio.run(main=order.Artist(item_id=sys.argv[3], quality=download_quality, path=download_path)) 204 | 205 | case "album": 206 | # Download Album 207 | asyncio.run(main=order.Album(item_id=sys.argv[3], quality=download_quality, path=download_path, artist_cover_path="")) 208 | 209 | case "track": 210 | # Download Track 211 | asyncio.run(main=order.Track(item_id=sys.argv[3], quality=download_quality, path=download_path, total_track_number="14", album_cover_path="", artist_cover_path="")) 212 | 213 | case "cover": 214 | # Download Cover 215 | asyncio.run(main=order.Cover(id=sys.argv[3], resolution=750, path=f"{download_path}/cover.jpg")) 216 | 217 | case "search": 218 | match sys.argv[2]: 219 | case "artist": 220 | # Search Artist 221 | asyncio.run(main=Artist().search(query=sys.argv[3])) 222 | 223 | case "album": 224 | # Search Album 225 | asyncio.run(main=Album().search(query=sys.argv[3])) 226 | 227 | case "track": 228 | # Search Track 229 | asyncio.run(main=Track().search(query=sys.argv[3])) 230 | 231 | case "cover": 232 | # Search Cover 233 | asyncio.run(main=Cover().search(query=sys.argv[3])) 234 | 235 | print(f"The program took {time.time() - timer} seconds to execute") 236 | -------------------------------------------------------------------------------- /tools/path.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Path: 5 | 6 | def Clean(self, string: str) -> str: 7 | for c in '*?<>"': 8 | string = string.replace(c, "") 9 | for c in "\\/:|": 10 | string = string.replace(c, " ") 11 | return string 12 | 13 | def Create(self, path: str) -> None: 14 | os.makedirs(name=path, exist_ok=True) 15 | --------------------------------------------------------------------------------