diff --git a/examples/concurrent_download_rich.py b/examples/concurrent_download_rich.py index 537a2bd..a1bd9c8 100644 --- a/examples/concurrent_download_rich.py +++ b/examples/concurrent_download_rich.py @@ -1,9 +1,12 @@ -"""Example of concurrent playlist downloading with ThreadPoolExecutor and rich.""" +""" +Example of concurrent album + playlist downloading with ThreadPoolExecutor and rich. +This will download tracks and videos. +""" import logging -from time import sleep -from random import randint +from pathlib import Path +from requests import Session from concurrent.futures import ThreadPoolExecutor from rich.console import Console @@ -16,9 +19,11 @@ from rich.progress import ( ) from tiddl.api import TidalApi +from tiddl.download import parseTrackStream, parseVideoStream from tiddl.config import Config from tiddl.models.api import PlaylistItems -from tiddl.models.resource import Track +from tiddl.models.resource import Track, Video +from tiddl.utils import convertFileExtension WORKERS_COUNT = 4 @@ -46,18 +51,54 @@ progress = Progress( def handleTrackDownload(task_id: TaskID, track: Track): - total = randint(10, 30) - progress.update(task_id, total=total, visible=True) + track_stream = api.getTrackStream(track.id, "LOW") + urls, extension = parseTrackStream(track_stream) + + progress.update(task_id, total=len(urls), visible=True) progress.start_task(task_id) - # simulate track download + with Session() as s: + stream_data = b"" - for _ in range(total): - sleep(0.1) - progress.update(task_id, advance=1) + for url in urls: + req = s.get(url) + stream_data += req.content + progress.update(task_id, advance=1) + + path = Path("tracks") / f"{track.title}{extension}" + path.parent.mkdir(parents=True, exist_ok=True) + + with path.open("wb") as f: + f.write(stream_data) console.log(track.title) + progress.remove_task(task_id) + +def handleVideoDownload(task_id: TaskID, video: Video): + video_stream = api.getVideoStream(video.id) + urls = parseVideoStream(video_stream) + + progress.update(task_id, total=len(urls), visible=True) + progress.start_task(task_id) + + with Session() as s: + video_data = b"" + + for url in urls: + req = s.get(url) + video_data += req.content + progress.update(task_id, advance=1) + + 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", remove_source=True, is_video=True) + + console.log(video.title) progress.remove_task(task_id) @@ -69,7 +110,6 @@ pool = ThreadPoolExecutor(max_workers=WORKERS_COUNT) def submitTrack(track: Track): task_id = progress.add_task( description=track.title, - track=track, start=False, visible=False, ) @@ -77,24 +117,38 @@ def submitTrack(track: Track): pool.submit(handleTrackDownload, task_id=task_id, track=track) +def submitVideo(video: Video): + task_id = progress.add_task( + description=video.title, + start=False, + visible=False, + ) + + pool.submit(handleVideoDownload, task_id=task_id, video=video) + + # 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=25) +playlist_items = api.getPlaylistItems(playlist_uuid=PLAYLIST_UUID, limit=10) for item in playlist_items.items: - track = item.item + item = item.item - if isinstance(track, PlaylistItems.PlaylistTrackItem.PlaylistTrack): - submitTrack(track) + if isinstance(item, PlaylistItems.PlaylistTrackItem.PlaylistTrack): + submitTrack(item) + elif isinstance(item, PlaylistItems.PlaylistVideoItem.PlaylistVideo): + submitVideo(item) -album_items = api.getAlbumItems(album_id=ALBUM_ID, limit=14) +album_items = api.getAlbumItems(album_id=ALBUM_ID, limit=5) for item in album_items.items: - track = item.item + item = item.item - if isinstance(track, Track): - submitTrack(track) + if isinstance(item, Track): + submitTrack(item) + elif isinstance(item, Video): + submitVideo(item) # cleanup diff --git a/examples/download_video.py b/examples/download_video.py new file mode 100644 index 0000000..58bd6e0 --- /dev/null +++ b/examples/download_video.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index ca570fb..6116853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "click>=8.1.7", "mutagen>=1.47.0", "ffmpeg-python>=0.2.0", + "m3u8>=6.0.0" ] [project.urls] diff --git a/tests/test_api.py b/tests/test_api.py index 7e25ac4..9007c16 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,7 +14,7 @@ class TestApi(unittest.TestCase): token, user_id, country_code = ( auth.token, auth.user_id, - auth.country_code + auth.country_code, ) assert token, "No token found in config file" @@ -77,6 +77,12 @@ class TestApi(unittest.TestCase): 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) + if __name__ == "__main__": unittest.main() diff --git a/tiddl/api.py b/tiddl/api.py index 94c9ec4..83bebc6 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -20,6 +20,7 @@ from tiddl.models.api import ( Track, TrackStream, Video, + VideoStream, ) from tiddl.models.constants import TrackQuality @@ -199,3 +200,14 @@ class TidalApi: return self.fetch( Video, f"videos/{video_id}", {"countryCode": self.country_code} ) + + def getVideoStream(self, video_id: str | int): + return self.fetch( + VideoStream, + f"videos/{video_id}/playbackinfo", + { + "videoquality": "HIGH", + "playbackmode": "STREAM", + "assetpresentation": "FULL", + }, + ) diff --git a/tiddl/download.py b/tiddl/download.py index 7c70415..22aad8f 100644 --- a/tiddl/download.py +++ b/tiddl/download.py @@ -1,11 +1,12 @@ import logging +from m3u8 import M3U8 from requests import Session from pydantic import BaseModel from base64 import b64decode from xml.etree.ElementTree import fromstring -from tiddl.models.api import TrackStream +from tiddl.models.api import TrackStream, VideoStream logger = logging.getLogger(__name__) @@ -99,3 +100,38 @@ def downloadTrackStream(track_stream: TrackStream) -> tuple[bytes, str]: stream_data += req.content return stream_data, file_extension + + +def parseVideoStream(video_stream: VideoStream) -> list[str]: + """Parse `video_stream` manifest and return video urls""" + + # TOOD: add video quality arg. + # for now we download the highest quality + + class VideoManifest(BaseModel): + mimeType: str + urls: list[str] + + decoded_manifest = b64decode(video_stream.manifest).decode() + manifest = VideoManifest.model_validate_json(decoded_manifest) + + with Session() as s: + # get all qualities + req = s.get(manifest.urls[0]) + m3u8 = M3U8(req.text) + + # get highest quality + uri = m3u8.playlists[-1].uri + + if not uri: + raise ValueError("M3U8 Playlist does not have `uri`.") + + req = s.get(uri) + video = M3U8(req.text) + + if not video.files: + raise ValueError("M3U8 Playlist is empty.") + + urls = [url for url in video.files if url] + + return urls diff --git a/tiddl/models/api.py b/tiddl/models/api.py index 1ad13b0..8c7478b 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -127,6 +127,17 @@ class TrackStream(BaseModel): sampleRate: Optional[int] = None +class VideoStream(BaseModel): + videoId: int + streamType: Literal["ON_DEMAND"] + assetPresentation: Literal["FULL"] + videoQuality: Literal["HIGH", "MEDIUM"] + # streamingSessionId: str # only in web? + manifestMimeType: Literal["application/vnd.tidal.emu"] + manifestHash: str + manifest: str + + class SearchAlbum(Album): # TODO: remove the artist field instead of making it None artist: None = None diff --git a/tiddl/utils.py b/tiddl/utils.py index 96778d9..5490706 100644 --- a/tiddl/utils.py +++ b/tiddl/utils.py @@ -120,7 +120,7 @@ def trackExists( def convertFileExtension( - source_file: Path, extension: str, remove_source=False + source_file: Path, extension: str, remove_source=False, is_video=False ) -> Path: """ Converts `source_file` extension and returns `Path` of file with new `extension`. @@ -138,9 +138,14 @@ def convertFileExtension( if extension == source_file.suffix: return source_file + + ffmpeg_args = {"c:a": "copy", "loglevel": "error"} + + if is_video: + ffmpeg_args["c:v"] = "copy" ffmpeg.input(str(source_file)).output( - str(output_file), **{"c:a": "copy", "loglevel": "error"} + str(output_file), **ffmpeg_args ).run(overwrite_output=1) if remove_source: