From 01eeff3cacf23ebdbd269ad85ff246ffa8ea3dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Dudzi=C5=84ski?= <56404247+oskvr37@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:52:56 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=202.8.0=20(#182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bump version * remove testing, add venv instructions, formatting * remove tests and examples --- README.md | 19 ++-- examples/concurrent_download_rich.py | 137 ------------------------- examples/download_video.py | 38 ------- pyproject.toml | 2 +- tests/__init__.py | 0 tests/test_api.py | 93 ----------------- tests/test_utils.py | 145 --------------------------- 7 files changed, 13 insertions(+), 421 deletions(-) delete mode 100644 examples/concurrent_download_rich.py delete mode 100644 examples/download_video.py delete mode 100644 tests/__init__.py delete mode 100644 tests/test_api.py delete mode 100644 tests/test_utils.py diff --git a/README.md b/README.md index ad93e04..7e7b643 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/examples/concurrent_download_rich.py b/examples/concurrent_download_rich.py deleted file mode 100644 index 511a26f..0000000 --- a/examples/concurrent_download_rich.py +++ /dev/null @@ -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() diff --git a/examples/download_video.py b/examples/download_video.py deleted file mode 100644 index 58bd6e0..0000000 --- a/examples/download_video.py +++ /dev/null @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 7909235..61461a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 661a6c2..0000000 --- a/tests/test_api.py +++ /dev/null @@ -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() diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 3b81d03..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -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()