mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 04:05:08 +03:00
Prepare for video downloading (#81)
* add video fetching to api * add video parsing * add video download example
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user