🚀 2.8.0 (#182)

* bump version

* remove testing, add venv instructions, formatting

* remove tests and examples
This commit is contained in:
Oskar Dudziński authored and GitHub committed 2025-11-04 20:52:56 +01:00
1 parent 9387a7f0b5
commit 01eeff3cac
7 files changed
+13 -421

No files matched your search

+12 -7
View File
@@ -53,11 +53,13 @@ Commands:
Based on python:alpine, slim build
**Docker run example (quickest / easiest)**
```
docker run -rm -v /downloads/dir:/root/Music/Tiddl/ -v ./config/tiddl/:/root/ ghcr.io/oskvr37/tiddl:latest
```
**docker-compose.yml example (not required, though allows for advanced configs)**
```
services:
tiddl:
@@ -65,10 +67,12 @@ services:
image: ghcr.io/oskvr37/tiddl:latest
volumes:
- /downloads/dir:/root/Music/Tiddl/ #default dir
- ./config/tiddl/:/root/ # Default location of config file
- ./config/tiddl/:/root/ # Default location of config file
command: tail -f /dev/null # Keep it running in background
```
**Access the container:**
```
docker exec -it tiddl sh
```
@@ -146,18 +150,19 @@ Clone the repository
git clone https://github.com/oskvr37/tiddl
```
You should create virtual environment and activate it
```bash
python -m venv .venv
source .venv/Scripts/activate
```
Install package with `--editable` flag
```bash
pip install -e .
```
Run tests
```bash
python -m unittest
```
# Resources
[Tidal API wiki](https://github.com/Fokka-Engineering/TIDAL)
-137
View File
@@ -1,137 +0,0 @@
"""
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
View File
@@ -1,38 +0,0 @@
"""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)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "2.8.0a1"
version = "2.8.0"
description = "Download Tidal tracks with CLI downloader."
readme = "README.md"
requires-python = ">=3.11"
View File
Whitespace-only changes.
-93
View File
@@ -1,93 +0,0 @@
import unittest
from tiddl.config import Config
from tiddl.api import TidalApi
class TestApi(unittest.TestCase):
api: TidalApi
def setUp(self):
config = Config.fromFile()
auth = config.auth
token, user_id, country_code = (
auth.token,
auth.user_id,
auth.country_code,
)
assert token, "No token found in config file"
assert user_id, "No user_id found in config file"
assert country_code, "No country_code found in config file"
self.api = TidalApi(token, user_id, country_code)
def test_ready(self):
session = self.api.getSession()
self.assertEqual(session.userId, int(self.api.user_id))
self.assertEqual(session.countryCode, self.api.country_code)
def test_track(self):
track = self.api.getTrack(103805726)
self.assertEqual(track.title, "Stronger")
def test_artist(self):
artist = self.api.getArtist(25022)
self.assertEqual(artist.name, "Kanye West")
def test_artist_albums(self):
self.api.getArtistAlbums(25022, filter="ALBUMS")
self.api.getArtistAlbums(25022, filter="EPSANDSINGLES")
def test_album(self):
album = self.api.getAlbum(103805723)
self.assertEqual(album.title, "Graduation")
def test_album_items(self):
album_items = self.api.getAlbumItems(103805723, limit=10)
self.assertEqual(len(album_items.items), 10)
album_items = self.api.getAlbumItems(103805723, limit=10, offset=10)
self.assertEqual(len(album_items.items), 4)
def test_album_items_credits(self):
album_items = self.api.getAlbumItemsCredits(103805723, limit=10)
self.assertEqual(len(album_items.items), 10)
def test_playlist(self):
playlist = self.api.getPlaylist("84974059-76af-406a-aede-ece2b78fa372")
self.assertEqual(playlist.title, "Kanye West Essentials")
def test_playlist_items(self):
playlist_items = self.api.getPlaylistItems(
"84974059-76af-406a-aede-ece2b78fa372"
)
self.assertEqual(len(playlist_items.items), 25)
def test_favorites(self):
favorites = self.api.getFavorites()
self.assertGreaterEqual(len(favorites.PLAYLIST), 0)
self.assertGreaterEqual(len(favorites.ALBUM), 0)
self.assertGreaterEqual(len(favorites.VIDEO), 0)
self.assertGreaterEqual(len(favorites.TRACK), 0)
self.assertGreaterEqual(len(favorites.ARTIST), 0)
def test_search(self):
self.api.getSearch("Kanye West")
def test_video(self):
self.api.getVideo(373513584)
def test_video_stream(self):
self.api.getVideoStream(373513584)
def test_lyrics(self):
track_id = 103805726
lyrics = self.api.getLyrics(track_id)
self.assertEqual(lyrics.trackId, track_id)
if __name__ == "__main__":
unittest.main()
-145
View File
@@ -1,145 +0,0 @@
import unittest
from tiddl.models.resource import Track
from tiddl.utils import TidalResource, formatTrack
class TestTidalResource(unittest.TestCase):
def test_resource_parsing(self):
positive_cases = [
("https://tidal.com/browse/track/12345678", "track", "12345678"),
("track/12345678", "track", "12345678"),
("https://tidal.com/browse/video/12345678", "video", "12345678"),
("video/12345678", "video", "12345678"),
("https://tidal.com/browse/album/12345678", "album", "12345678"),
("album/12345678", "album", "12345678"),
("https://tidal.com/browse/playlist/12345678", "playlist", "12345678"),
("playlist/12345678", "playlist", "12345678"),
("https://tidal.com/browse/artist/12345678", "artist", "12345678"),
("artist/12345678", "artist", "12345678"),
(
"https://tidal.com/browse/mix/f93b015796bf93b015796b",
"mix",
"f93b015796bf93b015796b",
),
("mix/f93b015796bf93b015796b", "mix", "f93b015796bf93b015796b"),
]
for resource, expected_type, expected_id in positive_cases:
with self.subTest(resource=resource):
tidal_resource = TidalResource.fromString(resource)
self.assertEqual(tidal_resource.type, expected_type)
self.assertEqual(tidal_resource.id, expected_id)
def test_failing_cases(self):
failing_cases = [
"https://tidal.com/browse/invalid/12345678",
"invalid/12345678",
"https://tidal.com/browse/track/invalid",
"track/invalid",
"",
"invalid",
"https://tidal.com/browse/track/",
"track/",
"/12345678",
]
for resource in failing_cases:
with self.subTest(resource=resource):
with self.assertRaises(ValueError):
TidalResource.fromString(resource)
class TestFormatTrack(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.track = Track(
**{
"id": 66421438,
"title": "Shutdown",
"duration": 189,
"replayGain": -9.95,
"peak": 0.966051,
"allowStreaming": True,
"streamReady": True,
"adSupportedStreamReady": True,
"djReady": True,
"stemReady": False,
"streamStartDate": "2016-11-15T00:00:00.000+0000",
"premiumStreamingOnly": False,
"trackNumber": 9,
"volumeNumber": 1,
"version": None,
"popularity": 24,
"copyright": "(P) 2016 Boy Better Know",
"bpm": 69,
"url": "http://www.tidal.com/track/66421438",
"isrc": "GB7QY1500024",
"editable": False,
"explicit": True,
"audioQuality": "LOSSLESS",
"audioModes": ["STEREO"],
"mediaMetadata": {"tags": ["LOSSLESS", "HIRES_LOSSLESS"]},
"artist": {
"id": 3566984,
"name": "Skepta",
"type": "MAIN",
"picture": "747af850-fa9c-4178-a3e6-49259b67df86",
},
"artists": [
{
"id": 3566984,
"name": "Skepta",
"type": "MAIN",
"picture": "747af850-fa9c-4178-a3e6-49259b67df86",
}
],
"album": {
"id": 66421429,
"title": "Konnichiwa",
"cover": "e0c2f05e-e21f-47c5-9c37-2993437df27d",
"vibrantColor": "#ae3b31",
"videoCover": None,
},
"mixes": {"TRACK_MIX": "001aa4abeb471e8f55f5784772b478"},
"playlistNumber": None,
}
)
def test_templating(self):
test_cases = [
("{id}", "66421438"),
("{title}", "Shutdown"),
("{version}", ""),
("{artist}", "Skepta"),
("{artists}", "Skepta"),
("{album}", "Konnichiwa"),
("{number}", "9"),
("{disc}", "1"),
("{date:%m-%d-%y}", "11-15-16"),
("{date:%Y}", "2016"),
("{year}", "2016"),
("{playlist_number}", "0"),
("{playlist_number:02d}", "00"),
("{bpm}", "69"),
("{quality}", "high"),
("{artist}/{album}/{title}", "Skepta/Konnichiwa/Shutdown"),
("{number:02d}. {title}", "09. Shutdown"),
]
for template, expected_result in test_cases:
with self.subTest(template=template, expected_result=expected_result):
result = formatTrack(template, self.track)
self.assertEqual(result, expected_result)
def test_invalid_characters(self):
test_cases = ["\\", ":", '"', "?", "<", ">", "|", "{number}:{title}", "{date}"]
for template in test_cases:
with self.subTest(template=template):
with self.assertRaises(ValueError):
formatTrack(template, self.track)
if __name__ == "__main__":
unittest.main()