Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: oskvr37/tiddl
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v1.2.0
Choose a base ref
...
head repository: oskvr37/tiddl
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: main
Choose a head ref
Loading
22 changes: 22 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: oskvr37

---

**Describe the bug**
Describe what happened.

**To Reproduce**
What command was used?

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Software (please complete the following information):**
- tiddl version: [e.g. v2.0.1]
- python version: [e.g. 3.11]
- OS: [e.g. Linux, Windows, iOS]
18 changes: 18 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: new feature
assignees: oskvr37

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...], It would be cool to [...]

**Describe the solution you'd like**

**Describe alternatives you've considered**

**Additional context**
Add any other context or screenshots about the feature request here.
10 changes: 8 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
{
"python.analysis.typeCheckingMode": "basic"
}
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.codeActionsOnSave": {
"source.organizeImports.ruff": "explicit",
}
},
"ruff.lineLength": 80,
}
118 changes: 77 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
# Tidal Downloader

TIDDL is the Python CLI application that allows downloading Tidal tracks.
Fully typed, no requirements.

![GitHub top language](https://img.shields.io/github/languages/top/oskvr37/tiddl?style=for-the-badge)
![PyPI - Downloads](https://img.shields.io/pypi/dm/tiddl?style=for-the-badge&color=%2332af64)
![PyPI - Version](https://img.shields.io/pypi/v/tiddl?style=for-the-badge)
![GitHub commits since latest release](https://img.shields.io/github/commits-since/oskvr37/tiddl/latest?style=for-the-badge)
[<img src="https://img.shields.io/badge/gitmoji-%20😜%20😍-FFDD67.svg?style=for-the-badge" />](https://gitmoji.dev)

TIDDL is the Python CLI application that allows downloading Tidal tracks and videos!

<img src="https://raw.githubusercontent.com/oskvr37/tiddl/refs/heads/main/docs/demo.gif" alt="tiddl album download in 6 seconds" />

It's inspired by [Tidal-Media-Downloader](https://github.com/yaronzz/Tidal-Media-Downloader) - currently not mantained project.
This repository will contain features requests from that project and will be the enhanced version.

> [!WARNING]
> This app is for personal use only and is not affiliated with Tidal. Users must ensure their use complies with Tidal's terms of service and local copyright laws. Downloaded tracks are for personal use and may not be shared or redistributed. The developer assumes no responsibility for misuse of this app.
# Installation

Install package using `pip`
@@ -19,62 +23,94 @@ Install package using `pip`
pip install tiddl
```

After installation you can use `tiddl` to set up auth token
Run the package cli with `tiddl`

```bash
$ tiddl
go to https://link.tidal.com/xxxxx and add device!
hit enter when you are ready...
authenticated!
token expires in 7 days
Usage: tiddl [OPTIONS] COMMAND [ARGS]...

TIDDL - Tidal Downloader ♫

Options:
-v, --verbose Show debug logs.
-q, --quiet Suppress logs.
-nc, --no-cache Omit Tidal API requests caching.
--help Show this message and exit.

Commands:
auth Manage Tidal token.
config Print path to the configuration file.
fav Get your Tidal favorites.
file Parse txt or JSON file with urls.
search Search on Tidal.
url Get Tidal URL.
```

# Basic usage

## Login with Tidal account

```bash
tiddl auth login
```

## Download resource

You can download track / video / album / artist / playlist

```bash
tiddl url https://listen.tidal.com/track/103805726 download
tiddl url https://listen.tidal.com/video/25747442 download
tiddl url https://listen.tidal.com/album/103805723 download
tiddl url https://listen.tidal.com/artist/25022 download
tiddl url https://listen.tidal.com/playlist/84974059-76af-406a-aede-ece2b78fa372 download
```

Use `tiddl -h` to show help message
> [!TIP]
> You don't have to paste full urls, track/103805726, album/103805723 etc. will also work
# CLI
## Download options

After authentication - when your token is ready - you can start downloading!
```bash
tiddl url track/103805726 download -q master -o "{artist}/{title} ({album})"
```

You can download `tracks` `albums` `playlists` `artists albums`
This command will:

- `tiddl -s -q high` sets high quality as default quality
- `tiddl <input>` downloads with high quality
- `tiddl <input> -q master` downloads with best possible quality
- `tiddl track/284165609 -p my_folder -o my_song` downloads track to `my_folder/my_song.flac`
- `tiddl track/284165609 -p my_folder -o my_song -s` same as above, but saves `my_folder` as default download path
- download with highest quality (master)
- save track with title and album name in artist folder

### Valid input
### Download quality

- https://tidal.com/browse/track/284165609
- track/284165609
- https://listen.tidal.com/album/284165608/track/284165609
- https://listen.tidal.com/album/284165608
- album/284165608
- https://listen.tidal.com/artist/7695548
- artist/7695548
- https://listen.tidal.com/playlist/803be625-97e4-4cbb-88dd-43f0b1c61ed7
- playlist/803be625-97e4-4cbb-88dd-43f0b1c61ed7
| Quality | File extension | Details |
| :-----: | :------------: | :-------------------: |
| LOW | .m4a | 96 kbps |
| NORMAL | .m4a | 320 kbps |
| HIGH | .flac | 16-bit, 44.1 kHz |
| MASTER | .flac | Up to 24-bit, 192 kHz |

# Modules
### Output format

You can also use TIDDL as module, it's fully typed so you will get type hints
More about file templating [on wiki](https://github.com/oskvr37/tiddl/wiki/Template-formatting).

```python
from tiddl import TidalApi, Config
# Development

config = Config()
Clone the repository

api = TidalApi(
config["token"],
config["user"]["user_id"],
config["user"]["country_code"]
)
```bash
git clone https://github.com/oskvr37/tiddl
```

album_id = 284165608
Install package with `--editable` flag

album = api.getAlbum(album_id)
```bash
pip install -e .
```

print(f"{album["title"]} has {album["numberOfTracks"]} tracks!")
Run tests

```bash
python -m unittest
```

# Resources
Binary file added docs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
137 changes: 137 additions & 0 deletions examples/concurrent_download_rich.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""
Example of concurrent album + playlist downloading with ThreadPoolExecutor and rich.
This will download tracks and videos.
"""

import logging

from typing import Union

from pathlib import Path
from requests import Session
from concurrent.futures import ThreadPoolExecutor

from rich.console import Console
from rich.logging import RichHandler
from rich.progress import (
BarColumn,
Progress,
TextColumn,
)

from tiddl.api import TidalApi
from tiddl.download import parseTrackStream, parseVideoStream
from tiddl.config import Config
from tiddl.models.resource import Track, Video
from tiddl.utils import convertFileExtension


WORKERS_COUNT = 4
PLAYLIST_UUID = "84974059-76af-406a-aede-ece2b78fa372"
ALBUM_ID = 103805723
QUALITY = "HI_RES_LOSSLESS"

console = Console()
logging.basicConfig(
level=logging.DEBUG, handlers=[RichHandler(console=console)]
)

logging.getLogger("urllib3").setLevel(logging.ERROR)

config = Config.fromFile() # load config from default directory

api = TidalApi(config.auth.token, config.auth.user_id, config.auth.country_code)

progress = Progress(
TextColumn("{task.description}"),
BarColumn(bar_width=40),
console=console,
transient=True,
auto_refresh=True,
)


def handleItemDownload(item: Union[Track, Video]):
if isinstance(item, Track):
track_stream = api.getTrackStream(item.id, quality=QUALITY)
urls, extension = parseTrackStream(track_stream)
elif isinstance(item, Video):
video_stream = api.getVideoStream(item.id)
urls = parseVideoStream(video_stream)
extension = ".ts"
else:
raise TypeError(
f"Invalid item type: expected an instance of Track or Video, "
f"received an instance of {type(item).__name__}. "
)

task_id = progress.add_task(
description=f"{type(item).__name__} {item.title}",
start=True,
visible=True,
total=len(urls),
)

with Session() as s:
stream_data = b""

for url in urls:
req = s.get(url)
stream_data += req.content
progress.advance(task_id)

path = Path("examples") / "downloads" / f"{item.id}{extension}"
path.parent.mkdir(parents=True, exist_ok=True)

with path.open("wb") as f:
f.write(stream_data)

if isinstance(item, Track):
if item.audioQuality == "HI_RES_LOSSLESS":
convertFileExtension(
source_file=path,
extension=".flac",
remove_source=True,
is_video=False,
copy_audio=True, # extract flac from m4a container
)

elif isinstance(item, Video):
convertFileExtension(
source_file=path,
extension=".mp4",
remove_source=True,
is_video=True,
copy_audio=True,
)

console.log(item.title)
progress.remove_task(task_id)


progress.start()

pool = ThreadPoolExecutor(max_workers=WORKERS_COUNT)


def submitItem(item: Union[Track, Video]):
pool.submit(handleItemDownload, item=item)


# NOTE: these api requests will run one by one,
# we will need to add some sleep between requests

playlist_items = api.getPlaylistItems(playlist_uuid=PLAYLIST_UUID, limit=10)

for item in playlist_items.items:
submitItem(item.item)

album_items = api.getAlbumItems(album_id=ALBUM_ID, limit=5)

for item in album_items.items:
submitItem(item.item)

# cleanup

pool.shutdown(wait=True)
progress.stop()
38 changes: 38 additions & 0 deletions examples/download_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Example of downloading a video from Tidal"""

import logging

from pathlib import Path
from requests import Session

from tiddl.api import TidalApi
from tiddl.config import Config
from tiddl.download import parseVideoStream
from tiddl.utils import convertFileExtension

logging.basicConfig(level=logging.DEBUG)

VIDEO_ID = 373513584

config = Config.fromFile() # load config from default directory

api = TidalApi(config.auth.token, config.auth.user_id, config.auth.country_code)

video_stream = api.getVideoStream(VIDEO_ID)

urls = parseVideoStream(video_stream)

with Session() as s:
video_data = b""

for url in urls:
req = s.get(url)
video_data += req.content

path = Path("videos") / f"{VIDEO_ID}.ts"
path.parent.mkdir(parents=True, exist_ok=True)

with path.open("wb") as f:
f.write(video_data)

convertFileExtension(path, ".mp4", True, True)
Loading