mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-10-22 02:52:56 -05:00
🚀 2.8.0 (#182)
* bump version * remove testing, add venv instructions, formatting * remove tests and examples
This commit is contained in:
1 parent
9387a7f0b5
commit
01eeff3cac
7 files changed
+13
-421
No files matched your search
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user