mirror of
https://zotify.xyz/zotify/zotify.git
synced 2025-06-23 02:26:44 +00:00
changes
This commit is contained in:
parent
a10b32b5b7
commit
360e342bc2
18 changed files with 923 additions and 463 deletions
|
@ -3,24 +3,25 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
from librespot.audio.decoders import VorbisOnlyAudioQuality
|
||||
from librespot.core import ApiClient, PlayableContentFeeder
|
||||
from librespot.core import ApiClient, ApResolver, PlayableContentFeeder
|
||||
from librespot.core import Session as LibrespotSession
|
||||
from librespot.metadata import EpisodeId, PlayableId, TrackId
|
||||
from pwinput import pwinput
|
||||
from requests import HTTPError, get
|
||||
|
||||
from zotify.loader import Loader
|
||||
from zotify.playable import Episode, Track
|
||||
from zotify.utils import API_URL, Quality
|
||||
from zotify.utils import Quality
|
||||
|
||||
API_URL = "https://api.sp" + "otify.com/v1/"
|
||||
|
||||
|
||||
class Api(ApiClient):
|
||||
def __init__(self, session: LibrespotSession, language: str = "en"):
|
||||
def __init__(self, session: Session):
|
||||
super(Api, self).__init__(session)
|
||||
self.__session = session
|
||||
self.__language = language
|
||||
|
||||
def __get_token(self) -> str:
|
||||
"""Returns user's API token"""
|
||||
return (
|
||||
self.__session.tokens()
|
||||
.get_token(
|
||||
|
@ -40,25 +41,25 @@ class Api(ApiClient):
|
|||
offset: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Requests data from api
|
||||
Requests data from API
|
||||
Args:
|
||||
url: API url and to get data from
|
||||
url: API URL and to get data from
|
||||
params: parameters to be sent in the request
|
||||
limit: The maximum number of items in the response
|
||||
offset: The offset of the items returned
|
||||
Returns:
|
||||
Dictionary representation of json response
|
||||
Dictionary representation of JSON response
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.__get_token()}",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": self.__language,
|
||||
"Accept-Language": self.__session.language(),
|
||||
"app-platform": "WebPlayer",
|
||||
}
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
response = get(url, headers=headers, params=params)
|
||||
response = get(API_URL + url, headers=headers, params=params)
|
||||
data = response.json()
|
||||
|
||||
try:
|
||||
|
@ -69,30 +70,39 @@ class Api(ApiClient):
|
|||
return data
|
||||
|
||||
|
||||
class Session:
|
||||
class Session(LibrespotSession):
|
||||
def __init__(
|
||||
self,
|
||||
librespot_session: LibrespotSession,
|
||||
language: str = "en",
|
||||
self, session_builder: LibrespotSession.Builder, language: str = "en"
|
||||
) -> None:
|
||||
"""
|
||||
Authenticates user, saves credentials to a file and generates api token.
|
||||
Args:
|
||||
session_builder: An instance of the Librespot Session.Builder
|
||||
session_builder: An instance of the Librespot Session builder
|
||||
langauge: ISO 639-1 language code
|
||||
"""
|
||||
self.__session = librespot_session
|
||||
self.__language = language
|
||||
self.__api = Api(self.__session, language)
|
||||
self.__country = self.api().invoke_url(API_URL + "me")["country"]
|
||||
with Loader("Logging in..."):
|
||||
super(Session, self).__init__(
|
||||
LibrespotSession.Inner(
|
||||
session_builder.device_type,
|
||||
session_builder.device_name,
|
||||
session_builder.preferred_locale,
|
||||
session_builder.conf,
|
||||
session_builder.device_id,
|
||||
),
|
||||
ApResolver.get_random_accesspoint(),
|
||||
)
|
||||
self.connect()
|
||||
self.authenticate(session_builder.login_credentials)
|
||||
self.__api = Api(self)
|
||||
self.__language = language
|
||||
|
||||
@staticmethod
|
||||
def from_file(cred_file: Path, langauge: str = "en") -> Session:
|
||||
def from_file(cred_file: Path, language: str = "en") -> Session:
|
||||
"""
|
||||
Creates session using saved credentials file
|
||||
Args:
|
||||
cred_file: Path to credentials file
|
||||
langauge: ISO 639-1 language code for API responses
|
||||
language: ISO 639-1 language code for API responses
|
||||
Returns:
|
||||
Zotify session
|
||||
"""
|
||||
|
@ -102,12 +112,12 @@ class Session:
|
|||
.build()
|
||||
)
|
||||
session = LibrespotSession.Builder(conf).stored_file(str(cred_file))
|
||||
return Session(session.create(), langauge)
|
||||
return Session(session, language)
|
||||
|
||||
@staticmethod
|
||||
def from_userpass(
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
username: str,
|
||||
password: str,
|
||||
save_file: Path | None = None,
|
||||
language: str = "en",
|
||||
) -> Session:
|
||||
|
@ -117,15 +127,10 @@ class Session:
|
|||
username: Account username
|
||||
password: Account password
|
||||
save_file: Path to save login credentials to, optional.
|
||||
langauge: ISO 639-1 language code for API responses
|
||||
language: ISO 639-1 language code for API responses
|
||||
Returns:
|
||||
Zotify session
|
||||
"""
|
||||
username = input("Username: ") if username == "" else username
|
||||
password = (
|
||||
pwinput(prompt="Password: ", mask="*") if password == "" else password
|
||||
)
|
||||
|
||||
builder = LibrespotSession.Configuration.Builder()
|
||||
if save_file:
|
||||
save_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
@ -136,21 +141,35 @@ class Session:
|
|||
session = LibrespotSession.Builder(builder.build()).user_pass(
|
||||
username, password
|
||||
)
|
||||
return Session(session.create(), language)
|
||||
return Session(session, language)
|
||||
|
||||
@staticmethod
|
||||
def from_prompt(save_file: Path | None = None, language: str = "en") -> Session:
|
||||
"""
|
||||
Creates a session with username + password supplied from CLI prompt
|
||||
Args:
|
||||
save_file: Path to save login credentials to, optional.
|
||||
language: ISO 639-1 language code for API responses
|
||||
Returns:
|
||||
Zotify session
|
||||
"""
|
||||
username = input("Username: ")
|
||||
password = pwinput(prompt="Password: ", mask="*")
|
||||
return Session.from_userpass(username, password, save_file, language)
|
||||
|
||||
def __get_playable(
|
||||
self, playable_id: PlayableId, quality: Quality
|
||||
) -> PlayableContentFeeder.LoadedStream:
|
||||
if quality.value is None:
|
||||
quality = Quality.VERY_HIGH if self.is_premium() else Quality.HIGH
|
||||
return self.__session.content_feeder().load(
|
||||
return self.content_feeder().load(
|
||||
playable_id,
|
||||
VorbisOnlyAudioQuality(quality.value),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
def get_track(self, track_id: TrackId, quality: Quality = Quality.AUTO) -> Track:
|
||||
def get_track(self, track_id: str, quality: Quality = Quality.AUTO) -> Track:
|
||||
"""
|
||||
Gets track/episode data and audio stream
|
||||
Args:
|
||||
|
@ -159,9 +178,11 @@ class Session:
|
|||
Returns:
|
||||
Track object
|
||||
"""
|
||||
return Track(self.__get_playable(track_id, quality), self.api())
|
||||
return Track(
|
||||
self.__get_playable(TrackId.from_base62(track_id), quality), self.api()
|
||||
)
|
||||
|
||||
def get_episode(self, episode_id: EpisodeId) -> Episode:
|
||||
def get_episode(self, episode_id: str) -> Episode:
|
||||
"""
|
||||
Gets track/episode data and audio stream
|
||||
Args:
|
||||
|
@ -169,20 +190,19 @@ class Session:
|
|||
Returns:
|
||||
Episode object
|
||||
"""
|
||||
return Episode(self.__get_playable(episode_id, Quality.NORMAL), self.api())
|
||||
return Episode(
|
||||
self.__get_playable(EpisodeId.from_base62(episode_id), Quality.NORMAL),
|
||||
self.api(),
|
||||
)
|
||||
|
||||
def api(self) -> ApiClient:
|
||||
def api(self) -> Api:
|
||||
"""Returns API Client"""
|
||||
return self.__api
|
||||
|
||||
def country(self) -> str:
|
||||
"""Returns two letter country code of user's account"""
|
||||
return self.__country
|
||||
def language(self) -> str:
|
||||
"""Returns session language"""
|
||||
return self.__language
|
||||
|
||||
def is_premium(self) -> bool:
|
||||
"""Returns users premium account status"""
|
||||
return self.__session.get_user_attribute("type") == "premium"
|
||||
|
||||
def clone(self) -> Session:
|
||||
"""Creates a copy of the session for use in a parallel thread"""
|
||||
return Session(self.__session, self.__language)
|
||||
return self.get_user_attribute("type") == "premium"
|
||||
|
|
|
@ -7,7 +7,7 @@ from zotify.app import App
|
|||
from zotify.config import CONFIG_PATHS, CONFIG_VALUES
|
||||
from zotify.utils import OptionalOrFalse, SimpleHelpFormatter
|
||||
|
||||
VERSION = "0.9.3"
|
||||
VERSION = "0.9.4"
|
||||
|
||||
|
||||
def main():
|
||||
|
@ -25,7 +25,7 @@ def main():
|
|||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Don't hide tracebacks",
|
||||
help="Display full tracebacks",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
|
@ -138,8 +138,9 @@ def main():
|
|||
from traceback import format_exc
|
||||
|
||||
print(format_exc().splitlines()[-1])
|
||||
exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("goodbye")
|
||||
exit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
444
zotify/app.py
444
zotify/app.py
|
@ -1,47 +1,33 @@
|
|||
from argparse import Namespace
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from librespot.metadata import (
|
||||
AlbumId,
|
||||
ArtistId,
|
||||
EpisodeId,
|
||||
PlayableId,
|
||||
PlaylistId,
|
||||
ShowId,
|
||||
TrackId,
|
||||
)
|
||||
from librespot.util import bytes_to_hex
|
||||
from typing import Any
|
||||
|
||||
from zotify import Session
|
||||
from zotify.collections import Album, Artist, Collection, Episode, Playlist, Show, Track
|
||||
from zotify.config import Config
|
||||
from zotify.file import TranscodingError
|
||||
from zotify.loader import Loader
|
||||
from zotify.printer import PrintChannel, Printer
|
||||
from zotify.utils import API_URL, AudioFormat, MetadataEntry, b62_to_hex
|
||||
from zotify.logger import LogChannel, Logger
|
||||
from zotify.utils import (
|
||||
AudioFormat,
|
||||
CollectionType,
|
||||
PlayableType,
|
||||
)
|
||||
|
||||
|
||||
class ParseError(ValueError):
|
||||
...
|
||||
|
||||
|
||||
class PlayableType(Enum):
|
||||
TRACK = "track"
|
||||
EPISODE = "episode"
|
||||
|
||||
|
||||
class PlayableData(NamedTuple):
|
||||
type: PlayableType
|
||||
id: PlayableId
|
||||
library: Path
|
||||
output: str
|
||||
metadata: list[MetadataEntry] = []
|
||||
class ParseError(ValueError): ...
|
||||
|
||||
|
||||
class Selection:
|
||||
def __init__(self, session: Session):
|
||||
self.__session = session
|
||||
self.__items: list[dict[str, Any]] = []
|
||||
self.__print_labels = {
|
||||
"album": ("name", "artists"),
|
||||
"playlist": ("name", "owner"),
|
||||
"track": ("title", "artists", "album"),
|
||||
"show": ("title", "creator"),
|
||||
}
|
||||
|
||||
def search(
|
||||
self,
|
||||
|
@ -57,54 +43,55 @@ class Selection:
|
|||
) -> list[str]:
|
||||
categories = ",".join(category)
|
||||
with Loader("Searching..."):
|
||||
country = self.__session.api().invoke_url("me")["country"]
|
||||
resp = self.__session.api().invoke_url(
|
||||
API_URL + "search",
|
||||
"search",
|
||||
{
|
||||
"q": search_text,
|
||||
"type": categories,
|
||||
"include_external": "audio",
|
||||
"market": self.__session.country(),
|
||||
"market": country,
|
||||
},
|
||||
limit=10,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
count = 0
|
||||
links = []
|
||||
for c in categories.split(","):
|
||||
label = c + "s"
|
||||
if len(resp[label]["items"]) > 0:
|
||||
for cat in categories.split(","):
|
||||
label = cat + "s"
|
||||
items = resp[label]["items"]
|
||||
if len(items) > 0:
|
||||
print(f"\n### {label.capitalize()} ###")
|
||||
for item in resp[label]["items"]:
|
||||
links.append(item)
|
||||
self.__print(count + 1, item)
|
||||
count += 1
|
||||
return self.__get_selection(links)
|
||||
try:
|
||||
self.__print(count, items, *self.__print_labels[cat])
|
||||
except KeyError:
|
||||
self.__print(count, items, "name")
|
||||
count += len(items)
|
||||
self.__items.extend(items)
|
||||
return self.__get_selection()
|
||||
|
||||
def get(self, category: str, name: str = "", content: str = "") -> list[str]:
|
||||
with Loader("Fetching items..."):
|
||||
r = self.__session.api().invoke_url(f"{API_URL}me/{category}", limit=50)
|
||||
r = self.__session.api().invoke_url(f"me/{category}", limit=50)
|
||||
if content != "":
|
||||
r = r[content]
|
||||
resp = r["items"]
|
||||
|
||||
items = []
|
||||
for i in range(len(resp)):
|
||||
try:
|
||||
item = resp[i][name]
|
||||
except KeyError:
|
||||
item = resp[i]
|
||||
items.append(item)
|
||||
self.__items.append(item)
|
||||
self.__print(i + 1, item)
|
||||
return self.__get_selection(items)
|
||||
return self.__get_selection()
|
||||
|
||||
@staticmethod
|
||||
def from_file(file_path: Path) -> list[str]:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return [line.strip() for line in f.readlines()]
|
||||
|
||||
@staticmethod
|
||||
def __get_selection(items: list[dict[str, Any]]) -> list[str]:
|
||||
def __get_selection(self) -> list[str]:
|
||||
print("\nResults to save (eg: 1,2,5 1-3)")
|
||||
selection = ""
|
||||
while len(selection) == 0:
|
||||
|
@ -115,64 +102,40 @@ class Selection:
|
|||
if "-" in i:
|
||||
split = i.split("-")
|
||||
for x in range(int(split[0]), int(split[1]) + 1):
|
||||
ids.append(items[x - 1]["uri"])
|
||||
ids.append(self.__items[x - 1]["uri"])
|
||||
else:
|
||||
ids.append(items[int(i) - 1]["uri"])
|
||||
ids.append(self.__items[int(i) - 1]["uri"])
|
||||
return ids
|
||||
|
||||
def __print(self, i: int, item: dict[str, Any]) -> None:
|
||||
match item["type"]:
|
||||
case "album":
|
||||
self.__print_album(i, item)
|
||||
case "playlist":
|
||||
self.__print_playlist(i, item)
|
||||
case "track":
|
||||
self.__print_track(i, item)
|
||||
case "show":
|
||||
self.__print_show(i, item)
|
||||
case _:
|
||||
print(
|
||||
"{:<2} {:<77}".format(i, self.__fix_string_length(item["name"], 77))
|
||||
def __print(self, count: int, items: list[dict[str, Any]], *args: str) -> None:
|
||||
arg_range = range(len(args))
|
||||
category_str = " " + " ".join("{:<38}" for _ in arg_range)
|
||||
print(category_str.format(*[s.upper() for s in list(args)]))
|
||||
for item in items:
|
||||
count += 1
|
||||
fmt_str = "{:<2} ".format(count) + " ".join("{:<38}" for _ in arg_range)
|
||||
fmt_vals: list[str] = []
|
||||
for arg in args:
|
||||
match arg:
|
||||
case "artists":
|
||||
fmt_vals.append(
|
||||
", ".join([artist["name"] for artist in item["artists"]])
|
||||
)
|
||||
case "owner":
|
||||
fmt_vals.append(item["owner"]["display_name"])
|
||||
case "album":
|
||||
fmt_vals.append(item["album"]["name"])
|
||||
case "creator":
|
||||
fmt_vals.append(item["publisher"])
|
||||
case "title":
|
||||
fmt_vals.append(item["name"])
|
||||
case _:
|
||||
fmt_vals.append(item[arg])
|
||||
print(
|
||||
fmt_str.format(
|
||||
*(self.__fix_string_length(fmt_vals[x], 38) for x in arg_range),
|
||||
)
|
||||
|
||||
def __print_album(self, i: int, item: dict[str, Any]) -> None:
|
||||
artists = ", ".join([artist["name"] for artist in item["artists"]])
|
||||
print(
|
||||
"{:<2} {:<38} {:<38}".format(
|
||||
i,
|
||||
self.__fix_string_length(item["name"], 38),
|
||||
self.__fix_string_length(artists, 38),
|
||||
)
|
||||
)
|
||||
|
||||
def __print_playlist(self, i: int, item: dict[str, Any]) -> None:
|
||||
print(
|
||||
"{:<2} {:<38} {:<38}".format(
|
||||
i,
|
||||
self.__fix_string_length(item["name"], 38),
|
||||
self.__fix_string_length(item["owner"]["display_name"], 38),
|
||||
)
|
||||
)
|
||||
|
||||
def __print_track(self, i: int, item: dict[str, Any]) -> None:
|
||||
artists = ", ".join([artist["name"] for artist in item["artists"]])
|
||||
print(
|
||||
"{:<2} {:<38} {:<38} {:<38}".format(
|
||||
i,
|
||||
self.__fix_string_length(item["name"], 38),
|
||||
self.__fix_string_length(artists, 38),
|
||||
self.__fix_string_length(item["album"]["name"], 38),
|
||||
)
|
||||
)
|
||||
|
||||
def __print_show(self, i: int, item: dict[str, Any]) -> None:
|
||||
print(
|
||||
"{:<2} {:<38} {:<38}".format(
|
||||
i,
|
||||
self.__fix_string_length(item["name"], 38),
|
||||
self.__fix_string_length(item["publisher"], 38),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __fix_string_length(text: str, max_length: int) -> str:
|
||||
|
@ -182,42 +145,48 @@ class Selection:
|
|||
|
||||
|
||||
class App:
|
||||
__playable_list: list[PlayableData] = []
|
||||
|
||||
def __init__(self, args: Namespace):
|
||||
self.__config = Config(args)
|
||||
Printer(self.__config)
|
||||
Logger(self.__config)
|
||||
|
||||
# Check options
|
||||
if self.__config.audio_format == AudioFormat.VORBIS and (
|
||||
self.__config.ffmpeg_args != "" or self.__config.ffmpeg_path != ""
|
||||
):
|
||||
Printer.print(
|
||||
PrintChannel.WARNINGS,
|
||||
Logger.log(
|
||||
LogChannel.WARNINGS,
|
||||
"FFmpeg options will be ignored since no transcoding is required",
|
||||
)
|
||||
|
||||
with Loader("Logging in..."):
|
||||
if (
|
||||
args.username != "" and args.password != ""
|
||||
) or not self.__config.credentials.is_file():
|
||||
self.__session = Session.from_userpass(
|
||||
args.username,
|
||||
args.password,
|
||||
self.__config.credentials,
|
||||
self.__config.language,
|
||||
)
|
||||
else:
|
||||
self.__session = Session.from_file(
|
||||
self.__config.credentials, self.__config.language
|
||||
)
|
||||
# Create session
|
||||
if args.username != "" and args.password != "":
|
||||
self.__session = Session.from_userpass(
|
||||
args.username,
|
||||
args.password,
|
||||
self.__config.credentials,
|
||||
self.__config.language,
|
||||
)
|
||||
elif self.__config.credentials.is_file():
|
||||
self.__session = Session.from_file(
|
||||
self.__config.credentials, self.__config.language
|
||||
)
|
||||
else:
|
||||
self.__session = Session.from_prompt(
|
||||
self.__config.credentials, self.__config.language
|
||||
)
|
||||
|
||||
# Get items to download
|
||||
ids = self.get_selection(args)
|
||||
with Loader("Parsing input..."):
|
||||
try:
|
||||
self.parse(ids)
|
||||
collections = self.parse(ids)
|
||||
except ParseError as e:
|
||||
Printer.print(PrintChannel.ERRORS, str(e))
|
||||
self.download_all()
|
||||
Logger.log(LogChannel.ERRORS, str(e))
|
||||
if len(collections) > 0:
|
||||
self.download_all(collections)
|
||||
else:
|
||||
Logger.log(LogChannel.WARNINGS, "there is nothing to do")
|
||||
exit(0)
|
||||
|
||||
def get_selection(self, args: Namespace) -> list[str]:
|
||||
selection = Selection(self.__session)
|
||||
|
@ -240,17 +209,14 @@ class App:
|
|||
elif args.urls:
|
||||
return args.urls
|
||||
except (FileNotFoundError, ValueError):
|
||||
Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
|
||||
Logger.log(LogChannel.WARNINGS, "there is nothing to do")
|
||||
except KeyboardInterrupt:
|
||||
Printer.print(PrintChannel.WARNINGS, "\nthere is nothing to do")
|
||||
exit()
|
||||
Logger.log(LogChannel.WARNINGS, "\nthere is nothing to do")
|
||||
exit(130)
|
||||
exit(0)
|
||||
|
||||
def parse(self, links: list[str]) -> None:
|
||||
"""
|
||||
Parses list of selected tracks/playlists/shows/etc...
|
||||
Args:
|
||||
links: List of links
|
||||
"""
|
||||
def parse(self, links: list[str]) -> list[Collection]:
|
||||
collections: list[Collection] = []
|
||||
for link in links:
|
||||
link = link.rsplit("?", 1)[0]
|
||||
try:
|
||||
|
@ -262,152 +228,92 @@ class App:
|
|||
|
||||
match id_type:
|
||||
case "album":
|
||||
self.__parse_album(b62_to_hex(_id))
|
||||
collections.append(Album(self.__session, _id))
|
||||
case "artist":
|
||||
self.__parse_artist(b62_to_hex(_id))
|
||||
collections.append(Artist(self.__session, _id))
|
||||
case "show":
|
||||
self.__parse_show(b62_to_hex(_id))
|
||||
collections.append(Show(self.__session, _id))
|
||||
case "track":
|
||||
self.__parse_track(b62_to_hex(_id))
|
||||
collections.append(Track(self.__session, _id))
|
||||
case "episode":
|
||||
self.__parse_episode(b62_to_hex(_id))
|
||||
collections.append(Episode(self.__session, _id))
|
||||
case "playlist":
|
||||
self.__parse_playlist(_id)
|
||||
collections.append(Playlist(self.__session, _id))
|
||||
case _:
|
||||
raise ParseError(f'Unknown content type "{id_type}"')
|
||||
raise ParseError(f'Unsupported content type "{id_type}"')
|
||||
return collections
|
||||
|
||||
def __parse_album(self, hex_id: str) -> None:
|
||||
album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id))
|
||||
for disc in album.disc:
|
||||
for track in disc.track:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
TrackId.from_hex(bytes_to_hex(track.gid)),
|
||||
self.__config.music_library,
|
||||
self.__config.output_album,
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_artist(self, hex_id: str) -> None:
|
||||
artist = self.__session.api().get_metadata_4_artist(ArtistId.from_hex(hex_id))
|
||||
for album_group in artist.album_group and artist.single_group:
|
||||
album = self.__session.api().get_metadata_4_album(
|
||||
AlbumId.from_hex(album_group.album[0].gid)
|
||||
)
|
||||
for disc in album.disc:
|
||||
for track in disc.track:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
TrackId.from_hex(bytes_to_hex(track.gid)),
|
||||
self.__config.music_library,
|
||||
self.__config.output_album,
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_playlist(self, b62_id: str) -> None:
|
||||
playlist = self.__session.api().get_playlist(PlaylistId(b62_id))
|
||||
for item in playlist.contents.items:
|
||||
split = item.uri.split(":")
|
||||
playable_type = PlayableType(split[1])
|
||||
id_map = {PlayableType.TRACK: TrackId, PlayableType.EPISODE: EpisodeId}
|
||||
playable_id = id_map[playable_type].from_base62(split[2])
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
playable_type,
|
||||
playable_id,
|
||||
self.__config.playlist_library,
|
||||
self.__config.get(f"output_playlist_{playable_type.value}"),
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_show(self, hex_id: str) -> None:
|
||||
show = self.__session.api().get_metadata_4_show(ShowId.from_hex(hex_id))
|
||||
for episode in show.episode:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.EPISODE,
|
||||
EpisodeId.from_hex(bytes_to_hex(episode.gid)),
|
||||
self.__config.podcast_library,
|
||||
self.__config.output_podcast,
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_track(self, hex_id: str) -> None:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
TrackId.from_hex(hex_id),
|
||||
self.__config.music_library,
|
||||
self.__config.output_album,
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_episode(self, hex_id: str) -> None:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.EPISODE,
|
||||
EpisodeId.from_hex(hex_id),
|
||||
self.__config.podcast_library,
|
||||
self.__config.output_podcast,
|
||||
)
|
||||
)
|
||||
|
||||
def get_playable_list(self) -> list[PlayableData]:
|
||||
"""Returns list of Playable items"""
|
||||
return self.__playable_list
|
||||
|
||||
def download_all(self) -> None:
|
||||
def download_all(self, collections: list[Collection]) -> None:
|
||||
"""Downloads playable to local file"""
|
||||
for playable in self.__playable_list:
|
||||
self.__download(playable)
|
||||
for collection in collections:
|
||||
for i in range(len(collection.playables)):
|
||||
playable = collection.playables[i]
|
||||
|
||||
def __download(self, playable: PlayableData) -> None:
|
||||
if playable.type == PlayableType.TRACK:
|
||||
with Loader("Fetching track..."):
|
||||
track = self.__session.get_track(
|
||||
playable.id, self.__config.download_quality
|
||||
)
|
||||
elif playable.type == PlayableType.EPISODE:
|
||||
with Loader("Fetching episode..."):
|
||||
track = self.__session.get_episode(playable.id)
|
||||
else:
|
||||
Printer.print(
|
||||
PrintChannel.SKIPS,
|
||||
f'Download Error: Unknown playable content "{playable.type}"',
|
||||
)
|
||||
return
|
||||
|
||||
output = track.create_output(playable.library, playable.output)
|
||||
file = track.write_audio_stream(
|
||||
output,
|
||||
self.__config.chunk_size,
|
||||
)
|
||||
|
||||
if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
|
||||
with Loader("Fetching lyrics..."):
|
||||
try:
|
||||
track.get_lyrics().save(output)
|
||||
except FileNotFoundError as e:
|
||||
Printer.print(PrintChannel.SKIPS, str(e))
|
||||
|
||||
Printer.print(PrintChannel.DOWNLOADS, f"\nDownloaded {track.name}")
|
||||
|
||||
if self.__config.audio_format != AudioFormat.VORBIS:
|
||||
try:
|
||||
with Loader(PrintChannel.PROGRESS, "Converting audio..."):
|
||||
file.transcode(
|
||||
self.__config.audio_format,
|
||||
self.__config.transcode_bitrate,
|
||||
True,
|
||||
self.__config.ffmpeg_path,
|
||||
self.__config.ffmpeg_args.split(),
|
||||
# Get track data
|
||||
if playable.type == PlayableType.TRACK:
|
||||
with Loader("Fetching track..."):
|
||||
track = self.__session.get_track(
|
||||
playable.id, self.__config.download_quality
|
||||
)
|
||||
elif playable.type == PlayableType.EPISODE:
|
||||
with Loader("Fetching episode..."):
|
||||
track = self.__session.get_episode(playable.id)
|
||||
else:
|
||||
Logger.log(
|
||||
LogChannel.SKIPS,
|
||||
f'Download Error: Unknown playable content "{playable.type}"',
|
||||
)
|
||||
except TranscodingError as e:
|
||||
Printer.print(PrintChannel.ERRORS, str(e))
|
||||
return
|
||||
|
||||
if self.__config.save_metadata:
|
||||
with Loader("Writing metadata..."):
|
||||
file.write_metadata(track.metadata)
|
||||
file.write_cover_art(track.get_cover_art(self.__config.artwork_size))
|
||||
# Create download location and generate file name
|
||||
match collection.type():
|
||||
case CollectionType.PLAYLIST:
|
||||
# TODO: add playlist name to track metadata
|
||||
library = self.__config.playlist_library
|
||||
template = (
|
||||
self.__config.output_playlist_track
|
||||
if playable.type == PlayableType.TRACK
|
||||
else self.__config.output_playlist_episode
|
||||
)
|
||||
case CollectionType.SHOW | CollectionType.EPISODE:
|
||||
library = self.__config.podcast_library
|
||||
template = self.__config.output_podcast
|
||||
case _:
|
||||
library = self.__config.music_library
|
||||
template = self.__config.output_album
|
||||
output = track.create_output(
|
||||
library, template, self.__config.replace_existing
|
||||
)
|
||||
|
||||
file = track.write_audio_stream(output)
|
||||
|
||||
# Download lyrics
|
||||
if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
|
||||
with Loader("Fetching lyrics..."):
|
||||
try:
|
||||
track.get_lyrics().save(output)
|
||||
except FileNotFoundError as e:
|
||||
Logger.log(LogChannel.SKIPS, str(e))
|
||||
Logger.log(LogChannel.DOWNLOADS, f"\nDownloaded {track.name}")
|
||||
|
||||
# Transcode audio
|
||||
if self.__config.audio_format != AudioFormat.VORBIS:
|
||||
try:
|
||||
with Loader(LogChannel.PROGRESS, "Converting audio..."):
|
||||
file.transcode(
|
||||
self.__config.audio_format,
|
||||
self.__config.transcode_bitrate,
|
||||
True,
|
||||
self.__config.ffmpeg_path,
|
||||
self.__config.ffmpeg_args.split(),
|
||||
)
|
||||
except TranscodingError as e:
|
||||
Logger.log(LogChannel.ERRORS, str(e))
|
||||
|
||||
# Write metadata
|
||||
if self.__config.save_metadata:
|
||||
with Loader("Writing metadata..."):
|
||||
file.write_metadata(track.metadata)
|
||||
file.write_cover_art(
|
||||
track.get_cover_art(self.__config.artwork_size)
|
||||
)
|
||||
|
|
95
zotify/collections.py
Normal file
95
zotify/collections.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
from librespot.metadata import (
|
||||
AlbumId,
|
||||
ArtistId,
|
||||
PlaylistId,
|
||||
ShowId,
|
||||
)
|
||||
|
||||
from zotify import Session
|
||||
from zotify.utils import CollectionType, PlayableData, PlayableType, bytes_to_base62
|
||||
|
||||
|
||||
class Collection:
|
||||
playables: list[PlayableData] = []
|
||||
|
||||
def type(self) -> CollectionType:
|
||||
return CollectionType(self.__class__.__name__.lower())
|
||||
|
||||
|
||||
class Album(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
album = session.api().get_metadata_4_album(AlbumId.from_base62(b62_id))
|
||||
for disc in album.disc:
|
||||
for track in disc.track:
|
||||
self.playables.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
bytes_to_base62(track.gid),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Artist(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
artist = session.api().get_metadata_4_artist(ArtistId.from_base62(b62_id))
|
||||
for album_group in (
|
||||
artist.album_group
|
||||
and artist.single_group
|
||||
and artist.compilation_group
|
||||
and artist.appears_on_group
|
||||
):
|
||||
album = session.api().get_metadata_4_album(
|
||||
AlbumId.from_hex(album_group.album[0].gid)
|
||||
)
|
||||
for disc in album.disc:
|
||||
for track in disc.track:
|
||||
self.playables.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
bytes_to_base62(track.gid),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Show(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
show = session.api().get_metadata_4_show(ShowId.from_base62(b62_id))
|
||||
for episode in show.episode:
|
||||
self.playables.append(
|
||||
PlayableData(PlayableType.EPISODE, bytes_to_base62(episode.gid))
|
||||
)
|
||||
|
||||
|
||||
class Playlist(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
playlist = session.api().get_playlist(PlaylistId(b62_id))
|
||||
# self.name = playlist.title
|
||||
for item in playlist.contents.items:
|
||||
split = item.uri.split(":")
|
||||
playable_type = split[1]
|
||||
if playable_type == "track":
|
||||
self.playables.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
split[2],
|
||||
)
|
||||
)
|
||||
elif playable_type == "episode":
|
||||
self.playables.append(
|
||||
PlayableData(
|
||||
PlayableType.EPISODE,
|
||||
split[2],
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError("Unknown playable content", playable_type)
|
||||
|
||||
|
||||
class Track(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
self.playables.append(PlayableData(PlayableType.TRACK, b62_id))
|
||||
|
||||
|
||||
class Episode(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
self.playables.append(PlayableData(PlayableType.EPISODE, b62_id))
|
|
@ -10,7 +10,6 @@ from zotify.utils import AudioFormat, ImageSize, Quality
|
|||
ALL_ARTISTS = "all_artists"
|
||||
ARTWORK_SIZE = "artwork_size"
|
||||
AUDIO_FORMAT = "audio_format"
|
||||
CHUNK_SIZE = "chunk_size"
|
||||
CREATE_PLAYLIST_FILE = "create_playlist_file"
|
||||
CREDENTIALS = "credentials"
|
||||
DOWNLOAD_QUALITY = "download_quality"
|
||||
|
@ -64,8 +63,8 @@ CONFIG_PATHS = {
|
|||
OUTPUT_PATHS = {
|
||||
"album": "{album_artist}/{album}/{track_number}. {artists} - {title}",
|
||||
"podcast": "{podcast}/{episode_number} - {title}",
|
||||
"playlist_track": "{playlist}/{playlist_number}. {artists} - {title}",
|
||||
"playlist_episode": "{playlist}/{playlist_number}. {episode_number} - {title}",
|
||||
"playlist_track": "{playlist}/{artists} - {title}",
|
||||
"playlist_episode": "{playlist}/{episode_number} - {title}",
|
||||
}
|
||||
|
||||
CONFIG_VALUES = {
|
||||
|
@ -222,12 +221,6 @@ CONFIG_VALUES = {
|
|||
"args": ["--skip-duplicates"],
|
||||
"help": "Skip downloading existing track to different album",
|
||||
},
|
||||
CHUNK_SIZE: {
|
||||
"default": 16384,
|
||||
"type": int,
|
||||
"args": ["--chunk-size"],
|
||||
"help": "Number of bytes read at a time during download",
|
||||
},
|
||||
PRINT_DOWNLOADS: {
|
||||
"default": False,
|
||||
"type": bool,
|
||||
|
@ -265,7 +258,6 @@ class Config:
|
|||
__config_file: Path | None
|
||||
artwork_size: ImageSize
|
||||
audio_format: AudioFormat
|
||||
chunk_size: int
|
||||
credentials: Path
|
||||
download_quality: Quality
|
||||
ffmpeg_args: str
|
||||
|
@ -274,13 +266,13 @@ class Config:
|
|||
language: str
|
||||
lyrics_file: bool
|
||||
output_album: str
|
||||
output_liked: str
|
||||
output_podcast: str
|
||||
output_playlist_track: str
|
||||
output_playlist_episode: str
|
||||
playlist_library: Path
|
||||
podcast_library: Path
|
||||
print_progress: bool
|
||||
replace_existing: bool
|
||||
save_metadata: bool
|
||||
transcode_bitrate: int
|
||||
|
||||
|
@ -323,14 +315,14 @@ class Config:
|
|||
|
||||
# "library" arg overrides all *_library options
|
||||
if args.library:
|
||||
self.music_library = args.library
|
||||
self.playlist_library = args.library
|
||||
self.podcast_library = args.library
|
||||
print("args.library")
|
||||
self.music_library = Path(args.library).expanduser().resolve()
|
||||
self.playlist_library = Path(args.library).expanduser().resolve()
|
||||
self.podcast_library = Path(args.library).expanduser().resolve()
|
||||
|
||||
# "output" arg overrides all output_* options
|
||||
if args.output:
|
||||
self.output_album = args.output
|
||||
self.output_liked = args.output
|
||||
self.output_podcast = args.output
|
||||
self.output_playlist_track = args.output
|
||||
self.output_playlist_episode = args.output
|
||||
|
@ -338,10 +330,10 @@ class Config:
|
|||
@staticmethod
|
||||
def __parse_arg_value(key: str, value: Any) -> Any:
|
||||
config_type = CONFIG_VALUES[key]["type"]
|
||||
if type(value) == config_type:
|
||||
if type(value) is config_type:
|
||||
return value
|
||||
elif config_type == Path:
|
||||
return Path(value).expanduser()
|
||||
return Path(value).expanduser().resolve()
|
||||
elif config_type == AudioFormat:
|
||||
return AudioFormat[value.upper()]
|
||||
elif config_type == ImageSize.from_string:
|
||||
|
|
|
@ -8,8 +8,7 @@ from mutagen.oggvorbis import OggVorbisHeaderError
|
|||
from zotify.utils import AudioFormat, MetadataEntry
|
||||
|
||||
|
||||
class TranscodingError(RuntimeError):
|
||||
...
|
||||
class TranscodingError(RuntimeError): ...
|
||||
|
||||
|
||||
class LocalFile:
|
||||
|
|
|
@ -8,7 +8,7 @@ from sys import platform
|
|||
from threading import Thread
|
||||
from time import sleep
|
||||
|
||||
from zotify.printer import Printer
|
||||
from zotify.logger import Logger
|
||||
|
||||
|
||||
class Loader:
|
||||
|
@ -50,7 +50,7 @@ class Loader:
|
|||
for c in cycle(self.steps):
|
||||
if self.done:
|
||||
break
|
||||
Printer.print_loader(f"\r {c} {self.desc} ")
|
||||
Logger.print_loader(f"\r {c} {self.desc} ")
|
||||
sleep(self.timeout)
|
||||
|
||||
def __enter__(self) -> None:
|
||||
|
@ -59,10 +59,10 @@ class Loader:
|
|||
def stop(self) -> None:
|
||||
self.done = True
|
||||
cols = get_terminal_size((80, 20)).columns
|
||||
Printer.print_loader("\r" + " " * cols)
|
||||
Logger.print_loader("\r" + " " * cols)
|
||||
|
||||
if self.end != "":
|
||||
Printer.print_loader(f"\r{self.end}")
|
||||
Logger.print_loader(f"\r{self.end}")
|
||||
|
||||
def __exit__(self, exc_type, exc_value, tb) -> None:
|
||||
# handle exceptions with those variables ^
|
||||
|
|
|
@ -13,7 +13,7 @@ from zotify.config import (
|
|||
)
|
||||
|
||||
|
||||
class PrintChannel(Enum):
|
||||
class LogChannel(Enum):
|
||||
SKIPS = PRINT_SKIPS
|
||||
PROGRESS = PRINT_PROGRESS
|
||||
ERRORS = PRINT_ERRORS
|
||||
|
@ -21,7 +21,7 @@ class PrintChannel(Enum):
|
|||
DOWNLOADS = PRINT_DOWNLOADS
|
||||
|
||||
|
||||
class Printer:
|
||||
class Logger:
|
||||
__config: Config
|
||||
|
||||
@classmethod
|
||||
|
@ -29,15 +29,15 @@ class Printer:
|
|||
cls.__config = config
|
||||
|
||||
@classmethod
|
||||
def print(cls, channel: PrintChannel, msg: str) -> None:
|
||||
def log(cls, channel: LogChannel, msg: str) -> None:
|
||||
"""
|
||||
Prints a message to console if the print channel is enabled
|
||||
Args:
|
||||
channel: PrintChannel to print to
|
||||
msg: Message to print
|
||||
channel: LogChannel to print to
|
||||
msg: Message to log
|
||||
"""
|
||||
if cls.__config.get(channel.value):
|
||||
if channel == PrintChannel.ERRORS:
|
||||
if channel == LogChannel.ERRORS:
|
||||
print(msg, file=stderr)
|
||||
else:
|
||||
print(msg)
|
||||
|
@ -76,7 +76,7 @@ class Printer:
|
|||
"""
|
||||
Prints animated loading symbol
|
||||
Args:
|
||||
msg: Message to print
|
||||
msg: Message to display
|
||||
"""
|
||||
if cls.__config.print_progress:
|
||||
print(msg, flush=True, end="")
|
|
@ -3,37 +3,40 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from librespot.core import PlayableContentFeeder
|
||||
from librespot.metadata import AlbumId
|
||||
from librespot.structure import GeneralAudioStream
|
||||
from librespot.util import bytes_to_hex
|
||||
from requests import get
|
||||
|
||||
from zotify.file import LocalFile
|
||||
from zotify.printer import Printer
|
||||
from zotify.logger import Logger
|
||||
from zotify.utils import (
|
||||
IMG_URL,
|
||||
LYRICS_URL,
|
||||
AudioFormat,
|
||||
ImageSize,
|
||||
MetadataEntry,
|
||||
PlayableType,
|
||||
bytes_to_base62,
|
||||
fix_filename,
|
||||
)
|
||||
|
||||
IMG_URL = "https://i.s" + "cdn.co/image/"
|
||||
LYRICS_URL = "https://sp" + "client.wg.sp" + "otify.com/color-lyrics/v2/track/"
|
||||
|
||||
|
||||
class Lyrics:
|
||||
def __init__(self, lyrics: dict, **kwargs):
|
||||
self.lines = []
|
||||
self.sync_type = lyrics["syncType"]
|
||||
self.__lines = []
|
||||
self.__sync_type = lyrics["syncType"]
|
||||
for line in lyrics["lines"]:
|
||||
self.lines.append(line["words"] + "\n")
|
||||
if self.sync_type == "line_synced":
|
||||
self.lines_synced = []
|
||||
self.__lines.append(line["words"] + "\n")
|
||||
if self.__sync_type == "line_synced":
|
||||
self.__lines_synced = []
|
||||
for line in lyrics["lines"]:
|
||||
timestamp = int(line["start_time_ms"])
|
||||
ts_minutes = str(floor(timestamp / 60000)).zfill(2)
|
||||
ts_seconds = str(floor((timestamp % 60000) / 1000)).zfill(2)
|
||||
ts_millis = str(floor(timestamp % 1000))[:2].zfill(2)
|
||||
self.lines_synced.append(
|
||||
self.__lines_synced.append(
|
||||
f"[{ts_minutes}:{ts_seconds}.{ts_millis}]{line.words}\n"
|
||||
)
|
||||
|
||||
|
@ -44,21 +47,24 @@ class Lyrics:
|
|||
location: path to target lyrics file
|
||||
prefer_synced: Use line synced lyrics if available
|
||||
"""
|
||||
if self.sync_type == "line_synced" and prefer_synced:
|
||||
if self.__sync_type == "line_synced" and prefer_synced:
|
||||
with open(f"{path}.lrc", "w+", encoding="utf-8") as f:
|
||||
f.writelines(self.lines_synced)
|
||||
f.writelines(self.__lines_synced)
|
||||
else:
|
||||
with open(f"{path}.txt", "w+", encoding="utf-8") as f:
|
||||
f.writelines(self.lines[:-1])
|
||||
f.writelines(self.__lines[:-1])
|
||||
|
||||
|
||||
class Playable:
|
||||
cover_images: list[Any]
|
||||
input_stream: GeneralAudioStream
|
||||
metadata: list[MetadataEntry]
|
||||
name: str
|
||||
input_stream: GeneralAudioStream
|
||||
type: PlayableType
|
||||
|
||||
def create_output(self, library: Path, output: str, replace: bool = False) -> Path:
|
||||
def create_output(
|
||||
self, library: Path = Path("./"), output: str = "{title}", replace: bool = False
|
||||
) -> Path:
|
||||
"""
|
||||
Creates save directory for the output file
|
||||
Args:
|
||||
|
@ -68,9 +74,11 @@ class Playable:
|
|||
Returns:
|
||||
File path for the track
|
||||
"""
|
||||
for m in self.metadata:
|
||||
if m.output is not None:
|
||||
output = output.replace("{" + m.name + "}", fix_filename(m.output))
|
||||
for meta in self.metadata:
|
||||
if meta.string is not None:
|
||||
output = output.replace(
|
||||
"{" + meta.name + "}", fix_filename(meta.string)
|
||||
)
|
||||
file_path = library.joinpath(output).expanduser()
|
||||
if file_path.exists() and not replace:
|
||||
raise FileExistsError("File already downloaded")
|
||||
|
@ -81,18 +89,16 @@ class Playable:
|
|||
def write_audio_stream(
|
||||
self,
|
||||
output: Path,
|
||||
chunk_size: int = 128 * 1024,
|
||||
) -> LocalFile:
|
||||
"""
|
||||
Writes audio stream to file
|
||||
Args:
|
||||
output: File path of saved audio stream
|
||||
chunk_size: maximum number of bytes to read at a time
|
||||
Returns:
|
||||
LocalFile object
|
||||
"""
|
||||
file = f"{output}.ogg"
|
||||
with open(file, "wb") as f, Printer.progress(
|
||||
with open(file, "wb") as f, Logger.progress(
|
||||
desc=self.name,
|
||||
total=self.input_stream.size,
|
||||
unit="B",
|
||||
|
@ -103,7 +109,7 @@ class Playable:
|
|||
) as p_bar:
|
||||
chunk = None
|
||||
while chunk != b"":
|
||||
chunk = self.input_stream.stream().read(chunk_size)
|
||||
chunk = self.input_stream.stream().read(1024)
|
||||
p_bar.update(f.write(chunk))
|
||||
return LocalFile(Path(file), AudioFormat.VORBIS)
|
||||
|
||||
|
@ -121,8 +127,6 @@ class Playable:
|
|||
|
||||
|
||||
class Track(PlayableContentFeeder.LoadedStream, Playable):
|
||||
lyrics: Lyrics
|
||||
|
||||
def __init__(self, track: PlayableContentFeeder.LoadedStream, api):
|
||||
super(Track, self).__init__(
|
||||
track.track,
|
||||
|
@ -131,8 +135,10 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
|
|||
track.metrics,
|
||||
)
|
||||
self.__api = api
|
||||
self.__lyrics: Lyrics
|
||||
self.cover_images = self.album.cover_group.image
|
||||
self.metadata = self.__default_metadata()
|
||||
self.type = PlayableType.TRACK
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
|
@ -142,6 +148,10 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
|
|||
|
||||
def __default_metadata(self) -> list[MetadataEntry]:
|
||||
date = self.album.date
|
||||
if not hasattr(self.album, "genre"):
|
||||
self.track.album = self.__api().get_metadata_4_album(
|
||||
AlbumId.from_hex(bytes_to_hex(self.album.gid))
|
||||
)
|
||||
return [
|
||||
MetadataEntry("album", self.album.name),
|
||||
MetadataEntry("album_artist", [a.name for a in self.album.artist]),
|
||||
|
@ -155,6 +165,7 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
|
|||
MetadataEntry("popularity", int(self.popularity * 255) / 100),
|
||||
MetadataEntry("track_number", self.number, str(self.number).zfill(2)),
|
||||
MetadataEntry("title", self.name),
|
||||
MetadataEntry("year", date.year),
|
||||
MetadataEntry(
|
||||
"replaygain_track_gain", self.normalization_data.track_gain_db, ""
|
||||
),
|
||||
|
@ -169,21 +180,21 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
|
|||
),
|
||||
]
|
||||
|
||||
def get_lyrics(self) -> Lyrics:
|
||||
def lyrics(self) -> Lyrics:
|
||||
"""Returns track lyrics if available"""
|
||||
if not self.track.has_lyrics:
|
||||
raise FileNotFoundError(
|
||||
f"No lyrics available for {self.track.artist[0].name} - {self.track.name}"
|
||||
)
|
||||
try:
|
||||
return self.lyrics
|
||||
return self.__lyrics
|
||||
except AttributeError:
|
||||
self.lyrics = Lyrics(
|
||||
self.__lyrics = Lyrics(
|
||||
self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.gid))[
|
||||
"lyrics"
|
||||
]
|
||||
)
|
||||
return self.lyrics
|
||||
return self.__lyrics
|
||||
|
||||
|
||||
class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
||||
|
@ -197,6 +208,7 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
|||
self.__api = api
|
||||
self.cover_images = self.episode.cover_image.image
|
||||
self.metadata = self.__default_metadata()
|
||||
self.type = PlayableType.EPISODE
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
|
@ -216,23 +228,21 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
|||
MetadataEntry("title", self.name),
|
||||
]
|
||||
|
||||
def write_audio_stream(
|
||||
self, output: Path, chunk_size: int = 128 * 1024
|
||||
) -> LocalFile:
|
||||
def write_audio_stream(self, output: Path) -> LocalFile:
|
||||
"""
|
||||
Writes audio stream to file
|
||||
Writes audio stream to file.
|
||||
Uses external source if available for faster download.
|
||||
Args:
|
||||
output: File path of saved audio stream
|
||||
chunk_size: maximum number of bytes to read at a time
|
||||
Returns:
|
||||
LocalFile object
|
||||
"""
|
||||
if not bool(self.external_url):
|
||||
return super().write_audio_stream(output, chunk_size)
|
||||
return super().write_audio_stream(output)
|
||||
file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
|
||||
with get(self.external_url, stream=True) as r, open(
|
||||
file, "wb"
|
||||
) as f, Printer.progress(
|
||||
) as f, Logger.progress(
|
||||
desc=self.name,
|
||||
total=self.input_stream.size,
|
||||
unit="B",
|
||||
|
@ -241,6 +251,6 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
|||
position=0,
|
||||
leave=False,
|
||||
) as p_bar:
|
||||
for chunk in r.iter_content(chunk_size=chunk_size):
|
||||
for chunk in r.iter_content(chunk_size=1024):
|
||||
p_bar.update(f.write(chunk))
|
||||
return LocalFile(Path(file))
|
||||
|
|
|
@ -7,12 +7,8 @@ from sys import stderr
|
|||
from typing import Any, NamedTuple
|
||||
|
||||
from librespot.audio.decoders import AudioQuality
|
||||
from librespot.util import Base62, bytes_to_hex
|
||||
from requests import get
|
||||
from librespot.util import Base62
|
||||
|
||||
API_URL = "https://api.sp" + "otify.com/v1/"
|
||||
IMG_URL = "https://i.s" + "cdn.co/image/"
|
||||
LYRICS_URL = "https://sp" + "client.wg.sp" + "otify.com/color-lyrics/v2/track/"
|
||||
BASE62 = Base62.create_instance_with_inverted_character_set()
|
||||
|
||||
|
||||
|
@ -74,30 +70,47 @@ class ImageSize(IntEnum):
|
|||
class MetadataEntry:
|
||||
name: str
|
||||
value: Any
|
||||
output: str
|
||||
string: str
|
||||
|
||||
def __init__(self, name: str, value: Any, output_value: str | None = None):
|
||||
def __init__(self, name: str, value: Any, string_value: str | None = None):
|
||||
"""
|
||||
Holds metadata entries
|
||||
args:
|
||||
name: name of metadata key
|
||||
value: Value to use in metadata tags
|
||||
output_value: Value when used in output formatting, if none is provided
|
||||
string_value: Value when used in output formatting, if none is provided
|
||||
will use value from previous argument.
|
||||
"""
|
||||
self.name = name
|
||||
|
||||
if type(value) == list:
|
||||
if isinstance(value, tuple):
|
||||
value = "\0".join(value)
|
||||
self.value = value
|
||||
|
||||
if output_value is None:
|
||||
output_value = self.value
|
||||
elif output_value == "":
|
||||
output_value = None
|
||||
if type(output_value) == list:
|
||||
output_value = ", ".join(output_value)
|
||||
self.output = str(output_value)
|
||||
if string_value is None:
|
||||
string_value = self.value
|
||||
if isinstance(string_value, list):
|
||||
string_value = ", ".join(string_value)
|
||||
self.string = str(string_value)
|
||||
|
||||
|
||||
class CollectionType(Enum):
|
||||
ALBUM = "album"
|
||||
ARTIST = "artist"
|
||||
SHOW = "show"
|
||||
PLAYLIST = "playlist"
|
||||
TRACK = "track"
|
||||
EPISODE = "episode"
|
||||
|
||||
|
||||
class PlayableType(Enum):
|
||||
TRACK = "track"
|
||||
EPISODE = "episode"
|
||||
|
||||
|
||||
class PlayableData(NamedTuple):
|
||||
type: PlayableType
|
||||
id: str
|
||||
|
||||
|
||||
class SimpleHelpFormatter(HelpFormatter):
|
||||
|
@ -147,7 +160,14 @@ class OptionalOrFalse(Action):
|
|||
setattr(
|
||||
namespace,
|
||||
self.dest,
|
||||
True if not option_string.startswith("--no-") else False,
|
||||
(
|
||||
True
|
||||
if not (
|
||||
option_string.startswith("--no-")
|
||||
or option_string.startswith("--dont-")
|
||||
)
|
||||
else False
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@ -172,29 +192,12 @@ def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM)
|
|||
return sub(regex, substitute, str(filename), flags=IGNORECASE)
|
||||
|
||||
|
||||
def download_cover_art(images: list, size: ImageSize) -> bytes:
|
||||
"""
|
||||
Returns image data of cover art
|
||||
Args:
|
||||
images: list of retrievable images
|
||||
size: Desired size in pixels of cover art, can be 640, 300, or 64
|
||||
Returns:
|
||||
Image data of cover art
|
||||
"""
|
||||
return get(images[size.value]["url"]).content
|
||||
|
||||
|
||||
def str_to_bool(value: str) -> bool:
|
||||
if value.lower() in ["yes", "y", "true"]:
|
||||
return True
|
||||
if value.lower() in ["no", "n", "false"]:
|
||||
return False
|
||||
raise TypeError("Not a boolean: " + value)
|
||||
|
||||
|
||||
def bytes_to_base62(id: bytes) -> str:
|
||||
"""
|
||||
Converts bytes to base62
|
||||
Args:
|
||||
id: bytes
|
||||
Returns:
|
||||
base62
|
||||
"""
|
||||
return BASE62.encode(id, 22).decode()
|
||||
|
||||
|
||||
def b62_to_hex(base62: str) -> str:
|
||||
return bytes_to_hex(BASE62.decode(base62.encode(), 16))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue