Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge various prs #169

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@ Be aware you have to set boolean values in the commandline like this: `--downloa
| SONG_ARCHIVE | --song-archive | | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED
| ROOT_PATH | --root-path | | Directory where Zotify saves music
| ROOT_PODCAST_PATH | --root-podcast-path | | Directory where Zotify saves podcasts
| MAX_FILENAME_LENGTH | --max-filename-length | 255 | Maximum filename length
| SPLIT_ALBUM_DISCS | --split-album-discs | False | Saves each disk in its own folder
| DOWNLOAD_LYRICS | --download-lyrics | True | Downloads synced lyrics in .lrc format, uses unsynced as fallback.
| MD_ARTISTDELIMITER | --md-artistdelimiter | ', ' | Delimiter character used to split artists in metadata
| MD_ALLGENRES | --md-allgenres | False | Save all relevant genres in metadata
| MD_GENREDELIMITER | --md-genredelimiter | , | Delimiter character used to split genres in metadata
| MD_GENREDELIMITER | --md-genredelimiter | ', ' | Delimiter character used to split genres in metadata
| DOWNLOAD_FORMAT | --download-format | ogg | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis)
| DOWNLOAD_QUALITY | --download-quality | auto | Audio quality of downloaded songs (normal, high, very_high*)
| TRANSCODE_BITRATE | --transcode-bitrate | auto | Overwrite the bitrate for ffmpeg encoding
Expand Down
14 changes: 13 additions & 1 deletion zotify/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
PRINT_DOWNLOADS = 'PRINT_DOWNLOADS'
PRINT_API_ERRORS = 'PRINT_API_ERRORS'
TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR'
MD_ARTISTDELIMITER = 'MD_ARTISTDELIMITER'
MD_SAVE_GENRES = 'MD_SAVE_GENRES'
MD_ALLGENRES = 'MD_ALLGENRES'
MD_GENREDELIMITER = 'MD_GENREDELIMITER'
Expand All @@ -36,6 +37,7 @@
RETRY_ATTEMPTS = 'RETRY_ATTEMPTS'
CONFIG_VERSION = 'CONFIG_VERSION'
DOWNLOAD_LYRICS = 'DOWNLOAD_LYRICS'
MAX_FILENAME_LENGTH = 'MAX_FILENAME_LENGTH'

CONFIG_VALUES = {
SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' },
Expand All @@ -44,11 +46,13 @@
SONG_ARCHIVE: { 'default': '', 'type': str, 'arg': '--song-archive' },
ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' },
ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' },
MAX_FILENAME_LENGTH: { 'default': '255', 'type': int, 'arg': '--max-filename-length' },
SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' },
MD_ARTISTDELIMITER: { 'default': ', ', 'type': str, 'arg': '--md-artistdelimiter' },
MD_SAVE_GENRES: { 'default': 'False', 'type': bool, 'arg': '--md-save-genres' },
MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' },
MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' },
MD_GENREDELIMITER: { 'default': ', ', 'type': str, 'arg': '--md-genredelimiter' },
DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' },
DOWNLOAD_QUALITY: { 'default': 'auto', 'type': str, 'arg': '--download-quality' },
TRANSCODE_BITRATE: { 'default': 'auto', 'type': str, 'arg': '--transcode-bitrate' },
Expand Down Expand Up @@ -261,6 +265,10 @@ def get_temp_download_dir(cls) -> str:
return ''
return PurePath(cls.get_root_path()).joinpath(cls.get(TEMP_DOWNLOAD_DIR))

@classmethod
def get_artist_delimiter(cls) -> bool:
return cls.get(MD_ARTISTDELIMITER)

@classmethod
def get_save_genres(cls) -> bool:
return cls.get(MD_SAVE_GENRES)
Expand Down Expand Up @@ -308,3 +316,7 @@ def get_output(cls, mode: str) -> str:
@classmethod
def get_retry_attempts(cls) -> int:
return cls.get(RETRY_ATTEMPTS)

@classmethod
def get_max_filename_length(cls) -> int:
return cls.get(MAX_FILENAME_LENGTH)
6 changes: 5 additions & 1 deletion zotify/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

TRACKNUMBER = 'tracknumber'

TOTALTRACKS = 'totaltracks'

DISCNUMBER = 'discnumber'

YEAR = 'year'
Expand Down Expand Up @@ -58,6 +60,8 @@

TRACK_NUMBER = 'track_number'

TOTAL_TRACKS = 'total_tracks'

DISC_NUMBER = 'disc_number'

SHOW = 'show'
Expand Down Expand Up @@ -110,6 +114,6 @@
'm4a': 'm4a',
'mp3': 'mp3',
'ogg': 'ogg',
'opus': 'ogg',
'opus': 'opus',
'vorbis': 'ogg',
}
65 changes: 49 additions & 16 deletions zotify/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from librespot.metadata import TrackId
import ffmpy

from zotify.const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
from zotify.const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, TOTAL_TRACKS, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
RELEASE_DATE, ID, TRACKS_URL, FOLLOWED_ARTISTS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, \
HREF, ARTISTS, WIDTH
from zotify.termoutput import Printer, PrintChannel
Expand Down Expand Up @@ -46,7 +46,7 @@ def get_followed_artists() -> list:
return artists


def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, Any, Any, Any, Any, int]:
def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, Any, Any, Any, Any, Any, int]:
""" Retrieves metadata for downloaded songs """
with Loader(PrintChannel.PROGRESS_INFO, "Fetching track information..."):
(raw, info) = Zotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token')
Expand All @@ -64,6 +64,7 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An
release_year = info[TRACKS][0][ALBUM][RELEASE_DATE].split('-')[0]
disc_number = info[TRACKS][0][DISC_NUMBER]
track_number = info[TRACKS][0][TRACK_NUMBER]
total_tracks = info[TRACKS][0][ALBUM][TOTAL_TRACKS]
scraped_song_id = info[TRACKS][0][ID]
is_playable = info[TRACKS][0][IS_PLAYABLE]
duration_ms = info[TRACKS][0][DURATION_MS]
Expand All @@ -74,7 +75,7 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An
image = i
image_url = image[URL]

return artists, info[TRACKS][0][ARTISTS], album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms
return artists, info[TRACKS][0][ARTISTS], album_name, name, image_url, release_year, disc_number, track_number, total_tracks, scraped_song_id, is_playable, duration_ms
except Exception as e:
raise ValueError(f'Failed to parse TRACKS_URL response: {str(e)}\n{raw}')

Expand Down Expand Up @@ -155,7 +156,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
output_template = Zotify.CONFIG.get_output(mode)

(artists, raw_artists, album_name, name, image_url, release_year, disc_number,
track_number, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id)
track_number, total_tracks, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id)

song_name = fix_filename(artists[0]) + ' - ' + fix_filename(name)

Expand All @@ -169,7 +170,8 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
output_template = output_template.replace("{song_name}", fix_filename(name))
output_template = output_template.replace("{release_year}", fix_filename(release_year))
output_template = output_template.replace("{disc_number}", fix_filename(disc_number))
output_template = output_template.replace("{track_number}", fix_filename(track_number))
output_template = output_template.replace("{track_number}", '{:02d}'.format(int(fix_filename(track_number))))
output_template = output_template.replace("{total_tracks}", fix_filename(total_tracks))
output_template = output_template.replace("{id}", fix_filename(scraped_song_id))
output_template = output_template.replace("{track_id}", fix_filename(track_id))
output_template = output_template.replace("{ext}", ext)
Expand All @@ -187,12 +189,17 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba

# a song with the same name is installed
if not check_id and check_name:
c = len([file for file in Path(filedir).iterdir() if re.search(f'^{filename}_', str(file))]) + 1

if Zotify.CONFIG.get_skip_existing():
Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n")
return
GitGitro marked this conversation as resolved.
Show resolved Hide resolved
else:
c = len([file for file in Path(filedir).iterdir() if re.search(f'^{filename}_', str(file))]) + 1

fname = PurePath(PurePath(filename).name).parent
ext = PurePath(PurePath(filename).name).suffix
fname = PurePath(filename).stem
ext = PurePath(PurePath(filename).name).suffix

filename = PurePath(filedir).joinpath(f'{fname}_{c}{ext}')
filename = PurePath(filedir).joinpath(f'{fname}_{c}{ext}')

except Exception as e:
Printer.print(PrintChannel.ERRORS, '### SKIPPING SONG - FAILED TO QUERY METADATA ###')
Expand Down Expand Up @@ -261,7 +268,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
Printer.print(PrintChannel.SKIPS, f"### Skipping lyrics for {song_name}: lyrics not available ###")
convert_audio_format(filename_temp)
try:
set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number)
set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number, total_tracks)
set_music_thumbnail(filename_temp, image_url)
except Exception:
Printer.print(PrintChannel.ERRORS, "Unable to write metadata, ensure ffmpeg is installed and added to your PATH.")
Expand All @@ -282,6 +289,22 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba

if Zotify.CONFIG.get_bulk_wait_time():
time.sleep(Zotify.CONFIG.get_bulk_wait_time())

# Verifica se o nome da playlist foi fornecido
if extra_keys and 'playlist' in extra_keys:
playlist_file = PurePath(Zotify.CONFIG.get_root_path()).joinpath(extra_keys['playlist'] + '.m3u8')

# Se for o primeiro item da playlist, realiza o truncamento
if extra_keys['playlist_num'].lstrip('0') == '1':
with open(playlist_file, 'w', encoding='utf-8') as f:
f.write("#EXTM3U\n")

# Adiciona o nome do arquivo da música baixada à playlist M3U8
with open(playlist_file, "a", encoding='utf-8') as f:
#f.write("#EXTINF:-1," + urlencode(song_name) + "\n")
#f.write(f"{filename}\n")
f.write(f"{filename.relative_to(PurePath(Zotify.CONFIG.get_root_path()))}\n")

except Exception as e:
Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###')
Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id))
Expand All @@ -304,21 +327,31 @@ def convert_audio_format(filename) -> None:
download_format = Zotify.CONFIG.get_download_format().lower()
file_codec = CODEC_MAP.get(download_format, 'copy')
if file_codec != 'copy':
bitrate = Zotify.CONFIG.get_transcode_bitrate()
bitrates = {
'auto': '320k' if Zotify.check_premium() else '160k',
'normal': '96k',
'high': '160k',
'very_high': '320k'
}
bitrate = bitrates[Zotify.CONFIG.get_download_quality()]

# This var does the job of the old 'bitrate' var.
predef_quality_level_bitrate = bitrates[Zotify.CONFIG.get_download_quality()]
# New var to hold user-specified bitrate separately
custom_bitrate = Zotify.CONFIG.get_transcode_bitrate()

# Check if the user has specified a custom bitrate, ie they've put something in for transcode bitrate, take that and use it as the bitrate. It's more important than the predefined quality levels
if custom_bitrate != None:
set_bitrate = custom_bitrate
else:
set_bitrate = predef_quality_level_bitrate

else:
bitrate = None
set_bitrate = None

output_params = ['-c:a', file_codec]
if bitrate:
output_params += ['-b:a', bitrate]

Printer.print(PrintChannel.DOWNLOADS, f'### Downloading in {file_codec.upper()} ###')
if set_bitrate:
output_params += ['-b:a', set_bitrate]
try:
ff_m = ffmpy.FFmpeg(
global_options=['-y', '-hide_banner', '-loglevel error'],
Expand Down
33 changes: 21 additions & 12 deletions zotify/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
import music_tag
import requests

from zotify.const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \
from zotify.const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, TOTALTRACKS, ARTWORK, \
WINDOWS_SYSTEM, LINUX_SYSTEM, ALBUMARTIST
from zotify.zotify import Zotify


class MusicFormat(str, Enum):
MP3 = 'mp3',
OGG = 'ogg',
OPUS = 'opus'


def create_download_directory(download_path: str) -> None:
Expand Down Expand Up @@ -126,7 +127,7 @@ def clear() -> None:
os.system('clear')


def set_audio_tags(filename, artists, genres, name, album_name, release_year, disc_number, track_number) -> None:
def set_audio_tags(filename, artists, genres, name, album_name, release_year, disc_number, track_number, total_tracks) -> None:
""" sets music_tag metadata """
tags = music_tag.load_file(filename)
tags[ALBUMARTIST] = artists[0]
Expand All @@ -137,12 +138,13 @@ def set_audio_tags(filename, artists, genres, name, album_name, release_year, di
tags[YEAR] = release_year
tags[DISCNUMBER] = disc_number
tags[TRACKNUMBER] = track_number
tags[TOTALTRACKS] = total_tracks
tags.save()


def conv_artist_format(artists) -> str:
""" Returns converted artist format """
return ', '.join(artists)
return Zotify.CONFIG.get_artist_delimiter().join(artists)


def set_music_thumbnail(filename, image_url) -> None:
Expand All @@ -158,42 +160,42 @@ def regex_input_for_urls(search_input) -> Tuple[str, str, str, str, str, str]:
track_uri_search = re.search(
r'^spotify:track:(?P<TrackID>[0-9a-zA-Z]{22})$', search_input)
track_url_search = re.search(
r'^(https?://)?open\.spotify\.com/track/(?P<TrackID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/track/(?P<TrackID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
search_input,
)

album_uri_search = re.search(
r'^spotify:album:(?P<AlbumID>[0-9a-zA-Z]{22})$', search_input)
album_url_search = re.search(
r'^(https?://)?open\.spotify\.com/album/(?P<AlbumID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/album/(?P<AlbumID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
search_input,
)

playlist_uri_search = re.search(
r'^spotify:playlist:(?P<PlaylistID>[0-9a-zA-Z]{22})$', search_input)
playlist_url_search = re.search(
r'^(https?://)?open\.spotify\.com/playlist/(?P<PlaylistID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/playlist/(?P<PlaylistID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
search_input,
)

episode_uri_search = re.search(
r'^spotify:episode:(?P<EpisodeID>[0-9a-zA-Z]{22})$', search_input)
episode_url_search = re.search(
r'^(https?://)?open\.spotify\.com/episode/(?P<EpisodeID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/episode/(?P<EpisodeID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
search_input,
)

show_uri_search = re.search(
r'^spotify:show:(?P<ShowID>[0-9a-zA-Z]{22})$', search_input)
show_url_search = re.search(
r'^(https?://)?open\.spotify\.com/show/(?P<ShowID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/show/(?P<ShowID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
search_input,
)

artist_uri_search = re.search(
r'^spotify:artist:(?P<ArtistID>[0-9a-zA-Z]{22})$', search_input)
artist_url_search = re.search(
r'^(https?://)?open\.spotify\.com/artist/(?P<ArtistID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/artist/(?P<ArtistID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
search_input,
)

Expand Down Expand Up @@ -259,11 +261,18 @@ def fix_filename(name):
True
"""
if platform.system() == WINDOWS_SYSTEM:
return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE)
name = re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE)
elif platform.system() == LINUX_SYSTEM:
return re.sub(r'[/\0]', "_", str(name))
name = re.sub(r'[/\0]', "_", str(name))
else: # MacOS
return re.sub(r'[/:\0]', "_", str(name))
name = re.sub(r'[/:\0]', "_", str(name))

max_filename_length = Zotify.CONFIG.get_max_filename_length()

if len(name) > max_filename_length:
name = name[:max_filename_length]

return name


def fmt_seconds(secs: float) -> str:
Expand Down