Better search
This commit is contained in:
parent
8d8d173a78
commit
2908dadc5b
6 changed files with 161 additions and 100 deletions
|
@ -21,7 +21,7 @@ Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https:/
|
||||||
*Non-premium accounts are limited to 160kbps
|
*Non-premium accounts are limited to 160kbps
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
Requires Python 3.9 or greater. \
|
Requires Python 3.10 or greater. \
|
||||||
Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
|
Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
|
||||||
|
|
||||||
Enter the following command in terminal to install Zotify. \
|
Enter the following command in terminal to install Zotify. \
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
librespot@https://github.com/kokarare1212/librespot-python/archive/refs/heads/main.zip
|
librespot>=0.0.9
|
||||||
music-tag
|
music-tag
|
||||||
mutagen
|
mutagen
|
||||||
Pillow
|
Pillow
|
||||||
|
|
|
@ -17,9 +17,9 @@ classifiers =
|
||||||
|
|
||||||
[options]
|
[options]
|
||||||
packages = zotify
|
packages = zotify
|
||||||
python_requires = >=3.9
|
python_requires = >=3.10
|
||||||
install_requires =
|
install_requires =
|
||||||
librespot@https://github.com/kokarare1212/librespot-python/archive/refs/heads/main.zip
|
librespot>=0.0.9
|
||||||
music-tag
|
music-tag
|
||||||
mutagen
|
mutagen
|
||||||
Pillow
|
Pillow
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from zotify.app import client
|
from zotify.app import App
|
||||||
from zotify.config import CONFIG_PATHS, CONFIG_VALUES
|
from zotify.config import CONFIG_PATHS, CONFIG_VALUES
|
||||||
from zotify.utils import OptionalOrFalse
|
from zotify.utils import OptionalOrFalse
|
||||||
|
|
||||||
|
@ -118,7 +118,7 @@ def main():
|
||||||
help=v["help"],
|
help=v["help"],
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.set_defaults(func=client)
|
parser.set_defaults(func=App)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.version:
|
if args.version:
|
||||||
print(VERSION)
|
print(VERSION)
|
||||||
|
|
243
zotify/app.py
243
zotify/app.py
|
@ -23,46 +23,20 @@ from zotify.printer import Printer, PrintChannel
|
||||||
from zotify.utils import API_URL, AudioFormat, b62_to_hex
|
from zotify.utils import API_URL, AudioFormat, b62_to_hex
|
||||||
|
|
||||||
|
|
||||||
def client(args: Namespace) -> None:
|
class ParsingError(RuntimeError):
|
||||||
config = Config(args)
|
...
|
||||||
Printer(config)
|
|
||||||
with Loader("Logging in..."):
|
|
||||||
if config.credentials is False:
|
|
||||||
session = Session()
|
|
||||||
else:
|
|
||||||
session = Session(
|
|
||||||
cred_file=config.credentials, save=True, language=config.language
|
|
||||||
)
|
|
||||||
selection = Selection(session)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if args.search:
|
|
||||||
ids = selection.search(args.search, args.category)
|
|
||||||
elif args.playlist:
|
|
||||||
ids = selection.get("playlists", "items")
|
|
||||||
elif args.followed:
|
|
||||||
ids = selection.get("following?type=artist", "artists")
|
|
||||||
elif args.liked_tracks:
|
|
||||||
ids = selection.get("tracks", "items")
|
|
||||||
elif args.liked_episodes:
|
|
||||||
ids = selection.get("episodes", "items")
|
|
||||||
elif args.download:
|
|
||||||
ids = []
|
|
||||||
for x in args.download:
|
|
||||||
ids.extend(selection.from_file(x))
|
|
||||||
elif args.urls:
|
|
||||||
ids = args.urls
|
|
||||||
except (FileNotFoundError, ValueError):
|
|
||||||
Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
|
|
||||||
return
|
|
||||||
|
|
||||||
app = App(config, session)
|
class PlayableType(Enum):
|
||||||
with Loader("Parsing input..."):
|
TRACK = "track"
|
||||||
try:
|
EPISODE = "episode"
|
||||||
app.parse(ids)
|
|
||||||
except (IndexError, TypeError) as e:
|
|
||||||
Printer.print(PrintChannel.ERRORS, str(e))
|
class PlayableData(NamedTuple):
|
||||||
app.download()
|
type: PlayableType
|
||||||
|
id: PlayableId
|
||||||
|
library: Path
|
||||||
|
output: str
|
||||||
|
|
||||||
|
|
||||||
class Selection:
|
class Selection:
|
||||||
|
@ -119,7 +93,7 @@ class Selection:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_selection(items: list[dict[str, Any]]) -> list[str]:
|
def __get_selection(items: list[dict[str, Any]]) -> list[str]:
|
||||||
print("\nResults to save (eg: 1,2,3 1-3)")
|
print("\nResults to save (eg: 1,2,5 1-3)")
|
||||||
selection = ""
|
selection = ""
|
||||||
while len(selection) == 0:
|
while len(selection) == 0:
|
||||||
selection = input("==> ")
|
selection = input("==> ")
|
||||||
|
@ -134,34 +108,155 @@ class Selection:
|
||||||
ids.append(items[int(i) - 1]["uri"])
|
ids.append(items[int(i) - 1]["uri"])
|
||||||
return ids
|
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_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
|
@staticmethod
|
||||||
def __print(i: int, item: dict[str, Any]) -> None:
|
def __fix_string_length(text: str, max_length: int) -> str:
|
||||||
print("{:<2} {:<77}".format(i, item["name"]))
|
if len(text) > max_length:
|
||||||
|
return text[: max_length - 3] + "..."
|
||||||
|
return text
|
||||||
class PlayableType(Enum):
|
|
||||||
TRACK = "track"
|
|
||||||
EPISODE = "episode"
|
|
||||||
|
|
||||||
|
|
||||||
class PlayableData(NamedTuple):
|
|
||||||
type: PlayableType
|
|
||||||
id: PlayableId
|
|
||||||
library: Path
|
|
||||||
output: str
|
|
||||||
|
|
||||||
|
|
||||||
class App:
|
class App:
|
||||||
__playable_list: list[PlayableData]
|
__config: Config
|
||||||
|
__session: Session
|
||||||
|
__playable_list: list[PlayableData] = []
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: Config,
|
args: Namespace,
|
||||||
session: Session,
|
|
||||||
):
|
):
|
||||||
self.__config = config
|
self.__config = Config(args)
|
||||||
self.__session = session
|
Printer(self.__config)
|
||||||
self.__playable_list = []
|
|
||||||
|
if self.__config.audio_format == AudioFormat.VORBIS and (self.__config.ffmpeg_args != "" or self.__config.ffmpeg_path != ""):
|
||||||
|
Printer.print(PrintChannel.WARNINGS, "FFmpeg options will be ignored since no transcoding is required")
|
||||||
|
|
||||||
|
with Loader("Logging in..."):
|
||||||
|
if self.__config.credentials is False:
|
||||||
|
self.__session = Session()
|
||||||
|
else:
|
||||||
|
self.__session = Session(
|
||||||
|
cred_file=self.__config.credentials,
|
||||||
|
save=True,
|
||||||
|
language=self.__config.language,
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = self.get_selection(args)
|
||||||
|
with Loader("Parsing input..."):
|
||||||
|
try:
|
||||||
|
self.parse(ids)
|
||||||
|
except (IndexError, TypeError) as e:
|
||||||
|
Printer.print(PrintChannel.ERRORS, str(e))
|
||||||
|
self.download()
|
||||||
|
|
||||||
|
def get_selection(self, args: Namespace) -> list[str]:
|
||||||
|
selection = Selection(self.__session)
|
||||||
|
try:
|
||||||
|
if args.search:
|
||||||
|
return selection.search(args.search, args.category)
|
||||||
|
elif args.playlist:
|
||||||
|
return selection.get("playlists", "items")
|
||||||
|
elif args.followed:
|
||||||
|
return selection.get("following?type=artist", "artists")
|
||||||
|
elif args.liked_tracks:
|
||||||
|
return selection.get("tracks", "items")
|
||||||
|
elif args.liked_episodes:
|
||||||
|
return selection.get("episodes", "items")
|
||||||
|
elif args.download:
|
||||||
|
ids = []
|
||||||
|
for x in args.download:
|
||||||
|
ids.extend(selection.from_file(x))
|
||||||
|
return ids
|
||||||
|
elif args.urls:
|
||||||
|
return args.urls
|
||||||
|
except (FileNotFoundError, ValueError):
|
||||||
|
pass
|
||||||
|
Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
|
||||||
|
exit()
|
||||||
|
|
||||||
|
def parse(self, links: list[str]) -> None:
|
||||||
|
"""
|
||||||
|
Parses list of selected tracks/playlists/shows/etc...
|
||||||
|
Args:
|
||||||
|
links: List of links
|
||||||
|
"""
|
||||||
|
for link in links:
|
||||||
|
link = link.rsplit("?", 1)[0]
|
||||||
|
try:
|
||||||
|
split = link.split(link[-23])
|
||||||
|
_id = split[-1]
|
||||||
|
id_type = split[-2]
|
||||||
|
except IndexError:
|
||||||
|
raise ParsingError(f'Could not parse "{link}"')
|
||||||
|
|
||||||
|
match id_type:
|
||||||
|
case "album":
|
||||||
|
self.__parse_album(b62_to_hex(_id))
|
||||||
|
case "artist":
|
||||||
|
self.__parse_artist(b62_to_hex(_id))
|
||||||
|
case "show":
|
||||||
|
self.__parse_show(b62_to_hex(_id))
|
||||||
|
case "track":
|
||||||
|
self.__parse_track(b62_to_hex(_id))
|
||||||
|
case "episode":
|
||||||
|
self.__parse_episode(b62_to_hex(_id))
|
||||||
|
case "playlist":
|
||||||
|
self.__parse_playlist(_id)
|
||||||
|
case _:
|
||||||
|
raise ParsingError(f'Unknown content type "{id_type}"')
|
||||||
|
|
||||||
def __parse_album(self, hex_id: str) -> None:
|
def __parse_album(self, hex_id: str) -> None:
|
||||||
album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id))
|
album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id))
|
||||||
|
@ -241,36 +336,6 @@ class App:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def parse(self, links: list[str]) -> None:
|
|
||||||
"""
|
|
||||||
Parses list of selected tracks/playlists/shows/etc...
|
|
||||||
Args:
|
|
||||||
links: List of links
|
|
||||||
"""
|
|
||||||
for link in links:
|
|
||||||
link = link.rsplit("?", 1)[0]
|
|
||||||
try:
|
|
||||||
split = link.split(link[-23])
|
|
||||||
_id = split[-1]
|
|
||||||
id_type = split[-2]
|
|
||||||
except IndexError:
|
|
||||||
raise IndexError(f'Parsing Error: Could not parse "{link}"')
|
|
||||||
|
|
||||||
if id_type == "album":
|
|
||||||
self.__parse_album(b62_to_hex(_id))
|
|
||||||
elif id_type == "artist":
|
|
||||||
self.__parse_artist(b62_to_hex(_id))
|
|
||||||
elif id_type == "playlist":
|
|
||||||
self.__parse_playlist(_id)
|
|
||||||
elif id_type == "show":
|
|
||||||
self.__parse_show(b62_to_hex(_id))
|
|
||||||
elif id_type == "track":
|
|
||||||
self.__parse_track(b62_to_hex(_id))
|
|
||||||
elif id_type == "episode":
|
|
||||||
self.__parse_episode(b62_to_hex(_id))
|
|
||||||
else:
|
|
||||||
raise TypeError(f'Parsing Error: Unknown type "{id_type}"')
|
|
||||||
|
|
||||||
def get_playable_list(self) -> list[PlayableData]:
|
def get_playable_list(self) -> list[PlayableData]:
|
||||||
"""Returns list of Playable items"""
|
"""Returns list of Playable items"""
|
||||||
return self.__playable_list
|
return self.__playable_list
|
||||||
|
|
|
@ -210,10 +210,6 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
||||||
"title": self.name,
|
"title": self.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
def can_download_direct(self) -> bool:
|
|
||||||
"""Returns true if episode can be downloaded from its original external source"""
|
|
||||||
return bool(self.external_url)
|
|
||||||
|
|
||||||
def write_audio_stream(
|
def write_audio_stream(
|
||||||
self, output: Path, chunk_size: int = 128 * 1024
|
self, output: Path, chunk_size: int = 128 * 1024
|
||||||
) -> LocalFile:
|
) -> LocalFile:
|
||||||
|
@ -225,7 +221,7 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
||||||
Returns:
|
Returns:
|
||||||
LocalFile object
|
LocalFile object
|
||||||
"""
|
"""
|
||||||
if not self.can_download_direct():
|
if bool(self.external_url):
|
||||||
return super().write_audio_stream(output, chunk_size)
|
return super().write_audio_stream(output, chunk_size)
|
||||||
file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
|
file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
|
||||||
with get(self.external_url, stream=True) as r, open(
|
with get(self.external_url, stream=True) as r, open(
|
||||||
|
|
Loading…
Add table
Reference in a new issue