Prepare for video downloading (#81)

* add video fetching to api
* add video parsing
* add video download example
This commit is contained in:
Oskar Dudziński
2025-02-06 21:18:20 +01:00
committed by GitHub
parent 8bf6906b81
commit 2dcae191da
8 changed files with 186 additions and 23 deletions
+73 -19
View File
@@ -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
+38
View File
@@ -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)
+1
View File
@@ -20,6 +20,7 @@ dependencies = [
"click>=8.1.7",
"mutagen>=1.47.0",
"ffmpeg-python>=0.2.0",
"m3u8>=6.0.0"
]
[project.urls]
+7 -1
View File
@@ -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()
+12
View File
@@ -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",
},
)
+37 -1
View File
@@ -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
+11
View File
@@ -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
+7 -2
View File
@@ -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: