mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 04:05:08 +03:00
✨ Threaded Track Download & Videos Support (#85)
This commit is contained in:
@@ -48,11 +48,11 @@ tiddl auth login
|
||||
|
||||
## Download resource
|
||||
|
||||
You can download track / album / artist / playlist / (video coming soon)
|
||||
<!-- TODO: remove coming soon after adding video download -->
|
||||
You can download track / video / album / artist / playlist
|
||||
|
||||
```bash
|
||||
tiddl url https://listen.tidal.com/track/103805726 download
|
||||
tiddl url https://listen.tidal.com/video/25747442 download
|
||||
tiddl url https://listen.tidal.com/album/103805723 download
|
||||
tiddl url https://listen.tidal.com/artist/25022 download
|
||||
tiddl url https://listen.tidal.com/playlist/84974059-76af-406a-aede-ece2b78fa372 download
|
||||
|
||||
@@ -52,8 +52,6 @@ progress = Progress(
|
||||
|
||||
|
||||
def handleItemDownload(item: Union[Track, Video]):
|
||||
# TODO: check if item is already downloaded
|
||||
|
||||
if isinstance(item, Track):
|
||||
track_stream = api.getTrackStream(item.id, quality=QUALITY)
|
||||
urls, extension = parseTrackStream(track_stream)
|
||||
|
||||
+10
-4
@@ -4,7 +4,12 @@ from pathlib import Path
|
||||
from typing import Any, Literal, Type, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
from requests_cache import CachedSession, EXPIRE_IMMEDIATELY, NEVER_EXPIRE
|
||||
from requests_cache import (
|
||||
CachedSession,
|
||||
EXPIRE_IMMEDIATELY,
|
||||
NEVER_EXPIRE,
|
||||
DO_NOT_CACHE,
|
||||
)
|
||||
|
||||
from tiddl.models.api import (
|
||||
Album,
|
||||
@@ -28,6 +33,7 @@ from tiddl.exceptions import ApiError
|
||||
from tiddl.config import HOME_PATH
|
||||
|
||||
DEBUG = False
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -209,7 +215,7 @@ class TidalApi:
|
||||
|
||||
def getSession(self):
|
||||
return self.fetch(
|
||||
SessionResponse, "sessions", expire_after=EXPIRE_IMMEDIATELY
|
||||
SessionResponse, "sessions", expire_after=DO_NOT_CACHE
|
||||
)
|
||||
|
||||
def getTrack(self, track_id: str | int):
|
||||
@@ -226,7 +232,7 @@ class TidalApi:
|
||||
"playbackmode": "STREAM",
|
||||
"assetpresentation": "FULL",
|
||||
},
|
||||
expire_after=3600,
|
||||
expire_after=DO_NOT_CACHE,
|
||||
)
|
||||
|
||||
def getVideo(self, video_id: str | int):
|
||||
@@ -243,5 +249,5 @@ class TidalApi:
|
||||
"playbackmode": "STREAM",
|
||||
"assetpresentation": "FULL",
|
||||
},
|
||||
expire_after=3600,
|
||||
expire_after=DO_NOT_CACHE,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ from .ctx import ContextObj, passContext, Context
|
||||
from .auth import AuthGroup
|
||||
from .download import UrlGroup, FavGroup, SearchGroup, FileGroup
|
||||
from .config import ConfigCommand
|
||||
from .download_concurrent import DownloadCommand
|
||||
|
||||
from tiddl.config import HOME_PATH
|
||||
|
||||
@@ -59,7 +58,6 @@ cli.add_command(UrlGroup)
|
||||
cli.add_command(FavGroup)
|
||||
cli.add_command(SearchGroup)
|
||||
cli.add_command(FileGroup)
|
||||
cli.add_command(DownloadCommand)
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
||||
+217
-102
@@ -1,6 +1,31 @@
|
||||
import logging
|
||||
import click
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from requests import Session
|
||||
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
Progress,
|
||||
TextColumn,
|
||||
)
|
||||
|
||||
from tiddl.download import parseTrackStream, parseVideoStream
|
||||
from tiddl.exceptions import ApiError, AuthError
|
||||
from tiddl.metadata import Cover, addMetadata, addVideoMetadata
|
||||
from tiddl.models.api import AlbumItemsCredits
|
||||
from tiddl.models.constants import ARG_TO_QUALITY, TrackArg
|
||||
from tiddl.models.resource import Track, Video, Album
|
||||
from tiddl.utils import (
|
||||
TidalResource,
|
||||
formatResource,
|
||||
convertFileExtension,
|
||||
trackExists,
|
||||
)
|
||||
|
||||
from typing import List, Literal, Union
|
||||
|
||||
from .fav import FavGroup
|
||||
from .file import FileGroup
|
||||
from .search import SearchGroup
|
||||
@@ -8,43 +33,35 @@ from .url import UrlGroup
|
||||
|
||||
from ..ctx import Context, passContext
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from tiddl.download import downloadTrackStream
|
||||
from tiddl.utils import (
|
||||
formatTrack,
|
||||
trackExists,
|
||||
TidalResource,
|
||||
convertFileExtension,
|
||||
)
|
||||
from tiddl.metadata import addMetadata, Cover
|
||||
from tiddl.exceptions import ApiError, AuthError
|
||||
from tiddl.models.constants import TrackArg, ARG_TO_QUALITY
|
||||
from tiddl.models.resource import Track, Album
|
||||
from tiddl.models.api import PlaylistItems, AlbumItemsCredits
|
||||
|
||||
SinglesFilter = Literal["none", "only", "include"]
|
||||
|
||||
|
||||
@click.command("download")
|
||||
@click.option(
|
||||
"--quality", "-q", "quality", type=click.Choice(TrackArg.__args__)
|
||||
"--quality", "-q", "QUALITY", type=click.Choice(TrackArg.__args__)
|
||||
)
|
||||
@click.option(
|
||||
"--output", "-o", "template", type=str, help="Format track file template."
|
||||
"--output", "-o", "TEMPLATE", type=str, help="Format track file template."
|
||||
)
|
||||
@click.option(
|
||||
"--threads",
|
||||
"-t",
|
||||
"THREADS_COUNT",
|
||||
type=int,
|
||||
help="Number of threads to use in concurrent download; use with caution.",
|
||||
)
|
||||
@click.option(
|
||||
"--noskip",
|
||||
"-ns",
|
||||
"noskip",
|
||||
"DO_NOT_SKIP",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Dont skip downloaded tracks.",
|
||||
help="Do not skip already downloaded tracks.",
|
||||
)
|
||||
@click.option(
|
||||
"--singles",
|
||||
"-s",
|
||||
"singles_filter",
|
||||
"SINGLES_FILTER",
|
||||
type=click.Choice(SinglesFilter.__args__),
|
||||
default="none",
|
||||
help="Defines how to treat artist EPs and singles.",
|
||||
@@ -52,92 +69,179 @@ SinglesFilter = Literal["none", "only", "include"]
|
||||
@passContext
|
||||
def DownloadCommand(
|
||||
ctx: Context,
|
||||
quality: TrackArg | None,
|
||||
template: str | None,
|
||||
noskip: bool,
|
||||
singles_filter: SinglesFilter = "none",
|
||||
QUALITY: TrackArg | None,
|
||||
TEMPLATE: str | None,
|
||||
THREADS_COUNT: int,
|
||||
DO_NOT_SKIP: bool,
|
||||
SINGLES_FILTER: SinglesFilter,
|
||||
):
|
||||
"""Download the tracks"""
|
||||
"""Download resources"""
|
||||
|
||||
# TODO: pretty print
|
||||
logging.debug(
|
||||
(QUALITY, TEMPLATE, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER)
|
||||
)
|
||||
|
||||
DOWNLOAD_QUALITY = ARG_TO_QUALITY[
|
||||
QUALITY or ctx.obj.config.download.quality
|
||||
]
|
||||
|
||||
api = ctx.obj.getApi()
|
||||
|
||||
def downloadTrack(
|
||||
track: Track,
|
||||
file_name: str,
|
||||
progress = Progress(
|
||||
TextColumn("{task.description}"),
|
||||
BarColumn(bar_width=40),
|
||||
console=ctx.obj.console,
|
||||
transient=True,
|
||||
auto_refresh=True,
|
||||
)
|
||||
|
||||
def handleItemDownload(
|
||||
item: Union[Track, Video],
|
||||
path: Path,
|
||||
cover_data=b"",
|
||||
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
|
||||
):
|
||||
if not track.allowStreaming:
|
||||
logging.warning(f"Track {file_name} does not allow streaming")
|
||||
return
|
||||
if isinstance(item, Track):
|
||||
track_stream = api.getTrackStream(item.id, quality=DOWNLOAD_QUALITY)
|
||||
logging.info(
|
||||
f"★ Track '{item.title}' "
|
||||
f"{(str(track_stream.bitDepth) + ' bit') if track_stream.bitDepth else ''} "
|
||||
f"{str(track_stream.sampleRate) + ' kHz' if track_stream.sampleRate else ''}"
|
||||
)
|
||||
|
||||
download_quality = ARG_TO_QUALITY[
|
||||
quality or ctx.obj.config.download.quality
|
||||
]
|
||||
urls, extension = parseTrackStream(track_stream)
|
||||
elif isinstance(item, Video):
|
||||
video_stream = api.getVideoStream(item.id)
|
||||
logging.info(
|
||||
f"★ Video '{item.title}' {video_stream.videoQuality} quality"
|
||||
)
|
||||
|
||||
# .suffix is needed because the Path.with_suffix method will replace any content after dot
|
||||
# for example: 'album/01. title' becomes 'album/01.m4a'
|
||||
path = ctx.obj.config.download.path / f"{file_name}.suffix"
|
||||
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__}. "
|
||||
)
|
||||
|
||||
if not noskip and trackExists(
|
||||
track.audioQuality, download_quality, path
|
||||
):
|
||||
logging.info(f"Skipping track {file_name}")
|
||||
return
|
||||
task_id = progress.add_task(
|
||||
description=f"{type(item).__name__}: {item.title}",
|
||||
start=True,
|
||||
visible=True,
|
||||
total=len(urls),
|
||||
)
|
||||
|
||||
logging.info(f"Downloading track {file_name}")
|
||||
with Session() as s:
|
||||
stream_data = b""
|
||||
|
||||
track_stream = api.getTrackStream(track.id, download_quality)
|
||||
for url in urls:
|
||||
req = s.get(url)
|
||||
|
||||
stream_data, file_extension = downloadTrackStream(track_stream)
|
||||
assert req.status_code == 200, (
|
||||
f"Could not download stream data for: "
|
||||
f"{type(item).__name__} '{item.title}', "
|
||||
f"status code: {req.status_code}"
|
||||
)
|
||||
|
||||
full_path = path.with_suffix(file_extension)
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stream_data += req.content
|
||||
progress.advance(task_id)
|
||||
|
||||
with full_path.open("wb") as f:
|
||||
path = path.with_suffix(extension)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with path.open("wb") as f:
|
||||
f.write(stream_data)
|
||||
|
||||
# extract flac from m4a container
|
||||
if isinstance(item, Track):
|
||||
if track_stream.audioQuality == "HI_RES_LOSSLESS":
|
||||
path = convertFileExtension(
|
||||
source_file=path,
|
||||
extension=".flac",
|
||||
remove_source=True,
|
||||
is_video=False,
|
||||
copy_audio=True, # extract flac from m4a container
|
||||
)
|
||||
|
||||
if track_stream.audioQuality == "HI_RES_LOSSLESS":
|
||||
full_path = convertFileExtension(
|
||||
full_path, ".flac", remove_source=True, copy_audio=True
|
||||
if not cover_data and item.album.cover:
|
||||
cover_data = Cover(item.album.cover).content
|
||||
|
||||
try:
|
||||
addMetadata(path, item, cover_data, credits)
|
||||
except Exception as e:
|
||||
logging.error(f"Can not add metadata to: {path}, {e}")
|
||||
|
||||
elif isinstance(item, Video):
|
||||
path = convertFileExtension(
|
||||
source_file=path,
|
||||
extension=".mp4",
|
||||
remove_source=True,
|
||||
is_video=True,
|
||||
copy_audio=True,
|
||||
)
|
||||
|
||||
if not cover_data and track.album.cover:
|
||||
cover_data = Cover(track.album.cover).content
|
||||
try:
|
||||
addVideoMetadata(path, item)
|
||||
except Exception as e:
|
||||
logging.error(f"Can not add metadata to: {path}, {e}")
|
||||
|
||||
try:
|
||||
addMetadata(
|
||||
full_path, track, cover_data=cover_data, credits=credits
|
||||
progress.remove_task(task_id)
|
||||
logging.info(f"✔ '{item.title}'")
|
||||
|
||||
pool = ThreadPoolExecutor(
|
||||
max_workers=THREADS_COUNT or ctx.obj.config.download.threads
|
||||
)
|
||||
|
||||
def submitItem(
|
||||
item: Union[Track, Video],
|
||||
filename: str,
|
||||
cover_data=b"",
|
||||
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
|
||||
):
|
||||
if not item.allowStreaming:
|
||||
logging.warning(
|
||||
f"✖ {type(item).__name__} '{item.title}' does not allow streaming"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Cant set metadata to {file_name}: {e}")
|
||||
return
|
||||
|
||||
path = ctx.obj.config.download.path / f"{filename}.*"
|
||||
|
||||
if not DO_NOT_SKIP: # check if item is already downloaded
|
||||
if isinstance(item, Track):
|
||||
if trackExists(item.audioQuality, DOWNLOAD_QUALITY, path):
|
||||
logging.warning(f"Track '{item.title}' skipped")
|
||||
return
|
||||
elif isinstance(item, Video):
|
||||
if path.with_suffix(".mp4").exists():
|
||||
logging.warning(f"Video '{item.title}' skipped")
|
||||
return
|
||||
|
||||
pool.submit(
|
||||
handleItemDownload,
|
||||
item=item,
|
||||
path=path,
|
||||
cover_data=cover_data,
|
||||
credits=credits,
|
||||
)
|
||||
|
||||
def downloadAlbum(album: Album):
|
||||
logging.info(f"Album {album.title}")
|
||||
logging.info(f"★ Album '{album.title}'")
|
||||
|
||||
cover_data = Cover(album.cover).content if album.cover else b""
|
||||
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
album_items = api.getAlbumItemsCredits(album.id, offset=offset)
|
||||
|
||||
for item in album_items.items:
|
||||
if isinstance(item.item, Track):
|
||||
track = item.item
|
||||
filename = formatResource(
|
||||
template=TEMPLATE or ctx.obj.config.template.album,
|
||||
resource=item.item,
|
||||
album_artist=album.artist.name,
|
||||
)
|
||||
|
||||
file_name = formatTrack(
|
||||
template=template or ctx.obj.config.template.album,
|
||||
track=track,
|
||||
album_artist=album.artist.name,
|
||||
)
|
||||
|
||||
downloadTrack(
|
||||
track=track,
|
||||
file_name=file_name,
|
||||
cover_data=cover_data,
|
||||
credits=item.credits,
|
||||
)
|
||||
submitItem(item.item, filename, cover_data, item.credits)
|
||||
|
||||
if (
|
||||
album_items.limit + album_items.offset
|
||||
@@ -147,26 +251,34 @@ def DownloadCommand(
|
||||
|
||||
offset += album_items.limit
|
||||
|
||||
def handleResource(resource: TidalResource):
|
||||
def handleResource(resource: TidalResource) -> None:
|
||||
logging.debug(f"Handling Resource '{resource}'")
|
||||
|
||||
match resource.type:
|
||||
case "track":
|
||||
track = api.getTrack(resource.id)
|
||||
file_name = formatTrack(
|
||||
template=template or ctx.obj.config.template.track,
|
||||
track=track,
|
||||
filename = formatResource(
|
||||
TEMPLATE or ctx.obj.config.template.track, track
|
||||
)
|
||||
|
||||
downloadTrack(
|
||||
track=track,
|
||||
file_name=file_name,
|
||||
submitItem(track, filename)
|
||||
|
||||
case "video":
|
||||
video = api.getVideo(resource.id)
|
||||
filename = formatResource(
|
||||
TEMPLATE or ctx.obj.config.template.video, video
|
||||
)
|
||||
|
||||
submitItem(video, filename)
|
||||
|
||||
case "album":
|
||||
album = api.getAlbum(resource.id)
|
||||
|
||||
downloadAlbum(album)
|
||||
|
||||
case "artist":
|
||||
artist = api.getArtist(resource.id)
|
||||
logging.info(f"★ Artist '{artist.name}'")
|
||||
|
||||
def getAllAlbums(singles: bool):
|
||||
offset = 0
|
||||
@@ -189,16 +301,15 @@ def DownloadCommand(
|
||||
|
||||
offset += artist_albums.limit
|
||||
|
||||
if singles_filter == "include":
|
||||
if SINGLES_FILTER == "include":
|
||||
getAllAlbums(False)
|
||||
getAllAlbums(True)
|
||||
else:
|
||||
getAllAlbums(singles_filter == "only")
|
||||
getAllAlbums(SINGLES_FILTER == "only")
|
||||
|
||||
case "playlist":
|
||||
playlist = api.getPlaylist(resource.id)
|
||||
logging.info(f"Playlist {playlist.title}")
|
||||
|
||||
logging.info(f"★ Playlist '{playlist.title}'")
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
@@ -207,21 +318,15 @@ def DownloadCommand(
|
||||
)
|
||||
|
||||
for item in playlist_items.items:
|
||||
if isinstance(
|
||||
item.item,
|
||||
PlaylistItems.PlaylistTrackItem.PlaylistTrack,
|
||||
):
|
||||
track = item.item
|
||||
filename = formatResource(
|
||||
template=TEMPLATE
|
||||
or ctx.obj.config.template.playlist,
|
||||
resource=item.item,
|
||||
playlist_title=playlist.title,
|
||||
playlist_index=item.item.index // 100000,
|
||||
)
|
||||
|
||||
file_name = formatTrack(
|
||||
template=template
|
||||
or ctx.obj.config.template.playlist,
|
||||
track=track,
|
||||
playlist_title=playlist.title,
|
||||
playlist_index=track.index // 100000,
|
||||
)
|
||||
|
||||
downloadTrack(track=item.item, file_name=file_name)
|
||||
submitItem(item.item, filename)
|
||||
|
||||
if (
|
||||
playlist_items.limit + playlist_items.offset
|
||||
@@ -231,16 +336,26 @@ def DownloadCommand(
|
||||
|
||||
offset += playlist_items.limit
|
||||
|
||||
progress.start()
|
||||
|
||||
# TODO: make sure every resource is unique
|
||||
for resource in ctx.obj.resources:
|
||||
try:
|
||||
handleResource(resource)
|
||||
|
||||
except AuthError as e:
|
||||
logging.error(e)
|
||||
break
|
||||
|
||||
except ApiError as e:
|
||||
logging.error(e)
|
||||
|
||||
except AuthError as e:
|
||||
logging.error(e)
|
||||
return
|
||||
# session does not have streaming privileges
|
||||
if e.sub_status == 4006:
|
||||
break
|
||||
|
||||
pool.shutdown(wait=True)
|
||||
progress.stop()
|
||||
|
||||
|
||||
UrlGroup.add_command(DownloadCommand)
|
||||
|
||||
@@ -3,7 +3,7 @@ import click
|
||||
from tiddl.utils import TidalResource, ResourceTypeLiteral
|
||||
from ..ctx import Context, passContext
|
||||
|
||||
ResourceTypeList: list[ResourceTypeLiteral] = ["track", "album", "artist", "playlist"]
|
||||
ResourceTypeList: list[ResourceTypeLiteral] = ["track", "video", "album", "artist", "playlist"]
|
||||
|
||||
|
||||
@click.group("fav")
|
||||
|
||||
@@ -23,6 +23,10 @@ def SearchGroup(ctx: Context, query: str):
|
||||
# it's not that big deal as we refetch one resource at most,
|
||||
# but it should be redesigned
|
||||
|
||||
if not search.topHit:
|
||||
click.echo(f"No search results for '{query}'")
|
||||
return
|
||||
|
||||
value = search.topHit.value
|
||||
icon = click.style("\u2bcc", "magenta")
|
||||
|
||||
@@ -39,6 +43,7 @@ def SearchGroup(ctx: Context, query: str):
|
||||
resource = TidalResource(type="playlist", id=str(value.uuid))
|
||||
click.echo(f"{icon} Playlist {value.title}")
|
||||
elif isinstance(value, Video):
|
||||
click.echo(f"{icon} Video {value.title} (currently not supported)")
|
||||
resource = TidalResource(type="video", id=str(value.id))
|
||||
click.echo(f"{icon} Video {value.title}")
|
||||
|
||||
ctx.obj.resources.append(resource)
|
||||
|
||||
@@ -21,7 +21,7 @@ def UrlGroup(ctx: Context, url: TidalResource):
|
||||
Get Tidal URL.
|
||||
|
||||
It can be Tidal link or `resource_type/resource_id` format.
|
||||
The resource can be a track, album, playlist or artist.
|
||||
The resource can be a track, video, album, playlist or artist.
|
||||
"""
|
||||
|
||||
ctx.obj.resources.append(url)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import logging
|
||||
import click
|
||||
|
||||
from ..ctx import Context, passContext
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
Progress,
|
||||
TextColumn,
|
||||
)
|
||||
|
||||
from tiddl.download import downloadTrackStream
|
||||
from tiddl.utils import (
|
||||
formatTrack,
|
||||
trackExists,
|
||||
TidalResource,
|
||||
convertFileExtension,
|
||||
)
|
||||
from tiddl.metadata import addMetadata, Cover
|
||||
from tiddl.exceptions import ApiError, AuthError
|
||||
from tiddl.models.constants import TrackArg, ARG_TO_QUALITY
|
||||
from tiddl.models.resource import Track, Album
|
||||
from tiddl.models.api import PlaylistItems, AlbumItemsCredits
|
||||
|
||||
SinglesFilter = Literal["none", "only", "include"]
|
||||
|
||||
|
||||
@click.command("download")
|
||||
@click.option(
|
||||
"--quality", "-q", "QUALITY", type=click.Choice(TrackArg.__args__)
|
||||
)
|
||||
@click.option(
|
||||
"--output", "-o", "TEMPLATE", type=str, help="Format track file template."
|
||||
)
|
||||
@click.option(
|
||||
"--threads",
|
||||
"-t",
|
||||
"THREADS_COUNT",
|
||||
type=int,
|
||||
help="Number of threads to use in concurrent download; use with caution.",
|
||||
default=1,
|
||||
)
|
||||
@click.option(
|
||||
"--noskip",
|
||||
"-ns",
|
||||
"DO_NOT_SKIP",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Do not skip already downloaded tracks.",
|
||||
)
|
||||
@click.option(
|
||||
"--singles",
|
||||
"-s",
|
||||
"SINGLES_FILTER",
|
||||
type=click.Choice(SinglesFilter.__args__),
|
||||
default="none",
|
||||
help="Defines how to treat artist EPs and singles.",
|
||||
)
|
||||
@passContext
|
||||
def DownloadCommand(
|
||||
ctx: Context,
|
||||
QUALITY: TrackArg | None,
|
||||
TEMPLATE: str | None,
|
||||
THREADS_COUNT: int,
|
||||
DO_NOT_SKIP: bool,
|
||||
SINGLES_FILTER: SinglesFilter,
|
||||
):
|
||||
"""Download resources"""
|
||||
|
||||
logging.debug(
|
||||
(QUALITY, TEMPLATE, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER)
|
||||
)
|
||||
|
||||
api = ctx.obj.getApi()
|
||||
|
||||
def handleResource(resource: TidalResource) -> None:
|
||||
pass
|
||||
|
||||
failed_resources: list[TidalResource] = []
|
||||
|
||||
for resource in ctx.obj.resources:
|
||||
try:
|
||||
handleResource(resource)
|
||||
|
||||
except ApiError as e:
|
||||
# TODO: handle rate limit
|
||||
logging.error(e)
|
||||
failed_resources.append(resource)
|
||||
|
||||
except AuthError as e:
|
||||
logging.error(e)
|
||||
return
|
||||
|
||||
# TODO: do something with `failed_resources`
|
||||
+1
-1
@@ -20,7 +20,7 @@ class TemplateConfig(BaseModel):
|
||||
class DownloadConfig(BaseModel):
|
||||
quality: TrackArg = "high"
|
||||
path: Path = Path.home() / "Music" / "Tiddl"
|
||||
threads: int = 1
|
||||
threads: int = 4
|
||||
|
||||
|
||||
class AuthConfig(BaseModel):
|
||||
|
||||
+30
-1
@@ -9,7 +9,7 @@ from mutagen.flac import Picture
|
||||
from mutagen.mp4 import MP4 as MutagenMP4
|
||||
from mutagen.mp4 import MP4Cover
|
||||
|
||||
from tiddl.models.resource import Track
|
||||
from tiddl.models.resource import Track, Video
|
||||
from tiddl.models.api import AlbumItemsCredits
|
||||
|
||||
from typing import List
|
||||
@@ -105,7 +105,36 @@ def addMetadata(
|
||||
logger.error(f"Failed to add metadata to {track_path}: {e}")
|
||||
|
||||
|
||||
def addVideoMetadata(path: Path, video: Video):
|
||||
metadata = MutagenEasyMP4(path)
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"title": video.title,
|
||||
"albumartist": video.artist.name if video.artist else "",
|
||||
"artist": ";".join(
|
||||
[artist.name.strip() for artist in video.artists]
|
||||
),
|
||||
"album": video.album.title if video.album else "",
|
||||
"date": str(video.streamStartDate) if video.streamStartDate else "",
|
||||
}
|
||||
)
|
||||
|
||||
if video.trackNumber:
|
||||
metadata["tracknumber"] = str(video.trackNumber)
|
||||
|
||||
if video.volumeNumber:
|
||||
metadata["discnumber"] = str(video.volumeNumber)
|
||||
|
||||
try:
|
||||
metadata.save(path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add metadata to {path}: {e}")
|
||||
|
||||
|
||||
class Cover:
|
||||
# TODO: cache covers
|
||||
|
||||
def __init__(self, uid: str, size=1280) -> None:
|
||||
if size > 1280:
|
||||
logger.warning(
|
||||
|
||||
+1
-1
@@ -168,4 +168,4 @@ class Search(BaseModel):
|
||||
playlists: Playlists
|
||||
tracks: Tracks
|
||||
videos: Videos
|
||||
topHit: TopHit
|
||||
topHit: Optional[TopHit] = None
|
||||
|
||||
Reference in New Issue
Block a user