mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 12:15:13 +03:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5374d1f64f | |||
| b6607ce64d | |||
| b385722946 | |||
| 40f82b51a2 | |||
| a3c744b06c | |||
| ab57b700f0 | |||
| e41181e502 | |||
| e2777faa89 | |||
| 680b9b9760 | |||
| 56968be9a2 | |||
| 92f3feda2e | |||
| 4289875599 | |||
| 01b06b480c | |||
| 1908b81334 | |||
| 970dfd016a | |||
| 733b51dd33 | |||
| 84358f3537 | |||
| 7a6a742cbb | |||
| c4e5486372 | |||
| 8518e69a9f | |||
| 6565ff19c7 | |||
| 3158f795cc | |||
| 7f7cfe6b4c | |||
| 87e7073f62 | |||
| 02cee273a6 | |||
| 78a382e83e |
@@ -11,11 +11,12 @@ assignees: oskvr37
|
||||
Describe what happened.
|
||||
|
||||
**To Reproduce**
|
||||
Which command was used?
|
||||
What command was used?
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Software (please complete the following information):**
|
||||
- tiddl version: [e.g. v2.0.1]
|
||||
- python version: [e.g. 3.11]
|
||||
- OS: [e.g. Linux, Windows, iOS]
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||

|
||||
[<img src="https://img.shields.io/badge/gitmoji-%20😜%20😍-FFDD67.svg?style=for-the-badge" />](https://gitmoji.dev)
|
||||
|
||||
TIDDL is Python CLI application that allows downloading Tidal tracks.
|
||||
TIDDL is the Python CLI application that allows downloading Tidal tracks and videos!
|
||||
|
||||
<img src="https://raw.githubusercontent.com/oskvr37/tiddl/refs/heads/main/docs/demo.gif" alt="tiddl album download in 6 seconds" />
|
||||
|
||||
It's inspired by [Tidal-Media-Downloader](https://github.com/yaronzz/Tidal-Media-Downloader) - currently not mantained project.
|
||||
This repository will contain features requests from that project and will be the enhanced version.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tiddl"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
description = "Download Tidal tracks with CLI downloader."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+19
-3
@@ -10,6 +10,8 @@ from .config import ConfigCommand
|
||||
|
||||
from tiddl.config import HOME_PATH
|
||||
|
||||
from .auth import refresh
|
||||
|
||||
|
||||
@click.group()
|
||||
@passContext
|
||||
@@ -20,7 +22,7 @@ from tiddl.config import HOME_PATH
|
||||
)
|
||||
def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool):
|
||||
"""TIDDL - Tidal Downloader \u266b"""
|
||||
ctx.obj = ContextObj(omit_cache=no_cache)
|
||||
ctx.obj = ContextObj()
|
||||
|
||||
# latest logs
|
||||
file_handler = logging.FileHandler(
|
||||
@@ -34,11 +36,20 @@ def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool):
|
||||
)
|
||||
)
|
||||
|
||||
rich_handler = RichHandler(console=ctx.obj.console, rich_tracebacks=True)
|
||||
rich_handler.setLevel(
|
||||
LEVEL = (
|
||||
logging.DEBUG if verbose else logging.ERROR if quiet else logging.INFO
|
||||
)
|
||||
|
||||
rich_handler = RichHandler(console=ctx.obj.console, rich_tracebacks=True)
|
||||
rich_handler.setLevel(LEVEL)
|
||||
|
||||
if LEVEL == logging.DEBUG:
|
||||
rich_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(name)s.%(funcName)s] %(message)s", datefmt="[%X]"
|
||||
)
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
handlers=[
|
||||
@@ -51,6 +62,11 @@ def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool):
|
||||
|
||||
logging.getLogger("urllib3").setLevel(logging.ERROR)
|
||||
|
||||
if ctx.invoked_subcommand in ("fav", "file", "search", "url"):
|
||||
ctx.invoke(refresh)
|
||||
|
||||
ctx.obj.initApi(omit_cache=no_cache)
|
||||
|
||||
|
||||
cli.add_command(ConfigCommand)
|
||||
cli.add_command(AuthGroup)
|
||||
|
||||
+47
-27
@@ -1,11 +1,17 @@
|
||||
import click
|
||||
import logging
|
||||
|
||||
from click import style
|
||||
from time import sleep, time
|
||||
|
||||
from tiddl.auth import getDeviceAuth, getToken, refreshToken, removeToken, AuthError
|
||||
from tiddl.config import AuthConfig
|
||||
from tiddl.auth import (
|
||||
getDeviceAuth,
|
||||
getToken,
|
||||
refreshToken,
|
||||
removeToken,
|
||||
AuthError,
|
||||
)
|
||||
|
||||
from .ctx import passContext, Context
|
||||
|
||||
|
||||
@@ -17,33 +23,46 @@ def AuthGroup():
|
||||
"""Manage Tidal token."""
|
||||
|
||||
|
||||
@AuthGroup.command("refresh")
|
||||
@passContext
|
||||
def refresh(ctx: Context):
|
||||
"""Refresh auth token when is expired"""
|
||||
|
||||
logger.debug("Invoked refresh command")
|
||||
|
||||
auth = ctx.obj.config.auth
|
||||
|
||||
if auth.refresh_token and time() > auth.expires:
|
||||
logger.info("Refreshing token...")
|
||||
token = refreshToken(auth.refresh_token)
|
||||
|
||||
ctx.obj.config.auth.expires = token.expires_in + int(time())
|
||||
ctx.obj.config.auth.token = token.access_token
|
||||
|
||||
ctx.obj.config.save()
|
||||
logger.info("Refreshed auth token!")
|
||||
|
||||
|
||||
@AuthGroup.command("login")
|
||||
@passContext
|
||||
def login(ctx: Context):
|
||||
"""Add token to the config"""
|
||||
|
||||
auth = ctx.obj.config.auth
|
||||
logger.debug("Invoked login command")
|
||||
|
||||
if auth.token:
|
||||
if auth.refresh_token and time() > auth.expires:
|
||||
click.echo(style("Refreshing token...", fg="yellow"))
|
||||
token = refreshToken(auth.refresh_token)
|
||||
|
||||
ctx.obj.config.auth.expires = token.expires_in + int(time())
|
||||
ctx.obj.config.auth.token = token.access_token
|
||||
|
||||
ctx.obj.config.save()
|
||||
|
||||
click.echo(style("Authenticated!", fg="green"))
|
||||
if ctx.obj.config.auth.token:
|
||||
logger.info("Already logged in.")
|
||||
refresh(ctx)
|
||||
return
|
||||
|
||||
auth = getDeviceAuth()
|
||||
|
||||
uri = f"https://{auth.verificationUriComplete}"
|
||||
click.launch(uri)
|
||||
click.echo(f"Go to {style(uri, fg='cyan')} and complete authentication!")
|
||||
|
||||
time_left = time() + auth.expiresIn
|
||||
logger.info(f"Go to {uri} and complete authentication!")
|
||||
|
||||
auth_end_at = time() + auth.expiresIn
|
||||
|
||||
while True:
|
||||
sleep(auth.interval)
|
||||
@@ -52,29 +71,28 @@ def login(ctx: Context):
|
||||
token = getToken(auth.deviceCode)
|
||||
except AuthError as e:
|
||||
if e.error == "authorization_pending":
|
||||
# FIX: `Time left: 0 secondsss` 🐍
|
||||
time_left = auth_end_at - time()
|
||||
minutes, seconds = time_left // 60, int(time_left % 60)
|
||||
|
||||
click.echo(f"\rTime left: {time_left - time():.0f} seconds", nl=False)
|
||||
click.echo(
|
||||
f"\rTime left: {minutes:.0f}:{seconds:02d}", nl=False
|
||||
)
|
||||
continue
|
||||
|
||||
if e.error == "expired_token":
|
||||
click.echo(
|
||||
f"\nTime for authentication {style('has expired', fg='red')}."
|
||||
)
|
||||
logger.info("\nTime for authentication has expired.")
|
||||
break
|
||||
|
||||
new_auth = AuthConfig(
|
||||
ctx.obj.config.auth = AuthConfig(
|
||||
token=token.access_token,
|
||||
refresh_token=token.refresh_token,
|
||||
expires=token.expires_in + int(time()),
|
||||
user_id=str(token.user.userId),
|
||||
country_code=token.user.countryCode,
|
||||
)
|
||||
|
||||
ctx.obj.config.auth = new_auth
|
||||
ctx.obj.config.save()
|
||||
|
||||
click.echo(style("\nAuthenticated!", fg="green"))
|
||||
logger.info("\nAuthenticated!")
|
||||
|
||||
break
|
||||
|
||||
@@ -84,10 +102,12 @@ def login(ctx: Context):
|
||||
def logout(ctx: Context):
|
||||
"""Remove token from config"""
|
||||
|
||||
logger.debug("Invoked logout command")
|
||||
|
||||
access_token = ctx.obj.config.auth.token
|
||||
|
||||
if not access_token:
|
||||
click.echo(style("Not logged in", fg="yellow"))
|
||||
logger.info("Not logged in.")
|
||||
return
|
||||
|
||||
removeToken(access_token)
|
||||
@@ -95,4 +115,4 @@ def logout(ctx: Context):
|
||||
ctx.obj.config.auth = AuthConfig()
|
||||
ctx.obj.config.save()
|
||||
|
||||
click.echo(style("Logged out!", fg="green"))
|
||||
logger.info("Logged out!")
|
||||
|
||||
+41
-5
@@ -2,18 +2,54 @@ import click
|
||||
|
||||
from tiddl.config import CONFIG_PATH
|
||||
|
||||
from .ctx import Context, passContext
|
||||
|
||||
|
||||
@click.command("config")
|
||||
@click.option(
|
||||
"--open",
|
||||
"-o",
|
||||
"OPEN_CONFIG",
|
||||
is_flag=True,
|
||||
help="Open the configuration file with the default editor",
|
||||
help="Open the configuration file with the default editor.",
|
||||
)
|
||||
def ConfigCommand(open: bool):
|
||||
"""Print path to the configuration file."""
|
||||
@click.option(
|
||||
"--locate",
|
||||
"-l",
|
||||
"LOCATE_CONFIG",
|
||||
is_flag=True,
|
||||
help="Launch a file manager with the located configuration file.",
|
||||
)
|
||||
@click.option(
|
||||
"--print",
|
||||
"-p",
|
||||
"PRINT_CONFIG",
|
||||
is_flag=True,
|
||||
help="Show current configuration.",
|
||||
)
|
||||
@passContext
|
||||
def ConfigCommand(
|
||||
ctx: Context, OPEN_CONFIG: bool, LOCATE_CONFIG: bool, PRINT_CONFIG: bool
|
||||
):
|
||||
"""
|
||||
Configuration file options.
|
||||
|
||||
click.echo(str(CONFIG_PATH))
|
||||
By default it prints location of tiddl config file.
|
||||
|
||||
if open:
|
||||
This command can be used in variable like `vim $(tiddl config)`
|
||||
- this will open your config with vim editor.
|
||||
"""
|
||||
|
||||
if OPEN_CONFIG:
|
||||
click.launch(str(CONFIG_PATH))
|
||||
|
||||
elif LOCATE_CONFIG:
|
||||
click.launch(str(CONFIG_PATH), locate=True)
|
||||
|
||||
elif PRINT_CONFIG:
|
||||
config_without_auth = ctx.obj.config.model_copy()
|
||||
del config_without_auth.auth
|
||||
ctx.obj.console.print(config_without_auth.model_dump_json(indent=2))
|
||||
|
||||
else:
|
||||
click.echo(str(CONFIG_PATH))
|
||||
|
||||
+2
-1
@@ -16,12 +16,13 @@ class ContextObj:
|
||||
resources: list[TidalResource]
|
||||
console: Console
|
||||
|
||||
def __init__(self, omit_cache=False) -> None:
|
||||
def __init__(self) -> None:
|
||||
self.config = Config.fromFile()
|
||||
self.resources = []
|
||||
self.api = None
|
||||
self.console = Console()
|
||||
|
||||
def initApi(self, omit_cache=False):
|
||||
auth = self.config.auth
|
||||
|
||||
if auth.token and auth.user_id and auth.country_code:
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import logging
|
||||
import click
|
||||
|
||||
from time import perf_counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from requests import Session
|
||||
|
||||
from rich.highlighter import ReprHighlighter
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
SpinnerColumn,
|
||||
Progress,
|
||||
TextColumn,
|
||||
)
|
||||
@@ -15,7 +17,7 @@ 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.constants import ARG_TO_QUALITY, TrackArg, SinglesFilter
|
||||
from tiddl.models.resource import Track, Video, Album
|
||||
from tiddl.utils import (
|
||||
TidalResource,
|
||||
@@ -24,7 +26,7 @@ from tiddl.utils import (
|
||||
trackExists,
|
||||
)
|
||||
|
||||
from typing import List, Literal, Union
|
||||
from typing import List, Union
|
||||
|
||||
from .fav import FavGroup
|
||||
from .file import FileGroup
|
||||
@@ -33,15 +35,29 @@ from .url import UrlGroup
|
||||
|
||||
from ..ctx import Context, passContext
|
||||
|
||||
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__),
|
||||
help="Track quality.",
|
||||
)
|
||||
@click.option(
|
||||
"--output", "-o", "TEMPLATE", type=str, help="Format track file template."
|
||||
"--output",
|
||||
"-o",
|
||||
"TEMPLATE",
|
||||
type=str,
|
||||
help="Format output file template. "
|
||||
"This will be used instead of your config templates.",
|
||||
)
|
||||
@click.option(
|
||||
"--path",
|
||||
"-p",
|
||||
"PATH",
|
||||
type=str,
|
||||
help="Base path of download directory. Default is ~/Music/Tiddl.",
|
||||
)
|
||||
@click.option(
|
||||
"--threads",
|
||||
@@ -56,30 +72,32 @@ SinglesFilter = Literal["none", "only", "include"]
|
||||
"DO_NOT_SKIP",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Do not skip already downloaded tracks.",
|
||||
help="Do not skip already downloaded files.",
|
||||
)
|
||||
@click.option(
|
||||
"--singles",
|
||||
"-s",
|
||||
"SINGLES_FILTER",
|
||||
type=click.Choice(SinglesFilter.__args__),
|
||||
default="none",
|
||||
help="Defines how to treat artist EPs and singles.",
|
||||
help="Defines how to treat artist EPs and singles, used while downloading artist.",
|
||||
)
|
||||
@passContext
|
||||
def DownloadCommand(
|
||||
ctx: Context,
|
||||
QUALITY: TrackArg | None,
|
||||
TEMPLATE: str | None,
|
||||
PATH: str | None,
|
||||
THREADS_COUNT: int,
|
||||
DO_NOT_SKIP: bool,
|
||||
SINGLES_FILTER: SinglesFilter,
|
||||
):
|
||||
"""Download resources"""
|
||||
|
||||
SINGLES_FILTER = SINGLES_FILTER or ctx.obj.config.download.singles_filter
|
||||
|
||||
# TODO: pretty print
|
||||
logging.debug(
|
||||
(QUALITY, TEMPLATE, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER)
|
||||
(QUALITY, TEMPLATE, PATH, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER)
|
||||
)
|
||||
|
||||
DOWNLOAD_QUALITY = ARG_TO_QUALITY[
|
||||
@@ -89,8 +107,12 @@ def DownloadCommand(
|
||||
api = ctx.obj.getApi()
|
||||
|
||||
progress = Progress(
|
||||
TextColumn("{task.description}"),
|
||||
BarColumn(bar_width=40),
|
||||
SpinnerColumn(),
|
||||
TextColumn(
|
||||
"{task.description} • "
|
||||
"{task.fields[speed]:.2f} Mbps • {task.fields[size]:.2f} MB",
|
||||
highlighter=ReprHighlighter(),
|
||||
),
|
||||
console=ctx.obj.console,
|
||||
transient=True,
|
||||
auto_refresh=True,
|
||||
@@ -101,11 +123,12 @@ def DownloadCommand(
|
||||
path: Path,
|
||||
cover_data=b"",
|
||||
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
|
||||
album_artist="",
|
||||
):
|
||||
if isinstance(item, Track):
|
||||
track_stream = api.getTrackStream(item.id, quality=DOWNLOAD_QUALITY)
|
||||
logging.info(
|
||||
f"★ Track '{item.title}' "
|
||||
description = (
|
||||
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 ''}"
|
||||
)
|
||||
@@ -113,8 +136,8 @@ def DownloadCommand(
|
||||
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"
|
||||
description = (
|
||||
f"Video '{item.title}' {video_stream.videoQuality} quality"
|
||||
)
|
||||
|
||||
urls = parseVideoStream(video_stream)
|
||||
@@ -126,14 +149,18 @@ def DownloadCommand(
|
||||
)
|
||||
|
||||
task_id = progress.add_task(
|
||||
description=f"{type(item).__name__}: {item.title}",
|
||||
description=description,
|
||||
start=True,
|
||||
visible=True,
|
||||
total=len(urls),
|
||||
total=None,
|
||||
# fields
|
||||
speed=0,
|
||||
size=0,
|
||||
)
|
||||
|
||||
with Session() as s:
|
||||
stream_data = b""
|
||||
time_start = perf_counter()
|
||||
|
||||
for url in urls:
|
||||
req = s.get(url)
|
||||
@@ -145,7 +172,18 @@ def DownloadCommand(
|
||||
)
|
||||
|
||||
stream_data += req.content
|
||||
progress.advance(task_id)
|
||||
speed = (
|
||||
len(stream_data)
|
||||
/ (perf_counter() - time_start)
|
||||
/ (1024 * 128)
|
||||
)
|
||||
size = len(stream_data) / 1024**2
|
||||
progress.update(
|
||||
task_id,
|
||||
advance=len(req.content),
|
||||
speed=speed,
|
||||
size=size,
|
||||
)
|
||||
|
||||
path = path.with_suffix(extension)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -167,7 +205,9 @@ def DownloadCommand(
|
||||
cover_data = Cover(item.album.cover).content
|
||||
|
||||
try:
|
||||
addMetadata(path, item, cover_data, credits)
|
||||
addMetadata(
|
||||
path, item, cover_data, credits, album_artist=album_artist
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Can not add metadata to: {path}, {e}")
|
||||
|
||||
@@ -186,7 +226,7 @@ def DownloadCommand(
|
||||
logging.error(f"Can not add metadata to: {path}, {e}")
|
||||
|
||||
progress.remove_task(task_id)
|
||||
logging.info(f"✔ '{item.title}'")
|
||||
logging.info(f"{item.title!r} • {speed:.2f} Mbps • {size:.2f} MB")
|
||||
|
||||
pool = ThreadPoolExecutor(
|
||||
max_workers=THREADS_COUNT or ctx.obj.config.download.threads
|
||||
@@ -197,6 +237,7 @@ def DownloadCommand(
|
||||
filename: str,
|
||||
cover_data=b"",
|
||||
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
|
||||
album_artist="",
|
||||
):
|
||||
if not item.allowStreaming:
|
||||
logging.warning(
|
||||
@@ -204,7 +245,8 @@ def DownloadCommand(
|
||||
)
|
||||
return
|
||||
|
||||
path = ctx.obj.config.download.path / f"{filename}.*"
|
||||
path = Path(PATH) if PATH else ctx.obj.config.download.path
|
||||
path /= f"{filename}.*"
|
||||
|
||||
if not DO_NOT_SKIP: # check if item is already downloaded
|
||||
if isinstance(item, Track):
|
||||
@@ -222,10 +264,11 @@ def DownloadCommand(
|
||||
path=path,
|
||||
cover_data=cover_data,
|
||||
credits=credits,
|
||||
album_artist=album_artist,
|
||||
)
|
||||
|
||||
def downloadAlbum(album: Album):
|
||||
logging.info(f"★ Album '{album.title}'")
|
||||
logging.info(f"Album {album.title!r}")
|
||||
|
||||
cover_data = Cover(album.cover).content if album.cover else b""
|
||||
|
||||
@@ -241,7 +284,13 @@ def DownloadCommand(
|
||||
album_artist=album.artist.name,
|
||||
)
|
||||
|
||||
submitItem(item.item, filename, cover_data, item.credits)
|
||||
submitItem(
|
||||
item.item,
|
||||
filename,
|
||||
cover_data,
|
||||
item.credits,
|
||||
album.artist.name,
|
||||
)
|
||||
|
||||
if (
|
||||
album_items.limit + album_items.offset
|
||||
@@ -278,7 +327,7 @@ def DownloadCommand(
|
||||
|
||||
case "artist":
|
||||
artist = api.getArtist(resource.id)
|
||||
logging.info(f"★ Artist '{artist.name}'")
|
||||
logging.info(f"Artist {artist.name!r}")
|
||||
|
||||
def getAllAlbums(singles: bool):
|
||||
offset = 0
|
||||
@@ -309,7 +358,7 @@ def DownloadCommand(
|
||||
|
||||
case "playlist":
|
||||
playlist = api.getPlaylist(resource.id)
|
||||
logging.info(f"★ Playlist '{playlist.title}'")
|
||||
logging.info(f"Playlist {playlist.title!r}")
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
|
||||
from tiddl.models.constants import TrackArg
|
||||
from tiddl.models.constants import TrackArg, SinglesFilter
|
||||
|
||||
HOME_PATH = Path.home()
|
||||
CONFIG_PATH = HOME_PATH / "tiddl.json"
|
||||
@@ -21,6 +21,7 @@ class DownloadConfig(BaseModel):
|
||||
quality: TrackArg = "high"
|
||||
path: Path = Path.home() / "Music" / "Tiddl"
|
||||
threads: int = 4
|
||||
singles_filter: SinglesFilter = "none"
|
||||
|
||||
|
||||
class AuthConfig(BaseModel):
|
||||
|
||||
+3
-2
@@ -105,8 +105,9 @@ def downloadTrackStream(track_stream: TrackStream) -> tuple[bytes, str]:
|
||||
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
|
||||
# TODO: add video quality arg,
|
||||
# for now we download the highest quality.
|
||||
# -vq option in download command
|
||||
|
||||
class VideoManifest(BaseModel):
|
||||
mimeType: str
|
||||
|
||||
+13
-7
@@ -22,6 +22,7 @@ def addMetadata(
|
||||
track: Track,
|
||||
cover_data=b"",
|
||||
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
|
||||
album_artist="",
|
||||
):
|
||||
logger.debug((track_path, track.id))
|
||||
|
||||
@@ -42,23 +43,28 @@ def addMetadata(
|
||||
metadata["TRACKNUMBER"] = str(track.trackNumber)
|
||||
metadata["DISCNUMBER"] = str(track.volumeNumber)
|
||||
|
||||
if track.artist:
|
||||
metadata["ARTIST"] = track.artist.name
|
||||
|
||||
metadata["ARTISTS"] = [artist.name for artist in track.artists]
|
||||
metadata["ALBUM"] = track.album.title
|
||||
metadata["ALBUMARTIST"] = ", ".join(
|
||||
|
||||
metadata["ARTIST"] = "; ".join(
|
||||
[artist.name.strip() for artist in track.artists]
|
||||
)
|
||||
|
||||
if album_artist:
|
||||
metadata["ALBUMARTIST"] = album_artist
|
||||
elif track.artist:
|
||||
metadata["ALBUMARTIST"] = track.artist.name
|
||||
|
||||
if track.streamStartDate:
|
||||
metadata["DATE"] = track.streamStartDate.strftime("%Y-%m-%d")
|
||||
metadata["ORIGINALDATE"] = track.streamStartDate.strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
metadata["YEAR"] = str(track.streamStartDate.strftime("%Y"))
|
||||
metadata["ORIGINALYEAR"] = str(track.streamStartDate.strftime("%Y"))
|
||||
|
||||
metadata["COPYRIGHT"] = track.copyright
|
||||
if track.copyright:
|
||||
metadata["COPYRIGHT"] = track.copyright
|
||||
|
||||
metadata["ISRC"] = track.isrc
|
||||
|
||||
if track.bpm:
|
||||
@@ -83,7 +89,7 @@ def addMetadata(
|
||||
"title": track.title,
|
||||
"tracknumber": str(track.trackNumber),
|
||||
"discnumber": str(track.volumeNumber),
|
||||
"copyright": track.copyright,
|
||||
"copyright": track.copyright if track.copyright else "",
|
||||
"albumartist": track.artist.name if track.artist else "",
|
||||
"artist": ";".join(
|
||||
[artist.name.strip() for artist in track.artists]
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Literal
|
||||
|
||||
TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"]
|
||||
TrackArg = Literal["low", "normal", "high", "master"]
|
||||
SinglesFilter = Literal["none", "only", "include"]
|
||||
|
||||
ARG_TO_QUALITY: dict[TrackArg, TrackQuality] = {
|
||||
"low": "LOW",
|
||||
|
||||
@@ -38,7 +38,7 @@ class Track(BaseModel):
|
||||
volumeNumber: int
|
||||
version: Optional[str] = None
|
||||
popularity: int
|
||||
copyright: str
|
||||
copyright: Optional[str] = None
|
||||
bpm: Optional[int] = None
|
||||
url: str
|
||||
isrc: str
|
||||
@@ -51,7 +51,7 @@ class Track(BaseModel):
|
||||
artist: Optional[Artist] = None
|
||||
artists: List[Artist]
|
||||
album: Album
|
||||
mixes: Dict[str, str]
|
||||
mixes: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class Video(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user