realtime downloading for podcasts

This commit is contained in:
logykk 2021-12-18 22:11:43 +13:00
parent 2f9766859a
commit dc20e4367c
7 changed files with 76 additions and 35 deletions

View File

@ -1,3 +1,23 @@
#! /usr/bin/env python3
"""
ZSpotify
It's like youtube-dl, but for Spotify.
Copyright (C) 2021 Deathmonger/Footsiefat and ZSpotify Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import argparse import argparse
from app import client from app import client

View File

@ -19,7 +19,8 @@ def client(args) -> None:
""" Connects to spotify to perform query's and get songs to download """ """ Connects to spotify to perform query's and get songs to download """
ZSpotify(args) ZSpotify(args)
Printer.print(PrintChannel.SPLASH, splash()) if ZSpotify.CONFIG.get_print_splash():
Printer.print(PrintChannel.SPLASH, splash())
if ZSpotify.check_premium(): if ZSpotify.check_premium():
Printer.print(PrintChannel.SPLASH, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n') Printer.print(PrintChannel.SPLASH, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n')

View File

@ -31,12 +31,14 @@ MD_ALLGENRES = 'MD_ALLGENRES'
MD_GENREDELIMITER = 'MD_GENREDELIMITER' MD_GENREDELIMITER = 'MD_GENREDELIMITER'
PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO' PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO'
PRINT_WARNINGS = 'PRINT_WARNINGS' PRINT_WARNINGS = 'PRINT_WARNINGS'
RETRY_ATTEMPTS = 'RETRY_ATTEMPTS'
CONFIG_VALUES = { CONFIG_VALUES = {
ROOT_PATH: { 'default': '../ZSpotify Music/', 'type': str, 'arg': '--root-path' }, ROOT_PATH: { 'default': '../ZSpotify Music/', 'type': str, 'arg': '--root-path' },
ROOT_PODCAST_PATH: { 'default': '../ZSpotify Podcasts/', 'type': str, 'arg': '--root-podcast-path' }, ROOT_PODCAST_PATH: { 'default': '../ZSpotify Podcasts/', 'type': str, 'arg': '--root-podcast-path' },
SKIP_EXISTING_FILES: { 'default': 'True', 'type': bool, 'arg': '--skip-existing-files' }, SKIP_EXISTING_FILES: { 'default': 'True', 'type': bool, 'arg': '--skip-existing-files' },
SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' }, SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' },
RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' },
DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' }, DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' },
FORCE_PREMIUM: { 'default': 'False', 'type': bool, 'arg': '--force-premium' }, FORCE_PREMIUM: { 'default': 'False', 'type': bool, 'arg': '--force-premium' },
ANTI_BAN_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--anti-ban-wait-time' }, ANTI_BAN_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--anti-ban-wait-time' },
@ -49,7 +51,7 @@ CONFIG_VALUES = {
SONG_ARCHIVE: { 'default': '.song_archive', 'type': str, 'arg': '--song-archive' }, SONG_ARCHIVE: { 'default': '.song_archive', 'type': str, 'arg': '--song-archive' },
CREDENTIALS_LOCATION: { 'default': 'credentials.json', 'type': str, 'arg': '--credentials-location' }, CREDENTIALS_LOCATION: { 'default': 'credentials.json', 'type': str, 'arg': '--credentials-location' },
OUTPUT: { 'default': '', 'type': str, 'arg': '--output' }, OUTPUT: { 'default': '', 'type': str, 'arg': '--output' },
PRINT_SPLASH: { 'default': 'True', 'type': bool, 'arg': '--print-splash' }, PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' },
PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' }, PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' },
PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' },
PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' }, PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' },
@ -207,6 +209,10 @@ class Config:
def get_allGenres(cls) -> bool: def get_allGenres(cls) -> bool:
return cls.get(MD_ALLGENRES) return cls.get(MD_ALLGENRES)
@classmethod
def get_print_splash(cls) -> bool:
return cls.get(PRINT_SPLASH)
@classmethod @classmethod
def get_allGenresDelimiter(cls) -> bool: def get_allGenresDelimiter(cls) -> bool:
return cls.get(MD_GENREDELIMITER) return cls.get(MD_GENREDELIMITER)

View File

@ -1,22 +1,29 @@
import os import os
import time
from typing import Optional, Tuple from typing import Optional, Tuple
from librespot.metadata import EpisodeId from librespot.metadata import EpisodeId
from const import (ERROR, ID, ITEMS, NAME, SHOW) from const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS
from termoutput import PrintChannel, Printer from termoutput import PrintChannel, Printer
from utils import create_download_directory, fix_filename from utils import create_download_directory, fix_filename
from zspotify import ZSpotify from zspotify import ZSpotify
from loader import Loader
EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes' EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes'
SHOWS_URL = 'https://api.spotify.com/v1/shows' SHOWS_URL = 'https://api.spotify.com/v1/shows'
def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]: def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]:
(raw, info) = ZSpotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}') with Loader(PrintChannel.PROGRESS_INFO, "Fetching episode information..."):
(raw, info) = ZSpotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}')
if not info:
Printer.print(PrintChannel.ERRORS, "### INVALID EPISODE ID ###")
duration_ms = info[DURATION_MS]
if ERROR in info: if ERROR in info:
return None, None return None, None
return fix_filename(info[SHOW][NAME]), fix_filename(info[NAME]) return fix_filename(info[SHOW][NAME]), duration_ms, fix_filename(info[NAME])
def get_show_episodes(show_id_str) -> list: def get_show_episodes(show_id_str) -> list:
@ -24,14 +31,15 @@ def get_show_episodes(show_id_str) -> list:
offset = 0 offset = 0
limit = 50 limit = 50
while True: with Loader(PrintChannel.PROGRESS_INFO, "Fetching episodes..."):
resp = ZSpotify.invoke_url_with_params( while True:
f'{SHOWS_URL}/{show_id_str}/episodes', limit=limit, offset=offset) resp = ZSpotify.invoke_url_with_params(
offset += limit f'{SHOWS_URL}/{show_id_str}/episodes', limit=limit, offset=offset)
for episode in resp[ITEMS]: offset += limit
episodes.append(episode[ID]) for episode in resp[ITEMS]:
if len(resp[ITEMS]) < limit: episodes.append(episode[ID])
break if len(resp[ITEMS]) < limit:
break
return episodes return episodes
@ -64,12 +72,14 @@ def download_podcast_directly(url, filename):
def download_episode(episode_id) -> None: def download_episode(episode_id) -> None:
podcast_name, episode_name = get_episode_info(episode_id) podcast_name, duration_ms, episode_name = get_episode_info(episode_id)
extra_paths = podcast_name + '/' extra_paths = podcast_name + '/'
prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Preparing download...")
prepare_download_loader.start()
if podcast_name is None: if podcast_name is None:
Printer.print(PrintChannel.SKIPS, '### SKIPPING: (EPISODE NOT FOUND) ###') Printer.print(PrintChannel.SKIPS, '### SKIPPING: (EPISODE NOT FOUND) ###')
prepare_download_loader.stop()
else: else:
filename = podcast_name + ' - ' + episode_name filename = podcast_name + ' - ' + episode_name
@ -94,21 +104,31 @@ def download_episode(episode_id) -> None:
and ZSpotify.CONFIG.get_skip_existing_files() and ZSpotify.CONFIG.get_skip_existing_files()
): ):
Printer.print(PrintChannel.SKIPS, "\n### SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS) ###") Printer.print(PrintChannel.SKIPS, "\n### SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS) ###")
prepare_download_loader.stop()
return return
prepare_download_loader.stop()
time_start = time.time()
downloaded = 0
with open(filepath, 'wb') as file, Printer.progress( with open(filepath, 'wb') as file, Printer.progress(
desc=filename, desc=filename,
total=total_size, total=total_size,
unit='B', unit='B',
unit_scale=True, unit_scale=True,
unit_divisor=1024 unit_divisor=1024
) as bar: ) as p_bar:
prepare_download_loader.stop()
for _ in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1): for _ in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1):
bar.update(file.write( data = stream.input_stream.stream().read(ZSpotify.CONFIG.get_chunk_size())
stream.input_stream.stream().read(ZSpotify.CONFIG.get_chunk_size()))) p_bar.update(file.write(data))
downloaded += len(data)
if ZSpotify.CONFIG.get_download_real_time():
delta_real = time.time() - time_start
delta_want = (downloaded / total_size) * (duration_ms/1000)
if delta_want > delta_real:
time.sleep(delta_want - delta_real)
else: else:
filepath = os.path.join(download_directory, f"{filename}.mp3") filepath = os.path.join(download_directory, f"{filename}.mp3")
download_podcast_directly(direct_download_url, filepath) download_podcast_directly(direct_download_url, filepath)
# convert_audio_format(ROOT_PODCAST_PATH + prepare_download_loader.stop()
# extra_paths + filename + '.ogg')

View File

@ -15,9 +15,9 @@ from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_down
get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds
from zspotify import ZSpotify from zspotify import ZSpotify
import traceback import traceback
from loader import Loader from loader import Loader
def get_saved_tracks() -> list: def get_saved_tracks() -> list:
""" Returns user's saved tracks """ """ Returns user's saved tracks """
songs = [] songs = []
@ -189,7 +189,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
unit_divisor=1024, unit_divisor=1024,
disable=disable_progressbar disable=disable_progressbar
) as p_bar: ) as p_bar:
for chunk in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1): for _ in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1):
data = stream.input_stream.stream().read(ZSpotify.CONFIG.get_chunk_size()) data = stream.input_stream.stream().read(ZSpotify.CONFIG.get_chunk_size())
p_bar.update(file.write(data)) p_bar.update(file.write(data))
downloaded += len(data) downloaded += len(data)

View File

@ -280,3 +280,5 @@ def fmt_seconds(secs: float) -> str:
return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
else: else:
return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)

View File

@ -1,11 +1,3 @@
#! /usr/bin/env python3
"""
ZSpotify
It's like youtube-dl, but for Spotify.
(Made by Deathmonger/Footsiefat - @doomslayer117:matrix.org)
"""
import os import os
import os.path import os.path
from getpass import getpass from getpass import getpass
@ -81,17 +73,17 @@ class ZSpotify:
return requests.get(url, headers=headers, params=params).json() return requests.get(url, headers=headers, params=params).json()
@classmethod @classmethod
def invoke_url(cls, url, tryCount = 0): def invoke_url(cls, url, tryCount=0):
# we need to import that here, otherwise we will get circular imports! # we need to import that here, otherwise we will get circular imports!
from termoutput import Printer, PrintChannel from termoutput import Printer, PrintChannel
headers = cls.get_auth_header() headers = cls.get_auth_header()
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
responsetext = response.text responsetext = response.text
responsejson = response.json() responsejson = response.json()
if 'error' in responsejson: if 'error' in responsejson:
if tryCount < 5: if tryCount < (cls.CONFIG.retry_attemps - 1):
Printer.print(PrintChannel.WARNINGS, f"Spotify API Error (try {tryCount}) ({responsejson['error']['status']}): {responsejson['error']['message']}") Printer.print(PrintChannel.WARNINGS, f"Spotify API Error (try {tryCount + 1}) ({responsejson['error']['status']}): {responsejson['error']['message']}")
time.sleep(5) time.sleep(5)
return cls.invoke_url(url, tryCount + 1) return cls.invoke_url(url, tryCount + 1)